Merge remote-tracking branch 'origin/main'
# Conflicts: # fusion_plating/fusion_plating/__manifest__.py # fusion_plating/fusion_plating_jobs/__manifest__.py # fusion_plating/fusion_plating_jobs/models/fp_job_step.py # fusion_plating/fusion_plating_shopfloor/__manifest__.py # fusion_plating/fusion_plating_shopfloor/static/src/js/job_workspace.js
This commit is contained in:
58
CLAUDE.md
58
CLAUDE.md
@@ -33,6 +33,10 @@
|
|||||||
|
|
||||||
15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`.
|
15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`.
|
||||||
|
|
||||||
|
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
|
## 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:
|
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
|
```css
|
||||||
@@ -94,7 +98,8 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
|||||||
|
|
||||||
## Module-Specific Notes
|
## 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_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_authorizer_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
|
## Workflow
|
||||||
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||||
@@ -136,6 +141,19 @@ PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U po
|
|||||||
- `fusionapps.code_snippets` — reference code
|
- `fusionapps.code_snippets` — reference code
|
||||||
- `fusionapps.quick_commands` — deployment and admin commands
|
- `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)
|
## Fusion Helpdesk — Customer Follow-up + Embedded Inbox (deployment + handoff)
|
||||||
|
|
||||||
Two modules: **`fusion_helpdesk`** (client — runs on each client deployment, e.g. entech)
|
Two modules: **`fusion_helpdesk`** (client — runs on each client deployment, e.g. entech)
|
||||||
@@ -230,3 +248,41 @@ catches undefined names instantly.
|
|||||||
open the systray helpdesk dialog. The Mine/All toggle appears for the owner; "All" shows
|
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.
|
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).
|
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).
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
'website',
|
'website',
|
||||||
'mail',
|
'mail',
|
||||||
'fusion_claims',
|
'fusion_claims',
|
||||||
'fusion_authorizer_portal',
|
'fusion_portal',
|
||||||
],
|
],
|
||||||
'data': [
|
'data': [
|
||||||
'security/security.xml',
|
'security/security.xml',
|
||||||
|
|||||||
194
docs/plans/fusion_maintenance_brainstorm.md
Normal file
194
docs/plans/fusion_maintenance_brainstorm.md
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
# fusion_maintenance — Brainstorm & Handoff Brief
|
||||||
|
|
||||||
|
> Status: **research/brainstorm only — no code, no final decisions.** Written from a
|
||||||
|
> Claude Code *web* session that could **not** reach the private network (no Tailscale,
|
||||||
|
> no docker daemon, Supabase KB unreachable). Resume from a **Tailscale-connected env**
|
||||||
|
> (dev box or a host that can reach Westin production) and do the live inspection in
|
||||||
|
> Step 0 **before** committing to the design.
|
||||||
|
|
||||||
|
## Goal (user's words, paraphrased)
|
||||||
|
Automated maintenance follow-ups for mobility/accessibility equipment we've sold, to turn
|
||||||
|
service into **recurring revenue**. Reminder emails → client books maintenance → booking
|
||||||
|
happens in **real time** and **lands in our calendar**. Leverage Odoo Enterprise's
|
||||||
|
appointment system. Decide whether this lives in `fusion_repairs` or a new module — the
|
||||||
|
result must be **seamless and production-ready**.
|
||||||
|
|
||||||
|
## Decisions locked with the user (this session)
|
||||||
|
- **Same DB**: `fusion_claims` + `fusion_repairs` run on one database → new module may depend on both.
|
||||||
|
- **Enterprise `appointment` is available** → build real-time booking ON it (`appointment.type` /
|
||||||
|
`appointment.slot` / `calendar.event`), do **not** hand-roll a calendar.
|
||||||
|
- **Public self-serve booking** → reminder email carries a token link to a no-login slot picker
|
||||||
|
(extend the existing `/repairs/maintenance/book/<token>` pattern). Elderly clients shouldn't log in.
|
||||||
|
- **Target box for grounding = Westin production** (where `fusion_claims` runs day-to-day).
|
||||||
|
|
||||||
|
## Key findings from repo exploration
|
||||||
|
|
||||||
|
### `fusion_repairs` (v19.0.2.2.6) ALREADY has a maintenance engine — reuse it, don't fork
|
||||||
|
- `fusion.repair.maintenance.contract`: interval, due/last-service dates, state machine.
|
||||||
|
Auto-spawned on SO confirm when `product.template.x_fc_maintenance_interval_months > 0`.
|
||||||
|
- Daily reminder cron `cron_maintenance_due_reminders` → 30/7/1-day bands → branded email
|
||||||
|
`email_template_maintenance_due_reminder` with tokenized link `/repairs/maintenance/book/<token>`.
|
||||||
|
- Booking controller: `controllers/portal_maintenance_booking.py` — **single date-confirm form,
|
||||||
|
NO slot availability, NO conflict check, NO calendar event.** ← this is the real gap.
|
||||||
|
- Contract **roll-forward** on technician-task completion (`next_due_date += interval`).
|
||||||
|
- `fusion.repair.service.plan.subscription`: pre-paid visit plans (recurring-revenue primitive).
|
||||||
|
- Deps: `repair, maintenance, sale_management, stock, purchase, website, portal, fusion_tasks,
|
||||||
|
fusion_poynt, fusion_authorizer_portal`. ~8.3k LOC, 25+ models.
|
||||||
|
|
||||||
|
### `fusion_claims` (v19.0.9.2.0) is the ideal trigger source
|
||||||
|
- Claim container = `sale.order` (`x_fc_sale_type`: adp, odsp, wsib, insurance, march_of_dimes, …).
|
||||||
|
- **Equipment unit** = `sale.order.line.x_fc_serial_number` + `product_id`.
|
||||||
|
- **Equipment category** = `fusion.adp.device.code.device_type` (wheelchair, walker, hospital bed,
|
||||||
|
stair lift, porch lift, custom ramp, …) — matches the user's "sale groups".
|
||||||
|
- **Schedule anchors**: `x_fc_adp_delivery_date`, `x_fc_service_start_date`; gate on `x_fc_adp_approved`.
|
||||||
|
- Customer = `sale.order.partner_id`; prescriber = `x_fc_authorizer_id`.
|
||||||
|
- Already depends on `calendar, fusion_tasks, ai, fusion_ringcentral`.
|
||||||
|
|
||||||
|
## Proposed architecture (PENDING live verification)
|
||||||
|
**New module `fusion_maintenance`** depending on `fusion_repairs`, `fusion_claims`, `appointment`.
|
||||||
|
Reuses the existing contract/reminder/roll-forward engine; adds the 3 genuinely-missing pieces:
|
||||||
|
|
||||||
|
1. **`fusion.maintenance.policy`** (ops-configurable, no code per category):
|
||||||
|
`device_type` → `interval_months`, reminder bands, `service_product_id` (priced visit),
|
||||||
|
`appointment_type_id`, required technician skill. Turns "stair lift = 6 mo, $X" into data.
|
||||||
|
2. **Claims bridge** (daily cron): scan `fusion_claims` `sale.order.line` for delivered+approved
|
||||||
|
devices whose `device_type` matches an active policy → ensure a maintenance contract exists,
|
||||||
|
anchored at `delivery_date + interval`. Idempotent (key on serial / sale-line). Extend the
|
||||||
|
reused contract with `x_fc_source_claim_line_id`, `x_fc_device_type`, `x_fc_policy_id` so the
|
||||||
|
repairs path and claims path both feed **one** contract model.
|
||||||
|
3. **Real-time booking on `appointment`**: token link → slot picker backed by `appointment.type`
|
||||||
|
(partner pre-resolved from token, no login). Slot pick → real `calendar.event` → hook spawns
|
||||||
|
`repair.order` + technician task, assigns by skill/zone, advances reminder band, rolls contract
|
||||||
|
forward.
|
||||||
|
|
||||||
|
**Recurring revenue**: each policy carries `service_product_id` → booked visit drafts a priced
|
||||||
|
SO/invoice; optional pre-paid annual plan via existing `service.plan.subscription`; optional
|
||||||
|
door payment via existing `fusion_poynt`.
|
||||||
|
|
||||||
|
## STEP 0 — run on Westin production FIRST (grounding before any decision)
|
||||||
|
> Replace `APP`/`DB` with the real Westin container + database. CLAUDE.md rule #1: never code
|
||||||
|
> from memory — read the real Enterprise `appointment` source before building the booking layer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# RESOLVED 2026-06-02 — Westin Odoo prod migrated OFF Digital Ocean onto the on-prem Proxmox
|
||||||
|
# cluster. Old DO IPs (152.42.146.204 / 178.128.229.92) are DEAD (:22 timeout). Live box:
|
||||||
|
# host `odoo-westin` = 192.168.1.40 via the `supabase-prod` Tailscale jump (Windows OpenSSH
|
||||||
|
# ProxyCommand → run `ssh odoo-westin ...` from PowerShell). App container `odoo-dev-app`
|
||||||
|
# (odoo:19, Enterprise); DB container `odoo-dev-db`; DB `westin-v19`; user `odoo` (local-socket
|
||||||
|
# trust inside odoo-dev-db). Enterprise addons → /mnt/enterprise-addons, custom → /mnt/extra-addons.
|
||||||
|
# SQL: ssh odoo-westin 'docker exec odoo-dev-db psql -U odoo -d westin-v19 -c "..."'
|
||||||
|
# FS read: ssh odoo-westin 'docker exec odoo-dev-app sed -n 1,160p /mnt/enterprise-addons/...'
|
||||||
|
APP=odoo-dev-app ; DB=westin-v19 ; DBC=odoo-dev-db
|
||||||
|
|
||||||
|
# 1) Install matrix — confirm same-DB + Enterprise appointment present + versions
|
||||||
|
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||||||
|
"SELECT name,state,latest_version FROM ir_module_module \
|
||||||
|
WHERE name IN ('fusion_claims','fusion_repairs','fusion_maintenance','calendar','maintenance','repair') \
|
||||||
|
OR name LIKE 'appointment%' ORDER BY name;"
|
||||||
|
|
||||||
|
# 2) Real device_type distribution (drives per-category policies)
|
||||||
|
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||||||
|
"SELECT device_type, count(*) FROM fusion_adp_device_code GROUP BY device_type ORDER BY 2 DESC;"
|
||||||
|
|
||||||
|
# 3) Locate the Enterprise appointment source (read, don't guess the API)
|
||||||
|
docker exec "$APP" bash -lc 'ls -d /mnt/enterprise-addons/appointment 2>/dev/null || \
|
||||||
|
find / -maxdepth 6 -type d -name appointment 2>/dev/null | grep -i addons | head'
|
||||||
|
|
||||||
|
# 4) Appointment model surface to build booking on (adjust path from #3)
|
||||||
|
docker exec "$APP" cat <appointment_path>/models/appointment_type.py | head -160
|
||||||
|
docker exec "$APP" ls <appointment_path>/controllers/ # find the public booking controller
|
||||||
|
|
||||||
|
# 5) How fusion_repairs maintenance contracts already look in live data
|
||||||
|
docker exec "$APP" psql -U odoo -d "$DB" -c \
|
||||||
|
"SELECT state, count(*) FROM fusion_repair_maintenance_contract GROUP BY state;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## STEP 0 — RESULTS (ran 2026-06-02 against Westin prod `westin-v19`)
|
||||||
|
> Grounding facts only — **no design decisions made**. These correct several assumptions above.
|
||||||
|
|
||||||
|
**Connection (resolved):** host `odoo-westin` (192.168.1.40) via the `supabase-prod` Tailscale jump.
|
||||||
|
App container `odoo-dev-app` (odoo:19, Enterprise), DB container `odoo-dev-db`, DB `westin-v19`,
|
||||||
|
user `odoo`. Old Digital Ocean boxes are DEAD — Westin migrated on-prem.
|
||||||
|
|
||||||
|
**1) Install matrix** — `appointment` **19.0.1.3 installed** (+ `appointment_account_payment`,
|
||||||
|
`_crm`, `_hr`, `_microsoft_calendar`, `_sms`). All deps present: `calendar`, `maintenance`, `repair`,
|
||||||
|
`sale_management`, `portal`, `website`, `resource`, `phone_validation`, `web_gantt`. `fusion_claims`
|
||||||
|
**19.0.9.2.0 installed**. `fusion_repairs` and `fusion_maintenance` are **absent entirely** (no
|
||||||
|
records). → a module depending on `appointment` installs cleanly; "reuse the fusion_repairs engine"
|
||||||
|
means *deploy fusion_repairs to Westin first* (heavy) **or** own a lean contract model here. Note
|
||||||
|
Odoo's native `maintenance` (CMMS) is installed — an under-considered third reuse option.
|
||||||
|
|
||||||
|
**2) device_type** — 119 distinct values, but `fusion.adp.device.code` is the ADP billing-code
|
||||||
|
**CATALOG** (`_order='device_type, device_code'`), so counts are catalog codes per type, **NOT units
|
||||||
|
installed**. Top entries are seating COMPONENTS (Seat Cushion 564, Back Support 375, Headrest 193).
|
||||||
|
The maintainable **equipment classes** ≈ wheelchairs (manual + power tilt), power bases, power
|
||||||
|
scooters, wheeled walkers / walking frames, paediatric standing frames, specialty strollers (~6-8
|
||||||
|
clean categories). → `device_type` can't be a 1:1 policy key (119 values, mostly parts); needs a
|
||||||
|
grouping/whitelist. **Real install base sized on `sale.order.line`** (`x_fc_adp_device_type` [stored compute from
|
||||||
|
product's `x_fc_adp_device_code_id.device_type`], `x_fc_serial_number`, `x_fc_adp_approved`; delivery
|
||||||
|
dates `x_fc_adp_delivery_date` / `x_fc_service_start_date`) — **see the Install-base sizing block below.**
|
||||||
|
|
||||||
|
**3) + 4) Enterprise appointment source** — `/mnt/enterprise-addons/appointment`. The no-login token
|
||||||
|
slot-picker is **mostly NATIVE — don't hand-roll it**: public booking (`auth="public"`), invite
|
||||||
|
tokens (`appointment.invite`, `/appointment/<id>?…invite_token`), live availability
|
||||||
|
(`/appointment/<id>/update_available_slots`, jsonrpc/public), slot submit → real `calendar.event`
|
||||||
|
(`/appointment/<id>/submit`), auto/manual staff+resource assignment, capacity, booked/cancelled mail
|
||||||
|
templates. Model `appointment.type`; controller `controllers/appointment.py`. → the module mainly
|
||||||
|
needs to: seed an `appointment.type` per category, drop a partner-bound invite link into the reminder
|
||||||
|
email, and hook `calendar.event` create → spawn the service task + advance the contract.
|
||||||
|
`appointment_account_payment` is installed → native pay-to-book is on the table for the revenue mechanic.
|
||||||
|
|
||||||
|
**5) Maintenance-contract state** — `relation "fusion_repair_maintenance_contract" does not exist`
|
||||||
|
→ confirms the fusion_repairs maintenance engine is **not** on Westin.
|
||||||
|
|
||||||
|
**Headline correction:** Westin's ADP data has **zero** stair lifts / porch lifts / ramps / hospital
|
||||||
|
beds — those belong to the fusion_repairs / EN-Tech (mobility) domain. Westin's recurring-revenue
|
||||||
|
play is **wheelchairs / power bases / scooters / walkers / seating**. Open questions updated below.
|
||||||
|
|
||||||
|
**Install-base sizing (ran 2026-06-02 — the REAL units, complementing #2's catalog counts).** Big tell:
|
||||||
|
serial numbers are captured **~only on actual equipment** (every part/option/mod device_type shows 0
|
||||||
|
serials), so `x_fc_serial_number` is already a de-facto "trackable unit" marker — convenient, because the
|
||||||
|
bridge's idempotency key is the serial.
|
||||||
|
|
||||||
|
- **Addressable base ≈ 138 serial-tracked units across ~136 customers** (all funders). By equipment
|
||||||
|
family (serial-tracked / of which delivered): **Walkers & walking frames 68 (55)**, **Wheelchairs 45
|
||||||
|
(40)**, **Power bases 7 (6)**, **Scooters 4 (3)**, plus **14 units with no ADP device_type** (likely
|
||||||
|
private-pay) and 1 misc.
|
||||||
|
- **Funder split** (serial-tracked): adp 109, direct_private 13, adp_odsp 10, march_of_dimes 7;
|
||||||
|
wsib / insurance / standalone-odsp / rental / regular = **0 serials**. → an ADP-only gate
|
||||||
|
(`x_fc_adp_approved`) captures ~110 and **misses ~28** real units. The bridge should likely key on
|
||||||
|
**serial (funder-agnostic)**, not approval.
|
||||||
|
- **Two data gaps the design must absorb:** (a) the 14 serial units with no ADP device_type can't be
|
||||||
|
classified by a device_type→policy map → need a product-level or manual category override; (b) non-ADP
|
||||||
|
units have no `x_fc_adp_delivery_date` → the contract anchor (`delivery_date + interval`) needs a
|
||||||
|
fallback (invoice/order date).
|
||||||
|
- Deliveries span **2022-10 → 2026-05** (active program) — history to anchor intervals + a live pipeline.
|
||||||
|
- Top serial-tracked device_types: Adult Wheeled Walker Type 3 (47), Adult Manual Dynamic Tilt Type 5
|
||||||
|
Wheelchair (23), Adult Lightweight Performance Type 3 (11), Adult Lightweight Standard Type 1 (10),
|
||||||
|
Adult Wheeled Walker Type 2 (9), Adult Power Base Type 3 (5), Power Scooter (3). (1 line ≈ 1 unit;
|
||||||
|
equipment device_types are 1 base line each.)
|
||||||
|
|
||||||
|
## Open questions to resolve with the user (in the connected session)
|
||||||
|
- **MVP cut**: which categories first? Sizing surfaces a real tension: **by volume** it's walkers (68) +
|
||||||
|
wheelchairs (45) ≈ 82% of the base, but rollators/walkers are mechanically low-service; **by
|
||||||
|
service-revenue-per-unit** the targets are the powered units (power bases 7 + scooters 4 + power
|
||||||
|
wheelchairs) — high maintenance value but only ~11–15 units today. Volume vs. margin — or phase it
|
||||||
|
(powered units first to prove the booking loop, then walkers/manual chairs for reach)?
|
||||||
|
- **Revenue mechanic**: auto-draft a priced SO/invoice per booking, vs. pre-paid annual plan, vs.
|
||||||
|
pay-at-door via Poynt — which is the default?
|
||||||
|
- **Technician assignment**: auto-assign by skill+zone at booking time, or leave dispatch manual
|
||||||
|
(fusion_tasks) and only reserve the calendar slot?
|
||||||
|
- **Booking-portal strategy**: Step 0 shows Enterprise `appointment` already ships public,
|
||||||
|
token-based real-time booking (`appointment.invite` + `/appointment/<id>/...`, `auth="public"`).
|
||||||
|
Ride on that (generate an invite per reminder, partner pre-bound, no login) vs. a custom
|
||||||
|
`/maintenance/book/<token>` route? (The `/repairs/...` route is moot — fusion_repairs isn't on Westin.)
|
||||||
|
|
||||||
|
## Applicable CLAUDE.md rules (don't relearn the hard way)
|
||||||
|
- Rule #1: read reference files from the running instance before coding (esp. the appointment source).
|
||||||
|
- Odoo 19: `res.users.group_ids` (not `groups_id`); `ir.cron` has no `numbercall`; declarative
|
||||||
|
`models.Constraint`/`models.Index`; HTTP routes `type="jsonrpc"`; OWL uses standalone `rpc()`.
|
||||||
|
- No `sale.subscription` model exists — a subscription is a `sale.order` with `is_subscription=True`.
|
||||||
|
- New fields use `x_fc_` prefix; Canadian English; `$` Monetary + `currency_id`.
|
||||||
|
- Route attachment opens through `fusion_pdf_preview` (`att.action_fusion_preview(...)`).
|
||||||
|
- Tests need `--http-port=0 --gevent-port=0`. Westin prod is Enterprise; local dev is Community
|
||||||
|
(so the appointment-dependent module can't be installed/tested on `odoo-modsdev-app`).
|
||||||
@@ -0,0 +1,864 @@
|
|||||||
|
# Fusion Clock — Province-Aware Automatic Unpaid Break Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Make the unpaid meal break deduct automatically from worked hours on every path (portal, kiosk, NFC, cron, **and manual backend entry**), using a 2-tier per-province rule table (Ontario: 5h→30min, 10h→+30min), with no duplicated logic.
|
||||||
|
|
||||||
|
**Architecture:** A new `fusion.clock.break.rule` table holds the per-province thresholds. `hr.employee._get_fclk_break_rule()` resolves an employee's rule from its company's province (global default fallback). `hr.attendance.x_fclk_break_minutes` becomes a single stored **computed** field — `statutory_break(worked_hours) + Σ penalty_minutes` — that recomputes on every save and replaces the four scattered write sites (controller `_apply_break_deduction` ×3 call sites, the auto-clock-out cron, and the penalty code's manual write).
|
||||||
|
|
||||||
|
**Tech Stack:** Odoo 19, Python, QWeb/XML views, Odoo test framework (`TransactionCase`).
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-05-31-fusion-clock-statutory-break-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dev environment & sync (READ FIRST — applies to every task)
|
||||||
|
|
||||||
|
**Two working copies (per project memory `feedback_dual_path_fusion_clock`):**
|
||||||
|
- **Git/source tree (edit + commit here):** `K:\Github\Odoo-Modules\fusion_clock`
|
||||||
|
- **Docker/active tree (what the container loads):** `K:\Github\odoo-modsdev\addons\fusion_clock`
|
||||||
|
|
||||||
|
Edit in the **git tree**, then **mirror to the Docker tree before every test run**:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
robocopy "K:\Github\Odoo-Modules\fusion_clock" "K:\Github\odoo-modsdev\addons\fusion_clock" /MIR /XD ".git" "__pycache__" /XF "*.pyc" /NFL /NDL /NJH /NJS; if ($LASTEXITCODE -lt 8) { "sync ok" } else { "sync FAILED" }
|
||||||
|
```
|
||||||
|
(robocopy exit codes < 8 = success.) **Preflight:** if `K:\Github\odoo-modsdev\addons\fusion_clock` does not exist, the dual-tree setup changed — STOP and confirm the active copy with the user before continuing.
|
||||||
|
|
||||||
|
**Container/DB:** `odoo-modsdev-app` / db `modsdev` (per memory `reference_docker_env_names`).
|
||||||
|
|
||||||
|
**Canonical commands** (note the ephemeral ports — `--test-enable` forces `http_spawn()` so 8069/8072 collide without them; per repo CLAUDE.md):
|
||||||
|
|
||||||
|
- Run this module's tests:
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -100
|
||||||
|
```
|
||||||
|
- Plain upgrade (no tests):
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app odoo -d modsdev -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -50
|
||||||
|
```
|
||||||
|
- Pyflakes a changed Python file (catches undefined names instantly):
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/<relpath>.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Commit:** only from the git tree (`git -C "K:/Github/Odoo-Modules" ...`). Per memory `feedback_always_push_to_main`, push after each commit on `main`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Created:**
|
||||||
|
- `fusion_clock/models/clock_break_rule.py` — the `fusion.clock.break.rule` model + tier engine + constraints.
|
||||||
|
- `fusion_clock/data/clock_break_rule_data.xml` — seed Ontario rule (`is_default`).
|
||||||
|
- `fusion_clock/views/clock_break_rule_views.xml` — list/form/action for the rule.
|
||||||
|
- `fusion_clock/migrations/19.0.4.1.0/post-migrate.py` — drop retired param + recompute break.
|
||||||
|
- `fusion_clock/tests/test_break_rules.py` — all new tests.
|
||||||
|
|
||||||
|
**Modified:**
|
||||||
|
- `fusion_clock/models/__init__.py` — import the new model.
|
||||||
|
- `fusion_clock/models/hr_employee.py` — add `_get_fclk_break_rule()`.
|
||||||
|
- `fusion_clock/models/hr_attendance.py` — `x_fclk_break_minutes` → stored compute; drop cron break-write.
|
||||||
|
- `fusion_clock/controllers/clock_api.py` — delete `_apply_break_deduction`, its clock-out call, and the penalty break-write.
|
||||||
|
- `fusion_clock/controllers/clock_kiosk.py` — delete the `_apply_break_deduction` call.
|
||||||
|
- `fusion_clock/controllers/clock_nfc_kiosk.py` — delete the `_apply_break_deduction` call.
|
||||||
|
- `fusion_clock/models/res_config_settings.py` — remove `fclk_break_threshold_hours`.
|
||||||
|
- `fusion_clock/views/res_config_settings_views.xml` — remove threshold row; relabel default-break as scheduling-only; point to Break Rules.
|
||||||
|
- `fusion_clock/data/ir_config_parameter_data.xml` — remove the `break_threshold_hours` seed record.
|
||||||
|
- `fusion_clock/security/ir.model.access.csv` — manager access for the new model.
|
||||||
|
- `fusion_clock/views/clock_menus.xml` — "Break Rules" config menu.
|
||||||
|
- `fusion_clock/__manifest__.py` — version bump + new data/view files.
|
||||||
|
- `fusion_clock/tests/__init__.py` — import the new test module.
|
||||||
|
- `fusion_clock/tests/test_settings.py` — assert the retired field is gone.
|
||||||
|
- `fusion_clock/CLAUDE.md` — model map, settings keys, break gotcha (Task 5).
|
||||||
|
|
||||||
|
**Behaviour-change note (intentional, approved by spec §4.3):** today a *late-in* penalty written at clock-in (e.g. +15) is silently swallowed at clock-out because `_apply_break_deduction` does `max(break, current)`. The new compute makes **all** penalty minutes strictly additive (`statutory + Σ penalties`), so a late-in penalty on a long shift is no longer lost. Net hours for such shifts will be correctly lower than before.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: New model `fusion.clock.break.rule`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `fusion_clock/models/clock_break_rule.py`
|
||||||
|
- Create: `fusion_clock/data/clock_break_rule_data.xml`
|
||||||
|
- Create: `fusion_clock/views/clock_break_rule_views.xml`
|
||||||
|
- Create: `fusion_clock/tests/test_break_rules.py`
|
||||||
|
- Modify: `fusion_clock/models/__init__.py`
|
||||||
|
- Modify: `fusion_clock/tests/__init__.py`
|
||||||
|
- Modify: `fusion_clock/security/ir.model.access.csv`
|
||||||
|
- Modify: `fusion_clock/views/clock_menus.xml`
|
||||||
|
- Modify: `fusion_clock/__manifest__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** — create `fusion_clock/tests/test_break_rules.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from odoo.tests import tagged, TransactionCase
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||||
|
class TestBreakRules(TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||||
|
cls.ICP.set_param('fusion_clock.auto_deduct_break', 'True')
|
||||||
|
cls.Rule = cls.env['fusion.clock.break.rule']
|
||||||
|
cls.default_rule = cls.Rule.search([('is_default', '=', True)], limit=1)
|
||||||
|
cls.employee = cls.env['hr.employee'].create({'name': 'FCLK Break Test'})
|
||||||
|
|
||||||
|
def _mk_att(self, hours):
|
||||||
|
check_in = datetime(2026, 1, 5, 9, 0, 0)
|
||||||
|
return self.env['hr.attendance'].create({
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'check_in': check_in,
|
||||||
|
'check_out': check_in + timedelta(hours=hours),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- Task 1: tier engine + constraints ----
|
||||||
|
def test_break_minutes_for_tiers(self):
|
||||||
|
rule = self.Rule.create({
|
||||||
|
'name': 'Tier Test', 'is_default': False,
|
||||||
|
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||||
|
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||||
|
})
|
||||||
|
self.assertEqual(rule.break_minutes_for(4.99), 0.0)
|
||||||
|
self.assertEqual(rule.break_minutes_for(5.0), 30.0)
|
||||||
|
self.assertEqual(rule.break_minutes_for(9.99), 30.0)
|
||||||
|
self.assertEqual(rule.break_minutes_for(10.0), 60.0)
|
||||||
|
self.assertEqual(rule.break_minutes_for(12.0), 60.0)
|
||||||
|
|
||||||
|
def test_second_tier_must_exceed_first(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.Rule.create({
|
||||||
|
'name': 'Bad', 'is_default': False,
|
||||||
|
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||||
|
'break2_after_hours': 5.0, 'break2_minutes': 30.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_single_default_enforced(self):
|
||||||
|
self.assertTrue(self.default_rule, "seed default rule must exist")
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.Rule.create({
|
||||||
|
'name': 'Another Default', 'is_default': True, 'active': True,
|
||||||
|
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||||
|
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Append the import to `fusion_clock/tests/__init__.py` (add the line if not already present):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from . import test_break_rules
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create the model** — `fusion_clock/models/clock_break_rule.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class FusionClockBreakRule(models.Model):
|
||||||
|
_name = 'fusion.clock.break.rule'
|
||||||
|
_description = 'Statutory Break Rule'
|
||||||
|
_order = 'sequence, name'
|
||||||
|
|
||||||
|
name = fields.Char(string='Name', required=True)
|
||||||
|
country_id = fields.Many2one('res.country', string='Country')
|
||||||
|
state_id = fields.Many2one(
|
||||||
|
'res.country.state',
|
||||||
|
string='Province / State',
|
||||||
|
help="Employees whose company is in this province use this rule.",
|
||||||
|
)
|
||||||
|
is_default = fields.Boolean(
|
||||||
|
string='Default Rule',
|
||||||
|
help="Used when an employee's company province matches no other rule. "
|
||||||
|
"Only one active rule may be the default.",
|
||||||
|
)
|
||||||
|
break1_after_hours = fields.Float(
|
||||||
|
string='First Break After (h)', default=5.0,
|
||||||
|
help="Worked hours at or above this trigger the first unpaid break.",
|
||||||
|
)
|
||||||
|
break1_minutes = fields.Float(
|
||||||
|
string='First Break (min)', default=30.0,
|
||||||
|
help="Length of the first unpaid break. 0 disables it.",
|
||||||
|
)
|
||||||
|
break2_after_hours = fields.Float(
|
||||||
|
string='Second Break After (h)', default=10.0,
|
||||||
|
help="Worked hours at or above this add the second unpaid break.",
|
||||||
|
)
|
||||||
|
break2_minutes = fields.Float(
|
||||||
|
string='Second Break (min)', default=30.0,
|
||||||
|
help="Length of the second unpaid break. 0 disables it.",
|
||||||
|
)
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
|
||||||
|
def break_minutes_for(self, worked_hours):
|
||||||
|
"""Total statutory unpaid break (minutes) for the given worked hours.
|
||||||
|
|
||||||
|
Tiers are inclusive (``>=``): a break applies when worked hours are
|
||||||
|
equal to or greater than the threshold. The second tier adds on top of
|
||||||
|
the first.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
worked = worked_hours or 0.0
|
||||||
|
total = 0.0
|
||||||
|
if self.break1_minutes and worked >= self.break1_after_hours:
|
||||||
|
total += self.break1_minutes
|
||||||
|
if self.break2_minutes and worked >= self.break2_after_hours:
|
||||||
|
total += self.break2_minutes
|
||||||
|
return total
|
||||||
|
|
||||||
|
@api.constrains('break1_after_hours', 'break1_minutes',
|
||||||
|
'break2_after_hours', 'break2_minutes')
|
||||||
|
def _check_tiers(self):
|
||||||
|
for rule in self:
|
||||||
|
if min(rule.break1_after_hours, rule.break1_minutes,
|
||||||
|
rule.break2_after_hours, rule.break2_minutes) < 0:
|
||||||
|
raise ValidationError(_("Break hours and minutes cannot be negative."))
|
||||||
|
if rule.break2_minutes and rule.break2_after_hours <= rule.break1_after_hours:
|
||||||
|
raise ValidationError(_(
|
||||||
|
"The second break threshold (%(n2)s h) must be greater than "
|
||||||
|
"the first (%(n1)s h).",
|
||||||
|
n2=rule.break2_after_hours, n1=rule.break1_after_hours))
|
||||||
|
|
||||||
|
@api.constrains('is_default', 'active')
|
||||||
|
def _check_single_default(self):
|
||||||
|
for rule in self:
|
||||||
|
if rule.is_default and rule.active:
|
||||||
|
dupe = self.search([
|
||||||
|
('is_default', '=', True), ('active', '=', True),
|
||||||
|
('id', '!=', rule.id),
|
||||||
|
], limit=1)
|
||||||
|
if dupe:
|
||||||
|
raise ValidationError(_(
|
||||||
|
"Only one active break rule can be the default "
|
||||||
|
"(currently: %s).", dupe.name))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Register the model** — add to `fusion_clock/models/__init__.py` after the `clock_penalty` import:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from . import clock_break_rule
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Grant access** — append one row to `fusion_clock/security/ir.model.access.csv`:
|
||||||
|
|
||||||
|
```
|
||||||
|
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
|
||||||
|
```
|
||||||
|
|
||||||
|
(No user/portal grant needed — the resolver reads the table via `sudo()`.)
|
||||||
|
|
||||||
|
- [ ] **Step 5: Seed the Ontario rule** — create `fusion_clock/data/clock_break_rule_data.xml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
<record id="break_rule_ontario" model="fusion.clock.break.rule">
|
||||||
|
<field name="name">Ontario</field>
|
||||||
|
<field name="country_id" ref="base.ca"/>
|
||||||
|
<field name="state_id" ref="base.state_ca_on"/>
|
||||||
|
<field name="is_default" eval="True"/>
|
||||||
|
<field name="break1_after_hours">5.0</field>
|
||||||
|
<field name="break1_minutes">30.0</field>
|
||||||
|
<field name="break2_after_hours">10.0</field>
|
||||||
|
<field name="break2_minutes">30.0</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Views + action** — create `fusion_clock/views/clock_break_rule_views.xml`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_fusion_clock_break_rule_list" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.clock.break.rule.list</field>
|
||||||
|
<field name="model">fusion.clock.break.rule</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="state_id"/>
|
||||||
|
<field name="country_id" optional="hide"/>
|
||||||
|
<field name="break1_after_hours" widget="float_time"/>
|
||||||
|
<field name="break1_minutes"/>
|
||||||
|
<field name="break2_after_hours" widget="float_time"/>
|
||||||
|
<field name="break2_minutes"/>
|
||||||
|
<field name="is_default"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fusion_clock_break_rule_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.clock.break.rule.form</field>
|
||||||
|
<field name="model">fusion.clock.break.rule</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<sheet>
|
||||||
|
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
|
||||||
|
invisible="active"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1><field name="name" placeholder="e.g. Ontario"/></h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group string="Jurisdiction">
|
||||||
|
<field name="country_id"/>
|
||||||
|
<field name="state_id"
|
||||||
|
domain="[('country_id', '=', country_id)]"/>
|
||||||
|
<field name="is_default"/>
|
||||||
|
<field name="active"/>
|
||||||
|
</group>
|
||||||
|
<group string="Unpaid Break Tiers">
|
||||||
|
<label for="break1_after_hours" string="First break after"/>
|
||||||
|
<div class="o_row">
|
||||||
|
<field name="break1_after_hours" widget="float_time"/>
|
||||||
|
<span>h →</span>
|
||||||
|
<field name="break1_minutes"/>
|
||||||
|
<span>min</span>
|
||||||
|
</div>
|
||||||
|
<label for="break2_after_hours" string="Second break after"/>
|
||||||
|
<div class="o_row">
|
||||||
|
<field name="break2_after_hours" widget="float_time"/>
|
||||||
|
<span>h →</span>
|
||||||
|
<field name="break2_minutes"/>
|
||||||
|
<span>min</span>
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<p class="text-muted">
|
||||||
|
Breaks are unpaid and deducted from actual worked hours. A tier with
|
||||||
|
0 minutes is disabled. Triggers are inclusive — a break applies when
|
||||||
|
worked hours are equal to or above the threshold.
|
||||||
|
</p>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fusion_clock_break_rule" model="ir.actions.act_window">
|
||||||
|
<field name="name">Break Rules</field>
|
||||||
|
<field name="res_model">fusion.clock.break.rule</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="context">{'active_test': False}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">Create a statutory break rule</p>
|
||||||
|
<p>Define unpaid meal-break thresholds per province/country. Employees inherit
|
||||||
|
the rule matching their company's province, or the default rule.</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Add the menu** — in `fusion_clock/views/clock_menus.xml`, insert after the `menu_fusion_clock_locations_config` menuitem (the Locations config item) and before `menu_fusion_clock_nfc_enrollment`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<menuitem id="menu_fusion_clock_break_rules"
|
||||||
|
name="Break Rules"
|
||||||
|
parent="menu_fusion_clock_config"
|
||||||
|
action="action_fusion_clock_break_rule"
|
||||||
|
sequence="25"
|
||||||
|
groups="group_fusion_clock_manager"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Wire the manifest** — in `fusion_clock/__manifest__.py`:
|
||||||
|
|
||||||
|
**Do NOT bump the version yet** — it stays `19.0.4.0.3` until Task 4, so the
|
||||||
|
`19.0.4.1.0` migration actually fires in dev (Odoo only runs a version's migration
|
||||||
|
when the installed version is *lower* than the manifest version).
|
||||||
|
|
||||||
|
Add the seed data file after `'data/ir_config_parameter_data.xml',`:
|
||||||
|
```python
|
||||||
|
'data/clock_break_rule_data.xml',
|
||||||
|
```
|
||||||
|
Add the view file after `'views/clock_schedule_views.xml',`:
|
||||||
|
```python
|
||||||
|
'views/clock_break_rule_views.xml',
|
||||||
|
```
|
||||||
|
(Data and view files reload on every `-u` regardless of the version number, so the
|
||||||
|
new model/menu install without a bump. No assets change in this plan, so the bump's
|
||||||
|
only purpose is the migration trigger — deferred to Task 4.)
|
||||||
|
|
||||||
|
- [ ] **Step 9: Sync, upgrade, run tests**
|
||||||
|
|
||||||
|
Sync (see preamble), then:
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -100
|
||||||
|
```
|
||||||
|
Expected: module upgrades cleanly; `test_break_minutes_for_tiers`, `test_second_tier_must_exceed_first`, `test_single_default_enforced` PASS. (Other tests in the class will error until Tasks 2–3 add their dependencies — that's expected if you scoped the run; otherwise the not-yet-added methods simply don't exist yet.)
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/clock_break_rule.py fusion_clock/models/__init__.py fusion_clock/data/clock_break_rule_data.xml fusion_clock/views/clock_break_rule_views.xml fusion_clock/views/clock_menus.xml fusion_clock/security/ir.model.access.csv fusion_clock/__manifest__.py fusion_clock/tests/test_break_rules.py fusion_clock/tests/__init__.py
|
||||||
|
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): add fusion.clock.break.rule per-province break table" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
git -C "K:/Github/Odoo-Modules" push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Jurisdiction resolver on `hr.employee`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_clock/models/hr_employee.py`
|
||||||
|
- Modify: `fusion_clock/tests/test_break_rules.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the resolver tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ---- Task 2: jurisdiction resolver ----
|
||||||
|
def test_resolver_matches_company_province(self):
|
||||||
|
bc = self.env.ref('base.state_ca_bc')
|
||||||
|
bc_rule = self.Rule.create({
|
||||||
|
'name': 'British Columbia', 'state_id': bc.id, 'is_default': False,
|
||||||
|
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||||
|
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||||
|
})
|
||||||
|
self.employee.company_id.state_id = bc.id
|
||||||
|
self.assertEqual(self.employee._get_fclk_break_rule(), bc_rule)
|
||||||
|
|
||||||
|
def test_resolver_falls_back_to_default(self):
|
||||||
|
self.assertTrue(self.default_rule, "seed default rule must exist")
|
||||||
|
alberta = self.env.ref('base.state_ca_ab') # no rule for AB
|
||||||
|
self.employee.company_id.state_id = alberta.id
|
||||||
|
self.assertEqual(self.employee._get_fclk_break_rule(), self.default_rule)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify they fail**
|
||||||
|
|
||||||
|
Sync, then:
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||||
|
```
|
||||||
|
Expected: FAIL — `AttributeError: 'hr.employee' object has no attribute '_get_fclk_break_rule'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the resolver** — in `fusion_clock/models/hr_employee.py`, add this method immediately after the `_get_fclk_break_minutes` method (after its `return float(...)` block, before `_get_fclk_scheduled_times`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _get_fclk_break_rule(self):
|
||||||
|
"""Return the statutory break rule for this employee.
|
||||||
|
|
||||||
|
Resolution: company's province → matching rule; else the global default
|
||||||
|
rule; else an empty recordset (caller treats as zero break). Read via
|
||||||
|
sudo so the portal net-hours compute can resolve it without a direct ACL.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
Rule = self.env['fusion.clock.break.rule'].sudo()
|
||||||
|
rule = Rule.browse()
|
||||||
|
state = self.company_id.state_id
|
||||||
|
if state:
|
||||||
|
rule = Rule.search([('state_id', '=', state.id)], limit=1)
|
||||||
|
if not rule:
|
||||||
|
rule = Rule.search([('is_default', '=', True)], limit=1)
|
||||||
|
return rule
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify they pass**
|
||||||
|
|
||||||
|
Sync, then re-run the Step 2 command. Expected: `test_resolver_matches_company_province` and `test_resolver_falls_back_to_default` PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_employee.py fusion_clock/tests/test_break_rules.py
|
||||||
|
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): resolve employee break rule from company province" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
git -C "K:/Github/Odoo-Modules" push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `x_fclk_break_minutes` → stored compute; remove all manual writes
|
||||||
|
|
||||||
|
This task is atomic: once the field is computed (no inverse), any remaining `write({'x_fclk_break_minutes': ...})` raises at runtime, so the field conversion and the removal of all four write sites must land together.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_clock/models/hr_attendance.py`
|
||||||
|
- Modify: `fusion_clock/controllers/clock_api.py`
|
||||||
|
- Modify: `fusion_clock/controllers/clock_kiosk.py`
|
||||||
|
- Modify: `fusion_clock/controllers/clock_nfc_kiosk.py`
|
||||||
|
- Modify: `fusion_clock/tests/test_break_rules.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the attendance tests** — append these methods to `TestBreakRules` in `fusion_clock/tests/test_break_rules.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ---- Task 3: automatic deduction on every path ----
|
||||||
|
def test_manual_attendance_applies_statutory_break(self):
|
||||||
|
att = self._mk_att(6) # 6h >= 5 -> first break
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 30.0)
|
||||||
|
self.assertAlmostEqual(att.x_fclk_net_hours, 5.5, places=2)
|
||||||
|
|
||||||
|
def test_manual_edit_extends_break(self):
|
||||||
|
att = self._mk_att(6)
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 30.0)
|
||||||
|
att.check_out = att.check_in + timedelta(hours=10) # now >= 10
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 60.0)
|
||||||
|
self.assertAlmostEqual(att.x_fclk_net_hours, 9.0, places=2)
|
||||||
|
|
||||||
|
def test_under_first_threshold_no_break(self):
|
||||||
|
att = self._mk_att(4) # 4h < 5 -> nothing
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||||
|
self.assertAlmostEqual(att.x_fclk_net_hours, 4.0, places=2)
|
||||||
|
|
||||||
|
def test_penalty_minutes_are_additive(self):
|
||||||
|
att = self._mk_att(6) # statutory 30
|
||||||
|
self.env['fusion.clock.penalty'].create({
|
||||||
|
'attendance_id': att.id,
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'penalty_type': 'early_out',
|
||||||
|
'penalty_minutes': 15.0,
|
||||||
|
'date': att.check_in.date(),
|
||||||
|
})
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 45.0)
|
||||||
|
|
||||||
|
def test_master_toggle_off_zero_statutory(self):
|
||||||
|
self.ICP.set_param('fusion_clock.auto_deduct_break', 'False')
|
||||||
|
att = self._mk_att(6)
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||||
|
|
||||||
|
def test_open_attendance_zero_break(self):
|
||||||
|
att = self.env['hr.attendance'].create({
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'check_in': datetime(2026, 1, 5, 9, 0, 0),
|
||||||
|
})
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify they fail**
|
||||||
|
|
||||||
|
Sync, then run the module tests. Expected: the new tests FAIL — e.g. `test_manual_attendance_applies_statutory_break` asserts 30 but gets 0 (no write override exists yet).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Convert the field to a stored compute** — in `fusion_clock/models/hr_attendance.py`, replace the field definition:
|
||||||
|
|
||||||
|
OLD:
|
||||||
|
```python
|
||||||
|
x_fclk_break_minutes = fields.Float(
|
||||||
|
string='Break (min)',
|
||||||
|
default=0.0,
|
||||||
|
tracking=True,
|
||||||
|
help="Break duration in minutes to deduct from worked hours.",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
NEW:
|
||||||
|
```python
|
||||||
|
x_fclk_break_minutes = fields.Float(
|
||||||
|
string='Break (min)',
|
||||||
|
compute='_compute_fclk_break_minutes',
|
||||||
|
store=True,
|
||||||
|
tracking=True,
|
||||||
|
help="Unpaid break deducted from worked hours: statutory break (per the "
|
||||||
|
"employee's province rule, from actual hours worked) plus any penalty "
|
||||||
|
"minutes. Computed automatically on every save.",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add the compute method** — in the same file, insert this method immediately before the `_compute_net_hours` method (just above its `@api.depends('worked_hours', 'x_fclk_break_minutes')` decorator):
|
||||||
|
|
||||||
|
```python
|
||||||
|
@api.depends('worked_hours', 'check_out',
|
||||||
|
'x_fclk_penalty_ids.penalty_minutes', 'employee_id')
|
||||||
|
def _compute_fclk_break_minutes(self):
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True'
|
||||||
|
for att in self:
|
||||||
|
statutory = 0.0
|
||||||
|
if auto and att.check_out and att.employee_id:
|
||||||
|
rule = att.employee_id._get_fclk_break_rule()
|
||||||
|
if rule:
|
||||||
|
statutory = rule.break_minutes_for(att.worked_hours or 0.0)
|
||||||
|
penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes'))
|
||||||
|
att.x_fclk_break_minutes = statutory + penalties
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Remove the cron's break write** — in the same file, inside `_cron_fusion_auto_clock_out`:
|
||||||
|
|
||||||
|
Remove the now-unused threshold read (the line near the top of the method):
|
||||||
|
```python
|
||||||
|
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||||
|
```
|
||||||
|
Remove the two now-unused locals in the per-attendance loop:
|
||||||
|
```python
|
||||||
|
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
|
||||||
|
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
|
||||||
|
```
|
||||||
|
Remove the break-write block (the compute now applies the break when `check_out` is set):
|
||||||
|
```python
|
||||||
|
if (att.worked_hours or 0) >= threshold:
|
||||||
|
att.sudo().write(
|
||||||
|
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
(Leave the surrounding `employee = att.employee_id` and `clock_out_time = effective_deadline` lines intact.)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Delete the controller helper and its call sites** — in `fusion_clock/controllers/clock_api.py`:
|
||||||
|
|
||||||
|
Delete the entire `_apply_break_deduction` method:
|
||||||
|
```python
|
||||||
|
def _apply_break_deduction(self, attendance, employee):
|
||||||
|
"""Apply automatic break deduction if configured."""
|
||||||
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True':
|
||||||
|
return
|
||||||
|
|
||||||
|
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
||||||
|
worked = attendance.worked_hours or 0.0
|
||||||
|
|
||||||
|
if worked >= threshold:
|
||||||
|
local_date = get_local_today(request.env, employee)
|
||||||
|
if attendance.check_in:
|
||||||
|
tz_name = (
|
||||||
|
employee.resource_id.tz
|
||||||
|
or (employee.user_id.partner_id.tz if employee.user_id else False)
|
||||||
|
or employee.company_id.partner_id.tz
|
||||||
|
or 'UTC'
|
||||||
|
)
|
||||||
|
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
|
||||||
|
break_min = employee._get_fclk_break_minutes(local_date)
|
||||||
|
current = attendance.x_fclk_break_minutes or 0.0
|
||||||
|
# Set to whichever is higher: configured break or existing (penalty-inflated) value
|
||||||
|
new_val = max(break_min, current)
|
||||||
|
if new_val != current:
|
||||||
|
attendance.sudo().write({'x_fclk_break_minutes': new_val})
|
||||||
|
|
||||||
|
```
|
||||||
|
Delete its clock-out call (in the CLOCK OUT branch):
|
||||||
|
```python
|
||||||
|
# Apply break deduction
|
||||||
|
self._apply_break_deduction(attendance, employee)
|
||||||
|
|
||||||
|
```
|
||||||
|
Delete the penalty break-write in `_check_and_create_penalty` (keep the penalty-record `create` above it and the activity log below it):
|
||||||
|
```python
|
||||||
|
# Deduct penalty minutes from attendance (adds to break deduction)
|
||||||
|
current_break = attendance.x_fclk_break_minutes or 0.0
|
||||||
|
attendance.sudo().write({
|
||||||
|
'x_fclk_break_minutes': current_break + deduction,
|
||||||
|
})
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Delete the kiosk call sites**
|
||||||
|
|
||||||
|
In `fusion_clock/controllers/clock_kiosk.py`, delete the line:
|
||||||
|
```python
|
||||||
|
api._apply_break_deduction(attendance, employee)
|
||||||
|
```
|
||||||
|
In `fusion_clock/controllers/clock_nfc_kiosk.py`, delete the line:
|
||||||
|
```python
|
||||||
|
api._apply_break_deduction(attendance, employee)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 8: Pyflakes the touched controllers/models** (catches a missed `pytz`/var reference instantly)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app python3 -m pyflakes /mnt/extra-addons/fusion_clock/controllers/clock_api.py /mnt/extra-addons/fusion_clock/controllers/clock_kiosk.py /mnt/extra-addons/fusion_clock/controllers/clock_nfc_kiosk.py /mnt/extra-addons/fusion_clock/models/hr_attendance.py
|
||||||
|
```
|
||||||
|
Expected: no output (clean). If it flags `pytz` as unused in `hr_attendance.py`, that's fine only if no other code uses it — verify before removing the import (the absence/overtime crons still use `pytz`, so leave the import).
|
||||||
|
|
||||||
|
- [ ] **Step 9: Run to verify all Task 3 tests pass**
|
||||||
|
|
||||||
|
Sync, then run the module tests. Expected: all `test_manual_*`, `test_under_first_threshold_no_break`, `test_penalty_minutes_are_additive`, `test_master_toggle_off_zero_statutory`, `test_open_attendance_zero_break` PASS, and the existing NFC/kiosk/dashboard tests still PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/hr_attendance.py fusion_clock/controllers/clock_api.py fusion_clock/controllers/clock_kiosk.py fusion_clock/controllers/clock_nfc_kiosk.py fusion_clock/tests/test_break_rules.py
|
||||||
|
git -C "K:/Github/Odoo-Modules" commit -m "feat(fusion_clock): auto-apply statutory break via one stored compute" -m "x_fclk_break_minutes is now statutory(worked_hours) + penalties, recomputed on every path including manual backend entry. Removes the four duplicated write sites (controller _apply_break_deduction + 3 call sites, auto-clock-out cron, penalty write)." -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
git -C "K:/Github/Odoo-Modules" push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Retire `break_threshold_hours`; clean settings & migrate
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_clock/models/res_config_settings.py`
|
||||||
|
- Modify: `fusion_clock/views/res_config_settings_views.xml`
|
||||||
|
- Modify: `fusion_clock/data/ir_config_parameter_data.xml`
|
||||||
|
- Create: `fusion_clock/migrations/19.0.4.1.0/post-migrate.py`
|
||||||
|
- Modify: `fusion_clock/tests/test_settings.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the dead-setting assertion** — in `fusion_clock/tests/test_settings.py`, add one line to `test_dead_settings_removed`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.assertNotIn('fclk_break_threshold_hours', fields)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the settings field** — in `fusion_clock/models/res_config_settings.py`, delete:
|
||||||
|
|
||||||
|
```python
|
||||||
|
fclk_break_threshold_hours = fields.Float(
|
||||||
|
string='Break Threshold (hours)',
|
||||||
|
config_parameter='fusion_clock.break_threshold_hours',
|
||||||
|
default=4.0,
|
||||||
|
help="Only deduct break if shift is longer than this many hours.",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fix the settings view** — in `fusion_clock/views/res_config_settings_views.xml`, replace the whole `fclk_auto_break` setting block:
|
||||||
|
|
||||||
|
OLD:
|
||||||
|
```xml
|
||||||
|
<setting id="fclk_auto_break" string="Auto-Deduct Break"
|
||||||
|
help="Automatically deduct unpaid break from worked hours on clock-out.">
|
||||||
|
<field name="fclk_auto_deduct_break"/>
|
||||||
|
<div class="content-group" invisible="not fclk_auto_deduct_break">
|
||||||
|
<div class="row mt16">
|
||||||
|
<label for="fclk_default_break_minutes" string="Duration (min)" class="col-lg-5 o_light_label"/>
|
||||||
|
<field name="fclk_default_break_minutes"/>
|
||||||
|
</div>
|
||||||
|
<div class="row mt8">
|
||||||
|
<label for="fclk_break_threshold_hours" string="Min. Shift" class="col-lg-5 o_light_label"/>
|
||||||
|
<field name="fclk_break_threshold_hours" widget="float_time"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
```
|
||||||
|
NEW:
|
||||||
|
```xml
|
||||||
|
<setting id="fclk_auto_break" string="Auto-Deduct Break"
|
||||||
|
help="Automatically deduct the statutory unpaid break from worked hours. Break lengths and thresholds are configured per province under Configuration → Break Rules.">
|
||||||
|
<field name="fclk_auto_deduct_break"/>
|
||||||
|
<div class="content-group" invisible="not fclk_auto_deduct_break">
|
||||||
|
<div class="row mt16">
|
||||||
|
<label for="fclk_default_break_minutes" string="Default scheduling break (min)" class="col-lg-5 o_light_label"/>
|
||||||
|
<field name="fclk_default_break_minutes"/>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small mt4">
|
||||||
|
Used as the default break when building shifts/schedules
|
||||||
|
(planned hours). Actual deductions follow the province Break Rules.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Remove the seed param** — in `fusion_clock/data/ir_config_parameter_data.xml`, delete:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<record id="config_break_threshold_hours" model="ir.config_parameter">
|
||||||
|
<field name="key">fusion_clock.break_threshold_hours</field>
|
||||||
|
<field name="value">4.0</field>
|
||||||
|
</record>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Bump the version + create the migration**
|
||||||
|
|
||||||
|
First bump the manifest so the migration fires (installed `19.0.4.0.3` < manifest
|
||||||
|
`19.0.4.1.0`). In `fusion_clock/__manifest__.py`:
|
||||||
|
```python
|
||||||
|
'version': '19.0.4.1.0',
|
||||||
|
```
|
||||||
|
Then create `fusion_clock/migrations/19.0.4.1.0/post-migrate.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from odoo import api, SUPERUSER_ID
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
"""Retire the single-threshold break param (superseded by per-rule
|
||||||
|
break1_after_hours), and force-recompute the now-computed break field so
|
||||||
|
existing closed attendances reflect the province rule + their penalties."""
|
||||||
|
cr.execute(
|
||||||
|
"DELETE FROM ir_config_parameter WHERE key = %s",
|
||||||
|
('fusion_clock.break_threshold_hours',),
|
||||||
|
)
|
||||||
|
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||||
|
Attendance = env['hr.attendance']
|
||||||
|
field = Attendance._fields['x_fclk_break_minutes']
|
||||||
|
closed = Attendance.search([('check_out', '!=', False)])
|
||||||
|
if closed:
|
||||||
|
env.add_to_compute(field, closed)
|
||||||
|
closed.flush_recordset(['x_fclk_break_minutes'])
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Sync, upgrade, run tests**
|
||||||
|
|
||||||
|
Sync, then run the module tests. Expected: module upgrades cleanly and the `19.0.4.1.0` migration executes (installed `19.0.4.0.3` < manifest `19.0.4.1.0`; modsdev shows the INFO line, nexa/entech run `log_level=warn`), `test_dead_settings_removed` PASS, full `fusion_clock` suite green.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Verify the param is gone and historical rows recomputed** (sanity)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app odoo shell -d modsdev --no-http 2>/dev/null <<'PY'
|
||||||
|
ICP = env['ir.config_parameter'].sudo()
|
||||||
|
print('threshold param:', ICP.get_param('fusion_clock.break_threshold_hours', 'ABSENT'))
|
||||||
|
print('default rule:', env['fusion.clock.break.rule'].search([('is_default','=',True)]).mapped('name'))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
Expected: `threshold param: ABSENT`; `default rule: ['Ontario']`.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C "K:/Github/Odoo-Modules" add fusion_clock/models/res_config_settings.py fusion_clock/views/res_config_settings_views.xml fusion_clock/data/ir_config_parameter_data.xml fusion_clock/migrations/19.0.4.1.0/post-migrate.py fusion_clock/tests/test_settings.py fusion_clock/__manifest__.py
|
||||||
|
git -C "K:/Github/Odoo-Modules" commit -m "refactor(fusion_clock): retire break_threshold_hours; breaks now driven by Break Rules" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
git -C "K:/Github/Odoo-Modules" push
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Full verification, docs, manual smoke
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_clock/CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full test run (whole module)**
|
||||||
|
|
||||||
|
Sync, then:
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -120
|
||||||
|
```
|
||||||
|
Expected: all `fusion_clock` tests PASS, zero tracebacks. If anything fails, fix before continuing.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Manual smoke (manager UI)** at http://localhost:8082
|
||||||
|
|
||||||
|
- Configuration → **Break Rules** exists; the **Ontario** row shows 5h→30 / 10h→30, Default ticked.
|
||||||
|
- Attendances → create a manual attendance, check-in 09:00 check-out 15:00 (6h) → **Break = 30**, Net = 5.5h, with no clock action.
|
||||||
|
- Edit that record's check-out to 19:00 (10h) → **Break = 60**, Net = 9.0h.
|
||||||
|
- Create a 4h attendance → **Break = 0**.
|
||||||
|
- Settings → the old "Min. Shift" threshold field is gone; the Auto-Deduct Break help points to Break Rules.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update the module CLAUDE.md** — in `fusion_clock/CLAUDE.md`:
|
||||||
|
|
||||||
|
- §4 Model Map: add a row — `fusion.clock.break.rule | models/clock_break_rule.py | Per-province statutory unpaid-break thresholds (2-tier).`
|
||||||
|
- §5 Clocking Flow: note that the break deduction is no longer a controller step — `x_fclk_break_minutes` is a stored compute (`statutory(worked_hours) + Σ penalties`) that fires on every path including manual backend entry; resolved rule via `hr.employee._get_fclk_break_rule()` (company province → default).
|
||||||
|
- §11 Settings Keys: remove `fusion_clock.break_threshold_hours`.
|
||||||
|
- §13 Gotchas: add — "Unpaid break is computed, not written: never `write({'x_fclk_break_minutes': ...})`; change the province rule (`fusion.clock.break.rule`) or `auto_deduct_break` instead. Penalty minutes are now strictly additive (the old `max()` that swallowed late-in penalties is gone)."
|
||||||
|
- Bump the version line in §1 to `19.0.4.1.0`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the docs**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C "K:/Github/Odoo-Modules" add fusion_clock/CLAUDE.md
|
||||||
|
git -C "K:/Github/Odoo-Modules" commit -m "docs(fusion_clock): document province break rules + computed break field" -m "Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
git -C "K:/Github/Odoo-Modules" push
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Report** — summarize what changed, the behaviour-change note (penalties now additive), and that live deployment to entech (`odoo-entech`) is a separate step pending user sign-off.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (performed against the spec)
|
||||||
|
|
||||||
|
**1. Spec coverage**
|
||||||
|
- §4.1 model → Task 1. §4.2 resolver → Task 2. §4.3 stored compute → Task 3. §4.4 removals → Task 3 (writes) + Task 4 (setting/param/view). §4.5 UI/security/data → Task 1 (+ settings view in Task 4). §5 edge cases → tests in Tasks 1 & 3. §6 migration → Task 4. §7 tests → all six+ cases present across Tasks 1–3. §8 rollout → preamble + Task 5. ✓ No gaps.
|
||||||
|
|
||||||
|
**2. Placeholder scan** — every step has full code/commands; no TBD/TODO/"similar to". ✓
|
||||||
|
|
||||||
|
**3. Type/name consistency** — `break_minutes_for`, `_get_fclk_break_rule`, `_compute_fclk_break_minutes`, fields `break1_after_hours/break1_minutes/break2_after_hours/break2_minutes/is_default`, model `fusion.clock.break.rule`, access id `model_fusion_clock_break_rule`, action `action_fusion_clock_break_rule`, menu `menu_fusion_clock_break_rules` — all used identically across tasks. The compute folds `Σ penalty_minutes` (field `penalty_minutes` on `fusion.clock.penalty`, confirmed). ✓
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Accessibility Funding-Source Selector — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (inline) — this is a 3-file change. Steps use `- [ ]` checkboxes.
|
||||||
|
|
||||||
|
**Goal:** Let the rep mark an accessibility assessment's funding source (Private / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other) on the web form, so the generated sale order routes to the correct funding pipeline instead of always defaulting to private pay.
|
||||||
|
|
||||||
|
**Architecture:** The model (`fusion.accessibility.assessment.x_fc_funding_source`) and the SO routing (`_create_draft_sale_order` → `sale_type_map` → `x_fc_sale_type`) already exist (the "2026-04 portal audit fix"). The only gaps: (1) the form has no funding field, (2) the save controller never reads `funding_source` from the POST, (3) `hardship` is missing from the selectable funding sources. The submit JS already serialises every named form field via `FormData`, so no JS change is needed.
|
||||||
|
|
||||||
|
**Tech Stack:** Odoo 19, QWeb portal template, JSON-RPC controller. Module `fusion_portal` (worktree `K:\Github\Odoo-Modules-wt-portal`, branch `feat/assessment-visit`).
|
||||||
|
|
||||||
|
**Verification constraint:** `fusion_portal` depends on Enterprise `knowledge`, so it can NOT be installed on the local Community Docker. Syntax-check with host Python; functional verification is on westin (or a clone): pick "March of Dimes" on a form → the draft SO gets `x_fc_sale_type='march_of_dimes'` and lands in the MOD pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Hardship to the funding source + route it
|
||||||
|
|
||||||
|
**Files:** Modify `fusion_portal/models/accessibility_assessment.py` (selection ~:71-87, `sale_type_map` ~:771-779)
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add `('hardship', 'Hardship Funding')` to the `x_fc_funding_source` selection list (after `'wsib'`).
|
||||||
|
- [ ] **Step 2:** Add `'hardship': 'hardship',` to `sale_type_map` in `_create_draft_sale_order` (the target `x_fc_sale_type='hardship'` already exists in `fusion_claims` `sale_order.py:332`).
|
||||||
|
- [ ] **Step 3:** `python -m py_compile fusion_portal/models/accessibility_assessment.py` → no error.
|
||||||
|
- [ ] **Step 4:** Commit.
|
||||||
|
|
||||||
|
### Task 2: Add the funding select to the shared client-info form
|
||||||
|
|
||||||
|
**Files:** Modify `fusion_portal/views/portal_accessibility_templates.xml` (`accessibility_client_info_section`, ~:366-375)
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add a new row with a `<select name="funding_source">` (options mirror the model selection; `direct_private` pre-selected so existing private behaviour is unchanged) right after the phone/email row, before the card closes.
|
||||||
|
- [ ] **Step 2:** Validate XML well-formedness (`[xml]` parse).
|
||||||
|
- [ ] **Step 3:** Commit.
|
||||||
|
|
||||||
|
### Task 3: Capture funding_source in the save controller
|
||||||
|
|
||||||
|
**Files:** Modify `fusion_portal/controllers/portal_main.py` (`accessibility_assessment_save` vals, ~:2498-2511)
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Add `'x_fc_funding_source': post.get('funding_source') or 'direct_private',` to the `vals` dict.
|
||||||
|
- [ ] **Step 2:** `python -m pyflakes fusion_portal/controllers/portal_main.py` → no new undefined-name errors.
|
||||||
|
- [ ] **Step 3:** Commit.
|
||||||
|
|
||||||
|
### Task 4: Verify + ship
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Grep confirms `funding_source` flows form → controller → `x_fc_funding_source` → `sale_type_map`.
|
||||||
|
- [ ] **Step 2:** Deploy to westin (backup → scp the 3 files → `-u fusion_portal` → cache-bust → restart) and confirm: open `/my/accessibility/stairlift/straight`, pick "March of Dimes", complete → the new SO shows `x_fc_sale_type = march_of_dimes` and appears in the MOD pipeline.
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
# fusion_maintenance Foundation — Implementation Plan (Plan 1 of 5)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Confirming a sale of a maintainable product auto-creates a *priced* maintenance contract, and the due-reminder email shows the maintenance cost.
|
||||||
|
|
||||||
|
**Architecture:** Extend `fusion_repairs`. A maintenance **policy** (enabled / interval / flat fee) lives on `fusion.repair.product.category`, with a per-product fee/interval override on `product.template`. We fix the dead `_spawn_maintenance_contracts()` (anchor on delivery date, capture serial + fee + provenance, dedup) and call it from the **existing** `action_confirm()` override. The branded reminder email gains a fee line.
|
||||||
|
|
||||||
|
**Tech Stack:** Odoo 19 **Community**, Python, `TransactionCase`. Local dev: `docker odoo-modsdev-app`, DB `fusion-dev`.
|
||||||
|
|
||||||
|
**Spec:** [`2026-06-02-fusion-maintenance-design.md`](../specs/2026-06-02-fusion-maintenance-design.md). This is **Plan 1 of 5**; see the Roadmap at the bottom for Plans 2–5 (booking, visit log, backfill, office crons) — each is written when reached because it needs its own live-source reads (spec §15).
|
||||||
|
|
||||||
|
**Conventions (from CLAUDE.md):** new fields `x_fc_` prefix; Canadian English; Monetary = `$` + `currency_id`; declarative `models.Constraint` / `models.Index` (no `_sql_constraints`); `message_post` HTML wrapped in `Markup()`; `res.users` group field is `group_ids`.
|
||||||
|
|
||||||
|
**Run tests:**
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs \
|
||||||
|
-u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||||
|
```
|
||||||
|
|
||||||
|
**Grounding (verified source, 2026-06-02):**
|
||||||
|
- [`maintenance_contract.py`](../../../fusion_repairs/models/maintenance_contract.py) — contract model (fields end at `company_id`, line 81; `_booking_token_unique` constraint line 83); dead `_spawn_maintenance_contracts()` (line 198, anchors on `today`, dedups by partner/product/SO, no fee/serial/source).
|
||||||
|
- [`repair_product_category.py`](../../../fusion_repairs/models/repair_product_category.py) — category model; `safety_critical`, `equipment_class`; `_code_unique` constraint line 56.
|
||||||
|
- [`product_template.py`](../../../fusion_repairs/models/product_template.py) — `x_fc_repair_category_id` (line 11), `x_fc_maintenance_interval_months` (line 23, default 0).
|
||||||
|
- [`repair_service_plan.py`](../../../fusion_repairs/models/repair_service_plan.py) — **existing** `action_confirm()` override (line 229) ending `return res` (line 250); wire the maintenance spawn here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- **Modify** `fusion_repairs/models/repair_product_category.py` — add maintenance-policy fields + `currency_id`.
|
||||||
|
- **Modify** `fusion_repairs/models/product_template.py` — add `x_fc_maintenance_fee` override.
|
||||||
|
- **Modify** `fusion_repairs/models/maintenance_contract.py` — add contract fields + indexes; add `_fc_maintenance_anchor_date`; rewrite `_spawn_maintenance_contracts`.
|
||||||
|
- **Modify** `fusion_repairs/models/repair_service_plan.py` — call `self._spawn_maintenance_contracts()` inside `action_confirm`.
|
||||||
|
- **Modify** `fusion_repairs/data/mail_template_data.xml` — add a fee row to the reminder template.
|
||||||
|
- **Modify** `fusion_repairs/views/repair_product_category_views.xml` — expose the policy fields.
|
||||||
|
- **Create** `fusion_repairs/tests/__init__.py`, `fusion_repairs/tests/test_maintenance_foundation.py`.
|
||||||
|
- **Modify** `fusion_repairs/__manifest__.py` — bump `version` to `19.0.2.3.0`.
|
||||||
|
|
||||||
|
> **Scope note:** the technician-skill field (`x_fc_maintenance_skill_id`) is deferred to **Plan 2 (booking)** because skill matching is a booking concern and the exact skills representation is an open item (spec §15). Plan 1 is enrollment + pricing only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Maintenance policy fields on the equipment category
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_repairs/models/repair_product_category.py` (insert after `intake_template_id`, before `_code_unique` at line 56)
|
||||||
|
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the tests package + write the failing test**
|
||||||
|
|
||||||
|
Create `fusion_repairs/tests/__init__.py`:
|
||||||
|
```python
|
||||||
|
from . import test_maintenance_foundation
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `fusion_repairs/tests/test_maintenance_foundation.py`:
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo.tests import TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('post_install', '-at_install')
|
||||||
|
class TestMaintenanceFoundation(TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.partner = cls.env['res.partner'].create({'name': 'Mrs. Test Client'})
|
||||||
|
cls.category = cls.env['fusion.repair.product.category'].create({
|
||||||
|
'name': 'Stair Lift', 'code': 'stairlift',
|
||||||
|
'equipment_class': 'lift_elevating', 'safety_critical': True,
|
||||||
|
'x_fc_maintenance_enabled': True,
|
||||||
|
'x_fc_maintenance_interval_months': 6,
|
||||||
|
'x_fc_maintenance_fee': 149.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_category_policy_fields_exist(self):
|
||||||
|
self.assertTrue(self.category.x_fc_maintenance_enabled)
|
||||||
|
self.assertEqual(self.category.x_fc_maintenance_interval_months, 6)
|
||||||
|
self.assertEqual(self.category.x_fc_maintenance_fee, 149.0)
|
||||||
|
self.assertTrue(self.category.currency_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40
|
||||||
|
```
|
||||||
|
Expected: FAIL — `Invalid field 'x_fc_maintenance_enabled' on model 'fusion.repair.product.category'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the policy fields**
|
||||||
|
|
||||||
|
In `repair_product_category.py`, insert before the `_code_unique = models.Constraint(...)` line:
|
||||||
|
```python
|
||||||
|
# ── Maintenance policy (per equipment type) ──────────────────────────
|
||||||
|
x_fc_maintenance_enabled = fields.Boolean(
|
||||||
|
string='Offer Maintenance',
|
||||||
|
help='If set, units in this category are enrolled in recurring preventive '
|
||||||
|
'maintenance on sale (and via the backfill wizard).',
|
||||||
|
)
|
||||||
|
x_fc_maintenance_interval_months = fields.Integer(
|
||||||
|
string='Maintenance Interval (Months)', default=6,
|
||||||
|
help='Default months between preventive maintenance visits for this category. '
|
||||||
|
'Overridden by the product field of the same name when that is > 0.',
|
||||||
|
)
|
||||||
|
currency_id = fields.Many2one(
|
||||||
|
'res.currency', string='Currency',
|
||||||
|
default=lambda self: self.env.company.currency_id,
|
||||||
|
)
|
||||||
|
x_fc_maintenance_fee = fields.Monetary(
|
||||||
|
string='Maintenance Fee', currency_field='currency_id',
|
||||||
|
help='Flat fee shown to the client for a maintenance visit of this equipment type.',
|
||||||
|
)
|
||||||
|
x_fc_maintenance_service_product_id = fields.Many2one(
|
||||||
|
'product.product', string='Maintenance Service Product',
|
||||||
|
help='Optional product used when drafting the priced visit line (Plan 2). '
|
||||||
|
'Falls back to a generic visit product.',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run the same command as Step 2. Expected: `test_category_policy_fields_exist` PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add fusion_repairs/models/repair_product_category.py fusion_repairs/tests/
|
||||||
|
git commit -m "feat(fusion_repairs): maintenance policy fields on equipment category"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Per-product fee override
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_repairs/models/product_template.py` (after `x_fc_maintenance_interval_months`, line 28)
|
||||||
|
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test** (append to the test class)
|
||||||
|
```python
|
||||||
|
def test_product_fee_override_field_exists(self):
|
||||||
|
tmpl = self.env['product.template'].create({
|
||||||
|
'name': 'Handicare Freecurve Stairlift',
|
||||||
|
'x_fc_repair_category_id': self.category.id,
|
||||||
|
'x_fc_maintenance_fee': 199.0,
|
||||||
|
})
|
||||||
|
self.assertEqual(tmpl.x_fc_maintenance_fee, 199.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run the test command. Expected: FAIL — `Invalid field 'x_fc_maintenance_fee' on model 'product.template'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the field**
|
||||||
|
|
||||||
|
In `product_template.py`, after the `x_fc_maintenance_interval_months` field (line 28):
|
||||||
|
```python
|
||||||
|
x_fc_maintenance_fee = fields.Monetary(
|
||||||
|
string='Maintenance Fee (override)', currency_field='currency_id',
|
||||||
|
help='Per-product override of the category maintenance fee. 0 = use the category fee.',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
(`product.template` already provides `currency_id`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes** — `test_product_fee_override_field_exists` PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add fusion_repairs/models/product_template.py fusion_repairs/tests/test_maintenance_foundation.py
|
||||||
|
git commit -m "feat(fusion_repairs): per-product maintenance fee override"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Contract model extensions (fee, source, serial, policy)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_repairs/models/maintenance_contract.py` (add fields after `company_id`, line 81; add indexes near `_booking_token_unique`, line 83)
|
||||||
|
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
```python
|
||||||
|
def test_contract_extension_fields_exist(self):
|
||||||
|
c = self.env['fusion.repair.maintenance.contract'].create({
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'product_id': self.env['product.product'].create({'name': 'Unit'}).id,
|
||||||
|
'next_due_date': '2026-12-01',
|
||||||
|
'x_fc_source': 'sale',
|
||||||
|
'x_fc_device_serial': 'SN-123',
|
||||||
|
'x_fc_maintenance_fee': 149.0,
|
||||||
|
})
|
||||||
|
self.assertEqual(c.x_fc_source, 'sale')
|
||||||
|
self.assertEqual(c.x_fc_device_serial, 'SN-123')
|
||||||
|
self.assertEqual(c.x_fc_maintenance_fee, 149.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails** — `Invalid field 'x_fc_source' ...`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the fields + indexes**
|
||||||
|
|
||||||
|
In `maintenance_contract.py`, after the `company_id` field (line 81), before `_booking_token_unique`:
|
||||||
|
```python
|
||||||
|
currency_id = fields.Many2one(
|
||||||
|
'res.currency', default=lambda self: self.env.company.currency_id,
|
||||||
|
)
|
||||||
|
x_fc_maintenance_fee = fields.Monetary(
|
||||||
|
string='Maintenance Fee', currency_field='currency_id',
|
||||||
|
help='Flat fee shown to the client for this maintenance visit.',
|
||||||
|
)
|
||||||
|
x_fc_source = fields.Selection(
|
||||||
|
[('sale', 'New Sale'), ('backfill', 'Backfill'),
|
||||||
|
('claims', 'Claims Bridge'), ('manual', 'Manual')],
|
||||||
|
string='Source', default='manual', index=True,
|
||||||
|
)
|
||||||
|
x_fc_source_sale_line_id = fields.Many2one(
|
||||||
|
'sale.order.line', string='Source Sale Line', index=True, copy=False,
|
||||||
|
)
|
||||||
|
x_fc_device_serial = fields.Char(string='Serial (text)', index=True, copy=False)
|
||||||
|
x_fc_policy_category_id = fields.Many2one(
|
||||||
|
'fusion.repair.product.category', string='Maintenance Policy',
|
||||||
|
)
|
||||||
|
```
|
||||||
|
(Idempotency is enforced in Python — Task 4 — to support the two-regime dedup in spec §6.2; the `index=True` above covers lookups.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes** — `test_contract_extension_fields_exist` PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add fusion_repairs/models/maintenance_contract.py fusion_repairs/tests/test_maintenance_foundation.py
|
||||||
|
git commit -m "feat(fusion_repairs): maintenance contract fee/source/serial/policy fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Spawn priced contracts on sale confirm (fix the dead trigger + wire it)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_repairs/models/maintenance_contract.py` (rewrite `_spawn_maintenance_contracts`, lines 198-227; add `_fc_maintenance_anchor_date` helper)
|
||||||
|
- Modify: `fusion_repairs/models/repair_service_plan.py` (call it in `action_confirm`, before `return res` at line 250)
|
||||||
|
- Test: `fusion_repairs/tests/test_maintenance_foundation.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
```python
|
||||||
|
def _make_product(self, **kw):
|
||||||
|
vals = {'name': 'Stairlift Unit', 'type': 'consu',
|
||||||
|
'x_fc_repair_category_id': self.category.id}
|
||||||
|
vals.update(kw)
|
||||||
|
return self.env['product.product'].create(vals)
|
||||||
|
|
||||||
|
def _confirm_so(self, product, commitment='2026-01-10'):
|
||||||
|
so = self.env['sale.order'].create({
|
||||||
|
'partner_id': self.partner.id,
|
||||||
|
'commitment_date': commitment,
|
||||||
|
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 1})],
|
||||||
|
})
|
||||||
|
so.action_confirm()
|
||||||
|
return so
|
||||||
|
|
||||||
|
def _contracts_for(self, so):
|
||||||
|
return self.env['fusion.repair.maintenance.contract'].search(
|
||||||
|
[('original_sale_order_id', '=', so.id)])
|
||||||
|
|
||||||
|
def test_no_contract_when_category_not_maintainable(self):
|
||||||
|
cat = self.env['fusion.repair.product.category'].create(
|
||||||
|
{'name': 'Cane', 'code': 'cane', 'x_fc_maintenance_enabled': False})
|
||||||
|
so = self._confirm_so(self._make_product(x_fc_repair_category_id=cat.id))
|
||||||
|
self.assertFalse(self._contracts_for(so))
|
||||||
|
|
||||||
|
def test_contract_created_via_category_policy(self):
|
||||||
|
so = self._confirm_so(self._make_product())
|
||||||
|
contracts = self._contracts_for(so)
|
||||||
|
self.assertEqual(len(contracts), 1)
|
||||||
|
c = contracts
|
||||||
|
self.assertEqual(c.interval_months, 6)
|
||||||
|
self.assertEqual(c.x_fc_maintenance_fee, 149.0)
|
||||||
|
self.assertEqual(c.x_fc_source, 'sale')
|
||||||
|
self.assertEqual(c.x_fc_policy_category_id, self.category)
|
||||||
|
# anchor = commitment_date + 6 months
|
||||||
|
self.assertEqual(str(c.next_due_date), '2026-07-10')
|
||||||
|
|
||||||
|
def test_product_override_beats_category(self):
|
||||||
|
p = self._make_product()
|
||||||
|
p.product_tmpl_id.x_fc_maintenance_interval_months = 3
|
||||||
|
p.product_tmpl_id.x_fc_maintenance_fee = 199.0
|
||||||
|
so = self._confirm_so(p)
|
||||||
|
c = self._contracts_for(so)
|
||||||
|
self.assertEqual(c.interval_months, 3)
|
||||||
|
self.assertEqual(c.x_fc_maintenance_fee, 199.0)
|
||||||
|
|
||||||
|
def test_idempotent_on_reconfirm(self):
|
||||||
|
p = self._make_product()
|
||||||
|
so = self._confirm_so(p)
|
||||||
|
so._spawn_maintenance_contracts() # call again
|
||||||
|
self.assertEqual(len(self._contracts_for(so)), 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify they fail** — contracts not created (trigger not wired) → assertions fail.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Rewrite `_spawn_maintenance_contracts` + add the anchor helper**
|
||||||
|
|
||||||
|
Replace the body of `_spawn_maintenance_contracts` (lines 198-227) and add the helper, in the `SaleOrder` class of `maintenance_contract.py`:
|
||||||
|
```python
|
||||||
|
def _fc_maintenance_anchor_date(self, line):
|
||||||
|
"""Best-available delivery anchor: commitment_date -> date_order -> today.
|
||||||
|
(Non-ADP/lift units lack a delivery date; this fallback chain handles them.)"""
|
||||||
|
so = line.order_id
|
||||||
|
anchor = so.commitment_date or so.date_order
|
||||||
|
return fields.Date.to_date(anchor) if anchor else fields.Date.context_today(self)
|
||||||
|
|
||||||
|
def _spawn_maintenance_contracts(self):
|
||||||
|
"""Create a priced maintenance contract per maintainable unit on a confirmed SO.
|
||||||
|
Policy = product interval override, else the product's category policy.
|
||||||
|
Idempotent: by serial when captured, else by source sale line."""
|
||||||
|
Contract = self.env['fusion.repair.maintenance.contract'].sudo()
|
||||||
|
for so in self:
|
||||||
|
if so.state not in ('sale', 'done'):
|
||||||
|
continue
|
||||||
|
for line in so.order_line:
|
||||||
|
product = line.product_id
|
||||||
|
if not product:
|
||||||
|
continue
|
||||||
|
tmpl = product.product_tmpl_id
|
||||||
|
category = tmpl.x_fc_repair_category_id
|
||||||
|
product_interval = tmpl.x_fc_maintenance_interval_months or 0
|
||||||
|
cat_enabled = bool(category) and category.x_fc_maintenance_enabled
|
||||||
|
interval = product_interval or (
|
||||||
|
category.x_fc_maintenance_interval_months if cat_enabled else 0)
|
||||||
|
if interval <= 0 or not (product_interval > 0 or cat_enabled):
|
||||||
|
continue
|
||||||
|
fee = tmpl.x_fc_maintenance_fee or (
|
||||||
|
category.x_fc_maintenance_fee if category else 0.0)
|
||||||
|
# Capture serial only if fusion_claims' line field is present.
|
||||||
|
serial = ''
|
||||||
|
if 'x_fc_serial_number' in line._fields:
|
||||||
|
serial = (line.x_fc_serial_number or '').strip()
|
||||||
|
# Idempotency: serial regime vs source-line regime (spec §6.2).
|
||||||
|
if serial:
|
||||||
|
dedup = [('state', '=', 'active'), ('x_fc_device_serial', '=', serial)]
|
||||||
|
else:
|
||||||
|
dedup = [('state', '=', 'active'),
|
||||||
|
('x_fc_source_sale_line_id', '=', line.id)]
|
||||||
|
if Contract.search_count(dedup):
|
||||||
|
continue
|
||||||
|
anchor = so._fc_maintenance_anchor_date(line)
|
||||||
|
# One contract per serialized unit; without a serial, per quantity.
|
||||||
|
count = 1 if serial else max(int(line.product_uom_qty or 1), 1)
|
||||||
|
for _i in range(count):
|
||||||
|
Contract.create({
|
||||||
|
'partner_id': so.partner_id.id,
|
||||||
|
'product_id': product.id,
|
||||||
|
'original_sale_order_id': so.id,
|
||||||
|
'x_fc_source_sale_line_id': line.id,
|
||||||
|
'x_fc_source': 'sale',
|
||||||
|
'x_fc_device_serial': serial,
|
||||||
|
'x_fc_policy_category_id': category.id if category else False,
|
||||||
|
'interval_months': interval,
|
||||||
|
'x_fc_maintenance_fee': fee,
|
||||||
|
'next_due_date': anchor + relativedelta(months=interval),
|
||||||
|
'state': 'active',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wire it into the existing `action_confirm`**
|
||||||
|
|
||||||
|
In `repair_service_plan.py`, in `action_confirm`, change line 249-250 from:
|
||||||
|
```python
|
||||||
|
self._fc_spawn_labor_warranties()
|
||||||
|
return res
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```python
|
||||||
|
self._fc_spawn_labor_warranties()
|
||||||
|
self._spawn_maintenance_contracts()
|
||||||
|
return res
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run to verify the Task-4 tests pass** — all four PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
```bash
|
||||||
|
git add fusion_repairs/models/maintenance_contract.py fusion_repairs/models/repair_service_plan.py fusion_repairs/tests/test_maintenance_foundation.py
|
||||||
|
git commit -m "feat(fusion_repairs): spawn priced maintenance contracts on sale confirm"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Show the fee in the reminder email
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_repairs/data/mail_template_data.xml` (the `email_template_maintenance_due_reminder` record)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current template**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app sh -c "grep -n 'email_template_maintenance_due_reminder' /mnt/odoo-modules/fusion_repairs/data/mail_template_data.xml"
|
||||||
|
```
|
||||||
|
Then open that record's `<field name="body_html">` and find the equipment-name / due-date details table (the green-accent reminder).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a fee row to the details table**
|
||||||
|
|
||||||
|
Inside the details table of the reminder body, after the "Next due" row, add (Canadian English, `$` + currency):
|
||||||
|
```xml
|
||||||
|
<tr t-if="object.x_fc_maintenance_fee">
|
||||||
|
<td style="opacity:0.6;width:35%;">Maintenance fee</td>
|
||||||
|
<td><span t-field="object.x_fc_maintenance_fee"
|
||||||
|
t-options='{"widget": "monetary", "display_currency": object.currency_id}'/>
|
||||||
|
<span style="opacity:0.6;"> + applicable tax</span></td>
|
||||||
|
</tr>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Upgrade + manually verify the rendered email**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init
|
||||||
|
```
|
||||||
|
Then in odoo-shell render the template for a contract with a fee and confirm the fee line appears:
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app odoo shell -d fusion-dev --no-http <<'PY'
|
||||||
|
c = env['fusion.repair.maintenance.contract'].search([('x_fc_maintenance_fee','>',0)], limit=1)
|
||||||
|
tpl = env.ref('fusion_repairs.email_template_maintenance_due_reminder')
|
||||||
|
print('FEE' if 'applicable tax' in tpl._render_field('body_html', c.ids)[c.id] else 'MISSING')
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
Expected: `FEE`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
```bash
|
||||||
|
git add fusion_repairs/data/mail_template_data.xml
|
||||||
|
git commit -m "feat(fusion_repairs): show maintenance fee in due-reminder email"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Expose policy fields in the category form + bump version
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `fusion_repairs/views/repair_product_category_views.xml`
|
||||||
|
- Modify: `fusion_repairs/__manifest__.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the category form view**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app sh -c "grep -n 'fusion.repair.product.category' /mnt/odoo-modules/fusion_repairs/views/repair_product_category_views.xml | head"
|
||||||
|
```
|
||||||
|
Locate the `<form>` for the category.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a Maintenance group to the form**
|
||||||
|
|
||||||
|
Inside the category form sheet, add:
|
||||||
|
```xml
|
||||||
|
<group string="Maintenance Policy">
|
||||||
|
<field name="x_fc_maintenance_enabled"/>
|
||||||
|
<field name="x_fc_maintenance_interval_months"
|
||||||
|
invisible="not x_fc_maintenance_enabled"/>
|
||||||
|
<field name="x_fc_maintenance_fee"
|
||||||
|
invisible="not x_fc_maintenance_enabled"/>
|
||||||
|
<field name="x_fc_maintenance_service_product_id"
|
||||||
|
invisible="not x_fc_maintenance_enabled"/>
|
||||||
|
<field name="currency_id" invisible="1"/>
|
||||||
|
</group>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Bump the version**
|
||||||
|
|
||||||
|
In `fusion_repairs/__manifest__.py`, change `'version': '19.0.2.2.6',` to `'version': '19.0.2.3.0',`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Upgrade + run the full test module green**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -40
|
||||||
|
```
|
||||||
|
Expected: all `TestMaintenanceFoundation` tests PASS, 0 failures, module loads.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
```bash
|
||||||
|
git add fusion_repairs/views/repair_product_category_views.xml fusion_repairs/__manifest__.py
|
||||||
|
git commit -m "feat(fusion_repairs): category maintenance-policy UI + version 19.0.2.3.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review (against the spec)
|
||||||
|
|
||||||
|
- **Spec §2 D2 (flat fee per type):** Tasks 1-2 (policy on category + product override), Task 4 (fee snapshot on contract), Task 5 (fee in email). ✓
|
||||||
|
- **Spec §3.2 gap #1 (dead trigger):** Task 4 fixes + wires `_spawn_maintenance_contracts`. ✓
|
||||||
|
- **Spec §3.2 gap #3 (no cost shown):** Task 5. ✓
|
||||||
|
- **Spec §5.1 / §5.2 (policy + contract fields):** Tasks 1-3. ✓
|
||||||
|
- **Spec §6.1 (new-sale path, delivery anchor, idempotent, serial when present):** Task 4 (`_fc_maintenance_anchor_date`, two-regime dedup, guarded serial capture). ✓
|
||||||
|
- **Deferred to Plan 2:** `x_fc_maintenance_skill_id` (skills representation is §15 open item) — noted in File Structure.
|
||||||
|
- **No placeholders:** every code step shows complete code; the two "read first" steps (Tasks 5-6) target XML whose exact surrounding markup must be read live before editing, and give the exact snippet to insert.
|
||||||
|
- **Type consistency:** `x_fc_maintenance_fee` Monetary + `currency_id` used identically on category, product, contract; `_spawn_maintenance_contracts` / `_fc_maintenance_anchor_date` names consistent between maintenance_contract.py and the call site in repair_service_plan.py.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap — Plans 2–5 (write each when reached; each needs its own live-source reads per spec §15)
|
||||||
|
|
||||||
|
- **Plan 2 — Technician-aware booking** (the largest build): read `fusion_tasks/models/technician_task.py` `_find_next_available_slot` (line 544) / `_get_available_gaps` (line 664) signatures + working-hours source; add `x_fc_maintenance_skill_id` to the category and confirm the `res.users.x_fc_repair_skills` representation; replace the `<input type="date">` booking page with a real slot-picker controller; on confirm create a `fusion.technician.task` (`task_type='maintenance'`) + the maintenance `repair.order`; double-book guard; office "Book maintenance" action; per-cycle `booking_token` regen in `roll_next_due_date`. Delivers: real self-serve booking.
|
||||||
|
- **Plan 3 — Maintenance visit log + checklist**: read the visit-report wizard + the inspection-certificate (M1) API; add `fusion.repair.maintenance.visit` + `fusion.repair.maintenance.checklist.line`; seed checklists per category; issue an inspection certificate for `safety_critical` categories. Delivers: queryable per-unit history + compliance proof.
|
||||||
|
- **Plan 4 — Backfill wizard** (two-regime, spec §6.2): `fusion.repair.maintenance.backfill.wizard`; serial dedup for ADP wheelchairs (guarded `fusion_claims` read), partner+base-product+sale-line dedup for lifts with accessory-line exclusion; stagger; dry-run report → execute. Delivers: the existing install base enrolled.
|
||||||
|
- **Plan 5 — Office follow-up crons**: `unbooked` + `overdue` crons gated on the existing `ir.config_parameter` toggles; per-row savepoint isolation. Delivers: staff nudges when clients don't self-serve.
|
||||||
@@ -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`. ✔
|
||||||
@@ -109,7 +109,7 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
|
|||||||
| T1 | **Open in Maps button on task** | 2 | `geo:` / Apple Maps URL; one-tap |
|
| T1 | **Open in Maps button on task** | 2 | `geo:` / Apple Maps URL; one-tap |
|
||||||
| T2 | **AI pre-visit brief on mobile form** | 2 | Surfaces `x_fc_ai_summary` prominently; "What to bring" + safety flags |
|
| T2 | **AI pre-visit brief on mobile form** | 2 | Surfaces `x_fc_ai_summary` prominently; "What to bring" + safety flags |
|
||||||
| T3 | Labour timer via fusion_clock | 3 | Tap Start/Pause; final time pre-fills visit report |
|
| T3 | Labour timer via fusion_clock | 3 | Tap Start/Pause; final time pre-fills visit report |
|
||||||
| T4 | **Client signature on completion** | 2 | OWL signature pad on visit report wizard; attached to repair (pattern from [`fusion_authorizer_portal`](fusion_authorizer_portal)) |
|
| T4 | **Client signature on completion** | 2 | OWL signature pad on visit report wizard; attached to repair (pattern from [`fusion_portal`](fusion_portal)) |
|
||||||
| T5 | "Found another issue" button | 2 | Spawn new repair from current visit, same partner, different equipment |
|
| T5 | "Found another issue" button | 2 | Spawn new repair from current visit, same partner, different equipment |
|
||||||
| T6 | Parts replaced — serial capture | 3 | Scan/type replaced part serials; stores for OEM warranty + traceability |
|
| T6 | Parts replaced — serial capture | 3 | Scan/type replaced part serials; stores for OEM warranty + traceability |
|
||||||
| T7 | No-show photo proof | 3 | "Client not home" → camera → photo attached → repair flagged + service-call fee added |
|
| T7 | No-show photo proof | 3 | "Client not home" → camera → photo attached → repair flagged + service-call fee added |
|
||||||
@@ -164,11 +164,11 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
|
|||||||
| CL19 | **Voice input → AI transcription** | 4 | Client speaks the problem into mic, AI transcribes + classifies |
|
| CL19 | **Voice input → AI transcription** | 4 | Client speaks the problem into mic, AI transcribes + classifies |
|
||||||
| CL20 | **Resolution survey + Google review** | 2 | After "resolved" outcome, ask "save you time today?" + Google review CTA |
|
| CL20 | **Resolution survey + Google review** | 2 | After "resolved" outcome, ask "save you time today?" + Google review CTA |
|
||||||
|
|
||||||
### Sales rep portal (mirrors fusion_authorizer_portal pattern)
|
### Sales rep portal (mirrors fusion_portal pattern)
|
||||||
|
|
||||||
| ID | Feature | Phase | Notes |
|
| ID | Feature | Phase | Notes |
|
||||||
|----|---------|-------|-------|
|
|----|---------|-------|-------|
|
||||||
| S1 | **Sales rep web intake form** | 1 | `/my/repair/new` — same question flow as backend wizard, mobile-friendly. Reuses `is_sales_rep_portal` flag on `res.partner` from [`fusion_authorizer_portal/security/portal_security.xml`](fusion_authorizer_portal/security/portal_security.xml) line 11 |
|
| S1 | **Sales rep web intake form** | 1 | `/my/repair/new` — same question flow as backend wizard, mobile-friendly. Reuses `is_sales_rep_portal` flag on `res.partner` from [`fusion_portal/security/portal_security.xml`](fusion_portal/security/portal_security.xml) line 11 |
|
||||||
| S2 | Sales rep dashboard tile | 1 | Add "Service Calls" tile to `/my/sales-rep/dashboard` showing count of repairs they logged + recent 5 |
|
| S2 | Sales rep dashboard tile | 1 | Add "Service Calls" tile to `/my/sales-rep/dashboard` showing count of repairs they logged + recent 5 |
|
||||||
| S3 | **My Service Calls** list page | 1 | `/my/repairs` — sales rep sees their submitted repairs, status, assigned tech, scheduled date |
|
| S3 | **My Service Calls** list page | 1 | `/my/repairs` — sales rep sees their submitted repairs, status, assigned tech, scheduled date |
|
||||||
| S4 | View repair status from portal | 1 | `/my/repair/<id>` — read-only timeline, chatter for non-internal messages, ability to add a comment |
|
| S4 | View repair status from portal | 1 | `/my/repair/<id>` — read-only timeline, chatter for non-internal messages, ability to add a comment |
|
||||||
@@ -183,7 +183,7 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
|
|||||||
|
|
||||||
**Routing namespace:** `/my/repair/*` (intake + my list) and a `/my/sales-rep/repairs` summary route added to the existing sales rep dashboard.
|
**Routing namespace:** `/my/repair/*` (intake + my list) and a `/my/sales-rep/repairs` summary route added to the existing sales rep dashboard.
|
||||||
|
|
||||||
**Record rule** (mirrors [`fusion_authorizer_portal/security/portal_security.xml`](fusion_authorizer_portal/security/portal_security.xml) line 129 pattern):
|
**Record rule** (mirrors [`fusion_portal/security/portal_security.xml`](fusion_portal/security/portal_security.xml) line 129 pattern):
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<record id="rule_repair_order_sales_rep_portal" model="ir.rule">
|
<record id="rule_repair_order_sales_rep_portal" model="ir.rule">
|
||||||
@@ -236,7 +236,7 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
|
|||||||
'website', # QWeb portal templates
|
'website', # QWeb portal templates
|
||||||
'fusion_tasks', # technician tasks + fusion.email.builder.mixin
|
'fusion_tasks', # technician tasks + fusion.email.builder.mixin
|
||||||
'fusion_poynt', # payment collection
|
'fusion_poynt', # payment collection
|
||||||
'fusion_authorizer_portal', # sales rep portal flag + group + dashboard scaffold
|
'fusion_portal', # sales rep portal flag + group + dashboard scaffold
|
||||||
]
|
]
|
||||||
# Phase 3 soft-add: 'appointment', 'fusion_schedule' for client self-booking
|
# Phase 3 soft-add: 'appointment', 'fusion_schedule' for client self-booking
|
||||||
# Phase 3 soft-add: 'fusion_clock' for tech labour timer (T3)
|
# Phase 3 soft-add: 'fusion_clock' for tech labour timer (T3)
|
||||||
@@ -245,7 +245,7 @@ Every feature below has been accepted for inclusion (full scope). Phase assignme
|
|||||||
# Phase 3 soft-add: 'fusion_ringcentral' for SMS verify (CL12) + voicemail greeting (CL16) + caller-ID launch (Phase 4)
|
# Phase 3 soft-add: 'fusion_ringcentral' for SMS verify (CL12) + voicemail greeting (CL16) + caller-ID launch (Phase 4)
|
||||||
# Phase 4 soft-add: 'fusion_shipping', 'fusion_canada_post' for mail-in repairs (M4)
|
# Phase 4 soft-add: 'fusion_shipping', 'fusion_canada_post' for mail-in repairs (M4)
|
||||||
# Soft-call (no depend) at runtime: 'fusion.api.service' via try/except per fusion-api-integration rule
|
# Soft-call (no depend) at runtime: 'fusion.api.service' via try/except per fusion-api-integration rule
|
||||||
# NOTE: fusion_authorizer_portal transitively pulls fusion_claims — accepted for portal reuse
|
# NOTE: fusion_portal transitively pulls fusion_claims — accepted for portal reuse
|
||||||
```
|
```
|
||||||
|
|
||||||
Before coding any Odoo 19 view/JS, read reference files from local OrbStack Docker per project rules.
|
Before coding any Odoo 19 view/JS, read reference files from local OrbStack Docker per project rules.
|
||||||
@@ -712,7 +712,7 @@ Themes adapt via project SCSS rules — no hardcoded colours per CLAUDE.md.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sales rep portal (Phase 1 — mirrors fusion_authorizer_portal)
|
## Sales rep portal (Phase 1 — mirrors fusion_portal)
|
||||||
|
|
||||||
**Goal:** A sales rep on the road takes a client call and submits a repair request from their phone — same intake flow as backend CS wizard, no Odoo login screen.
|
**Goal:** A sales rep on the road takes a client call and submits a repair request from their phone — same intake flow as backend CS wizard, no Odoo login screen.
|
||||||
|
|
||||||
@@ -720,10 +720,10 @@ Themes adapt via project SCSS rules — no hardcoded colours per CLAUDE.md.
|
|||||||
|
|
||||||
| Option | Recommendation |
|
| Option | Recommendation |
|
||||||
|--------|----------------|
|
|--------|----------------|
|
||||||
| **Hard depend on `fusion_authorizer_portal`** | RECOMMENDED — reuses the existing `is_sales_rep_portal` flag, `group_sales_rep_portal`, sales rep dashboard scaffolding. Transitively pulls fusion_claims (already core in your stack). |
|
| **Hard depend on `fusion_portal`** | RECOMMENDED — reuses the existing `is_sales_rep_portal` flag, `group_sales_rep_portal`, sales rep dashboard scaffolding. Transitively pulls fusion_claims (already core in your stack). |
|
||||||
| Soft depend (try/except + own fallback flag) | Possible but doubles the code: own `is_sales_rep_portal` mirror + own group. Only worth it if you ever want fusion_repairs standalone. |
|
| Soft depend (try/except + own fallback flag) | Possible but doubles the code: own `is_sales_rep_portal` mirror + own group. Only worth it if you ever want fusion_repairs standalone. |
|
||||||
|
|
||||||
We go with hard depend. Add `fusion_authorizer_portal` to the manifest `depends` list.
|
We go with hard depend. Add `fusion_portal` to the manifest `depends` list.
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
@@ -740,7 +740,7 @@ flowchart LR
|
|||||||
|
|
||||||
### Controller layout ([`controllers/portal_sales_rep_repair.py`](fusion_repairs/controllers/portal_sales_rep_repair.py))
|
### Controller layout ([`controllers/portal_sales_rep_repair.py`](fusion_repairs/controllers/portal_sales_rep_repair.py))
|
||||||
|
|
||||||
Routes scoped to `is_sales_rep_portal` users (gate at controller top, pattern from [`fusion_authorizer_portal/controllers/portal_assessment.py`](fusion_authorizer_portal/controllers/portal_assessment.py) line 25):
|
Routes scoped to `is_sales_rep_portal` users (gate at controller top, pattern from [`fusion_portal/controllers/portal_assessment.py`](fusion_portal/controllers/portal_assessment.py) line 25):
|
||||||
|
|
||||||
| Route | Type | Purpose |
|
| Route | Type | Purpose |
|
||||||
|-------|------|---------|
|
|-------|------|---------|
|
||||||
@@ -767,14 +767,14 @@ Avoids the trap of two intake flows drifting out of sync.
|
|||||||
|
|
||||||
### Templates ([`views/portal_sales_rep_templates.xml`](fusion_repairs/views/portal_sales_rep_templates.xml))
|
### Templates ([`views/portal_sales_rep_templates.xml`](fusion_repairs/views/portal_sales_rep_templates.xml))
|
||||||
|
|
||||||
QWeb templates following [`fusion_authorizer_portal/views/portal_assessment_express.xml`](fusion_authorizer_portal/views/portal_assessment_express.xml) style:
|
QWeb templates following [`fusion_portal/views/portal_assessment_express.xml`](fusion_portal/views/portal_assessment_express.xml) style:
|
||||||
|
|
||||||
- `portal_repair_intake_form` — multi-step (accordion or stepper) with same 5 sections as backend wizard
|
- `portal_repair_intake_form` — multi-step (accordion or stepper) with same 5 sections as backend wizard
|
||||||
- `portal_repair_list` — card list with status badge, scheduled date, tech name
|
- `portal_repair_list` — card list with status badge, scheduled date, tech name
|
||||||
- `portal_repair_detail` — timeline + chatter
|
- `portal_repair_detail` — timeline + chatter
|
||||||
- `portal_repair_intake_thanks` — confirmation page with "Submit Another" button (common on multi-call days)
|
- `portal_repair_intake_thanks` — confirmation page with "Submit Another" button (common on multi-call days)
|
||||||
|
|
||||||
Reuses portal gradient/header style via `portal_gradient` template variable already set by [`portal_main.home()`](fusion_authorizer_portal/controllers/portal_main.py) line 85.
|
Reuses portal gradient/header style via `portal_gradient` template variable already set by [`portal_main.home()`](fusion_portal/controllers/portal_main.py) line 85.
|
||||||
|
|
||||||
### JS ([`static/src/js/portal_repair_intake.js`](fusion_repairs/static/src/js/portal_repair_intake.js))
|
### JS ([`static/src/js/portal_repair_intake.js`](fusion_repairs/static/src/js/portal_repair_intake.js))
|
||||||
|
|
||||||
@@ -860,7 +860,7 @@ Extend repair order form view with Intake tab (answers), Maintenance tab, and st
|
|||||||
|
|
||||||
**Reused (do NOT recreate):**
|
**Reused (do NOT recreate):**
|
||||||
- [`fusion_tasks.group_field_technician`](fusion_tasks/security/security.xml) — for technician access to `repair.order` (parallel to existing tech task rules). Same domain `('technician_id', '=', user.id)` adapted as `('x_fc_technician_task_ids.technician_id', '=', user.id)` on repair orders
|
- [`fusion_tasks.group_field_technician`](fusion_tasks/security/security.xml) — for technician access to `repair.order` (parallel to existing tech task rules). Same domain `('technician_id', '=', user.id)` adapted as `('x_fc_technician_task_ids.technician_id', '=', user.id)` on repair orders
|
||||||
- [`fusion_authorizer_portal.group_sales_rep_portal`](fusion_authorizer_portal/security/portal_security.xml) — for sales rep portal access (see Sales rep portal section)
|
- [`fusion_portal.group_sales_rep_portal`](fusion_portal/security/portal_security.xml) — for sales rep portal access (see Sales rep portal section)
|
||||||
|
|
||||||
**New groups specific to fusion_repairs:**
|
**New groups specific to fusion_repairs:**
|
||||||
- `group_fusion_repairs_user` — CS intake, view repairs (implied by `base.group_user`)
|
- `group_fusion_repairs_user` — CS intake, view repairs (implied by `base.group_user`)
|
||||||
@@ -896,7 +896,7 @@ Extend repair order form view with Intake tab (answers), Maintenance tab, and st
|
|||||||
|
|
||||||
**Sales rep portal (S1-S4, S6, S8):**
|
**Sales rep portal (S1-S4, S6, S8):**
|
||||||
- Portal controllers `/my/repair/new`, `/my/repairs`, `/my/repair/<id>`
|
- Portal controllers `/my/repair/new`, `/my/repairs`, `/my/repair/<id>`
|
||||||
- Mobile-friendly QWeb templates following [`fusion_authorizer_portal/views/portal_assessment_express.xml`](fusion_authorizer_portal/views/portal_assessment_express.xml) style
|
- Mobile-friendly QWeb templates following [`fusion_portal/views/portal_assessment_express.xml`](fusion_portal/views/portal_assessment_express.xml) style
|
||||||
- Same intake question flow as backend (via shared service layer)
|
- Same intake question flow as backend (via shared service layer)
|
||||||
- Mobile photo / camera capture
|
- Mobile photo / camera capture
|
||||||
- Client history sidebar exposed in portal form
|
- Client history sidebar exposed in portal form
|
||||||
@@ -1137,7 +1137,7 @@ After implementation, test on local dev only:
|
|||||||
| Backend wizard and sales rep portal drift apart | Both call the same `fusion.repair.intake.service.create_repair_orders(payload)` AbstractModel method; no duplicate business logic |
|
| Backend wizard and sales rep portal drift apart | Both call the same `fusion.repair.intake.service.create_repair_orders(payload)` AbstractModel method; no duplicate business logic |
|
||||||
| Sales rep accidentally sees other reps' repairs | Record rule `('x_fc_intake_user_id', '=', user.id)` scoped to `base.group_portal`; integration test asserts cross-rep isolation |
|
| Sales rep accidentally sees other reps' repairs | Record rule `('x_fc_intake_user_id', '=', user.id)` scoped to `base.group_portal`; integration test asserts cross-rep isolation |
|
||||||
| Portal form abandoned mid-flow on call drop | Save partial state to `localStorage` keyed by partner + timestamp; "Resume" prompt on `/my/repair/new` if recent draft exists |
|
| Portal form abandoned mid-flow on call drop | Save partial state to `localStorage` keyed by partner + timestamp; "Resume" prompt on `/my/repair/new` if recent draft exists |
|
||||||
| fusion_authorizer_portal install becomes mandatory | Documented in module description; if a deployment doesn't want fusion_authorizer_portal, fall back to a `fusion_repairs_portal_lite` companion module that recreates only the `is_sales_rep_portal` flag |
|
| fusion_portal install becomes mandatory | Documented in module description; if a deployment doesn't want fusion_portal, fall back to a `fusion_repairs_portal_lite` companion module that recreates only the `is_sales_rep_portal` flag |
|
||||||
| **Public form spam / abuse** | reCAPTCHA v3 + honeypot + per-IP rate limit + per-phone rate limit + SMS verify before submit (Phase 2). Block ASN ranges via Odoo's `ir.rule` if needed |
|
| **Public form spam / abuse** | reCAPTCHA v3 + honeypot + per-IP rate limit + per-phone rate limit + SMS verify before submit (Phase 2). Block ASN ranges via Odoo's `ir.rule` if needed |
|
||||||
| **AI giving unsafe medical advice** | Strict system prompt + JSON schema validation + keyword filter (rejects "diagnose", "you have", "stop using"); falls back to deterministic rules on any malformed/unsafe output; legal disclaimer "this is not medical advice" shown on every AI step |
|
| **AI giving unsafe medical advice** | Strict system prompt + JSON schema validation + keyword filter (rejects "diagnose", "you have", "stop using"); falls back to deterministic rules on any malformed/unsafe output; legal disclaimer "this is not medical advice" shown on every AI step |
|
||||||
| **AI cost runaway from public traffic** | Hard daily/monthly budget cap via `fusion.api.service`; CAPTCHA gates AI calls; cache results for identical symptom-category pairs; deterministic fallback never costs anything |
|
| **AI cost runaway from public traffic** | Hard daily/monthly budget cap via `fusion.api.service`; CAPTCHA gates AI calls; cache results for identical symptom-category pairs; deterministic fallback never costs anything |
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# Fusion Clock — Province-Aware Automatic Unpaid Break (2-tier)
|
||||||
|
|
||||||
|
- **Date:** 2026-05-31
|
||||||
|
- **Module:** `fusion_clock`
|
||||||
|
- **Version bump:** `19.0.4.0.3` → `19.0.4.1.0`
|
||||||
|
- **Status:** Approved design, pending implementation plan
|
||||||
|
- **Author:** Claude Code (brainstormed with user)
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
Statutory unpaid meal breaks are jurisdiction-driven: a break is required after N1
|
||||||
|
hours of work, and a second break after a higher N2 threshold. Ontario, for example:
|
||||||
|
a 30-minute eating period after 5 hours of work, and (per the user's policy) another
|
||||||
|
30 minutes after 10 hours. The deduction must be **automatic** and must apply on **every**
|
||||||
|
way an attendance is recorded — including a manager manually adding or editing hours.
|
||||||
|
|
||||||
|
### Audit of current behaviour (what exists today)
|
||||||
|
|
||||||
|
The deduction field is `hr.attendance.x_fclk_break_minutes` (minutes). Net hours are
|
||||||
|
`x_fclk_net_hours = worked_hours − x_fclk_break_minutes/60` (`models/hr_attendance.py:261`).
|
||||||
|
|
||||||
|
Break minutes are written from **four** places, all implementing variations of one rule:
|
||||||
|
|
||||||
|
1. `controllers/clock_api.py::_apply_break_deduction` (line 161) — on **clock-out**;
|
||||||
|
reused by the PIN kiosk (`controllers/clock_kiosk.py:158`) and NFC kiosk
|
||||||
|
(`controllers/clock_nfc_kiosk.py:381`). Logic: `if worked_hours >= break_threshold_hours`
|
||||||
|
(default **4.0h**) → set break to `employee._get_fclk_break_minutes()` (default **30**),
|
||||||
|
using `max(new, current)` so it doesn't wipe penalty minutes.
|
||||||
|
2. Auto-clock-out cron (`models/hr_attendance.py:343`) — same single-threshold write.
|
||||||
|
3. `controllers/clock_api.py::_check_and_create_penalty` (line 140) — **adds** penalty
|
||||||
|
minutes into the same `x_fclk_break_minutes` field.
|
||||||
|
|
||||||
|
### Gaps vs. requirement
|
||||||
|
|
||||||
|
1. **Single tier only** — one threshold (4h), one break (30m). No second break.
|
||||||
|
2. **Not applied on manual entry** — there is **no `create`/`write` override** on
|
||||||
|
`hr.attendance`. A manager-created or manager-edited attendance gets break `= 0`.
|
||||||
|
This is the central gap.
|
||||||
|
3. **No province/country awareness** — no jurisdiction field exists anywhere (location
|
||||||
|
has address/timezone but no province; company has none). Threshold + amount are flat
|
||||||
|
global config params.
|
||||||
|
4. **First-break default is 4h, not 5h** (Ontario is 5h).
|
||||||
|
|
||||||
|
## 2. Goals / Non-goals
|
||||||
|
|
||||||
|
**Goals**
|
||||||
|
- Statutory unpaid break applies automatically based on **actual worked hours**, on every
|
||||||
|
path (portal, systray, PIN kiosk, NFC kiosk, auto-clock-out cron, **and manual backend
|
||||||
|
create/edit**).
|
||||||
|
- Two tiers: first break after N1 hours, second break adds after N2 hours. Trigger is
|
||||||
|
`worked_hours >= N` (inclusive; nothing under N1).
|
||||||
|
- Rules are defined **per province/country** in a table; an employee resolves its rule
|
||||||
|
from its **company's province**, with a single global default fallback.
|
||||||
|
- **Eliminate the duplicated deduction logic** — one calculator, called everywhere.
|
||||||
|
|
||||||
|
**Non-goals (YAGNI)**
|
||||||
|
- Per-employee break-rule override (resolver is structured so this is a cheap add later).
|
||||||
|
- GPS/location-based jurisdiction detection.
|
||||||
|
- More than two tiers (the table is 2-tier; a 3rd break would be a future schema change).
|
||||||
|
- Changing the *planned* break concept used for scheduled-hours math.
|
||||||
|
|
||||||
|
## 3. Locked decisions
|
||||||
|
|
||||||
|
| # | Decision | Choice |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Rule model | **Per-province table**, 2-tier (`fusion.clock.break.rule`) |
|
||||||
|
| 2 | Jurisdiction source | **Company province** (`company_id.state_id`) + global default fallback |
|
||||||
|
| 3 | Override behaviour | **Fully automatic** — idempotent stored compute, recomputes on every save |
|
||||||
|
| 4 | Planned-vs-statute | **Statutory only** — the planned/scheduled break never affects the actual deduction |
|
||||||
|
|
||||||
|
## 4. Design
|
||||||
|
|
||||||
|
### 4.1 New model `fusion.clock.break.rule`
|
||||||
|
|
||||||
|
`models/clock_break_rule.py`, `_name = 'fusion.clock.break.rule'`,
|
||||||
|
`_description = 'Statutory Break Rule'`, `_order = 'sequence, name'`.
|
||||||
|
|
||||||
|
| Field | Type | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `name` | Char (required) | — | e.g. "Ontario" |
|
||||||
|
| `country_id` | Many2one `res.country` | — | scopes the province picker |
|
||||||
|
| `state_id` | Many2one `res.country.state` | — | the province; `domain` on `country_id` |
|
||||||
|
| `is_default` | Boolean | False | global fallback when no province matches |
|
||||||
|
| `break1_after_hours` | Float | 5.0 | first break trigger N1 |
|
||||||
|
| `break1_minutes` | Float | 30.0 | first break amount M1 (0 = disabled) |
|
||||||
|
| `break2_after_hours` | Float | 10.0 | second break trigger N2 |
|
||||||
|
| `break2_minutes` | Float | 30.0 | second break amount M2 (0 = disabled) |
|
||||||
|
| `sequence` | Integer | 10 | |
|
||||||
|
| `active` | Boolean | True | |
|
||||||
|
|
||||||
|
**Constraints** (`models.Constraint`, per repo Odoo-19 rule 9):
|
||||||
|
- `break1_after_hours >= 0`, `break2_after_hours >= 0`, minutes `>= 0`.
|
||||||
|
- When `break2_minutes > 0`: `break2_after_hours > break1_after_hours`
|
||||||
|
(a misordered second tier is a config error).
|
||||||
|
- (Soft) at most one `is_default = True` — enforced in a Python `@api.constrains`
|
||||||
|
rather than a partial unique index, to give a friendly message.
|
||||||
|
|
||||||
|
**Method** — `break_minutes_for(self, worked_hours)`:
|
||||||
|
```
|
||||||
|
self.ensure_one()
|
||||||
|
total = 0.0
|
||||||
|
if self.break1_minutes and worked_hours >= self.break1_after_hours:
|
||||||
|
total += self.break1_minutes
|
||||||
|
if self.break2_minutes and worked_hours >= self.break2_after_hours:
|
||||||
|
total += self.break2_minutes
|
||||||
|
return total
|
||||||
|
```
|
||||||
|
`>=` is intentional and matches the requirement ("equal to or more than N1").
|
||||||
|
|
||||||
|
**Seed** (`data/clock_break_rule_data.xml`, `noupdate="1"`): one row —
|
||||||
|
`name="Ontario"`, `state_id=base.state_ca_on`, `is_default=True`,
|
||||||
|
`break1_after_hours=5.0`, `break1_minutes=30.0`,
|
||||||
|
`break2_after_hours=10.0`, `break2_minutes=30.0`.
|
||||||
|
(Acting as both the Ontario match and the global fallback for this deployment.
|
||||||
|
Other provinces can be added as rows.)
|
||||||
|
|
||||||
|
### 4.2 Jurisdiction resolver — `hr.employee._get_fclk_break_rule()`
|
||||||
|
|
||||||
|
```
|
||||||
|
self.ensure_one()
|
||||||
|
Rule = self.env['fusion.clock.break.rule'].sudo()
|
||||||
|
state = self.company_id.state_id
|
||||||
|
rule = Rule.browse()
|
||||||
|
if state:
|
||||||
|
rule = Rule.search([('state_id', '=', state.id)], limit=1)
|
||||||
|
if not rule:
|
||||||
|
rule = Rule.search([('is_default', '=', True)], limit=1)
|
||||||
|
return rule # may be empty recordset → caller treats as 0 break
|
||||||
|
```
|
||||||
|
`sudo()` so the portal net-hours compute (run as the employee) can read the rule table
|
||||||
|
without a direct ACL grant. Resolver is a single method → adding a per-employee override
|
||||||
|
(`x_fclk_break_rule_id`) later is a two-line change.
|
||||||
|
|
||||||
|
### 4.3 `hr.attendance` — `x_fclk_break_minutes` becomes a stored compute
|
||||||
|
|
||||||
|
The field changes from a plain editable Float to a **stored computed** field — this is the
|
||||||
|
single calculator that replaces all four write sites.
|
||||||
|
|
||||||
|
```python
|
||||||
|
x_fclk_break_minutes = fields.Float(
|
||||||
|
string='Break (min)',
|
||||||
|
compute='_compute_fclk_break_minutes',
|
||||||
|
store=True,
|
||||||
|
tracking=True,
|
||||||
|
help="Unpaid break deducted from worked hours: statutory break (by province "
|
||||||
|
"rule, from actual hours worked) plus any penalty minutes.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('worked_hours', 'check_out',
|
||||||
|
'x_fclk_penalty_ids.penalty_minutes', 'employee_id')
|
||||||
|
def _compute_fclk_break_minutes(self):
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True'
|
||||||
|
for att in self:
|
||||||
|
statutory = 0.0
|
||||||
|
if auto and att.check_out and att.employee_id:
|
||||||
|
rule = att.employee_id._get_fclk_break_rule()
|
||||||
|
if rule:
|
||||||
|
statutory = rule.break_minutes_for(att.worked_hours or 0.0)
|
||||||
|
penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes'))
|
||||||
|
att.x_fclk_break_minutes = statutory + penalties
|
||||||
|
```
|
||||||
|
|
||||||
|
Properties:
|
||||||
|
- **Idempotent** — same hours + same penalties always yield the same value; no drift,
|
||||||
|
nothing to wipe.
|
||||||
|
- **Fires on every path** — `worked_hours` recomputes whenever `check_in`/`check_out`
|
||||||
|
change, so portal, kiosk, NFC, cron, **and manual backend create/edit** all recompute
|
||||||
|
automatically. This is what fixes the manual-entry gap.
|
||||||
|
- **Mid-shift = 0** — `check_out` empty → statutory 0 (penalties, if any, still counted).
|
||||||
|
- **Master toggle preserved** — `auto_deduct_break` False → statutory 0 (penalties remain).
|
||||||
|
- `_compute_net_hours` is unchanged (still `worked_hours − break/60`); it now depends on a
|
||||||
|
computed-stored field, which Odoo chains correctly.
|
||||||
|
|
||||||
|
The attendance form's Break field becomes read-only (consistent with "fully automatic").
|
||||||
|
`views/hr_attendance_views.xml` updated accordingly.
|
||||||
|
|
||||||
|
### 4.4 Removals (the de-duplication)
|
||||||
|
|
||||||
|
| Remove | File | Replaced by |
|
||||||
|
|---|---|---|
|
||||||
|
| `_apply_break_deduction` method + its 3 call sites | `controllers/clock_api.py:161`, `controllers/clock_kiosk.py:158`, `controllers/clock_nfc_kiosk.py:381` | the compute |
|
||||||
|
| cron's `x_fclk_break_minutes` write | `models/hr_attendance.py:343-346` | the compute |
|
||||||
|
| penalty's `current_break + deduction` write | `controllers/clock_api.py:140-144` | the compute's `Σ penalty_minutes` |
|
||||||
|
| setting `fclk_break_threshold_hours` + `fusion_clock.break_threshold_hours` | `models/res_config_settings.py:39`, seed in `data/ir_config_parameter_data.xml` | per-rule `break1_after_hours` |
|
||||||
|
|
||||||
|
**Kept and untouched:** `hr.employee._get_fclk_break_minutes()`, `fusion_clock.default_break_minutes`,
|
||||||
|
`fusion.clock.shift.break_minutes`, `fusion.clock.schedule.break_minutes` — these are the
|
||||||
|
**planned** break (used to compute scheduled `planned_hours`), a separate concept from the
|
||||||
|
actual worked-hours deduction. Decision #4 keeps them out of the deduction path.
|
||||||
|
|
||||||
|
**Kept:** the `auto_deduct_break` master toggle (now gates the statutory portion only).
|
||||||
|
|
||||||
|
### 4.5 UI / security / data
|
||||||
|
|
||||||
|
- **Menu:** *Fusion Clock → Configuration → Break Rules* (new `ir.actions.act_window` +
|
||||||
|
list/form views in `views/clock_break_rule_views.xml`), gated to
|
||||||
|
`group_fusion_clock_manager`. Add the menu item in `views/clock_menus.xml`.
|
||||||
|
- **Security:** `security/ir.model.access.csv` — `fusion.clock.break.rule`: manager =
|
||||||
|
full CRUD; team-lead/user = read (or none — the resolver uses sudo, so no direct grant
|
||||||
|
is strictly required; grant manager full, no portal access).
|
||||||
|
- **Manifest `data`:** add `data/clock_break_rule_data.xml` (after security, before crons)
|
||||||
|
and `views/clock_break_rule_views.xml` (with the other config views, before
|
||||||
|
`clock_menus.xml`). Bump `version` to `19.0.4.1.0`.
|
||||||
|
|
||||||
|
## 5. Edge cases
|
||||||
|
|
||||||
|
- **No rule resolvable** (no province match, no default) → statutory 0. The seeded default
|
||||||
|
prevents this in practice.
|
||||||
|
- **Company has no `state_id`** → falls to the default rule.
|
||||||
|
- **`break2_after_hours <= break1_after_hours`** → blocked by constraint.
|
||||||
|
- **Penalty created after clock-out** → `x_fclk_penalty_ids` change retriggers the compute;
|
||||||
|
final break = statutory + penalty (preserves today's combined-field semantics, reported
|
||||||
|
as one "Break" number).
|
||||||
|
- **Open attendance** (no checkout) → break 0; recomputed when it's closed.
|
||||||
|
- **Worked hours exactly at a boundary** (5.0h, 10.0h) → tier fires (`>=`).
|
||||||
|
|
||||||
|
## 6. Migration / upgrade
|
||||||
|
|
||||||
|
- On upgrade, flipping `x_fclk_break_minutes` to `store=True compute` makes Odoo recompute
|
||||||
|
it for all existing rows. For closed attendances this re-derives break from
|
||||||
|
`worked_hours` + linked penalties using the seeded Ontario rule — which is the intended
|
||||||
|
corrected value. Any historical hand-edited break values are replaced (acceptable per
|
||||||
|
Decision #3, "fully automatic"). Call this out in the change log.
|
||||||
|
- No `pre`/`post` migration script is required; the recompute is automatic. (If we later
|
||||||
|
want to *avoid* touching very old periods, a guarded post-migrate could pin them — out of
|
||||||
|
scope for now.)
|
||||||
|
|
||||||
|
## 7. Testing (`tests/test_break_rules.py`, `@tagged('-at_install','post_install','fusion_clock')`)
|
||||||
|
|
||||||
|
1. `break_minutes_for`: 4.99h→0, 5.0h→30, 9.99h→30, 10.0h→60.
|
||||||
|
2. Resolver: company in Ontario → Ontario rule; company with unset/other province → default.
|
||||||
|
3. **Manual backend create** of a closed attendance (check_in/out spanning 6h) → break 30,
|
||||||
|
net = worked − 0.5. **Manual edit** extending to 10h → break 60. (This is the headline
|
||||||
|
gap; assert it directly via `env['hr.attendance'].create(...)`, not via a controller.)
|
||||||
|
4. Penalty additivity: 6h + one 15-min penalty record → break 45.
|
||||||
|
5. Master toggle off (`auto_deduct_break=False`) → statutory 0 (penalty-only).
|
||||||
|
6. Constraint: `break2_after_hours <= break1_after_hours` raises.
|
||||||
|
|
||||||
|
Run (note ephemeral ports per repo CLAUDE.md):
|
||||||
|
```
|
||||||
|
docker exec odoo-modsdev-app odoo -d modsdev --test-enable --test-tags /fusion_clock \
|
||||||
|
-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Rollout notes
|
||||||
|
|
||||||
|
- **Dual-path write** during dev: edit files in **both** `K:\Github\odoo-modsdev\addons\fusion_clock`
|
||||||
|
(Docker-mounted, for tests) **and** `K:\Github\Odoo-Modules\fusion_clock` (git); commit
|
||||||
|
from the git path only. (Per project memory.)
|
||||||
|
- Live target is **entech** (`odoo-entech`); deploy after local tests pass and user review.
|
||||||
|
- Asset/version bump already covered by the manifest `version` change.
|
||||||
|
|
||||||
|
## 9. Open questions
|
||||||
|
|
||||||
|
None — all four design forks resolved (see §3).
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Assessment Visit — bundled, funding-routed assessments
|
||||||
|
|
||||||
|
**Date:** 2026-06-02
|
||||||
|
**Module:** `fusion_portal` (depends on `fusion_claims`, `fusion_tasks`); live on `odoo-westin` (DB `westin-v19`)
|
||||||
|
**Status:** Draft for review
|
||||||
|
**Author:** Brainstormed with Gurpreet (Fusion / Westin Healthcare)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem & goals
|
||||||
|
|
||||||
|
A sales rep visits a client's home **with an occupational therapist (OT) and the client present for only 30–45 minutes**, and the OT's time is the scarcest resource. In that window the team often does more than one assessment — a wheelchair (ADP) plus, opportunistically, accessibility products the rep spots (a ramp at the front steps, a stair lift inside, a tub cutout, a patient lift for transfers). Today each assessment is a **separate, standalone web form** that re-collects the client's details and creates its own sale order, and the front-end forms give the rep **no way to mark a case's funding source** — so March-of-Dimes work silently defaults to private pay and never reaches the MOD pipeline.
|
||||||
|
|
||||||
|
**Goals**
|
||||||
|
|
||||||
|
1. **One visit, many assessments, entered once.** Bundle every assessment from one home visit; capture the client + funding details a single time.
|
||||||
|
2. **Measurement-first.** Capture measurements while the OT is present; defer client/health-card data to after they leave; let the OT sign the ADP application on the spot.
|
||||||
|
3. **Add as you go.** The rep adds an assessment/product the instant they spot it — repeatable, with a location tag (Front / Back / Inside).
|
||||||
|
4. **Route by funding workflow.** On completion the visit emits **one sale order per funding workflow** (ADP, March of Dimes, ODSP, WSIB, private, …) — never one combined SO, and never a separate SO per item within the same funding.
|
||||||
|
5. **Let the rep set funding at assessment time** (the real MOD "tracking" gap).
|
||||||
|
6. **ADP multi-device** with valid-combination rules, including a new **mobility scooter** type and a **home-accessibility hard rule** for power mobility that feeds the accessibility upsell.
|
||||||
|
|
||||||
|
**Non-goals (v1):** voice/dictated entry; rebuilding the measurement math; a new MOD/ADP claim model (the pipelines already exist — we reuse them).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Current state (verified against source)
|
||||||
|
|
||||||
|
- **Two assessment models, already two separate SO lineages.** `fusion.assessment` (ADP: rollator/wheelchair/powerchair) and `fusion.accessibility.assessment` (the 7 lift/mod types) each have their own `_create_draft_sale_order` (`assessment.py:587`, `accessibility_assessment.py:751`), their own `x_fc_sale_type`, and their own state machine — ADP's 24-state `x_fc_adp_application_status` vs MOD's 16-state `x_fc_mod_status`. Each guards against a second SO (`accessibility_assessment.py:503-511`). SO back-links are **scalar** Many2one: `assessment_id`, `accessibility_assessment_id` (`fusion_portal/models/sale_order.py:37,48`).
|
||||||
|
- **SOs are born with no order lines.** Specs become a **chatter HTML note** (`_format_assessment_html_table`, `accessibility_assessment.py:815`); a human prices the draft afterward. **No per-type product mapping exists.**
|
||||||
|
- **Funding is modelled but not on the measurement forms.** `x_fc_funding_source` (required, default `direct_private`) on the accessibility model — values `march_of_dimes`, `odsp`, `wsib`, `insurance`, `direct_private`, `other` (`accessibility_assessment.py:71-87`) — is present on the public booking form but **absent from all 7 measurement forms**, so they default to private. Canonical billing type `sale.order.x_fc_sale_type` (`fusion_claims/models/sale_order.py:320`) carries the full set incl. `adp`, `adp_odsp`, `march_of_dimes`, etc.
|
||||||
|
- **MOD tracking already exists** as `x_fc_mod_status` (16 states) + ~60 `x_fc_mod_*` fields (HVMP reference #, vendor code, drawings, PCA, POD, approved/payment amounts, dated audit trail) + MOD views + ~7 wizards + ~40 MOD/ODSP stage emails (`fusion_claims/models/sale_order.py:438,877`). An accessibility assessment funded `march_of_dimes` already lands its SO in this pipeline at `need_to_schedule`. **The gap is purely that the rep can't choose `march_of_dimes` on the form.**
|
||||||
|
- **Emails** are mostly Python-built via the shared `fusion.email.builder.mixin._email_build` (`fusion_tasks/models/email_builder_mixin.py:8`), gated by `ir.config_parameter` `fusion_claims.enable_email_notifications`. Completion email fires from inside `_create_draft_sale_order` (`assessment.py:847`; `accessibility_assessment.py:624`). Stage emails (`_adp_send_stage_email`, `_mod_email_build`, `_odsp_email_build`) are keyed off the SO's funding type + status, so **they keep working per-SO unchanged**.
|
||||||
|
- **Known bug:** backend ADP `action_complete()` sends the authorizer **two** completion emails (template pair at `assessment.py:494` + inline report via `:847`). Must consolidate before fanning out across a visit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The design
|
||||||
|
|
||||||
|
### 3.1 The Visit aggregate (only net-new model)
|
||||||
|
|
||||||
|
`fusion.assessment.visit` — the hub for one home visit.
|
||||||
|
|
||||||
|
- **Client/context, entered once:** `partner_id`, address fields, `visit_date`, `sales_rep_id`, `authorizer_id` (OT), `x_fc_funding_source`-style default, `state` (`measuring` → `client_pending` → `done`).
|
||||||
|
- **Links to its assessments:** `adp_assessment_ids` (One2many → `fusion.assessment`) and `accessibility_assessment_ids` (One2many → `fusion.accessibility.assessment`). Each assessment gains `visit_id`.
|
||||||
|
- **Links to its sale orders:** `sale_order_ids` (One2many → `sale.order`) — one per funding workflow it produced.
|
||||||
|
- On the SO side, add `visit_id`. Each assessment already carries `sale_order_id` (Many2one — `accessibility_assessment.py:153`, `assessment.py:422`), so several same-funding assessments can already point at one SO; the redundant **scalar** `assessment_id` / `accessibility_assessment_id` on the SO (`fusion_portal/models/sale_order.py:37,48`) become **One2many** (or are dropped in favour of the `sale_order_id` reverse) so an SO no longer assumes a single source assessment.
|
||||||
|
|
||||||
|
Client info moves to the Visit as the single source of truth; the per-assessment `client_name`-required gate is relaxed (the model keeps the field for back-compat / standalone use but the Visit flow fills it from `partner_id`).
|
||||||
|
|
||||||
|
### 3.2 Add-as-you-go workspace (portal UX)
|
||||||
|
|
||||||
|
A portal "visit workspace" (reps are portal users, tablet-first):
|
||||||
|
|
||||||
|
- Always-present **"+ Add"** → pick a type + location tag (Front / Back / Inside / custom) → drop **straight into the existing measurement form** for that type. No client paperwork required to start.
|
||||||
|
- Each added assessment is a **card** showing type, location, status (To measure / Measured / Signed), and — once priced — its amount.
|
||||||
|
- **Measurement-first:** the forms render with client fields hidden/optional; a **deferred "Client + funding" step** is completed after the OT leaves and is shared by every item.
|
||||||
|
- The **OT signs the ADP application (Page 11)** inline on the wheelchair/ADP item, on-site, independent of client demographics (reuse `portal_assessment_express` Page-11 section + signature pad).
|
||||||
|
- Mockups (for reference, in repo `docs/mockups/` if committed): `fusion_portal_new_approach_mockup.html`.
|
||||||
|
|
||||||
|
### 3.3 Multi-instance + location tags
|
||||||
|
|
||||||
|
Any type can be added **more than once**, each its own assessment record with a **location label** ("Main stairs", "Basement", "Front porch"). Two stair lifts = two assessment records (→ two lines on the same funding SO; see §3.6). A **"Same as the previous"** action copies shared options so the rep only re-enters the differing measurements.
|
||||||
|
|
||||||
|
### 3.4 Per-item funding selector — the MOD gap fix
|
||||||
|
|
||||||
|
Expose `x_fc_funding_source` on **each accessibility assessment** in the flow: **Private Pay / March of Dimes / ODSP / WSIB / Hardship / Insurance / Other**. This one field drives the existing `sale_type_map` → `x_fc_sale_type` → correct pipeline (MOD 16-state tracker, ODSP, hardship, …). Defaults to the previous item's funding so an all-MOD visit isn't re-picked each time. **ADP/wheelchair items are fixed to ADP** (no picker). This is the minimal change that closes the "can't mark a case as March of Dimes" gap — no new tracking model.
|
||||||
|
|
||||||
|
> **Patient lift** is an accessibility/equipment item that uses this same picker — funded by March of Dimes, **ODSP**, or **Hardship** (e.g. Toronto residents), so its funding is chosen per case, not fixed.
|
||||||
|
> **`sale_type_map` gap:** `x_fc_funding_source` currently lacks `hardship` while `x_fc_sale_type` already has it (`sale_order.py:320`) — add `hardship` to the picker + a `sale_type_map` entry (`accessibility_assessment.py:771`), and review the map so every offered funding routes to a real `x_fc_sale_type`.
|
||||||
|
> **MOD funding cap** applies to MOD items — see Resolved decision 1 (§4).
|
||||||
|
|
||||||
|
### 3.5 ADP multi-device + combinations + scooter + home-access rule
|
||||||
|
|
||||||
|
**Multi-device ADP order.** Today one ADP device per order; the visit allows a **valid combination** of ADP devices for one client, all landing on the **one ADP SO**. Each ADP device is an item; the combination check runs across the visit's ADP items.
|
||||||
|
|
||||||
|
**Device categories:** Walker/Rollator · Manual Wheelchair · Power Wheelchair · **Scooter (new)**.
|
||||||
|
|
||||||
|
**Combination rules (confirmed):**
|
||||||
|
|
||||||
|
| Combination | Allowed? |
|
||||||
|
|---|---|
|
||||||
|
| Any single device | ✓ |
|
||||||
|
| Walker + Manual Wheelchair | ✓ |
|
||||||
|
| Walker + Power Wheelchair | ✓ |
|
||||||
|
| Walker + Scooter | ✓ |
|
||||||
|
| Manual + Power Wheelchair | ✗ |
|
||||||
|
| Power Wheelchair + Scooter | ✗ |
|
||||||
|
| Manual Wheelchair + Scooter | ✗ |
|
||||||
|
| Two walkers / any duplicate | ✗ |
|
||||||
|
|
||||||
|
Rule in words: **at most one "seated-mobility" device** {manual wheelchair, power wheelchair, scooter}, **optionally one walker/rollator alongside, no duplicates.** Enforced when adding/saving an ADP device.
|
||||||
|
|
||||||
|
**Scooter (new ADP type) fields:** `client_weight` (exists), scooter type, **maximum travel range**, and the home-accessibility check (below). Gets its own measurement section in the ADP form, mirroring the rollator/wheelchair/powerchair sections.
|
||||||
|
|
||||||
|
**Power-mobility home-accessibility hard rule.** For **scooter and power wheelchair**, a required check: *"Is the home accessible enough for the device to be used **inside and outside** the home independently — no lifting, not left outside/in the garage?"* ADP will not fund power mobility a home can't accommodate. If the answer is **No**, the visit **flags an accessibility need** and prompts the rep to add an accessibility item (ramp / porch lift, typically March of Dimes) to remediate. This is the explicit bridge between the ADP power-mobility item and the accessibility/MOD upsell.
|
||||||
|
|
||||||
|
> **The power-wheelchair form is already well-optimized — do NOT change its fields.** The *only* addition there is this home-accessibility warning. The new **scooter** type gets its own section (fields above); the manual-wheelchair and rollator sections are unchanged.
|
||||||
|
|
||||||
|
### 3.6 Funding-workflow grouping → one SO per workflow
|
||||||
|
|
||||||
|
On visit completion, group its assessments by **funding workflow** (`x_fc_sale_type`) and create **one SO per group**:
|
||||||
|
|
||||||
|
- All `march_of_dimes` items (stair lift + porch lift + tub cutout, or two stair lifts) → **one MOD SO, multiple lines** (funding permitting).
|
||||||
|
- All ADP devices (the valid combination) → **one ADP SO**.
|
||||||
|
- Private / ODSP / WSIB / insurance → their own SO each.
|
||||||
|
- A separate SO appears **only when the case type changes**, never per-item within a funding.
|
||||||
|
|
||||||
|
Refactor the two per-model `_create_draft_sale_order` routines into a **shared, group-aware builder** that takes a set of same-funding assessments and produces one SO, branching on funding type to stamp the right starting status field (`x_fc_adp_application_status` for ADP, `x_fc_mod_status` for MOD, etc. — mirroring `assessment.py:600-622`) and the right links. **Reuse the existing MOD/ADP/ODSP pipelines unchanged.**
|
||||||
|
|
||||||
|
### 3.7 Emails
|
||||||
|
|
||||||
|
- Reuse `fusion.email.builder.mixin` and the existing per-funding stage emails (they're keyed off SO type + status, so per-SO they keep working).
|
||||||
|
- **Move the completion send to per-SO** inside the new builder (not per-assessment), and **dedupe recipients**, so a 3-item visit doesn't emit 3–6 completion emails.
|
||||||
|
- **Fix the existing duplicate** (authorizer gets two completion emails on backend ADP completion) as part of this.
|
||||||
|
- Make `enable_email_notifications` gating consistent across the sends the visit touches.
|
||||||
|
|
||||||
|
### 3.8 Reused vs net-new
|
||||||
|
|
||||||
|
- **Reused, largely untouched:** the 7 accessibility measurement forms + their JS/Python calc; the ADP Express form + Page-11 signature; the MOD/ADP/ODSP pipelines, views, wizards, and stage emails; the email branding mixin.
|
||||||
|
- **Net-new:** the `fusion.assessment.visit` model + workspace UI; per-item funding selector on the accessibility forms; the group-aware SO builder + link-cardinality change; ADP multi-device + combination validation; scooter type + fields; power-mobility home-access rule + cross-sell flag; completion-email consolidation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Resolved decisions
|
||||||
|
|
||||||
|
1. **MOD funding cap — documented rule, light-touch in v1.** March of Dimes covers **up to $15,000 per person, lifetime**, income-gated: if the client's income is **under** that year's threshold (the threshold changes annually), MOD funds the full $15k; if **over**, MOD may **deny or partially approve**. **v1:** surface this cap as a reminder on MOD items and capture an *"income under MOD threshold? (yes / no / unknown)"* flag so the rep can judge — **do not** auto-compute lifetime used-vs-remaining across the client's prior MOD orders (the SO's existing `x_fc_mod_*` approved/payment fields already record per-order amounts). **Future:** yearly-threshold config + automatic lifetime-remaining tracking + a hard warning.
|
||||||
|
2. **No auto pricing / products in v1.** The visit creates a **draft** SO per funding workflow and appends each assessment's specs to that SO's chatter (today's pattern); **the sales rep builds the quotation lines manually.** One SO can hold many items. No per-assessment-type product mapping. (Auto-pricing is a future expansion.)
|
||||||
|
3. **Patient-lift funding is chosen per case** via the funding picker — March of Dimes, **ODSP**, or **Hardship** (e.g. Toronto residents) all fund it; it is not fixed (see §3.4).
|
||||||
|
4. **Power-wheelchair form unchanged** — already well-optimized; the only addition is the **home-accessibility warning** (device usable **inside and outside** the home). The home-access rule applies to **scooter (new type, new section) and power wheelchair (warning only)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phasing
|
||||||
|
|
||||||
|
- **Phase 1 — Funding correctness + visit backbone:** `fusion.assessment.visit`, link-cardinality change, **funding selector on the accessibility forms** (incl. Hardship; patient-lift routing), **MOD $15k-cap reminder + income-threshold flag** (informational), group-and-route to per-workflow **draft** SOs (specs to chatter, manual pricing) reusing existing pipelines, completion-email consolidation + duplicate fix. *(Delivers the MOD-routing fix and the multi-SO split.)*
|
||||||
|
- **Phase 2 — ADP expansion:** multi-device ADP order + combination validation, **scooter** type + fields, power-mobility **home-access hard rule** + accessibility cross-sell prompt.
|
||||||
|
- **Phase 3 — Seamless field UX:** the full add-as-you-go workspace, measurement-first deferral, location tags, "same as previous", OT on-site sign-off polish.
|
||||||
|
- **Later:** product-line auto-pricing, MOD funding-cap tracking, voice/quick entry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Risks (from investigation)
|
||||||
|
|
||||||
|
- **Duplicate completion emails** already live on the ADP backend path — fix before fan-out (§3.7).
|
||||||
|
- **Scalar back-links + double-SO guards** assume one SO per assessment; grouping breaks them — must move to `visit_id` / One2many and make the guard visit-aware.
|
||||||
|
- **Inconsistent `enable_email_notifications`** — template sends ignore the kill-switch; don't route new traffic through templates without honoring it.
|
||||||
|
- **Label drift** `x_fc_funding_source` vs `x_fc_sale_type` (`insurance`="Private Insurance" vs "Insurance"; `direct_private`="Private Pay (Direct)" vs "Direct/Private") — keys match so routing works; align labels in any shared UI.
|
||||||
|
- **Unreachable funding types from accessibility:** `sale_type_map` (`accessibility_assessment.py:771`) covers 6 values; decide which funding types each assessment type may emit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Files in scope
|
||||||
|
|
||||||
|
- `fusion_portal/models/assessment.py` — ADP `_create_draft_sale_order` (:587), completion email (:847), multi-device + scooter + home-access.
|
||||||
|
- `fusion_portal/models/accessibility_assessment.py` — accessibility `_create_draft_sale_order` (:751), `action_complete` (:493), completion email (:624), funding routing.
|
||||||
|
- `fusion_portal/models/sale_order.py` — back-links (:37,:48) → `visit_id` / One2many.
|
||||||
|
- `fusion_portal/models/visit.py` — **new** `fusion.assessment.visit`.
|
||||||
|
- `fusion_portal/views/portal_accessibility_forms.xml` + `portal_assessment_express.xml` — funding selector, scooter section, home-access check; workspace shell.
|
||||||
|
- `fusion_portal/controllers/portal_main.py` (`/my/accessibility/save` :2482) + `portal_assessment.py` — visit-aware save/group/route.
|
||||||
|
- `fusion_claims/models/sale_order.py` — reuse `x_fc_sale_type` (:320), `x_fc_mod_status` (:438), stage emails (:6876,:9038,:10063); no pipeline rebuild.
|
||||||
|
- `fusion_tasks/models/email_builder_mixin.py` — reuse for any new visit emails.
|
||||||
|
|
||||||
|
**Deployment note:** `fusion_portal` is live on `odoo-westin` (`westin-v19`, container `odoo-dev-app`). Ship per the rename/deploy procedure (backup → code sync → `-u fusion_portal` → cache-bust → restart → verify).
|
||||||
298
docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md
Normal file
298
docs/superpowers/specs/2026-06-02-fusion-maintenance-design.md
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
# fusion_maintenance — Design Spec
|
||||||
|
|
||||||
|
> Automated preventive‑maintenance follow‑ups + self‑serve real‑time booking for Westin
|
||||||
|
> medical mobility equipment (stair lifts, porch lifts, lift chairs, wheelchairs, power
|
||||||
|
> wheelchairs/scooters), to keep clients on schedule and turn service into recurring revenue.
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Status** | Design **approved** (brainstorm dialogue 2026‑06‑02). Ready for implementation plan. |
|
||||||
|
| **Implemented by** | **Extending `fusion_repairs`** (no new module). Version bump. |
|
||||||
|
| **Target instance** | Westin production — host `odoo-westin` (192.168.1.40), container `odoo-dev-app`, DB `westin-v19`. One company / one DB running `fusion_claims` (live) + `fusion_repairs` (to be deployed). |
|
||||||
|
| **Relates to** | [`docs/plans/fusion_maintenance_brainstorm.md`](../../plans/fusion_maintenance_brainstorm.md) (brief + Step 0 + sizing), [`2026-05-20-fusion-repairs-design.md`](2026-05-20-fusion-repairs-design.md) (base module). |
|
||||||
|
| **Next step** | `writing-plans` → implementation plan. **No code until the plan is written and this spec is reviewed.** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Westin sells/services mobility equipment that needs preventive maintenance every **1–6 months
|
||||||
|
depending on the product**. Today there is no system keeping clients on schedule. We want:
|
||||||
|
|
||||||
|
1. The system **automatically emails the client** when a unit is due for maintenance.
|
||||||
|
2. The client can **book the visit themselves** (real‑time, self‑serve, no login) **or** call the
|
||||||
|
office and staff book it for them.
|
||||||
|
3. The booking **lands in our scheduling/calendar** as a real technician job.
|
||||||
|
4. The **technician accesses and updates the maintenance log** on the visit; the system keeps the
|
||||||
|
full history per unit.
|
||||||
|
5. The **next maintenance is auto‑rescheduled** → recurring loop.
|
||||||
|
6. The client is **told the cost** up front.
|
||||||
|
7. Outcome: clients stay on track **and** Westin gains **recurring revenue**.
|
||||||
|
8. Design/UX stays **consistent with `fusion_claims`** (branded emails, `x_fc_` naming, Canadian
|
||||||
|
English, `$`+`currency_id`).
|
||||||
|
|
||||||
|
## 2. Locked decisions (from the brainstorm)
|
||||||
|
|
||||||
|
| # | Decision | Choice | Why |
|
||||||
|
|---|----------|--------|-----|
|
||||||
|
| D1 | Separate module vs. part of `fusion_repairs` | **Build into `fusion_repairs`** | The maintenance engine already lives there (~90% built); a separate module would duplicate it. fusion_repairs already owns the equipment categories, `repair.order`, technician tasks, service plans, and the Westin rate card. |
|
||||||
|
| D2 | Pricing / revenue model | **Flat fee per equipment type** | Transparent cost to show the client; recurring per‑visit revenue. Configured per equipment **category** with per‑product override. |
|
||||||
|
| D3 | Enrollment scope | **New sales + backfill existing install base** | The recurring revenue and "keep clients on track" value is in the *existing* base, not just future sales. |
|
||||||
|
| D4 | Booking engine | **Technician‑aware picker on `fusion_tasks`** (NOT Enterprise `appointment`) | Clients see only slots a qualified tech is genuinely free for (route/skill‑aware); booking creates the technician task directly — one scheduling world, no appointment↔task bridge. Bonus: **no Enterprise dependency → Community‑testable locally.** |
|
||||||
|
|
||||||
|
## 3. Grounding (verified, not assumed)
|
||||||
|
|
||||||
|
### 3.1 What `fusion_repairs` ALREADY has (reuse — do not rebuild)
|
||||||
|
Source: [`fusion_repairs/models/maintenance_contract.py`](../../../fusion_repairs/models/maintenance_contract.py), [`technician_task.py`](../../../fusion_repairs/models/technician_task.py), [`repair_service_plan.py`](../../../fusion_repairs/models/repair_service_plan.py), `cloud.md`.
|
||||||
|
|
||||||
|
- `fusion.repair.maintenance.contract` — partner/product/lot/original_SO, `interval_months`,
|
||||||
|
`last_service_date`, `next_due_date`, state machine (`draft/active/paused/cancelled`),
|
||||||
|
`booking_token` (unique), `last_reminder_band`, `booking_repair_id`. `roll_next_due_date()`
|
||||||
|
advances the cycle correctly via `relativedelta`.
|
||||||
|
- Reminder cron `cron_send_due_reminders` — daily, **30/7/1‑day** bands, per‑band dedup, queued
|
||||||
|
branded email `email_template_maintenance_due_reminder` with the tokenized link.
|
||||||
|
- Public booking controller `/repairs/maintenance/book/<token>` — `auth='public'`, token‑validated,
|
||||||
|
already‑booked guard, thanks page.
|
||||||
|
- `create_repair_from_booking()` — spawns a `repair.order` (`x_fc_intake_source='client_portal'`),
|
||||||
|
links `x_fc_maintenance_contract_id`, dedups.
|
||||||
|
- **Roll‑forward** on technician task completion ([`technician_task.py:88`](../../../fusion_repairs/models/technician_task.py:88)): when a `task_type='maintenance'` task → `status='completed'`, sets `last_service_date`, calls `roll_next_due_date()`, posts chatter. **This is the recurring loop.**
|
||||||
|
- Pre‑paid **service‑plan subscriptions** (`fusion.repair.service.plan.subscription`) wired to
|
||||||
|
`sale.order.action_confirm()` + visit burn engine (revenue primitive; optional here).
|
||||||
|
- **Rate card** (`fusion.repair.callout.rate`, standard vs `lift_elevating`), `repair.order.x_fc_quote_total`.
|
||||||
|
- **Equipment category taxonomy** (`fusion.repair.product.category`): stairlift / porch_lift /
|
||||||
|
lift_chair flagged `equipment_class=lift_elevating`, `safety_critical=True`.
|
||||||
|
- **Inspection certificate** (`fusion.repair.inspection.certificate`, M1 — Done): PDF + expiry cron.
|
||||||
|
- Visit‑report wizard (signature, parts, labour timer).
|
||||||
|
- `product.template.x_fc_maintenance_interval_months` (exists, [product_template.py:23](../../../fusion_repairs/models/product_template.py:23)).
|
||||||
|
- `fusion_tasks` availability engine: [`_find_next_available_slot(tech_id, date, ...)`](../../../fusion_tasks/models/technician_task.py:544) and [`_get_available_gaps(tech_id, date, ...)`](../../../fusion_tasks/models/technician_task.py:664) — **route‑aware** (tech start address + geocoding + travel). Tech skills on `res.users.x_fc_repair_skills`.
|
||||||
|
|
||||||
|
### 3.2 The 4 gaps this spec closes
|
||||||
|
1. **Contract auto‑creation trigger is dead code** — `_spawn_maintenance_contracts()` is defined on
|
||||||
|
`sale.order` ([maintenance_contract.py:198](../../../fusion_repairs/models/maintenance_contract.py:198)) but **never called**. No `action_confirm` override invokes it → no contracts exist today.
|
||||||
|
2. **No real booking** — the booking page is a bare `<input type="date">` ("a team member will call
|
||||||
|
to confirm"); no availability, no slots, no calendar/task. **This is the main new build.**
|
||||||
|
3. **No cost shown to the client** anywhere (email or booking page).
|
||||||
|
4. **No auto tech‑task creation, no structured maintenance log, no office‑follow‑up crons**
|
||||||
|
(`ir.config_parameter` toggles exist; no cron/Python).
|
||||||
|
|
||||||
|
### 3.3 Install‑base sizing (Westin live, 2026‑06‑02)
|
||||||
|
- Serial numbers are captured **~only on real equipment** (parts have 0 serials) → `x_fc_serial_number`
|
||||||
|
is a de‑facto "trackable unit" marker and the natural **idempotency key**.
|
||||||
|
- ADP‑side base ≈ **138 serial‑tracked units / ~136 customers** (walkers 68, wheelchairs 45, power
|
||||||
|
bases 7, scooters 4, +14 no‑device‑type). Funders: adp 109, direct_private 13, adp_odsp 10,
|
||||||
|
march_of_dimes 7. Deliveries 2022‑10 → 2026‑05.
|
||||||
|
- **Lifts (sized 2026‑06‑02; name‑based, approximate)** — a LARGE base in Westin's Odoo: stair lifts
|
||||||
|
~254 customers (416 lines incl. accessories), porch/VPL ~30 customers (75 lines), lift chairs ~41
|
||||||
|
customers (47 lines) — real products (Access BDD, Handicare, Serenity VPL, Pride VivaLift). **But lift
|
||||||
|
serial coverage is ~0** (12/416 stairlift lines, 0 VPL, 2 lift‑chair). So the serial‑as‑unit‑key
|
||||||
|
approach that works for ADP wheelchairs **does NOT work for lifts** — lifts must be keyed by
|
||||||
|
(partner + base‑unit product + sale line), excluding accessory lines (curves, rails, remotes, charging
|
||||||
|
stations, rentals). This splits the backfill into two regimes (§6.2).
|
||||||
|
- Two backfill data gaps: 14 units have no device_type (need product/manual category); non‑ADP units
|
||||||
|
lack `x_fc_adp_delivery_date` (need an invoice/order‑date fallback anchor).
|
||||||
|
|
||||||
|
## 4. Architecture
|
||||||
|
|
||||||
|
Extend `fusion_repairs`. No new module, no new top‑level dependency for the core flow (booking uses
|
||||||
|
`fusion_tasks`, already a hard dep; pricing/Poynt already deps). The optional `fusion_claims` read
|
||||||
|
for the wheelchair backfill is a **soft** dependency (guarded `if 'fusion.claims' model present`),
|
||||||
|
so `fusion_repairs` still installs/test‑runs without `fusion_claims` on local dev.
|
||||||
|
|
||||||
|
Reuse map: contract engine (extend), `fusion.technician.task` (booking target + availability +
|
||||||
|
roll‑forward), `repair.order` (visit container/pricing/Poynt), inspection certificate (lift
|
||||||
|
compliance), visit‑report wizard (extend with checklist), branded email pattern, rate card.
|
||||||
|
|
||||||
|
## 5. Data model
|
||||||
|
|
||||||
|
All new fields `x_fc_`, Canadian English labels, Monetary = `$` + `currency_id`.
|
||||||
|
|
||||||
|
### 5.1 Maintenance policy — on `fusion.repair.product.category` ("per equipment type")
|
||||||
|
- `x_fc_maintenance_enabled` (Boolean) — is this category maintainable?
|
||||||
|
- `x_fc_maintenance_interval_months` (Integer) — default cadence (1–6+).
|
||||||
|
- `x_fc_maintenance_fee` (Monetary, `currency_id`) — the **flat fee** shown to the client.
|
||||||
|
- `x_fc_maintenance_skill_id` — the technician skill the booking matches on (maps to
|
||||||
|
`res.users.x_fc_repair_skills`). **If skills are already category‑based** (a tech's
|
||||||
|
`x_fc_repair_skills` are equipment categories), drop this field and simply match technicians whose
|
||||||
|
skills include *this* category — confirm the skills representation before modelling (§15).
|
||||||
|
- `x_fc_maintenance_service_product_id` (M2O `product.product`, optional) — the service product used
|
||||||
|
when drafting the priced invoice/SO line; falls back to a generic "Maintenance visit" product.
|
||||||
|
|
||||||
|
**Per‑product override:** `product.template.x_fc_maintenance_interval_months` (exists) +
|
||||||
|
new `product.template.x_fc_maintenance_fee` (Monetary, optional). Resolution order at contract
|
||||||
|
creation: product override → category policy.
|
||||||
|
|
||||||
|
### 5.2 Extend `fusion.repair.maintenance.contract`
|
||||||
|
- `x_fc_maintenance_fee` (Monetary) — resolved price snapshot, shown to client.
|
||||||
|
- `x_fc_source` (Selection: `sale` / `backfill` / `claims` / `manual`).
|
||||||
|
- `x_fc_source_sale_line_id` (M2O `sale.order.line`) — provenance + idempotency.
|
||||||
|
- `x_fc_device_serial` (Char, indexed) — idempotency key (esp. for claims/backfill where no lot).
|
||||||
|
- `x_fc_policy_category_id` (M2O `fusion.repair.product.category`).
|
||||||
|
- Constraint: at most one **active** contract per `(x_fc_device_serial)` (or per source sale line
|
||||||
|
when serial absent) — declarative `models.Constraint` / partial `models.Index`.
|
||||||
|
|
||||||
|
### 5.3 New `fusion.repair.maintenance.visit` (the log)
|
||||||
|
A structured, queryable per‑visit record — *not* buried in chatter.
|
||||||
|
- `contract_id` (M2O, required), `technician_task_id` (M2O `fusion.technician.task`),
|
||||||
|
`repair_order_id` (M2O `repair.order`, the container), `partner_id`, `product_id`, `lot_id`.
|
||||||
|
- `visit_date`, `technician_id` (res.users), `state` (`scheduled/in_progress/done/no_show/cancelled`).
|
||||||
|
- `checklist_line_ids` (O2M to `fusion.repair.maintenance.checklist.line`: label, result
|
||||||
|
`pass/fail/na`, note) — items seeded **per equipment category** (lift checklist ≠ wheelchair
|
||||||
|
checklist).
|
||||||
|
- `findings` (Html, `Markup()`), `parts_note`, `x_fc_fee` (Monetary), `signature` (Binary),
|
||||||
|
`inspection_certificate_id` (M2O — set for `safety_critical` categories).
|
||||||
|
- "log/history" view = the list of visits per contract/unit (smart button on contract + partner).
|
||||||
|
|
||||||
|
## 6. Enrollment — two paths
|
||||||
|
|
||||||
|
### 6.1 Path A — new sales (fix the dead trigger)
|
||||||
|
Override `sale.order.action_confirm()` to call `_spawn_maintenance_contracts()` (reuse the existing
|
||||||
|
method; fix + wire it). For each confirmed line whose product/category has
|
||||||
|
`x_fc_maintenance_enabled` and a serial/lot:
|
||||||
|
- Create one `active` contract per unit (respect quantity), `x_fc_source='sale'`,
|
||||||
|
`x_fc_source_sale_line_id` set, serial captured.
|
||||||
|
- `next_due_date = (delivery/commitment date or date_order) + interval` (fallback chain handles
|
||||||
|
non‑ADP units lacking a delivery date).
|
||||||
|
- Resolve + snapshot `x_fc_maintenance_fee`.
|
||||||
|
- **Idempotent**: skip if an active contract already exists for the serial / sale line.
|
||||||
|
|
||||||
|
### 6.2 Path B — backfill existing install base (one‑time wizard, idempotent)
|
||||||
|
`fusion.repair.maintenance.backfill.wizard`:
|
||||||
|
- **Scan** historical `sale.order.line` for products whose category/product is maintenance‑enabled and
|
||||||
|
were delivered. **Two unit‑identity regimes**, because lifts carry no serials (§3.3):
|
||||||
|
- **Serial‑tracked** (ADP wheelchairs/power chairs, via the `fusion_claims` serial/`device_type` data
|
||||||
|
— soft dep, guarded; map ADP `device_type` → maintenance category): require a serial, **dedup by serial**.
|
||||||
|
- **Non‑serial** (lifts — stair/porch/VPL/lift‑chair): do **NOT** require a serial. One contract per
|
||||||
|
**base‑unit line**, **dedup by (partner + maintainable product + source sale line)**. The per‑product
|
||||||
|
`x_fc_maintenance_enabled` flag is what includes base units and **excludes accessory lines** (curves,
|
||||||
|
rails, remotes, charging stations, rentals) — only the lift itself gets a contract, not its add‑ons.
|
||||||
|
- **Stagger** the first `next_due_date` across a configurable window (e.g. spread overdue units over
|
||||||
|
N weeks) so years of equipment don't all email on day one.
|
||||||
|
- **Dry‑run first**: produce a report (counts by category, # new vs already‑enrolled, # skipped for
|
||||||
|
missing serial/date, the stagger schedule). Nothing is created or emailed until the operator
|
||||||
|
approves and runs "Execute".
|
||||||
|
- Anchor fallback for units with no delivery date: invoice date → order date → today.
|
||||||
|
|
||||||
|
## 7. Booking flow (the main build)
|
||||||
|
|
||||||
|
### 7.1 Client self‑serve (no login)
|
||||||
|
1. Reminder email (existing branded template, **+ fee line added**) → tokenized link.
|
||||||
|
2. Public slot‑picker page (extend the existing `/repairs/maintenance/book/<token>` route; replace
|
||||||
|
the date input). The page:
|
||||||
|
- Resolves the contract from the token; shows unit + **flat fee** ("$X + applicable tax").
|
||||||
|
- Computes candidate technicians = users whose `x_fc_repair_skills` include the policy's
|
||||||
|
`x_fc_maintenance_skill_id`.
|
||||||
|
- Calls `fusion_tasks` `_get_available_gaps` / `_find_next_available_slot` per candidate tech over
|
||||||
|
the next ~2–3 weeks, ranked by **proximity** to the client address → presents a short list of
|
||||||
|
real open slots (date + window + implied tech).
|
||||||
|
3. Client picks a slot → POST confirm:
|
||||||
|
- **Re‑validate** the slot is still free (gap check) — if taken/expired, re‑render slots with a
|
||||||
|
gentle notice (prevents double‑booking).
|
||||||
|
- Create a `fusion.technician.task` (`task_type='maintenance'`) on that slot, **assigned to the
|
||||||
|
qualified tech** (auto‑assignment by availability+skill), linked to the contract.
|
||||||
|
- Spawn/link the maintenance‑type `repair.order` (container) + the `fusion.repair.maintenance.visit`
|
||||||
|
(state `scheduled`, checklist seeded from the category).
|
||||||
|
- Send the branded confirmation email (date/window/tech, fee, what to expect).
|
||||||
|
- Set `booking_repair_id` (dedup).
|
||||||
|
4. **No‑slot fallback:** if no qualified tech/slot in range → show "request a callback" → create an
|
||||||
|
office activity. Never a dead end.
|
||||||
|
|
||||||
|
### 7.2 Office books on the client's behalf
|
||||||
|
- A **"Book maintenance"** action on the `fusion.repair.maintenance.contract` form opens the same
|
||||||
|
slot‑picker logic in the backend (office books while on the phone).
|
||||||
|
- The existing dispatch board remains available for manual scheduling/override.
|
||||||
|
|
||||||
|
### 7.3 Token security fix
|
||||||
|
On `roll_next_due_date()`, **regenerate `booking_token`** (currently it is not regenerated, so an
|
||||||
|
old link stays valid across cycles). Old token → friendly "link expired" page.
|
||||||
|
|
||||||
|
## 8. Cost & revenue
|
||||||
|
|
||||||
|
- The **flat fee** (`x_fc_maintenance_fee`) is shown in **both** the reminder email and the
|
||||||
|
slot‑picker page, Canadian English, `$` + tax note.
|
||||||
|
- On booking, draft a priced line (SO/invoice) using `x_fc_maintenance_service_product_id` (or the
|
||||||
|
generic visit product) at the contract's fee. Payment options: **pay‑at‑door via `fusion_poynt`**
|
||||||
|
(existing `action_collect_payment` on the repair) or invoice after the visit.
|
||||||
|
- Recurring revenue = one priced visit per cycle; the roll‑forward arms the next cycle automatically.
|
||||||
|
(Pre‑paid annual plan upsell via the existing subscription engine is out of v1 — §11.)
|
||||||
|
|
||||||
|
## 9. Maintenance log & the recurring loop
|
||||||
|
|
||||||
|
- The technician fills the visit via the **extended visit‑report wizard** (existing tool) — checklist
|
||||||
|
results, findings, parts, signature — which writes the `fusion.repair.maintenance.visit` record.
|
||||||
|
- For `safety_critical` categories (lifts), completing the visit **issues an inspection certificate**
|
||||||
|
(reuse M1) and links it on the visit — the log doubles as compliance proof.
|
||||||
|
- On task `status='completed'` → existing **roll‑forward**: `last_service_date=today`,
|
||||||
|
`next_due_date += interval`, reset `last_reminder_band`, **regenerate token**, visit → `done`.
|
||||||
|
- Next cycle's reminder fires automatically when `next_due_date` re‑enters the 30‑day band.
|
||||||
|
|
||||||
|
## 10. Office follow‑up crons (toggle‑gated, exist as config only today)
|
||||||
|
- **Unbooked**: reminder sent, no booking after N days → office call activity on the contract.
|
||||||
|
- **Overdue**: `next_due_date` passed with no completed visit in the cycle → escalation activity.
|
||||||
|
- Driven by the existing `ir.config_parameter` toggles in `data/ir_config_parameter_data.xml`.
|
||||||
|
- Per‑row **savepoint** isolation inside the cron loop (no `cr.commit()` in tests — CLAUDE.md #14).
|
||||||
|
|
||||||
|
## 11. Out of scope (v1 — YAGNI)
|
||||||
|
- SMS reminders / two‑way SMS booking (needs `fusion_ringcentral`).
|
||||||
|
- Logged‑in `/my/equipment` client portal (X5).
|
||||||
|
- Pre‑paid annual maintenance‑plan auto‑upsell at booking.
|
||||||
|
- Full multi‑stop route optimization / batching (we use per‑tech availability + proximity ranking,
|
||||||
|
not a global optimizer).
|
||||||
|
- ADP funder re‑billing of maintenance (maintenance is private‑pay flat fee in v1).
|
||||||
|
|
||||||
|
## 12. Error handling & edge cases
|
||||||
|
- **Double‑booking:** re‑validate the gap at confirm; lose the race → re‑show slots.
|
||||||
|
- **Token:** per‑cycle regeneration; invalid/expired/already‑booked → friendly pages (exist, extend).
|
||||||
|
- **No qualified tech / no slots:** callback fallback, not an error page.
|
||||||
|
- **Backfill:** dry‑run + report; strict serial dedup; stagger; fallback anchor chain; never email on
|
||||||
|
dry‑run.
|
||||||
|
- **Missing data:** units with no device_type/category → excluded from auto‑backfill, listed in the
|
||||||
|
report for manual enrollment.
|
||||||
|
- **Audit on failure paths** (if any "booking failed" row is written in an `except`): use a separate
|
||||||
|
`self.env.registry.cursor()` so it survives rollback (CLAUDE.md audit rule).
|
||||||
|
- **`message_post` HTML** bodies wrapped in `Markup()` (CLAUDE.md).
|
||||||
|
|
||||||
|
## 13. Testing
|
||||||
|
`fusion_repairs/tests/` (none exist today). Local dev is **Community** and — because we chose
|
||||||
|
`fusion_tasks` over Enterprise `appointment` — the **entire feature is Community‑testable** on
|
||||||
|
`odoo-modsdev`. `TransactionCase` coverage:
|
||||||
|
- Contract spawn on `sale.order` confirm (enabled vs disabled category; quantity; idempotency).
|
||||||
|
- Backfill wizard: **two‑regime dedup** (serial for wheelchairs; partner+product+line for lifts), accessory‑line exclusion, stagger, dry‑run produces no records, anchor fallback.
|
||||||
|
- Booking: slot list comes from real gaps; confirm creates task+repair+visit; **double‑book guard**;
|
||||||
|
no‑slot fallback.
|
||||||
|
- Roll‑forward on completion: dates advance, band reset, **token regenerated**, visit → done.
|
||||||
|
- Crons: reminder bands; unbooked/overdue follow‑ups (savepoint isolation).
|
||||||
|
- Run: `docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_repairs -u fusion_repairs --stop-after-init --http-port=0 --gevent-port=0`.
|
||||||
|
|
||||||
|
## 14. Deployment & configuration
|
||||||
|
1. Land on local dev, full E2E + tests green.
|
||||||
|
2. **Deploy `fusion_repairs` to Westin** (`odoo-westin` / `westin-v19`) — the accepted bigger lift
|
||||||
|
(first production deploy of fusion_repairs; verify rate‑card numbers, ACLs, asset bundles).
|
||||||
|
3. **Configure** maintainable categories: `x_fc_maintenance_enabled`, interval, fee, skill, service
|
||||||
|
product — for lifts (stairlift/porch/lift chair) + power & manual wheelchairs.
|
||||||
|
4. Ensure technicians have `x_fc_repair_skills` + start addresses (for availability/routing).
|
||||||
|
5. Run the **backfill wizard dry‑run → review report → execute** (staggered).
|
||||||
|
6. Watch the first reminder/booking cycle; confirm emails, slots, task creation, completion → roll.
|
||||||
|
|
||||||
|
## 15. Open items to verify at implementation (rule #1 — read live source)
|
||||||
|
- Exact representation of tech skills (`res.users.x_fc_repair_skills`) and how a category's required
|
||||||
|
skill maps to it (Selection vs M2O vs tag) — read fusion_repairs/fusion_tasks before modelling
|
||||||
|
`x_fc_maintenance_skill_id`.
|
||||||
|
- Signatures of `_find_next_available_slot` / `_get_available_gaps` (params, return shape, working
|
||||||
|
hours source) and whether they already account for travel windows.
|
||||||
|
- The visit‑report wizard's current fields/flow before extending it with the checklist.
|
||||||
|
- The inspection‑certificate issue API (how M1 creates a certificate) for the lift link.
|
||||||
|
- **Lift base sized** (§3.3): ~254 stairlift + ~30 porch/VPL + ~41 lift‑chair customers, but ~0 serials.
|
||||||
|
Still to verify: which exact products are **base units vs accessories** (so `x_fc_maintenance_enabled`
|
||||||
|
lands on base units only), plus the lift interval/fee per category. Lift products aren't yet tagged
|
||||||
|
with `fusion_repairs` categories on Westin (module not deployed there) — categorization is a deploy step.
|
||||||
|
- `fusion_claims` device_type → maintenance‑category mapping table for the wheelchair backfill.
|
||||||
|
|
||||||
|
## 16. Build sequence (for the implementation plan)
|
||||||
|
1. **Policy + fee data model** (category fields, product override, contract extensions, constraints).
|
||||||
|
2. **Path A trigger** (wire `_spawn_maintenance_contracts` into `action_confirm`, fee resolution, anchor fallback) + tests.
|
||||||
|
3. **Cost in email** (add fee to the reminder template).
|
||||||
|
4. **Technician‑aware booking** (slot‑picker page + controller on `fusion_tasks` availability; task/repair/visit creation; double‑book guard; office action; token regen) + tests — the largest unit.
|
||||||
|
5. **Maintenance visit log + checklist** (model, per‑category seed, visit‑report‑wizard extension, inspection‑cert link) + tests.
|
||||||
|
6. **Backfill wizard** (scan/dedup/stagger/dry‑run; fusion_claims soft bridge) + tests.
|
||||||
|
7. **Office follow‑up crons** (unbooked/overdue) + tests.
|
||||||
|
8. **Deploy + configure + backfill** on Westin.
|
||||||
@@ -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.
|
||||||
BIN
fusion_authorizer_portal/.DS_Store
vendored
BIN
fusion_authorizer_portal/.DS_Store
vendored
Binary file not shown.
@@ -1,883 +0,0 @@
|
|||||||
# Graph Report - /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal (2026-04-22)
|
|
||||||
|
|
||||||
## Corpus Check
|
|
||||||
- 33 files · ~40,589 words
|
|
||||||
- Verdict: corpus is large enough that graph structure adds value.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
- 470 nodes · 550 edges · 123 communities detected
|
|
||||||
- Extraction: 89% EXTRACTED · 11% INFERRED · 0% AMBIGUOUS · INFERRED: 60 edges (avg confidence: 0.76)
|
|
||||||
- Token cost: 0 input · 0 output
|
|
||||||
|
|
||||||
## Community Hubs (Navigation)
|
|
||||||
- [[_COMMUNITY_Community 0|Community 0]]
|
|
||||||
- [[_COMMUNITY_Community 1|Community 1]]
|
|
||||||
- [[_COMMUNITY_Community 2|Community 2]]
|
|
||||||
- [[_COMMUNITY_Community 3|Community 3]]
|
|
||||||
- [[_COMMUNITY_Community 4|Community 4]]
|
|
||||||
- [[_COMMUNITY_Community 5|Community 5]]
|
|
||||||
- [[_COMMUNITY_Community 6|Community 6]]
|
|
||||||
- [[_COMMUNITY_Community 7|Community 7]]
|
|
||||||
- [[_COMMUNITY_Community 8|Community 8]]
|
|
||||||
- [[_COMMUNITY_Community 9|Community 9]]
|
|
||||||
- [[_COMMUNITY_Community 10|Community 10]]
|
|
||||||
- [[_COMMUNITY_Community 11|Community 11]]
|
|
||||||
- [[_COMMUNITY_Community 12|Community 12]]
|
|
||||||
- [[_COMMUNITY_Community 13|Community 13]]
|
|
||||||
- [[_COMMUNITY_Community 14|Community 14]]
|
|
||||||
- [[_COMMUNITY_Community 15|Community 15]]
|
|
||||||
- [[_COMMUNITY_Community 16|Community 16]]
|
|
||||||
- [[_COMMUNITY_Community 17|Community 17]]
|
|
||||||
- [[_COMMUNITY_Community 18|Community 18]]
|
|
||||||
- [[_COMMUNITY_Community 19|Community 19]]
|
|
||||||
- [[_COMMUNITY_Community 20|Community 20]]
|
|
||||||
- [[_COMMUNITY_Community 21|Community 21]]
|
|
||||||
- [[_COMMUNITY_Community 22|Community 22]]
|
|
||||||
- [[_COMMUNITY_Community 23|Community 23]]
|
|
||||||
- [[_COMMUNITY_Community 24|Community 24]]
|
|
||||||
- [[_COMMUNITY_Community 25|Community 25]]
|
|
||||||
- [[_COMMUNITY_Community 26|Community 26]]
|
|
||||||
- [[_COMMUNITY_Community 27|Community 27]]
|
|
||||||
- [[_COMMUNITY_Community 28|Community 28]]
|
|
||||||
- [[_COMMUNITY_Community 29|Community 29]]
|
|
||||||
- [[_COMMUNITY_Community 30|Community 30]]
|
|
||||||
- [[_COMMUNITY_Community 31|Community 31]]
|
|
||||||
- [[_COMMUNITY_Community 32|Community 32]]
|
|
||||||
- [[_COMMUNITY_Community 33|Community 33]]
|
|
||||||
- [[_COMMUNITY_Community 34|Community 34]]
|
|
||||||
- [[_COMMUNITY_Community 35|Community 35]]
|
|
||||||
- [[_COMMUNITY_Community 36|Community 36]]
|
|
||||||
- [[_COMMUNITY_Community 37|Community 37]]
|
|
||||||
- [[_COMMUNITY_Community 38|Community 38]]
|
|
||||||
- [[_COMMUNITY_Community 39|Community 39]]
|
|
||||||
- [[_COMMUNITY_Community 40|Community 40]]
|
|
||||||
- [[_COMMUNITY_Community 41|Community 41]]
|
|
||||||
- [[_COMMUNITY_Community 42|Community 42]]
|
|
||||||
- [[_COMMUNITY_Community 43|Community 43]]
|
|
||||||
- [[_COMMUNITY_Community 44|Community 44]]
|
|
||||||
- [[_COMMUNITY_Community 45|Community 45]]
|
|
||||||
- [[_COMMUNITY_Community 46|Community 46]]
|
|
||||||
- [[_COMMUNITY_Community 47|Community 47]]
|
|
||||||
- [[_COMMUNITY_Community 48|Community 48]]
|
|
||||||
- [[_COMMUNITY_Community 49|Community 49]]
|
|
||||||
- [[_COMMUNITY_Community 50|Community 50]]
|
|
||||||
- [[_COMMUNITY_Community 51|Community 51]]
|
|
||||||
- [[_COMMUNITY_Community 52|Community 52]]
|
|
||||||
- [[_COMMUNITY_Community 53|Community 53]]
|
|
||||||
- [[_COMMUNITY_Community 54|Community 54]]
|
|
||||||
- [[_COMMUNITY_Community 55|Community 55]]
|
|
||||||
- [[_COMMUNITY_Community 56|Community 56]]
|
|
||||||
- [[_COMMUNITY_Community 57|Community 57]]
|
|
||||||
- [[_COMMUNITY_Community 58|Community 58]]
|
|
||||||
- [[_COMMUNITY_Community 59|Community 59]]
|
|
||||||
- [[_COMMUNITY_Community 60|Community 60]]
|
|
||||||
- [[_COMMUNITY_Community 61|Community 61]]
|
|
||||||
- [[_COMMUNITY_Community 62|Community 62]]
|
|
||||||
- [[_COMMUNITY_Community 63|Community 63]]
|
|
||||||
- [[_COMMUNITY_Community 64|Community 64]]
|
|
||||||
- [[_COMMUNITY_Community 65|Community 65]]
|
|
||||||
- [[_COMMUNITY_Community 66|Community 66]]
|
|
||||||
- [[_COMMUNITY_Community 67|Community 67]]
|
|
||||||
- [[_COMMUNITY_Community 68|Community 68]]
|
|
||||||
- [[_COMMUNITY_Community 69|Community 69]]
|
|
||||||
- [[_COMMUNITY_Community 70|Community 70]]
|
|
||||||
- [[_COMMUNITY_Community 71|Community 71]]
|
|
||||||
- [[_COMMUNITY_Community 72|Community 72]]
|
|
||||||
- [[_COMMUNITY_Community 73|Community 73]]
|
|
||||||
- [[_COMMUNITY_Community 74|Community 74]]
|
|
||||||
- [[_COMMUNITY_Community 75|Community 75]]
|
|
||||||
- [[_COMMUNITY_Community 76|Community 76]]
|
|
||||||
- [[_COMMUNITY_Community 77|Community 77]]
|
|
||||||
- [[_COMMUNITY_Community 78|Community 78]]
|
|
||||||
- [[_COMMUNITY_Community 79|Community 79]]
|
|
||||||
- [[_COMMUNITY_Community 80|Community 80]]
|
|
||||||
- [[_COMMUNITY_Community 81|Community 81]]
|
|
||||||
- [[_COMMUNITY_Community 82|Community 82]]
|
|
||||||
- [[_COMMUNITY_Community 83|Community 83]]
|
|
||||||
- [[_COMMUNITY_Community 84|Community 84]]
|
|
||||||
- [[_COMMUNITY_Community 85|Community 85]]
|
|
||||||
- [[_COMMUNITY_Community 86|Community 86]]
|
|
||||||
- [[_COMMUNITY_Community 87|Community 87]]
|
|
||||||
- [[_COMMUNITY_Community 88|Community 88]]
|
|
||||||
- [[_COMMUNITY_Community 89|Community 89]]
|
|
||||||
- [[_COMMUNITY_Community 90|Community 90]]
|
|
||||||
- [[_COMMUNITY_Community 91|Community 91]]
|
|
||||||
- [[_COMMUNITY_Community 92|Community 92]]
|
|
||||||
- [[_COMMUNITY_Community 93|Community 93]]
|
|
||||||
- [[_COMMUNITY_Community 94|Community 94]]
|
|
||||||
- [[_COMMUNITY_Community 95|Community 95]]
|
|
||||||
- [[_COMMUNITY_Community 96|Community 96]]
|
|
||||||
- [[_COMMUNITY_Community 97|Community 97]]
|
|
||||||
- [[_COMMUNITY_Community 98|Community 98]]
|
|
||||||
- [[_COMMUNITY_Community 99|Community 99]]
|
|
||||||
- [[_COMMUNITY_Community 100|Community 100]]
|
|
||||||
- [[_COMMUNITY_Community 101|Community 101]]
|
|
||||||
- [[_COMMUNITY_Community 102|Community 102]]
|
|
||||||
- [[_COMMUNITY_Community 103|Community 103]]
|
|
||||||
- [[_COMMUNITY_Community 104|Community 104]]
|
|
||||||
- [[_COMMUNITY_Community 105|Community 105]]
|
|
||||||
- [[_COMMUNITY_Community 106|Community 106]]
|
|
||||||
- [[_COMMUNITY_Community 107|Community 107]]
|
|
||||||
- [[_COMMUNITY_Community 108|Community 108]]
|
|
||||||
- [[_COMMUNITY_Community 109|Community 109]]
|
|
||||||
- [[_COMMUNITY_Community 110|Community 110]]
|
|
||||||
- [[_COMMUNITY_Community 111|Community 111]]
|
|
||||||
- [[_COMMUNITY_Community 112|Community 112]]
|
|
||||||
- [[_COMMUNITY_Community 113|Community 113]]
|
|
||||||
- [[_COMMUNITY_Community 114|Community 114]]
|
|
||||||
- [[_COMMUNITY_Community 115|Community 115]]
|
|
||||||
- [[_COMMUNITY_Community 116|Community 116]]
|
|
||||||
- [[_COMMUNITY_Community 117|Community 117]]
|
|
||||||
- [[_COMMUNITY_Community 118|Community 118]]
|
|
||||||
- [[_COMMUNITY_Community 119|Community 119]]
|
|
||||||
- [[_COMMUNITY_Community 120|Community 120]]
|
|
||||||
- [[_COMMUNITY_Community 121|Community 121]]
|
|
||||||
- [[_COMMUNITY_Community 122|Community 122]]
|
|
||||||
|
|
||||||
## God Nodes (most connected - your core abstractions)
|
|
||||||
1. `create()` - 22 edges
|
|
||||||
2. `FusionAssessment` - 20 edges
|
|
||||||
3. `AuthorizerPortal` - 19 edges
|
|
||||||
4. `ResPartner` - 16 edges
|
|
||||||
5. `accessibility_assessment_save()` - 12 edges
|
|
||||||
6. `FusionAccessibilityAssessment` - 11 edges
|
|
||||||
7. `selectField()` - 11 edges
|
|
||||||
8. `PDFTemplateFiller` - 10 edges
|
|
||||||
9. `SaleOrder` - 10 edges
|
|
||||||
10. `FusionPdfTemplate` - 9 edges
|
|
||||||
|
|
||||||
## Surprising Connections (you probably didn't know these)
|
|
||||||
- `create_field()` --calls--> `create()` [INFERRED]
|
|
||||||
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/pdf_editor.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/accessibility_assessment.py
|
|
||||||
- `FusionPdfTemplatePreview` --uses--> `PDFTemplateFiller` [INFERRED]
|
|
||||||
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/pdf_template.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/pdf_filler.py
|
|
||||||
- `FusionPdfTemplateField` --uses--> `PDFTemplateFiller` [INFERRED]
|
|
||||||
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/pdf_template.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/pdf_filler.py
|
|
||||||
- `Generate PNG preview images from the PDF using poppler (pdftoppm). Falls` --uses--> `PDFTemplateFiller` [INFERRED]
|
|
||||||
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/pdf_template.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/pdf_filler.py
|
|
||||||
- `Set template to active.` --uses--> `PDFTemplateFiller` [INFERRED]
|
|
||||||
/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/pdf_template.py → /Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/pdf_filler.py
|
|
||||||
|
|
||||||
## Communities
|
|
||||||
|
|
||||||
### Community 0 - "Community 0"
|
|
||||||
Cohesion: 0.05
|
|
||||||
Nodes (29): accessibility_bathroom(), accessibility_ceiling_lift(), accessibility_ramp(), accessibility_stairlift_curved(), accessibility_stairlift_straight(), accessibility_tub_cutout(), accessibility_vpl(), home() (+21 more)
|
|
||||||
|
|
||||||
### Community 1 - "Community 1"
|
|
||||||
Cohesion: 0.06
|
|
||||||
Nodes (20): Assign role-specific portal groups to a portal user based on contact checkboxes., Assign backend groups to an internal user based on contact checkboxes. A, Grant portal access to this partner, or update permissions for existing users., Create a role-specific welcome Knowledge article for the new portal user., Send a professional portal invitation email to the partner. Gen, Resend portal invitation email to an existing portal user., Open the list of assigned sale orders, Open the list of assessments for this partner (+12 more)
|
|
||||||
|
|
||||||
### Community 2 - "Community 2"
|
|
||||||
Cohesion: 0.07
|
|
||||||
Nodes (19): create(), FusionAssessment, Format assessment data as HTML table for chatter, Format wheelchair specifications for the sale order notes (legacy), Generate document records for signed pages, Send email notifications when assessment is completed, View related documents, View the created sale order (+11 more)
|
|
||||||
|
|
||||||
### Community 3 - "Community 3"
|
|
||||||
Cohesion: 0.08
|
|
||||||
Nodes (15): create(), FusionAccessibilityAssessment, Complete the assessment and create a Sale Order. 2026-04 portal audit f, Add a tag to the sale order based on assessment type, Copy assessment photos to sale order chatter, Send email notification to office about assessment completion, Schedule a follow-up activity for the sales rep, Find or create a partner for the client (+7 more)
|
|
||||||
|
|
||||||
### Community 4 - "Community 4"
|
|
||||||
Cohesion: 0.08
|
|
||||||
Nodes (20): Complete express assessment and create draft sale order (no signatures required), CustomerPortal, Ensure all module views are active after install/update. Odoo silently deac, _reactivate_views(), AssessmentPortal, portal_assessment_express_edit(), portal_assessment_express_new(), portal_assessment_express_save() (+12 more)
|
|
||||||
|
|
||||||
### Community 5 - "Community 5"
|
|
||||||
Cohesion: 0.09
|
|
||||||
Nodes (14): authorizer_cases_search(), sales_rep_cases_search(), get_authorizer_portal_cases(), get_sales_rep_portal_cases(), Open composer to send message to authorizer only, Send email when an authorizer is assigned to the order, View portal documents, Get data for portal display, excluding sensitive information (+6 more)
|
|
||||||
|
|
||||||
### Community 6 - "Community 6"
|
|
||||||
Cohesion: 0.12
|
|
||||||
Nodes (14): preview_pdf(), _draw_field(), fill_template(), PDFTemplateFiller, Generic PDF template filler. Works with any template, any number of pages., create(), FusionPdfTemplate, FusionPdfTemplateField (+6 more)
|
|
||||||
|
|
||||||
### Community 7 - "Community 7"
|
|
||||||
Cohesion: 0.11
|
|
||||||
Nodes (14): accessibility_assessment_save(), AuthorizerPortal, Portal controller for Authorizers (OTs/Therapists), Parse straight stair lift specific fields, Parse curved stair lift specific fields, Parse VPL specific fields, Parse ceiling lift specific fields, Parse ramp specific fields (+6 more)
|
|
||||||
|
|
||||||
### Community 8 - "Community 8"
|
|
||||||
Cohesion: 0.21
|
|
||||||
Nodes (22): buildDataKeyOptions(), buildDataKeysSidebar(), init(), jsonrpc(), loadFields(), normalize(), onFieldDragStart(), renderFieldMarker() (+14 more)
|
|
||||||
|
|
||||||
### Community 9 - "Community 9"
|
|
||||||
Cohesion: 0.29
|
|
||||||
Nodes (11): checkClockStatus(), ensureModal(), getLocation(), hideModal(), isTechnicianPortal(), logLocation(), showDeniedBanner(), showModal() (+3 more)
|
|
||||||
|
|
||||||
### Community 10 - "Community 10"
|
|
||||||
Cohesion: 0.18
|
|
||||||
Nodes (3): ADPDocument, Download the document, Get the download URL for portal access
|
|
||||||
|
|
||||||
### Community 11 - "Community 11"
|
|
||||||
Cohesion: 0.2
|
|
||||||
Nodes (5): create_field(), FusionPdfEditorController, Controller for the PDF field position visual editor., update_field(), upload_preview_image()
|
|
||||||
|
|
||||||
### Community 12 - "Community 12"
|
|
||||||
Cohesion: 0.38
|
|
||||||
Nodes (4): page11_sign_form(), page11_sign_submit(), Page11PublicSignController, Look up and validate a signing request by token.
|
|
||||||
|
|
||||||
### Community 13 - "Community 13"
|
|
||||||
Cohesion: 0.4
|
|
||||||
Nodes (1): migrate()
|
|
||||||
|
|
||||||
### Community 14 - "Community 14"
|
|
||||||
Cohesion: 0.5
|
|
||||||
Nodes (1): AuthorizerComment
|
|
||||||
|
|
||||||
### Community 15 - "Community 15"
|
|
||||||
Cohesion: 0.83
|
|
||||||
Nodes (3): _detectAndSaveTimezone(), _getCookie(), start()
|
|
||||||
|
|
||||||
### Community 16 - "Community 16"
|
|
||||||
Cohesion: 0.67
|
|
||||||
Nodes (1): FusionLoanerCheckoutAssessment
|
|
||||||
|
|
||||||
### Community 17 - "Community 17"
|
|
||||||
Cohesion: 0.67
|
|
||||||
Nodes (0):
|
|
||||||
|
|
||||||
### Community 18 - "Community 18"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (2): registerPushSubscription(), urlBase64ToUint8Array()
|
|
||||||
|
|
||||||
### Community 19 - "Community 19"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (0):
|
|
||||||
|
|
||||||
### Community 20 - "Community 20"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (0):
|
|
||||||
|
|
||||||
### Community 21 - "Community 21"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (0):
|
|
||||||
|
|
||||||
### Community 22 - "Community 22"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (0):
|
|
||||||
|
|
||||||
### Community 23 - "Community 23"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Fill a PDF template by overlaying text/checkmarks/signatures at configured posit
|
|
||||||
|
|
||||||
### Community 24 - "Community 24"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Draw a single field onto the reportlab canvas. Args: c: rep
|
|
||||||
|
|
||||||
### Community 25 - "Community 25"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Override create to generate reference number
|
|
||||||
|
|
||||||
### Community 26 - "Community 26"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Get authorizer from x_fc_authorizer_id field
|
|
||||||
|
|
||||||
### Community 27 - "Community 27"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Get cases for authorizer portal with optional search
|
|
||||||
|
|
||||||
### Community 28 - "Community 28"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Get cases for sales rep portal with optional search
|
|
||||||
|
|
||||||
### Community 29 - "Community 29"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Override create to handle revision numbering
|
|
||||||
|
|
||||||
### Community 30 - "Community 30"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Get documents for a sale order, optionally filtered by type
|
|
||||||
|
|
||||||
### Community 31 - "Community 31"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Get all revisions of a specific document type
|
|
||||||
|
|
||||||
### Community 32 - "Community 32"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Override create to set author from current user if not provided
|
|
||||||
|
|
||||||
### Community 33 - "Community 33"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Kanban group expansion — always show all 6 workflow states.
|
|
||||||
|
|
||||||
### Community 34 - "Community 34"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Straight stair lift: (steps × nose_to_nose) + 13" top landing
|
|
||||||
|
|
||||||
### Community 35 - "Community 35"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Use manual override if provided, otherwise use calculated
|
|
||||||
|
|
||||||
### Community 36 - "Community 36"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Curved stair lift calculation: - 12" per step - 16" per curve
|
|
||||||
|
|
||||||
### Community 37 - "Community 37"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Use manual override if provided, otherwise use calculated
|
|
||||||
|
|
||||||
### Community 38 - "Community 38"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Ontario Building Code: 12 inches length per 1 inch height (1:12 ratio)
|
|
||||||
|
|
||||||
### Community 39 - "Community 39"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Landing required every 30 feet (360 inches)
|
|
||||||
|
|
||||||
### Community 40 - "Community 40"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Total length including landings (5 feet = 60 inches each)
|
|
||||||
|
|
||||||
### Community 41 - "Community 41"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Compute portal access status based on user account and login history.
|
|
||||||
|
|
||||||
### Community 42 - "Community 42"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Count sale orders where this partner is the authorizer
|
|
||||||
|
|
||||||
### Community 43 - "Community 43"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Count assessments where this partner is involved
|
|
||||||
|
|
||||||
### Community 44 - "Community 44"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Count sale orders assigned to this partner as delivery technician
|
|
||||||
|
|
||||||
### Community 45 - "Community 45"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (0):
|
|
||||||
|
|
||||||
### Community 46 - "Community 46"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (0):
|
|
||||||
|
|
||||||
### Community 47 - "Community 47"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (0):
|
|
||||||
|
|
||||||
### Community 48 - "Community 48"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (0):
|
|
||||||
|
|
||||||
### Community 49 - "Community 49"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (0):
|
|
||||||
|
|
||||||
### Community 50 - "Community 50"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Display the Page 11 signing form.
|
|
||||||
|
|
||||||
### Community 51 - "Community 51"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Process the submitted Page 11 signature.
|
|
||||||
|
|
||||||
### Community 52 - "Community 52"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Download the signed Page 11 PDF.
|
|
||||||
|
|
||||||
### Community 53 - "Community 53"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Start a new assessment
|
|
||||||
|
|
||||||
### Community 54 - "Community 54"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): View/edit an assessment
|
|
||||||
|
|
||||||
### Community 55 - "Community 55"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Save assessment data (create or update)
|
|
||||||
|
|
||||||
### Community 56 - "Community 56"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Signature capture page
|
|
||||||
|
|
||||||
### Community 57 - "Community 57"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Save a signature (AJAX)
|
|
||||||
|
|
||||||
### Community 58 - "Community 58"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Complete the assessment
|
|
||||||
|
|
||||||
### Community 59 - "Community 59"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Start a new express assessment (Page 1 - Equipment Selection)
|
|
||||||
|
|
||||||
### Community 60 - "Community 60"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Continue/edit an express assessment
|
|
||||||
|
|
||||||
### Community 61 - "Community 61"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Save express assessment data (create or update)
|
|
||||||
|
|
||||||
### Community 62 - "Community 62"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Public page for booking an accessibility assessment.
|
|
||||||
|
|
||||||
### Community 63 - "Community 63"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Process assessment booking form submission.
|
|
||||||
|
|
||||||
### Community 64 - "Community 64"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Render the visual field editor for a PDF template.
|
|
||||||
|
|
||||||
### Community 65 - "Community 65"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Return all fields for a template.
|
|
||||||
|
|
||||||
### Community 66 - "Community 66"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Update a field's position or properties.
|
|
||||||
|
|
||||||
### Community 67 - "Community 67"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Create a new field on a template.
|
|
||||||
|
|
||||||
### Community 68 - "Community 68"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Delete a field from a template.
|
|
||||||
|
|
||||||
### Community 69 - "Community 69"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Return the preview image URL for a specific page.
|
|
||||||
|
|
||||||
### Community 70 - "Community 70"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Upload a preview image for a template page directly from the editor.
|
|
||||||
|
|
||||||
### Community 71 - "Community 71"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Generate a preview filled PDF with sample data.
|
|
||||||
|
|
||||||
### Community 72 - "Community 72"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Auto-save browser-detected timezone to the user profile if not already set.
|
|
||||||
|
|
||||||
### Community 73 - "Community 73"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Override home to add ADP posting info for Fusion users
|
|
||||||
|
|
||||||
### Community 74 - "Community 74"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Authorizer dashboard - simplified mobile-first view
|
|
||||||
|
|
||||||
### Community 75 - "Community 75"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): List of cases assigned to the authorizer
|
|
||||||
|
|
||||||
### Community 76 - "Community 76"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): AJAX search endpoint for real-time search
|
|
||||||
|
|
||||||
### Community 77 - "Community 77"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Add a comment to a case - posts to sale order chatter and emails salesperson
|
|
||||||
|
|
||||||
### Community 78 - "Community 78"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Upload a document for a case
|
|
||||||
|
|
||||||
### Community 79 - "Community 79"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Download an attachment from sale order (original application, xml, proof of deli
|
|
||||||
|
|
||||||
### Community 80 - "Community 80"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): View an approval photo
|
|
||||||
|
|
||||||
### Community 81 - "Community 81"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Sales rep dashboard with search and filters
|
|
||||||
|
|
||||||
### Community 82 - "Community 82"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): List of cases for the sales rep
|
|
||||||
|
|
||||||
### Community 83 - "Community 83"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): AJAX search endpoint for sales rep real-time search
|
|
||||||
|
|
||||||
### Community 84 - "Community 84"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): View a specific case for sales rep
|
|
||||||
|
|
||||||
### Community 85 - "Community 85"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Add a comment to a case (sales rep) - posts to sale order chatter and emails aut
|
|
||||||
|
|
||||||
### Community 86 - "Community 86"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): List of funding claims for the client
|
|
||||||
|
|
||||||
### Community 87 - "Community 87"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): View a specific funding claim
|
|
||||||
|
|
||||||
### Community 88 - "Community 88"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Download a document from a funding claim
|
|
||||||
|
|
||||||
### Community 89 - "Community 89"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Download proof of delivery from a funding claim
|
|
||||||
|
|
||||||
### Community 90 - "Community 90"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Technician dashboard - today's schedule with timeline.
|
|
||||||
|
|
||||||
### Community 91 - "Community 91"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): List of all tasks for the technician.
|
|
||||||
|
|
||||||
### Community 92 - "Community 92"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): View a specific technician task.
|
|
||||||
|
|
||||||
### Community 93 - "Community 93"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Add notes (and optional photos) to a completed task. :param notes: text
|
|
||||||
|
|
||||||
### Community 94 - "Community 94"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Handle task status changes (start, complete, en_route, cancel). Location
|
|
||||||
|
|
||||||
### Community 95 - "Community 95"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Transcribe voice recording using OpenAI Whisper, translate to English.
|
|
||||||
|
|
||||||
### Community 96 - "Community 96"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Use GPT to clean up and format raw notes text.
|
|
||||||
|
|
||||||
### Community 97 - "Community 97"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Format transcription with GPT and complete the task.
|
|
||||||
|
|
||||||
### Community 98 - "Community 98"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Next day preparation view.
|
|
||||||
|
|
||||||
### Community 99 - "Community 99"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): View schedule for a specific date.
|
|
||||||
|
|
||||||
### Community 100 - "Community 100"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Admin map view showing latest technician locations using Google Maps.
|
|
||||||
|
|
||||||
### Community 101 - "Community 101"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Log the technician's current GPS location.
|
|
||||||
|
|
||||||
### Community 102 - "Community 102"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Check if the current technician is clocked in. Returns {clocked_in: boo
|
|
||||||
|
|
||||||
### Community 103 - "Community 103"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Save the technician's personal start location.
|
|
||||||
|
|
||||||
### Community 104 - "Community 104"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Register a push notification subscription.
|
|
||||||
|
|
||||||
### Community 105 - "Community 105"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Legacy: List of deliveries for the technician (redirects to tasks).
|
|
||||||
|
|
||||||
### Community 106 - "Community 106"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): View a specific delivery for technician (legacy, still works).
|
|
||||||
|
|
||||||
### Community 107 - "Community 107"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): POD signature capture page - accessible by technicians and sales reps
|
|
||||||
|
|
||||||
### Community 108 - "Community 108"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Save POD signature via AJAX
|
|
||||||
|
|
||||||
### Community 109 - "Community 109"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Task-level POD signature capture page (works for all tasks including shadow).
|
|
||||||
|
|
||||||
### Community 110 - "Community 110"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Save POD signature directly on a task.
|
|
||||||
|
|
||||||
### Community 111 - "Community 111"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Show the accessibility assessment type selector
|
|
||||||
|
|
||||||
### Community 112 - "Community 112"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): List all accessibility assessments for the current user (sales rep or authorizer
|
|
||||||
|
|
||||||
### Community 113 - "Community 113"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Straight stair lift assessment form
|
|
||||||
|
|
||||||
### Community 114 - "Community 114"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Curved stair lift assessment form
|
|
||||||
|
|
||||||
### Community 115 - "Community 115"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Vertical Platform Lift assessment form
|
|
||||||
|
|
||||||
### Community 116 - "Community 116"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Ceiling Lift assessment form
|
|
||||||
|
|
||||||
### Community 117 - "Community 117"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Custom Ramp assessment form
|
|
||||||
|
|
||||||
### Community 118 - "Community 118"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Bathroom Modification assessment form
|
|
||||||
|
|
||||||
### Community 119 - "Community 119"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Tub Cutout assessment form
|
|
||||||
|
|
||||||
### Community 120 - "Community 120"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Save an accessibility assessment and optionally create a Sale Order
|
|
||||||
|
|
||||||
### Community 121 - "Community 121"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Render the rental pickup inspection form for the technician.
|
|
||||||
|
|
||||||
### Community 122 - "Community 122"
|
|
||||||
Cohesion: 1.0
|
|
||||||
Nodes (1): Save the rental inspection results.
|
|
||||||
|
|
||||||
## Knowledge Gaps
|
|
||||||
- **177 isolated node(s):** `Ensure all module views are active after install/update. Odoo silently deac`, `Generic PDF template filler. Works with any template, any number of pages.`, `Fill a PDF template by overlaying text/checkmarks/signatures at configured posit`, `Draw a single field onto the reportlab canvas. Args: c: rep`, `Override create to generate reference number` (+172 more)
|
|
||||||
These have ≤1 connection - possible missing edges or undocumented components.
|
|
||||||
- **Thin community `Community 19`** (1 nodes): `__init__.py`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 20`** (1 nodes): `__init__.py`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 21`** (1 nodes): `__init__.py`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 22`** (1 nodes): `__manifest__.py`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 23`** (1 nodes): `Fill a PDF template by overlaying text/checkmarks/signatures at configured posit`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 24`** (1 nodes): `Draw a single field onto the reportlab canvas. Args: c: rep`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 25`** (1 nodes): `Override create to generate reference number`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 26`** (1 nodes): `Get authorizer from x_fc_authorizer_id field`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 27`** (1 nodes): `Get cases for authorizer portal with optional search`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 28`** (1 nodes): `Get cases for sales rep portal with optional search`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 29`** (1 nodes): `Override create to handle revision numbering`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 30`** (1 nodes): `Get documents for a sale order, optionally filtered by type`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 31`** (1 nodes): `Get all revisions of a specific document type`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 32`** (1 nodes): `Override create to set author from current user if not provided`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 33`** (1 nodes): `Kanban group expansion — always show all 6 workflow states.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 34`** (1 nodes): `Straight stair lift: (steps × nose_to_nose) + 13" top landing`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 35`** (1 nodes): `Use manual override if provided, otherwise use calculated`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 36`** (1 nodes): `Curved stair lift calculation: - 12" per step - 16" per curve`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 37`** (1 nodes): `Use manual override if provided, otherwise use calculated`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 38`** (1 nodes): `Ontario Building Code: 12 inches length per 1 inch height (1:12 ratio)`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 39`** (1 nodes): `Landing required every 30 feet (360 inches)`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 40`** (1 nodes): `Total length including landings (5 feet = 60 inches each)`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 41`** (1 nodes): `Compute portal access status based on user account and login history.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 42`** (1 nodes): `Count sale orders where this partner is the authorizer`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 43`** (1 nodes): `Count assessments where this partner is involved`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 44`** (1 nodes): `Count sale orders assigned to this partner as delivery technician`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 45`** (1 nodes): `assessment_form.js`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 46`** (1 nodes): `technician_sw.js`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 47`** (1 nodes): `loaner_portal.js`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 48`** (1 nodes): `signature_pad.js`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 49`** (1 nodes): `portal_search.js`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 50`** (1 nodes): `Display the Page 11 signing form.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 51`** (1 nodes): `Process the submitted Page 11 signature.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 52`** (1 nodes): `Download the signed Page 11 PDF.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 53`** (1 nodes): `Start a new assessment`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 54`** (1 nodes): `View/edit an assessment`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 55`** (1 nodes): `Save assessment data (create or update)`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 56`** (1 nodes): `Signature capture page`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 57`** (1 nodes): `Save a signature (AJAX)`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 58`** (1 nodes): `Complete the assessment`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 59`** (1 nodes): `Start a new express assessment (Page 1 - Equipment Selection)`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 60`** (1 nodes): `Continue/edit an express assessment`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 61`** (1 nodes): `Save express assessment data (create or update)`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 62`** (1 nodes): `Public page for booking an accessibility assessment.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 63`** (1 nodes): `Process assessment booking form submission.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 64`** (1 nodes): `Render the visual field editor for a PDF template.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 65`** (1 nodes): `Return all fields for a template.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 66`** (1 nodes): `Update a field's position or properties.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 67`** (1 nodes): `Create a new field on a template.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 68`** (1 nodes): `Delete a field from a template.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 69`** (1 nodes): `Return the preview image URL for a specific page.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 70`** (1 nodes): `Upload a preview image for a template page directly from the editor.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 71`** (1 nodes): `Generate a preview filled PDF with sample data.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 72`** (1 nodes): `Auto-save browser-detected timezone to the user profile if not already set.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 73`** (1 nodes): `Override home to add ADP posting info for Fusion users`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 74`** (1 nodes): `Authorizer dashboard - simplified mobile-first view`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 75`** (1 nodes): `List of cases assigned to the authorizer`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 76`** (1 nodes): `AJAX search endpoint for real-time search`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 77`** (1 nodes): `Add a comment to a case - posts to sale order chatter and emails salesperson`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 78`** (1 nodes): `Upload a document for a case`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 79`** (1 nodes): `Download an attachment from sale order (original application, xml, proof of deli`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 80`** (1 nodes): `View an approval photo`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 81`** (1 nodes): `Sales rep dashboard with search and filters`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 82`** (1 nodes): `List of cases for the sales rep`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 83`** (1 nodes): `AJAX search endpoint for sales rep real-time search`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 84`** (1 nodes): `View a specific case for sales rep`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 85`** (1 nodes): `Add a comment to a case (sales rep) - posts to sale order chatter and emails aut`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 86`** (1 nodes): `List of funding claims for the client`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 87`** (1 nodes): `View a specific funding claim`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 88`** (1 nodes): `Download a document from a funding claim`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 89`** (1 nodes): `Download proof of delivery from a funding claim`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 90`** (1 nodes): `Technician dashboard - today's schedule with timeline.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 91`** (1 nodes): `List of all tasks for the technician.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 92`** (1 nodes): `View a specific technician task.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 93`** (1 nodes): `Add notes (and optional photos) to a completed task. :param notes: text`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 94`** (1 nodes): `Handle task status changes (start, complete, en_route, cancel). Location`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 95`** (1 nodes): `Transcribe voice recording using OpenAI Whisper, translate to English.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 96`** (1 nodes): `Use GPT to clean up and format raw notes text.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 97`** (1 nodes): `Format transcription with GPT and complete the task.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 98`** (1 nodes): `Next day preparation view.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 99`** (1 nodes): `View schedule for a specific date.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 100`** (1 nodes): `Admin map view showing latest technician locations using Google Maps.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 101`** (1 nodes): `Log the technician's current GPS location.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 102`** (1 nodes): `Check if the current technician is clocked in. Returns {clocked_in: boo`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 103`** (1 nodes): `Save the technician's personal start location.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 104`** (1 nodes): `Register a push notification subscription.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 105`** (1 nodes): `Legacy: List of deliveries for the technician (redirects to tasks).`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 106`** (1 nodes): `View a specific delivery for technician (legacy, still works).`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 107`** (1 nodes): `POD signature capture page - accessible by technicians and sales reps`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 108`** (1 nodes): `Save POD signature via AJAX`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 109`** (1 nodes): `Task-level POD signature capture page (works for all tasks including shadow).`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 110`** (1 nodes): `Save POD signature directly on a task.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 111`** (1 nodes): `Show the accessibility assessment type selector`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 112`** (1 nodes): `List all accessibility assessments for the current user (sales rep or authorizer`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 113`** (1 nodes): `Straight stair lift assessment form`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 114`** (1 nodes): `Curved stair lift assessment form`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 115`** (1 nodes): `Vertical Platform Lift assessment form`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 116`** (1 nodes): `Ceiling Lift assessment form`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 117`** (1 nodes): `Custom Ramp assessment form`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 118`** (1 nodes): `Bathroom Modification assessment form`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 119`** (1 nodes): `Tub Cutout assessment form`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 120`** (1 nodes): `Save an accessibility assessment and optionally create a Sale Order`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 121`** (1 nodes): `Render the rental pickup inspection form for the technician.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
- **Thin community `Community 122`** (1 nodes): `Save the rental inspection results.`
|
|
||||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
|
||||||
|
|
||||||
## Suggested Questions
|
|
||||||
_Questions this graph is uniquely positioned to answer:_
|
|
||||||
|
|
||||||
- **Why does `create()` connect `Community 3` to `Community 0`, `Community 1`, `Community 4`, `Community 7`, `Community 11`?**
|
|
||||||
_High betweenness centrality (0.080) - this node is a cross-community bridge._
|
|
||||||
- **Why does `FusionAssessment` connect `Community 2` to `Community 4`?**
|
|
||||||
_High betweenness centrality (0.059) - this node is a cross-community bridge._
|
|
||||||
- **Why does `AuthorizerPortal` connect `Community 7` to `Community 0`, `Community 4`?**
|
|
||||||
_High betweenness centrality (0.047) - this node is a cross-community bridge._
|
|
||||||
- **Are the 17 inferred relationships involving `create()` (e.g. with `._generate_tutorial_articles()` and `.action_grant_portal_access()`) actually correct?**
|
|
||||||
_`create()` has 17 INFERRED edges - model-reasoned connections that need verification._
|
|
||||||
- **Are the 2 inferred relationships involving `accessibility_assessment_save()` (e.g. with `create()` and `.action_complete()`) actually correct?**
|
|
||||||
_`accessibility_assessment_save()` has 2 INFERRED edges - model-reasoned connections that need verification._
|
|
||||||
- **What connects `Ensure all module views are active after install/update. Odoo silently deac`, `Generic PDF template filler. Works with any template, any number of pages.`, `Fill a PDF template by overlaying text/checkmarks/signatures at configured posit` to the rest of the system?**
|
|
||||||
_177 weakly-connected nodes found - possible documentation gaps or missing edges._
|
|
||||||
- **Should `Community 0` be split into smaller, more focused modules?**
|
|
||||||
_Cohesion score 0.05 - nodes in this community are weakly interconnected._
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "label": "authorizer_comment.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L1"}, {"id": "authorizer_comment_authorizercomment", "label": "AuthorizerComment", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L9"}, {"id": "authorizer_comment_compute_display_name", "label": "_compute_display_name()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L70"}, {"id": "authorizer_comment_create", "label": "create()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L78"}, {"id": "authorizer_comment_rationale_79", "label": "Override create to set author from current user if not provided", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L79"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "authorizer_comment_authorizercomment", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "authorizer_comment_compute_display_name", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L70", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_authorizer_comment_py", "target": "authorizer_comment_create", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L78", "weight": 1.0}, {"source": "authorizer_comment_rationale_79", "target": "authorizer_comment_authorizercomment_create", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L79", "weight": 1.0}], "raw_calls": [{"caller_nid": "authorizer_comment_compute_display_name", "callee": "strftime", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L73"}, {"caller_nid": "authorizer_comment_compute_display_name", "callee": "_", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L75"}, {"caller_nid": "authorizer_comment_create", "callee": "get", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L81"}, {"caller_nid": "authorizer_comment_create", "callee": "get", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L83"}, {"caller_nid": "authorizer_comment_create", "callee": "super", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/authorizer_comment.py", "source_location": "L85"}]}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_5_0_end_migrate_py", "label": "end-migrate.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L1"}, {"id": "end_migrate_migrate", "label": "migrate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L16"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_5_0_end_migrate_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_5_0_end_migrate_py", "target": "end_migrate_migrate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L16", "weight": 1.0}], "raw_calls": [{"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L20"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L31"}, {"caller_nid": "end_migrate_migrate", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L33"}, {"caller_nid": "end_migrate_migrate", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py", "source_location": "L35"}]}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "label": "chatter_message_authorizer.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L1"}, {"id": "chatter_message_authorizer_setup", "label": "setup()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L14"}, {"id": "chatter_message_authorizer_onclickmessageauthorizer", "label": "onClickMessageAuthorizer()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L20"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "chatter", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "patch", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L10", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "hooks", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L11", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "chatter_message_authorizer_setup", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L14", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_chatter_message_authorizer_js", "target": "chatter_message_authorizer_onclickmessageauthorizer", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L20", "weight": 1.0}], "raw_calls": [{"caller_nid": "chatter_message_authorizer_setup", "callee": "useService", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L16"}, {"caller_nid": "chatter_message_authorizer_setup", "callee": "useService", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L17"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "call", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L25"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "map", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L32"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "split", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L32"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "doAction", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L34"}, {"caller_nid": "chatter_message_authorizer_onclickmessageauthorizer", "callee": "warn", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/chatter_message_authorizer.js", "source_location": "L37"}]}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L6", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L7", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L8", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L10", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/__init__.py", "source_location": "L11", "weight": 1.0}], "raw_calls": []}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "label": "timezone_detect.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L1"}, {"id": "timezone_detect_start", "label": "start()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L8"}, {"id": "timezone_detect_detectandsavetimezone", "label": "_detectAndSaveTimezone()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L13"}, {"id": "timezone_detect_getcookie", "label": "_getCookie()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L30"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "target": "public_widget", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "target": "timezone_detect_start", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L8", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "target": "timezone_detect_detectandsavetimezone", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L13", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_timezone_detect_js", "target": "timezone_detect_getcookie", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L30", "weight": 1.0}, {"source": "timezone_detect_start", "target": "timezone_detect_detectandsavetimezone", "relation": "calls", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L10", "weight": 1.0}, {"source": "timezone_detect_detectandsavetimezone", "target": "timezone_detect_getcookie", "relation": "calls", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L22", "weight": 1.0}], "raw_calls": [{"caller_nid": "timezone_detect_start", "callee": "_super", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L9"}, {"caller_nid": "timezone_detect_detectandsavetimezone", "callee": "resolvedOptions", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L16"}, {"caller_nid": "timezone_detect_detectandsavetimezone", "callee": "DateTimeFormat", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L16"}, {"caller_nid": "timezone_detect_detectandsavetimezone", "callee": "catch", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L27"}, {"caller_nid": "timezone_detect_detectandsavetimezone", "callee": "_rpc", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L27"}, {"caller_nid": "timezone_detect_getcookie", "callee": "match", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L31"}, {"caller_nid": "timezone_detect_getcookie", "callee": "decodeURIComponent", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/timezone_detect.js", "source_location": "L32"}]}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/controllers/__init__.py", "source_location": "L6", "weight": 1.0}], "raw_calls": []}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_6_0_end_migrate_py", "label": "end-migrate.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L1"}, {"id": "end_migrate_migrate", "label": "migrate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L24"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_6_0_end_migrate_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L11", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_6_0_end_migrate_py", "target": "end_migrate_migrate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L24", "weight": 1.0}], "raw_calls": [{"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L28"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchone", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L33"}, {"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L35"}, {"caller_nid": "end_migrate_migrate", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L36"}, {"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L39"}, {"caller_nid": "end_migrate_migrate", "callee": "info", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L44"}, {"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L49"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L60"}, {"caller_nid": "end_migrate_migrate", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L62"}, {"caller_nid": "end_migrate_migrate", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.6.0/end-migrate.py", "source_location": "L64"}]}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L1"}, {"id": "init_reactivate_views", "label": "_reactivate_views()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L7"}, {"id": "init_rationale_8", "label": "Ensure all module views are active after install/update. Odoo silently deac", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L8"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_init_py", "target": "init_reactivate_views", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L7", "weight": 1.0}, {"source": "init_rationale_8", "target": "init_reactivate_views", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L8", "weight": 1.0}], "raw_calls": [{"caller_nid": "init_reactivate_views", "callee": "search", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L15"}, {"caller_nid": "init_reactivate_views", "callee": "sudo", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L15"}, {"caller_nid": "init_reactivate_views", "callee": "write", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L20"}, {"caller_nid": "init_reactivate_views", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L21"}, {"caller_nid": "init_reactivate_views", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L26"}, {"caller_nid": "init_reactivate_views", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L28"}, {"caller_nid": "init_reactivate_views", "callee": "getLogger", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L28"}, {"caller_nid": "init_reactivate_views", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__init__.py", "source_location": "L29"}]}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_manifest_py", "label": "__manifest__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/__manifest__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_signature_pad_js", "label": "signature_pad.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/signature_pad.js", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_assessment_form_js", "label": "assessment_form.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/assessment_form.js", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_4_0_end_migrate_py", "label": "end-migrate.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L1"}, {"id": "end_migrate_migrate", "label": "migrate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L16"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_4_0_end_migrate_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_4_0_end_migrate_py", "target": "end_migrate_migrate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L16", "weight": 1.0}], "raw_calls": [{"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L20"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L31"}, {"caller_nid": "end_migrate_migrate", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L33"}, {"caller_nid": "end_migrate_migrate", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py", "source_location": "L35"}]}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_3_0_end_migrate_py", "label": "end-migrate.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L1"}, {"id": "end_migrate_migrate", "label": "migrate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L16"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_3_0_end_migrate_py", "target": "logging", "relation": "imports", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L9", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_migrations_19_0_2_3_0_end_migrate_py", "target": "end_migrate_migrate", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L16", "weight": 1.0}], "raw_calls": [{"caller_nid": "end_migrate_migrate", "callee": "execute", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L20"}, {"caller_nid": "end_migrate_migrate", "callee": "fetchall", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L31"}, {"caller_nid": "end_migrate_migrate", "callee": "warning", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L33"}, {"caller_nid": "end_migrate_migrate", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py", "source_location": "L35"}]}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_portal_search_js", "label": "portal_search.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/portal_search.js", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_loaner_checkout_py", "label": "loaner_checkout.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L1"}, {"id": "loaner_checkout_fusionloanercheckoutassessment", "label": "FusionLoanerCheckoutAssessment", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L6"}, {"id": "loaner_checkout_fusionloanercheckoutassessment_action_view_assessment", "label": ".action_view_assessment()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L17"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_loaner_checkout_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_models_loaner_checkout_py", "target": "loaner_checkout_fusionloanercheckoutassessment", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L6", "weight": 1.0}, {"source": "loaner_checkout_fusionloanercheckoutassessment", "target": "loaner_checkout_fusionloanercheckoutassessment_action_view_assessment", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L17", "weight": 1.0}], "raw_calls": [{"caller_nid": "loaner_checkout_fusionloanercheckoutassessment_action_view_assessment", "callee": "ensure_one", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/models/loaner_checkout.py", "source_location": "L18"}]}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_loaner_portal_js", "label": "loaner_portal.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/loaner_portal.js", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_loaner_portal_js", "target": "public_widget", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/loaner_portal.js", "source_location": "L3", "weight": 1.0}], "raw_calls": []}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_static_src_js_technician_sw_js", "label": "technician_sw.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/static/src/js/technician_sw.js", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_utils_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_utils_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_authorizer_portal_utils_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_authorizer_portal/utils/__init__.py", "source_location": "L3", "weight": 1.0}], "raw_calls": []}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -247,3 +247,24 @@ class FusionBillingService(models.Model):
|
|||||||
sub.action_confirm()
|
sub.action_confirm()
|
||||||
return {'status': 'ok', 'subscription_id': sub.id,
|
return {'status': 'ok', 'subscription_id': sub.id,
|
||||||
'subscription_state': sub.subscription_state}
|
'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_importer
|
||||||
from . import test_reconciliation
|
from . import test_reconciliation
|
||||||
from . import test_invoice_ledger
|
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')
|
@tagged('post_install', '-at_install')
|
||||||
class TestLedgerFamily(TransactionCase):
|
class TestLedgerFamily(TransactionCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
_fc_ensure_ca_billing_env(self.env)
|
||||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||||
|
|
||||||
def test_family_classification(self):
|
def test_family_classification(self):
|
||||||
@@ -47,6 +62,7 @@ class TestLedgerTax(TransactionCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
_fc_ensure_ca_billing_env(self.env)
|
||||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||||
|
|
||||||
def test_tax_for_13pct_is_a_13_percent_sale_tax(self):
|
def test_tax_for_13pct_is_a_13_percent_sale_tax(self):
|
||||||
@@ -68,6 +84,7 @@ class TestLedgerIngest(TransactionCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
_fc_ensure_ca_billing_env(self.env)
|
||||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||||
self.Move = self.env['account.move']
|
self.Move = self.env['account.move']
|
||||||
|
|
||||||
@@ -174,6 +191,7 @@ class TestLedgerVerifiedSync(TransactionCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
_fc_ensure_ca_billing_env(self.env)
|
||||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||||
self.Move = self.env['account.move']
|
self.Move = self.env['account.move']
|
||||||
ICP = self.env['ir.config_parameter'].sudo()
|
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):
|
def setUp(self):
|
||||||
super().setUp()
|
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'})
|
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||||
self.plan_a = self.env['sale.subscription.plan'].sudo().create(
|
self.plan_a = self.env['sale.subscription.plan'].sudo().create(
|
||||||
{'name': 'Plan A', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
{'name': 'Plan A', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||||
@@ -67,7 +68,8 @@ class TestUsageIngestion(TransactionCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
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'})
|
{'name': 'CPU seconds', 'code': 'cpu_seconds', 'aggregation': 'sum'})
|
||||||
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
self.plan = self.env['sale.subscription.plan'].sudo().create(
|
||||||
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
{'name': 'Monthly', 'billing_period_value': 1, 'billing_period_unit': 'month'})
|
||||||
|
|||||||
@@ -13,11 +13,17 @@ class TestWebhookEngine(TransactionCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.service = self.env['fusion.billing.service'].sudo().create({
|
Service = self.env['fusion.billing.service'].sudo()
|
||||||
|
vals = {
|
||||||
'name': 'NexaCloud', 'code': 'nexacloud',
|
'name': 'NexaCloud', 'code': 'nexacloud',
|
||||||
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook',
|
'webhook_url': 'https://api.vps.nexasystems.ca/billing/webhook',
|
||||||
'webhook_secret': 'whsec_test',
|
'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()
|
self.Webhook = self.env['fusion.billing.webhook'].sudo()
|
||||||
|
|
||||||
def test_enqueue_signs_payload(self):
|
def test_enqueue_signs_payload(self):
|
||||||
|
|||||||
@@ -33,14 +33,14 @@ fusion_ringcentral, fusion_tasks
|
|||||||
|
|
||||||
`wizard/odsp_submit_to_odsp_wizard.py` calls into `fusion_faxes.send.fax.wizard` (the fax composer) and reads `partner.x_ff_fax_number` — **but `fusion_faxes` is NOT in `__manifest__.py.depends`**. The fax actions are guarded by `hasattr` checks so the wizard still loads if `fusion_faxes` is missing, but the "Send Fax" / "Send Email + Fax" buttons will fail at click-time. If you're moving this module to a new database, install `fusion_faxes` alongside it.
|
`wizard/odsp_submit_to_odsp_wizard.py` calls into `fusion_faxes.send.fax.wizard` (the fax composer) and reads `partner.x_ff_fax_number` — **but `fusion_faxes` is NOT in `__manifest__.py.depends`**. The fax actions are guarded by `hasattr` checks so the wizard still loads if `fusion_faxes` is missing, but the "Send Fax" / "Send Email + Fax" buttons will fail at click-time. If you're moving this module to a new database, install `fusion_faxes` alongside it.
|
||||||
|
|
||||||
### ⚠ Reverse-dependency: `fusion_authorizer_portal` always installed alongside
|
### ⚠ Reverse-dependency: `fusion_portal` always installed alongside
|
||||||
|
|
||||||
The dependency direction is **`fusion_authorizer_portal` → `fusion_claims`** (hard, declared in fusion_authorizer_portal's manifest), but fusion_claims uses APIs that only exist when fusion_authorizer_portal is installed:
|
The dependency direction is **`fusion_portal` → `fusion_claims`** (hard, declared in fusion_portal's manifest), but fusion_claims uses APIs that only exist when fusion_portal is installed:
|
||||||
|
|
||||||
- `sale.order._apply_pod_signature_to_approval_form` imports `PDFTemplateFiller` from `odoo.addons.fusion_authorizer_portal.utils.pdf_filler` — `ImportError` if missing.
|
- `sale.order._apply_pod_signature_to_approval_form` imports `PDFTemplateFiller` from `odoo.addons.fusion_portal.utils.pdf_filler` — `ImportError` if missing.
|
||||||
- `fusion.page11.sign.request` renders PDFs using `fusion.pdf.template` records — that **model lives in fusion_authorizer_portal**, not here.
|
- `fusion.page11.sign.request` renders PDFs using `fusion.pdf.template` records — that **model lives in fusion_portal**, not here.
|
||||||
- The `/page11/sign/<token>` URL that the Page 11 wizard generates is handled by `fusion_authorizer_portal.controllers.portal_page11_sign` — without it the public signing flow is dead.
|
- The `/page11/sign/<token>` URL that the Page 11 wizard generates is handled by `fusion_portal.controllers.portal_page11_sign` — without it the public signing flow is dead.
|
||||||
- `page11_sign_request._generate_signed_pdf` references `fusion.assessment` records — that model also lives in fusion_authorizer_portal.
|
- `page11_sign_request._generate_signed_pdf` references `fusion.assessment` records — that model also lives in fusion_portal.
|
||||||
|
|
||||||
In practice both modules are always installed together. See §29 for the full integration map.
|
In practice both modules are always installed together. See §29 for the full integration map.
|
||||||
|
|
||||||
@@ -861,7 +861,7 @@ Mirrors the MOD on_hold pattern. `x_fc_odsp_previous_status_before_hold` saves t
|
|||||||
| Method | Used when | Mechanism |
|
| Method | Used when | Mechanism |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `action_sign_sa_mobility_form` | Client signs the SA Mobility form directly (Page 2 client consent) | **Hard-coded coordinates**: writes printed name at `(180, h-180)` and `(72, h-560)`, date at `(350, h-560)`, signature image at `(72, h-540, 200×50px)`. Uses `reportlab.pdfgen.canvas` + `odoo.tools.pdf.PdfFileReader/Writer`. **Brittle** — if the gov PDF layout changes, the coordinates must be re-measured. |
|
| `action_sign_sa_mobility_form` | Client signs the SA Mobility form directly (Page 2 client consent) | **Hard-coded coordinates**: writes printed name at `(180, h-180)` and `(72, h-560)`, date at `(350, h-560)`, signature image at `(72, h-540, 200×50px)`. Uses `reportlab.pdfgen.canvas` + `odoo.tools.pdf.PdfFileReader/Writer`. **Brittle** — if the gov PDF layout changes, the coordinates must be re-measured. |
|
||||||
| `_apply_pod_signature_to_approval_form` | POD signature collected (auto-fired by `write` override when `x_fc_pod_signature` is set) | **PDFTemplateFiller** from `fusion_authorizer_portal` — reads field positions from the active `fusion.pdf.template` (category=`odsp`), uses per-case `x_fc_sa_signature_page`. Configurable via drag-and-drop visual editor, not code. Bypass via `skip_pod_signature_hook=True` context. |
|
| `_apply_pod_signature_to_approval_form` | POD signature collected (auto-fired by `write` override when `x_fc_pod_signature` is set) | **PDFTemplateFiller** from `fusion_portal` — reads field positions from the active `fusion.pdf.template` (category=`odsp`), uses per-case `x_fc_sa_signature_page`. Configurable via drag-and-drop visual editor, not code. Bypass via `skip_pod_signature_hook=True` context. |
|
||||||
|
|
||||||
The PDFTemplateFiller approach is the preferred path going forward — it survives gov form revisions because positions live in the database, not in Python code.
|
The PDFTemplateFiller approach is the preferred path going forward — it survives gov form revisions because positions live in the database, not in Python code.
|
||||||
|
|
||||||
@@ -1588,7 +1588,7 @@ All user-facing text is **Canadian English** (per repo CLAUDE.md). All monetary
|
|||||||
|
|
||||||
74. **`odsp_sa_mobility_wizard._get_template_path()` uses raw `os.path`** instead of Odoo's `tools.misc.file_path`. If the module is ever deployed as a zip (rare in Odoo deployments but possible), this will fail. Migrate to `file_path('fusion_claims/static/src/pdf/sa_mobility_form_template.pdf')` if you ship this for multi-tenant.
|
74. **`odsp_sa_mobility_wizard._get_template_path()` uses raw `os.path`** instead of Odoo's `tools.misc.file_path`. If the module is ever deployed as a zip (rare in Odoo deployments but possible), this will fail. Migrate to `file_path('fusion_claims/static/src/pdf/sa_mobility_form_template.pdf')` if you ship this for multi-tenant.
|
||||||
|
|
||||||
75. **PDF template field positions for ODSP signing live in `fusion.pdf.template` (category=odsp)** — managed via a drag-and-drop editor that lives in `fusion_authorizer_portal`. The OWL editor reads field positions per-page; `_apply_pod_signature_to_approval_form` consumes them. If the gov SA form layout changes, edit the template via the visual editor, not by changing Python coordinates.
|
75. **PDF template field positions for ODSP signing live in `fusion.pdf.template` (category=odsp)** — managed via a drag-and-drop editor that lives in `fusion_portal`. The OWL editor reads field positions per-page; `_apply_pod_signature_to_approval_form` consumes them. If the gov SA form layout changes, edit the template via the visual editor, not by changing Python coordinates.
|
||||||
|
|
||||||
76. **SA Mobility wizard limits rows**: 6 parts, 5 labour, 4 fees. The gov PDF only has that many slots. If the SO has more lines, the rest are silently dropped from the form fill (but still appear in the invoice). The wizard truncates via slicing in `default_get`.
|
76. **SA Mobility wizard limits rows**: 6 parts, 5 labour, 4 fees. The gov PDF only has that many slots. If the SO has more lines, the rest are silently dropped from the form fill (but still appear in the invoice). The wizard truncates via slicing in `default_get`.
|
||||||
|
|
||||||
@@ -1862,11 +1862,11 @@ This module is the **lower-level engine**. Two sibling modules layer on top of i
|
|||||||
|
|
||||||
The whole technician task → sale order coupling lives in `fusion_claims/models/technician_task.py:674` — and the calendar / map / scheduling logic stays in the base `fusion.technician.task` model in fusion_tasks.
|
The whole technician task → sale order coupling lives in `fusion_claims/models/technician_task.py:674` — and the calendar / map / scheduling logic stays in the base `fusion.technician.task` model in fusion_tasks.
|
||||||
|
|
||||||
### 29.2 `fusion_authorizer_portal` (portal layer — undeclared but co-installed)
|
### 29.2 `fusion_portal` (portal layer — undeclared but co-installed)
|
||||||
|
|
||||||
fusion_authorizer_portal manifest declares `fusion_claims` + `fusion_tasks` + `fusion_loaners_management` as hard deps. fusion_claims uses APIs that only exist when fusion_authorizer_portal is installed — see the dependency note at the top of §2.
|
fusion_portal manifest declares `fusion_claims` + `fusion_tasks` + `fusion_loaners_management` as hard deps. fusion_claims uses APIs that only exist when fusion_portal is installed — see the dependency note at the top of §2.
|
||||||
|
|
||||||
| Provided by fusion_authorizer_portal | Used by fusion_claims |
|
| Provided by fusion_portal | Used by fusion_claims |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `PDFTemplateFiller` class (`utils/pdf_filler.py`) | `sale.order._apply_pod_signature_to_approval_form` imports it. Same pattern as Odoo Enterprise Sign module — overlays text/checkmarks/signatures via reportlab Canvas + `mergePage()`. |
|
| `PDFTemplateFiller` class (`utils/pdf_filler.py`) | `sale.order._apply_pod_signature_to_approval_form` imports it. Same pattern as Odoo Enterprise Sign module — overlays text/checkmarks/signatures via reportlab Canvas + `mergePage()`. |
|
||||||
| `fusion.pdf.template` model + `fusion.pdf.template.field` + `fusion.pdf.template.preview` | Drag-and-drop visual editor for placing fields on PDF preview images. Categories: `adp`, `mod`, `odsp`, `hardship`, `other`. fusion_claims searches for `(category='odsp', state='active')` for SA Mobility / OW signature overlays. The Page 11 wizard searches for `name ilike 'adp_page_11'` or `'page 11'`. |
|
| `fusion.pdf.template` model + `fusion.pdf.template.field` + `fusion.pdf.template.preview` | Drag-and-drop visual editor for placing fields on PDF preview images. Categories: `adp`, `mod`, `odsp`, `hardship`, `other`. fusion_claims searches for `(category='odsp', state='active')` for SA Mobility / OW signature overlays. The Page 11 wizard searches for `name ilike 'adp_page_11'` or `'page 11'`. |
|
||||||
@@ -1888,7 +1888,7 @@ fusion_authorizer_portal manifest declares `fusion_claims` + `fusion_tasks` + `f
|
|||||||
- Renaming a field on `sale.order` likely affects portal templates (`portal_templates.xml`, `portal_assessment_express.xml`, `portal_accessibility_*.xml`) that reference it via QWeb.
|
- Renaming a field on `sale.order` likely affects portal templates (`portal_templates.xml`, `portal_assessment_express.xml`, `portal_accessibility_*.xml`) that reference it via QWeb.
|
||||||
- Adding a new `x_fc_adp_application_status` value may need a portal-side handler in `portal_main.py` to render the new state.
|
- Adding a new `x_fc_adp_application_status` value may need a portal-side handler in `portal_main.py` to render the new state.
|
||||||
- The `fusion.pdf.template` schema (page-positioned fields) is the ground truth for ODSP signature placement — DON'T hard-code coordinates in fusion_claims when you could create a template field instead.
|
- The `fusion.pdf.template` schema (page-positioned fields) is the ground truth for ODSP signature placement — DON'T hard-code coordinates in fusion_claims when you could create a template field instead.
|
||||||
- The `_reactivate_views` post-init hook on fusion_authorizer_portal exists specifically because the inheritance from this module's views is fragile — if you rename a field referenced by an xpath in fusion_authorizer_portal, that view goes dead and stays dead.
|
- The `_reactivate_views` post-init hook on fusion_portal exists specifically because the inheritance from this module's views is fragile — if you rename a field referenced by an xpath in fusion_portal, that view goes dead and stays dead.
|
||||||
|
|
||||||
### 29.3 Other co-installed Nexa modules
|
### 29.3 Other co-installed Nexa modules
|
||||||
|
|
||||||
@@ -1896,7 +1896,7 @@ fusion_authorizer_portal manifest declares `fusion_claims` + `fusion_tasks` + `f
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `fusion_ringcentral` | RingCentral softphone, click-to-dial widget, fax composer | Click-to-dial works on any phone field — no direct API calls from this module |
|
| `fusion_ringcentral` | RingCentral softphone, click-to-dial widget, fax composer | Click-to-dial works on any phone field — no direct API calls from this module |
|
||||||
| `fusion_faxes` | `fusion_faxes.send.fax.wizard` + `partner.x_ff_fax_number` | Hard-soft-dep: `odsp_submit_to_odsp_wizard` calls the fax wizard for ODSP submissions |
|
| `fusion_faxes` | `fusion_faxes.send.fax.wizard` + `partner.x_ff_fax_number` | Hard-soft-dep: `odsp_submit_to_odsp_wizard` calls the fax wizard for ODSP submissions |
|
||||||
| `fusion_loaners_management` | Loaner equipment lending | fusion_authorizer_portal depends on this; fusion_claims doesn't touch it directly |
|
| `fusion_loaners_management` | Loaner equipment lending | fusion_portal depends on this; fusion_claims doesn't touch it directly |
|
||||||
| `fusion_pdf_preview` | PDF preview client action + report intercept | Project CLAUDE.md says prefer this over `act_url`+`target=new` for attachments. fusion_claims still has legacy attachment buttons using the old pattern — see gotcha #12 |
|
| `fusion_pdf_preview` | PDF preview client action + report intercept | Project CLAUDE.md says prefer this over `act_url`+`target=new` for attachments. fusion_claims still has legacy attachment buttons using the old pattern — see gotcha #12 |
|
||||||
|
|
||||||
## 30. Per-funder workflow state machines
|
## 30. Per-funder workflow state machines
|
||||||
@@ -2323,7 +2323,7 @@ Creates a `fusion.technician.location` record on the remote with `source='sync'`
|
|||||||
- `context['skip_travel_recalc']` — prevents the pull from triggering local recalculations.
|
- `context['skip_travel_recalc']` — prevents the pull from triggering local recalculations.
|
||||||
- Terminal-state tasks (`completed`, `cancelled`) — push side does write, but pull side does NOT update existing shadow records that are already terminal (defensive against late race conditions).
|
- Terminal-state tasks (`completed`, `cancelled`) — push side does write, but pull side does NOT update existing shadow records that are already terminal (defensive against late race conditions).
|
||||||
|
|
||||||
## 33. `fusion.assessment` (OT assessment model — lives in `fusion_authorizer_portal`)
|
## 33. `fusion.assessment` (OT assessment model — lives in `fusion_portal`)
|
||||||
|
|
||||||
The 1,636-line model that captures an OT's assessment of a client + their equipment needs, then generates the draft sale order.
|
The 1,636-line model that captures an OT's assessment of a client + their equipment needs, then generates the draft sale order.
|
||||||
|
|
||||||
@@ -2395,7 +2395,7 @@ The model has `signature_page_11` + `signature_page_12` binary fields. `signatur
|
|||||||
|
|
||||||
`action_complete_express()` skips step 3 (signatures) entirely — used for the "express" assessment route from the sales-rep portal where the rep just needs to spec a wheelchair without doing the full ADP assessment.
|
`action_complete_express()` skips step 3 (signatures) entirely — used for the "express" assessment route from the sales-rep portal where the rep just needs to spec a wheelchair without doing the full ADP assessment.
|
||||||
|
|
||||||
## 34. `fusion.accessibility.assessment` (MOD/accessibility assessment — lives in `fusion_authorizer_portal`)
|
## 34. `fusion.accessibility.assessment` (MOD/accessibility assessment — lives in `fusion_portal`)
|
||||||
|
|
||||||
The 966-line sibling for accessibility modifications (not ADP).
|
The 966-line sibling for accessibility modifications (not ADP).
|
||||||
|
|
||||||
@@ -2466,7 +2466,7 @@ The model has hundreds of measurement fields, only some of which are visible per
|
|||||||
|
|
||||||
`stairlift_curved`, `vpl`, `ceiling_lift`, `ramp`, `bathroom`, `tub_cutout` each have their own set of fields.
|
`stairlift_curved`, `vpl`, `ceiling_lift`, `ramp`, `bathroom`, `tub_cutout` each have their own set of fields.
|
||||||
|
|
||||||
## 35. `fusion_authorizer_portal` controller routes — detailed
|
## 35. `fusion_portal` controller routes — detailed
|
||||||
|
|
||||||
Full per-route inventory from `portal_main.py` (2,827 lines), `portal_assessment.py` (1,238), `portal_page11_sign.py` (206), `pdf_editor.py` (218).
|
Full per-route inventory from `portal_main.py` (2,827 lines), `portal_assessment.py` (1,238), `portal_page11_sign.py` (206), `pdf_editor.py` (218).
|
||||||
|
|
||||||
@@ -2816,7 +2816,7 @@ All filtered to `move_type in ['out_invoice', 'out_refund']` (customer invoices
|
|||||||
|
|
||||||
ACSD (Assistance to Children with Severe Disabilities) is a CLIENT TYPE, not a sale type. The menu has a dedicated ACSD entry that catches any sale type but with `client_type='ACS'`.
|
ACSD (Assistance to Children with Severe Disabilities) is a CLIENT TYPE, not a sale type. The menu has a dedicated ACSD entry that catches any sale type but with `client_type='ACS'`.
|
||||||
|
|
||||||
## 40. `fusion_authorizer_portal.sale_order` extensions (266 lines)
|
## 40. `fusion_portal.sale_order` extensions (266 lines)
|
||||||
|
|
||||||
Adds 6 fields to `sale.order` + 5 methods:
|
Adds 6 fields to `sale.order` + 5 methods:
|
||||||
|
|
||||||
@@ -2843,7 +2843,7 @@ JSON-RPC methods (called from portal JS):
|
|||||||
`_get_partner_address_display()` — formatted address string.
|
`_get_partner_address_display()` — formatted address string.
|
||||||
`_get_product_lines_for_portal()` — product lines minus internal-only data.
|
`_get_product_lines_for_portal()` — product lines minus internal-only data.
|
||||||
|
|
||||||
## 41. `fusion_authorizer_portal.res_partner` extensions (767 lines)
|
## 41. `fusion_portal.res_partner` extensions (767 lines)
|
||||||
|
|
||||||
Adds geolocation + portal access management:
|
Adds geolocation + portal access management:
|
||||||
|
|
||||||
@@ -3019,7 +3019,7 @@ ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -u fusion_claims --
|
|||||||
ssh odoo-mobility "docker exec odoo-mobility-app odoo -d mobility -u fusion_claims --stop-after-init && docker restart odoo-mobility-app"
|
ssh odoo-mobility "docker exec odoo-mobility-app odoo -d mobility -u fusion_claims --stop-after-init && docker restart odoo-mobility-app"
|
||||||
```
|
```
|
||||||
|
|
||||||
For multiple modules: `-u fusion_claims,fusion_tasks,fusion_authorizer_portal`.
|
For multiple modules: `-u fusion_claims,fusion_tasks,fusion_portal`.
|
||||||
|
|
||||||
### 46.3 Database probes
|
### 46.3 Database probes
|
||||||
|
|
||||||
@@ -3077,7 +3077,7 @@ After 9 rounds of deep diving, here's what CLAUDE.md covers vs the codebase:
|
|||||||
- Every cron job with cadence + logic
|
- Every cron job with cadence + logic
|
||||||
- Every constraint method with regex + rule
|
- Every constraint method with regex + rule
|
||||||
- Every special-character/edge-case behaviour I encountered
|
- Every special-character/edge-case behaviour I encountered
|
||||||
- Every cross-module integration point with both sibling modules (fusion_tasks, fusion_authorizer_portal)
|
- Every cross-module integration point with both sibling modules (fusion_tasks, fusion_portal)
|
||||||
- Every PDF report's conditional sections + business logic
|
- Every PDF report's conditional sections + business logic
|
||||||
- Every ICP setting (~60+)
|
- Every ICP setting (~60+)
|
||||||
- Every gotcha (~83)
|
- Every gotcha (~83)
|
||||||
@@ -3103,4 +3103,4 @@ After 9 rounds of deep diving, here's what CLAUDE.md covers vs the codebase:
|
|||||||
- Build new reports following the established color/header/footer conventions
|
- Build new reports following the established color/header/footer conventions
|
||||||
- Add new gotchas in the right format
|
- Add new gotchas in the right format
|
||||||
- Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview`
|
- Understand the soft-dep on `fusion_faxes` + `fusion_pdf_preview`
|
||||||
- Know the deployment fact that fusion_authorizer_portal is always co-installed
|
- Know the deployment fact that fusion_portal is always co-installed
|
||||||
|
|||||||
@@ -1709,7 +1709,7 @@ class SaleOrder(models.Model):
|
|||||||
return
|
return
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
from odoo.addons.fusion_authorizer_portal.utils.pdf_filler import PDFTemplateFiller
|
from odoo.addons.fusion_portal.utils.pdf_filler import PDFTemplateFiller
|
||||||
|
|
||||||
tpl = self.env['fusion.pdf.template'].search([
|
tpl = self.env['fusion.pdf.template'].search([
|
||||||
('category', '=', 'odsp'), ('state', '=', 'active'),
|
('category', '=', 'odsp'), ('state', '=', 'active'),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## 1. What This Module Is
|
## 1. What This Module Is
|
||||||
|
|
||||||
- **Name**: Fusion Clock.
|
- **Name**: Fusion Clock.
|
||||||
- **Version**: `19.0.3.3.0`.
|
- **Version**: `19.0.4.1.0`.
|
||||||
- **Category**: Human Resources/Attendances.
|
- **Category**: Human Resources/Attendances.
|
||||||
- **License**: OPL-1, Nexa Systems Inc.
|
- **License**: OPL-1, Nexa Systems Inc.
|
||||||
- **Purpose**: complete time and attendance app built on Odoo `hr.attendance`.
|
- **Purpose**: complete time and attendance app built on Odoo `hr.attendance`.
|
||||||
@@ -68,6 +68,7 @@ Custom models:
|
|||||||
| `fusion.clock.leave.request` | `models/clock_leave_request.py` | Portal leave requests, auto-approved but office-notified. |
|
| `fusion.clock.leave.request` | `models/clock_leave_request.py` | Portal leave requests, auto-approved but office-notified. |
|
||||||
| `fusion.clock.correction` | `models/clock_correction.py` | Timesheet correction requests with approve/reject workflow. |
|
| `fusion.clock.correction` | `models/clock_correction.py` | Timesheet correction requests with approve/reject workflow. |
|
||||||
| `fusion.clock.report` | `models/clock_report.py` | Employee or batch pay-period report with PDF/CSV export and email send. |
|
| `fusion.clock.report` | `models/clock_report.py` | Employee or batch pay-period report with PDF/CSV export and email send. |
|
||||||
|
| `fusion.clock.break.rule` | `models/clock_break_rule.py` | Per-province statutory unpaid-break thresholds (2-tier: first break after N1 h, second after N2 h). |
|
||||||
| `fusion.clock.nfc.enrollment.wizard` | `wizard/clock_nfc_enrollment_wizard.py` | Backend NFC card enrolment/reassignment wizard. |
|
| `fusion.clock.nfc.enrollment.wizard` | `wizard/clock_nfc_enrollment_wizard.py` | Backend NFC card enrolment/reassignment wizard. |
|
||||||
|
|
||||||
Inherited models:
|
Inherited models:
|
||||||
@@ -101,7 +102,7 @@ Clock-out flow:
|
|||||||
1. Verify location again.
|
1. Verify location again.
|
||||||
2. Call `_attendance_action_change()`.
|
2. Call `_attendance_action_change()`.
|
||||||
3. Write out-distance.
|
3. Write out-distance.
|
||||||
4. Apply break deduction when configured.
|
4. Break is deducted automatically — `x_fclk_break_minutes` is a stored compute (see §13), not an explicit controller step.
|
||||||
5. Create `early_out` penalty when outside grace.
|
5. Create `early_out` penalty when outside grace.
|
||||||
6. Log `clock_out`.
|
6. Log `clock_out`.
|
||||||
7. Log overtime if computed overtime is positive.
|
7. Log overtime if computed overtime is positive.
|
||||||
@@ -252,7 +253,6 @@ fusion_clock.default_clock_in_time
|
|||||||
fusion_clock.default_clock_out_time
|
fusion_clock.default_clock_out_time
|
||||||
fusion_clock.default_break_minutes
|
fusion_clock.default_break_minutes
|
||||||
fusion_clock.auto_deduct_break
|
fusion_clock.auto_deduct_break
|
||||||
fusion_clock.break_threshold_hours
|
|
||||||
fusion_clock.enable_auto_clockout
|
fusion_clock.enable_auto_clockout
|
||||||
fusion_clock.max_shift_hours
|
fusion_clock.max_shift_hours
|
||||||
fusion_clock.enable_penalties
|
fusion_clock.enable_penalties
|
||||||
@@ -327,8 +327,9 @@ All new JSON endpoints must use `type="jsonrpc"`, not deprecated `type="json"`.
|
|||||||
|
|
||||||
- Always use local-day helpers for date domains. UTC midnight boundaries will break attendance totals around timezone offsets.
|
- Always use local-day helpers for date domains. UTC midnight boundaries will break attendance totals around timezone offsets.
|
||||||
- `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons.
|
- `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons.
|
||||||
- Break deduction is stored as minutes in `hr.attendance.x_fclk_break_minutes`; penalties add to that same field.
|
- **`hr.attendance.x_fclk_break_minutes` is a stored COMPUTE, not a writable field** (`_compute_fclk_break_minutes`): statutory break (per the employee's province `fusion.clock.break.rule`, from actual `worked_hours`, 2-tier — first break after N1 h, second after N2 h, inclusive `>=`) **plus** Σ penalty minutes. It recomputes on every path incl. manual backend create/edit, which is what makes the break auto-apply on manually-entered hours. NEVER `write()` it — change the province rule or toggle `fusion_clock.auto_deduct_break` instead. Penalty minutes are now strictly additive (the old controller `max()` that could swallow a late clock-in penalty is gone). Rule resolved via `hr.employee._get_fclk_break_rule()` (company `state_id` → matching rule → global `is_default` rule). The retired `break_threshold_hours` setting is superseded by per-rule `break1_after_hours`.
|
||||||
- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes.
|
- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes. **Gotcha: `worked_hours` itself subtracts the resource-calendar lunch interval for NON-flexible employees** (Odoo core `hr.attendance._get_worked_hours_in_range`), so the statutory tiers run on lunch-excluded hours; flexible / no-calendar employees get the raw check_in→check_out span. Tests that need a deterministic span give the employee a `flexible_hours` calendar.
|
||||||
|
- **Migration recompute gotcha**: recomputing ONE stored computed field via `env.add_to_compute(field, recs) + recs.flush_recordset([field])` does NOT cascade to fields that depend on it. The `19.0.4.1.0` post-migrate recomputes `x_fclk_break_minutes`, `x_fclk_net_hours` AND `x_fclk_overtime_hours` (in that dependency order, flushing each) — recomputing only the break left historical `net_hours` stale (caught on the entech deploy 2026-06-01).
|
||||||
- Daily overtime compares net hours to the employee's scheduled hours or the daily threshold. (The old `weekly_overtime_threshold` and `grace_period_minutes` settings were removed 2026-05-31 — they were defined/shown but never consumed.)
|
- Daily overtime compares net hours to the employee's scheduled hours or the daily threshold. (The old `weekly_overtime_threshold` and `grace_period_minutes` settings were removed 2026-05-31 — they were defined/shown but never consumed.)
|
||||||
- `fusion_clock.enable_ip_fallback` is honoured: `_verify_location()` only attempts IP-whitelist matching when the toggle is on (default on).
|
- `fusion_clock.enable_ip_fallback` is honoured: `_verify_location()` only attempts IP-whitelist matching when the toggle is on (default on).
|
||||||
- **All fusion_clock Boolean settings are persisted explicitly** (`'True'`/`'False'`) via the `_FCLK_BOOL_PARAMS` loop in `res.config.settings.get_values/set_values`, NOT via `config_parameter=`. Reason: a `config_parameter` Boolean can't be turned OFF (Odoo deletes the param row on a falsy value, so `get_param` returns the default and the feature stays on). When adding a new Boolean setting, add it to `_FCLK_BOOL_PARAMS` with its default; don't use `config_parameter=`.
|
- **All fusion_clock Boolean settings are persisted explicitly** (`'True'`/`'False'`) via the `_FCLK_BOOL_PARAMS` loop in `res.config.settings.get_values/set_values`, NOT via `config_parameter=`. Reason: a `config_parameter` Boolean can't be turned OFF (Odoo deletes the param row on a falsy value, so `get_param` returns the default and the feature stays on). When adding a new Boolean setting, add it to `_FCLK_BOOL_PARAMS` with its default; don't use `config_parameter=`.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Clock',
|
'name': 'Fusion Clock',
|
||||||
'version': '19.0.4.0.3',
|
'version': '19.0.4.2.0',
|
||||||
'category': 'Human Resources/Attendances',
|
'category': 'Human Resources/Attendances',
|
||||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||||
'description': """
|
'description': """
|
||||||
@@ -52,6 +52,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
|||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
# Data
|
# Data
|
||||||
'data/ir_config_parameter_data.xml',
|
'data/ir_config_parameter_data.xml',
|
||||||
|
'data/clock_break_rule_data.xml',
|
||||||
'data/ir_cron_data.xml',
|
'data/ir_cron_data.xml',
|
||||||
# Reports (must load before mail templates that reference them)
|
# Reports (must load before mail templates that reference them)
|
||||||
'report/clock_report_template.xml',
|
'report/clock_report_template.xml',
|
||||||
@@ -71,6 +72,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
|||||||
'views/clock_dashboard_views.xml',
|
'views/clock_dashboard_views.xml',
|
||||||
'views/hr_employee_views.xml',
|
'views/hr_employee_views.xml',
|
||||||
'views/clock_schedule_views.xml',
|
'views/clock_schedule_views.xml',
|
||||||
|
'views/clock_break_rule_views.xml',
|
||||||
# Wizards (must load before clock_menus.xml since menu references wizard action)
|
# Wizards (must load before clock_menus.xml since menu references wizard action)
|
||||||
'wizard/clock_nfc_enrollment_views.xml',
|
'wizard/clock_nfc_enrollment_views.xml',
|
||||||
'wizard/clock_period_picker_views.xml',
|
'wizard/clock_period_picker_views.xml',
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
import base64
|
import base64
|
||||||
import math
|
import math
|
||||||
import logging
|
import logging
|
||||||
import pytz
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from odoo import http, fields, _
|
from odoo import http, fields, _
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
@@ -137,12 +136,6 @@ class FusionClockAPI(http.Controller):
|
|||||||
'date': actual_dt.date() if isinstance(actual_dt, datetime) else get_local_today(request.env, employee),
|
'date': actual_dt.date() if isinstance(actual_dt, datetime) else get_local_today(request.env, employee),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Deduct penalty minutes from attendance (adds to break deduction)
|
|
||||||
current_break = attendance.x_fclk_break_minutes or 0.0
|
|
||||||
attendance.sudo().write({
|
|
||||||
'x_fclk_break_minutes': current_break + deduction,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Log penalty
|
# Log penalty
|
||||||
log_type = 'late_clock_in' if penalty_type == 'late_in' else 'early_clock_out'
|
log_type = 'late_clock_in' if penalty_type == 'late_in' else 'early_clock_out'
|
||||||
request.env['fusion.clock.activity.log'].sudo().create({
|
request.env['fusion.clock.activity.log'].sudo().create({
|
||||||
@@ -158,32 +151,6 @@ class FusionClockAPI(http.Controller):
|
|||||||
if penalty_type == 'late_in':
|
if penalty_type == 'late_in':
|
||||||
employee.sudo().write({'x_fclk_ontime_streak': 0})
|
employee.sudo().write({'x_fclk_ontime_streak': 0})
|
||||||
|
|
||||||
def _apply_break_deduction(self, attendance, employee):
|
|
||||||
"""Apply automatic break deduction if configured."""
|
|
||||||
ICP = request.env['ir.config_parameter'].sudo()
|
|
||||||
if ICP.get_param('fusion_clock.auto_deduct_break', 'True') != 'True':
|
|
||||||
return
|
|
||||||
|
|
||||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
|
||||||
worked = attendance.worked_hours or 0.0
|
|
||||||
|
|
||||||
if worked >= threshold:
|
|
||||||
local_date = get_local_today(request.env, employee)
|
|
||||||
if attendance.check_in:
|
|
||||||
tz_name = (
|
|
||||||
employee.resource_id.tz
|
|
||||||
or (employee.user_id.partner_id.tz if employee.user_id else False)
|
|
||||||
or employee.company_id.partner_id.tz
|
|
||||||
or 'UTC'
|
|
||||||
)
|
|
||||||
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
|
|
||||||
break_min = employee._get_fclk_break_minutes(local_date)
|
|
||||||
current = attendance.x_fclk_break_minutes or 0.0
|
|
||||||
# Set to whichever is higher: configured break or existing (penalty-inflated) value
|
|
||||||
new_val = max(break_min, current)
|
|
||||||
if new_val != current:
|
|
||||||
attendance.sudo().write({'x_fclk_break_minutes': new_val})
|
|
||||||
|
|
||||||
def _log_activity(self, employee, log_type, description, attendance=None,
|
def _log_activity(self, employee, log_type, description, attendance=None,
|
||||||
location=None, latitude=0, longitude=0, distance=0, source='portal'):
|
location=None, latitude=0, longitude=0, distance=0, source='portal'):
|
||||||
"""Create an activity log entry."""
|
"""Create an activity log entry."""
|
||||||
@@ -320,6 +287,11 @@ class FusionClockAPI(http.Controller):
|
|||||||
|
|
||||||
attendance.sudo().write(write_vals)
|
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
|
# Log clock-in
|
||||||
self._log_activity(
|
self._log_activity(
|
||||||
employee, 'clock_in',
|
employee, 'clock_in',
|
||||||
@@ -405,9 +377,6 @@ class FusionClockAPI(http.Controller):
|
|||||||
'x_fclk_out_distance': round(distance, 1),
|
'x_fclk_out_distance': round(distance, 1),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Apply break deduction
|
|
||||||
self._apply_break_deduction(attendance, employee)
|
|
||||||
|
|
||||||
# Check for early clock-out penalty
|
# Check for early clock-out penalty
|
||||||
if not is_scheduled_off:
|
if not is_scheduled_off:
|
||||||
_, scheduled_out = self._get_scheduled_times(employee, today)
|
_, scheduled_out = self._get_scheduled_times(employee, today)
|
||||||
@@ -578,7 +547,10 @@ class FusionClockAPI(http.Controller):
|
|||||||
'is_checked_in': is_checked_in,
|
'is_checked_in': is_checked_in,
|
||||||
'employee_name': employee.name,
|
'employee_name': employee.name,
|
||||||
'enable_clock': employee.x_fclk_enable_clock,
|
'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,
|
'ontime_streak': employee.x_fclk_ontime_streak,
|
||||||
}
|
}
|
||||||
local_today = get_local_today(request.env, employee)
|
local_today = get_local_today(request.env, employee)
|
||||||
@@ -764,7 +736,8 @@ class FusionClockAPI(http.Controller):
|
|||||||
'is_checked_in': is_checked_in,
|
'is_checked_in': is_checked_in,
|
||||||
'check_in': check_in,
|
'check_in': check_in,
|
||||||
'location_name': location_name,
|
'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,
|
'today_hours': today_hours,
|
||||||
'week_hours': week_hours,
|
'week_hours': week_hours,
|
||||||
'overtime_week': round(employee.x_fclk_overtime_this_week or 0, 2),
|
'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_clock_source': 'kiosk',
|
||||||
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
|
'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}",
|
api._log_activity(employee, 'clock_in', f"Kiosk clock-in at {location.name}",
|
||||||
attendance=attendance, location=location,
|
attendance=attendance, location=location,
|
||||||
latitude=0, longitude=0, distance=0, source='kiosk')
|
latitude=0, longitude=0, distance=0, source='kiosk')
|
||||||
@@ -155,7 +158,6 @@ class FusionClockKiosk(http.Controller):
|
|||||||
'x_fclk_out_distance': 0.0,
|
'x_fclk_out_distance': 0.0,
|
||||||
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
||||||
})
|
})
|
||||||
api._apply_break_deduction(attendance, employee)
|
|
||||||
if not is_scheduled_off:
|
if not is_scheduled_off:
|
||||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||||
|
|||||||
@@ -345,6 +345,9 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
'x_fclk_clock_source': 'nfc_kiosk',
|
'x_fclk_clock_source': 'nfc_kiosk',
|
||||||
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
|
'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(
|
api._log_activity(
|
||||||
employee, 'clock_in',
|
employee, 'clock_in',
|
||||||
f"NFC kiosk clock-in at {location.name}",
|
f"NFC kiosk clock-in at {location.name}",
|
||||||
@@ -378,7 +381,6 @@ class FusionClockNfcKiosk(http.Controller):
|
|||||||
'x_fclk_out_distance': 0.0,
|
'x_fclk_out_distance': 0.0,
|
||||||
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
||||||
})
|
})
|
||||||
api._apply_break_deduction(attendance, employee)
|
|
||||||
if not is_scheduled_off:
|
if not is_scheduled_off:
|
||||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||||
|
|||||||
13
fusion_clock/data/clock_break_rule_data.xml
Normal file
13
fusion_clock/data/clock_break_rule_data.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
<record id="break_rule_ontario" model="fusion.clock.break.rule">
|
||||||
|
<field name="name">Ontario</field>
|
||||||
|
<field name="country_id" ref="base.ca"/>
|
||||||
|
<field name="state_id" ref="base.state_ca_on"/>
|
||||||
|
<field name="is_default" eval="True"/>
|
||||||
|
<field name="break1_after_hours">5.0</field>
|
||||||
|
<field name="break1_minutes">30.0</field>
|
||||||
|
<field name="break2_after_hours">10.0</field>
|
||||||
|
<field name="break2_minutes">30.0</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -20,10 +20,6 @@
|
|||||||
<field name="key">fusion_clock.auto_deduct_break</field>
|
<field name="key">fusion_clock.auto_deduct_break</field>
|
||||||
<field name="value">True</field>
|
<field name="value">True</field>
|
||||||
</record>
|
</record>
|
||||||
<record id="config_break_threshold_hours" model="ir.config_parameter">
|
|
||||||
<field name="key">fusion_clock.break_threshold_hours</field>
|
|
||||||
<field name="value">4.0</field>
|
|
||||||
</record>
|
|
||||||
|
|
||||||
<!-- Auto Clock-Out -->
|
<!-- Auto Clock-Out -->
|
||||||
<record id="config_enable_auto_clockout" model="ir.config_parameter">
|
<record id="config_enable_auto_clockout" model="ir.config_parameter">
|
||||||
|
|||||||
26
fusion_clock/migrations/19.0.4.1.0/post-migrate.py
Normal file
26
fusion_clock/migrations/19.0.4.1.0/post-migrate.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from odoo import api, SUPERUSER_ID
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(cr, version):
|
||||||
|
"""Retire the single-threshold break param (superseded by per-rule
|
||||||
|
break1_after_hours), and force-recompute the now-computed break field so
|
||||||
|
existing closed attendances reflect the province rule + their penalties."""
|
||||||
|
cr.execute(
|
||||||
|
"DELETE FROM ir_config_parameter WHERE key = %s",
|
||||||
|
('fusion_clock.break_threshold_hours',),
|
||||||
|
)
|
||||||
|
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||||
|
Attendance = env['hr.attendance']
|
||||||
|
closed = Attendance.search([('check_out', '!=', False)])
|
||||||
|
if closed:
|
||||||
|
# Recompute the break AND everything that derives from it, in dependency
|
||||||
|
# order (break -> net hours -> overtime). Recomputing break alone leaves
|
||||||
|
# stored x_fclk_net_hours / x_fclk_overtime_hours stale, because
|
||||||
|
# add_to_compute + flush of one field does not cascade to its dependents.
|
||||||
|
for fname in ('x_fclk_break_minutes', 'x_fclk_net_hours', 'x_fclk_overtime_hours'):
|
||||||
|
env.add_to_compute(Attendance._fields[fname], closed)
|
||||||
|
closed.flush_recordset([fname])
|
||||||
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"
|
||||||
|
)
|
||||||
@@ -5,6 +5,7 @@ from . import clock_location
|
|||||||
from . import hr_attendance
|
from . import hr_attendance
|
||||||
from . import hr_employee
|
from . import hr_employee
|
||||||
from . import clock_penalty
|
from . import clock_penalty
|
||||||
|
from . import clock_break_rule
|
||||||
from . import clock_report
|
from . import clock_report
|
||||||
from . import res_config_settings
|
from . import res_config_settings
|
||||||
from . import clock_activity_log
|
from . import clock_activity_log
|
||||||
|
|||||||
85
fusion_clock/models/clock_break_rule.py
Normal file
85
fusion_clock/models/clock_break_rule.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
class FusionClockBreakRule(models.Model):
|
||||||
|
_name = 'fusion.clock.break.rule'
|
||||||
|
_description = 'Statutory Break Rule'
|
||||||
|
_order = 'sequence, name'
|
||||||
|
|
||||||
|
name = fields.Char(string='Name', required=True)
|
||||||
|
country_id = fields.Many2one('res.country', string='Country')
|
||||||
|
state_id = fields.Many2one(
|
||||||
|
'res.country.state',
|
||||||
|
string='Province / State',
|
||||||
|
help="Employees whose company is in this province use this rule.",
|
||||||
|
)
|
||||||
|
is_default = fields.Boolean(
|
||||||
|
string='Default Rule',
|
||||||
|
help="Used when an employee's company province matches no other rule. "
|
||||||
|
"Only one active rule may be the default.",
|
||||||
|
)
|
||||||
|
break1_after_hours = fields.Float(
|
||||||
|
string='First Break After (h)', default=5.0,
|
||||||
|
help="Worked hours at or above this trigger the first unpaid break.",
|
||||||
|
)
|
||||||
|
break1_minutes = fields.Float(
|
||||||
|
string='First Break (min)', default=30.0,
|
||||||
|
help="Length of the first unpaid break. 0 disables it.",
|
||||||
|
)
|
||||||
|
break2_after_hours = fields.Float(
|
||||||
|
string='Second Break After (h)', default=10.0,
|
||||||
|
help="Worked hours at or above this add the second unpaid break.",
|
||||||
|
)
|
||||||
|
break2_minutes = fields.Float(
|
||||||
|
string='Second Break (min)', default=30.0,
|
||||||
|
help="Length of the second unpaid break. 0 disables it.",
|
||||||
|
)
|
||||||
|
sequence = fields.Integer(default=10)
|
||||||
|
active = fields.Boolean(default=True)
|
||||||
|
|
||||||
|
def break_minutes_for(self, worked_hours):
|
||||||
|
"""Total statutory unpaid break (minutes) for the given worked hours.
|
||||||
|
|
||||||
|
Tiers are inclusive (``>=``): a break applies when worked hours are
|
||||||
|
equal to or greater than the threshold. The second tier adds on top of
|
||||||
|
the first.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
worked = worked_hours or 0.0
|
||||||
|
total = 0.0
|
||||||
|
if self.break1_minutes and worked >= self.break1_after_hours:
|
||||||
|
total += self.break1_minutes
|
||||||
|
if self.break2_minutes and worked >= self.break2_after_hours:
|
||||||
|
total += self.break2_minutes
|
||||||
|
return total
|
||||||
|
|
||||||
|
@api.constrains('break1_after_hours', 'break1_minutes',
|
||||||
|
'break2_after_hours', 'break2_minutes')
|
||||||
|
def _check_tiers(self):
|
||||||
|
for rule in self:
|
||||||
|
if min(rule.break1_after_hours, rule.break1_minutes,
|
||||||
|
rule.break2_after_hours, rule.break2_minutes) < 0:
|
||||||
|
raise ValidationError(_("Break hours and minutes cannot be negative."))
|
||||||
|
if rule.break2_minutes and rule.break2_after_hours <= rule.break1_after_hours:
|
||||||
|
raise ValidationError(_(
|
||||||
|
"The second break threshold (%(n2)s h) must be greater than "
|
||||||
|
"the first (%(n1)s h).",
|
||||||
|
n2=rule.break2_after_hours, n1=rule.break1_after_hours))
|
||||||
|
|
||||||
|
@api.constrains('is_default', 'active')
|
||||||
|
def _check_single_default(self):
|
||||||
|
for rule in self:
|
||||||
|
if rule.is_default and rule.active:
|
||||||
|
dupe = self.search([
|
||||||
|
('is_default', '=', True), ('active', '=', True),
|
||||||
|
('id', '!=', rule.id),
|
||||||
|
], limit=1)
|
||||||
|
if dupe:
|
||||||
|
raise ValidationError(_(
|
||||||
|
"Only one active break rule can be the default "
|
||||||
|
"(currently: %s).", dupe.name))
|
||||||
@@ -161,9 +161,12 @@ class HrAttendance(models.Model):
|
|||||||
)
|
)
|
||||||
x_fclk_break_minutes = fields.Float(
|
x_fclk_break_minutes = fields.Float(
|
||||||
string='Break (min)',
|
string='Break (min)',
|
||||||
default=0.0,
|
compute='_compute_fclk_break_minutes',
|
||||||
|
store=True,
|
||||||
tracking=True,
|
tracking=True,
|
||||||
help="Break duration in minutes to deduct from worked hours.",
|
help="Unpaid break deducted from worked hours: statutory break (per the "
|
||||||
|
"employee's province rule, from actual hours worked) plus any penalty "
|
||||||
|
"minutes. Computed automatically on every save.",
|
||||||
)
|
)
|
||||||
x_fclk_net_hours = fields.Float(
|
x_fclk_net_hours = fields.Float(
|
||||||
string='Net Hours',
|
string='Net Hours',
|
||||||
@@ -258,6 +261,20 @@ class HrAttendance(models.Model):
|
|||||||
def _search_fclk_in_next_period(self, operator, value):
|
def _search_fclk_in_next_period(self, operator, value):
|
||||||
return self._fclk_period_search('next', operator, value)
|
return self._fclk_period_search('next', operator, value)
|
||||||
|
|
||||||
|
@api.depends('worked_hours', 'check_out',
|
||||||
|
'x_fclk_penalty_ids.penalty_minutes', 'employee_id')
|
||||||
|
def _compute_fclk_break_minutes(self):
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
auto = ICP.get_param('fusion_clock.auto_deduct_break', 'True') == 'True'
|
||||||
|
for att in self:
|
||||||
|
statutory = 0.0
|
||||||
|
if auto and att.check_out and att.employee_id:
|
||||||
|
rule = att.employee_id._get_fclk_break_rule()
|
||||||
|
if rule:
|
||||||
|
statutory = rule.break_minutes_for(att.worked_hours or 0.0)
|
||||||
|
penalties = sum(att.x_fclk_penalty_ids.mapped('penalty_minutes'))
|
||||||
|
att.x_fclk_break_minutes = statutory + penalties
|
||||||
|
|
||||||
@api.depends('worked_hours', 'x_fclk_break_minutes')
|
@api.depends('worked_hours', 'x_fclk_break_minutes')
|
||||||
def _compute_net_hours(self):
|
def _compute_net_hours(self):
|
||||||
for att in self:
|
for att in self:
|
||||||
@@ -314,7 +331,6 @@ class HrAttendance(models.Model):
|
|||||||
|
|
||||||
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0'))
|
max_shift = float(ICP.get_param('fusion_clock.max_shift_hours', '16.0'))
|
||||||
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
|
||||||
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
|
|
||||||
|
|
||||||
now = fields.Datetime.now()
|
now = fields.Datetime.now()
|
||||||
open_attendances = self.sudo().search([('check_out', '=', False)])
|
open_attendances = self.sudo().search([('check_out', '=', False)])
|
||||||
@@ -329,8 +345,9 @@ class HrAttendance(models.Model):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
employee = att.employee_id
|
employee = att.employee_id
|
||||||
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
|
# Owners / attendance-exempt employees are never auto-clocked-out or nagged.
|
||||||
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
|
if employee._fclk_is_attendance_exempt():
|
||||||
|
continue
|
||||||
clock_out_time = effective_deadline
|
clock_out_time = effective_deadline
|
||||||
try:
|
try:
|
||||||
with self.env.cr.savepoint():
|
with self.env.cr.savepoint():
|
||||||
@@ -340,10 +357,6 @@ class HrAttendance(models.Model):
|
|||||||
'x_fclk_grace_used': True,
|
'x_fclk_grace_used': True,
|
||||||
'x_fclk_clock_source': 'auto',
|
'x_fclk_clock_source': 'auto',
|
||||||
})
|
})
|
||||||
if (att.worked_hours or 0) >= threshold:
|
|
||||||
att.sudo().write(
|
|
||||||
{'x_fclk_break_minutes': employee._get_fclk_break_minutes(check_in_date)}
|
|
||||||
)
|
|
||||||
att.sudo().message_post(
|
att.sudo().message_post(
|
||||||
body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} "
|
body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} "
|
||||||
f"(max-shift cap reached). Net hours: {att.x_fclk_net_hours:.1f}h",
|
f"(max-shift cap reached). Net hours: {att.x_fclk_net_hours:.1f}h",
|
||||||
@@ -446,6 +459,9 @@ class HrAttendance(models.Model):
|
|||||||
for emp in employees:
|
for emp in employees:
|
||||||
try:
|
try:
|
||||||
with self.env.cr.savepoint():
|
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)
|
yesterday = get_local_today(self.env, emp) - timedelta(days=1)
|
||||||
|
|
||||||
# Only days the employee was actually scheduled to work
|
# Only days the employee was actually scheduled to work
|
||||||
@@ -488,7 +504,11 @@ class HrAttendance(models.Model):
|
|||||||
'source': 'system',
|
'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_start = yesterday.replace(day=1)
|
||||||
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
|
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
|
||||||
@@ -536,6 +556,9 @@ class HrAttendance(models.Model):
|
|||||||
for emp in employees:
|
for emp in employees:
|
||||||
try:
|
try:
|
||||||
with self.env.cr.savepoint():
|
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)
|
today = get_local_today(self.env, emp)
|
||||||
if not emp._get_fclk_day_plan(today).get('scheduled'):
|
if not emp._get_fclk_day_plan(today).get('scheduled'):
|
||||||
continue
|
continue
|
||||||
@@ -600,6 +623,9 @@ class HrAttendance(models.Model):
|
|||||||
company_name = company.name or ''
|
company_name = company.name or ''
|
||||||
|
|
||||||
for emp in employees:
|
for emp in employees:
|
||||||
|
# Owners / attendance-exempt employees get no weekly summary.
|
||||||
|
if emp._fclk_is_attendance_exempt():
|
||||||
|
continue
|
||||||
if not emp.work_email:
|
if not emp.work_email:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ class HrEmployee(models.Model):
|
|||||||
help="If set, employee must explain a missed clock-out before clocking in again.",
|
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
|
# Kiosk PIN
|
||||||
x_fclk_kiosk_pin = fields.Char(
|
x_fclk_kiosk_pin = fields.Char(
|
||||||
string='Kiosk PIN',
|
string='Kiosk PIN',
|
||||||
@@ -122,6 +134,19 @@ class HrEmployee(models.Model):
|
|||||||
help="Tracks the last date a reminder was sent to avoid duplicates.",
|
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):
|
def _get_fclk_schedule_for_date(self, date):
|
||||||
"""Return this employee's dated Fusion Clock schedule for a local date."""
|
"""Return this employee's dated Fusion Clock schedule for a local date."""
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
@@ -215,6 +240,23 @@ class HrEmployee(models.Model):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_fclk_break_rule(self):
|
||||||
|
"""Return the statutory break rule for this employee.
|
||||||
|
|
||||||
|
Resolution: company's province -> matching rule; else the global default
|
||||||
|
rule; else an empty recordset (caller treats as zero break). Read via
|
||||||
|
sudo so the portal net-hours compute can resolve it without a direct ACL.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
Rule = self.env['fusion.clock.break.rule'].sudo()
|
||||||
|
rule = Rule.browse()
|
||||||
|
state = self.company_id.state_id
|
||||||
|
if state:
|
||||||
|
rule = Rule.search([('state_id', '=', state.id)], limit=1)
|
||||||
|
if not rule:
|
||||||
|
rule = Rule.search([('is_default', '=', True)], limit=1)
|
||||||
|
return rule
|
||||||
|
|
||||||
def _get_fclk_scheduled_times(self, date):
|
def _get_fclk_scheduled_times(self, date):
|
||||||
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
|
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date.
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,6 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
default=30.0,
|
default=30.0,
|
||||||
help="Default unpaid break duration in minutes.",
|
help="Default unpaid break duration in minutes.",
|
||||||
)
|
)
|
||||||
fclk_break_threshold_hours = fields.Float(
|
|
||||||
string='Break Threshold (hours)',
|
|
||||||
config_parameter='fusion_clock.break_threshold_hours',
|
|
||||||
default=4.0,
|
|
||||||
help="Only deduct break if shift is longer than this many hours.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Attendance Rules ───────────────────────────────────────────────
|
# ── Attendance Rules ───────────────────────────────────────────────
|
||||||
fclk_enable_auto_clockout = fields.Boolean(
|
fclk_enable_auto_clockout = fields.Boolean(
|
||||||
|
|||||||
@@ -27,3 +27,4 @@ access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,ba
|
|||||||
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
|
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
|
||||||
access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0
|
access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0
|
||||||
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
|
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
|
||||||
|
access_fusion_clock_break_rule_manager,fusion.clock.break.rule.manager,model_fusion_clock_break_rule,group_fusion_clock_manager,1,1,1,1
|
||||||
|
|||||||
|
@@ -49,6 +49,18 @@
|
|||||||
<field name="comment">Can manage locations, view all attendance, generate reports</field>
|
<field name="comment">Can manage locations, view all attendance, generate reports</field>
|
||||||
</record>
|
</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
|
<!-- Dedicated kiosk-operator permission: can run the shared clock kiosk
|
||||||
(NFC tap / PIN) WITHOUT full Clock Manager access. Gates the
|
(NFC tap / PIN) WITHOUT full Clock Manager access. Gates the
|
||||||
"Fusion Clock Kiosk" app menu and is accepted by the kiosk controllers.
|
"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.todayHours = (result.today_hours || 0).toFixed(1);
|
||||||
this.state.weekHours = (result.week_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;
|
this.state.showReasonDialog = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ from . import test_dashboard
|
|||||||
from . import test_pay_period
|
from . import test_pay_period
|
||||||
from . import test_settings
|
from . import test_settings
|
||||||
from . import test_clock_kiosk
|
from . import test_clock_kiosk
|
||||||
|
from . import test_break_rules
|
||||||
|
from . import test_pending_reason_exempt
|
||||||
|
|||||||
124
fusion_clock/tests/test_break_rules.py
Normal file
124
fusion_clock/tests/test_break_rules.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from odoo.tests import tagged, TransactionCase
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||||
|
class TestBreakRules(TransactionCase):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
super().setUpClass()
|
||||||
|
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||||
|
cls.ICP.set_param('fusion_clock.auto_deduct_break', 'True')
|
||||||
|
cls.Rule = cls.env['fusion.clock.break.rule']
|
||||||
|
cls.default_rule = cls.Rule.search([('is_default', '=', True)], limit=1)
|
||||||
|
# Flexible calendar -> hr.attendance.worked_hours is the raw check_in..check_out
|
||||||
|
# span (no lunch interval subtracted), so the tier math below is deterministic.
|
||||||
|
# This also mirrors real clock-in/out employees, who are effectively flexible.
|
||||||
|
flex = cls.env['resource.calendar'].create({
|
||||||
|
'name': 'FCLK Flexible', 'flexible_hours': True,
|
||||||
|
})
|
||||||
|
cls.employee = cls.env['hr.employee'].create({
|
||||||
|
'name': 'FCLK Break Test', 'resource_calendar_id': flex.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _mk_att(self, hours):
|
||||||
|
check_in = datetime(2026, 1, 5, 9, 0, 0)
|
||||||
|
return self.env['hr.attendance'].create({
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'check_in': check_in,
|
||||||
|
'check_out': check_in + timedelta(hours=hours),
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- Task 1: tier engine + constraints ----
|
||||||
|
def test_break_minutes_for_tiers(self):
|
||||||
|
rule = self.Rule.create({
|
||||||
|
'name': 'Tier Test', 'is_default': False,
|
||||||
|
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||||
|
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||||
|
})
|
||||||
|
self.assertEqual(rule.break_minutes_for(4.99), 0.0)
|
||||||
|
self.assertEqual(rule.break_minutes_for(5.0), 30.0)
|
||||||
|
self.assertEqual(rule.break_minutes_for(9.99), 30.0)
|
||||||
|
self.assertEqual(rule.break_minutes_for(10.0), 60.0)
|
||||||
|
self.assertEqual(rule.break_minutes_for(12.0), 60.0)
|
||||||
|
|
||||||
|
def test_second_tier_must_exceed_first(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.Rule.create({
|
||||||
|
'name': 'Bad', 'is_default': False,
|
||||||
|
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||||
|
'break2_after_hours': 5.0, 'break2_minutes': 30.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_single_default_enforced(self):
|
||||||
|
self.assertTrue(self.default_rule, "seed default rule must exist")
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
self.Rule.create({
|
||||||
|
'name': 'Another Default', 'is_default': True, 'active': True,
|
||||||
|
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||||
|
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ---- Task 2: jurisdiction resolver ----
|
||||||
|
def test_resolver_matches_company_province(self):
|
||||||
|
bc = self.env.ref('base.state_ca_bc')
|
||||||
|
bc_rule = self.Rule.create({
|
||||||
|
'name': 'British Columbia', 'state_id': bc.id, 'is_default': False,
|
||||||
|
'break1_after_hours': 5.0, 'break1_minutes': 30.0,
|
||||||
|
'break2_after_hours': 10.0, 'break2_minutes': 30.0,
|
||||||
|
})
|
||||||
|
self.employee.company_id.state_id = bc.id
|
||||||
|
self.assertEqual(self.employee._get_fclk_break_rule(), bc_rule)
|
||||||
|
|
||||||
|
def test_resolver_falls_back_to_default(self):
|
||||||
|
self.assertTrue(self.default_rule, "seed default rule must exist")
|
||||||
|
alberta = self.env.ref('base.state_ca_ab') # no rule for AB
|
||||||
|
self.employee.company_id.state_id = alberta.id
|
||||||
|
self.assertEqual(self.employee._get_fclk_break_rule(), self.default_rule)
|
||||||
|
|
||||||
|
# ---- Task 3: automatic deduction on every path ----
|
||||||
|
def test_manual_attendance_applies_statutory_break(self):
|
||||||
|
att = self._mk_att(6) # 6h >= 5 -> first break
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 30.0)
|
||||||
|
self.assertAlmostEqual(att.x_fclk_net_hours, 5.5, places=2)
|
||||||
|
|
||||||
|
def test_manual_edit_extends_break(self):
|
||||||
|
att = self._mk_att(6)
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 30.0)
|
||||||
|
att.check_out = att.check_in + timedelta(hours=10) # now >= 10
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 60.0)
|
||||||
|
self.assertAlmostEqual(att.x_fclk_net_hours, 9.0, places=2)
|
||||||
|
|
||||||
|
def test_under_first_threshold_no_break(self):
|
||||||
|
att = self._mk_att(4) # 4h < 5 -> nothing
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||||
|
self.assertAlmostEqual(att.x_fclk_net_hours, 4.0, places=2)
|
||||||
|
|
||||||
|
def test_penalty_minutes_are_additive(self):
|
||||||
|
att = self._mk_att(6) # statutory 30
|
||||||
|
self.env['fusion.clock.penalty'].create({
|
||||||
|
'attendance_id': att.id,
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'penalty_type': 'early_out',
|
||||||
|
'penalty_minutes': 15.0,
|
||||||
|
'date': att.check_in.date(),
|
||||||
|
})
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 45.0)
|
||||||
|
|
||||||
|
def test_master_toggle_off_zero_statutory(self):
|
||||||
|
self.ICP.set_param('fusion_clock.auto_deduct_break', 'False')
|
||||||
|
att = self._mk_att(6)
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||||
|
|
||||||
|
def test_open_attendance_zero_break(self):
|
||||||
|
att = self.env['hr.attendance'].create({
|
||||||
|
'employee_id': self.employee.id,
|
||||||
|
'check_in': datetime(2026, 1, 5, 9, 0, 0),
|
||||||
|
})
|
||||||
|
self.assertEqual(att.x_fclk_break_minutes, 0.0)
|
||||||
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'))
|
||||||
@@ -40,3 +40,4 @@ class TestFusionClockSettings(TransactionCase):
|
|||||||
fields = self.env['res.config.settings']._fields
|
fields = self.env['res.config.settings']._fields
|
||||||
self.assertNotIn('fclk_grace_period_minutes', fields)
|
self.assertNotIn('fclk_grace_period_minutes', fields)
|
||||||
self.assertNotIn('fclk_weekly_overtime_threshold', fields)
|
self.assertNotIn('fclk_weekly_overtime_threshold', fields)
|
||||||
|
self.assertNotIn('fclk_break_threshold_hours', fields)
|
||||||
|
|||||||
79
fusion_clock/views/clock_break_rule_views.xml
Normal file
79
fusion_clock/views/clock_break_rule_views.xml
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_fusion_clock_break_rule_list" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.clock.break.rule.list</field>
|
||||||
|
<field name="model">fusion.clock.break.rule</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list>
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="state_id"/>
|
||||||
|
<field name="country_id" optional="hide"/>
|
||||||
|
<field name="break1_after_hours" widget="float_time"/>
|
||||||
|
<field name="break1_minutes"/>
|
||||||
|
<field name="break2_after_hours" widget="float_time"/>
|
||||||
|
<field name="break2_minutes"/>
|
||||||
|
<field name="is_default"/>
|
||||||
|
<field name="active" widget="boolean_toggle"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_fusion_clock_break_rule_form" model="ir.ui.view">
|
||||||
|
<field name="name">fusion.clock.break.rule.form</field>
|
||||||
|
<field name="model">fusion.clock.break.rule</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form>
|
||||||
|
<sheet>
|
||||||
|
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger"
|
||||||
|
invisible="active"/>
|
||||||
|
<div class="oe_title">
|
||||||
|
<h1><field name="name" placeholder="e.g. Ontario"/></h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group string="Jurisdiction">
|
||||||
|
<field name="country_id"/>
|
||||||
|
<field name="state_id"
|
||||||
|
domain="[('country_id', '=', country_id)]"/>
|
||||||
|
<field name="is_default"/>
|
||||||
|
<field name="active"/>
|
||||||
|
</group>
|
||||||
|
<group string="Unpaid Break Tiers">
|
||||||
|
<label for="break1_after_hours" string="First break after"/>
|
||||||
|
<div class="o_row">
|
||||||
|
<field name="break1_after_hours" widget="float_time"/>
|
||||||
|
<span>h →</span>
|
||||||
|
<field name="break1_minutes"/>
|
||||||
|
<span>min</span>
|
||||||
|
</div>
|
||||||
|
<label for="break2_after_hours" string="Second break after"/>
|
||||||
|
<div class="o_row">
|
||||||
|
<field name="break2_after_hours" widget="float_time"/>
|
||||||
|
<span>h →</span>
|
||||||
|
<field name="break2_minutes"/>
|
||||||
|
<span>min</span>
|
||||||
|
</div>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<p class="text-muted">
|
||||||
|
Breaks are unpaid and deducted from actual worked hours. A tier with
|
||||||
|
0 minutes is disabled. Triggers are inclusive — a break applies when
|
||||||
|
worked hours are equal to or above the threshold.
|
||||||
|
</p>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_fusion_clock_break_rule" model="ir.actions.act_window">
|
||||||
|
<field name="name">Break Rules</field>
|
||||||
|
<field name="res_model">fusion.clock.break.rule</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="context">{'active_test': False}</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">Create a statutory break rule</p>
|
||||||
|
<p>Define unpaid meal-break thresholds per province/country. Employees inherit
|
||||||
|
the rule matching their company's province, or the default rule.</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -196,6 +196,13 @@
|
|||||||
sequence="20"
|
sequence="20"
|
||||||
groups="group_fusion_clock_manager"/>
|
groups="group_fusion_clock_manager"/>
|
||||||
|
|
||||||
|
<menuitem id="menu_fusion_clock_break_rules"
|
||||||
|
name="Break Rules"
|
||||||
|
parent="menu_fusion_clock_config"
|
||||||
|
action="action_fusion_clock_break_rule"
|
||||||
|
sequence="25"
|
||||||
|
groups="group_fusion_clock_manager"/>
|
||||||
|
|
||||||
<menuitem id="menu_fusion_clock_nfc_enrollment"
|
<menuitem id="menu_fusion_clock_nfc_enrollment"
|
||||||
name="Enroll NFC Card"
|
name="Enroll NFC Card"
|
||||||
parent="menu_fusion_clock_config"
|
parent="menu_fusion_clock_config"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<group>
|
<group>
|
||||||
<group string="Configuration">
|
<group string="Configuration">
|
||||||
<field name="x_fclk_enable_clock"/>
|
<field name="x_fclk_enable_clock"/>
|
||||||
|
<field name="x_fclk_exempt_from_attendance"/>
|
||||||
<field name="x_fclk_shift_id"/>
|
<field name="x_fclk_shift_id"/>
|
||||||
<field name="x_fclk_default_location_id"/>
|
<field name="x_fclk_default_location_id"/>
|
||||||
<field name="x_fclk_break_minutes"/>
|
<field name="x_fclk_break_minutes"/>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- Clock link removed from portal home - now handled by fusion_authorizer_portal -->
|
<!-- Clock link removed from portal home - now handled by fusion_portal -->
|
||||||
<template id="portal_my_home_clock" name="Portal My Home: Clock"
|
<template id="portal_my_home_clock" name="Portal My Home: Clock"
|
||||||
inherit_id="portal.portal_my_home" priority="60">
|
inherit_id="portal.portal_my_home" priority="60">
|
||||||
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
|
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
|
||||||
|
|||||||
@@ -28,16 +28,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</setting>
|
</setting>
|
||||||
<setting id="fclk_auto_break" string="Auto-Deduct Break"
|
<setting id="fclk_auto_break" string="Auto-Deduct Break"
|
||||||
help="Automatically deduct unpaid break from worked hours on clock-out.">
|
help="Automatically deduct the statutory unpaid break from worked hours. Break lengths and thresholds are configured per province under Configuration → Break Rules.">
|
||||||
<field name="fclk_auto_deduct_break"/>
|
<field name="fclk_auto_deduct_break"/>
|
||||||
<div class="content-group" invisible="not fclk_auto_deduct_break">
|
<div class="content-group" invisible="not fclk_auto_deduct_break">
|
||||||
<div class="row mt16">
|
<div class="row mt16">
|
||||||
<label for="fclk_default_break_minutes" string="Duration (min)" class="col-lg-5 o_light_label"/>
|
<label for="fclk_default_break_minutes" string="Default scheduling break (min)" class="col-lg-5 o_light_label"/>
|
||||||
<field name="fclk_default_break_minutes"/>
|
<field name="fclk_default_break_minutes"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mt8">
|
<div class="text-muted small mt4">
|
||||||
<label for="fclk_break_threshold_hours" string="Min. Shift" class="col-lg-5 o_light_label"/>
|
Used as the default break when building shifts/schedules
|
||||||
<field name="fclk_break_threshold_hours" widget="float_time"/>
|
(planned hours). Actual deductions follow the province Break Rules.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</setting>
|
</setting>
|
||||||
|
|||||||
@@ -630,8 +630,27 @@ De-Racking → Final inspection → Shipping`
|
|||||||
Columns are first-class — they always render in this exact order, never
|
Columns are first-class — they always render in this exact order, never
|
||||||
reorder, never collapse when empty. Driven by `fp.work.centre.area_kind`
|
reorder, never collapse when empty. Driven by `fp.work.centre.area_kind`
|
||||||
Selection (added 2026-05-23). Each `fp.job.step.area_kind` is computed
|
Selection (added 2026-05-23). Each `fp.job.step.area_kind` is computed
|
||||||
(stored) from `work_centre.area_kind` with a fallback to a step-kind
|
(stored) in `_compute_area_kind` (`fusion_plating_jobs/models/fp_job_step.py`):
|
||||||
dispatch table (`_STEP_KIND_TO_AREA` in `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,
|
**Spec D3:** all wet-line steps (Soak Clean, Electroclean, Acid Dip,
|
||||||
Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide,
|
Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide,
|
||||||
@@ -1832,3 +1851,57 @@ When adding a new admin config, drop it into the right Configuration folder:
|
|||||||
- Generic value lists → Reference Data
|
- Generic value lists → Reference Data
|
||||||
|
|
||||||
Don't add new top-level Configuration entries (siblings of the 7 folders) unless absolutely necessary — Settings is the only one allowed.
|
Don't add new top-level Configuration entries (siblings of the 7 folders) unless absolutely necessary — Settings is the only one allowed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Partial Order Handling — parts fanning across stages (shipped 2026-06-02)
|
||||||
|
|
||||||
|
A 50-part job can have parts at several stages at once (10 Masking, 20 Plating, 20 Baking). The data layer always supported this (`fp.job.step.qty_at_step` = live parked count, computed from `fp.job.step.move` rows); 2026-06-02 made it **visible and operable**. Spec: [`docs/superpowers/specs/2026-06-02-shopfloor-partial-order-handling-design.md`](docs/superpowers/specs/2026-06-02-shopfloor-partial-order-handling-design.md). Versions: `fusion_plating 19.0.22.2.0`, `fusion_plating_jobs 19.0.11.6.0`, `fusion_plating_shopfloor 19.0.36.2.0`. Tracking model = **fluid quantities per stage** for normal flow + existing hold/scrap/rework records for exceptions (no new model, no migration). Close behaviour = **wait to reconverge** (the lifecycle is unchanged; the diverged subset keeps the job open via the existing `qty_done + qty_scrapped == qty` gate).
|
||||||
|
|
||||||
|
**Durable gotchas (non-obvious):**
|
||||||
|
|
||||||
|
1. **The plant kanban emits one card PER (job, stage), keyed by a composite `"{job_id}:{area}"`** — NOT one card per job. `cards` is a dict of composite-key → presence payload; a split job lists its key in several `columns[].card_ids`. See `_job_presences` / `_render_presence` in `plant_kanban.py`. A job with all parts at one stage yields exactly ONE presence (identical to the old board). The PRIMARY presence (active-step column) keeps the full job-level `card_state`; SECONDARY presences derive a simpler state from their own focus step (`_secondary_card_state`). Anything reading the board payload must handle composite keys + multi-column jobs. **A presence is emitted ONLY where parts physically are (`qty_at_step > 0`, incl. the first-active seed) OR a step is `in_progress`/`paused` — NEVER for a merely `ready`/`pending` future step.** These recipes seed EVERY downstream step to `ready` at job creation (not `pending`), so keying presence off `ready` made one job show in every not-yet-started stage at once (WO-30061 bug, fixed 2026-06-02). The old single-card board masked this because `active_step_id` picked just one. Strict sequential progress falls out for free: the `qty_at_step` seed always sits on the lowest-sequence non-terminal step and advances as each completes — so don't add `ready` back to the presence condition.
|
||||||
|
|
||||||
|
2. **`fp.job.step` has NO `qty_done` / `qty_scrapped` fields.** Those live on `fp.job`. The Move controller previously read `from_step.qty_done - from_step.qty_scrapped` for "available to move" → always 0 → the partial-move path was effectively dead. The source of truth for "parts parked here" is **`qty_at_step`** (move preview/commit + rack moves all read it now). Never reintroduce `step.qty_done`.
|
||||||
|
|
||||||
|
3. **The Move Parts dialog was only wired into the DEPRECATED `shopfloor_tablet.js`** — the live `fp_job_workspace` had no move/advance action, so operators literally could not move partial parts. The "Send → <next>" action now lives in `job_workspace.js` (`getStepActions` advance descriptor → `onAdvanceStep` → `FpMovePartsDialog`). The dialog itself was slimmed (qty steppers, no keyboard; Transfer Type + To Location collapsed behind "More options"). If you add another operator surface, wire the advance action there too.
|
||||||
|
|
||||||
|
4. **Partial-flow "light up" lives in `move_controller._do_move_parts_commit` / `_do_move_rack_commit`:** a forward (`transfer_type='step'`) move (a) flips the destination step `pending → ready` so the receiving operator gets an actionable card with no action by anyone, and (b) calls `from_step._fp_try_autofinish_on_drain()` (best-effort, swallows finish-gate UserErrors). It does **not** auto-START the destination — `button_start` stays explicit to keep the labour timer accurate (S16). No auto-ready/auto-finish for hold/scrap/rework moves. **Two non-obvious traps in `_fp_try_autofinish_on_drain` (both fixed 2026-06-02):** (1) it must guard on a real **OUTGOING** move (`move_ids` to a different step, `qty_moved > 0`), NOT `_fp_has_real_incoming()` — the FIRST/seeded stage (e.g. Racking) is fed by the `qty_at_step` seed, has no incoming move, and so never auto-finished when all its parts were sent forward. (2) It is **best-effort and gated**: `button_finish` still runs the required-step-input / sign-off / contract-review gates, so a step with an unrecorded required input (e.g. Racking's "Count the Parts") will NOT auto-finish on drain — it stays `in_progress` with `qty_at_step=0` ("running, 0 here → finish me") until the operator records the input and finishes. That's correct (can't complete a step missing compliance data); don't try to force auto-finish past the gates.
|
||||||
|
|
||||||
|
5. **The predecessor gate is qty-aware: `_fp_should_block_predecessors()` returns False once `_fp_has_real_incoming()` is true** (an incoming move from a different step with `qty_moved > 0`). A step with parts physically parked at it is startable regardless of whether upstream steps are fully done. This is the single source of truth shared by `can_start`, `_compute_blocker`, `button_start`, and the Move dialog's `_blockers_for_move`. **Don't "fix" the predecessor gate back to pure sequence-based** — it would re-lock the next stage while the rest of the batch is still upstream. **Second, distinct trap (fixed 2026-06-02): the Move dialog's `_blockers_for_move` predecessor check must only flag unfinished steps STRICTLY BETWEEN `from_step` and `to_step` (`from_step.sequence < s.sequence < to_step.sequence`), NOT all steps before `to_step`.** The original `s.sequence < to_step.sequence` filter counted the `from_step` itself (which is in-progress *by definition* when you advance partial parts out of it) as an "unfinished predecessor" of the destination — so EVERY partial advance to a not-yet-started next step showed a hard "Predecessor not done: \<from_step\>" blocker and greyed out SEND (hit on WO-30061). The between-only rule allows the immediate-next advance, still blocks skip-ahead moves over incomplete intermediate stages, and leaves backward (rework) moves unblocked (empty range).
|
||||||
|
|
||||||
|
6. **Move-based scrap (`transfer_type='scrap'`) does NOT touch `job.qty_scrapped`.** At close, `button_mark_done` calls `_fp_scrapped_via_moves()` and folds it into `qty_scrapped`, then auto-fills `qty_done = qty − qty_scrapped` (was: blindly `= job.qty`, which over-counted when parts were scrapped). The reconciliation gate is still the safety net.
|
||||||
|
|
||||||
|
**Verification:** the plating modules can't be installed on the local Community dev DB (missing enterprise deps — same reason `fusion_plating` shows `installed=0` in `modsdev`/`fusion-dev`). Static checks done: pyflakes (Python), lxml parse (XML), `node --check` as `.mjs` (JS — `node --check` on a `.js` errors with "Cannot use import statement outside a module"; copy to `/tmp/x.mjs` first). Dynamic tests + browser check require an installed env (entech / odoo-trial).
|
||||||
|
|
||||||
|
### 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. **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,173 @@
|
|||||||
|
# Shop Floor — Partial Order Handling (design)
|
||||||
|
|
||||||
|
- **Date:** 2026-06-02
|
||||||
|
- **Status:** Approved (design), pending implementation plan
|
||||||
|
- **Modules touched:** `fusion_plating_shopfloor`, `fusion_plating_jobs`, `fusion_plating` (core step model)
|
||||||
|
- **Author context:** Nexa Systems / EN Technologies (entech), Odoo 19
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
A plating job runs 50 parts, but parts physically fan out across stages — 10 at Masking, 20 at Plating, 20 at Baking — because the shop processes in racks/waves through tank-limited stations. Today the Shop Floor board collapses each job to **one card in one column** (the single `active_step_id.area_kind`), so an operator standing at Baking has no way to see "10 of this job's parts are here, waiting for me." Operators need to handle the spread-out parts through the different stages, and it must be **easy** (minimal typing, minimal guessing) and **production-ready**.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Operators can **see** a job's parts at every stage where they physically are.
|
||||||
|
- Operators can **advance** a subset to the next stage with near-zero friction.
|
||||||
|
- Failed/held/rework subsets are tracked and visible (not silently lost).
|
||||||
|
- The change is **additive** — it must not redesign the board, the quantity model, the workspace entry contract, or the close/cert/ship/invoice lifecycle.
|
||||||
|
|
||||||
|
## Non-goals (explicitly out of scope for v1)
|
||||||
|
|
||||||
|
- **Partial shipping** — shipping the good parts now and the rest later (split CoCs, split deliveries, partial invoicing). The job reconverges and ships once.
|
||||||
|
- **Named sub-lots** — persistent per-group identity/labels for interchangeable parts.
|
||||||
|
- **Manager-initiated job split** — closing a job at a shipped quantity and spawning a follow-on job for the remainder. Notable as a possible future add-on; operators never touch it.
|
||||||
|
- Multi-cert / multi-delivery per job; per-delivery quantity tracking.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current-state baseline (verified in code)
|
||||||
|
|
||||||
|
**The data layer already supports partial quantities.**
|
||||||
|
- `fp.job.step.qty_at_step` — live "parts parked here" = `sum(incoming moves) − sum(outgoing moves)`, with a first-step seed (the earliest non-terminal step implicitly holds full `job.qty` before any move). Compute at `fusion_plating/models/fp_job_step.py::_compute_qty_at_step`.
|
||||||
|
- `fp.job.step.move` (`fusion_plating/models/fp_job_step_move.py`) — chain-of-custody row per move; `qty_moved` already lets an operator move a subset; `transfer_type` ∈ {step, hold, scrap, rework, split, return}.
|
||||||
|
- `move_parts_commit` (`fusion_plating_shopfloor/controllers/move_controller.py`) already moves a subset and advances `qty_at_step_start/finish`.
|
||||||
|
- `button_finish` already refuses to close a stage while parts are parked there with a downstream step waiting (`fusion_plating/models/fp_job_step.py`).
|
||||||
|
|
||||||
|
**The gap is visibility + interaction.**
|
||||||
|
- `fp.job.active_step_id` picks **one** step by priority (in_progress > paused > ready > pending) — `fusion_plating_jobs/models/fp_job.py::_compute_active_step_id`.
|
||||||
|
- The plant kanban places the whole job in the **one** column of that step — `fusion_plating_shopfloor/controllers/plant_kanban.py::_resolve_card_area` / `_render_card`; the board payload is `cards` (dict keyed by `str(job.id)`) + `columns[].card_ids` (list of job ids); OWL renders one `FpPlantCard` per id (`plant_kanban.xml`, `components/plant_card.js`).
|
||||||
|
- The Move dialog (`move_parts_dialog.{js,xml}`) exposes Transfer Type (6 options) and To Location (6 options) as always-visible dropdowns and uses a raw numeric input for qty.
|
||||||
|
|
||||||
|
**The close lifecycle assumes one job = one quantity = one CoC = one delivery.**
|
||||||
|
- `button_mark_done` (`fusion_plating_jobs/models/fp_job.py`) gates on step-completion, bake, `qty_done + qty_scrapped == qty`, receiving reconciliation, and QC, then calls `_fp_create_delivery()` + `_fp_create_certificates()` (one each).
|
||||||
|
- Auto-advance: last step finished → `awaiting_cert` (if a cert is required) or `awaiting_ship`; cert issue advances `awaiting_cert → awaiting_ship`; cert void regresses. There is an order-level "ship together" constraint (`_fp_order_ship_state`).
|
||||||
|
- No partial-shipment infrastructure exists anywhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Fork | Decision |
|
||||||
|
|---|---|
|
||||||
|
| Tracking model | **C — fluid quantities per stage + existing records for exceptions.** Normal flow uses `qty_at_step`; failed/held/rework subsets ride the existing hold/scrap/rework records. No new core model. |
|
||||||
|
| Board representation | **Option 2 — a card per stage-presence.** A job appears as a card in every stage where it has parts; composite `job:area` card keys. Unsplit jobs render identically to today. |
|
||||||
|
| Operator move interaction | **Easy-advance.** One intent-named "Send to [next] →" action with everything defaulted; steppers / rack-tap instead of a keyboard; Hold/Scrap/Rework as distinct buttons. |
|
||||||
|
| Downstream "light-up" | **Auto-ready on arrival + qty-aware predecessor gate + auto-finish source on drain.** No auto-start (labour accuracy). |
|
||||||
|
| Close behaviour | **Option B — wait to reconverge.** The close/cert/ship/invoice lifecycle is unchanged; the diverged subset keeps the job open via the existing reconciliation gate. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detailed design
|
||||||
|
|
||||||
|
### A. Data model
|
||||||
|
|
||||||
|
No new model and no new core fields are required. The feature reuses:
|
||||||
|
- `fp.job.step.qty_at_step` (live parked count) — already the source of truth.
|
||||||
|
- `fp.job.step.move` + `transfer_type` — already records advance/hold/scrap/rework.
|
||||||
|
- `fusion.plating.quality.hold` — already represents "N parts of this job are held" (the tracked exception group).
|
||||||
|
|
||||||
|
### B. Board — card per stage-presence
|
||||||
|
|
||||||
|
**Backend (`plant_kanban.py`).** Replace the "one area per job" bucketing with per-stage presences. For each in-flight job:
|
||||||
|
|
||||||
|
1. Group the job's **non-terminal** steps by `area_kind` (note: many steps can share an area, e.g. all wet-line steps roll into `plating` per the existing column map).
|
||||||
|
2. For each area, compute:
|
||||||
|
- `qty_here` = sum of `qty_at_step` across that area's non-terminal steps.
|
||||||
|
- `focus_step_id` = the most-actionable step in the area (in_progress > paused > ready > pending) — used for the tap target and the per-presence state.
|
||||||
|
3. **Emit a presence** for an area when `qty_here > 0` **or** any step in the area is in_progress/paused/ready (a started-but-drained stage still shows until finished).
|
||||||
|
4. **Exception presences:** a quality hold on N parts surfaces as a flagged presence ("🔴 N on hold") in the stage it is associated with. *Linkage detail* (hold→step/area vs. hold→job + area inference) is finalized in the plan after reading the hold model; fallback is to attach the held indicator to the job's furthest-along presence. Rework re-entering an earlier stage shows as a normal presence there (optionally flagged "rework"). Scrap is not a presence (counted in `qty_scrapped`, shown in job totals only).
|
||||||
|
|
||||||
|
**Payload schema.** `cards` becomes a dict keyed by composite `"{job_id}:{area}"`; `columns[].card_ids` lists composite keys; a split job lists one key in each occupied column. Each presence payload is the existing card payload **plus**: `area_kind`, `qty_here`, `job_qty`, `focus_step_id`, and a per-presence `card_state` / `state_chip` / `operator` / `step_name` derived from that area's focus step (not the job's global `active_step_id`). Reuse the existing helpers (`_state_chip`, `_compute_tags`, `_due_label`, `_icons`).
|
||||||
|
|
||||||
|
- The job-level `card_state` / `active_step_id` computes stay **unchanged** — they still drive server-side filters and KPI counts.
|
||||||
|
- **KPIs** dedupe by `job_id` (count distinct jobs, not presences).
|
||||||
|
- **mini_timeline** on every presence still shows the **whole-job** spread, so the big picture is visible from any stage.
|
||||||
|
|
||||||
|
**Frontend (`plant_kanban.xml`, `components/plant_card.js`).**
|
||||||
|
- The render loop (`t-foreach columns → card_ids → FpPlantCard`) is **unchanged** — `t-key="card_id"` already works with composite keys.
|
||||||
|
- `FpPlantCard` gains a "**{qty_here} of {job_qty}**" line; `onCardClick` passes `focus_step_id` into the workspace (the workspace already accepts `focus_step_id` — see the FP-STEP scan path in `plant_kanban.js`).
|
||||||
|
- `filteredCardIds` is unchanged (it filters on card payload fields; each presence carries the same searchable fields, so all presences of a matching job show).
|
||||||
|
|
||||||
|
**Unsplit invariant.** A job with all parts at one stage produces exactly one presence → one card in one column → byte-for-byte identical to today. Multi-card behaviour only activates on an actual split.
|
||||||
|
|
||||||
|
### C. Operator flow — easy advance
|
||||||
|
|
||||||
|
**Primary action: "Send to [next stage] →".** Surfaced on the stage presence (card/workspace). Opens a slim confirm pre-set to:
|
||||||
|
- `to_step` = next step in recipe sequence (no guessing).
|
||||||
|
- `qty` = all parked here (`qty_at_step`); adjustable with **± steppers + an "All" preset** — the keyboard never opens for the common case.
|
||||||
|
- `transfer_type` = `step` (hidden); `to_location` = `global` (hidden behind **More options**); `to_tank` = recipe default (existing behaviour).
|
||||||
|
- Compliance prompts render only when the recipe author marked them (unchanged), using pickers, not free text.
|
||||||
|
|
||||||
|
**Racked parts:** when parts are on racks, advancing is **rack-granular** — tap the rack(s) to send, moving that rack's count atomically via the existing **Move Rack** flow. No quantity typed at all.
|
||||||
|
|
||||||
|
**Exceptions get their own intent-named buttons** (replacing the Transfer Type dropdown for everyday use):
|
||||||
|
- **Hold** — pick qty + reason from a picker → reuses the existing hold composer → the subset becomes the tracked held group; the rest stay put.
|
||||||
|
- **Scrap** — qty + reason → counted in `qty_scrapped`.
|
||||||
|
- **Rework** — qty + destination earlier stage → a `transfer_type='rework'` move.
|
||||||
|
|
||||||
|
The full generic Move dialog remains available behind "More options" — we slim the default path, we don't delete capability.
|
||||||
|
|
||||||
|
### D. State machine — the invisible "light-up"
|
||||||
|
|
||||||
|
Three small, additive behaviours so operators never manage stage state manually:
|
||||||
|
|
||||||
|
1. **Auto-ready on arrival.** In `_do_move_parts_commit` (and `_do_move_rack_commit`): after the move + counter advance, if `to_step.state == 'pending'`, set it to `ready`. Never downgrade a step. Result: the receiving operator's column immediately shows a "{qty} of {job_qty} · Ready to start" card with no action by anyone.
|
||||||
|
2. **Qty-aware predecessor gate.** A step that has **real parts parked** (an incoming move with `from_step_id != step` and `qty_moved > 0`) is startable regardless of whether upstream steps are fully done. Applied consistently in `_fp_should_block_predecessors` (used by both `button_start` and the Move dialog's `_blockers_for_move`). Rationale: once parts physically arrive, the predecessor lock is moot.
|
||||||
|
3. **Auto-finish the source on drain-to-zero.** When a move drains `from_step.qty_at_step` to 0 and the step is in_progress with no remaining work, finish it via the existing finish path (generalize the `action_complete_one_to_next` drain→finish pattern to bulk moves). One fewer tap.
|
||||||
|
|
||||||
|
Deliberately **no auto-start** of the receiving step — `button_start` stays an explicit tap because it begins the labour timer (keeps cost/time accurate and avoids the phantom-timer problem the S16 cron already fights).
|
||||||
|
|
||||||
|
**Correctness fix.** The move preview currently derives availability from `from_step.qty_done − from_step.qty_scrapped`; change it to read `qty_at_step` (the live parked count shown on the card) so the pre-filled number **always matches what the operator sees**.
|
||||||
|
|
||||||
|
### E. Close — wait to reconverge (Option B)
|
||||||
|
|
||||||
|
The close/cert/ship/invoice lifecycle is **unchanged**:
|
||||||
|
- `button_mark_done` gates, `_fp_create_certificates` (one CoC), `_fp_create_delivery` (one delivery), and the `awaiting_cert`/`awaiting_ship` transitions stay as-is.
|
||||||
|
- The diverged subset keeps the job open **for free**: with parts in hold/rework, `qty_done + qty_scrapped` cannot equal `qty`, so the existing reconciliation gate simply won't let the job close until those parts resolve (reworked → done, or scrapped → counted). This is the correct behaviour, already enforced.
|
||||||
|
- Normal jobs reconverge at Final Inspection (all parts back in one count) and close once.
|
||||||
|
|
||||||
|
**Hardening (additive).** At close, `qty_done` auto-fill should derive from the quantity that actually **completed the final runnable step** (via the last step's `qty_at_step_finish` / move chain), not assume `job.qty`. Keep the existing reconciliation gate — just feed it an honest number when parts fanned out.
|
||||||
|
|
||||||
|
### F. Reconciliation invariant
|
||||||
|
|
||||||
|
At any time: `job.qty = (parts parked across all stages) + (on hold) + (in rework) + (scrapped) + (completed/shipped)`. The board surfaces the first three as presences; `qty_scrapped` and the final count feed the existing close gate `qty_done + qty_scrapped == qty`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blast radius
|
||||||
|
|
||||||
|
**Changes**
|
||||||
|
- `plant_kanban.py` — per-stage presence rendering + composite keys + KPI dedupe (localized to `_render_card` + the bucketing loop; reuses all existing chip/tag/icon helpers).
|
||||||
|
- `components/plant_card.js` + `plant_kanban.xml` — "{qty} of {job_qty}" line; tap passes `focus_step_id` (~a few lines).
|
||||||
|
- `move_parts_dialog.{js,xml}` — slim "Advance" default (steppers, "All", hidden advanced fields); Hold/Scrap/Rework intent buttons; full dialog behind "More options".
|
||||||
|
- `move_controller.py` — auto-ready on arrival; auto-finish on drain; preview availability/pre-fill from `qty_at_step`.
|
||||||
|
- `fp_job_step.py` — qty-aware predecessor gate; bulk drain→finish helper.
|
||||||
|
- `fp_job.py` — `qty_done` derivation at close (hardening only).
|
||||||
|
|
||||||
|
**Does NOT change**
|
||||||
|
- The quantity model (`qty_at_step`, `fp.job.step.move`).
|
||||||
|
- The OWL component tree (FpTabletLock → header → board → columns → FpPlantCard → FpMiniTimeline), polling, filters, search, station pairing, QR.
|
||||||
|
- The Job Workspace entry contract (`focus_step_id` already supported).
|
||||||
|
- Holds / certs / bake windows / QC.
|
||||||
|
- The close → cert → ship → invoice lifecycle (`button_mark_done`, `_fp_create_certificates`, `_fp_create_delivery`, awaiting_cert/awaiting_ship, order "ship together").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- **No recipe / no steps:** orphan fallback unchanged (Receiving column).
|
||||||
|
- **Multiple steps in one area** (wet-line → plating): presence aggregates `qty_here` across them; `focus_step_id` is the most-actionable.
|
||||||
|
- **First-step seed:** before any move, the whole qty sits at the first stage → one presence = today's single card.
|
||||||
|
- **Recombination:** 30 + 20 both reaching Baking simply read as `qty_here = 50` at that stage (fluid quantities merge automatically).
|
||||||
|
- **Held parts with no step linkage:** held indicator attaches to the job's furthest-along presence (plan-time detail).
|
||||||
|
- **Search match:** every presence of a matching job shows across its columns (each carries the same searchable fields).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Unit:** `qty_at_step` across a multi-stage split; qty-aware predecessor (parts-present → startable); auto-ready pending→ready on move; auto-finish source on drain-to-zero; `qty_done` derivation at close; reconciliation gate still fires with held parts.
|
||||||
|
- **Integration:** board emits N presences for a split job and exactly 1 for an unsplit job; KPIs dedupe by job; tapping a presence opens the workspace on the right step.
|
||||||
|
- **Persona walk:** operator finishes a subset at Plating → taps Send → receiver sees a "Ready" card at Baking with the right qty; 5 parts go on Hold → job stays open until resolved → reworked/closed → one cert, one delivery.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Per-module `__manifest__.py` version bumps so the SCSS/asset bundle busts and any data reloads. Entech is native Odoo (LXC 111, DB `admin`) — standard `-u fusion_plating_shopfloor,fusion_plating_jobs,fusion_plating` upgrade. No data migration required (no new persistent fields; additive behaviours only).
|
||||||
@@ -2074,11 +2074,27 @@ class FpJob(models.Model):
|
|||||||
# the operator reconciles by hand. Mirrors the receiving
|
# the operator reconciles by hand. Mirrors the receiving
|
||||||
# `_update_job_qty_received` pattern: server fills the
|
# `_update_job_qty_received` pattern: server fills the
|
||||||
# obvious default, operator owns the edge cases.
|
# obvious default, operator owns the edge cases.
|
||||||
if (not job.qty_done and not job.qty_scrapped
|
# Partial-order handling (2026-06-02): surface scrap that
|
||||||
|
# was recorded through the Move log (transfer_type='scrap')
|
||||||
|
# into qty_scrapped, so the reconciliation + cert qty stay
|
||||||
|
# honest even when scrap was done from the tablet Move
|
||||||
|
# dialog rather than the qty_scrapped field. Only when the
|
||||||
|
# field hasn't been set by hand.
|
||||||
|
scrap_moves = job._fp_scrapped_via_moves()
|
||||||
|
if scrap_moves and not job.qty_scrapped:
|
||||||
|
job.qty_scrapped = scrap_moves
|
||||||
|
# Clean-close auto-fill: derive the good (done) count from
|
||||||
|
# what physically came in minus scrap, instead of blindly
|
||||||
|
# assuming the whole order completed (which over-counts when
|
||||||
|
# parts were scrapped mid-line). Skips when the operator
|
||||||
|
# already typed qty_done, or when visual rejects make the
|
||||||
|
# split non-obvious — then the gate below makes them
|
||||||
|
# reconcile by hand.
|
||||||
|
if (not job.qty_done
|
||||||
and not (job.qty_visual_inspection_rejects or 0)
|
and not (job.qty_visual_inspection_rejects or 0)
|
||||||
and job.qty_received
|
and job.qty_received
|
||||||
and abs(job.qty_received - job.qty) < 0.0001):
|
and abs(job.qty_received - job.qty) < 0.0001):
|
||||||
job.qty_done = job.qty
|
job.qty_done = job.qty - (job.qty_scrapped or 0)
|
||||||
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
|
accounted = (job.qty_done or 0) + (job.qty_scrapped or 0)
|
||||||
if abs(accounted - job.qty) > 0.0001:
|
if abs(accounted - job.qty) > 0.0001:
|
||||||
raise UserError(_(
|
raise UserError(_(
|
||||||
@@ -2439,6 +2455,19 @@ class FpJob(models.Model):
|
|||||||
fp_skip_step_gate=True,
|
fp_skip_step_gate=True,
|
||||||
).button_mark_done()
|
).button_mark_done()
|
||||||
|
|
||||||
|
def _fp_scrapped_via_moves(self):
|
||||||
|
"""Total parts scrapped through the Move log (transfer_type=
|
||||||
|
'scrap') for this job. Lets button_mark_done's reconciliation
|
||||||
|
count scrap done via the tablet Move dialog, not just the
|
||||||
|
qty_scrapped field (partial-order handling, 2026-06-02)."""
|
||||||
|
self.ensure_one()
|
||||||
|
Move = self.env['fp.job.step.move']
|
||||||
|
moves = Move.sudo().search([
|
||||||
|
('job_id', '=', self.id),
|
||||||
|
('transfer_type', '=', 'scrap'),
|
||||||
|
])
|
||||||
|
return int(sum(m.qty_moved or 0 for m in moves))
|
||||||
|
|
||||||
def _fp_check_advance_post_shop(self):
|
def _fp_check_advance_post_shop(self):
|
||||||
"""Auto-advance in_progress jobs whose recipe steps are all
|
"""Auto-advance in_progress jobs whose recipe steps are all
|
||||||
terminal. Called from fp.job.step.button_finish post-super().
|
terminal. Called from fp.job.step.button_finish post-super().
|
||||||
|
|||||||
@@ -54,12 +54,37 @@ class FpJobStep(models.Model):
|
|||||||
# leak permissive behaviour through a related-field None.
|
# leak permissive behaviour through a related-field None.
|
||||||
if not self.job_id:
|
if not self.job_id:
|
||||||
return True
|
return True
|
||||||
|
# Partial-flow short-circuit (2026-06-02 partial-order handling).
|
||||||
|
# Once REAL parts have physically arrived at this step (a move
|
||||||
|
# parked them here), the predecessor lock is moot — the parts are
|
||||||
|
# on the floor at this station, so the step is startable
|
||||||
|
# regardless of whether upstream steps are fully done. This is
|
||||||
|
# what lets a partial group "light up" the next stage while the
|
||||||
|
# rest of the batch is still being processed upstream. Single
|
||||||
|
# source of truth: every caller (can_start, blocker, button_start,
|
||||||
|
# the Move dialog's _blockers_for_move) inherits this behaviour.
|
||||||
|
if self._fp_has_real_incoming():
|
||||||
|
return False
|
||||||
recipe_seq = self.job_id.enforce_sequential
|
recipe_seq = self.job_id.enforce_sequential
|
||||||
if recipe_seq:
|
if recipe_seq:
|
||||||
return not self.parallel_start
|
return not self.parallel_start
|
||||||
# Free-flow recipe — only the legacy per-step flag still gates.
|
# Free-flow recipe — only the legacy per-step flag still gates.
|
||||||
return bool(self.requires_predecessor_done)
|
return bool(self.requires_predecessor_done)
|
||||||
|
|
||||||
|
def _fp_has_real_incoming(self):
|
||||||
|
"""True when real parts have physically arrived at this step via
|
||||||
|
a move — an incoming move from a DIFFERENT step with qty_moved > 0.
|
||||||
|
|
||||||
|
Distinct from the qty_at_step first-step seed (a notional UI hint
|
||||||
|
with no backing move) and from self-loop measurement moves
|
||||||
|
(from_step == to_step, used by the Record Inputs wizard). Mirrors
|
||||||
|
the has_real_incoming test in core button_finish's qty gate.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
return bool(self.incoming_move_ids.filtered(
|
||||||
|
lambda m: m.from_step_id != self and (m.qty_moved or 0) > 0
|
||||||
|
))
|
||||||
|
|
||||||
def _fp_has_unfinished_predecessors(self):
|
def _fp_has_unfinished_predecessors(self):
|
||||||
"""True when an earlier-sequence step on the same job is not yet
|
"""True when an earlier-sequence step on the same job is not yet
|
||||||
in a terminal state. Composes with _fp_should_block_predecessors
|
in a terminal state. Composes with _fp_should_block_predecessors
|
||||||
@@ -86,6 +111,10 @@ class FpJobStep(models.Model):
|
|||||||
'job_id.enforce_sequential',
|
'job_id.enforce_sequential',
|
||||||
'job_id.step_ids.state',
|
'job_id.step_ids.state',
|
||||||
'job_id.step_ids.sequence',
|
'job_id.step_ids.sequence',
|
||||||
|
# Partial-flow: arriving parts clear the predecessor gate
|
||||||
|
# (_fp_has_real_incoming), so can_start must recompute on move.
|
||||||
|
'incoming_move_ids.qty_moved',
|
||||||
|
'incoming_move_ids.from_step_id',
|
||||||
)
|
)
|
||||||
def _compute_can_start(self):
|
def _compute_can_start(self):
|
||||||
for step in self:
|
for step in self:
|
||||||
@@ -129,47 +158,87 @@ class FpJobStep(models.Model):
|
|||||||
'work_centre_id.area_kind',
|
'work_centre_id.area_kind',
|
||||||
'recipe_node_id.kind_id.area_kind',
|
'recipe_node_id.kind_id.area_kind',
|
||||||
'name',
|
'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):
|
def _compute_area_kind(self):
|
||||||
"""Resolve the plant-view column this step belongs in.
|
"""Resolve the plant-view column this step belongs in.
|
||||||
|
|
||||||
Priority chain:
|
Priority chain (non-gating steps):
|
||||||
1. name-based override for unambiguous de-rack / de-mask steps
|
1. step-NAME override for unambiguous de-rack / de-mask / bake
|
||||||
(2026-06-03): their recipe kind AND/OR work-centre is
|
steps (2026-06-03) — their recipe kind and/or work-centre is
|
||||||
frequently wrong (tagged 'racking'/'mask', a shared station,
|
frequently wrong (tagged 'racking'/'mask', a shared station, or
|
||||||
or left blank), which scattered de-racking cards across the
|
left blank), scattering cards across the Racking / Masking /
|
||||||
Racking / Masking / Plating columns. The operator-facing step
|
Plating columns. The operator-facing NAME is unambiguous, so it
|
||||||
NAME is unambiguous for these, so it wins OUTRIGHT — even over
|
wins OUTRIGHT — even over an explicit work-centre. Bake/oven
|
||||||
an explicit work-centre. Bake/oven steps that merely mention
|
steps that merely mention "de-rack" stay in Baking. See spec
|
||||||
"de-rack" in their name are excluded so they stay in Baking.
|
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
|
||||||
2. work_centre.area_kind (explicit operator setup)
|
2. work_centre.area_kind (explicit operator setup)
|
||||||
3. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
|
3. recipe_node.kind_id.area_kind (kind taxonomy authoritative)
|
||||||
4. catch-all 'plating' (data integrity issue if we land here)
|
4. catch-all 'plating' (data integrity issue if we land here)
|
||||||
|
|
||||||
The kind taxonomy remains the source of truth for every area
|
Gating/marker steps (kind `code == 'gating'` — the "Ready for X"
|
||||||
EXCEPT de-rack/de-mask (step 1). See spec
|
steps) have NO physical location; the taxonomy maps them to
|
||||||
2026-05-24-shopfloor-live-step-fix-design.md Change 6.
|
'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:
|
for step in self:
|
||||||
# 1. Name override (de-rack/de-mask -> De-Racking, bake/oven ->
|
step.area_kind = step._fp_resolve_area_kind()
|
||||||
# Baking) — unambiguous; the authored kind / work-centre is
|
|
||||||
# frequently wrong/blank for these. See _fp_area_from_step_name.
|
def _fp_raw_area_kind(self):
|
||||||
name_area = self._fp_area_from_step_name(step.name)
|
"""Area from this step's OWN name / work_centre / kind only — no
|
||||||
if name_area:
|
look-ahead and no dependence on the computed `area_kind` field (so
|
||||||
step.area_kind = name_area
|
the gating fall-forward below can't recurse).
|
||||||
continue
|
|
||||||
# 2. Explicit work_centre setup
|
Name override (de-rack/de-mask -> De-Racking, bake/oven -> Baking)
|
||||||
if step.work_centre_id and step.work_centre_id.area_kind:
|
wins OUTRIGHT: the authored kind / work-centre is frequently
|
||||||
step.area_kind = step.work_centre_id.area_kind
|
wrong/blank for these. See _fp_area_from_step_name."""
|
||||||
continue
|
self.ensure_one()
|
||||||
# 3. Kind taxonomy
|
name_area = self._fp_area_from_step_name(self.name)
|
||||||
node = step.recipe_node_id
|
if name_area:
|
||||||
if node and node.kind_id and node.kind_id.area_kind:
|
return name_area
|
||||||
step.area_kind = node.kind_id.area_kind
|
if self.work_centre_id and self.work_centre_id.area_kind:
|
||||||
continue
|
return self.work_centre_id.area_kind
|
||||||
# 4. Catch-all — only reached for orphaned steps (no
|
node = self.recipe_node_id
|
||||||
# work_centre AND no recipe_node).
|
if node and node.kind_id and node.kind_id.area_kind:
|
||||||
step.area_kind = 'plating'
|
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
|
@staticmethod
|
||||||
def _fp_area_from_step_name(name):
|
def _fp_area_from_step_name(name):
|
||||||
@@ -266,6 +335,9 @@ class FpJobStep(models.Model):
|
|||||||
'state', 'sequence', 'parallel_start', 'requires_predecessor_done',
|
'state', 'sequence', 'parallel_start', 'requires_predecessor_done',
|
||||||
'job_id.enforce_sequential',
|
'job_id.enforce_sequential',
|
||||||
'job_id.step_ids.state', 'job_id.step_ids.sequence',
|
'job_id.step_ids.state', 'job_id.step_ids.sequence',
|
||||||
|
# Partial-flow: arriving parts clear the predecessor gate.
|
||||||
|
'incoming_move_ids.qty_moved',
|
||||||
|
'incoming_move_ids.from_step_id',
|
||||||
)
|
)
|
||||||
def _compute_blocker(self):
|
def _compute_blocker(self):
|
||||||
for step in self:
|
for step in self:
|
||||||
@@ -701,6 +773,52 @@ class FpJobStep(models.Model):
|
|||||||
).sorted('sequence')
|
).sorted('sequence')
|
||||||
return candidates[:1] or self.env['fp.job.step']
|
return candidates[:1] or self.env['fp.job.step']
|
||||||
|
|
||||||
|
def _fp_try_autofinish_on_drain(self):
|
||||||
|
"""Best-effort auto-finish when a step has drained to zero parked
|
||||||
|
parts (2026-06-02 partial-order handling).
|
||||||
|
|
||||||
|
Called by the Move controller after a bulk move commits. When the
|
||||||
|
last parts leave an in_progress step it should close itself — one
|
||||||
|
fewer tap for the operator. But finishing runs the full gate chain
|
||||||
|
(required inputs, sign-off, contract review, receiving, and the
|
||||||
|
post-shop close gates on the last step). If any gate isn't
|
||||||
|
satisfied we must NOT fail the move that already succeeded — so we
|
||||||
|
swallow the UserError and leave the step in_progress for the
|
||||||
|
operator to finish manually (the board will show it "running, 0
|
||||||
|
here", which reads as "finish me").
|
||||||
|
|
||||||
|
Fires for any step that actually moved parts OUT and drained to
|
||||||
|
zero — INCLUDING the first/seeded stage (its qty comes from the
|
||||||
|
qty_at_step seed, not a real incoming move). Returns True if the
|
||||||
|
step finished.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.state != 'in_progress':
|
||||||
|
return False
|
||||||
|
# qty_at_step is a non-stored compute off the move rows — force a
|
||||||
|
# re-read so we see the just-committed outgoing move.
|
||||||
|
self.invalidate_recordset(['qty_at_step'])
|
||||||
|
if self.qty_at_step != 0:
|
||||||
|
return False
|
||||||
|
# Guard: only auto-finish a step that genuinely moved parts OUT (a
|
||||||
|
# real outgoing move, excluding self-loop measurement moves). The
|
||||||
|
# earlier guard checked _fp_has_real_incoming() — the WRONG
|
||||||
|
# direction: the first/seeded stage (e.g. Racking) is fed by the
|
||||||
|
# qty_at_step seed, not an incoming move, so it never auto-finished
|
||||||
|
# when all its parts were sent forward. Checking for a real
|
||||||
|
# OUTGOING move covers the seeded first stage correctly.
|
||||||
|
if not self.move_ids.filtered(
|
||||||
|
lambda m: m.to_step_id != self and (m.qty_moved or 0) > 0):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self.button_finish()
|
||||||
|
return True
|
||||||
|
except UserError:
|
||||||
|
# Gates still pending (missing prompts / sign-off / etc.) —
|
||||||
|
# leave the step in_progress for a manual finish. The move
|
||||||
|
# itself stands.
|
||||||
|
return False
|
||||||
|
|
||||||
def _fp_has_uncaptured_step_inputs(self):
|
def _fp_has_uncaptured_step_inputs(self):
|
||||||
"""True when the recipe step has REQUIRED step_input prompts
|
"""True when the recipe step has REQUIRED step_input prompts
|
||||||
whose values haven't been recorded yet.
|
whose values haven't been recorded yet.
|
||||||
|
|||||||
@@ -124,8 +124,18 @@ class FpTabletMoveController(http.Controller):
|
|||||||
hasattr(to_step, '_fp_should_block_predecessors')
|
hasattr(to_step, '_fp_should_block_predecessors')
|
||||||
and to_step._fp_should_block_predecessors()
|
and to_step._fp_should_block_predecessors()
|
||||||
):
|
):
|
||||||
|
# Partial-flow (2026-06-02): only an unfinished step STRICTLY
|
||||||
|
# BETWEEN from_step and to_step blocks the move (you'd be skipping
|
||||||
|
# an incomplete intermediate stage). The from_step itself is
|
||||||
|
# in-progress BY DEFINITION when advancing partial parts out of
|
||||||
|
# it — counting it (or any earlier step) as an "unfinished
|
||||||
|
# predecessor" blocked every partial advance to a not-yet-started
|
||||||
|
# next step. Steps before from_step are irrelevant: the parts
|
||||||
|
# being moved are physically at from_step, ready for the next
|
||||||
|
# stage. Backward moves (rework: from > to) yield an empty range
|
||||||
|
# and are never predecessor-blocked.
|
||||||
unfinished = to_step.job_id.step_ids.filtered(
|
unfinished = to_step.job_id.step_ids.filtered(
|
||||||
lambda s: s.sequence < to_step.sequence
|
lambda s: from_step.sequence < s.sequence < to_step.sequence
|
||||||
and s.state not in ('done', 'skipped', 'cancelled')
|
and s.state not in ('done', 'skipped', 'cancelled')
|
||||||
)
|
)
|
||||||
if unfinished:
|
if unfinished:
|
||||||
@@ -147,7 +157,12 @@ class FpTabletMoveController(http.Controller):
|
|||||||
Step = request.env['fp.job.step']
|
Step = request.env['fp.job.step']
|
||||||
from_step = Step.browse(from_step_id)
|
from_step = Step.browse(from_step_id)
|
||||||
to_step = Step.browse(to_step_id)
|
to_step = Step.browse(to_step_id)
|
||||||
qty = (from_step.qty_done or 0) - (from_step.qty_scrapped or 0)
|
# Available-to-move = parts currently parked here (qty_at_step —
|
||||||
|
# the exact number the operator sees on the card). The old
|
||||||
|
# qty_done − qty_scrapped read referenced step fields that don't
|
||||||
|
# exist on fp.job.step (always 0), which is why the move path was
|
||||||
|
# effectively unusable. See partial-order-handling design.
|
||||||
|
qty = from_step.qty_at_step or 0
|
||||||
return {
|
return {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'qty_available': qty,
|
'qty_available': qty,
|
||||||
@@ -186,7 +201,7 @@ class FpTabletMoveController(http.Controller):
|
|||||||
if hard:
|
if hard:
|
||||||
raise UserError(hard[0]['message'])
|
raise UserError(hard[0]['message'])
|
||||||
|
|
||||||
qty_avail = (from_step.qty_done or 0) - (from_step.qty_scrapped or 0)
|
qty_avail = from_step.qty_at_step or 0
|
||||||
move = Move.create({
|
move = Move.create({
|
||||||
'job_id': from_step.job_id.id,
|
'job_id': from_step.job_id.id,
|
||||||
'from_step_id': from_step.id,
|
'from_step_id': from_step.id,
|
||||||
@@ -214,6 +229,28 @@ class FpTabletMoveController(http.Controller):
|
|||||||
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
|
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
|
||||||
from_step.qty_at_step_finish = (from_step.qty_at_step_finish or 0) + qty
|
from_step.qty_at_step_finish = (from_step.qty_at_step_finish or 0) + qty
|
||||||
|
|
||||||
|
# Partial-flow "light up" (2026-06-02 partial-order handling).
|
||||||
|
# A normal forward transfer that parks parts at the destination
|
||||||
|
# makes that stage actionable — flip pending -> ready so the
|
||||||
|
# receiving operator immediately sees a "Ready" card in their
|
||||||
|
# column with zero action by anyone. Never downgrade a step that
|
||||||
|
# is already past pending. Hold/scrap/rework/return route parts
|
||||||
|
# elsewhere and must NOT auto-ready a recipe step, so gate on
|
||||||
|
# transfer_type == 'step'.
|
||||||
|
if transfer_type == 'step' and to_step.state == 'pending':
|
||||||
|
to_step.state = 'ready'
|
||||||
|
# No auto-START — that begins the labour timer, which stays an
|
||||||
|
# explicit operator tap (keeps cost accurate; avoids the S16
|
||||||
|
# phantom-timer problem).
|
||||||
|
|
||||||
|
# Auto-finish the source when THIS forward move drained it to zero
|
||||||
|
# parked parts — one fewer tap. Best-effort: swallows finish-gate
|
||||||
|
# failures so the move always stands. Restricted to 'step' moves:
|
||||||
|
# a step drained by a HOLD still has unresolved held parts and
|
||||||
|
# must not auto-finish.
|
||||||
|
if transfer_type == 'step':
|
||||||
|
from_step._fp_try_autofinish_on_drain()
|
||||||
|
|
||||||
# Manager-bypass audit trail
|
# Manager-bypass audit trail
|
||||||
ctx = request.env.context
|
ctx = request.env.context
|
||||||
bypass_flags = [
|
bypass_flags = [
|
||||||
@@ -279,7 +316,7 @@ class FpTabletMoveController(http.Controller):
|
|||||||
'batches': [
|
'batches': [
|
||||||
{
|
{
|
||||||
'step_id': s.id,
|
'step_id': s.id,
|
||||||
'qty': (s.qty_done or 0) - (s.qty_scrapped or 0),
|
'qty': s.qty_at_step or 0,
|
||||||
'part_number': (s.job_id.product_id.default_code or ''),
|
'part_number': (s.job_id.product_id.default_code or ''),
|
||||||
'wo_number': s.job_id.name or '',
|
'wo_number': s.job_id.name or '',
|
||||||
}
|
}
|
||||||
@@ -343,7 +380,7 @@ class FpTabletMoveController(http.Controller):
|
|||||||
|
|
||||||
moves = []
|
moves = []
|
||||||
for batch in Step.search([('rack_id', '=', rack.id)]):
|
for batch in Step.search([('rack_id', '=', rack.id)]):
|
||||||
qty = (batch.qty_done or 0) - (batch.qty_scrapped or 0)
|
qty = batch.qty_at_step or 0
|
||||||
move = Move.create({
|
move = Move.create({
|
||||||
'job_id': batch.job_id.id,
|
'job_id': batch.job_id.id,
|
||||||
'from_step_id': batch.id,
|
'from_step_id': batch.id,
|
||||||
@@ -353,9 +390,19 @@ class FpTabletMoveController(http.Controller):
|
|||||||
'rack_id': rack.id,
|
'rack_id': rack.id,
|
||||||
'to_tank_id': to_tank_id or False,
|
'to_tank_id': to_tank_id or False,
|
||||||
})
|
})
|
||||||
batch.qty_at_step_finish = qty
|
batch.qty_at_step_finish = (batch.qty_at_step_finish or 0) + qty
|
||||||
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
|
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
|
||||||
moves.append(move.id)
|
moves.append(move.id)
|
||||||
|
# Partial-flow "light up" — auto-finish the drained source
|
||||||
|
# batch (best-effort; see _fp_try_autofinish_on_drain).
|
||||||
|
if transfer_type == 'step':
|
||||||
|
batch._fp_try_autofinish_on_drain()
|
||||||
|
|
||||||
|
# Auto-ready the destination once parts have arrived (pending ->
|
||||||
|
# ready) so the receiving operator sees an actionable card. No
|
||||||
|
# auto-start (labour timer stays an explicit tap).
|
||||||
|
if transfer_type == 'step' and to_step.state == 'pending':
|
||||||
|
to_step.state = 'ready'
|
||||||
|
|
||||||
rack.racking_state = 'in_use'
|
rack.racking_state = 'in_use'
|
||||||
return {'move_ids': moves, 'count': len(moves)}
|
return {'move_ids': moves, 'count': len(moves)}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md.
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime
|
||||||
|
|
||||||
from odoo import _, http
|
from odoo import _, http
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
@@ -110,19 +110,28 @@ class PlantKanbanController(http.Controller):
|
|||||||
|
|
||||||
jobs = Job.search(domain, limit=500)
|
jobs = Job.search(domain, limit=500)
|
||||||
|
|
||||||
# Bucket by area_kind of the active step (or 'receiving' when no
|
# Partial-order handling (2026-06-02): a job shows up as a card in
|
||||||
# active step yet — matches the contract_review / no_parts states
|
# EVERY stage where it currently has parts (a "presence"), not just
|
||||||
# that live in Receiving column per spec §3 D5).
|
# the single active-step column. Cards are keyed by a composite
|
||||||
|
# "{job_id}:{area}" so one job can appear in several columns. A job
|
||||||
|
# whose parts are all at one stage produces exactly one presence —
|
||||||
|
# byte-for-byte identical to the previous one-card-per-job board.
|
||||||
cards = {}
|
cards = {}
|
||||||
cards_by_area = {area: [] for area, _label in _COLUMN_LABELS}
|
cards_by_area = {area: [] for area, _label in _COLUMN_LABELS}
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
area = _resolve_card_area(job)
|
active_area = (job.active_step_id.area_kind
|
||||||
cards_by_area.setdefault(area, []).append(job.id)
|
if job.active_step_id else _resolve_card_area(job))
|
||||||
cards[str(job.id)] = _render_card(job, paired)
|
for area, focus_step, qty_here in _job_presences(job):
|
||||||
|
key = '%s:%s' % (job.id, area)
|
||||||
|
cards[key] = _render_presence(
|
||||||
|
job, area, focus_step, qty_here,
|
||||||
|
area == active_area, paired,
|
||||||
|
)
|
||||||
|
cards_by_area.setdefault(area, []).append(key)
|
||||||
|
|
||||||
# Sort within each column by priority then due date
|
# Sort within each column by priority then due date
|
||||||
for area in cards_by_area:
|
for area in cards_by_area:
|
||||||
cards_by_area[area].sort(key=lambda jid: _sort_key(cards[str(jid)]))
|
cards_by_area[area].sort(key=lambda k: _sort_key(cards[k]))
|
||||||
|
|
||||||
columns = [
|
columns = [
|
||||||
{
|
{
|
||||||
@@ -251,21 +260,109 @@ def _resolve_card_area(job):
|
|||||||
return 'receiving'
|
return 'receiving'
|
||||||
|
|
||||||
|
|
||||||
def _render_card(job, paired):
|
def _job_presences(job):
|
||||||
"""Build the full card payload for one fp.job."""
|
"""Return the list of (area, focus_step, qty_here) presences for a job.
|
||||||
# Sudo the job recordset so cross-module field reads (sale.order,
|
|
||||||
# fp.part.catalog, fusion.plating.customer.spec) don't AccessError
|
One entry per Shop Floor area where the job currently has parts parked
|
||||||
# for low-privilege roles like Technician. The output is denormalized
|
OR an actionable (in_progress / paused / ready) step. This is what lets
|
||||||
# display data; the underlying record visibility is controlled by the
|
a split job appear in several columns at once. A job whose parts are
|
||||||
# caller's fp.job ACL (Technician can read all jobs).
|
all at one stage yields exactly ONE presence — byte-for-byte identical
|
||||||
|
to the previous one-card-per-job board.
|
||||||
|
"""
|
||||||
|
job = job.sudo()
|
||||||
|
Step = job.env['fp.job.step']
|
||||||
|
# Post-shop + no-parts states are single-column, state-driven (mirrors
|
||||||
|
# _resolve_card_area). No per-stage fan-out once the job has cleared
|
||||||
|
# the line or hasn't received parts yet.
|
||||||
|
if job.card_state == 'no_parts':
|
||||||
|
return [('receiving', job.active_step_id, 0)]
|
||||||
|
if job.state == 'awaiting_cert':
|
||||||
|
return [('inspection', Step, 0)]
|
||||||
|
if job.state == 'awaiting_ship':
|
||||||
|
return [('shipping', Step, 0)]
|
||||||
|
|
||||||
|
open_steps = job.step_ids.filtered(
|
||||||
|
lambda s: s.state not in ('done', 'skipped', 'cancelled')
|
||||||
|
)
|
||||||
|
by_area = {}
|
||||||
|
for s in open_steps:
|
||||||
|
by_area.setdefault(s.area_kind or 'plating', []).append(s)
|
||||||
|
|
||||||
|
presences = []
|
||||||
|
for area, steps in by_area.items():
|
||||||
|
qty_here = sum((s.qty_at_step or 0) for s in steps)
|
||||||
|
# A stage shows ONLY where parts physically are (qty_here > 0 —
|
||||||
|
# which includes the first-active step's qty_at_step seed) OR where
|
||||||
|
# a step is actively being worked (in_progress / paused — e.g.
|
||||||
|
# drained to zero but not yet finished). A merely `ready` / `pending`
|
||||||
|
# step with NO parts is a FUTURE stage and must NOT show — otherwise
|
||||||
|
# the job appears in every not-yet-started step at once (these
|
||||||
|
# recipes seed all downstream steps to `ready`, so 6 ready steps =
|
||||||
|
# 6 phantom cards; bug on WO-30061). Strict sequential progress
|
||||||
|
# falls out for free because the qty_at_step seed always sits on the
|
||||||
|
# lowest-sequence non-terminal step and advances as each completes.
|
||||||
|
being_worked = any(
|
||||||
|
s.state in ('in_progress', 'paused') for s in steps
|
||||||
|
)
|
||||||
|
if qty_here > 0 or being_worked:
|
||||||
|
presences.append((area, _pick_focus_step(steps), qty_here))
|
||||||
|
|
||||||
|
if not presences:
|
||||||
|
# Nothing parked and nothing actionable — fall back to the single
|
||||||
|
# resolved column so the job never vanishes from the board.
|
||||||
|
return [(_resolve_card_area(job), job.active_step_id, 0)]
|
||||||
|
return presences
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_focus_step(steps):
|
||||||
|
"""The most-actionable step in an area: in_progress > paused > ready >
|
||||||
|
pending, lowest sequence within a state. Drives the presence card's
|
||||||
|
step label, operator pill, and tap target (focus_step_id)."""
|
||||||
|
ordered = sorted(steps, key=lambda s: s.sequence or 0)
|
||||||
|
for state in ('in_progress', 'paused', 'ready', 'pending'):
|
||||||
|
for s in ordered:
|
||||||
|
if s.state == state:
|
||||||
|
return s
|
||||||
|
return ordered[0] if ordered else None
|
||||||
|
|
||||||
|
|
||||||
|
def _secondary_card_state(step, paired):
|
||||||
|
"""Card state for a NON-primary presence (a stage other than the job's
|
||||||
|
active step). Derived purely from the focus step so the operator at
|
||||||
|
that stage gets an honest 'running' / 'ready' chip. The PRIMARY
|
||||||
|
presence keeps the full job-level card_state (holds, QC, bake, etc.)."""
|
||||||
|
if not step:
|
||||||
|
return 'ready'
|
||||||
|
mine = bool(
|
||||||
|
paired and step.work_centre_id
|
||||||
|
and step.work_centre_id.id == paired.id
|
||||||
|
)
|
||||||
|
if step.state == 'in_progress':
|
||||||
|
return 'running_mine' if mine else 'running'
|
||||||
|
if step.state == 'paused':
|
||||||
|
return 'running'
|
||||||
|
# ready / pending → queued at this stage
|
||||||
|
return 'ready_mine' if mine else 'ready'
|
||||||
|
|
||||||
|
|
||||||
|
def _render_presence(job, area, step, qty_here, is_primary, paired):
|
||||||
|
"""Build a card payload for one (job, stage) presence.
|
||||||
|
|
||||||
|
The PRIMARY presence (the job's active-step column) carries the full
|
||||||
|
job-level card_state so every existing job-level signal (hold, QC,
|
||||||
|
bake-due, sign-off, idle, post-shop) renders exactly as before.
|
||||||
|
SECONDARY presences derive a simpler state from their own focus step.
|
||||||
|
|
||||||
|
Sudo the job so cross-module reads (sale.order, fp.part.catalog,
|
||||||
|
customer.spec) don't AccessError for low-privilege roles (Rule 13m) —
|
||||||
|
the output is denormalized display data; fp.job ACL gates visibility.
|
||||||
|
"""
|
||||||
job = job.sudo()
|
job = job.sudo()
|
||||||
step = job.active_step_id
|
|
||||||
try:
|
try:
|
||||||
timeline = json.loads(job.mini_timeline_json or '[]')
|
timeline = json.loads(job.mini_timeline_json or '[]')
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
timeline = []
|
timeline = []
|
||||||
|
|
||||||
# Cross-module field probes (sudo'd via job.sudo() above)
|
|
||||||
part = job.part_catalog_id if 'part_catalog_id' in job._fields else None
|
part = job.part_catalog_id if 'part_catalog_id' in job._fields else None
|
||||||
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else None
|
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else None
|
||||||
so = job.sale_order_id
|
so = job.sale_order_id
|
||||||
@@ -274,10 +371,11 @@ def _render_card(job, paired):
|
|||||||
if so and 'x_fc_po_number' in so._fields:
|
if so and 'x_fc_po_number' in so._fields:
|
||||||
po_number = so.x_fc_po_number or ''
|
po_number = so.x_fc_po_number or ''
|
||||||
|
|
||||||
# Tag chips (Rush / FAIR / VIP / AS9100 — only render when applicable)
|
|
||||||
tags = _compute_tags(job, part, spec)
|
tags = _compute_tags(job, part, spec)
|
||||||
|
|
||||||
# Step + tank labels
|
card_state = (job.card_state if is_primary
|
||||||
|
else _secondary_card_state(step, paired))
|
||||||
|
|
||||||
step_name = step.name if step else _('—')
|
step_name = step.name if step else _('—')
|
||||||
step_seq = step.sequence if step else 0
|
step_seq = step.sequence if step else 0
|
||||||
step_total = len(job.step_ids)
|
step_total = len(job.step_ids)
|
||||||
@@ -285,23 +383,15 @@ def _render_card(job, paired):
|
|||||||
if step and step.work_centre_id:
|
if step and step.work_centre_id:
|
||||||
tank_label = step.work_centre_id.name or step.work_centre_id.code or ''
|
tank_label = step.work_centre_id.name or step.work_centre_id.code or ''
|
||||||
|
|
||||||
# State chip
|
state_chip = _state_chip(card_state, step)
|
||||||
state_chip = _state_chip(job.card_state, step)
|
|
||||||
|
|
||||||
# Operator pill (only when step has an assigned user)
|
|
||||||
operator = None
|
operator = None
|
||||||
if step and step.assigned_user_id:
|
if step and step.assigned_user_id:
|
||||||
u = step.assigned_user_id
|
u = step.assigned_user_id
|
||||||
operator = {
|
operator = {'id': u.id, 'name': u.name, 'initials': _initials_for(u)}
|
||||||
'id': u.id,
|
|
||||||
'name': u.name,
|
|
||||||
'initials': _initials_for(u),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Icon row
|
|
||||||
icons = _icons(job, step)
|
icons = _icons(job, step)
|
||||||
|
|
||||||
# Due label
|
|
||||||
due_label = _due_label(job.date_deadline) if job.date_deadline else ''
|
due_label = _due_label(job.date_deadline) if job.date_deadline else ''
|
||||||
is_overdue = (
|
is_overdue = (
|
||||||
bool(job.date_deadline)
|
bool(job.date_deadline)
|
||||||
@@ -311,9 +401,17 @@ def _render_card(job, paired):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
'job_id': job.id,
|
'job_id': job.id,
|
||||||
|
# Composite identity — one job can have several presences.
|
||||||
|
'card_key': '%s:%s' % (job.id, area),
|
||||||
|
'area_kind': area,
|
||||||
|
'is_primary': is_primary,
|
||||||
|
# Partial-order fields: parts parked at THIS stage vs whole job.
|
||||||
|
'qty_here': int(qty_here or 0),
|
||||||
|
'job_qty': int(job.qty or 0),
|
||||||
|
'focus_step_id': step.id if step else False,
|
||||||
'wo_name': job.display_wo_name or job.name or '',
|
'wo_name': job.display_wo_name or job.name or '',
|
||||||
'is_mine': job.card_state in ('ready_mine', 'running_mine'),
|
'is_mine': card_state in ('ready_mine', 'running_mine'),
|
||||||
'card_state': job.card_state or '',
|
'card_state': card_state or '',
|
||||||
'due_date': (job.date_deadline.strftime('%Y-%m-%d')
|
'due_date': (job.date_deadline.strftime('%Y-%m-%d')
|
||||||
if job.date_deadline else None),
|
if job.date_deadline else None),
|
||||||
'due_label': due_label,
|
'due_label': due_label,
|
||||||
|
|||||||
@@ -90,6 +90,11 @@ class FpWorkspaceController(http.Controller):
|
|||||||
# Drives the embedded rack-split panel inside this step's row.
|
# Drives the embedded rack-split panel inside this step's row.
|
||||||
'is_racking': step.area_kind == 'racking',
|
'is_racking': step.area_kind == 'racking',
|
||||||
'state': step.state,
|
'state': step.state,
|
||||||
|
# Partial-order handling — parts currently parked at this
|
||||||
|
# step. Drives the "Send to next" button visibility + the
|
||||||
|
# per-step "N here" hint; the Move dialog pre-fills from the
|
||||||
|
# same number via the preview endpoint.
|
||||||
|
'qty_at_step': int(getattr(step, 'qty_at_step', 0) or 0),
|
||||||
'assigned_user_id': step.assigned_user_id.id or False,
|
'assigned_user_id': step.assigned_user_id.id or False,
|
||||||
'assigned_user_name': step.assigned_user_id.name or '',
|
'assigned_user_name': step.assigned_user_id.name or '',
|
||||||
'work_centre_name': step.work_centre_id.name or '',
|
'work_centre_name': step.work_centre_id.name or '',
|
||||||
|
|||||||
@@ -60,11 +60,15 @@ export class FpPlantCard extends Component {
|
|||||||
onCardClick() {
|
onCardClick() {
|
||||||
const c = this.props.card;
|
const c = this.props.card;
|
||||||
if (!c.job_id) return;
|
if (!c.job_id) return;
|
||||||
|
// Open the workspace focused on THIS stage's step (partial-order
|
||||||
|
// handling) — tapping the Baking card lands on the Baking step,
|
||||||
|
// not the job's global active step. The workspace already accepts
|
||||||
|
// focus_step_id (see the FP-STEP scan path in plant_kanban.js).
|
||||||
this.action.doAction({
|
this.action.doAction({
|
||||||
type: "ir.actions.client",
|
type: "ir.actions.client",
|
||||||
tag: "fp_job_workspace",
|
tag: "fp_job_workspace",
|
||||||
target: "current",
|
target: "current",
|
||||||
params: { job_id: c.job_id },
|
params: { job_id: c.job_id, focus_step_id: c.focus_step_id || false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,13 +31,14 @@ import { FpRackPartsDialog } from "./rack_parts_dialog";
|
|||||||
import { FpDamageDialog } from "./fp_damage_dialog";
|
import { FpDamageDialog } from "./fp_damage_dialog";
|
||||||
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
|
import { FpFinishBlockDialog } from "./fp_finish_block_dialog";
|
||||||
import { RackingPanel } from "./components/racking_panel";
|
import { RackingPanel } from "./components/racking_panel";
|
||||||
|
import { FpMovePartsDialog } from "./move_parts_dialog";
|
||||||
import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook";
|
import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook";
|
||||||
import { FileModel } from "@web/core/file_viewer/file_model";
|
import { FileModel } from "@web/core/file_viewer/file_model";
|
||||||
|
|
||||||
export class FpJobWorkspace extends Component {
|
export class FpJobWorkspace extends Component {
|
||||||
static template = "fusion_plating_shopfloor.JobWorkspace";
|
static template = "fusion_plating_shopfloor.JobWorkspace";
|
||||||
static props = ["*"];
|
static props = ["*"];
|
||||||
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel };
|
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock, FpRackPartsDialog, FpDamageDialog, FpFinishBlockDialog, RackingPanel, FpMovePartsDialog };
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.notification = useService("notification");
|
this.notification = useService("notification");
|
||||||
@@ -248,7 +249,21 @@ export class FpJobWorkspace extends Component {
|
|||||||
if (step.override_excluded) return [];
|
if (step.override_excluded) return [];
|
||||||
|
|
||||||
const actions = [];
|
const actions = [];
|
||||||
|
// Partial-order handling — "Send to next →" advances parts parked
|
||||||
|
// at this step to the next stage. Only shown when parts are here
|
||||||
|
// AND a next stage exists. The destination name is on the button
|
||||||
|
// so there's nothing to guess; qty defaults to all parked here.
|
||||||
|
const advanceAction = () => {
|
||||||
|
const nxt = this.nextStepFor(step);
|
||||||
|
if (nxt && (step.qty_at_step || 0) > 0) {
|
||||||
|
return { key: "advance", label: "Send → " + nxt.name,
|
||||||
|
icon: "fa fa-arrow-right", cssClass: "btn btn-primary" };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
if (step.state === "in_progress") {
|
if (step.state === "in_progress") {
|
||||||
|
const adv = advanceAction();
|
||||||
|
if (adv) actions.push(adv);
|
||||||
actions.push({ key: "record_inputs", label: "Record Inputs",
|
actions.push({ key: "record_inputs", label: "Record Inputs",
|
||||||
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
||||||
actions.push({ key: "pause", label: "Pause",
|
actions.push({ key: "pause", label: "Pause",
|
||||||
@@ -263,6 +278,8 @@ export class FpJobWorkspace extends Component {
|
|||||||
if (step.state === "paused") {
|
if (step.state === "paused") {
|
||||||
actions.push({ key: "resume", label: "Resume",
|
actions.push({ key: "resume", label: "Resume",
|
||||||
icon: "fa fa-play", cssClass: "btn btn-primary" });
|
icon: "fa fa-play", cssClass: "btn btn-primary" });
|
||||||
|
const adv = advanceAction();
|
||||||
|
if (adv) actions.push(adv);
|
||||||
actions.push({ key: "record_inputs", label: "Record Inputs",
|
actions.push({ key: "record_inputs", label: "Record Inputs",
|
||||||
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
icon: "fa fa-pencil", cssClass: "btn btn-secondary" });
|
||||||
actions.push({
|
actions.push({
|
||||||
@@ -304,6 +321,7 @@ export class FpJobWorkspace extends Component {
|
|||||||
case "mark_passed": return this.onMarkPassed(step);
|
case "mark_passed": return this.onMarkPassed(step);
|
||||||
case "open_contract_review": return this.onOpenContractReview(step);
|
case "open_contract_review": return this.onOpenContractReview(step);
|
||||||
case "start_with_rack": return this.onStartWithRack(step);
|
case "start_with_rack": return this.onStartWithRack(step);
|
||||||
|
case "advance": return this.onAdvanceStep(step);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,6 +504,44 @@ export class FpJobWorkspace extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Partial-order advance (2026-06-02) -------------------------------
|
||||||
|
// "Send to next →" — moves parts parked at this step to the next stage.
|
||||||
|
// The destination auto-readies server-side (move_controller), so the
|
||||||
|
// receiving operator sees a Ready card immediately; the source
|
||||||
|
// auto-finishes when it drains to zero. Pure client-side next-step
|
||||||
|
// resolution off the loaded step list — no extra RPC.
|
||||||
|
|
||||||
|
nextStepFor(step) {
|
||||||
|
// The next stage parts flow into: lowest-sequence non-terminal step
|
||||||
|
// after this one. Returns null at the end of the line (parts finish
|
||||||
|
// in place there and close out at job mark-done).
|
||||||
|
const steps = (this.state.data && this.state.data.steps) || [];
|
||||||
|
const candidates = steps
|
||||||
|
.filter((s) => s.sequence > step.sequence
|
||||||
|
&& ["pending", "ready", "paused", "in_progress"].includes(s.state))
|
||||||
|
.sort((a, b) => a.sequence - b.sequence);
|
||||||
|
return candidates.length ? candidates[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onAdvanceStep(step) {
|
||||||
|
const nxt = this.nextStepFor(step);
|
||||||
|
if (!nxt) {
|
||||||
|
this.notification.add(
|
||||||
|
"This is the last stage — parts finish here and close out at job completion.",
|
||||||
|
{ type: "warning" },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Open the slim Move dialog pre-set to advance to the next stage.
|
||||||
|
// Qty defaults to all parked here (qty_at_step) via the preview
|
||||||
|
// endpoint; the operator confirms or trims it with the steppers.
|
||||||
|
this.dialog.add(FpMovePartsDialog, {
|
||||||
|
fromStepId: step.id,
|
||||||
|
toStepId: nxt.id,
|
||||||
|
onCommit: async () => { await this.refresh(); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Receiving handlers (Spec C1+C2 2026-05-24) -----------------------
|
// ---- Receiving handlers (Spec C1+C2 2026-05-24) -----------------------
|
||||||
// The receiver card at the top of the workspace lets the dock receiver
|
// The receiver card at the top of the workspace lets the dock receiver
|
||||||
// count boxes, set per-line received quantities + condition, log damage
|
// count boxes, set per-line received quantities + condition, log damage
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export class FpMovePartsDialog extends Component {
|
|||||||
promptValues: {},
|
promptValues: {},
|
||||||
blockers: [],
|
blockers: [],
|
||||||
committing: false,
|
committing: false,
|
||||||
|
// Advanced fields (Transfer Type, To Location) stay collapsed
|
||||||
|
// by default — the everyday flow is "advance all to the next
|
||||||
|
// stage", which needs none of them. Keeps the dialog to a qty
|
||||||
|
// confirm + SEND for the 95% case.
|
||||||
|
showAdvanced: false,
|
||||||
});
|
});
|
||||||
onWillStart(async () => {
|
onWillStart(async () => {
|
||||||
await this.loadPreview();
|
await this.loadPreview();
|
||||||
@@ -152,4 +157,20 @@ export class FpMovePartsDialog extends Component {
|
|||||||
{ type: "warning" });
|
{ type: "warning" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Qty steppers (no keyboard) ---------------------------------------
|
||||||
|
// The operator taps − / + or "All". Clamped to [1, qtyAvailable] so the
|
||||||
|
// count can never exceed what's parked here.
|
||||||
|
incQty() {
|
||||||
|
if (this.state.qty < this.state.qtyAvailable) this.state.qty += 1;
|
||||||
|
}
|
||||||
|
decQty() {
|
||||||
|
if (this.state.qty > 1) this.state.qty -= 1;
|
||||||
|
}
|
||||||
|
setQtyAll() {
|
||||||
|
this.state.qty = this.state.qtyAvailable;
|
||||||
|
}
|
||||||
|
toggleAdvanced() {
|
||||||
|
this.state.showAdvanced = !this.state.showAdvanced;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,5 +26,5 @@ $_gate-text-hex: #b06600;
|
|||||||
.o_fp_gate_icon { color: $_gate-border-hex; margin-top: 0.15rem; }
|
.o_fp_gate_icon { color: $_gate-border-hex; margin-top: 0.15rem; }
|
||||||
.o_fp_gate_body { flex: 1; }
|
.o_fp_gate_body { flex: 1; }
|
||||||
.o_fp_gate_title { font-weight: 600; color: $_gate-text-hex; font-size: 0.85rem; }
|
.o_fp_gate_title { font-weight: 600; color: $_gate-text-hex; font-size: 0.85rem; }
|
||||||
.o_fp_gate_reason { color: var(--text-secondary, #666); font-size: 0.78rem; margin-top: 0.1rem; }
|
.o_fp_gate_reason { color: var(--bs-secondary-color, #666); font-size: 0.78rem; margin-top: 0.1rem; }
|
||||||
.o_fp_gate_jump { flex-shrink: 0; }
|
.o_fp_gate_jump { flex-shrink: 0; }
|
||||||
|
|||||||
@@ -17,5 +17,5 @@
|
|||||||
.o_fp_hc_row label {
|
.o_fp_hc_row label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary, #666);
|
color: var(--bs-secondary-color, #666);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,18 +34,18 @@ $_kc-hover-hex: #f5f5f7;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_kcard_h2 {
|
.o_fp_kcard_h2 {
|
||||||
color: var(--text-secondary, #666);
|
color: var(--bs-secondary-color, #666);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_kcard_qty {
|
.o_fp_kcard_qty {
|
||||||
display: flex; justify-content: space-between;
|
display: flex; justify-content: space-between;
|
||||||
font-size: 0.7rem; color: var(--text-secondary, #777);
|
font-size: 0.7rem; color: var(--bs-secondary-color, #777);
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_kcard_due { color: var(--text-secondary, #999); }
|
.o_fp_kcard_due { color: var(--bs-secondary-color, #999); }
|
||||||
|
|
||||||
.o_fp_kcard_bar {
|
.o_fp_kcard_bar {
|
||||||
height: 4px; background: rgba(0,0,0,0.08);
|
height: 4px; background: rgba(0,0,0,0.08);
|
||||||
@@ -74,7 +74,7 @@ $_kc-hover-hex: #f5f5f7;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_kcard_wc {
|
.o_fp_kcard_wc {
|
||||||
color: var(--text-secondary, #999);
|
color: var(--bs-secondary-color, #999);
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ $_pin-dot-fill-hex: #1d1d1f;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_pin_title { font-size: 1.1rem; font-weight: 600; }
|
.o_fp_pin_title { font-size: 1.1rem; font-weight: 600; }
|
||||||
.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--text-secondary, #666); text-align: center; }
|
.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--bs-secondary-color, #666); text-align: center; }
|
||||||
|
|
||||||
.o_fp_pin_dots {
|
.o_fp_pin_dots {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -83,8 +83,8 @@ $_pin-dot-fill-hex: #1d1d1f;
|
|||||||
&:disabled { opacity: 0.5; cursor: wait; }
|
&:disabled { opacity: 0.5; cursor: wait; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_pin_key_clear { font-size: 0.95rem; color: var(--text-secondary, #666); }
|
.o_fp_pin_key_clear { font-size: 0.95rem; color: var(--bs-secondary-color, #666); }
|
||||||
.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--text-secondary, #666); }
|
.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--bs-secondary-color, #666); }
|
||||||
|
|
||||||
@keyframes o_fp_pin_shake_kf {
|
@keyframes o_fp_pin_shake_kf {
|
||||||
0%, 100% { transform: translateX(0); }
|
0%, 100% { transform: translateX(0); }
|
||||||
|
|||||||
@@ -91,6 +91,21 @@
|
|||||||
.card-sub-em { color: $plant-text; font-weight: 600; }
|
.card-sub-em { color: $plant-text; font-weight: 600; }
|
||||||
.card-meta { font-size: 11px; color: $plant-muted; }
|
.card-meta { font-size: 11px; color: $plant-muted; }
|
||||||
.card-step { font-size: 14px; font-weight: 600; color: $plant-text; margin-top: 2px; }
|
.card-step { font-size: 14px; font-weight: 600; color: $plant-text; margin-top: 2px; }
|
||||||
|
// Partial-order handling — "20 of 50 here" per-stage count. The big
|
||||||
|
// number pops so an operator scanning their column instantly sees how
|
||||||
|
// many of a job's parts are at their station. Uses existing tokens so
|
||||||
|
// dark mode is handled at compile time by _plant_tokens.scss.
|
||||||
|
.card-qty-here {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $plant-muted;
|
||||||
|
margin-top: 1px;
|
||||||
|
.qty-here-num {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $plant-mine-border;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
}
|
||||||
.card-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
.card-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ $_sig-canvas-border-hex: #d8dadd;
|
|||||||
|
|
||||||
.o_fp_sig_ctx {
|
.o_fp_sig_ctx {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-secondary, #666);
|
color: var(--bs-secondary-color, #666);
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_sig_canvas {
|
.o_fp_sig_canvas {
|
||||||
@@ -36,6 +36,6 @@ $_sig-canvas-border-hex: #d8dadd;
|
|||||||
|
|
||||||
.o_fp_sig_hint {
|
.o_fp_sig_hint {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--bs-secondary-color, #999);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
.o_fp_ws_loading {
|
.o_fp_ws_loading {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-secondary, #666);
|
color: var(--bs-secondary-color, #666);
|
||||||
|
|
||||||
> div { margin-top: 0.6rem; }
|
> div { margin-top: 0.6rem; }
|
||||||
}
|
}
|
||||||
@@ -87,8 +87,8 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_ws_wo { font-weight: 700; font-size: 1.3rem; letter-spacing: 0.01em; }
|
.o_fp_ws_wo { font-weight: 700; font-size: 1.3rem; letter-spacing: 0.01em; }
|
||||||
.o_fp_ws_dot { color: var(--text-secondary, #999); }
|
.o_fp_ws_dot { color: var(--bs-secondary-color, #999); }
|
||||||
.o_fp_ws_cust, .o_fp_ws_part { color: var(--text-secondary, #555); font-size: 0.95rem; }
|
.o_fp_ws_cust, .o_fp_ws_part { color: var(--bs-secondary-color, #555); font-size: 0.95rem; }
|
||||||
|
|
||||||
.o_fp_ws_pill {
|
.o_fp_ws_pill {
|
||||||
background: linear-gradient(135deg, $_ws-card-hex 0%, $_ws-page-hex 100%);
|
background: linear-gradient(135deg, $_ws-card-hex 0%, $_ws-page-hex 100%);
|
||||||
@@ -97,7 +97,7 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary, #555);
|
color: var(--bs-secondary-color, #555);
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
.o_fp_ws_bar_label {
|
.o_fp_ws_bar_label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-secondary, #888);
|
color: var(--bs-secondary-color, #888);
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
.o_fp_ws_empty {
|
.o_fp_ws_empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--bs-secondary-color, #999);
|
||||||
|
|
||||||
> div { margin-top: 0.5rem; }
|
> div { margin-top: 0.5rem; }
|
||||||
}
|
}
|
||||||
@@ -289,9 +289,9 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_ws_step_icon { width: 18px; text-align: center; font-weight: 700; }
|
.o_fp_ws_step_icon { width: 18px; text-align: center; font-weight: 700; }
|
||||||
.o_fp_ws_step_num { color: var(--text-secondary, #999); font-size: 0.78rem; min-width: 50px; }
|
.o_fp_ws_step_num { color: var(--bs-secondary-color, #999); font-size: 0.78rem; min-width: 50px; }
|
||||||
.o_fp_ws_step_name { font-weight: 600; }
|
.o_fp_ws_step_name { font-weight: 600; }
|
||||||
.o_fp_ws_step_meta { color: var(--text-secondary, #999); font-size: 0.78rem; margin-left: auto; }
|
.o_fp_ws_step_meta { color: var(--bs-secondary-color, #999); font-size: 0.78rem; margin-left: auto; }
|
||||||
|
|
||||||
.o_fp_ws_step_badge {
|
.o_fp_ws_step_badge {
|
||||||
background: #0071e3;
|
background: #0071e3;
|
||||||
@@ -314,7 +314,7 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_ws_step_chips { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
.o_fp_ws_step_chips { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
||||||
.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--text-secondary, #555); font-style: italic; }
|
.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--bs-secondary-color, #555); font-style: italic; }
|
||||||
.o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
|
.o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
// ---- Masking reference tiles (tap → full-screen FileViewer) -----------
|
// ---- Masking reference tiles (tap → full-screen FileViewer) -----------
|
||||||
@@ -402,7 +402,7 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
|
|
||||||
.o_fp_ws_step_excluded {
|
.o_fp_ws_step_excluded {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--text-secondary, #888);
|
color: var(--bs-secondary-color, #888);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +433,7 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: var(--text-secondary, #777);
|
color: var(--bs-secondary-color, #777);
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -464,14 +464,14 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--text-secondary, #777);
|
color: var(--bs-secondary-color, #777);
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_ws_note .author { font-weight: 600; }
|
.o_fp_ws_note .author { font-weight: 600; }
|
||||||
.o_fp_ws_note .body { color: var(--text-secondary, #555); margin-top: 0.15rem; }
|
.o_fp_ws_note .body { color: var(--bs-secondary-color, #555); margin-top: 0.15rem; }
|
||||||
|
|
||||||
.o_fp_ws_empty_small {
|
.o_fp_ws_empty_small {
|
||||||
color: var(--text-secondary, #999);
|
color: var(--bs-secondary-color, #999);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@@ -574,7 +574,7 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--text-secondary, #777);
|
color: var(--bs-secondary-color, #777);
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_ws_ship_fields {
|
.o_fp_ws_ship_fields {
|
||||||
@@ -645,8 +645,8 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
.o_fp_ws_rcv_status {
|
.o_fp_ws_rcv_status {
|
||||||
padding: 0.2rem 0.6rem;
|
padding: 0.2rem 0.6rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: #fef3c7;
|
background-color: color-mix(in srgb, #f59e0b 18%, var(--bs-body-bg));
|
||||||
color: #78350f;
|
color: var(--bs-body-color);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -664,7 +664,7 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
|
|
||||||
label {
|
label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary, #555);
|
color: var(--bs-secondary-color, #555);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -725,7 +725,7 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--text-secondary, #666);
|
color: var(--bs-secondary-color, #666);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
@@ -794,7 +794,7 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--text-secondary, #666);
|
color: var(--bs-secondary-color, #666);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -840,7 +840,7 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_ws_rcv_damage_photos {
|
.o_fp_ws_rcv_damage_photos {
|
||||||
color: var(--text-secondary, #666);
|
color: var(--bs-secondary-color, #666);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -876,7 +876,7 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_dmg_field { display: flex; flex-direction: column; gap: 0.4rem; }
|
.o_fp_dmg_field { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
.o_fp_dmg_label { font-weight: 600; color: var(--text-secondary, #555); }
|
.o_fp_dmg_label { font-weight: 600; color: var(--bs-secondary-color, #555); }
|
||||||
.o_fp_dmg_req { color: #dc2626; }
|
.o_fp_dmg_req { color: #dc2626; }
|
||||||
|
|
||||||
.o_fp_dmg_pills {
|
.o_fp_dmg_pills {
|
||||||
@@ -980,8 +980,8 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_ws_step_timer_over {
|
.o_fp_ws_step_timer_over {
|
||||||
background: #fee2e2;
|
background-color: color-mix(in srgb, #ef4444 16%, var(--bs-body-bg));
|
||||||
color: #7f1d1d;
|
color: var(--bs-body-color);
|
||||||
animation: o_fp_ws_timer_pulse 1.5s ease-in-out infinite;
|
animation: o_fp_ws_timer_pulse 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,17 +1002,25 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: Odoo's backend CSS does NOT define --bs-body-color /
|
||||||
|
// --bs-secondary-color / --bs-*-bg as custom properties (verified: 0
|
||||||
|
// definitions in the compiled bundle — they're SCSS literals + two
|
||||||
|
// bundles + [data-bs-theme]). So var(--bs-body-color, #hex) ALWAYS
|
||||||
|
// resolves to the dark #hex fallback, in light AND dark mode. The fix
|
||||||
|
// for dialog text is to INHERIT the modal's theme-correct colour (the
|
||||||
|
// dialog title and the "Count the Parts" list items do exactly this and
|
||||||
|
// are readable in both modes). Tinted boxes use translucent rgba() so
|
||||||
|
// they work over whatever the live theme background is.
|
||||||
.o_fp_finish_block_step {
|
.o_fp_finish_block_step {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: #b45309;
|
background-color: rgba(245, 158, 11, 0.16);
|
||||||
background: #fef3c7;
|
|
||||||
padding: 0.7rem 1rem;
|
padding: 0.7rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border-left: 4px solid #f59e0b;
|
border-left: 4px solid #f59e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_finish_block_msg {
|
.o_fp_finish_block_msg {
|
||||||
color: var(--text-secondary, #333);
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_finish_block_list {
|
.o_fp_finish_block_list {
|
||||||
@@ -1027,9 +1035,9 @@ $_ws-text-hex: #1d1d1f;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.o_fp_finish_block_action_note {
|
.o_fp_finish_block_action_note {
|
||||||
color: var(--text-secondary, #555);
|
// Inherit text colour; translucent neutral box works in both themes.
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
padding: 0.6rem 0.8rem;
|
padding: 0.6rem 0.8rem;
|
||||||
background: #f3f4f6;
|
background: rgba(128, 128, 128, 0.12);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user