Compare commits
140 Commits
76866a7c76
...
feat/fusio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00f7e90a3d | ||
|
|
903ceb10d0 | ||
|
|
4f48bab6e9 | ||
|
|
b616375679 | ||
|
|
5c4a26b65f | ||
|
|
b59ad6b21e | ||
|
|
8a1a09b150 | ||
|
|
a092c385ea | ||
|
|
ca44461b6f | ||
|
|
249adf8145 | ||
|
|
cc568b0ec8 | ||
|
|
17d21bffb5 | ||
|
|
aafc2db8a8 | ||
|
|
6c3830fd4c | ||
|
|
12d383a8c2 | ||
|
|
139e917e09 | ||
|
|
de3e0df5fc | ||
|
|
747c814249 | ||
|
|
c527c7cade | ||
|
|
f7ec1e28f9 | ||
|
|
96b3f124f8 | ||
|
|
2c32e7bcd0 | ||
|
|
aa9b95bd5d | ||
|
|
493f01827e | ||
|
|
2ab59bccde | ||
|
|
914c96a09a | ||
|
|
b015958edc | ||
|
|
ca94a4c42a | ||
|
|
a5ec79013a | ||
|
|
b61e159e6f | ||
|
|
13a892c7ab | ||
|
|
2ee01fd1f2 | ||
|
|
d6d6bbe161 | ||
|
|
31098c4d14 | ||
|
|
1a1ab2da4f | ||
|
|
3f78f652e7 | ||
|
|
e230e42d81 | ||
|
|
06346cfa6b | ||
|
|
a858693d9c | ||
|
|
68b10e1199 | ||
|
|
e260f030d1 | ||
|
|
8d6fcb75a7 | ||
|
|
fef99809e5 | ||
|
|
ea4f216c1a | ||
|
|
db48029e61 | ||
|
|
be721f82ae | ||
|
|
806ec5a5a6 | ||
|
|
0acd2251e6 | ||
|
|
defa7250e1 | ||
|
|
719853c251 | ||
|
|
6a9c7c74ea | ||
|
|
87639a12b5 | ||
|
|
360370db15 | ||
|
|
85bbd8a20e | ||
|
|
136a64ea21 | ||
|
|
a479052b72 | ||
|
|
11108dfea3 | ||
|
|
85cdecddea | ||
|
|
2aaa1a57e7 | ||
|
|
b5d5a9acba | ||
|
|
0d94af6532 | ||
|
|
95abd2e337 | ||
|
|
b1db851e29 | ||
|
|
f18c59fe89 | ||
|
|
2fb774e4fa | ||
|
|
60c25f8241 | ||
|
|
47a6523e24 | ||
|
|
4a9f31cef5 | ||
|
|
dd908c3861 | ||
|
|
5c1f60b3b8 | ||
|
|
55da42e91f | ||
|
|
ab3e6fa1e2 | ||
|
|
e2f7fa6d19 | ||
|
|
2c8ad83d43 | ||
|
|
3fd074ff6d | ||
|
|
e26a7cd9e8 | ||
|
|
09cea73e50 | ||
|
|
3235d4ceca | ||
|
|
5a488ae86e | ||
|
|
55898dd1d4 | ||
|
|
2a16f80d8d | ||
|
|
cecc699a70 | ||
|
|
4949856336 | ||
|
|
9826e03b4e | ||
|
|
69aa6b050b | ||
|
|
5675784916 | ||
|
|
0d4a871d0c | ||
|
|
aac95ee16b | ||
|
|
028814b292 | ||
|
|
2bd0672b52 | ||
|
|
dc1dacddc2 | ||
|
|
6dde3ec2b1 | ||
|
|
a2ac804238 | ||
|
|
f8929eb686 | ||
|
|
a07a5f931a | ||
|
|
c6022c70f9 | ||
|
|
7efaadc1c1 | ||
|
|
21300db8e8 | ||
|
|
1e9ffccd6b | ||
|
|
b2186ab032 | ||
|
|
855b160752 | ||
|
|
da7ec59474 | ||
|
|
2ed3dcee58 | ||
|
|
9b18f77e06 | ||
|
|
1ae83e187e | ||
|
|
1b0657bd76 | ||
|
|
f75e082e67 | ||
|
|
f1273798cd | ||
|
|
bb814a46ff | ||
|
|
be7256ce4c | ||
|
|
d37f10f1c3 | ||
|
|
b98ee8a6fb | ||
|
|
df0de97a68 | ||
|
|
49a0a953e5 | ||
|
|
64eb34cdff | ||
|
|
cd0c08f348 | ||
|
|
6a5364e053 | ||
|
|
ec78fc148d | ||
|
|
9d9be17542 | ||
|
|
1d1bbfe612 | ||
|
|
b1257b6983 | ||
|
|
687decca28 | ||
|
|
307afbf3c0 | ||
|
|
fecd2415f6 | ||
|
|
e36318f7a5 | ||
|
|
feddca19d6 | ||
|
|
95378ff1da | ||
|
|
c8529b8a99 | ||
|
|
7a66d7849d | ||
|
|
9ad09c32b0 | ||
|
|
6b63df8c3d | ||
|
|
72d3130c88 | ||
|
|
f6518b4d7e | ||
|
|
bf6ee2bb2c | ||
|
|
077f898283 | ||
|
|
779539d1b5 | ||
|
|
34a65f9c4a | ||
|
|
97cce8c755 | ||
|
|
fe98fadf61 | ||
|
|
32c7026558 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,3 +15,4 @@ __pycache__/
|
||||
|
||||
# Local-only diagnostic logs from test runs
|
||||
_test_*.log
|
||||
.superpowers/
|
||||
|
||||
@@ -77,6 +77,7 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
||||
|
||||
## Cursor-Managed Modules
|
||||
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
||||
- **fusion_repairs** — status and deferred work: [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) (bundles 1–11 shipped at `19.0.2.2.4`; not production-deployed)
|
||||
|
||||
## Workflow
|
||||
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
|
||||
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
|
||||
**`config_parameter=` Boolean fields don't round-trip `False` as a string.** Odoo's `set_values()` calls `IrConfigParameter.set_param(key, value)`, and `set_param` deletes the row when `value` is falsy (False / None / empty). So writing `False` to a Boolean config field means the param no longer exists in `ir_config_parameter`; a subsequent `get_param(key)` returns the *default* (Python `False`), not `'False'`. Test like `self.assertFalse(ICP.get_param('...'))` — never `assertEqual(..., 'False')`. (Integer/Float/Char go through `repr(value)` / strip, so they DO persist as strings — `'90'`, `'0'`, etc.) Source: `odoo/addons/base/models/res_config.py::set_values` and `ir_config_parameter.py::set_param`.
|
||||
6. **res.groups**: NO `users` field, NO `category_id` field.
|
||||
6. **res.groups**: NO `users` field, NO `category_id` field. **The Odoo 19 replacement for `category_id` is `res.groups.privilege`.** To make a module's groups appear as application-access dropdowns on the user form (Settings → Users → *Application Accesses*) instead of only in developer mode: define an `ir.module.category`, a `res.groups.privilege` (with `category_id` → that category), and set each group's `privilege_id` → that privilege. Groups under one privilege that form an `implied_ids` chain render as a single role dropdown; a standalone group in its own privilege renders as a separate row under the same category header. Verified in `fusion_clock/security/security.xml`; mirrors `fusion_plating`/`fusion_tasks`.
|
||||
**res.users**: field was renamed `groups_id` → `group_ids` (also `all_group_ids` for implied). The plural form is gone; using `groups_id` raises `ValueError: Invalid field 'groups_id' in 'res.users'`.
|
||||
**`ir.ui.view`**: same rename — view-level visibility gating uses `group_ids`, not `groups_id`. A record like `<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>` on an `ir.ui.view` raises `ValueError: Invalid field 'groups_id' in 'ir.ui.view'` at module install. (The XML *attribute* `groups="base.group_system"` on form elements like `<page>`, `<button>`, `<field>` is unrelated and still works.)
|
||||
**`ir.rule` `groups` field is additive, not restrictive.** A rule with `groups=[some_group]` applies ONLY to users in that group — it does NOT restrict non-members. So `domain_force=[(1,'=',1)]` + `groups=[base.group_system]` does NOT mean "only admins see rows"; it means "admins see all rows (and the rule is silent on everyone else)". Non-admins are gated by the ACL (`ir.model.access.csv`), not the rule. To truly restrict by group at the rule layer, pair a global rule (`groups=[]`, `domain_force=[(0,'=',1)]` = block-all baseline) with a group-scoped allow rule. Default to letting the ACL do the gating; use rules for row-level filters that ACLs cannot express.
|
||||
@@ -33,6 +33,8 @@
|
||||
|
||||
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`.
|
||||
|
||||
## Card Styling — Copy Odoo's Kanban Pattern
|
||||
Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values:
|
||||
```css
|
||||
@@ -92,8 +94,9 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
||||
- Canadian English for all user-facing text
|
||||
- Currency: `$` sign with Monetary fields + currency_id
|
||||
|
||||
## Cursor-Managed Modules
|
||||
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
|
||||
## Module-Specific Notes
|
||||
- **fusion_clock** — developed in **Claude Code** (no longer Cursor; no concurrent-editing conflicts). Changed a lot recently (NFC kiosk: tap-to-clock, enrollment + program-from-unknown-tap, manager page, sounds, screen lock, guided profile-photo capture, faster animations). Still read files fresh before editing rather than assuming the layout. Live on entech (`odoo-entech` / LXC 111 on `pve-worker5`).
|
||||
- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.2.4`.** Bundles 1–11 shipped in repo (intake, portals, dashboard, pricing, flowcharts, parts/PO). **Not production-deployed** to Westin as of 2026-05-27. Local: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init`. Outstanding: RingCentral SMS, C2 history sidebar UI, office follow-up crons (config keys only), `tests/`, more flowchart content, sales-rep dashboard tile in `fusion_portal`.
|
||||
|
||||
## Workflow
|
||||
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
'website',
|
||||
'mail',
|
||||
'fusion_claims',
|
||||
'fusion_authorizer_portal',
|
||||
'fusion_portal',
|
||||
],
|
||||
'data': [
|
||||
'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`).
|
||||
@@ -120,11 +120,36 @@ All four chunks of #2 are now built. The brainstorm "which slice" question resol
|
||||
`nexacloud_dsn`, all charges `plan_id` NULL (rating cron no-op), no webhooks queued
|
||||
(dispatch cron no-op), inbound API 401s with no key configured. Synced to
|
||||
`/opt/odoo/custom-addons` + `-i` via the restart-safe recipe.
|
||||
- **NexaCloud: NOT deployed (deliberately).** `deploy.sh` does `rsync --delete` of the
|
||||
**local working tree** then `--no-cache` rebuild — it would ship the concurrent
|
||||
**uncommitted Cursor WIP** (7 files) to prod. My 2b/2c is also feature-flagged OFF and
|
||||
the app wasn't boot-tested. Deploy only AFTER: Cursor's WIP is committed/finished, the
|
||||
app boots clean, and `ODOO_BILLING_*` config is set (otherwise it's inert anyway).
|
||||
- **NexaCloud (prod, `vps.nexasystems.ca` / 192.168.1.250): DEPLOYED — INERT.** Did NOT
|
||||
use `./deploy.sh` (it `rsync --delete`s the working tree → would have shipped the
|
||||
concurrent **uncommitted Cursor WIP** (7 files) AND wiped the gitignored prod `.env`
|
||||
files). Instead deployed **surgically**: rsync of ONLY my 6 committed billing files (no
|
||||
`--delete`; `.env` + Cursor's files untouched), `docker compose build backend`,
|
||||
**boot-tested in a throwaway container** (`run --rm --no-deps backend python -c "import
|
||||
app.main"` → BOOT_OK) before swapping, then `up -d backend`. `nexacloud-api` healthy,
|
||||
`/health` OK. Feature OFF: `ODOO_BILLING_ENABLED` unset → `/billing/webhooks/central`
|
||||
returns 404 and no usage is pushed. Activate later by setting `ODOO_BILLING_*` in
|
||||
`/opt/nexacloud/.env` (+ compose env passthrough) once the Odoo side is wired.
|
||||
**NOTE:** Cursor's 7-file WIP remains uncommitted locally and was never deployed — when
|
||||
Cursor finishes, a normal `./deploy.sh` will ship it (and re-sync `.env`).
|
||||
|
||||
**Dual-run stand-up results (2026-05-27) — STOPPED here for review, NOT flipped:**
|
||||
- Read-only role `odoo_billing_ro` created on nexacloud Postgres (192.168.1.50); DSN set in
|
||||
`ir.config_parameter` `fusion_billing.nexacloud_dsn` on nexamain. Test Connection OK
|
||||
(read 7 users / 232 plans / 87 subscriptions).
|
||||
- **Shadow import committed on nexamain**: 7 partners, 232 plan catalogs, 87 draft shadow
|
||||
subscriptions; 0 skipped, 0 failed. (NOTE: importer takes ALL plans/subs regardless of
|
||||
active status → ~464 NC-* products now in the prod ERP catalog. Consider filtering to
|
||||
`is_active` plans / active subscriptions, or prune the shadow records — all reversible.)
|
||||
- **Reconciliation pass**: 9 (sub,period) rows had real billing activity → **2 match, 7
|
||||
delta**, 0 failed. The 7 deltas, MUST resolve before flipping:
|
||||
1. **One-off / non-subscription invoices** (3 rows: $877.99, $872.66, $32.20) — nexacloud
|
||||
invoices with NULL subscription_id (fees/manual/credits); not modeled per-subscription.
|
||||
2. **List-price ≠ actual-invoiced** (4 rows: Odoo $200/$50 vs actual ~$9.1x) — likely
|
||||
proration or NexaCloud invoicing ≠ plan list price.
|
||||
- **2d bug surfaced (analysis-only, not safety):** `_reconcile_rows` with an empty
|
||||
`subscription_external_id` matches NULL-field orders instead of skipping → spurious
|
||||
delta rows for the one-off invoices. Add `if not sub_ext: skip`.
|
||||
|
||||
**Remaining before go-live (gated on infra / ops you do):**
|
||||
1. Grant the read-only DSN (`fusion_billing.nexacloud_dsn`) — see the module README — then
|
||||
|
||||
637
docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md
Normal file
637
docs/superpowers/plans/2026-05-27-nexacloud-invoice-ledger.md
Normal file
@@ -0,0 +1,637 @@
|
||||
# NexaCloud → Odoo Invoice Ledger — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Ingest NexaCloud's real (Stripe-billed) invoices into Odoo as posted `account.move` customer invoices with reconciled payments + HST, so Odoo is the accounting system of record — all history + ongoing, revenue split by service family, draft-first on the live books.
|
||||
|
||||
**Architecture:** A new ingester in `fusion_centralize_billing` mirroring the importer's read/write split: `_read_nexacloud_invoices` (read-only psycopg2 via the existing DSN) → `_ingest_invoices` (pure Odoo: create `account.move` drafts idempotently, map lines to per-family income accounts, derive tax, reconcile Stripe payments) → `_post_ingested` (bulk-post after review). Reuses the `account.link` partner mapping. Native Odoo accounting does the rest.
|
||||
|
||||
**Tech Stack:** Odoo 19 Enterprise, `account_accountant`, `psycopg2`. Tests: `TransactionCase` on odoo-trial (`bash scripts/fcb_test_on_trial.sh`, pass = `FCB_EXIT=0`).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
- **Never code accounting internals from memory** (CLAUDE rule #1). Reference confirmed on trial: `account.move` has `invoice_line_ids`/`invoice_date`/`action_post`; `account.payment.register` exists; `account_type='income'`/`'asset_receivable'` valid; sale taxes are Canadian (find HST 13% by `amount=13` / name). Where a step says "read reference", confirm before relying on it.
|
||||
- **Models, not UI:** logic in model methods; the wizard only calls them. Testable under `TransactionCase`.
|
||||
- **New fields on native models:** `x_fc_*`. Declarative `models.Constraint` only.
|
||||
- Tests run on **odoo-trial** (`bash scripts/fcb_test_on_trial.sh`, full suite, ~1–2 min). Register each new `tests/test_*.py` in `tests/__init__.py` in the same task.
|
||||
|
||||
## File structure
|
||||
```
|
||||
fusion_centralize_billing/
|
||||
models/
|
||||
account_move.py # NEW: account.move inherit (x_fc_nexacloud_invoice_id, x_fc_stripe_invoice_id)
|
||||
__init__.py # + account_move
|
||||
wizards/
|
||||
invoice_ledger.py # NEW: the ingester (read + ingest + post + family/tax/payment helpers)
|
||||
__init__.py # + invoice_ledger
|
||||
views/
|
||||
invoice_ledger_views.xml # NEW: wizard form + action + menu + cron
|
||||
security/ir.model.access.csv # + ledger wizard ACL
|
||||
__manifest__.py # + views/invoice_ledger_views.xml
|
||||
tests/
|
||||
test_invoice_ledger.py # NEW
|
||||
__init__.py # + test_invoice_ledger
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Scaffold — account.move fields + ledger wizard skeleton
|
||||
|
||||
**Files:** create `models/account_move.py`, `wizards/invoice_ledger.py`, `views/invoice_ledger_views.xml`; modify `models/__init__.py`, `wizards/__init__.py`, `security/ir.model.access.csv`, `__manifest__.py`.
|
||||
|
||||
- [ ] **Step 1: account.move inherit** — `models/account_move.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
x_fc_nexacloud_invoice_id = fields.Char(
|
||||
index=True, copy=False, help="Source NexaCloud invoice id — ledger idempotency key.")
|
||||
x_fc_stripe_invoice_id = fields.Char(index=True, copy=False)
|
||||
|
||||
_fc_nc_invoice_uniq = models.Constraint(
|
||||
"unique(x_fc_nexacloud_invoice_id)",
|
||||
"One Odoo invoice per NexaCloud invoice id.")
|
||||
```
|
||||
Add `from . import account_move` to `models/__init__.py`.
|
||||
|
||||
- [ ] **Step 2: ledger wizard skeleton** — `wizards/invoice_ledger.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||
_name = "fusion.billing.invoice.ledger.wizard"
|
||||
_description = "Fusion Billing — NexaCloud Invoice Ledger Ingester"
|
||||
|
||||
dry_run = fields.Boolean(default=True)
|
||||
auto_post = fields.Boolean(
|
||||
default=False, help="Post invoices immediately (else leave draft for review).")
|
||||
result_summary = fields.Text(readonly=True)
|
||||
|
||||
def _ingest_invoices(self, data, post=False):
|
||||
return {"created": 0, "updated": 0, "posted": 0, "skipped": [], "failed": [], "by_family": {}}
|
||||
```
|
||||
Add `from . import invoice_ledger` to `wizards/__init__.py`.
|
||||
|
||||
- [ ] **Step 3: view + action + menu** — `views/invoice_ledger_views.xml`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fc_invoice_ledger_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.billing.invoice.ledger.wizard.form</field>
|
||||
<field name="model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Ingest NexaCloud Invoices">
|
||||
<group>
|
||||
<field name="dry_run"/>
|
||||
<field name="auto_post"/>
|
||||
</group>
|
||||
<group string="Result" invisible="not result_summary">
|
||||
<field name="result_summary" nolabel="1" widget="text"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_run" type="object" string="Run" class="btn-primary"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
<record id="action_fc_invoice_ledger_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Ingest NexaCloud Invoices</field>
|
||||
<field name="res_model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
<menuitem id="menu_fc_invoice_ledger" name="Ingest NexaCloud Invoices"
|
||||
parent="menu_fusion_billing_root"
|
||||
action="action_fc_invoice_ledger_wizard" sequence="20"
|
||||
groups="base.group_system"/>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: security + manifest** — append to `security/ir.model.access.csv`:
|
||||
```
|
||||
access_fc_invoice_ledger_wizard,fusion.billing.invoice.ledger.wizard,model_fusion_billing_invoice_ledger_wizard,base.group_system,1,1,1,1
|
||||
```
|
||||
Add `"views/invoice_ledger_views.xml"` to `__manifest__.py` `data`.
|
||||
|
||||
- [ ] **Step 5: verify upgrade** — `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0` (existing tests pass; new model/fields/view load).
|
||||
|
||||
- [ ] **Step 6: commit** — `feat(billing): invoice-ledger scaffold (account.move x_fc fields + wizard)`
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Service-family classification + income account
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`; create `tests/test_invoice_ledger.py` (+ register in `tests/__init__.py`).
|
||||
|
||||
- [ ] **Step 1: failing test** — `tests/test_invoice_ledger.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerFamily(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
|
||||
def test_family_classification(self):
|
||||
f = self.W._fc_family_for
|
||||
self.assertEqual(f('Odoo ERP Hosting (2026-05-01 to 2026-06-01)'), 'hosting')
|
||||
self.assertEqual(f('WordPress Website Hosting - Managed (at $50.00 / month)'), 'hosting')
|
||||
self.assertEqual(f('Managed Odoo - Standard (at $49.99 / month)'), 'managed')
|
||||
self.assertEqual(f('Daily Backup Protection'), 'addons')
|
||||
self.assertEqual(f('Remaining time on Daily Backup Protection after 27 May 2026'), 'addons')
|
||||
self.assertEqual(f('Something Unmapped'), 'other')
|
||||
|
||||
def test_income_account_per_family_distinct(self):
|
||||
a_host = self.W._fc_income_account('hosting')
|
||||
a_add = self.W._fc_income_account('addons')
|
||||
self.assertEqual(a_host.account_type, 'income')
|
||||
self.assertNotEqual(a_host, a_add) # split by family
|
||||
self.assertEqual(self.W._fc_income_account('hosting'), a_host) # idempotent
|
||||
```
|
||||
Append `from . import test_invoice_ledger` to `tests/__init__.py`.
|
||||
|
||||
- [ ] **Step 2: run** → FAIL (`_fc_family_for` missing).
|
||||
|
||||
- [ ] **Step 3: implement** — in `wizards/invoice_ledger.py`:
|
||||
```python
|
||||
_FAMILY_KEYWORDS = [
|
||||
('hosting', ['odoo erp hosting', 'wordpress website hosting']),
|
||||
('managed', ['managed']),
|
||||
('addons', ['daily backup', 'whatsapp', 'forms builder', 'white label']),
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _fc_family_for(self, description):
|
||||
import re
|
||||
d = (description or '').lower()
|
||||
m = re.match(r'remaining time on (.+?)(?: after| from |\s*\()', d)
|
||||
if m:
|
||||
d = m.group(1) # classify proration by the prorated item
|
||||
for fam, kws in self._FAMILY_KEYWORDS:
|
||||
if any(k in d for k in kws):
|
||||
return fam
|
||||
return 'other'
|
||||
|
||||
@api.model
|
||||
def _fc_income_account(self, family):
|
||||
Account = self.env['account.account']
|
||||
code = 'NCR-' + family.upper()[:6]
|
||||
acc = Account.search([('code', '=', code)], limit=1)
|
||||
if not acc:
|
||||
acc = Account.create({
|
||||
'code': code, 'name': 'NexaCloud %s Revenue' % family.title(),
|
||||
'account_type': 'income'})
|
||||
return acc
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If `account.account.create` needs more required fields on this build, read `account_account.py` on trial and add them — don't weaken the test.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): ledger service-family classification + per-family income accounts`
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Tax derivation (match NexaCloud's invoice.tax)
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerTax(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
|
||||
def test_tax_for_13pct_is_a_13_percent_sale_tax(self):
|
||||
tax = self.W._fc_tax_for(100.0, 13.0)
|
||||
self.assertTrue(tax, "expected an HST/13% sale tax on the Canadian COA")
|
||||
self.assertEqual(tax.type_tax_use, 'sale')
|
||||
# the chosen tax computes 13.00 on 100.00
|
||||
res = tax.compute_all(100.0)
|
||||
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 13.0, places=2)
|
||||
|
||||
def test_tax_for_zero_is_zero_or_empty(self):
|
||||
tax = self.W._fc_tax_for(100.0, 0.0)
|
||||
if tax:
|
||||
res = tax.compute_all(100.0)
|
||||
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 0.0, places=2)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement**:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_tax_for(self, subtotal, tax_amount):
|
||||
"""Map a NexaCloud invoice's (subtotal, tax_amount) to the Odoo sale tax whose
|
||||
computed tax equals it. Picks by effective percent; falls back to a 0% sale tax."""
|
||||
Tax = self.env['account.tax']
|
||||
sub = float(subtotal or 0.0)
|
||||
tax_amt = float(tax_amount or 0.0)
|
||||
if sub <= 0 or tax_amt <= 0:
|
||||
return Tax.search([('type_tax_use', '=', 'sale'), ('amount', '=', 0.0)], limit=1)
|
||||
rate = round(100.0 * tax_amt / sub)
|
||||
tax = Tax.search([('type_tax_use', '=', 'sale'), ('amount_type', '=', 'percent'),
|
||||
('amount', '=', float(rate))], limit=1)
|
||||
if not tax:
|
||||
tax = Tax.search([('type_tax_use', '=', 'sale'), ('name', 'ilike', '%s' % rate)], limit=1)
|
||||
return tax
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (Read reference if no 13% sale tax exists: `docker exec odoo-trial-app ... grep -i hst` the l10n_ca data; on nexamain confirm the HST 13% record from `nexa_coa_setup`.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): ledger tax derivation matching source invoice tax`
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Ingest invoices → draft account.move (idempotent)
|
||||
|
||||
**Read reference first:**
|
||||
```bash
|
||||
ssh pve-worker1 "qm guest exec 316 -- bash -lc 'docker exec odoo-trial-app bash -lc \"grep -nE \\\"def action_post|invoice_line_ids|move_type\\\" /mnt/enterprise-addons/account_accountant/../account/models/account_move.py | head\"'"
|
||||
```
|
||||
Confirm `account.move.create({'move_type':'out_invoice','partner_id':..,'invoice_line_ids':[(0,0,{'name','quantity','price_unit','account_id','tax_ids'})]})` and `move.amount_untaxed/amount_tax/amount_total`.
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append) — uses a fixture invoice dict shaped like `_read_nexacloud_invoices` output:
|
||||
```python
|
||||
def _inv_fixture():
|
||||
return [{
|
||||
'id': 'inv-1', 'stripe_invoice_id': 'in_test1', 'invoice_number': 'NEX-0001',
|
||||
'user_external_id': 'u-1', 'partner_name': 'Acme', 'partner_email': 'ar@acme.test',
|
||||
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
|
||||
'subtotal': 100.0, 'tax': 13.0, 'amount_paid': 0.0, 'paid_at': None,
|
||||
'items': [{'description': 'Odoo ERP Hosting (2026-05-01 to 2026-06-01)',
|
||||
'quantity': 1.0, 'unit_price': 100.0, 'amount': 100.0}],
|
||||
}]
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerIngest(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
self.svc = self.env['fusion.billing.service'].sudo().create(
|
||||
{'name': 'NexaCloud', 'code': 'nexacloud'})
|
||||
|
||||
def test_ingest_creates_draft_invoice_with_right_totals(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(len(mv), 1)
|
||||
self.assertEqual(mv.move_type, 'out_invoice')
|
||||
self.assertEqual(mv.state, 'draft')
|
||||
self.assertAlmostEqual(mv.amount_untaxed, 100.0, places=2)
|
||||
self.assertAlmostEqual(mv.amount_tax, 13.0, places=2) # equals source tax
|
||||
self.assertAlmostEqual(mv.amount_total, 113.0, places=2)
|
||||
self.assertEqual(mv.partner_id.email, 'ar@acme.test')
|
||||
line = mv.invoice_line_ids
|
||||
self.assertEqual(line.account_id, self.W._fc_income_account('hosting'))
|
||||
|
||||
def test_ingest_is_idempotent(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
self.assertEqual(self.env['account.move'].search_count(
|
||||
[('x_fc_nexacloud_invoice_id', '=', 'inv-1')]), 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement** the partner resolver + `_ingest_invoices`:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_partner_for(self, inv):
|
||||
"""Resolve the unified partner for an invoice via the nexacloud account.link
|
||||
(by user_external_id); create partner+link if missing (covers NULL-subscription
|
||||
invoices, which still carry a user)."""
|
||||
service = self.env['fusion.billing.service'].search([('code', '=', 'nexacloud')], limit=1)
|
||||
link = self.env['fusion.billing.account.link']._resolve_or_create_partner(
|
||||
service, str(inv.get('user_external_id')),
|
||||
name=inv.get('partner_name'), email=inv.get('partner_email'))
|
||||
return link.partner_id
|
||||
|
||||
@api.model
|
||||
def _ingest_invoices(self, data, post=False):
|
||||
Move = self.env['account.move']
|
||||
cad = self.env.ref('base.CAD', raise_if_not_found=False) or self.env.company.currency_id
|
||||
summary = {'created': 0, 'updated': 0, 'posted': 0, 'skipped': [], 'failed': [], 'by_family': {}}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get('id') or '')
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
existing = Move.search([('x_fc_nexacloud_invoice_id', '=', nc_id)], limit=1)
|
||||
if existing:
|
||||
if existing.state != 'draft':
|
||||
summary['skipped'].append({'id': nc_id, 'reason': 'already posted'})
|
||||
continue
|
||||
existing.invoice_line_ids.unlink() # draft: replace lines
|
||||
move = existing
|
||||
else:
|
||||
move = Move.create({
|
||||
'move_type': 'out_invoice',
|
||||
'partner_id': self._fc_partner_for(inv).id,
|
||||
'invoice_date': inv.get('invoice_date'),
|
||||
'ref': inv.get('invoice_number'),
|
||||
'currency_id': cad.id,
|
||||
'x_fc_nexacloud_invoice_id': nc_id,
|
||||
'x_fc_stripe_invoice_id': inv.get('stripe_invoice_id'),
|
||||
})
|
||||
tax = self._fc_tax_for(inv.get('subtotal'), inv.get('tax'))
|
||||
line_vals = []
|
||||
for it in inv.get('items', []):
|
||||
fam = self._fc_family_for(it.get('description'))
|
||||
summary['by_family'][fam] = round(
|
||||
summary['by_family'].get(fam, 0.0) + float(it.get('amount') or 0.0), 2)
|
||||
line_vals.append((0, 0, {
|
||||
'name': it.get('description') or 'NexaCloud',
|
||||
'quantity': float(it.get('quantity') or 1.0),
|
||||
'price_unit': float(it.get('unit_price') or it.get('amount') or 0.0),
|
||||
'account_id': self._fc_income_account(fam).id,
|
||||
'tax_ids': [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
|
||||
}))
|
||||
move.write({'invoice_line_ids': line_vals})
|
||||
summary['updated' if existing else 'created'] += 1
|
||||
if post:
|
||||
move.action_post()
|
||||
summary['posted'] += 1
|
||||
self._fc_reconcile_payment(move, inv)
|
||||
except Exception as e: # noqa: BLE001 - per-invoice isolation
|
||||
_logger.exception("Ledger ingest: invoice %s failed", nc_id)
|
||||
summary['failed'].append({'id': nc_id, 'error': '%s: %s' % (type(e).__name__, e)})
|
||||
return summary
|
||||
|
||||
@api.model
|
||||
def _fc_reconcile_payment(self, move, inv):
|
||||
"""Placeholder until Task 5; defined so post=True doesn't AttributeError."""
|
||||
return False
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If tax computes to 13.00 only when the company/fiscal position allows it, read the tax setup on trial; if `amount_tax` ≠ 13.00, the chosen tax is wrong — fix `_fc_tax_for`, never weaken the assertion.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): ingest NexaCloud invoices -> draft account.move (idempotent)`
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Reconcile Stripe payments (paid invoices show paid)
|
||||
|
||||
**Read reference first:** confirm the payment-register flow on trial:
|
||||
```bash
|
||||
ssh pve-worker1 "qm guest exec 316 -- bash -lc 'docker exec odoo-trial-app bash -lc \"grep -nE \\\"_create_payments|def action_create_payments\\\" /mnt/enterprise-addons/account/wizard/account_payment_register.py | head\"'"
|
||||
```
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
def test_paid_invoice_is_reconciled_and_shows_paid(self):
|
||||
data = _inv_fixture()
|
||||
data[0].update({'status': 'paid', 'amount_paid': 113.0, 'paid_at': '2026-05-02'})
|
||||
self.W._ingest_invoices(data, post=True)
|
||||
mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertIn(mv.payment_state, ('paid', 'in_payment'))
|
||||
```
|
||||
(Add this inside `TestLedgerIngest`.)
|
||||
|
||||
- [ ] **Step 2: run** → FAIL (payment not reconciled).
|
||||
|
||||
- [ ] **Step 3: implement** `_fc_reconcile_payment` + a journal helper (replace the placeholder):
|
||||
```python
|
||||
@api.model
|
||||
def _fc_stripe_journal(self):
|
||||
Journal = self.env['account.journal']
|
||||
j = Journal.search([('code', '=', 'NCSTR')], limit=1)
|
||||
if not j:
|
||||
j = Journal.create({'name': 'NexaCloud Stripe', 'code': 'NCSTR', 'type': 'bank'})
|
||||
return j
|
||||
|
||||
@api.model
|
||||
def _fc_reconcile_payment(self, move, inv):
|
||||
paid = float(inv.get('amount_paid') or 0.0)
|
||||
if (inv.get('status') != 'paid' and paid <= 0) or move.state != 'posted':
|
||||
return False
|
||||
reg = self.env['account.payment.register'].with_context(
|
||||
active_model='account.move', active_ids=move.ids).create({
|
||||
'journal_id': self._fc_stripe_journal().id,
|
||||
'payment_date': inv.get('paid_at') or move.invoice_date or fields.Date.today(),
|
||||
'amount': paid or move.amount_total,
|
||||
})
|
||||
reg._create_payments()
|
||||
return True
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If `payment_state` is `in_payment` rather than `paid`, that's expected when the bank journal isn't reconciled to a statement — accept both, as the assertion does.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): reconcile Stripe payments so ingested invoices show paid`
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Reader + wizard actions + bulk-post + cron
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `views/invoice_ledger_views.xml`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** for bulk-post + DSN guard (append):
|
||||
```python
|
||||
def test_post_ingested_posts_drafts(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
n = self.W._post_ingested()
|
||||
mv = self.env['account.move'].search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertGreaterEqual(n, 1)
|
||||
|
||||
def test_read_invoices_guards_missing_dsn(self):
|
||||
from odoo.exceptions import UserError
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
with self.assertRaises(UserError):
|
||||
self.W._read_nexacloud_invoices()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement** `_post_ingested`, `_read_nexacloud_invoices`, `action_run`, and a cron entry:
|
||||
```python
|
||||
@api.model
|
||||
def _post_ingested(self):
|
||||
moves = self.env['account.move'].search([
|
||||
('x_fc_nexacloud_invoice_id', '!=', False),
|
||||
('state', '=', 'draft'), ('move_type', '=', 'out_invoice')])
|
||||
posted = 0
|
||||
for mv in moves:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
mv.action_post()
|
||||
posted += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.exception("Ledger post: move %s failed", mv.id)
|
||||
return posted
|
||||
|
||||
def _read_nexacloud_invoices(self, since=None):
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
dsn = self.env['ir.config_parameter'].sudo().get_param('fusion_billing.nexacloud_dsn')
|
||||
if not dsn:
|
||||
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
where = "WHERE i.created_at >= %(since)s" if since else ""
|
||||
cur.execute(
|
||||
"SELECT i.id, i.stripe_invoice_id, i.invoice_number, i.user_id AS user_external_id, "
|
||||
"u.full_name AS partner_name, COALESCE(u.billing_email,u.email) AS partner_email, "
|
||||
"i.created_at AS invoice_date, i.currency, i.status, i.subtotal, i.tax, "
|
||||
"i.amount_paid, i.paid_at "
|
||||
"FROM invoices i JOIN users u ON u.id = i.user_id " + where +
|
||||
" ORDER BY i.created_at", {'since': since})
|
||||
invoices = {str(r['id']): dict(r, items=[]) for r in cur.fetchall()}
|
||||
cur.execute(
|
||||
"SELECT ii.invoice_id, ii.description, ii.quantity, ii.unit_price, ii.amount "
|
||||
"FROM invoice_items ii WHERE ii.invoice_id = ANY(%(ids)s)",
|
||||
{'ids': list(invoices.keys())})
|
||||
for r in cur.fetchall():
|
||||
inv = invoices.get(str(r['invoice_id']))
|
||||
if inv:
|
||||
inv['items'].append({'description': r['description'], 'quantity': r['quantity'],
|
||||
'unit_price': r['unit_price'], 'amount': r['amount']})
|
||||
for inv in invoices.values():
|
||||
inv['id'] = str(inv['id'])
|
||||
inv['user_external_id'] = str(inv['user_external_id'])
|
||||
return list(invoices.values())
|
||||
except psycopg2.Error as e:
|
||||
raise UserError("Failed reading NexaCloud invoices — schema may have changed:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def action_run(self):
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_invoices()
|
||||
if self.dry_run:
|
||||
class _Rollback(Exception):
|
||||
pass
|
||||
res = {}
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
res.update(self._ingest_invoices(data, post=False))
|
||||
raise _Rollback()
|
||||
except _Rollback:
|
||||
pass
|
||||
res['dry_run'] = True
|
||||
else:
|
||||
res = self._ingest_invoices(data, post=self.auto_post)
|
||||
self.result_summary = json.dumps(res, indent=2, default=str)
|
||||
if res.get('failed'):
|
||||
_logger.error("Ledger ingest: %s failed: %s", len(res['failed']), res['failed'])
|
||||
return {"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new"}
|
||||
```
|
||||
Add a daily cron to `views/invoice_ledger_views.xml`:
|
||||
```xml
|
||||
<record id="cron_fc_invoice_ledger" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Ingest NexaCloud invoices (daily)</field>
|
||||
<field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.create({'dry_run': False, 'auto_post': True})._cron_ingest_recent()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">False</field>
|
||||
</record>
|
||||
```
|
||||
And `_cron_ingest_recent` (ingest invoices from the last 2 days, idempotent):
|
||||
```python
|
||||
def _cron_ingest_recent(self):
|
||||
from datetime import timedelta
|
||||
since = fields.Datetime.to_string(fields.Datetime.now() - timedelta(days=2))
|
||||
return self._ingest_invoices(self._read_nexacloud_invoices(since=since), post=True)
|
||||
```
|
||||
(Cron ships `active=False` — enabled only after the backfill is reviewed.)
|
||||
|
||||
- [ ] **Step 4: run** → PASS.
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): invoice-ledger reader, wizard actions, bulk-post, daily cron`
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Prune obsolete metered shadow data
|
||||
|
||||
**Files:** modify `wizards/invoice_ledger.py`, `tests/test_invoice_ledger.py`.
|
||||
|
||||
- [ ] **Step 1: failing test** (append):
|
||||
```python
|
||||
def test_prune_shadow_removes_shadow_subs_only(self):
|
||||
# a shadow sub + a normal order
|
||||
p = self.env['res.partner'].sudo().create({'name': 'X'})
|
||||
shadow = self.env['sale.order'].sudo().create({'partner_id': p.id, 'x_fc_shadow': True})
|
||||
n = self.W._fc_prune_metered_shadow()
|
||||
self.assertFalse(shadow.exists())
|
||||
self.assertGreaterEqual(n.get('subscriptions', 0), 1)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: run** → FAIL.
|
||||
|
||||
- [ ] **Step 3: implement**:
|
||||
```python
|
||||
@api.model
|
||||
def _fc_prune_metered_shadow(self):
|
||||
"""Delete the superseded metered shadow data (shadow sale.orders, NC-* products,
|
||||
NexaCloud charges, reconciliation rows). Reversible only by re-import."""
|
||||
counts = {}
|
||||
subs = self.env['sale.order'].search([('x_fc_shadow', '=', True)])
|
||||
counts['subscriptions'] = len(subs)
|
||||
subs.unlink()
|
||||
prods = self.env['product.product'].search([('default_code', '=like', 'NC-%')])
|
||||
counts['products'] = len(prods)
|
||||
prods.unlink()
|
||||
ch = self.env['fusion.billing.charge'].search([])
|
||||
counts['charges'] = len(ch)
|
||||
ch.unlink()
|
||||
rec = self.env['fusion.billing.reconciliation'].search([])
|
||||
counts['reconciliations'] = len(rec)
|
||||
rec.unlink()
|
||||
return counts
|
||||
```
|
||||
|
||||
- [ ] **Step 4: run** → PASS. (If a product can't unlink due to references, archive instead — read the error and adjust.)
|
||||
|
||||
- [ ] **Step 5: commit** — `feat(billing): prune obsolete metered shadow data helper`
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full suite + static checks
|
||||
|
||||
- [ ] `bash scripts/fcb_test_on_trial.sh` → `FCB_EXIT=0`.
|
||||
- [ ] `grep -rn "_sql_constraints" fusion_centralize_billing/ || echo clean` → clean.
|
||||
- [ ] `grep -rnE "sale\.subscription[^.]" fusion_centralize_billing/ | grep -v "sale.subscription.plan"` → only docstring.
|
||||
- [ ] commit any fixes.
|
||||
|
||||
## Done = invoice ledger ready to run
|
||||
|
||||
Then (separate, gated, NOT in this plan): on nexamain — prune shadow data, **dry-run** the full backfill (review the per-family $ summary + unmatched "Other" lines), ingest **as draft**, you review a sample, **bulk-post**, enable the daily cron.
|
||||
@@ -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.
|
||||
@@ -3,7 +3,7 @@
|
||||
**Date:** 2026-05-20
|
||||
**Module:** `fusion_repairs` (new)
|
||||
**Owner:** Gurpreet
|
||||
**Status:** Approved (ready for implementation plan)
|
||||
**Status:** Implemented in repo (bundles 1–11); see [`fusion_repairs/cloud.md`](../../../fusion_repairs/cloud.md) for shipped vs deferred
|
||||
**Scope:** Four-phase build (~8-12 weeks); three intake surfaces; 53 features
|
||||
**Sister modules:** `fusion_repair_compliance`, `fusion_repair_plans`, `fusion_repair_shop`, `fusion_repair_analytics` (Phase 4, optional split)
|
||||
|
||||
@@ -26,8 +26,8 @@ Built incrementally across 4 phases; each phase ships a usable slice.
|
||||
|
||||
## Current state
|
||||
|
||||
- [`fusion_repairs/`](fusion_repairs/) is an **empty folder** — no `__manifest__.py`, models, or views yet.
|
||||
- No existing code in the repo extends Odoo's `repair` app.
|
||||
- [`fusion_repairs/`](fusion_repairs/) is a **full Odoo 19 addon** (~100+ files, version `19.0.2.2.4`). Living status: [`fusion_repairs/cloud.md`](../../../fusion_repairs/cloud.md).
|
||||
- Extends Odoo `repair.order`, `sale.order`, `fusion.technician.task`, portals, and adds flowchart / pricing / parts models.
|
||||
- Closest precedents:
|
||||
- [`fusion_ltc_management/models/ltc_repair.py`](fusion_ltc_management/models/ltc_repair.py) — repair workflow + SO + technician task (LTC facilities only; **keep separate**)
|
||||
- [`fusion_tasks/models/technician_task.py`](fusion_tasks/models/technician_task.py) — field service scheduling with `task_type` including `repair` / `maintenance`
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
|----|---------|-------|-------|
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
<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
|
||||
'fusion_tasks', # technician tasks + fusion.email.builder.mixin
|
||||
'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: '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 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
|
||||
# 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -720,10 +720,10 @@ Themes adapt via project SCSS rules — no hardcoded colours per CLAUDE.md.
|
||||
|
||||
| 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. |
|
||||
|
||||
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
|
||||
|
||||
@@ -740,7 +740,7 @@ flowchart LR
|
||||
|
||||
### 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 |
|
||||
|-------|------|---------|
|
||||
@@ -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))
|
||||
|
||||
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_list` — card list with status badge, scheduled date, tech name
|
||||
- `portal_repair_detail` — timeline + chatter
|
||||
- `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))
|
||||
|
||||
@@ -860,7 +860,7 @@ Extend repair order form view with Intake tab (answers), Maintenance tab, and st
|
||||
|
||||
**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_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:**
|
||||
- `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):**
|
||||
- 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)
|
||||
- Mobile photo / camera capture
|
||||
- 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| **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 |
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# NexaCloud → Odoo Invoice Ledger (Design)
|
||||
|
||||
- **Date:** 2026-05-27
|
||||
- **Status:** Design approved (brainstorming) — pending written-spec review
|
||||
- **Module:** `fusion_centralize_billing` (Odoo 19 Enterprise; build/test on odoo-trial, run on `nexamain`)
|
||||
- **Supersedes (for NexaCloud):** the metered-billing direction (recompute charges from a CPU-seconds model). The dual-run proved that model captures ~6% of reality.
|
||||
|
||||
## 1. Why this exists (the pivot)
|
||||
|
||||
The dual-run reconciliation (2026-05-27) showed **94% of NexaCloud's revenue is billed
|
||||
outside** the per-deployment/CPU-metered model the engine was built for:
|
||||
|
||||
| NexaCloud invoices | count | total |
|
||||
|---|---|---|
|
||||
| NOT linked to a `subscriptions` row (Hosting services, add-ons) | 22 | **$2,881.08** |
|
||||
| Linked to a `subscriptions` row (what the metered importer reads) | 7 | **$180.79** |
|
||||
|
||||
NexaCloud bills via **Stripe** — service invoices (Odoo ERP Hosting / WordPress Hosting
|
||||
~$214.50/mo), **add-ons** (Daily Backup, WhatsApp, Forms Builder, White Label), and
|
||||
**Stripe proration** ("Remaining time on …"). That billing already works. **Re-implementing
|
||||
Stripe's proration + add-on logic in Odoo is the wrong move.** Instead, Odoo **ingests
|
||||
NexaCloud's actual invoices** and becomes the single **accounting system of record**
|
||||
(posted invoices + reconciled payments + HST), while NexaCloud/Stripe keep doing the billing.
|
||||
|
||||
## 2. Goal & scope (locked in brainstorming)
|
||||
|
||||
- **Full accounting SoR:** posted `account.move` customer invoices, **Stripe payments
|
||||
reconciled** (invoices show paid, AR accurate), **HST** modelled.
|
||||
- **All history + ongoing.** Backfill every NexaCloud invoice, then a daily cron for new ones.
|
||||
- **Revenue split by service family** into distinct income accounts (P&L breakdown).
|
||||
- **Draft-first rollout:** first nexamain run creates drafts for review, then bulk-post.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
A new ingestion component in `fusion_centralize_billing`, mirroring the importer's
|
||||
read/write split (reuses the read-only DSN + the `account.link` partner mapping already
|
||||
set up on nexamain):
|
||||
|
||||
- **`_read_nexacloud_invoices(since=None)`** — read-only `psycopg2`: `invoices` +
|
||||
`invoice_items` (+ `users` for partner resolution), optionally since a date. Returns
|
||||
plain row dicts. The only code touching NexaCloud.
|
||||
- **`_ingest_invoices(data, post=False)`** — pure Odoo: for each NexaCloud invoice,
|
||||
upsert one `account.move` (`move_type='out_invoice'`) with lines, tax, and (if paid) a
|
||||
reconciled payment. Idempotent on `x_fc_nexacloud_invoice_id`. Returns a summary. With
|
||||
`post=False` invoices are left **draft**; a separate `_post_ingested(...)` bulk-posts
|
||||
after review.
|
||||
- Trigger: an **`account.move`-creation wizard/action** + a daily `ir.cron` for ongoing.
|
||||
|
||||
## 4. Data mapping
|
||||
|
||||
### 4.1 Invoice → `account.move`
|
||||
- `move_type='out_invoice'`, `partner_id` = unified `res.partner` (resolve `invoice.user_id`
|
||||
→ `account.link` (service=nexacloud) → partner; create via the importer's resolver if missing),
|
||||
`invoice_date` = NexaCloud invoice date, `ref` = `invoice_number`, `currency_id` = CAD.
|
||||
- New fields (x_fc_*) on `account.move`: `x_fc_nexacloud_invoice_id` (idempotency key, unique),
|
||||
`x_fc_stripe_invoice_id`.
|
||||
|
||||
### 4.2 `invoice_item` → `account.move.line` (one per item)
|
||||
- `name` = item description, `quantity`, `price_unit`, `account_id` = the **service-family
|
||||
income account** (see 4.3).
|
||||
- **Tax:** derive the invoice's effective rate from `invoice.tax / invoice.subtotal`; map to
|
||||
the matching Odoo `account.tax` — **HST 13%** when ≈13%, **no tax** when 0, else the closest
|
||||
configured tax. Odoo's computed tax must equal NexaCloud's `invoice.tax` (assert in tests).
|
||||
|
||||
### 4.3 Service-family → income account (keyword mapping, with fallback)
|
||||
| Family | Matches (description keywords) |
|
||||
|---|---|
|
||||
| **Hosting** | "Odoo ERP Hosting", "WordPress Website Hosting" |
|
||||
| **Managed plans** | "Managed", "Managed Odoo - Standard", "… - Managed" |
|
||||
| **Add-ons** | "Daily Backup Protection", "WhatsApp Business Messaging", "Forms Builder", "White Label Branding" |
|
||||
| **Proration** | "Remaining time on …" → resolve to the family of the named item |
|
||||
| **Other** (fallback) | anything unmatched → a generic NexaCloud income account (flagged in the summary for review) |
|
||||
|
||||
Income-account codes come from the COA (`nexa_coa_setup`); confirm/create at implementation.
|
||||
|
||||
### 4.4 Payment reconciliation
|
||||
- For invoices with `status='paid'` (or `amount_paid >= amount_due`): register an
|
||||
`account.payment` via a **"NexaCloud Stripe" bank journal**, dated `paid_at`, amount
|
||||
`amount_paid`, ref = `stripe_invoice_id`; reconcile it against the posted invoice so the
|
||||
invoice shows **paid** and AR clears.
|
||||
- Open/unpaid invoices: post (or draft) without a payment → they sit in AR. Void invoices:
|
||||
ingest as cancelled (or skip) — decide from the data at implementation.
|
||||
|
||||
## 5. Idempotency & ongoing sync
|
||||
- Upsert on `x_fc_nexacloud_invoice_id` (a DB-unique field on `account.move`). Re-running
|
||||
updates a still-draft move or skips a posted one (never duplicates, never silently mutates
|
||||
a posted ledger entry — posted invoices that changed upstream are reported for manual review).
|
||||
- Daily `ir.cron` calls `_read_nexacloud_invoices(since=last_run)` → `_ingest_invoices(post=True)`
|
||||
for go-forward invoices (configurable auto-post once trusted).
|
||||
|
||||
## 6. Safety & rollout (touches the live ledger)
|
||||
1. Build + **TDD on odoo-trial** (fixture invoices → assert move totals, tax = source tax,
|
||||
payment reconciled, idempotency, family→account mapping).
|
||||
2. **Dry-run** mode (read + report, write nothing) — like the importer.
|
||||
3. First **nexamain** run: ingest **all history as DRAFT**, report a summary (counts per
|
||||
family, total $, unmatched-"Other" lines, tax mismatches). **You review a sample.**
|
||||
4. **Bulk-post** after approval. Then enable the daily cron.
|
||||
5. **Prune the obsolete metered shadow data** first: delete the 87 draft shadow
|
||||
`sale.order`s (`x_fc_shadow=True`), the ~464 `NC-*` products, the NexaCloud charges, and
|
||||
the reconciliation rows — they belong to the superseded recompute approach and would
|
||||
confuse the ledger.
|
||||
|
||||
## 7. Out of scope
|
||||
- The metered recompute engine's go-live (flip, control loop, usage push) — superseded for
|
||||
NexaCloud. The engine code stays in the module (potential future metered service, e.g.
|
||||
NexaMaps) but is inert.
|
||||
- NexaDesk / NexaMaps ledgers — separate (same ingestion pattern when needed).
|
||||
- Reproducing Stripe's billing logic — explicitly NOT done; we ingest its output.
|
||||
|
||||
## 8. Verify at implementation (Odoo 19; never from memory)
|
||||
- `account.move` / `account.move.line` / `account.payment` field names + the post flow
|
||||
(`action_post`) and payment register/reconcile API (read `account` + `account_accountant`
|
||||
reference on odoo-trial).
|
||||
- The HST `account.tax` record + income accounts + a usable bank journal on `nexamain`
|
||||
(from `nexa_coa_setup`); create the "NexaCloud Stripe" journal + family income accounts if absent.
|
||||
- Whether `invoice_items.amount` is pre-tax (expected: `invoice.subtotal = Σ items`; tax separate).
|
||||
|
||||
## 9. Success criteria
|
||||
- A fixture NexaCloud invoice ingests to a posted `account.move` whose untaxed total, tax
|
||||
(= source `invoice.tax`), and total match the source; a paid one is reconciled and shows paid.
|
||||
- Re-running ingests nothing new (idempotent).
|
||||
- Dry-run on nexamain reports the full backfill (counts per family, $ totals, unmatched lines)
|
||||
with zero writes; the real run creates drafts; bulk-post on approval.
|
||||
- Full suite green on odoo-trial (`FCB_EXIT=0`).
|
||||
|
||||
## 10. Backfill status + go-forward caveat (2026-05-27)
|
||||
|
||||
- **Backfill done + verified on nexamain.** 23 customer invoices posted + payment-reconciled
|
||||
($3,403.46), 1 void deleted. NexaCloud's `created_at`/`status`/`paid_at` proved
|
||||
**unreliable** (sync-stamped today; one void marked otherwise), so invoice + payment dates
|
||||
and paid status were verified against the **source systems**:
|
||||
- **Stripe** (14 invoices, `in_*` ids) — real `created` / `paid_at` via the Stripe API.
|
||||
- **Lago** (9 `NEX-*` invoices, `lago:*` ids, billed pre-Stripe) — `issuing_date` +
|
||||
`payment_status=succeeded` via the Lago API (`billing.nexasystems.ca/api/api/v1`, key in
|
||||
Fusion-Chat; Lago host 192.168.1.117, double-hop ssh via supabase-prod).
|
||||
Partner names came from the NexaCloud `company` field (not the user's full_name).
|
||||
- **GO-FORWARD: verified sync is LIVE (2026-05-27).** The verification used in the backfill
|
||||
is now folded into the ingest path, and the daily cron is enabled:
|
||||
- `_fc_verify(inv)` routes each invoice to its source by `stripe_invoice_id` prefix
|
||||
(`in_` → Stripe REST `GET /v1/invoices/{id}`; `lago:` → Lago REST) and returns
|
||||
`{invoice_date, void, draft, paid, paid_at, amount_paid}` taken from the SOURCE — or
|
||||
`None` if it can't be determined/reached. Credentials live in `ir.config_parameter`:
|
||||
`fusion_billing.stripe_api_key` (set, live), `fusion_billing.lago_api_url` /
|
||||
`fusion_billing.lago_api_key` (optional; unset — no new Lago invoices expected).
|
||||
- `_cron_sync_verified()` reads all NexaCloud invoices, skips ones already posted, then
|
||||
for the rest: skips **void** and **draft** (not finalized at source), logs **unverified**
|
||||
for retry next run, and ingests the rest with `_ingest_invoices(post=True, verified=…)`
|
||||
so the move uses the source invoice_date (accounting date too) and a payment is
|
||||
reconciled ONLY when the source confirms paid. Never acts on NexaCloud's raw fields.
|
||||
- Cron `cron_fc_invoice_ledger` on nexamain: **active**, daily at 06:00 UTC. (A stale
|
||||
pre-existing copy of this record still called the removed `_cron_ingest_recent`; because
|
||||
the data file is `noupdate="1"` the upgrade didn't rewrite it, so its server-action code
|
||||
+ name were corrected once via SQL. Fresh installs get the right definition from the XML.)
|
||||
- First live run (2026-05-27): 23 already-posted, 1 void + 2 Stripe drafts + 5 genuine
|
||||
$0 invoices all correctly skipped, **0 new posted**, ledger intact at $3,403.46.
|
||||
- Verification helpers are unit-tested without network (routing short-circuits when no
|
||||
credentials are set; the cron is exercised with `_read_nexacloud_invoices` / `_fc_verify`
|
||||
patched). Full suite green on odoo-trial (`FCB_EXIT=0`).
|
||||
@@ -0,0 +1,247 @@
|
||||
# Schedule-Driven Attendance Automation — Design
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Module:** `fusion_clock`
|
||||
**Status:** Approved design → ready for implementation plan
|
||||
|
||||
## Goal
|
||||
|
||||
Drive every attendance automation (clock-in/out reminders, absence detection,
|
||||
late/early penalties, auto-clock-out) from each employee's **real schedule** —
|
||||
the team lead's **posted** planner entry first, then the employee's **recurring
|
||||
shift** — never the global 9–5 default. Employees who aren't scheduled get no
|
||||
reminders or absence flags. Overtime past the scheduled end is normal and is
|
||||
never cut off.
|
||||
|
||||
## Problem & root cause
|
||||
|
||||
The machinery already exists: `fusion.clock.shift` (recurring templates,
|
||||
assigned via `hr.employee.x_fclk_shift_id`), `fusion.clock.schedule` (dated
|
||||
per-employee entries built in the backend **shift planner** client action), and
|
||||
`hr.employee._get_fclk_day_plan(date)` which resolves per-day times. The crons
|
||||
already call these.
|
||||
|
||||
The bug: in `_get_fclk_day_plan()`, when an employee has **no dated entry and no
|
||||
assigned shift**, it silently falls back to the **global 9–5 default with
|
||||
`is_off = False`**. So everyone is treated as a 9–5 worker, and the reminder /
|
||||
absence crons fire off that global time. The crons also **hardcode-skip Sat/Sun**
|
||||
(`weekday() >= 5`), which is wrong for a production floor that runs weekends.
|
||||
Net effect: reminders are not actually schedule-driven for anyone who isn't on a
|
||||
fixed weekday 9–5 — exactly the spurious-email problem reported.
|
||||
|
||||
## Decisions (from brainstorming)
|
||||
|
||||
1. **"Expected to work" source:** posted planner entry → else recurring shift
|
||||
(if it covers that weekday) → else **not scheduled** (silent). The global
|
||||
default never makes someone "expected."
|
||||
2. **Overtime:** time past the scheduled end is overtime and is never cut off.
|
||||
Auto-clock-out fires **only** at a generous safety cap (forgot-to-clock-out).
|
||||
3. **Posting:** draft → post gate. Team leads build the week in draft;
|
||||
automation ignores draft days. "Post" publishes the week and emails each
|
||||
employee their shifts. Only posted entries drive automation.
|
||||
4. **Employee schedule view:** reuse the **existing "Today's Shift" card** on
|
||||
`/my/clock` — no new portal view. (See Coordination.)
|
||||
|
||||
## Non-goals / constraints
|
||||
|
||||
- **No edits to the employee `/my` portal shell.** A concurrent session
|
||||
("Internal employee portal design", `fusion_plating`) owns `/my` + `/my/home`
|
||||
routing and the `/my/clock` bottom-nav tabs (it is adding a Payslips tab).
|
||||
This feature makes **zero** edits to `controllers/portal_clock.py` routing,
|
||||
`views/portal_clock_templates.xml`, or `/my` routing. The existing "Today's
|
||||
Shift" card already renders `today_schedule.get('label') or 'Not scheduled'`,
|
||||
so once the resolver is schedule-driven the card updates itself. Employees get
|
||||
their full posted week via the Post notification email. A dedicated "My
|
||||
Schedule" nav tab, if ever wanted, belongs to the portal-shell session.
|
||||
- The backend **shift planner** client action (manager/team-lead facing) is
|
||||
*not* the `/my` portal and **is** in scope to edit (Post button, draft/posted
|
||||
visuals).
|
||||
- No change to how attendance hours / overtime are computed.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Schedule resolver — `hr.employee._get_fclk_day_plan(date)`
|
||||
|
||||
Rewrite to return an explicit `scheduled` flag and a precise `source`, keeping
|
||||
all existing keys for backward compatibility (`is_off`, `label`, `hours`,
|
||||
`start_time`, `end_time`, `break_minutes`).
|
||||
|
||||
Return shape:
|
||||
```python
|
||||
{
|
||||
'scheduled': bool, # is the employee expected to work this day?
|
||||
'source': 'schedule' | 'shift' | 'none',
|
||||
'is_off': bool,
|
||||
'start_time': float, 'end_time': float, 'break_minutes': float,
|
||||
'hours': float,
|
||||
'label': str, # '' when not scheduled → card shows 'Not scheduled'
|
||||
'schedule_id': int | False,
|
||||
}
|
||||
```
|
||||
|
||||
Resolution order:
|
||||
1. **Posted planner entry** (`fusion.clock.schedule`, `state == 'posted'`) for
|
||||
(employee, date) — *draft entries are ignored, treated as absent*:
|
||||
- `is_off` → `scheduled=False`, `is_off=True`, `source='schedule'`, `hours=0`,
|
||||
`label='OFF'`.
|
||||
- else → `scheduled=True`, times from entry, `source='schedule'`.
|
||||
2. Else **recurring shift** `x_fclk_shift_id` **and** the shift covers
|
||||
`date`'s weekday → `scheduled=True`, times from shift, `source='shift'`.
|
||||
3. Else → `scheduled=False`, `source='none'`, `is_off=False`, `label=''`,
|
||||
`hours=0`. (Global default may fill `start_time`/`end_time` as a display
|
||||
hint only; it never sets `scheduled=True`.)
|
||||
|
||||
`_get_fclk_scheduled_times()` and `_get_fclk_break_minutes()` keep working off
|
||||
this structure unchanged.
|
||||
|
||||
### 2. Data model changes
|
||||
|
||||
- **`fusion.clock.schedule`**: add
|
||||
- `state = Selection([('draft','Draft'),('posted','Posted')], default='draft')`
|
||||
- `posted_date = Datetime`
|
||||
- Automation reads only `state == 'posted'`.
|
||||
- **`fusion.clock.shift`**: add a weekday pattern —
|
||||
`day_mon … day_sun = Boolean` (default Mon–Fri True, Sat–Sun False) plus a
|
||||
helper `covers_weekday(date) -> bool`. This replaces the hardcoded weekend
|
||||
skip and lets weekend shifts exist. (Judgment call: pattern lives on the
|
||||
shared shift template, e.g. "Mon–Fri Day", "Sat–Sun Weekend"; unique patterns
|
||||
→ own template or a posted planner override.)
|
||||
|
||||
### 3. Posting workflow
|
||||
|
||||
- New jsonrpc route `POST /fusion_clock/shift_planner/post_week` in
|
||||
`controllers/shift_planner.py`:
|
||||
- Gate: manager OR team lead.
|
||||
- Scope: managers → all in-scope employees for the viewed week; team leads →
|
||||
their direct reports (`parent_id` == the team lead's employee). Reuse the
|
||||
existing dashboard scoping helper.
|
||||
- Set `state='posted'`, `posted_date=now` on those week entries.
|
||||
- Queue **one email per affected employee** summarizing their posted shifts
|
||||
for the week (reuse `_fclk_email_wrap`). Failures logged, never block the
|
||||
post.
|
||||
- New planner entries default to `draft`. Re-posting after edits re-publishes
|
||||
(and re-notifies, flagged as an update).
|
||||
- Planner client action (`static/src/js/fusion_clock_shift_planner.js` + its
|
||||
template) gains a **Post** button and a draft-vs-posted visual cue. (Backend
|
||||
client action — not the `/my` portal.)
|
||||
|
||||
### 4. Reminder cron — `hr.attendance._cron_fusion_employee_reminders`
|
||||
|
||||
- Remove the `weekday() >= 5` hardcode.
|
||||
- Per enabled employee: `plan = emp._get_fclk_day_plan(today)`; **if not
|
||||
`plan['scheduled']` → skip** (silent).
|
||||
- Missed clock-in: if scheduled, not checked in, no attendance today, and
|
||||
`now > scheduled_in + reminder_before_shift_minutes` → remind. Uses the
|
||||
employee's real start, so a 14:00 shift is never pinged at 09:30.
|
||||
- Clock-out reminder: **reframed** (judgment call). Drop the "your shift ends at
|
||||
X" nudge (noise when OT is the norm). Instead, if still checked in and
|
||||
approaching the safety cap (`check_in + max_shift_hours -
|
||||
reminder_before_end_minutes`), send "you're still clocked in — remember to
|
||||
clock out."
|
||||
|
||||
### 5. Absence cron — `hr.attendance._cron_fusion_check_absences`
|
||||
|
||||
- Remove the `weekday() >= 5` hardcode.
|
||||
- Per enabled employee: `plan = emp._get_fclk_day_plan(yesterday)`; **only flag
|
||||
absent if `plan['scheduled']`** AND no attendance AND no leave request AND no
|
||||
global holiday. Off/unscheduled → never flagged.
|
||||
|
||||
### 6. Auto-clock-out — `hr.attendance._cron_fusion_auto_clock_out`
|
||||
|
||||
- Stop closing at `scheduled_out + grace`. Close **only** at the safety cap
|
||||
`check_in + max_shift_hours`. Everything between the scheduled end and the cap
|
||||
is captured as overtime by the existing fields.
|
||||
- Bump default `max_shift_hours` **12 → 16** (still configurable).
|
||||
- Keep `x_fclk_pending_reason=True`, break deduction, and office notify on
|
||||
auto-close.
|
||||
|
||||
### 7. Penalties — `controllers/clock_api.py::_check_and_create_penalty`
|
||||
|
||||
- Skip when the day is not scheduled (`not plan['scheduled']`), in addition to
|
||||
the existing posted-OFF skip. Late-in / early-out stay keyed off the resolved
|
||||
scheduled start/end. Overtime is never penalized.
|
||||
|
||||
### 8. Kiosk callers — `clock_kiosk.py`, `clock_nfc_kiosk.py`
|
||||
|
||||
- The existing `is_scheduled_off = source == 'schedule' and is_off` checks keep
|
||||
working for posted-OFF days. Extend the "unscheduled shift" log + penalty-skip
|
||||
to also cover `source == 'none'` (clocked in on a day with no schedule) so a
|
||||
not-scheduled clock-in is logged as `unscheduled_shift` and creates no penalty.
|
||||
|
||||
### 9. Settings
|
||||
|
||||
- `res_config_settings`: change `fclk_max_shift_hours` default 12 → 16 (and the
|
||||
resolver/cron `get_param` fallback). Optionally surface the shift weekday
|
||||
pattern on the shift form. No other new settings required.
|
||||
|
||||
### 10. Frontend
|
||||
|
||||
- **No file edits.** The existing "Today's Shift" card auto-reflects the new
|
||||
resolver: scheduled → times + hours; posted OFF → "OFF"; not scheduled →
|
||||
"Not scheduled" (already coded as `label or 'Not scheduled'`).
|
||||
|
||||
## Data flow
|
||||
|
||||
posted planner entry / recurring shift → `_get_fclk_day_plan(date)` →
|
||||
`scheduled` flag → consumed by: reminder cron, absence cron, penalty helper,
|
||||
kiosk unscheduled-log, and (read-only) the portal "Today's Shift" card. Posting
|
||||
flips `state` to `posted` (making entries visible to the resolver) and emails
|
||||
employees.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Crons: wrap each employee's body in `with self.env.cr.savepoint():` so one bad
|
||||
record can't abort the batch (savepoints, not `cr.commit()` — works in prod and
|
||||
tests).
|
||||
- Posting: state writes + email queueing in one transaction; email creation in
|
||||
try/except with logging so a bad address never blocks the post.
|
||||
- Notifications: `mail.mail` with `auto_delete=True`; send failures logged.
|
||||
|
||||
## Testing (`tests/test_schedule_driven.py`, post_install)
|
||||
|
||||
- **Resolver matrix:** posted-working / posted-off / draft-ignored /
|
||||
recurring-covers-weekday / recurring-skips-weekday / nothing → not-scheduled.
|
||||
Assert `scheduled`, times, and `label`.
|
||||
- **Reminder cron:** scheduled + late + no attendance → reminder; not scheduled →
|
||||
none; 14:00 shift not pinged at 09:30; already clocked in → no clock-in
|
||||
reminder.
|
||||
- **Absence cron:** scheduled no-show → absent logged; not scheduled → not
|
||||
flagged; leave/holiday → not flagged.
|
||||
- **Auto-clock-out:** open past scheduled end but under cap → stays open; past
|
||||
cap → closed + `x_fclk_pending_reason`.
|
||||
- **Posting:** draft entry → resolver `scheduled=False` (ignored by crons); post
|
||||
→ `state='posted'`, resolver picks it up, email queued; team lead can post only
|
||||
direct reports.
|
||||
- **Penalties:** not-scheduled clock-in → no penalty; scheduled late → `late_in`.
|
||||
|
||||
## Files expected to change (for the plan)
|
||||
|
||||
- `models/hr_employee.py` — resolver refactor.
|
||||
- `models/clock_shift.py` — weekday booleans + `covers_weekday`.
|
||||
- `models/clock_schedule.py` — `state` + `posted_date`.
|
||||
- `models/hr_attendance.py` — reminders, absences, auto-clock-out + savepoints.
|
||||
- `controllers/clock_api.py` — penalty skip when not scheduled.
|
||||
- `controllers/clock_kiosk.py`, `controllers/clock_nfc_kiosk.py` — unscheduled
|
||||
log/penalty for `source == 'none'`.
|
||||
- `controllers/shift_planner.py` — `post_week` route + scope + notifications;
|
||||
default new entries to draft.
|
||||
- `static/src/js/fusion_clock_shift_planner.js` + planner template — Post button,
|
||||
draft/posted visuals.
|
||||
- `models/res_config_settings.py` + `views/res_config_settings_views.xml` —
|
||||
`max_shift_hours` default 16; optional weekday-pattern surfacing.
|
||||
- `views/clock_shift_views.xml` — weekday checkboxes on the shift form.
|
||||
- `views/clock_schedule_views.xml` — show `state`.
|
||||
- `tests/test_schedule_driven.py` (+ `tests/__init__.py`).
|
||||
- **Not touched:** `controllers/portal_clock.py` routing,
|
||||
`views/portal_clock_templates.xml`, `/my` routing (owned by the concurrent
|
||||
portal-shell session).
|
||||
|
||||
## Coordination
|
||||
|
||||
Concurrent session "Internal employee portal design" (`fusion_plating`) owns the
|
||||
employee `/my` portal shell: `/my` + `/my/home` redirect to the clock page and
|
||||
new bottom-nav tabs (Payslips). This feature is **backend-only on the frontend
|
||||
side** — it edits no `/my` portal files — so the two land without conflict
|
||||
regardless of order. Shared touchpoint to watch: both evolve the employee
|
||||
experience; if a "My Schedule" nav tab is desired, it is the portal-shell
|
||||
session's responsibility, fed by this feature's resolver.
|
||||
@@ -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.
|
||||
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
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
"name": "Fusion Centralized Billing",
|
||||
"version": "19.0.1.0.0",
|
||||
"version": "19.0.1.1.0",
|
||||
"category": "Accounting/Subscriptions",
|
||||
"summary": "Centralized billing engine for all NexaSystems services — metered usage, "
|
||||
"per-app billing API, and outbound webhooks on top of Odoo Enterprise subscriptions.",
|
||||
@@ -49,6 +49,7 @@ reference files from the container before implementing subscription/account inte
|
||||
"security/ir.model.access.csv",
|
||||
"data/ir_cron.xml",
|
||||
"views/import_wizard_views.xml",
|
||||
"views/invoice_ledger_views.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"application": False,
|
||||
|
||||
@@ -19,4 +19,17 @@
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Go-forward NexaCloud ledger sync. Ships INACTIVE: only enable once the Stripe
|
||||
(and Lago) API credentials are set on the instance and a manual run is verified,
|
||||
because the sync verifies each invoice against those sources before posting. -->
|
||||
<record id="cron_fc_invoice_ledger" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Sync NexaCloud invoices (Stripe/Lago verified)</field>
|
||||
<field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_sync_verified()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">False</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -7,3 +7,4 @@ from . import webhook
|
||||
from . import reconciliation
|
||||
from . import sale_order
|
||||
from . import res_partner
|
||||
from . import account_move
|
||||
|
||||
17
fusion_centralize_billing/models/account_move.py
Normal file
17
fusion_centralize_billing/models/account_move.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
x_fc_nexacloud_invoice_id = fields.Char(
|
||||
index=True, copy=False, help="Source NexaCloud invoice id — ledger idempotency key.")
|
||||
x_fc_stripe_invoice_id = fields.Char(index=True, copy=False)
|
||||
|
||||
_fc_nc_invoice_uniq = models.Constraint(
|
||||
"unique(x_fc_nexacloud_invoice_id)",
|
||||
"One Odoo invoice per NexaCloud invoice id.",
|
||||
)
|
||||
@@ -10,3 +10,4 @@ access_fusion_billing_metric_acct,fusion.billing.metric accountant,model_fusion_
|
||||
access_fusion_billing_charge_acct,fusion.billing.charge accountant,model_fusion_billing_charge,account.group_account_manager,1,1,1,0
|
||||
access_fusion_billing_reconciliation_acct,fusion.billing.reconciliation accountant,model_fusion_billing_reconciliation,account.group_account_manager,1,1,1,0
|
||||
access_fusion_billing_import_wizard,fusion.billing.import.wizard,model_fusion_billing_import_wizard,base.group_system,1,1,1,1
|
||||
access_fc_invoice_ledger_wizard,fusion.billing.invoice.ledger.wizard,model_fusion_billing_invoice_ledger_wizard,base.group_system,1,1,1,1
|
||||
|
||||
|
@@ -5,3 +5,4 @@ from . import test_api
|
||||
from . import test_webhook
|
||||
from . import test_importer
|
||||
from . import test_reconciliation
|
||||
from . import test_invoice_ledger
|
||||
|
||||
263
fusion_centralize_billing/tests/test_invoice_ledger.py
Normal file
263
fusion_centralize_billing/tests/test_invoice_ledger.py
Normal file
@@ -0,0 +1,263 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
def _inv_fixture():
|
||||
return [{
|
||||
'id': 'inv-1', 'stripe_invoice_id': 'in_test1', 'invoice_number': 'NEX-0001',
|
||||
'user_external_id': 'u-1', 'partner_name': 'Acme', 'partner_email': 'ar@acme.test',
|
||||
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
|
||||
'subtotal': 100.0, 'tax': 13.0, 'amount_paid': 0.0, 'paid_at': None,
|
||||
'items': [{'description': 'Odoo ERP Hosting (2026-05-01 to 2026-06-01)',
|
||||
'quantity': 1.0, 'unit_price': 100.0, 'amount': 100.0}],
|
||||
}]
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerFamily(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
|
||||
def test_family_classification(self):
|
||||
f = self.W._fc_family_for
|
||||
self.assertEqual(f('Odoo ERP Hosting (2026-05-01 to 2026-06-01)'), 'hosting')
|
||||
self.assertEqual(f('WordPress Website Hosting - Managed (at $50.00 / month)'), 'hosting')
|
||||
self.assertEqual(f('Managed Odoo - Standard (at $49.99 / month)'), 'managed')
|
||||
self.assertEqual(f('Daily Backup Protection'), 'addons')
|
||||
self.assertEqual(f('Remaining time on Daily Backup Protection after 27 May 2026'), 'addons')
|
||||
self.assertEqual(f('Something Unmapped'), 'other')
|
||||
|
||||
def test_income_account_per_family_distinct(self):
|
||||
a_host = self.W._fc_income_account('hosting')
|
||||
a_add = self.W._fc_income_account('addons')
|
||||
self.assertEqual(a_host.account_type, 'income')
|
||||
self.assertNotEqual(a_host, a_add)
|
||||
self.assertEqual(self.W._fc_income_account('hosting'), a_host) # idempotent
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerTax(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
|
||||
def test_tax_for_13pct_is_a_13_percent_sale_tax(self):
|
||||
tax = self.W._fc_tax_for(100.0, 13.0)
|
||||
self.assertTrue(tax, "expected an HST/13% sale tax on the Canadian COA")
|
||||
self.assertEqual(tax.type_tax_use, 'sale')
|
||||
res = tax.compute_all(100.0)
|
||||
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 13.0, places=2)
|
||||
|
||||
def test_tax_for_zero_is_zero_or_empty(self):
|
||||
tax = self.W._fc_tax_for(100.0, 0.0)
|
||||
if tax:
|
||||
res = tax.compute_all(100.0)
|
||||
self.assertAlmostEqual(res['total_included'] - res['total_excluded'], 0.0, places=2)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerIngest(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
self.Move = self.env['account.move']
|
||||
|
||||
def test_ingest_creates_draft_invoice_with_right_totals(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(len(mv), 1)
|
||||
self.assertEqual(mv.move_type, 'out_invoice')
|
||||
self.assertEqual(mv.state, 'draft')
|
||||
self.assertAlmostEqual(mv.amount_untaxed, 100.0, places=2)
|
||||
self.assertAlmostEqual(mv.amount_tax, 13.0, places=2) # equals source tax
|
||||
self.assertAlmostEqual(mv.amount_total, 113.0, places=2)
|
||||
self.assertEqual(mv.partner_id.email, 'ar@acme.test')
|
||||
self.assertEqual(mv.invoice_line_ids.account_id, self.W._fc_income_account('hosting'))
|
||||
|
||||
def test_ingest_is_idempotent(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
self.assertEqual(self.Move.search_count(
|
||||
[('x_fc_nexacloud_invoice_id', '=', 'inv-1')]), 1)
|
||||
|
||||
def test_paid_invoice_is_reconciled_and_shows_paid(self):
|
||||
data = _inv_fixture()
|
||||
data[0].update({'status': 'paid', 'amount_paid': 113.0, 'paid_at': '2026-05-02'})
|
||||
self.W._ingest_invoices(data, post=True)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertIn(mv.payment_state, ('paid', 'in_payment'))
|
||||
|
||||
def test_post_ingested_posts_drafts(self):
|
||||
self.W._ingest_invoices(_inv_fixture(), post=False)
|
||||
n = self.W._post_ingested()
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertGreaterEqual(n, 1)
|
||||
|
||||
def test_read_invoices_guards_missing_dsn(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_billing.nexacloud_dsn', '')
|
||||
with self.assertRaises(UserError):
|
||||
self.W._read_nexacloud_invoices()
|
||||
|
||||
def test_unitemized_subtotal_gets_reconciling_line(self):
|
||||
data = [{
|
||||
'id': 'inv-base', 'stripe_invoice_id': 'in_base', 'invoice_number': 'NEX-BASE',
|
||||
'user_external_id': 'u-2', 'partner_name': 'Globex', 'partner_email': 'ops@globex.test',
|
||||
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'open',
|
||||
'subtotal': 200.0, 'tax': 0.0, 'amount_paid': 0.0, 'paid_at': None,
|
||||
'items': [], # base plan billed via Stripe only — no line items
|
||||
}]
|
||||
self.W._ingest_invoices(data, post=False)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-base')])
|
||||
self.assertAlmostEqual(mv.amount_untaxed, 200.0, places=2) # captured via reconciling line
|
||||
self.assertTrue(any('base/unitemized' in (l.name or '') for l in mv.invoice_line_ids))
|
||||
|
||||
def test_zero_amount_invoice_skipped(self):
|
||||
data = [{'id': 'inv-zero', 'stripe_invoice_id': 'in_z', 'invoice_number': 'NEX-ZERO',
|
||||
'user_external_id': 'u-1', 'partner_name': 'Acme', 'partner_email': 'ar@acme.test',
|
||||
'invoice_date': '2026-05-01', 'currency': 'CAD', 'status': 'paid',
|
||||
'subtotal': 0.0, 'tax': 0.0, 'amount_paid': 0.0, 'paid_at': None, 'items': []}]
|
||||
summary = self.W._ingest_invoices(data, post=False)
|
||||
self.assertFalse(self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-zero')]))
|
||||
self.assertTrue(any(s.get('reason') == 'zero-amount invoice' for s in summary['skipped']))
|
||||
|
||||
def test_post_and_reconcile_paid_only(self):
|
||||
base = _inv_fixture()[0]
|
||||
paid = dict(base, id='inv-paid', invoice_number='NEX-PAID',
|
||||
status='paid', amount_paid=113.0, paid_at='2026-05-02',
|
||||
invoice_date='2026-05-01')
|
||||
unpaid = dict(base, id='inv-unpaid', invoice_number='NEX-UNPAID',
|
||||
status='open', amount_paid=0.0, invoice_date='2026-04-01')
|
||||
self.W._ingest_invoices([paid, unpaid], post=False)
|
||||
summary = self.W._post_and_reconcile_paid([paid, unpaid])
|
||||
pm = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-paid')])
|
||||
um = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-unpaid')])
|
||||
self.assertEqual(pm.state, 'posted')
|
||||
self.assertIn(pm.payment_state, ('paid', 'in_payment'))
|
||||
self.assertEqual(str(pm.invoice_date), '2026-05-01') # original invoice date kept
|
||||
self.assertEqual(um.state, 'draft') # unpaid stays draft
|
||||
self.assertEqual(summary['posted'], 1)
|
||||
self.assertEqual(summary['skipped_unpaid'], 1)
|
||||
|
||||
def test_partner_named_by_company_not_person(self):
|
||||
data = _inv_fixture()
|
||||
data[0]['partner_company'] = 'Acme Holdings Inc' # full_name is "Acme"; company wins
|
||||
self.W._ingest_invoices(data, post=False)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.partner_id.name, 'Acme Holdings Inc')
|
||||
self.assertTrue(mv.partner_id.is_company)
|
||||
|
||||
def test_prune_shadow_removes_shadow_subs_only(self):
|
||||
p = self.env['res.partner'].sudo().create({'name': 'X'})
|
||||
shadow = self.env['sale.order'].sudo().create({'partner_id': p.id, 'x_fc_shadow': True})
|
||||
counts = self.W._fc_prune_metered_shadow()
|
||||
self.assertFalse(shadow.exists())
|
||||
self.assertGreaterEqual(counts.get('subscriptions', 0), 1)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLedgerVerifiedSync(TransactionCase):
|
||||
"""The go-forward path: invoice date + paid status come from the SOURCE billing
|
||||
system (Stripe/Lago), never NexaCloud's own fields. HTTP is never hit in tests —
|
||||
routing short-circuits when no API credentials are configured, and the cron is
|
||||
exercised with _read_nexacloud_invoices / _fc_verify patched out."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.W = self.env['fusion.billing.invoice.ledger.wizard'].sudo()
|
||||
self.Move = self.env['account.move']
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
# ensure no real credentials -> verify helpers short-circuit, never touch network
|
||||
ICP.set_param('fusion_billing.stripe_api_key', '')
|
||||
ICP.set_param('fusion_billing.lago_api_url', '')
|
||||
ICP.set_param('fusion_billing.lago_api_key', '')
|
||||
|
||||
def test_ts_to_date_is_utc_and_none_safe(self):
|
||||
self.assertEqual(self.W._fc_ts_to_date(0), '1970-01-01')
|
||||
self.assertEqual(self.W._fc_ts_to_date(86400), '1970-01-02')
|
||||
self.assertIsNone(self.W._fc_ts_to_date(None))
|
||||
|
||||
def test_verify_routes_and_guards_without_network(self):
|
||||
# Stripe id with no key, Lago id with no config, and an unroutable id all -> None
|
||||
self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': 'in_abc'}))
|
||||
self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': 'lago:xyz'}))
|
||||
self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': 'mystery'}))
|
||||
self.assertIsNone(self.W._fc_verify({'stripe_invoice_id': None}))
|
||||
|
||||
def test_verified_paid_uses_source_date_and_reconciles(self):
|
||||
v = {'inv-1': {'invoice_date': '2026-02-10', 'void': False, 'paid': True,
|
||||
'paid_at': '2026-02-12', 'amount_paid': 113.0}}
|
||||
self.W._ingest_invoices(_inv_fixture(), post=True, verified=v)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertEqual(str(mv.invoice_date), '2026-02-10') # source date, not NexaCloud's
|
||||
self.assertEqual(str(mv.date), str(mv.invoice_date)) # accounting date tracks it
|
||||
self.assertIn(mv.payment_state, ('paid', 'in_payment'))
|
||||
|
||||
def test_verified_unpaid_posts_but_is_not_reconciled(self):
|
||||
v = {'inv-1': {'invoice_date': '2026-04-01', 'void': False, 'paid': False,
|
||||
'paid_at': None, 'amount_paid': 0.0}}
|
||||
self.W._ingest_invoices(_inv_fixture(), post=True, verified=v)
|
||||
mv = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-1')])
|
||||
self.assertEqual(mv.state, 'posted')
|
||||
self.assertEqual(str(mv.invoice_date), '2026-04-01')
|
||||
self.assertEqual(mv.payment_state, 'not_paid')
|
||||
|
||||
def test_cron_skips_void_draft_unverified_posts_only_finalized(self):
|
||||
base = _inv_fixture()[0]
|
||||
fixtures = [
|
||||
dict(base, id='inv-paid', invoice_number='NEX-P', stripe_invoice_id='in_paid'),
|
||||
dict(base, id='inv-void', invoice_number='NEX-V', stripe_invoice_id='in_void'),
|
||||
dict(base, id='inv-draft', invoice_number=None, stripe_invoice_id='in_draft'),
|
||||
dict(base, id='inv-unver', invoice_number='NEX-U', stripe_invoice_id='weird'),
|
||||
]
|
||||
verdicts = {
|
||||
'inv-paid': {'invoice_date': '2026-03-01', 'void': False, 'draft': False,
|
||||
'paid': True, 'paid_at': '2026-03-02', 'amount_paid': 113.0},
|
||||
'inv-void': {'invoice_date': '2026-03-01', 'void': True, 'draft': False,
|
||||
'paid': False, 'paid_at': None, 'amount_paid': 0.0},
|
||||
'inv-draft': {'invoice_date': '2026-03-01', 'void': False, 'draft': True,
|
||||
'paid': False, 'paid_at': None, 'amount_paid': 0.0},
|
||||
}
|
||||
cls = type(self.W)
|
||||
with patch.object(cls, '_read_nexacloud_invoices', return_value=fixtures), \
|
||||
patch.object(cls, '_fc_verify',
|
||||
side_effect=lambda inv: verdicts.get(str(inv.get('id')))):
|
||||
summary = self.W._cron_sync_verified()
|
||||
self.assertEqual(summary['skipped_void'], 1)
|
||||
self.assertEqual(summary['skipped_draft'], 1)
|
||||
self.assertEqual(summary['unverified'], ['inv-unver'])
|
||||
self.assertEqual(summary['posted'], 1)
|
||||
self.assertEqual(summary['reconciled'], 1)
|
||||
paid = self.Move.search([('x_fc_nexacloud_invoice_id', '=', 'inv-paid')])
|
||||
self.assertEqual(paid.state, 'posted')
|
||||
self.assertEqual(str(paid.invoice_date), '2026-03-01')
|
||||
self.assertIn(paid.payment_state, ('paid', 'in_payment'))
|
||||
for skipped in ('inv-void', 'inv-draft', 'inv-unver'):
|
||||
self.assertFalse(self.Move.search([('x_fc_nexacloud_invoice_id', '=', skipped)]))
|
||||
|
||||
def test_cron_leaves_already_posted_untouched(self):
|
||||
# first run posts inv-paid; second run must not re-touch it (idempotent)
|
||||
base = _inv_fixture()[0]
|
||||
fixtures = [dict(base, id='inv-x', invoice_number='NEX-X', stripe_invoice_id='in_x')]
|
||||
verdict = {'invoice_date': '2026-03-01', 'void': False, 'paid': True,
|
||||
'paid_at': '2026-03-02', 'amount_paid': 113.0}
|
||||
cls = type(self.W)
|
||||
with patch.object(cls, '_read_nexacloud_invoices', return_value=fixtures), \
|
||||
patch.object(cls, '_fc_verify', side_effect=lambda inv: verdict):
|
||||
self.W._cron_sync_verified()
|
||||
summary2 = self.W._cron_sync_verified()
|
||||
self.assertEqual(summary2['already_posted'], 1)
|
||||
self.assertEqual(summary2['posted'], 0)
|
||||
self.assertEqual(self.Move.search_count(
|
||||
[('x_fc_nexacloud_invoice_id', '=', 'inv-x')]), 1)
|
||||
44
fusion_centralize_billing/views/invoice_ledger_views.xml
Normal file
44
fusion_centralize_billing/views/invoice_ledger_views.xml
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fc_invoice_ledger_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.billing.invoice.ledger.wizard.form</field>
|
||||
<field name="model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Ingest NexaCloud Invoices">
|
||||
<group>
|
||||
<field name="dry_run"/>
|
||||
<field name="auto_post"/>
|
||||
</group>
|
||||
<group string="Result" invisible="not result_summary">
|
||||
<field name="result_summary" nolabel="1" widget="text"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_run" type="object" string="Run" class="btn-primary"/>
|
||||
<button string="Close" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fc_invoice_ledger_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Ingest NexaCloud Invoices</field>
|
||||
<field name="res_model">fusion.billing.invoice.ledger.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fc_invoice_ledger" name="Ingest NexaCloud Invoices"
|
||||
parent="menu_fusion_billing_root"
|
||||
action="action_fc_invoice_ledger_wizard" sequence="20"
|
||||
groups="base.group_system"/>
|
||||
|
||||
<record id="cron_fc_invoice_ledger" model="ir.cron">
|
||||
<field name="name">Fusion Billing: Ingest NexaCloud invoices (daily)</field>
|
||||
<field name="model_id" ref="model_fusion_billing_invoice_ledger_wizard"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.create({'dry_run': False, 'auto_post': True})._cron_ingest_recent()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">False</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -1 +1,2 @@
|
||||
from . import import_wizard
|
||||
from . import invoice_ledger
|
||||
|
||||
@@ -116,6 +116,7 @@ class FusionBillingImportWizard(models.TransientModel):
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
conn.set_client_encoding('UTF8')
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
data = {}
|
||||
cur.execute(
|
||||
@@ -161,6 +162,7 @@ class FusionBillingImportWizard(models.TransientModel):
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
conn.set_client_encoding('UTF8')
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(
|
||||
"SELECT subscription_id::text AS sub, "
|
||||
|
||||
498
fusion_centralize_billing/wizards/invoice_ledger.py
Normal file
498
fusion_centralize_billing/wizards/invoice_ledger.py
Normal file
@@ -0,0 +1,498 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1
|
||||
"""NexaCloud → Odoo invoice ledger ingester.
|
||||
|
||||
Reads NexaCloud's real (Stripe-billed) invoices and creates native Odoo
|
||||
``account.move`` customer invoices — posted, with the Stripe payments reconciled and
|
||||
HST modelled — so Odoo is the accounting system of record. Revenue is split by service
|
||||
family into distinct income accounts. NexaCloud/Stripe keep doing the billing; Odoo
|
||||
ingests its output. See docs/superpowers/specs/2026-05-27-nexacloud-invoice-ledger-design.md
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionBillingInvoiceLedgerWizard(models.TransientModel):
|
||||
_name = "fusion.billing.invoice.ledger.wizard"
|
||||
_description = "Fusion Billing — NexaCloud Invoice Ledger Ingester"
|
||||
|
||||
dry_run = fields.Boolean(default=True)
|
||||
auto_post = fields.Boolean(
|
||||
default=False, help="Post invoices immediately (else leave draft for review).")
|
||||
result_summary = fields.Text(readonly=True)
|
||||
|
||||
# description keyword -> service family (checked in order; hosting before managed)
|
||||
_FAMILY_KEYWORDS = [
|
||||
("hosting", ["odoo erp hosting", "wordpress website hosting"]),
|
||||
("managed", ["managed"]),
|
||||
("addons", ["daily backup", "whatsapp", "forms builder", "white label"]),
|
||||
]
|
||||
|
||||
def action_run(self):
|
||||
self.ensure_one()
|
||||
data = self._read_nexacloud_invoices()
|
||||
if self.dry_run:
|
||||
class _Rollback(Exception):
|
||||
pass
|
||||
|
||||
res = {}
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
res.update(self._ingest_invoices(data, post=False))
|
||||
raise _Rollback()
|
||||
except _Rollback:
|
||||
pass
|
||||
res["dry_run"] = True
|
||||
else:
|
||||
res = self._ingest_invoices(data, post=self.auto_post)
|
||||
self.result_summary = json.dumps(res, indent=2, default=str)
|
||||
if res.get("failed"):
|
||||
_logger.error("Ledger ingest: %s failed: %s", len(res["failed"]), res["failed"])
|
||||
return {"type": "ir.actions.act_window", "res_model": self._name,
|
||||
"res_id": self.id, "view_mode": "form", "target": "new"}
|
||||
|
||||
# ----- read side (the ONLY code that touches NexaCloud) ------------------
|
||||
def _read_nexacloud_invoices(self, since=None):
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
dsn = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.nexacloud_dsn")
|
||||
if not dsn:
|
||||
raise UserError("NexaCloud DSN not configured (fusion_billing.nexacloud_dsn).")
|
||||
try:
|
||||
conn = psycopg2.connect(dsn)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise UserError("Could not connect to the NexaCloud database: %s" % e)
|
||||
try:
|
||||
conn.set_session(readonly=True)
|
||||
conn.set_client_encoding('UTF8') # invoice descriptions contain non-ASCII (e.g. "×")
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
where = "WHERE i.created_at >= %(since)s" if since else ""
|
||||
cur.execute(
|
||||
"SELECT i.id, i.stripe_invoice_id, i.invoice_number, "
|
||||
"i.user_id AS user_external_id, u.full_name AS partner_name, "
|
||||
"u.company AS partner_company, "
|
||||
"COALESCE(u.billing_email, u.email) AS partner_email, "
|
||||
"i.created_at AS invoice_date, i.currency, i.status, i.subtotal, i.tax, "
|
||||
"i.amount_paid, i.paid_at "
|
||||
"FROM invoices i JOIN users u ON u.id = i.user_id " + where +
|
||||
" ORDER BY i.created_at", {"since": since})
|
||||
invoices = {str(r["id"]): dict(r, items=[]) for r in cur.fetchall()}
|
||||
if invoices:
|
||||
cur.execute(
|
||||
"SELECT ii.invoice_id, ii.description, ii.quantity, ii.unit_price, ii.amount "
|
||||
"FROM invoice_items ii WHERE ii.invoice_id::text = ANY(%(ids)s)",
|
||||
{"ids": list(invoices.keys())})
|
||||
for r in cur.fetchall():
|
||||
inv = invoices.get(str(r["invoice_id"]))
|
||||
if inv:
|
||||
inv["items"].append({
|
||||
"description": r["description"], "quantity": r["quantity"],
|
||||
"unit_price": r["unit_price"], "amount": r["amount"]})
|
||||
out = []
|
||||
for inv in invoices.values():
|
||||
inv["id"] = str(inv["id"])
|
||||
inv["user_external_id"] = str(inv["user_external_id"])
|
||||
out.append(inv)
|
||||
return out
|
||||
except psycopg2.Error as e:
|
||||
raise UserError(
|
||||
"Failed reading NexaCloud invoices — the source schema may have changed. "
|
||||
"Underlying error:\n%s" % e)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ----- ingest side (pure Odoo; unit-tested) ------------------------------
|
||||
@api.model
|
||||
def _ingest_invoices(self, data, post=False, verified=None):
|
||||
"""Upsert one account.move per NexaCloud invoice.
|
||||
|
||||
``verified`` (optional) maps nc_id -> the dict returned by ``_fc_verify``
|
||||
(date + paid status taken from the SOURCE billing system). When present for
|
||||
an invoice, the source invoice_date and paid status win over NexaCloud's own
|
||||
(unreliable) fields. Without it, the raw NexaCloud fields are used (manual
|
||||
backfill / dry-run path)."""
|
||||
verified = verified or {}
|
||||
Move = self.env["account.move"]
|
||||
cad = self.env.ref("base.CAD", raise_if_not_found=False) or self.env.company.currency_id
|
||||
summary = {"created": 0, "updated": 0, "posted": 0, "reconciled": 0,
|
||||
"skipped": [], "failed": [], "by_family": {}}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get("id") or "")
|
||||
v = verified.get(nc_id)
|
||||
inv_date = (v or {}).get("invoice_date") or inv.get("invoice_date")
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
existing = Move.search(
|
||||
[("x_fc_nexacloud_invoice_id", "=", nc_id)], limit=1)
|
||||
if existing and existing.state != "draft":
|
||||
summary["skipped"].append({"id": nc_id, "reason": "already posted"})
|
||||
continue
|
||||
partner = self._fc_partner_for(inv)
|
||||
if existing:
|
||||
existing.invoice_line_ids.unlink() # draft: replace lines
|
||||
if existing.partner_id != partner:
|
||||
existing.partner_id = partner.id
|
||||
if inv_date and str(existing.invoice_date) != str(inv_date):
|
||||
existing.invoice_date = inv_date
|
||||
move = existing
|
||||
else:
|
||||
move = Move.create({
|
||||
"move_type": "out_invoice",
|
||||
"partner_id": partner.id,
|
||||
"invoice_date": inv_date,
|
||||
"ref": inv.get("invoice_number"),
|
||||
"currency_id": cad.id,
|
||||
"x_fc_nexacloud_invoice_id": nc_id,
|
||||
"x_fc_stripe_invoice_id": inv.get("stripe_invoice_id"),
|
||||
})
|
||||
tax = self._fc_tax_for(inv.get("subtotal"), inv.get("tax"))
|
||||
line_vals = []
|
||||
for it in inv.get("items", []):
|
||||
fam = self._fc_family_for(it.get("description"))
|
||||
summary["by_family"][fam] = round(
|
||||
summary["by_family"].get(fam, 0.0) + float(it.get("amount") or 0.0), 2)
|
||||
line_vals.append((0, 0, {
|
||||
"name": it.get("description") or "NexaCloud",
|
||||
"quantity": float(it.get("quantity") or 1.0),
|
||||
"price_unit": float(it.get("unit_price") or it.get("amount") or 0.0),
|
||||
"account_id": self._fc_income_account(fam).id,
|
||||
"tax_ids": [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
|
||||
}))
|
||||
# Many NexaCloud base-plan invoices store the charge in `subtotal` with
|
||||
# NO invoice_items. Add a balancing line for any gap so the Odoo invoice
|
||||
# total matches what Stripe actually billed (captures un-itemized revenue
|
||||
# and absorbs proration credits where items exceed subtotal).
|
||||
items_total = round(sum(float(it.get("amount") or 0.0)
|
||||
for it in inv.get("items", [])), 2)
|
||||
gap = round(float(inv.get("subtotal") or 0.0) - items_total, 2)
|
||||
if abs(gap) > 0.01:
|
||||
summary["by_family"]["base"] = round(
|
||||
summary["by_family"].get("base", 0.0) + gap, 2)
|
||||
line_vals.append((0, 0, {
|
||||
"name": "NexaCloud base/unitemized charge",
|
||||
"quantity": 1.0, "price_unit": gap,
|
||||
"account_id": self._fc_income_account("base").id,
|
||||
"tax_ids": [(6, 0, tax.ids)] if tax else [(5, 0, 0)],
|
||||
}))
|
||||
if not line_vals:
|
||||
# zero-amount invoice (no items, $0 subtotal) — nothing to record;
|
||||
# drop the empty move (whether just-created or a pre-existing draft).
|
||||
move.unlink()
|
||||
summary["skipped"].append({"id": nc_id, "reason": "zero-amount invoice"})
|
||||
continue
|
||||
move.write({"invoice_line_ids": line_vals})
|
||||
summary["updated" if existing else "created"] += 1
|
||||
if post:
|
||||
if v and inv_date:
|
||||
# accounting date = source invoice date (else Odoo stamps today)
|
||||
move.write({"date": inv_date})
|
||||
move.action_post()
|
||||
summary["posted"] += 1
|
||||
if self._fc_reconcile_payment(move, inv, verified=v):
|
||||
summary["reconciled"] += 1
|
||||
except Exception as e: # noqa: BLE001 - per-invoice isolation
|
||||
_logger.exception("Ledger ingest: invoice %s failed", nc_id)
|
||||
summary["failed"].append({"id": nc_id, "error": "%s: %s" % (type(e).__name__, e)})
|
||||
return summary
|
||||
|
||||
@api.model
|
||||
def _post_ingested(self):
|
||||
moves = self.env["account.move"].search([
|
||||
("x_fc_nexacloud_invoice_id", "!=", False),
|
||||
("state", "=", "draft"), ("move_type", "=", "out_invoice")])
|
||||
posted = 0
|
||||
for mv in moves:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
mv.action_post()
|
||||
posted += 1
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.exception("Ledger post: move %s failed", mv.id)
|
||||
return posted
|
||||
|
||||
@api.model
|
||||
def _post_and_reconcile_paid(self, data):
|
||||
"""Post + reconcile ONLY the invoices NexaCloud marks paid, dating the ledger entry
|
||||
to the ORIGINAL invoice date and the payment to the actual paid_at. Leaves unpaid
|
||||
invoices as draft. Per-invoice isolated."""
|
||||
Move = self.env["account.move"]
|
||||
summary = {"posted": 0, "reconciled": 0, "skipped_unpaid": 0,
|
||||
"skipped_missing": 0, "failed": []}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get("id") or "")
|
||||
paid = float(inv.get("amount_paid") or 0.0)
|
||||
if inv.get("status") != "paid" and paid <= 0:
|
||||
summary["skipped_unpaid"] += 1
|
||||
continue
|
||||
mv = Move.search([("x_fc_nexacloud_invoice_id", "=", nc_id),
|
||||
("move_type", "=", "out_invoice")], limit=1)
|
||||
if not mv or not mv.invoice_line_ids:
|
||||
summary["skipped_missing"] += 1
|
||||
continue
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
if mv.state == "draft":
|
||||
inv_date = inv.get("invoice_date")
|
||||
# keep the original invoice + accounting date (not today)
|
||||
mv.write({"invoice_date": inv_date, "date": inv_date})
|
||||
mv.action_post()
|
||||
summary["posted"] += 1
|
||||
if mv.payment_state not in ("paid", "in_payment", "reversed"):
|
||||
if self._fc_reconcile_payment(mv, inv):
|
||||
summary["reconciled"] += 1
|
||||
except Exception as e: # noqa: BLE001 - per-invoice isolation
|
||||
_logger.exception("Post+pay: invoice %s failed", nc_id)
|
||||
summary["failed"].append({"id": nc_id, "error": "%s: %s" % (type(e).__name__, e)})
|
||||
return summary
|
||||
|
||||
def _cron_sync_verified(self):
|
||||
"""Daily go-forward sync (the only safe automatic path).
|
||||
|
||||
Reads NexaCloud invoices, then for each one not already in the ledger verifies
|
||||
it against its SOURCE billing system (Stripe / Lago) and ingests + posts only
|
||||
verified data: the real invoice date, and a reconciled payment ONLY when the
|
||||
source confirms it is paid. Voids are skipped; anything that cannot be verified
|
||||
is logged and left for the next run (never posted on NexaCloud's own unreliable
|
||||
created_at / status / paid_at). Idempotent — already-posted invoices are left
|
||||
untouched."""
|
||||
Move = self.env["account.move"]
|
||||
data = self._read_nexacloud_invoices()
|
||||
to_ingest, verified = [], {}
|
||||
summary = {"verified": 0, "skipped_void": 0, "skipped_draft": 0,
|
||||
"already_posted": 0, "unverified": []}
|
||||
for inv in data:
|
||||
nc_id = str(inv.get("id") or "")
|
||||
existing = Move.search(
|
||||
[("x_fc_nexacloud_invoice_id", "=", nc_id),
|
||||
("move_type", "=", "out_invoice")], limit=1)
|
||||
if existing and existing.state == "posted":
|
||||
summary["already_posted"] += 1
|
||||
continue
|
||||
v = self._fc_verify(inv)
|
||||
if v is None:
|
||||
summary["unverified"].append(nc_id)
|
||||
continue
|
||||
if v.get("void"):
|
||||
summary["skipped_void"] += 1
|
||||
continue
|
||||
if v.get("draft"):
|
||||
# not finalized at the source yet — will be picked up once it finalizes
|
||||
summary["skipped_draft"] += 1
|
||||
continue
|
||||
verified[nc_id] = v
|
||||
to_ingest.append(inv)
|
||||
summary["verified"] += 1
|
||||
res = self._ingest_invoices(to_ingest, post=True, verified=verified)
|
||||
for k in ("created", "updated", "posted", "reconciled", "failed"):
|
||||
summary[k] = res.get(k)
|
||||
if summary["unverified"]:
|
||||
_logger.warning("Ledger sync: %s invoice(s) unverified, will retry next run: %s",
|
||||
len(summary["unverified"]), summary["unverified"])
|
||||
_logger.info("Ledger sync summary: %s", summary)
|
||||
return summary
|
||||
|
||||
# ----- source-of-truth verification (Stripe / Lago) ----------------------
|
||||
@api.model
|
||||
def _fc_ts_to_date(self, ts):
|
||||
"""Unix timestamp (Stripe) -> 'YYYY-MM-DD' (UTC). None/blank-safe (0 = epoch)."""
|
||||
if ts is None or ts == "":
|
||||
return None
|
||||
return datetime.fromtimestamp(int(ts), tz=timezone.utc).date().isoformat()
|
||||
|
||||
@api.model
|
||||
def _fc_verify(self, inv):
|
||||
"""Route an invoice to its source billing system for verification.
|
||||
Returns a dict {invoice_date, void, paid, paid_at, amount_paid} or None if the
|
||||
source can't be determined / reached (caller then leaves it for the next run)."""
|
||||
sid = (inv.get("stripe_invoice_id") or "").strip()
|
||||
if sid.startswith("in_"):
|
||||
return self._fc_verify_stripe(sid)
|
||||
if sid.startswith("lago:"):
|
||||
return self._fc_verify_lago(sid[len("lago:"):])
|
||||
return None
|
||||
|
||||
@api.model
|
||||
def _fc_verify_stripe(self, stripe_invoice_id):
|
||||
key = self.env["ir.config_parameter"].sudo().get_param("fusion_billing.stripe_api_key")
|
||||
if not key:
|
||||
return None
|
||||
import requests
|
||||
try:
|
||||
resp = requests.get(
|
||||
"https://api.stripe.com/v1/invoices/%s" % stripe_invoice_id,
|
||||
auth=(key, ""), timeout=20)
|
||||
except Exception: # noqa: BLE001 - network failure: treat as unverifiable
|
||||
_logger.exception("Stripe verify failed for %s", stripe_invoice_id)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
_logger.warning("Stripe verify %s -> HTTP %s", stripe_invoice_id, resp.status_code)
|
||||
return None
|
||||
d = resp.json()
|
||||
status = d.get("status")
|
||||
paid_ts = (d.get("status_transitions") or {}).get("paid_at")
|
||||
return {
|
||||
"invoice_date": self._fc_ts_to_date(d.get("created")),
|
||||
"void": status == "void",
|
||||
"draft": status == "draft", # not finalized in Stripe -> not a real invoice yet
|
||||
"paid": status == "paid" or float(d.get("amount_paid") or 0) > 0,
|
||||
"paid_at": self._fc_ts_to_date(paid_ts),
|
||||
"amount_paid": float(d.get("amount_paid") or 0) / 100.0,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _fc_verify_lago(self, lago_invoice_id):
|
||||
cp = self.env["ir.config_parameter"].sudo()
|
||||
url = cp.get_param("fusion_billing.lago_api_url")
|
||||
key = cp.get_param("fusion_billing.lago_api_key")
|
||||
if not url or not key:
|
||||
return None
|
||||
import requests
|
||||
try:
|
||||
resp = requests.get(
|
||||
"%s/v1/invoices/%s" % (url.rstrip("/"), lago_invoice_id),
|
||||
headers={"Authorization": "Bearer %s" % key}, timeout=20)
|
||||
except Exception: # noqa: BLE001 - network failure: treat as unverifiable
|
||||
_logger.exception("Lago verify failed for %s", lago_invoice_id)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
_logger.warning("Lago verify %s -> HTTP %s", lago_invoice_id, resp.status_code)
|
||||
return None
|
||||
d = (resp.json() or {}).get("invoice") or {}
|
||||
issuing = d.get("issuing_date") # already 'YYYY-MM-DD'
|
||||
return {
|
||||
"invoice_date": issuing,
|
||||
"void": d.get("status") == "voided",
|
||||
"draft": d.get("status") == "draft", # not finalized in Lago yet
|
||||
"paid": d.get("payment_status") == "succeeded",
|
||||
"paid_at": issuing, # Lago exposes no clean paid-at; issuing date is the proxy
|
||||
"amount_paid": float(d.get("total_paid_amount_cents") or 0) / 100.0,
|
||||
}
|
||||
|
||||
# ----- helpers ------------------------------------------------------------
|
||||
@api.model
|
||||
def _fc_family_for(self, description):
|
||||
d = (description or "").lower()
|
||||
m = re.match(r"remaining time on (.+?)(?: after| from |\s*\()", d)
|
||||
if m:
|
||||
d = m.group(1) # classify proration by the prorated item
|
||||
for fam, kws in self._FAMILY_KEYWORDS:
|
||||
if any(k in d for k in kws):
|
||||
return fam
|
||||
return "other"
|
||||
|
||||
@api.model
|
||||
def _fc_income_account(self, family):
|
||||
Account = self.env["account.account"]
|
||||
# Odoo 19 account codes allow only alphanumerics + dots (no hyphen).
|
||||
code = "NCR." + family.upper()
|
||||
acc = Account.search([("code", "=", code)], limit=1)
|
||||
if not acc:
|
||||
acc = Account.create({
|
||||
"code": code, "name": "NexaCloud %s Revenue" % family.title(),
|
||||
"account_type": "income"})
|
||||
return acc
|
||||
|
||||
@api.model
|
||||
def _fc_tax_for(self, subtotal, tax_amount):
|
||||
"""Map a NexaCloud invoice's (subtotal, tax) to the Odoo sale tax whose computed
|
||||
tax equals it. Picks by effective percent; falls back to a 0% sale tax."""
|
||||
Tax = self.env["account.tax"]
|
||||
sub = float(subtotal or 0.0)
|
||||
amt = float(tax_amount or 0.0)
|
||||
if sub <= 0 or amt <= 0:
|
||||
return Tax.search([("type_tax_use", "=", "sale"), ("amount", "=", 0.0)], limit=1)
|
||||
rate = round(100.0 * amt / sub)
|
||||
tax = Tax.search([("type_tax_use", "=", "sale"), ("amount_type", "=", "percent"),
|
||||
("amount", "=", float(rate))], limit=1)
|
||||
if not tax:
|
||||
tax = Tax.search([("type_tax_use", "=", "sale"), ("name", "ilike", "%s" % rate)], limit=1)
|
||||
return tax
|
||||
|
||||
@api.model
|
||||
def _fc_partner_for(self, inv):
|
||||
"""Resolve the unified partner via the nexacloud account.link (by user id);
|
||||
create partner+link if missing (covers NULL-subscription invoices)."""
|
||||
service = self.env["fusion.billing.service"].search([("code", "=", "nexacloud")], limit=1)
|
||||
if not service:
|
||||
service = self.env["fusion.billing.service"].create(
|
||||
{"name": "NexaCloud", "code": "nexacloud"})
|
||||
company = (inv.get("partner_company") or "").strip()
|
||||
name = company or inv.get("partner_name") or str(inv.get("user_external_id"))
|
||||
link = self.env["fusion.billing.account.link"]._resolve_or_create_partner(
|
||||
service, str(inv.get("user_external_id")), name=name, email=inv.get("partner_email"))
|
||||
partner = link.partner_id
|
||||
# Name the partner for the BUSINESS (company), not the NexaCloud user's full_name —
|
||||
# one person (e.g. "Gurpreet Singh") can manage several distinct customer businesses.
|
||||
# Rewrite an existing partner so earlier full_name-based names get corrected.
|
||||
if company and (partner.name != company or not partner.is_company):
|
||||
partner.write({"name": company, "is_company": True})
|
||||
return partner
|
||||
|
||||
@api.model
|
||||
def _fc_stripe_journal(self):
|
||||
Journal = self.env["account.journal"]
|
||||
j = Journal.search([("code", "=", "NCSTR")], limit=1)
|
||||
if not j:
|
||||
j = Journal.create({"name": "NexaCloud Stripe", "code": "NCSTR", "type": "bank"})
|
||||
return j
|
||||
|
||||
@api.model
|
||||
def _fc_reconcile_payment(self, move, inv, verified=None):
|
||||
"""Register + reconcile a Stripe payment against a posted invoice.
|
||||
|
||||
When ``verified`` is given, paid status / amount / date come from the SOURCE
|
||||
system (Stripe/Lago); a payment is created ONLY if the source confirms paid.
|
||||
Without it, NexaCloud's own (unreliable) fields are used (manual/backfill path)."""
|
||||
if move.state != "posted":
|
||||
return False
|
||||
if verified is not None:
|
||||
if not verified.get("paid"):
|
||||
return False
|
||||
amount = verified.get("amount_paid") or move.amount_total
|
||||
payment_date = verified.get("paid_at") or move.invoice_date or fields.Date.today()
|
||||
else:
|
||||
paid = float(inv.get("amount_paid") or 0.0)
|
||||
if inv.get("status") != "paid" and paid <= 0:
|
||||
return False
|
||||
amount = paid or move.amount_total
|
||||
payment_date = inv.get("paid_at") or move.invoice_date or fields.Date.today()
|
||||
reg = self.env["account.payment.register"].with_context(
|
||||
active_model="account.move", active_ids=move.ids).create({
|
||||
"journal_id": self._fc_stripe_journal().id,
|
||||
"payment_date": payment_date,
|
||||
"amount": amount,
|
||||
})
|
||||
reg._create_payments()
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _fc_prune_metered_shadow(self):
|
||||
"""Delete the superseded metered shadow data (shadow sale.orders, NC-* products,
|
||||
NexaCloud charges, reconciliation rows)."""
|
||||
counts = {}
|
||||
subs = self.env["sale.order"].search([("x_fc_shadow", "=", True)])
|
||||
counts["subscriptions"] = len(subs)
|
||||
subs.unlink()
|
||||
ch = self.env["fusion.billing.charge"].search([]) # before products (charge -> product)
|
||||
counts["charges"] = len(ch)
|
||||
ch.unlink()
|
||||
rec = self.env["fusion.billing.reconciliation"].search([])
|
||||
counts["reconciliations"] = len(rec)
|
||||
rec.unlink()
|
||||
prods = self.env["product.product"].search([("default_code", "=like", "NC-%")])
|
||||
counts["products"] = len(prods)
|
||||
try:
|
||||
prods.unlink()
|
||||
except Exception: # noqa: BLE001 - undeletable (referenced) products: archive instead
|
||||
prods.write({"active": False})
|
||||
counts["products_archived"] = len(prods)
|
||||
return counts
|
||||
@@ -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.
|
||||
|
||||
### ⚠ 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.
|
||||
- `fusion.page11.sign.request` renders PDFs using `fusion.pdf.template` records — that **model lives in fusion_authorizer_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.
|
||||
- `page11_sign_request._generate_signed_pdf` references `fusion.assessment` records — that model also lives in fusion_authorizer_portal.
|
||||
- `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_portal**, not here.
|
||||
- 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_portal.
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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()`. |
|
||||
| `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.
|
||||
- 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 `_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
|
||||
|
||||
@@ -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_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 |
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -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'`.
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -2843,7 +2843,7 @@ JSON-RPC methods (called from portal JS):
|
||||
`_get_partner_address_display()` — formatted address string.
|
||||
`_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:
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -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 constraint method with regex + rule
|
||||
- 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 ICP setting (~60+)
|
||||
- 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
|
||||
- Add new gotchas in the right format
|
||||
- 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
|
||||
|
||||
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([
|
||||
('category', '=', 'odsp'), ('state', '=', 'active'),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 1. What This Module Is
|
||||
|
||||
- **Name**: Fusion Clock.
|
||||
- **Version**: `19.0.3.3.0`.
|
||||
- **Version**: `19.0.4.1.0`.
|
||||
- **Category**: Human Resources/Attendances.
|
||||
- **License**: OPL-1, Nexa Systems Inc.
|
||||
- **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.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.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. |
|
||||
|
||||
Inherited models:
|
||||
@@ -101,7 +102,7 @@ Clock-out flow:
|
||||
1. Verify location again.
|
||||
2. Call `_attendance_action_change()`.
|
||||
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.
|
||||
6. Log `clock_out`.
|
||||
7. Log overtime if computed overtime is positive.
|
||||
@@ -110,16 +111,23 @@ Location verification uses GPS when coordinates are available and geocoded locat
|
||||
|
||||
## 6. Kiosk And NFC
|
||||
|
||||
Classic kiosk:
|
||||
PIN kiosk (opt-in alternative to NFC; v19.0.4.0.0+):
|
||||
|
||||
- Page: `/fusion_clock/kiosk`
|
||||
- Page: `/fusion_clock/kiosk` — polished photo-tile → PIN flow (logo, brand-hue
|
||||
gradient, live clock), matching the NFC kiosk style; built as an Odoo 19
|
||||
Interaction (`#pin_kiosk_root`, `static/src/js/fusion_clock_kiosk.js`,
|
||||
`static/src/scss/pin_kiosk.scss`, brand-hue var `--pk-h`).
|
||||
- JSON routes:
|
||||
- `/fusion_clock/kiosk/search`
|
||||
- `/fusion_clock/kiosk/verify_pin`
|
||||
- `/fusion_clock/kiosk/clock`
|
||||
- Requires `fusion_clock.group_fusion_clock_manager`.
|
||||
- Controlled by `fusion_clock.enable_kiosk` and `fusion_clock.kiosk_pin_required`.
|
||||
- Uses `hr.employee.x_fclk_kiosk_pin`.
|
||||
- `/fusion_clock/kiosk/search` (grid rows: +`avatar_url`, +`has_pin`; also used by the NFC kiosk's employee_search — keep additive)
|
||||
- `/fusion_clock/kiosk/verify_pin` (returns `needs_setup` when the employee has no PIN)
|
||||
- `/fusion_clock/kiosk/set_pin` (first-use PIN creation, 4–6 digits)
|
||||
- `/fusion_clock/kiosk/clock` (uses the company kiosk location, no GPS geofence; optional master-gated selfie)
|
||||
- Requires `group_fusion_clock_manager` or `group_fusion_clock_kiosk_app`; has its own app icon.
|
||||
- Opt-in via `fusion_clock.enable_kiosk`. PIN is ALWAYS required (the old
|
||||
`kiosk_pin_required` setting was removed). Selfie capture is gated by the
|
||||
master `fusion_clock.enable_photo_verification`. Kiosk location =
|
||||
`res.company.x_fclk_nfc_kiosk_location_id` (shared with the NFC kiosk).
|
||||
- Uses `hr.employee.x_fclk_kiosk_pin` (manager-editable; created on first tap otherwise).
|
||||
|
||||
NFC kiosk:
|
||||
|
||||
@@ -245,16 +253,13 @@ fusion_clock.default_clock_in_time
|
||||
fusion_clock.default_clock_out_time
|
||||
fusion_clock.default_break_minutes
|
||||
fusion_clock.auto_deduct_break
|
||||
fusion_clock.break_threshold_hours
|
||||
fusion_clock.enable_auto_clockout
|
||||
fusion_clock.grace_period_minutes
|
||||
fusion_clock.max_shift_hours
|
||||
fusion_clock.enable_penalties
|
||||
fusion_clock.penalty_grace_minutes
|
||||
fusion_clock.penalty_deduction_minutes
|
||||
fusion_clock.enable_overtime
|
||||
fusion_clock.daily_overtime_threshold
|
||||
fusion_clock.weekly_overtime_threshold
|
||||
fusion_clock.office_user_id
|
||||
fusion_clock.very_late_threshold_minutes
|
||||
fusion_clock.max_monthly_absences
|
||||
@@ -266,7 +271,6 @@ fusion_clock.enable_ip_fallback
|
||||
fusion_clock.enable_photo_verification
|
||||
fusion_clock.google_maps_api_key
|
||||
fusion_clock.enable_kiosk
|
||||
fusion_clock.kiosk_pin_required
|
||||
fusion_clock.enable_correction_requests
|
||||
fusion_clock.enable_sounds
|
||||
fusion_clock.pay_period_type
|
||||
@@ -323,10 +327,12 @@ 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.
|
||||
- `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.
|
||||
- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes.
|
||||
- Daily overtime currently compares net hours to employee scheduled hours or daily threshold; weekly threshold is configured but not used in `hr.attendance._compute_overtime_hours()`.
|
||||
- `fusion_clock.enable_ip_fallback` exists in settings, but server-side `_verify_location()` attempts IP whitelist matching whenever a client IP is present.
|
||||
- **`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. **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.)
|
||||
- `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=`.
|
||||
- NFC kiosk needs a company-level `x_fclk_nfc_kiosk_location_id`; without it taps return `no_location_configured`.
|
||||
- Kiosk routes are authenticated (`auth='user'`) and manager-gated; wall tablets need a manager-authorised kiosk user.
|
||||
- Portal report download manually streams the PDF binary rather than using `fusion_pdf_preview`.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Clock',
|
||||
'version': '19.0.3.5.6',
|
||||
'version': '19.0.4.1.0',
|
||||
'category': 'Human Resources/Attendances',
|
||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||
'description': """
|
||||
@@ -52,6 +52,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'security/ir.model.access.csv',
|
||||
# Data
|
||||
'data/ir_config_parameter_data.xml',
|
||||
'data/clock_break_rule_data.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
# Reports (must load before mail templates that reference them)
|
||||
'report/clock_report_template.xml',
|
||||
@@ -71,13 +72,16 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'views/clock_dashboard_views.xml',
|
||||
'views/hr_employee_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)
|
||||
'wizard/clock_nfc_enrollment_views.xml',
|
||||
'wizard/clock_period_picker_views.xml',
|
||||
'views/clock_menus.xml',
|
||||
# Views - Portal
|
||||
'views/portal_clock_templates.xml',
|
||||
'views/portal_timesheet_templates.xml',
|
||||
'views/portal_report_templates.xml',
|
||||
'views/portal_payslip_templates.xml',
|
||||
'views/kiosk_templates.xml',
|
||||
'views/kiosk_nfc_templates.xml',
|
||||
],
|
||||
@@ -85,6 +89,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'web.assets_frontend': [
|
||||
'fusion_clock/static/src/css/portal_clock.css',
|
||||
'fusion_clock/static/src/scss/nfc_kiosk.scss',
|
||||
'fusion_clock/static/src/scss/pin_kiosk.scss',
|
||||
'fusion_clock/static/src/js/fusion_clock_portal.js',
|
||||
'fusion_clock/static/src/js/fusion_clock_kiosk.js',
|
||||
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,6 @@
|
||||
import base64
|
||||
import math
|
||||
import logging
|
||||
import pytz
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
@@ -74,9 +73,11 @@ class FusionClockAPI(http.Controller):
|
||||
if dist < nearest_distance:
|
||||
nearest_distance = dist
|
||||
|
||||
# IP fallback -- try when GPS is unavailable OR GPS is outside all geofences
|
||||
# IP fallback -- only when enabled (default on); try when GPS is
|
||||
# unavailable OR GPS is outside all geofences.
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if client_ip:
|
||||
ip_fallback_enabled = ICP.get_param('fusion_clock.enable_ip_fallback', 'True') == 'True'
|
||||
if client_ip and ip_fallback_enabled:
|
||||
for loc in locations:
|
||||
if loc.check_ip_whitelist(client_ip):
|
||||
return loc, 0, None, 'ip'
|
||||
@@ -110,7 +111,8 @@ class FusionClockAPI(http.Controller):
|
||||
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
|
||||
return
|
||||
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
|
||||
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
|
||||
if not day_plan.get('scheduled'):
|
||||
# No late/early penalties on days the employee isn't scheduled to work.
|
||||
return
|
||||
|
||||
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
|
||||
@@ -134,12 +136,6 @@ class FusionClockAPI(http.Controller):
|
||||
'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_type = 'late_clock_in' if penalty_type == 'late_in' else 'early_clock_out'
|
||||
request.env['fusion.clock.activity.log'].sudo().create({
|
||||
@@ -155,32 +151,6 @@ class FusionClockAPI(http.Controller):
|
||||
if penalty_type == 'late_in':
|
||||
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,
|
||||
location=None, latitude=0, longitude=0, distance=0, source='portal'):
|
||||
"""Create an activity log entry."""
|
||||
@@ -282,7 +252,8 @@ class FusionClockAPI(http.Controller):
|
||||
now = fields.Datetime.now()
|
||||
today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(today)
|
||||
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
||||
# "Unscheduled" = a posted OFF day OR a day with no schedule at all.
|
||||
is_scheduled_off = not day_plan.get('scheduled')
|
||||
|
||||
geo_info = {
|
||||
'latitude': latitude,
|
||||
@@ -304,8 +275,11 @@ class FusionClockAPI(http.Controller):
|
||||
'x_fclk_clock_source': source,
|
||||
}
|
||||
|
||||
# Photo verification
|
||||
if photo and location.require_photo:
|
||||
# Photo verification — only when the global toggle is on (master);
|
||||
# per-location require_photo refines it from there.
|
||||
enable_photo = request.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_clock.enable_photo_verification', 'False') == 'True'
|
||||
if photo and enable_photo and location.require_photo:
|
||||
try:
|
||||
write_vals['x_fclk_checkin_photo'] = photo
|
||||
except Exception:
|
||||
@@ -325,7 +299,7 @@ class FusionClockAPI(http.Controller):
|
||||
if is_scheduled_off:
|
||||
self._log_activity(
|
||||
employee, 'unscheduled_shift',
|
||||
f"Clocked in on a scheduled OFF day at {location.name}.",
|
||||
f"Clocked in on an unscheduled day at {location.name}.",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
source=source,
|
||||
@@ -335,7 +309,7 @@ class FusionClockAPI(http.Controller):
|
||||
request.env['hr.attendance'].sudo()._fclk_notify_office(
|
||||
office_user_id,
|
||||
f"Unscheduled Shift: {employee.name}",
|
||||
f"{employee.name} clocked in on a scheduled OFF day.",
|
||||
f"{employee.name} clocked in on an unscheduled day.",
|
||||
'hr.attendance',
|
||||
attendance.id,
|
||||
)
|
||||
@@ -398,9 +372,6 @@ class FusionClockAPI(http.Controller):
|
||||
'x_fclk_out_distance': round(distance, 1),
|
||||
})
|
||||
|
||||
# Apply break deduction
|
||||
self._apply_break_deduction(attendance, employee)
|
||||
|
||||
# Check for early clock-out penalty
|
||||
if not is_scheduled_off:
|
||||
_, scheduled_out = self._get_scheduled_times(employee, today)
|
||||
@@ -480,35 +451,47 @@ class FusionClockAPI(http.Controller):
|
||||
return {'success': True, 'message': 'Reason submitted. You may now clock in.'}
|
||||
|
||||
@http.route('/fusion_clock/request_leave', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def request_leave(self, leave_date='', reason='', **kw):
|
||||
"""Submit a leave request from the portal."""
|
||||
def request_leave(self, date_from='', date_to='', reason='', leave_date='', **kw):
|
||||
"""Submit a (possibly multi-day) leave request from the portal."""
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee record found for current user.'}
|
||||
|
||||
if not leave_date or not reason:
|
||||
return {'error': 'Please provide both a date and a reason.'}
|
||||
date_from = date_from or leave_date # back-compat with the old single-date payload
|
||||
date_to = date_to or date_from
|
||||
if not date_from or not reason:
|
||||
return {'error': 'Please provide a start date and a reason.'}
|
||||
|
||||
try:
|
||||
date_obj = fields.Date.from_string(leave_date)
|
||||
from_obj = fields.Date.from_string(date_from)
|
||||
to_obj = fields.Date.from_string(date_to)
|
||||
except Exception:
|
||||
return {'error': 'Invalid date format. Use YYYY-MM-DD.'}
|
||||
if to_obj < from_obj:
|
||||
return {'error': 'The end date cannot be before the start date.'}
|
||||
|
||||
# Reject if an existing request overlaps the requested range.
|
||||
existing = request.env['fusion.clock.leave.request'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('leave_date', '=', date_obj),
|
||||
('leave_date', '<=', to_obj),
|
||||
('date_to', '>=', from_obj),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return {'error': 'A leave request already exists for this date.'}
|
||||
return {'error': 'A leave request already overlaps these dates.'}
|
||||
|
||||
request.env['fusion.clock.leave.request'].sudo().create({
|
||||
'employee_id': employee.id,
|
||||
'leave_date': date_obj,
|
||||
'leave_date': from_obj,
|
||||
'date_to': to_obj,
|
||||
'reason': reason,
|
||||
'created_from': 'portal',
|
||||
})
|
||||
|
||||
return {'success': True, 'message': f'Leave request for {leave_date} submitted.'}
|
||||
if from_obj == to_obj:
|
||||
msg = f'Leave request for {date_from} submitted.'
|
||||
else:
|
||||
msg = f'Leave request for {date_from} to {date_to} submitted.'
|
||||
return {'success': True, 'message': msg}
|
||||
|
||||
@http.route('/fusion_clock/request_correction', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def request_correction(self, attendance_id=0, check_in='', check_out='', reason='', **kw):
|
||||
@@ -656,78 +639,216 @@ class FusionClockAPI(http.Controller):
|
||||
'enable_corrections': ICP.get_param('fusion_clock.enable_correction_requests', 'True') == 'True',
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/dashboard_data', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def dashboard_data(self, **kw):
|
||||
"""Return dashboard data for managers."""
|
||||
user = request.env.user
|
||||
is_manager = user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||
is_team_lead = user.has_group('fusion_clock.group_fusion_clock_team_lead')
|
||||
def _dashboard_personal(self, employee):
|
||||
"""Build the always-present personal block. Caller's own employee
|
||||
only — never another employee's data."""
|
||||
env = request.env
|
||||
local_today = get_local_today(env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(local_today)
|
||||
|
||||
if not is_manager and not is_team_lead:
|
||||
return {'error': 'Access denied.'}
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
check_in = False
|
||||
location_name = ''
|
||||
if is_checked_in:
|
||||
att = env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_out', '=', False),
|
||||
], limit=1)
|
||||
if att:
|
||||
check_in = fields.Datetime.to_string(att.check_in)
|
||||
location_name = att.x_fclk_location_id.name or ''
|
||||
|
||||
now = fields.Datetime.now()
|
||||
today = get_local_today(request.env)
|
||||
today_start, _ = get_local_day_boundaries(request.env, today)
|
||||
today_start_utc, today_end_utc = get_local_day_boundaries(env, local_today, employee)
|
||||
today_atts = env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', fields.Datetime.to_string(today_start_utc)),
|
||||
('check_in', '<', fields.Datetime.to_string(today_end_utc)),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
today_hours = round(sum(a.x_fclk_net_hours or 0 for a in today_atts), 2)
|
||||
|
||||
Attendance = request.env['hr.attendance'].sudo()
|
||||
Employee = request.env['hr.employee'].sudo()
|
||||
week_start = local_today - timedelta(days=local_today.weekday())
|
||||
week_start_utc, _ignore = get_local_day_boundaries(env, week_start, employee)
|
||||
week_atts = env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_in', '>=', fields.Datetime.to_string(week_start_utc)),
|
||||
('check_in', '<', fields.Datetime.to_string(today_end_utc)),
|
||||
('check_out', '!=', False),
|
||||
])
|
||||
week_hours = round(sum(a.x_fclk_net_hours or 0 for a in week_atts), 2)
|
||||
|
||||
# Filter employees by access
|
||||
if is_manager:
|
||||
employees = Employee.search([('x_fclk_enable_clock', '=', True)])
|
||||
if not employee.x_fclk_enable_clock:
|
||||
status_note = 'Clock disabled'
|
||||
elif day_plan.get('is_off'):
|
||||
status_note = 'Day off'
|
||||
elif not day_plan.get('scheduled'):
|
||||
status_note = 'Not scheduled today'
|
||||
elif is_checked_in:
|
||||
status_note = 'Clocked in'
|
||||
else:
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee record found.'}
|
||||
employees = Employee.search([
|
||||
('parent_id', '=', employee.id),
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
])
|
||||
status_note = 'Not clocked in'
|
||||
|
||||
emp_ids = employees.ids
|
||||
recent = env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_out', '!=', False),
|
||||
], order='check_in desc', limit=6)
|
||||
recent_activity = [{
|
||||
'check_in': fields.Datetime.to_string(a.check_in),
|
||||
'check_out': fields.Datetime.to_string(a.check_out),
|
||||
'worked_hours': round(a.worked_hours or 0, 2),
|
||||
'overtime_hours': round(a.x_fclk_overtime_hours or 0, 2),
|
||||
'location': a.x_fclk_location_id.name or '',
|
||||
} for a in recent]
|
||||
|
||||
leaves = env['fusion.clock.leave.request'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('leave_date', '>=', local_today),
|
||||
], order='leave_date asc', limit=5)
|
||||
leave_sel = dict(env['fusion.clock.leave.request']._fields['state'].selection)
|
||||
leave_list = [{
|
||||
'label': lv._fclk_date_label(),
|
||||
'state': leave_sel.get(lv.state, lv.state),
|
||||
} for lv in leaves]
|
||||
|
||||
month_start = local_today.replace(day=1)
|
||||
penalties = env['fusion.clock.penalty'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('date', '>=', month_start),
|
||||
], order='date desc', limit=5)
|
||||
pen_sel = dict(env['fusion.clock.penalty']._fields['penalty_type'].selection)
|
||||
penalty_list = [{
|
||||
'type': pen_sel.get(p.penalty_type, p.penalty_type),
|
||||
'date': fields.Date.to_string(p.date),
|
||||
'minutes': round(p.penalty_minutes or 0, 1),
|
||||
} for p in penalties]
|
||||
|
||||
return {
|
||||
'employee_name': employee.name,
|
||||
'enable_clock': employee.x_fclk_enable_clock,
|
||||
'is_checked_in': is_checked_in,
|
||||
'check_in': check_in,
|
||||
'location_name': location_name,
|
||||
'pending_reason': employee.x_fclk_pending_reason,
|
||||
'today_hours': today_hours,
|
||||
'week_hours': week_hours,
|
||||
'overtime_week': round(employee.x_fclk_overtime_this_week or 0, 2),
|
||||
'ontime_streak': employee.x_fclk_ontime_streak,
|
||||
'shift': {
|
||||
'label': day_plan.get('label') or '',
|
||||
'hours': round(day_plan.get('hours') or 0.0, 2),
|
||||
'source': day_plan.get('source') or 'none',
|
||||
'scheduled_off': bool(day_plan.get('is_off')),
|
||||
'scheduled': bool(day_plan.get('scheduled')),
|
||||
'status_note': status_note,
|
||||
},
|
||||
'recent_activity': recent_activity,
|
||||
'leaves': leave_list,
|
||||
'penalties': penalty_list,
|
||||
}
|
||||
|
||||
def _dashboard_team(self, emp_ids, scope):
|
||||
"""Build the team/org block for the given (already role-scoped)
|
||||
employee ids. ``scope`` is 'team' (lead's direct reports) or 'org'."""
|
||||
env = request.env
|
||||
today = get_local_today(env)
|
||||
today_start, _ignore = get_local_day_boundaries(env, today)
|
||||
Attendance = env['hr.attendance'].sudo()
|
||||
|
||||
# Currently clocked in
|
||||
open_atts = Attendance.search([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('check_out', '=', False),
|
||||
])
|
||||
clocked_in = [{
|
||||
'employee': a.employee_id.name,
|
||||
'check_in': fields.Datetime.to_string(a.check_in),
|
||||
'location': a.x_fclk_location_id.name or '',
|
||||
} for a in open_atts]
|
||||
|
||||
# Today stats
|
||||
today_atts = Attendance.search([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('check_in', '>=', today_start),
|
||||
])
|
||||
present_ids = set(a.employee_id.id for a in today_atts)
|
||||
|
||||
ActivityLog = request.env['fusion.clock.activity.log'].sudo()
|
||||
late_count = ActivityLog.search_count([
|
||||
ActivityLog = env['fusion.clock.activity.log'].sudo()
|
||||
late_logs = ActivityLog.search([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('log_type', '=', 'late_clock_in'),
|
||||
('log_date', '>=', today_start),
|
||||
])
|
||||
late_emp_ids = set(late_logs.mapped('employee_id').ids)
|
||||
|
||||
# Pending alerts
|
||||
pending_reasons = Employee.search_count([
|
||||
clocked_in = [{
|
||||
'employee': a.employee_id.name,
|
||||
'check_in': fields.Datetime.to_string(a.check_in),
|
||||
'location': a.x_fclk_location_id.name or '',
|
||||
'late': a.employee_id.id in late_emp_ids,
|
||||
} for a in open_atts]
|
||||
|
||||
today_atts = Attendance.search([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('check_in', '>=', today_start),
|
||||
])
|
||||
present_ids = set(today_atts.mapped('employee_id').ids)
|
||||
|
||||
# employees on an approved leave covering today
|
||||
leave_recs = env['fusion.clock.leave.request'].sudo().search([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('leave_date', '<=', today),
|
||||
])
|
||||
on_leave_ids = set()
|
||||
for lv in leave_recs:
|
||||
end = lv.date_to or lv.leave_date
|
||||
if lv.leave_date and lv.leave_date <= today <= end:
|
||||
on_leave_ids.add(lv.employee_id.id)
|
||||
|
||||
present_count = len(present_ids)
|
||||
on_leave_count = len(on_leave_ids - present_ids)
|
||||
absent_count = max(len(emp_ids) - present_count - on_leave_count, 0)
|
||||
|
||||
pending_reasons = env['hr.employee'].sudo().search_count([
|
||||
('id', 'in', emp_ids),
|
||||
('x_fclk_pending_reason', '=', True),
|
||||
])
|
||||
pending_corrections = request.env['fusion.clock.correction'].sudo().search_count([
|
||||
pending_approvals = env['fusion.clock.correction'].sudo().search_count([
|
||||
('employee_id', 'in', emp_ids),
|
||||
('state', '=', 'pending'),
|
||||
])
|
||||
|
||||
return {
|
||||
'clocked_in': clocked_in,
|
||||
'scope': scope,
|
||||
'total_employees': len(emp_ids),
|
||||
'present_count': len(present_ids),
|
||||
'absent_count': len(emp_ids) - len(present_ids),
|
||||
'late_count': late_count,
|
||||
'present_count': present_count,
|
||||
'on_leave_count': on_leave_count,
|
||||
'absent_count': absent_count,
|
||||
'late_count': len(late_emp_ids),
|
||||
'pending_reasons': pending_reasons,
|
||||
'pending_corrections': pending_corrections,
|
||||
'pending_approvals': pending_approvals,
|
||||
'clocked_in': clocked_in,
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/dashboard_data', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def dashboard_data(self, **kw):
|
||||
"""Layered, role-aware dashboard payload.
|
||||
|
||||
Everyone gets their own ``personal`` block. The ``team`` block is
|
||||
added ONLY for team leads (their direct reports) and managers
|
||||
(org-wide). A regular employee's payload never contains another
|
||||
employee's data.
|
||||
"""
|
||||
user = request.env.user
|
||||
employee = self._get_employee()
|
||||
if not employee:
|
||||
return {'error': 'No employee profile is linked to your account.'}
|
||||
|
||||
is_manager = user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||
is_team_lead = user.has_group('fusion_clock.group_fusion_clock_team_lead')
|
||||
role = 'manager' if is_manager else ('team_lead' if is_team_lead else 'employee')
|
||||
|
||||
result = {
|
||||
'role': role,
|
||||
'personal': self._dashboard_personal(employee),
|
||||
'team': None,
|
||||
}
|
||||
|
||||
Employee = request.env['hr.employee'].sudo()
|
||||
if is_manager:
|
||||
emp_ids = Employee.search([('x_fclk_enable_clock', '=', True)]).ids
|
||||
result['team'] = self._dashboard_team(emp_ids, 'org')
|
||||
elif is_team_lead:
|
||||
emp_ids = Employee.search([
|
||||
('parent_id', '=', employee.id),
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
]).ids
|
||||
result['team'] = self._dashboard_team(emp_ids, 'team')
|
||||
|
||||
return result
|
||||
|
||||
@@ -10,163 +10,161 @@ from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_kiosk_operator(user):
|
||||
"""Kiosk surfaces accept a full Clock Manager OR a dedicated Kiosk Operator."""
|
||||
return (user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||
or user.has_group('fusion_clock.group_fusion_clock_kiosk_app'))
|
||||
|
||||
|
||||
class FusionClockKiosk(http.Controller):
|
||||
"""Kiosk mode controller for shared-device clock-in/out."""
|
||||
"""PIN kiosk — shared-device clock-in/out: tap your photo, enter a PIN."""
|
||||
|
||||
@http.route('/fusion_clock/kiosk', type='http', auth='user', website=True)
|
||||
def kiosk_page(self, **kw):
|
||||
"""Kiosk clock-in/out page for shared tablets."""
|
||||
"""Polished PIN kiosk page for shared tablets."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return request.redirect('/my')
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_kiosk', 'False') != 'True':
|
||||
return request.redirect('/my')
|
||||
|
||||
company = request.env.company.sudo()
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
values = {
|
||||
'pin_required': ICP.get_param('fusion_clock.kiosk_pin_required', 'True') == 'True',
|
||||
'page_name': 'kiosk',
|
||||
'company_name': company.name,
|
||||
'company_logo_url': '/web/image/res.company/%s/logo' % company.id if company.logo else '',
|
||||
'location_name': location.name if location else 'No location configured',
|
||||
'sounds_enabled': ICP.get_param('fusion_clock.enable_sounds', 'True') == 'True',
|
||||
'photo_required': ICP.get_param('fusion_clock.enable_photo_verification', 'False') == 'True',
|
||||
}
|
||||
return request.render('fusion_clock.kiosk_page', values)
|
||||
|
||||
@http.route('/fusion_clock/kiosk/search', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def kiosk_search(self, query='', **kw):
|
||||
"""Search employees for kiosk identification."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
"""Employees for the kiosk grid. Also used by the NFC kiosk's
|
||||
employee_search — keep the return shape additive."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employees = request.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
('name', 'ilike', query),
|
||||
], limit=20)
|
||||
|
||||
return {
|
||||
'employees': [{
|
||||
], limit=200, order='name')
|
||||
rows = []
|
||||
for emp in employees:
|
||||
unique = emp.write_date.strftime('%Y%m%d%H%M%S') if emp.write_date else ''
|
||||
rows.append({
|
||||
'id': emp.id,
|
||||
'name': emp.name,
|
||||
'department': emp.department_id.name or '',
|
||||
'is_checked_in': emp.attendance_state == 'checked_in',
|
||||
} for emp in employees],
|
||||
}
|
||||
'card_uid': emp.x_fclk_nfc_card_uid or '',
|
||||
'has_pin': bool(emp.x_fclk_kiosk_pin),
|
||||
'avatar_url': '/web/image/hr.employee.public/%s/avatar_128?unique=%s' % (emp.id, unique),
|
||||
})
|
||||
return {'employees': rows}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/verify_pin', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def kiosk_verify_pin(self, employee_id=0, pin='', **kw):
|
||||
"""Verify employee PIN for kiosk mode."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
"""Verify a PIN. Employees with no PIN return needs_setup."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
||||
employee = request.env['hr.employee'].sudo().browse(int(employee_id))
|
||||
if not employee.exists():
|
||||
return {'error': 'Employee not found.'}
|
||||
return {'error': 'not_found'}
|
||||
if not employee.x_fclk_kiosk_pin:
|
||||
return {'needs_setup': True, 'employee_name': employee.name}
|
||||
if employee.x_fclk_kiosk_pin != pin:
|
||||
return {'error': 'invalid_pin'}
|
||||
return {'success': True, 'employee_name': employee.name,
|
||||
'is_checked_in': employee.attendance_state == 'checked_in'}
|
||||
|
||||
if employee.x_fclk_kiosk_pin and employee.x_fclk_kiosk_pin != pin:
|
||||
return {'error': 'Invalid PIN.'}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'employee_name': employee.name,
|
||||
'is_checked_in': employee.attendance_state == 'checked_in',
|
||||
}
|
||||
@http.route('/fusion_clock/kiosk/set_pin', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def kiosk_set_pin(self, employee_id=0, pin='', **kw):
|
||||
"""First-use PIN creation. Rejects if the employee already has one."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'Access denied.'}
|
||||
employee = request.env['hr.employee'].sudo().browse(int(employee_id))
|
||||
if not employee.exists() or not employee.x_fclk_enable_clock:
|
||||
return {'error': 'not_found'}
|
||||
if employee.x_fclk_kiosk_pin:
|
||||
return {'error': 'already_set'}
|
||||
pin = (pin or '').strip()
|
||||
if not (pin.isdigit() and 4 <= len(pin) <= 6):
|
||||
return {'error': 'bad_pin'}
|
||||
employee.write({'x_fclk_kiosk_pin': pin})
|
||||
return {'success': True, 'employee_name': employee.name}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/clock', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def kiosk_clock(self, employee_id=0, latitude=0, longitude=0, **kw):
|
||||
"""Perform clock action from kiosk on behalf of an employee."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
def kiosk_clock(self, employee_id=0, photo_b64='', **kw):
|
||||
"""Clock the employee in/out from the shared kiosk. Fixed wall device:
|
||||
uses the company kiosk location, no per-clock GPS geofence."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
employee = request.env['hr.employee'].sudo().browse(employee_id)
|
||||
employee = request.env['hr.employee'].sudo().browse(int(employee_id))
|
||||
if not employee.exists() or not employee.x_fclk_enable_clock:
|
||||
return {'error': 'Employee not found or clock not enabled.'}
|
||||
return {'error': 'not_found'}
|
||||
|
||||
from .clock_api import FusionClockAPI, haversine_distance
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
company = request.env.company.sudo()
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
if not location:
|
||||
return {'error': 'no_location_configured'}
|
||||
|
||||
from .clock_api import FusionClockAPI
|
||||
from .clock_nfc_kiosk import _strip_data_url_prefix
|
||||
api = FusionClockAPI()
|
||||
|
||||
location, distance, err, method = api._verify_location(latitude, longitude, employee)
|
||||
if not location:
|
||||
return {
|
||||
'error': api._location_error_message(err, distance),
|
||||
'allowed': False,
|
||||
}
|
||||
photo_enabled = ICP.get_param('fusion_clock.enable_photo_verification', 'False') == 'True'
|
||||
photo_bytes = _strip_data_url_prefix(photo_b64) if (photo_enabled and photo_b64) else b''
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
now = fields.Datetime.now()
|
||||
today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(today)
|
||||
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
||||
|
||||
geo_info = {
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
'browser': 'kiosk',
|
||||
'ip_address': request.httprequest.remote_addr or '',
|
||||
}
|
||||
is_scheduled_off = not day_plan.get('scheduled')
|
||||
|
||||
geo_info = {'latitude': 0, 'longitude': 0, 'browser': 'kiosk',
|
||||
'ip_address': request.httprequest.remote_addr or ''}
|
||||
try:
|
||||
attendance = employee.sudo()._attendance_action_change(geo_info)
|
||||
|
||||
if not is_checked_in:
|
||||
attendance.sudo().write({
|
||||
'x_fclk_location_id': location.id,
|
||||
'x_fclk_in_distance': round(distance, 1),
|
||||
'x_fclk_in_distance': 0.0,
|
||||
'x_fclk_clock_source': 'kiosk',
|
||||
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
|
||||
})
|
||||
|
||||
api._log_activity(
|
||||
employee, 'clock_in',
|
||||
f"Kiosk clock-in at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
source='kiosk',
|
||||
)
|
||||
|
||||
api._log_activity(employee, 'clock_in', f"Kiosk clock-in at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0, source='kiosk')
|
||||
if is_scheduled_off:
|
||||
api._log_activity(
|
||||
employee, 'unscheduled_shift',
|
||||
f"Kiosk clock-in on a scheduled OFF day at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
source='kiosk',
|
||||
)
|
||||
api._log_activity(employee, 'unscheduled_shift',
|
||||
f"Kiosk clock-in on an unscheduled day at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0, source='kiosk')
|
||||
else:
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_in',
|
||||
'employee_name': employee.name,
|
||||
'message': f'{employee.name} clocked in at {location.name}',
|
||||
}
|
||||
return {'success': True, 'action': 'clock_in', 'employee_name': employee.name,
|
||||
'message': f'{employee.name} clocked in at {location.name}', 'worked_hours': 0.0}
|
||||
else:
|
||||
attendance.sudo().write({
|
||||
'x_fclk_out_distance': round(distance, 1),
|
||||
'x_fclk_out_distance': 0.0,
|
||||
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
||||
})
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
|
||||
if not is_scheduled_off:
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
|
||||
api._log_activity(
|
||||
employee, 'clock_out',
|
||||
f"Kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
|
||||
attendance=attendance, location=location,
|
||||
latitude=latitude, longitude=longitude, distance=distance,
|
||||
source='kiosk',
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_out',
|
||||
'employee_name': employee.name,
|
||||
'message': f'{employee.name} clocked out from {location.name}',
|
||||
'net_hours': round(attendance.x_fclk_net_hours or 0, 2),
|
||||
}
|
||||
|
||||
api._log_activity(employee, 'clock_out',
|
||||
f"Kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0, source='kiosk')
|
||||
return {'success': True, 'action': 'clock_out', 'employee_name': employee.name,
|
||||
'message': f'{employee.name} clocked out from {location.name}',
|
||||
'net_hours': round(attendance.x_fclk_net_hours or 0, 2)}
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock kiosk error: %s", str(e))
|
||||
_logger.error("Fusion Clock PIN kiosk error: %s", str(e))
|
||||
return {'error': str(e)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
@@ -44,6 +45,12 @@ def _strip_data_url_prefix(b64):
|
||||
return b64.encode('ascii', errors='ignore') if isinstance(b64, str) else b64
|
||||
|
||||
|
||||
def _is_kiosk_operator(user):
|
||||
"""Kiosk surfaces accept a full Clock Manager OR a dedicated Kiosk Operator."""
|
||||
return (user.has_group('fusion_clock.group_fusion_clock_manager')
|
||||
or user.has_group('fusion_clock.group_fusion_clock_kiosk_app'))
|
||||
|
||||
|
||||
class FusionClockNfcKiosk(http.Controller):
|
||||
"""NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers."""
|
||||
|
||||
@@ -66,14 +73,14 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
def nfc_kiosk_page(self, **kw):
|
||||
"""Render the NFC kiosk page for a wall-mounted tablet."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return request.redirect('/my')
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
|
||||
return request.redirect('/my')
|
||||
|
||||
company = request.env.company
|
||||
company = request.env.company.sudo()
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
company_logo_url = (
|
||||
'/web/image/res.company/%s/logo' % company.id if company.logo else ''
|
||||
@@ -84,11 +91,49 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
'company_logo_url': company_logo_url,
|
||||
'location_name': location.name if location else 'No location configured',
|
||||
'location_configured': bool(location),
|
||||
'photo_required': ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True',
|
||||
'photo_required': (ICP.get_param('fusion_clock.enable_photo_verification', 'False') == 'True'
|
||||
and ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True'),
|
||||
'debug_enabled': ICP.get_param('fusion_clock.nfc_kiosk_debug', 'False') == 'True',
|
||||
'sounds_enabled': ICP.get_param('fusion_clock.enable_sounds', 'True') == 'True',
|
||||
}
|
||||
return request.render('fusion_clock.nfc_kiosk_page', values)
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/manifest.webmanifest', type='http', auth='public')
|
||||
def nfc_kiosk_manifest(self, **kw):
|
||||
"""Web App Manifest so the NFC kiosk installs as a full-screen home-screen app.
|
||||
|
||||
On a wall tablet, 'Install' (Chrome) / 'Add to Home Screen' (Safari) then
|
||||
launches the kiosk standalone -- no address bar or browser tabs, like Odoo's
|
||||
own PWA. Public so the icon/splash can load without a session.
|
||||
"""
|
||||
company = request.env.company.sudo()
|
||||
# Square icons via Odoo's on-the-fly resizer (placeholder if the company has no logo).
|
||||
icon_192 = '/web/image/res.company/%s/logo/192x192' % company.id
|
||||
icon_512 = '/web/image/res.company/%s/logo/512x512' % company.id
|
||||
manifest = {
|
||||
'name': 'Fusion Clock Kiosk',
|
||||
'short_name': 'Clock Kiosk',
|
||||
'description': 'Tap-to-clock NFC kiosk',
|
||||
'start_url': '/fusion_clock/kiosk/nfc',
|
||||
'scope': '/',
|
||||
'display': 'fullscreen',
|
||||
'display_override': ['fullscreen', 'standalone'],
|
||||
'background_color': '#0e1116',
|
||||
'theme_color': '#0e1116',
|
||||
'orientation': 'any',
|
||||
'icons': [
|
||||
{'src': icon_192, 'sizes': '192x192', 'type': 'image/png'},
|
||||
{'src': icon_512, 'sizes': '512x512', 'type': 'image/png'},
|
||||
],
|
||||
}
|
||||
return request.make_response(
|
||||
json.dumps(manifest),
|
||||
headers=[
|
||||
('Content-Type', 'application/manifest+json; charset=utf-8'),
|
||||
('Cache-Control', 'public, max-age=3600'),
|
||||
],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _check_enroll_password(env, supplied):
|
||||
"""Verify the enroll-mode password. Empty config = always-allow for managers."""
|
||||
@@ -98,10 +143,11 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
return (supplied or '') == configured
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/enroll', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', **kw):
|
||||
"""Bind an NFC card UID to an employee. Manager-gated, password-gated."""
|
||||
def nfc_enroll(self, employee_id=0, card_uid='', enroll_password='', force=False, **kw):
|
||||
"""Bind an NFC card UID to an employee. Manager-gated, password-gated.
|
||||
With force=True, a card already held by another employee is moved (reassigned)."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'access_denied'}
|
||||
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
@@ -121,10 +167,12 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
('id', '!=', target.id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return {
|
||||
'error': 'card_already_assigned',
|
||||
'existing_employee': existing.name,
|
||||
}
|
||||
if not force:
|
||||
return {
|
||||
'error': 'card_already_assigned',
|
||||
'existing_employee': existing.name,
|
||||
}
|
||||
existing.x_fclk_nfc_card_uid = False # reassign: clear the previous holder
|
||||
|
||||
target.x_fclk_nfc_card_uid = normalized
|
||||
|
||||
@@ -138,15 +186,97 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'employee_id': target.id,
|
||||
'employee_name': target.name,
|
||||
'card_uid': normalized,
|
||||
'needs_photo': not target.image_1920,
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/create_employee', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_create_employee(self, name='', enroll_password='', **kw):
|
||||
"""Create a minimal hr.employee from the kiosk; the caller then enrolls the card.
|
||||
|
||||
Manager/Kiosk-Operator gated + enroll-password gated. Creates the employee via
|
||||
sudo with just a name, clock enabled, and the current company — HR fills in the
|
||||
rest (department, contract, etc.) later.
|
||||
"""
|
||||
user = request.env.user
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'access_denied'}
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
clean = (name or '').strip()
|
||||
if len(clean) < 2:
|
||||
return {'error': 'invalid_name'}
|
||||
employee = request.env['hr.employee'].sudo().create({
|
||||
'name': clean,
|
||||
'x_fclk_enable_clock': True,
|
||||
'company_id': request.env.company.id,
|
||||
})
|
||||
return {'employee_id': employee.id, 'employee_name': employee.name}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/clear_tag', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_clear_tag(self, employee_id=0, enroll_password='', **kw):
|
||||
"""Unbind the NFC card from an employee. Manager/operator + password gated."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'access_denied'}
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0))
|
||||
if not emp.exists():
|
||||
return {'error': 'employee_not_found'}
|
||||
emp.x_fclk_nfc_card_uid = False
|
||||
return {'success': True, 'employee_name': emp.name}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/delete_employee', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_delete_employee(self, employee_id=0, enroll_password='', **kw):
|
||||
"""Archive an employee (active=False) and clear their tag — a safe 'delete' that
|
||||
preserves attendance history. Manager/operator + password gated."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'access_denied'}
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0))
|
||||
if not emp.exists():
|
||||
return {'error': 'employee_not_found'}
|
||||
name = emp.name
|
||||
emp.x_fclk_nfc_card_uid = False
|
||||
emp.active = False
|
||||
return {'success': True, 'employee_name': name}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/save_profile_photo', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_save_profile_photo(self, employee_id=0, photo_b64='', **kw):
|
||||
"""Save a captured photo to the employee's profile image. Operator-gated (the
|
||||
trusted kiosk device); no separate PIN, so it also works on self clock-in."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'access_denied'}
|
||||
photo = _strip_data_url_prefix(photo_b64)
|
||||
if not photo:
|
||||
return {'error': 'no_photo'}
|
||||
emp = request.env['hr.employee'].sudo().browse(int(employee_id or 0))
|
||||
if not emp.exists():
|
||||
return {'error': 'employee_not_found'}
|
||||
emp.image_1920 = photo
|
||||
# Also push to the linked user's partner image, which is the image Odoo
|
||||
# shows on the user's profile/preferences avatar (res.users delegates
|
||||
# image_1920 to res.partner). Employees with no user are HR-only photos.
|
||||
if emp.user_id and emp.user_id.partner_id:
|
||||
emp.user_id.partner_id.sudo().write({'image_1920': photo})
|
||||
return {'success': True}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/verify_pin', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_verify_pin(self, pin='', **kw):
|
||||
"""Verify the Manager PIN (enroll password) — used to unlock the kiosk screen.
|
||||
Returns only a boolean so the PIN itself never reaches the client."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'ok': False}
|
||||
return {'ok': self._check_enroll_password(request.env, pin)}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/tap', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_tap(self, card_uid='', photo_b64='', **kw):
|
||||
"""Toggle attendance state for the employee owning this card UID."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'access_denied'}
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
@@ -160,12 +290,14 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
if _is_debounced(normalized):
|
||||
return {'error': 'debounce'}
|
||||
|
||||
photo_required = ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True'
|
||||
# Master switch: no photo capture/storage when global Photo Verification is off.
|
||||
photo_enabled = ICP.get_param('fusion_clock.enable_photo_verification', 'False') == 'True'
|
||||
photo_required = photo_enabled and ICP.get_param('fusion_clock.nfc_photo_required', 'True') == 'True'
|
||||
if photo_required and not photo_b64:
|
||||
return {'error': 'photo_required', 'message': 'Camera unavailable. Ask IT to check the kiosk.'}
|
||||
photo_bytes = _strip_data_url_prefix(photo_b64) if photo_b64 else b''
|
||||
photo_bytes = _strip_data_url_prefix(photo_b64) if (photo_enabled and photo_b64) else b''
|
||||
|
||||
company = request.env.company
|
||||
company = request.env.company.sudo()
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
if not location:
|
||||
return {'error': 'no_location_configured'}
|
||||
@@ -183,10 +315,19 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
api = FusionClockAPI()
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
# Cache-buster: /web/image is browser-cached, so without a unique token a
|
||||
# freshly-saved profile photo never shows. write_date bumps on every
|
||||
# write (incl. saving image_1920), so it refreshes exactly when needed.
|
||||
avatar_unique = employee.write_date.strftime('%Y%m%d%H%M%S') if employee.write_date else ''
|
||||
# PUBLIC model: the kiosk runs as a non-HR operator who can't read
|
||||
# hr.employee images (ACL) — /web/image would serve a placeholder.
|
||||
# hr.employee.public exposes the same avatar to any internal user
|
||||
# (verified readable as the kiosk operator, uid 141).
|
||||
avatar_url = f'/web/image/hr.employee.public/{employee.id}/avatar_128?unique={avatar_unique}'
|
||||
now = fields.Datetime.now()
|
||||
today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(today)
|
||||
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
|
||||
is_scheduled_off = not day_plan.get('scheduled')
|
||||
|
||||
geo_info = {
|
||||
'latitude': 0,
|
||||
@@ -214,7 +355,7 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
if is_scheduled_off:
|
||||
api._log_activity(
|
||||
employee, 'unscheduled_shift',
|
||||
f"NFC kiosk clock-in on a scheduled OFF day at {location.name}",
|
||||
f"NFC kiosk clock-in on an unscheduled day at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0,
|
||||
source='nfc_kiosk',
|
||||
@@ -225,17 +366,18 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_in',
|
||||
'employee_id': employee.id,
|
||||
'employee_name': employee.name,
|
||||
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
||||
'employee_avatar_url': avatar_url,
|
||||
'message': f'{employee.name} clocked in at {location.name}',
|
||||
'net_hours_today': 0.0,
|
||||
'worked_hours': 0.0,
|
||||
'needs_photo': not employee.image_1920,
|
||||
}
|
||||
else:
|
||||
attendance.sudo().write({
|
||||
'x_fclk_out_distance': 0.0,
|
||||
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
||||
})
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
if not is_scheduled_off:
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
@@ -249,10 +391,15 @@ class FusionClockNfcKiosk(http.Controller):
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_out',
|
||||
'employee_id': employee.id,
|
||||
'employee_name': employee.name,
|
||||
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
||||
'employee_avatar_url': avatar_url,
|
||||
'message': f'{employee.name} clocked out',
|
||||
'net_hours_today': round(attendance.x_fclk_net_hours or 0, 2),
|
||||
# GROSS time between clock-in and clock-out (what the employee
|
||||
# expects to see). x_fclk_net_hours subtracts break + early-out
|
||||
# penalty minutes, which zeroed short shifts — that's for payroll.
|
||||
'worked_hours': attendance.worked_hours or 0.0,
|
||||
'needs_photo': not employee.image_1920,
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/employee_search', type='jsonrpc', auth='user', methods=['POST'])
|
||||
|
||||
@@ -65,6 +65,20 @@ class FusionClockPortal(CustomerPortal):
|
||||
], limit=1)
|
||||
return employee
|
||||
|
||||
def _payroll_available(self):
|
||||
"""True when fusion_payroll (hr.payslip) is installed on this DB."""
|
||||
return 'hr.payslip' in request.env
|
||||
|
||||
def _get_my_payslips(self, employee):
|
||||
"""Finalized payslips for this employee, newest first.
|
||||
|
||||
Caller must ensure payroll is installed (see _payroll_available).
|
||||
"""
|
||||
return request.env['hr.payslip'].sudo().search(
|
||||
[('employee_id', '=', employee.id), ('state', 'in', ('done', 'paid'))],
|
||||
order='date_to desc, id desc',
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Clock Page
|
||||
# =========================================================================
|
||||
@@ -157,6 +171,7 @@ class FusionClockPortal(CustomerPortal):
|
||||
'google_maps_key': google_maps_key,
|
||||
'enable_sounds': enable_sounds,
|
||||
'locations_json': locations_json,
|
||||
'show_payslips': self._payroll_available(),
|
||||
'page_name': 'clock',
|
||||
}
|
||||
return request.render('fusion_clock.portal_clock_page', values)
|
||||
@@ -234,6 +249,7 @@ class FusionClockPortal(CustomerPortal):
|
||||
'total_hours': round(total_hours, 1),
|
||||
'net_hours': round(net_hours, 1),
|
||||
'total_breaks': round(total_breaks, 0),
|
||||
'show_payslips': self._payroll_available(),
|
||||
'page_name': 'timesheets',
|
||||
}
|
||||
return request.render('fusion_clock.portal_timesheet_page', values)
|
||||
@@ -257,6 +273,7 @@ class FusionClockPortal(CustomerPortal):
|
||||
values = {
|
||||
'employee': employee,
|
||||
'reports': reports,
|
||||
'show_payslips': self._payroll_available(),
|
||||
'page_name': 'clock_reports',
|
||||
}
|
||||
return request.render('fusion_clock.portal_report_page', values)
|
||||
@@ -285,3 +302,64 @@ class FusionClockPortal(CustomerPortal):
|
||||
('Content-Disposition', f'attachment; filename="{filename}"'),
|
||||
],
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Payslips
|
||||
# =========================================================================
|
||||
|
||||
@http.route('/my/clock/payslips', type='http', auth='user', website=True)
|
||||
def portal_payslips(self, **kw):
|
||||
"""List the employee's finalized pay slips."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee or not self._payroll_available():
|
||||
return request.redirect('/my/clock')
|
||||
values = {
|
||||
'employee': employee,
|
||||
'payslips': self._get_my_payslips(employee),
|
||||
'show_payslips': True,
|
||||
'page_name': 'payslips',
|
||||
}
|
||||
return request.render('fusion_clock.portal_payslip_list_page', values)
|
||||
|
||||
@http.route('/my/clock/payslips/<int:payslip_id>', type='http', auth='user', website=True)
|
||||
def portal_payslip_detail(self, payslip_id, **kw):
|
||||
"""Inline paystub for one finalized slip the employee owns."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee or not self._payroll_available():
|
||||
return request.redirect('/my/clock')
|
||||
payslip = request.env['hr.payslip'].sudo().browse(payslip_id)
|
||||
if not payslip.exists() or payslip.employee_id.id != employee.id \
|
||||
or payslip.state not in ('done', 'paid'):
|
||||
return request.redirect('/my/clock/payslips')
|
||||
pdf_report = request.env['ir.actions.report'].sudo().search(
|
||||
[('model', '=', 'hr.payslip'), ('report_type', '=', 'qweb-pdf')], limit=1)
|
||||
values = {
|
||||
'employee': employee,
|
||||
'payslip': payslip,
|
||||
'has_pdf': bool(pdf_report),
|
||||
'show_payslips': True,
|
||||
'page_name': 'payslips',
|
||||
}
|
||||
return request.render('fusion_clock.portal_payslip_detail_page', values)
|
||||
|
||||
@http.route('/my/clock/payslips/<int:payslip_id>/pdf', type='http', auth='user', website=True)
|
||||
def portal_payslip_pdf(self, payslip_id, **kw):
|
||||
"""Render the standard payslip PDF (sudo) for a slip the employee owns."""
|
||||
employee = self._get_portal_employee()
|
||||
if not employee or not self._payroll_available():
|
||||
return request.redirect('/my/clock')
|
||||
payslip = request.env['hr.payslip'].sudo().browse(payslip_id)
|
||||
if not payslip.exists() or payslip.employee_id.id != employee.id \
|
||||
or payslip.state not in ('done', 'paid'):
|
||||
return request.redirect('/my/clock/payslips')
|
||||
report = request.env['ir.actions.report'].sudo().search(
|
||||
[('model', '=', 'hr.payslip'), ('report_type', '=', 'qweb-pdf')], limit=1)
|
||||
if not report:
|
||||
return request.redirect('/my/clock/payslips/%s' % payslip_id)
|
||||
pdf_content, _ctype = report._render_qweb_pdf(report.id, [payslip.id])
|
||||
slip_ref = payslip.number if 'number' in payslip._fields else False
|
||||
filename = 'Payslip-%s.pdf' % (slip_ref or payslip.id)
|
||||
return request.make_response(pdf_content, headers=[
|
||||
('Content-Type', 'application/pdf'),
|
||||
('Content-Disposition', 'attachment; filename="%s"' % filename),
|
||||
])
|
||||
|
||||
@@ -155,6 +155,41 @@ class FusionClockShiftPlanner(http.Controller):
|
||||
'data': self._load_week_data(week_start),
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/shift_planner/post_week', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def post_week(self, week_start=None, **kw):
|
||||
"""Publish (post) the viewed week's draft entries so automation acts on
|
||||
them, and email each newly-affected employee their posted shifts."""
|
||||
if not self._check_manager():
|
||||
return {'error': 'Access denied.'}
|
||||
|
||||
start = self._week_start(week_start)
|
||||
end = start + timedelta(days=6)
|
||||
employees = self._manager_employees()
|
||||
Schedule = request.env['fusion.clock.schedule'].sudo()
|
||||
|
||||
entries = Schedule.search([
|
||||
('employee_id', 'in', employees.ids),
|
||||
('schedule_date', '>=', start),
|
||||
('schedule_date', '<=', end),
|
||||
('state', '!=', 'posted'),
|
||||
])
|
||||
posted_count = len(entries)
|
||||
affected = entries.mapped('employee_id')
|
||||
if entries:
|
||||
entries.write({'state': 'posted', 'posted_date': fields.Datetime.now()})
|
||||
|
||||
notified = 0
|
||||
for employee in affected:
|
||||
if Schedule.fclk_email_posted_week(employee, start, end):
|
||||
notified += 1
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'posted': posted_count,
|
||||
'notified': notified,
|
||||
'data': self._load_week_data(start),
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def copy_previous_week(self, week_start=None, **kw):
|
||||
if not self._check_manager():
|
||||
|
||||
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,16 +20,8 @@
|
||||
<field name="key">fusion_clock.auto_deduct_break</field>
|
||||
<field name="value">True</field>
|
||||
</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>
|
||||
|
||||
<!-- Grace Period & Auto Clock-Out -->
|
||||
<record id="config_grace_period_minutes" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.grace_period_minutes</field>
|
||||
<field name="value">15</field>
|
||||
</record>
|
||||
<!-- Auto Clock-Out -->
|
||||
<record id="config_enable_auto_clockout" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_auto_clockout</field>
|
||||
<field name="value">True</field>
|
||||
@@ -92,15 +84,11 @@
|
||||
<field name="key">fusion_clock.daily_overtime_threshold</field>
|
||||
<field name="value">8.0</field>
|
||||
</record>
|
||||
<record id="config_weekly_overtime_threshold" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.weekly_overtime_threshold</field>
|
||||
<field name="value">40.0</field>
|
||||
</record>
|
||||
|
||||
<!-- Location & Verification -->
|
||||
<record id="config_enable_ip_fallback" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_ip_fallback</field>
|
||||
<field name="value">False</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
<record id="config_enable_photo_verification" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_photo_verification</field>
|
||||
@@ -112,10 +100,6 @@
|
||||
<field name="key">fusion_clock.enable_kiosk</field>
|
||||
<field name="value">False</field>
|
||||
</record>
|
||||
<record id="config_kiosk_pin_required" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.kiosk_pin_required</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
<!-- Corrections -->
|
||||
<record id="config_enable_corrections" model="ir.config_parameter">
|
||||
|
||||
@@ -61,4 +61,16 @@
|
||||
<field name="priority">80</field>
|
||||
</record>
|
||||
|
||||
<!-- Photo Wipe Cron: runs daily, deletes clock photos past the retention window -->
|
||||
<record id="cron_wipe_old_photos" model="ir.cron">
|
||||
<field name="name">Fusion Clock: Wipe Old Clock Photos</field>
|
||||
<field name="model_id" ref="hr_attendance.model_hr_attendance"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_fusion_wipe_old_photos()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active">True</field>
|
||||
<field name="priority">65</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# NFC Kiosk — Enrollment UX / PIN fix / Speed / Clock-out Hours — Implementation Plan
|
||||
|
||||
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax. Executed inline this session.
|
||||
|
||||
**Goal:** Make NFC-tag enrollment programmable from an unknown tap (with create-new-employee), fix the per-digit PIN re-render, speed up clock-in/out for lines, and clearly show shift hours on clock-out.
|
||||
|
||||
**Architecture:** Extend the existing IIFE kiosk state machine (`fusion_clock_nfc_kiosk.js`) — no Interaction migration. Add one sudo controller endpoint for kiosk employee-create. SCSS-only changes for animation timing. Spec: `docs/superpowers/specs/2026-05-30-nfc-kiosk-enroll-speed-design.md`.
|
||||
|
||||
**Tech Stack:** Odoo 19 HTTP controller (jsonrpc), vanilla JS IIFE, SCSS. Verify: `pyflakes`, `xmllint`, manifest `ast.literal_eval`, on-device deploy on entech (LXC 111 / pve-worker5).
|
||||
|
||||
**XSS note:** the kiosk uses `innerHTML`; every dynamic value (employee names, the typed new-employee name, errors) MUST go through the existing `escapeHtml()`. The new-employee name is user input — escape it everywhere it renders.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — `nfc_create_employee` endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `controllers/clock_nfc_kiosk.py` (add route after `nfc_enroll`)
|
||||
- Test: `tests/test_clock_nfc_kiosk.py` (add a method)
|
||||
|
||||
- [ ] **Step 1: Add the endpoint.** Manager/Kiosk-Operator gated (`_is_kiosk_operator`) + password gated (`_check_enroll_password`). Create `hr.employee` via sudo with name + `x_fclk_enable_clock=True` + `company_id`. Return `{employee_id, employee_name}` or `{error}`.
|
||||
|
||||
```python
|
||||
@http.route('/fusion_clock/kiosk/nfc/create_employee', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_create_employee(self, name='', enroll_password='', **kw):
|
||||
"""Create a minimal hr.employee from the kiosk (manager+password gated)."""
|
||||
user = request.env.user
|
||||
if not _is_kiosk_operator(user):
|
||||
return {'error': 'access_denied'}
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
clean = (name or '').strip()
|
||||
if len(clean) < 2:
|
||||
return {'error': 'invalid_name'}
|
||||
employee = request.env['hr.employee'].sudo().create({
|
||||
'name': clean,
|
||||
'x_fclk_enable_clock': True,
|
||||
'company_id': request.env.company.id,
|
||||
})
|
||||
return {'employee_id': employee.id, 'employee_name': employee.name}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a unit test** (runs when a test env is available; mirrors existing tests in the file).
|
||||
|
||||
```python
|
||||
def test_nfc_create_employee_creates_clock_enabled(self):
|
||||
Ctrl = self._controller() # follow existing pattern in this file for instantiating
|
||||
# password gate: wrong password rejected
|
||||
bad = Ctrl.nfc_create_employee(name='Test Person', enroll_password='wrong')
|
||||
self.assertEqual(bad.get('error'), 'invalid_password')
|
||||
# happy path (set the configured password in the test env first)
|
||||
self.env['ir.config_parameter'].sudo().set_param('fusion_clock.nfc_enroll_password', '1120')
|
||||
res = Ctrl.nfc_create_employee(name='Test Person', enroll_password='1120')
|
||||
emp = self.env['hr.employee'].browse(res['employee_id'])
|
||||
self.assertTrue(emp.exists())
|
||||
self.assertTrue(emp.x_fclk_enable_clock)
|
||||
```
|
||||
> If the existing test file doesn't instantiate controllers directly, adapt to its harness (or assert via model behaviour). Keep parity with existing tests.
|
||||
|
||||
- [ ] **Step 3: Verify.** `docker exec ... pyflakes controllers/clock_nfc_kiosk.py` (locally: `python3 -m pyflakes`). Expected: clean. Unit test runs in the next test invocation / on a Community dev box.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: JS — reusable fixed PIN-pad component (fixes per-digit re-render)
|
||||
|
||||
**Files:** Modify `static/src/js/fusion_clock_nfc_kiosk.js`
|
||||
|
||||
- [ ] **Step 1:** Add a `mountPinPad({title, onOk, onCancel})` helper that sets `stateContainer.innerHTML` **once** (title, `.pin-display`, numpad, cancel), keeps a local `let pin = ""`, and on digit/back/ok updates **only** `displayEl.textContent = "•".repeat(pin.length)` — never re-renders the panel. `ok` calls `onOk(pin)`; cancel calls `onCancel()`. Resets the enroll idle timer on each press.
|
||||
- [ ] **Step 2:** Rewrite `renderEnroll(phase:"password")` to call `mountPinPad({title:"Enter Manager PIN", onOk:(pin)=>{enrollPassword=pin; renderEnroll({phase:"search"});}, onCancel:exitEnrollMode})`. Remove the old per-digit `renderEnroll(...)` rebuild.
|
||||
- [ ] **Step 3: Verify.** Manual on device: digits append with no flicker/screen refresh; backspace works; OK advances.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: JS+SCSS — program-a-tag from an unknown tap (with create-new-employee)
|
||||
|
||||
**Files:** Modify `fusion_clock_nfc_kiosk.js`, `static/src/scss/nfc_kiosk.scss`
|
||||
|
||||
- [ ] **Step 1:** Add module var `let pendingEnrollUid = null;`. In `handleTap`, when `result.error === "card_unknown"`, call `renderUnknownCard(uid)` instead of the generic error result.
|
||||
- [ ] **Step 2:** `renderUnknownCard(uid)` renders an **amber** panel: "This card isn't programmed yet" + buttons "Program this card" / "Cancel". Auto-cancel to IDLE after 8s. "Program this card" → `pendingEnrollUid = uid; enrollPassword=""; setState(STATE.ENROLL,{phase:"program_pin"})`.
|
||||
- [ ] **Step 3:** Add enroll phases:
|
||||
- `program_pin` → `mountPinPad({title:"Manager PIN", onOk:(pin)=>{enrollPassword=pin; renderEnroll({phase:"employee"});}, onCancel:exitEnrollMode})`.
|
||||
- `employee` → search box (reuse existing `employee_search` debounced fetch) + a **"+ New employee"** button. Picking an existing row → `assignPendingCard(emp)`. "+ New employee" → `renderEnroll({phase:"new_employee"})`.
|
||||
- `new_employee` → a name input + "Create & assign" / back. On submit → POST `create_employee` {name, enroll_password}; on success → `assignPendingCard({id, name})`; on error → inline message (escape).
|
||||
- [ ] **Step 4:** `assignPendingCard(emp)`: POST `nfc/enroll` {employee_id: emp.id, card_uid: pendingEnrollUid, enroll_password}. Render enroll `result` phase (reuse existing). On done/another → reset `pendingEnrollUid`, back to IDLE.
|
||||
- [ ] **Step 5:** SCSS — add `.nfc-kiosk__result--warn` (amber: `#e0a83e`-ish border/glow) and a `.employee-create` styling block (reuse `.nfc-kiosk__enroll-panel` patterns). Escape all dynamic strings.
|
||||
- [ ] **Step 6: Verify.** `xmllint`/sass compile via deploy; device: unknown tap → program existing + new employee, card binds with no re-tap.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Speed — "Fast" timers + animation durations
|
||||
|
||||
**Files:** Modify `fusion_clock_nfc_kiosk.js`, `static/src/scss/nfc_kiosk.scss`
|
||||
|
||||
- [ ] **Step 1 (JS):** In `renderResult`: success `setTimeout(... , 3000)` → `1800`; error `4000` → `3000`.
|
||||
- [ ] **Step 2 (SCSS):** `nfc-state-in` 400ms→200ms (the `#nfc_state_container > *` rule + keyframe usages); `.nfc-kiosk__result--success` `nfc-success-burst` 700ms→350ms; `.nfc-kiosk__avatar` `nfc-avatar-in` 600ms→300ms. Leave idle wave/chip + mesh drift unchanged. Keep `prefers-reduced-motion` block.
|
||||
- [ ] **Step 3: Verify.** Device: noticeably snappier; result clears ~1.8s.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Clock-out shift hours — prominent + correct label
|
||||
|
||||
**Files:** Modify `fusion_clock_nfc_kiosk.js`, `static/src/scss/nfc_kiosk.scss`
|
||||
|
||||
- [ ] **Step 1 (JS):** In `renderResult` success branch, for `action === "clock_out"`: compute `const mins = Math.round((payload.net_hours_today || 0) * 60); const h = Math.floor(mins/60); const m = mins%60;` and always render `<div class="hours">Worked ${h}h ${m}m this shift</div>` (show even at 0). Clock-in: no hours line.
|
||||
- [ ] **Step 2 (SCSS):** Bump `.nfc-kiosk__result-text .hours` prominence (e.g. `font-size: 1.35rem; opacity: 0.9; margin-top: 0.6rem;`).
|
||||
- [ ] **Step 3: Verify.** Device: clock-out shows "Worked Xh Ym this shift".
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Version bump + deploy + verify
|
||||
|
||||
- [ ] **Step 1:** Bump `__manifest__.py` `version` `19.0.3.6.0` → `19.0.3.7.0` (assets changed).
|
||||
- [ ] **Step 2:** Local pre-flight: `pyflakes` controller, `xmllint`? (JS has no linter here — read carefully), manifest `ast.literal_eval`.
|
||||
- [ ] **Step 3:** Deploy to entech (backup → push 4 files → `-u fusion_clock` stop/upgrade/start). Bump asset cache (version bump handles it; `DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%'` + restart if the bundle doesn't refresh).
|
||||
- [ ] **Step 4:** Verify: service active, version 19.0.3.7.0, manifest route 200. On tablet (hard refresh): PIN no flicker; unknown tap → program (existing + new); faster; clock-out hours.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
- **Spec coverage:** PIN fix (T2), unknown-tap+create-new (T1,T3), speed (T4), clock-out hours (T5), deploy (T6). All covered.
|
||||
- **Placeholders:** none (test harness instantiation noted as adapt-to-existing — acceptable, file-specific).
|
||||
- **Consistency:** `pendingEnrollUid`, `enrollPassword`, `mountPinPad`, `assignPendingCard`, `_is_kiosk_operator`, `_check_enroll_password`, `net_hours_today` used consistently with the existing code read.
|
||||
@@ -0,0 +1,642 @@
|
||||
# Bi-Weekly Attendance Filter — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Let operators scope the All Attendances list to a pay period — one-click Current/Previous/Next Pay-Period filters plus a "Bi-Weekly Period" picker — reusing the module's existing Pay Period setting and date math.
|
||||
|
||||
**Architecture:** Extract the existing period math into one pure helper (`models/pay_period.py`) shared by the report, three search-method computed booleans on `hr.attendance` (→ search-view filters), and a transient picker wizard (→ menu item + dashboard tile). The window follows the configured Frequency (bi-weekly by default).
|
||||
|
||||
**Tech Stack:** Odoo 19, Python (pure helper + ORM search methods + TransientModel), QWeb search/wizard views, OWL dashboard tile, `TransactionCase` tests.
|
||||
|
||||
**Reference (read first):** spec `fusion_clock/docs/superpowers/specs/2026-05-31-biweekly-attendance-filter-design.md`; repo-root `CLAUDE.md` + `fusion_clock/CLAUDE.md`.
|
||||
|
||||
**Test command** (substitute `odoo-modsdev-app` if that's your dev container):
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock \
|
||||
-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
|
||||
**Commit discipline (shared working tree):** stage explicit paths, verify `git diff --cached --name-only`, then `git commit --only -- <paths>`. Never `git add -A`. Don't stage `.pyc`/`.DS_Store`. Push to **both** `origin` and `gitea` at the end.
|
||||
|
||||
**File structure:**
|
||||
- `models/pay_period.py` (new) — pure date math: `compute_pay_period`, `period_length_days`, `current_prev_next`.
|
||||
- `models/hr_attendance.py` — 3 computed booleans + search methods (filter backing).
|
||||
- `models/clock_report.py` — `_calculate_current_period` delegates to the helper.
|
||||
- `wizard/clock_period_picker_wizard.py` (new) — transient picker.
|
||||
- `wizard/clock_period_picker_views.xml` (new) — picker form + action.
|
||||
- `views/hr_attendance_views.xml` — 3 filters in the existing search view.
|
||||
- `views/clock_menus.xml` — "Bi-Weekly Period" menu item.
|
||||
- `views/res_config_settings_views.xml` — clarify Anchor Date help.
|
||||
- `static/src/js|xml/fusion_clock_dashboard.*` — dashboard tile.
|
||||
- `tests/test_pay_period.py` (new).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Shared period-math helper + report delegation
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_clock/models/pay_period.py`
|
||||
- Create: `fusion_clock/tests/test_pay_period.py`
|
||||
- Modify: `fusion_clock/models/__init__.py`, `fusion_clock/tests/__init__.py`, `fusion_clock/models/clock_report.py`
|
||||
|
||||
- [ ] **Step 1: Register the new test module**
|
||||
|
||||
In `fusion_clock/tests/__init__.py` add at the end:
|
||||
```python
|
||||
from . import test_pay_period
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing math test**
|
||||
|
||||
Create `fusion_clock/tests/test_pay_period.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
from datetime import date
|
||||
from odoo.tests import tagged, TransactionCase
|
||||
from odoo.addons.fusion_clock.models.pay_period import (
|
||||
compute_pay_period, period_length_days, current_prev_next,
|
||||
)
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestPayPeriodMath(TransactionCase):
|
||||
|
||||
def test_biweekly_window_is_14_days(self):
|
||||
# anchor Mon 2026-05-04; a date inside the 2nd period
|
||||
s, e = compute_pay_period('biweekly', '2026-05-04', date(2026, 5, 20))
|
||||
self.assertEqual(s, date(2026, 5, 18))
|
||||
self.assertEqual(e, date(2026, 5, 31))
|
||||
self.assertEqual((e - s).days, 13)
|
||||
|
||||
def test_weekly_window_is_7_days(self):
|
||||
s, e = compute_pay_period('weekly', '2026-05-04', date(2026, 5, 20))
|
||||
self.assertEqual(s, date(2026, 5, 18))
|
||||
self.assertEqual(e, date(2026, 5, 24))
|
||||
|
||||
def test_reference_before_anchor(self):
|
||||
# 2026-04-20 is one biweekly period BEFORE the anchor
|
||||
s, e = compute_pay_period('biweekly', '2026-05-04', date(2026, 4, 25))
|
||||
self.assertEqual(s, date(2026, 4, 20))
|
||||
self.assertEqual(e, date(2026, 5, 3))
|
||||
|
||||
def test_monthly_window(self):
|
||||
s, e = compute_pay_period('monthly', '', date(2026, 2, 10))
|
||||
self.assertEqual(s, date(2026, 2, 1))
|
||||
self.assertEqual(e, date(2026, 2, 28))
|
||||
|
||||
def test_semi_monthly_window(self):
|
||||
s1, e1 = compute_pay_period('semi_monthly', '', date(2026, 3, 10))
|
||||
self.assertEqual((s1, e1), (date(2026, 3, 1), date(2026, 3, 15)))
|
||||
s2, e2 = compute_pay_period('semi_monthly', '', date(2026, 3, 20))
|
||||
self.assertEqual((s2, e2), (date(2026, 3, 16), date(2026, 3, 31)))
|
||||
|
||||
def test_period_length_days(self):
|
||||
self.assertEqual(period_length_days('weekly'), 7)
|
||||
self.assertEqual(period_length_days('biweekly'), 14)
|
||||
self.assertIsNone(period_length_days('monthly'))
|
||||
|
||||
def test_current_prev_next_are_contiguous(self):
|
||||
w = current_prev_next('biweekly', '2026-05-04', date(2026, 5, 20))
|
||||
self.assertEqual(w['current'], (date(2026, 5, 18), date(2026, 5, 31)))
|
||||
self.assertEqual(w['previous'][1], w['current'][0] - __import__('datetime').timedelta(days=1))
|
||||
self.assertEqual(w['next'][0], w['current'][1] + __import__('datetime').timedelta(days=1))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the test, verify it FAILS**
|
||||
|
||||
Run the test command. Expected: import error / FAIL — `fusion_clock.models.pay_period` does not exist.
|
||||
|
||||
- [ ] **Step 4: Create the helper**
|
||||
|
||||
Create `fusion_clock/models/pay_period.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Pay-period date math shared by reports, attendance filters and the period
|
||||
picker. Pure functions (no ORM) so they unit-test trivially and never drift
|
||||
between callers."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
|
||||
def period_length_days(frequency):
|
||||
"""Fixed window length for grid frequencies; None for calendar-based ones."""
|
||||
return {'weekly': 7, 'biweekly': 14}.get(frequency)
|
||||
|
||||
|
||||
def compute_pay_period(frequency, anchor_str, reference_date):
|
||||
"""Return (start_date, end_date) for the period containing reference_date.
|
||||
|
||||
``anchor_str`` is a 'YYYY-MM-DD' string or falsy (falls back to
|
||||
first-of-month). Mirrors the original
|
||||
fusion.clock.report._calculate_current_period logic, including floor
|
||||
division so dates before the anchor resolve to the correct earlier period.
|
||||
"""
|
||||
if anchor_str:
|
||||
try:
|
||||
anchor = date.fromisoformat(anchor_str)
|
||||
except (ValueError, TypeError):
|
||||
anchor = reference_date.replace(day=1)
|
||||
else:
|
||||
anchor = reference_date.replace(day=1)
|
||||
|
||||
if frequency == 'weekly':
|
||||
period_num = (reference_date - anchor).days // 7
|
||||
start = anchor + timedelta(days=period_num * 7)
|
||||
end = start + timedelta(days=6)
|
||||
elif frequency == 'semi_monthly':
|
||||
if reference_date.day <= 15:
|
||||
start = reference_date.replace(day=1)
|
||||
end = reference_date.replace(day=15)
|
||||
else:
|
||||
start = reference_date.replace(day=16)
|
||||
next_month = reference_date.replace(day=28) + timedelta(days=4)
|
||||
end = next_month - timedelta(days=next_month.day)
|
||||
elif frequency == 'monthly':
|
||||
start = reference_date.replace(day=1)
|
||||
next_month = reference_date.replace(day=28) + timedelta(days=4)
|
||||
end = next_month - timedelta(days=next_month.day)
|
||||
else: # 'biweekly' and default
|
||||
period_num = (reference_date - anchor).days // 14
|
||||
start = anchor + timedelta(days=period_num * 14)
|
||||
end = start + timedelta(days=13)
|
||||
return start, end
|
||||
|
||||
|
||||
def current_prev_next(frequency, anchor_str, today):
|
||||
"""Return {'current','previous','next'} (start,end) windows. Previous/next
|
||||
are derived by stepping the reference date one day outside the current
|
||||
window, which works for grid AND calendar frequencies."""
|
||||
cur = compute_pay_period(frequency, anchor_str, today)
|
||||
prev = compute_pay_period(frequency, anchor_str, cur[0] - timedelta(days=1))
|
||||
nxt = compute_pay_period(frequency, anchor_str, cur[1] + timedelta(days=1))
|
||||
return {'current': cur, 'previous': prev, 'next': nxt}
|
||||
```
|
||||
|
||||
In `fusion_clock/models/__init__.py`, add as the FIRST import (before the model files that use it):
|
||||
```python
|
||||
from . import pay_period
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the math test, verify it PASSES**
|
||||
|
||||
Run the test command. Expected: all `TestPayPeriodMath` tests PASS.
|
||||
|
||||
- [ ] **Step 6: Delegate the report method to the helper**
|
||||
|
||||
In `fusion_clock/models/clock_report.py`, replace the entire body of `_calculate_current_period` (the method starting at `def _calculate_current_period(self, schedule_type, period_start_str, reference_date):` through its `return period_start, period_end`) with:
|
||||
```python
|
||||
def _calculate_current_period(self, schedule_type, period_start_str, reference_date):
|
||||
"""Calculate the period start/end dates based on schedule type.
|
||||
|
||||
Delegates to the shared pure helper so reports, the attendance period
|
||||
filters and the picker wizard all use one implementation.
|
||||
"""
|
||||
from .pay_period import compute_pay_period
|
||||
return compute_pay_period(schedule_type, period_start_str, reference_date)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Upgrade to confirm the delegation loads cleanly**
|
||||
|
||||
Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -20`
|
||||
Expected: no traceback; module loads.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/models/pay_period.py fusion_clock/models/__init__.py fusion_clock/models/clock_report.py fusion_clock/tests/test_pay_period.py fusion_clock/tests/__init__.py
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/models/pay_period.py fusion_clock/models/__init__.py fusion_clock/models/clock_report.py fusion_clock/tests/test_pay_period.py fusion_clock/tests/__init__.py \
|
||||
-m "refactor(fusion_clock): extract pay-period math to shared helper"
|
||||
```
|
||||
(Append the `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` trailer.)
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Period filters on the attendance list
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/models/hr_attendance.py`, `fusion_clock/views/hr_attendance_views.xml`, `fusion_clock/tests/test_pay_period.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing filter test**
|
||||
|
||||
Append to `fusion_clock/tests/test_pay_period.py`:
|
||||
```python
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestPayPeriodFilters(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from datetime import timedelta
|
||||
self.ICP = self.env['ir.config_parameter'].sudo()
|
||||
# Make TODAY the first day of the current bi-weekly window.
|
||||
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||
self.today = get_local_today(self.env)
|
||||
self.ICP.set_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
self.ICP.set_param('fusion_clock.pay_period_start', str(self.today))
|
||||
self.emp = self.env['hr.employee'].create({'name': 'Filter Fred'})
|
||||
Att = self.env['hr.attendance']
|
||||
# current period: today .. today+13 -> attendance at 10:00 "now-ish"
|
||||
self.att_current = Att.create({
|
||||
'employee_id': self.emp.id,
|
||||
'check_in': fields.Datetime.now(),
|
||||
'check_out': fields.Datetime.now(),
|
||||
})
|
||||
# previous period: today-14 .. today-1 -> attendance 8 days ago
|
||||
eight_ago = fields.Datetime.now() - timedelta(days=8)
|
||||
self.att_prev = Att.create({
|
||||
'employee_id': self.emp.id,
|
||||
'check_in': eight_ago,
|
||||
'check_out': eight_ago,
|
||||
})
|
||||
|
||||
def test_current_filter_returns_only_current(self):
|
||||
res = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', self.emp.id),
|
||||
('x_fclk_in_current_period', '=', True),
|
||||
])
|
||||
self.assertIn(self.att_current, res)
|
||||
self.assertNotIn(self.att_prev, res)
|
||||
|
||||
def test_previous_filter_returns_only_previous(self):
|
||||
res = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', self.emp.id),
|
||||
('x_fclk_in_previous_period', '=', True),
|
||||
])
|
||||
self.assertIn(self.att_prev, res)
|
||||
self.assertNotIn(self.att_current, res)
|
||||
```
|
||||
(`fields` is already imported at the top of the file from Task 1? No — add `from odoo import fields` to the test file's imports.)
|
||||
|
||||
In the test file imports (top), ensure:
|
||||
```python
|
||||
from odoo import fields
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test, verify it FAILS**
|
||||
|
||||
Run the test command. Expected: FAIL — `Invalid field 'x_fclk_in_current_period' in leaf ...` (field doesn't exist yet).
|
||||
|
||||
- [ ] **Step 3: Add the computed fields + search methods**
|
||||
|
||||
In `fusion_clock/models/hr_attendance.py`, add to the imports near the top (the file already imports `get_local_today, get_local_day_boundaries` from `.tz_utils`):
|
||||
```python
|
||||
from .pay_period import current_prev_next
|
||||
```
|
||||
Then add these fields and methods inside the `hr.attendance` model class (place them after the existing `x_fclk_*` field declarations):
|
||||
```python
|
||||
x_fclk_in_current_period = fields.Boolean(
|
||||
string='In Current Pay Period',
|
||||
compute='_compute_fclk_period_flags', search='_search_fclk_in_current_period')
|
||||
x_fclk_in_previous_period = fields.Boolean(
|
||||
string='In Previous Pay Period',
|
||||
compute='_compute_fclk_period_flags', search='_search_fclk_in_previous_period')
|
||||
x_fclk_in_next_period = fields.Boolean(
|
||||
string='In Next Pay Period',
|
||||
compute='_compute_fclk_period_flags', search='_search_fclk_in_next_period')
|
||||
|
||||
def _compute_fclk_period_flags(self):
|
||||
# Display-only; filtering happens entirely in the search methods.
|
||||
for att in self:
|
||||
att.x_fclk_in_current_period = False
|
||||
att.x_fclk_in_previous_period = False
|
||||
att.x_fclk_in_next_period = False
|
||||
|
||||
def _fclk_period_domain(self, which):
|
||||
"""check_in domain for the named pay-period window ('current' /
|
||||
'previous' / 'next'), computed from the configured frequency + anchor."""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
frequency = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
anchor = ICP.get_param('fusion_clock.pay_period_start', '')
|
||||
start, end = current_prev_next(frequency, anchor, get_local_today(self.env))[which]
|
||||
start_utc, _dummy = get_local_day_boundaries(self.env, start)
|
||||
_dummy2, end_excl_utc = get_local_day_boundaries(self.env, end)
|
||||
return ['&',
|
||||
('check_in', '>=', fields.Datetime.to_string(start_utc)),
|
||||
('check_in', '<', fields.Datetime.to_string(end_excl_utc))]
|
||||
|
||||
def _search_fclk_in_current_period(self, operator, value):
|
||||
return self._fclk_period_domain('current')
|
||||
|
||||
def _search_fclk_in_previous_period(self, operator, value):
|
||||
return self._fclk_period_domain('previous')
|
||||
|
||||
def _search_fclk_in_next_period(self, operator, value):
|
||||
return self._fclk_period_domain('next')
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test, verify it PASSES**
|
||||
|
||||
Run the test command. Expected: `TestPayPeriodFilters` tests PASS.
|
||||
|
||||
- [ ] **Step 5: Add the filters to the search view**
|
||||
|
||||
In `fusion_clock/views/hr_attendance_views.xml`, inside `view_hr_attendance_search_fusion_clock`, replace:
|
||||
```xml
|
||||
<filter name="fclk_has_overtime" string="Has Overtime" domain="[('x_fclk_is_overtime', '=', True)]"/>
|
||||
<separator/>
|
||||
<filter name="group_location" string="Location" context="{'group_by': 'x_fclk_location_id'}"/>
|
||||
```
|
||||
with:
|
||||
```xml
|
||||
<filter name="fclk_has_overtime" string="Has Overtime" domain="[('x_fclk_is_overtime', '=', True)]"/>
|
||||
<separator/>
|
||||
<filter name="fclk_period_current" string="Current Pay Period" domain="[('x_fclk_in_current_period', '=', True)]"/>
|
||||
<filter name="fclk_period_previous" string="Previous Pay Period" domain="[('x_fclk_in_previous_period', '=', True)]"/>
|
||||
<filter name="fclk_period_next" string="Next Pay Period" domain="[('x_fclk_in_next_period', '=', True)]"/>
|
||||
<separator/>
|
||||
<filter name="group_location" string="Location" context="{'group_by': 'x_fclk_location_id'}"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Upgrade to confirm the view parses**
|
||||
|
||||
Run: `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -20`
|
||||
Expected: no ParseError.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/models/hr_attendance.py fusion_clock/views/hr_attendance_views.xml fusion_clock/tests/test_pay_period.py
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/models/hr_attendance.py fusion_clock/views/hr_attendance_views.xml fusion_clock/tests/test_pay_period.py \
|
||||
-m "feat(fusion_clock): Current/Previous/Next Pay Period attendance filters"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: "Bi-Weekly Period" picker wizard + menu
|
||||
|
||||
**Files:**
|
||||
- Create: `fusion_clock/wizard/clock_period_picker_wizard.py`, `fusion_clock/wizard/clock_period_picker_views.xml`
|
||||
- Modify: `fusion_clock/wizard/__init__.py`, `fusion_clock/__manifest__.py`, `fusion_clock/views/clock_menus.xml`, `fusion_clock/tests/test_pay_period.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing wizard test**
|
||||
|
||||
Append to `fusion_clock/tests/test_pay_period.py`:
|
||||
```python
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestPeriodPickerWizard(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
|
||||
self.ICP = self.env['ir.config_parameter'].sudo()
|
||||
self.ICP.set_param('fusion_clock.pay_period_type', 'biweekly')
|
||||
self.ICP.set_param('fusion_clock.pay_period_start', '2026-05-04')
|
||||
self.today = get_local_today(self.env)
|
||||
|
||||
def test_default_start_is_current_period_start(self):
|
||||
from odoo.addons.fusion_clock.models.pay_period import current_prev_next
|
||||
wiz = self.env['fusion.clock.period.picker'].create({})
|
||||
expected_start = current_prev_next('biweekly', '2026-05-04', self.today)['current'][0]
|
||||
self.assertEqual(wiz.date_start, expected_start)
|
||||
|
||||
def test_onchange_autofills_two_weeks(self):
|
||||
from datetime import date, timedelta
|
||||
wiz = self.env['fusion.clock.period.picker'].new({'date_start': date(2026, 6, 1)})
|
||||
wiz._onchange_date_start()
|
||||
self.assertEqual(wiz.date_end, date(2026, 6, 1) + timedelta(days=13))
|
||||
|
||||
def test_action_apply_returns_attendance_domain(self):
|
||||
from datetime import date
|
||||
wiz = self.env['fusion.clock.period.picker'].create({
|
||||
'date_start': date(2026, 6, 1), 'date_end': date(2026, 6, 14),
|
||||
})
|
||||
act = wiz.action_apply()
|
||||
self.assertEqual(act['res_model'], 'hr.attendance')
|
||||
self.assertEqual(act['view_mode'], 'list,form')
|
||||
leaves = [l for l in act['domain'] if isinstance(l, tuple)]
|
||||
self.assertTrue(any(l[0] == 'check_in' and l[1] == '>=' for l in leaves))
|
||||
self.assertTrue(any(l[0] == 'check_in' and l[1] == '<' for l in leaves))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test, verify it FAILS**
|
||||
|
||||
Run the test command. Expected: FAIL — model `fusion.clock.period.picker` does not exist.
|
||||
|
||||
- [ ] **Step 3: Create the wizard model**
|
||||
|
||||
Create `fusion_clock/wizard/clock_period_picker_wizard.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from datetime import timedelta
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import ValidationError
|
||||
from ..models.pay_period import compute_pay_period, period_length_days, current_prev_next
|
||||
from ..models.tz_utils import get_local_today, get_local_day_boundaries
|
||||
|
||||
|
||||
class FusionClockPeriodPicker(models.TransientModel):
|
||||
"""Pick a pay-period window and open the attendance list filtered to it.
|
||||
|
||||
Defaults to the current pay period. Changing the start auto-fills the end
|
||||
to one pay period later (two weeks by default); the end stays editable so a
|
||||
fully custom range can be entered too.
|
||||
"""
|
||||
_name = 'fusion.clock.period.picker'
|
||||
_description = 'Bi-Weekly Period Picker'
|
||||
|
||||
date_start = fields.Date(string='Period Start', required=True,
|
||||
default=lambda self: self._fclk_default_window()[0])
|
||||
date_end = fields.Date(string='Period End', required=True,
|
||||
default=lambda self: self._fclk_default_window()[1])
|
||||
|
||||
def _fclk_config(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
return (ICP.get_param('fusion_clock.pay_period_type', 'biweekly'),
|
||||
ICP.get_param('fusion_clock.pay_period_start', ''))
|
||||
|
||||
def _fclk_default_window(self):
|
||||
frequency, anchor = self._fclk_config()
|
||||
return current_prev_next(frequency, anchor, get_local_today(self.env))['current']
|
||||
|
||||
@api.onchange('date_start')
|
||||
def _onchange_date_start(self):
|
||||
if not self.date_start:
|
||||
return
|
||||
frequency, anchor = self._fclk_config()
|
||||
length = period_length_days(frequency)
|
||||
if length:
|
||||
self.date_end = self.date_start + timedelta(days=length - 1)
|
||||
else:
|
||||
self.date_end = compute_pay_period(frequency, anchor, self.date_start)[1]
|
||||
|
||||
@api.constrains('date_start', 'date_end')
|
||||
def _check_dates(self):
|
||||
for rec in self:
|
||||
if rec.date_start and rec.date_end and rec.date_end < rec.date_start:
|
||||
raise ValidationError(_("Period end cannot be before period start."))
|
||||
|
||||
def action_apply(self):
|
||||
self.ensure_one()
|
||||
start_utc, _dummy = get_local_day_boundaries(self.env, self.date_start)
|
||||
_dummy2, end_excl_utc = get_local_day_boundaries(self.env, self.date_end)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _("Attendances · %s – %s") % (self.date_start, self.date_end),
|
||||
'res_model': 'hr.attendance',
|
||||
'view_mode': 'list,form',
|
||||
'domain': ['&',
|
||||
('check_in', '>=', fields.Datetime.to_string(start_utc)),
|
||||
('check_in', '<', fields.Datetime.to_string(end_excl_utc))],
|
||||
'target': 'current',
|
||||
}
|
||||
```
|
||||
|
||||
In `fusion_clock/wizard/__init__.py`, add:
|
||||
```python
|
||||
from . import clock_period_picker_wizard
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create the wizard view + action**
|
||||
|
||||
Create `fusion_clock/wizard/clock_period_picker_views.xml`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_fusion_clock_period_picker_form" model="ir.ui.view">
|
||||
<field name="name">fusion.clock.period.picker.form</field>
|
||||
<field name="model">fusion.clock.period.picker</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Bi-Weekly Period">
|
||||
<sheet>
|
||||
<div class="alert alert-info" role="alert">
|
||||
Pick the period start — the end auto-fills to one pay period later
|
||||
(two weeks by default). Adjust either date, then click
|
||||
<b>View Attendances</b>.
|
||||
</div>
|
||||
<group>
|
||||
<field name="date_start"/>
|
||||
<field name="date_end"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<footer>
|
||||
<button name="action_apply" string="View Attendances" type="object" class="btn-primary"/>
|
||||
<button special="cancel" string="Cancel" class="btn-secondary"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_clock_period_picker" model="ir.actions.act_window">
|
||||
<field name="name">Bi-Weekly Period</field>
|
||||
<field name="res_model">fusion.clock.period.picker</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_fusion_clock_period_picker_form"/>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
```
|
||||
|
||||
In `fusion_clock/__manifest__.py`, in the `data` list add this line immediately AFTER `'wizard/clock_nfc_enrollment_views.xml',` (it must load before `views/clock_menus.xml`, which references the action):
|
||||
```python
|
||||
'wizard/clock_period_picker_views.xml',
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the menu item**
|
||||
|
||||
In `fusion_clock/views/clock_menus.xml`, add after the `menu_fusion_clock_attendance_list` menuitem (the "All Attendances" item, sequence 10):
|
||||
```xml
|
||||
<menuitem id="menu_fusion_clock_biweekly_period"
|
||||
name="Bi-Weekly Period"
|
||||
parent="menu_fusion_clock_attendance"
|
||||
action="action_fusion_clock_period_picker"
|
||||
sequence="15"
|
||||
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run the wizard test, verify it PASSES**
|
||||
|
||||
Run the test command. Expected: `TestPeriodPickerWizard` tests PASS, and the upgrade (triggered by `-u`) loads the new view + menu with no ParseError.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/wizard/clock_period_picker_wizard.py fusion_clock/wizard/clock_period_picker_views.xml fusion_clock/wizard/__init__.py fusion_clock/__manifest__.py fusion_clock/views/clock_menus.xml fusion_clock/tests/test_pay_period.py
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/wizard/clock_period_picker_wizard.py fusion_clock/wizard/clock_period_picker_views.xml fusion_clock/wizard/__init__.py fusion_clock/__manifest__.py fusion_clock/views/clock_menus.xml fusion_clock/tests/test_pay_period.py \
|
||||
-m "feat(fusion_clock): Bi-Weekly Period picker wizard + Attendance menu item"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Dashboard tile, settings label, version bump, full verify
|
||||
|
||||
**Files:**
|
||||
- Modify: `fusion_clock/static/src/js/fusion_clock_dashboard.js`, `fusion_clock/static/src/xml/fusion_clock_dashboard.xml`, `fusion_clock/views/res_config_settings_views.xml`, `fusion_clock/__manifest__.py`
|
||||
|
||||
- [ ] **Step 1: Add the dashboard action handler**
|
||||
|
||||
In `fusion_clock/static/src/js/fusion_clock_dashboard.js`, add this method next to the other `onView*` handlers:
|
||||
```javascript
|
||||
onViewBiweekly() { this.action.doAction("fusion_clock.action_fusion_clock_period_picker"); }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the dashboard tile**
|
||||
|
||||
In `fusion_clock/static/src/xml/fusion_clock_dashboard.xml`, inside the Quick Actions `<t t-if="state.team">` block, add after the Activity Logs tile:
|
||||
```xml
|
||||
<span class="fclk-dash-act" t-on-click="onViewBiweekly">🗓 Bi-Weekly Period</span>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Clarify the Anchor Date setting help**
|
||||
|
||||
In `fusion_clock/views/res_config_settings_views.xml`, replace the Pay Period setting's `help` attribute:
|
||||
```xml
|
||||
<setting id="fclk_pay_period" string="Pay Period Schedule"
|
||||
help="Defines how often attendance reports are generated and the start/end dates of each reporting period.">
|
||||
```
|
||||
with:
|
||||
```xml
|
||||
<setting id="fclk_pay_period" string="Pay Period Schedule"
|
||||
help="Defines how often attendance reports are generated and the start/end dates of each period. The Anchor Date is the pay-period start used by both the reports AND the Bi-Weekly Period filter/picker on the Attendances list.">
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Bump the manifest version**
|
||||
|
||||
In `fusion_clock/__manifest__.py`, change the `version` string to `19.0.3.15.0`.
|
||||
|
||||
- [ ] **Step 5: Full upgrade + run the whole suite**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock \
|
||||
-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
Expected: upgrade succeeds; all `test_pay_period` classes pass; existing tests still pass; `0 failed, 0 error`.
|
||||
|
||||
- [ ] **Step 6: Manual browser smoke (local)**
|
||||
|
||||
http://localhost:8082 → Fusion Clock → Attendance → **All Attendances**: open Filters, confirm **Current / Previous / Next Pay Period** appear and each narrows the list. Then Attendance → **Bi-Weekly Period**: the dialog opens with the current period pre-filled; change the start and confirm the end jumps +2 weeks; **View Attendances** opens the list scoped to that window. On the dashboard (as manager), the **🗓 Bi-Weekly Period** tile opens the same dialog.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/static/src/js/fusion_clock_dashboard.js fusion_clock/static/src/xml/fusion_clock_dashboard.xml fusion_clock/views/res_config_settings_views.xml fusion_clock/__manifest__.py
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/static/src/js/fusion_clock_dashboard.js fusion_clock/static/src/xml/fusion_clock_dashboard.xml fusion_clock/views/res_config_settings_views.xml fusion_clock/__manifest__.py \
|
||||
-m "feat(fusion_clock): dashboard Bi-Weekly Period tile + settings note; bump 19.0.3.15.0"
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Push both remotes + deploy entech**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git log origin/main..HEAD --oneline
|
||||
git push origin main && git push gitea main
|
||||
```
|
||||
Then deploy to entech (whole module dir): tar (exclude `.superpowers`/`__pycache__`/`*.pyc`/`.DS_Store`) → `scp` to pve-worker5 → `pct push 111` → extract into `/mnt/extra-addons/custom` → `chown -R odoo:odoo` → upgrade as the `odoo` user (`systemctl stop odoo; runuser -u odoo -- /usr/bin/odoo --config /etc/odoo/odoo.conf -d admin -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 --logfile=/tmp/up.log; systemctl start odoo`). Verify `web/login` → 200 and `ir.module.module` version == `19.0.3.15.0`. Hard-refresh.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed inline)
|
||||
|
||||
- **Spec coverage:** §3A shared helper → Task 1; §3B filters → Task 2; §3C wizard → Task 3; §3D menu → Task 3, dashboard tile → Task 4; §3E settings note → Task 4; §6 tests → Tasks 1–3 + Task 4 full run; §9 deploy → Task 4 Step 8.
|
||||
- **Placeholder scan:** none — every code step has complete code; commands have expected output.
|
||||
- **Type/name consistency:** helper API (`compute_pay_period`, `period_length_days`, `current_prev_next` returning `{'current','previous','next'}`) is identical across Task 1 (definition + math test), Task 2 (`_fclk_period_domain` uses `current_prev_next(...)[which]`), and Task 3 (wizard uses all three). Field names `x_fclk_in_current_period` / `_previous_period` / `_next_period` match between the model (Task 2), the filters (Task 2), and the search-method names. Action xmlid `action_fusion_clock_period_picker` matches between the wizard view (Task 3), the menu (Task 3), and the dashboard handler (Task 4). `get_local_day_boundaries` end value used as the exclusive upper bound consistently.
|
||||
- **Scope:** single focused feature; one plan.
|
||||
1142
fusion_clock/docs/superpowers/plans/2026-05-31-dashboard-redesign.md
Normal file
1142
fusion_clock/docs/superpowers/plans/2026-05-31-dashboard-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
940
fusion_clock/docs/superpowers/plans/2026-05-31-pin-kiosk.md
Normal file
940
fusion_clock/docs/superpowers/plans/2026-05-31-pin-kiosk.md
Normal file
@@ -0,0 +1,940 @@
|
||||
# PIN Kiosk 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:** Ship a polished, opt-in PIN kiosk (photo-tile → PIN → optional selfie → clock) matching the NFC kiosk's premium dark/glass/brand-gradient style, gated by the existing `enable_kiosk` setting.
|
||||
|
||||
**Architecture:** Rework the existing `controllers/clock_kiosk.py` (4 routes + 1 new), rebuild `views/kiosk_templates.xml`, rewrite `static/src/js/fusion_clock_kiosk.js` as an Odoo-19 Interaction with a small state machine, and add a new `static/src/scss/pin_kiosk.scss` that mirrors `nfc_kiosk.scss` (scoped to `#pin_kiosk_root`, brand hue in `--pk-h`). Reuse the master photo gate, `hr.employee.public` avatars, and the company kiosk location.
|
||||
|
||||
**Tech Stack:** Odoo 19 HTTP controllers (`type='jsonrpc'` / `type='http'`), `@web/public/interaction` Interaction, SCSS (frontend bundle), `HttpCase`/`TransactionCase` tests.
|
||||
|
||||
**Reference (read first):** spec `fusion_clock/docs/superpowers/specs/2026-05-31-pin-kiosk-design.md`; mirror sources `static/src/scss/nfc_kiosk.scss` and `static/src/js/fusion_clock_nfc_kiosk.js` (hue extraction lines ~60-117, photo capture); repo `CLAUDE.md` + `fusion_clock/CLAUDE.md` (Interaction rule, scoped-SCSS rule).
|
||||
|
||||
**Test command** (substitute `odoo-modsdev-app` if that's your dev container):
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock \
|
||||
-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
|
||||
**Commit discipline (shared tree):** stage explicit paths, verify `git diff --cached --name-only`, `git commit --only -- <paths>`, never `git add -A`, no `.pyc`/`.DS_Store`. Push **origin + gitea** at the end. Append `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` to messages.
|
||||
|
||||
**File structure:**
|
||||
- `controllers/clock_kiosk.py` — rework `kiosk_search` (+avatar/has_pin), `kiosk_verify_pin` (→ needs_setup), new `kiosk_set_pin`, rework `kiosk_clock` (kiosk location + photo).
|
||||
- `static/src/scss/pin_kiosk.scss` (new) — kiosk styling, scoped to `#pin_kiosk_root`.
|
||||
- `views/kiosk_templates.xml` — rebuilt root + chrome + `#pin_state_container`.
|
||||
- `static/src/js/fusion_clock_kiosk.js` — Interaction state machine.
|
||||
- `models/res_config_settings.py`, `views/res_config_settings_views.xml`, `data/ir_config_parameter_data.xml` — drop `kiosk_pin_required`.
|
||||
- `models/res_company.py` — relabel kiosk-location field string.
|
||||
- `views/clock_menus.xml` — PIN kiosk app icon.
|
||||
- `__manifest__.py` — register scss + version bump.
|
||||
- `tests/test_clock_kiosk.py` (new).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Backend — employee list (+avatar/has_pin), verify_pin (needs_setup), set_pin
|
||||
|
||||
**Files:**
|
||||
- Modify: `controllers/clock_kiosk.py`
|
||||
- Create: `tests/test_clock_kiosk.py`
|
||||
- Modify: `tests/__init__.py`
|
||||
|
||||
- [ ] **Step 1: Register the test module** — add to `fusion_clock/tests/__init__.py`:
|
||||
```python
|
||||
from . import test_clock_kiosk
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write the failing tests** — create `fusion_clock/tests/test_clock_kiosk.py`:
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
from odoo.tests.common import HttpCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestPinKioskIdentity(HttpCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_kiosk', 'True')
|
||||
cls.location = cls.env['fusion.clock.location'].create({
|
||||
'name': 'PIN 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': 'PIN Kiosk Op', 'login': 'pin-kiosk-op', 'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
cls.withpin = cls.env['hr.employee'].create({
|
||||
'name': 'Pat WithPin', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234',
|
||||
})
|
||||
cls.nopin = cls.env['hr.employee'].create({
|
||||
'name': 'Nora NoPin', 'x_fclk_enable_clock': True,
|
||||
})
|
||||
|
||||
def _call(self, route, params):
|
||||
self.authenticate('pin-kiosk-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_search_returns_avatar_and_has_pin(self):
|
||||
res = self._call('/fusion_clock/kiosk/search', {'query': ''})
|
||||
rows = {e['name']: e for e in res['employees']}
|
||||
self.assertIn('Pat WithPin', rows)
|
||||
self.assertTrue(rows['Pat WithPin']['has_pin'])
|
||||
self.assertFalse(rows['Nora NoPin']['has_pin'])
|
||||
self.assertIn('/web/image/hr.employee.public/', rows['Pat WithPin']['avatar_url'])
|
||||
|
||||
def test_verify_pin_correct(self):
|
||||
res = self._call('/fusion_clock/kiosk/verify_pin', {'employee_id': self.withpin.id, 'pin': '1234'})
|
||||
self.assertTrue(res.get('success'))
|
||||
|
||||
def test_verify_pin_incorrect(self):
|
||||
res = self._call('/fusion_clock/kiosk/verify_pin', {'employee_id': self.withpin.id, 'pin': '9999'})
|
||||
self.assertEqual(res.get('error'), 'invalid_pin')
|
||||
|
||||
def test_verify_pin_needs_setup(self):
|
||||
res = self._call('/fusion_clock/kiosk/verify_pin', {'employee_id': self.nopin.id, 'pin': ''})
|
||||
self.assertTrue(res.get('needs_setup'))
|
||||
|
||||
def test_set_pin_success_then_required(self):
|
||||
res = self._call('/fusion_clock/kiosk/set_pin', {'employee_id': self.nopin.id, 'pin': '4321'})
|
||||
self.assertTrue(res.get('success'))
|
||||
self.assertEqual(self.nopin.x_fclk_kiosk_pin, '4321')
|
||||
# already set → reject
|
||||
res2 = self._call('/fusion_clock/kiosk/set_pin', {'employee_id': self.nopin.id, 'pin': '0000'})
|
||||
self.assertEqual(res2.get('error'), 'already_set')
|
||||
|
||||
def test_set_pin_rejects_bad_format(self):
|
||||
res = self._call('/fusion_clock/kiosk/set_pin', {'employee_id': self.withpin.id, 'pin': '12'})
|
||||
self.assertEqual(res.get('error'), 'bad_pin')
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the tests, verify they FAIL** — run the test command. Expected: FAIL (`search` lacks `has_pin`/`avatar_url`; `verify_pin` has no `needs_setup`; `set_pin` route 404).
|
||||
|
||||
- [ ] **Step 4: Implement** — in `controllers/clock_kiosk.py`, replace `kiosk_search` and `kiosk_verify_pin` and add `kiosk_set_pin`:
|
||||
```python
|
||||
@http.route('/fusion_clock/kiosk/search', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def kiosk_search(self, query='', **kw):
|
||||
"""Employees for the kiosk grid. Also used by the NFC kiosk's
|
||||
employee_search — keep the return shape additive."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'Access denied.'}
|
||||
employees = request.env['hr.employee'].sudo().search([
|
||||
('x_fclk_enable_clock', '=', True),
|
||||
('name', 'ilike', query),
|
||||
], limit=200, order='name')
|
||||
rows = []
|
||||
for emp in employees:
|
||||
unique = emp.write_date.strftime('%Y%m%d%H%M%S') if emp.write_date else ''
|
||||
rows.append({
|
||||
'id': emp.id,
|
||||
'name': emp.name,
|
||||
'department': emp.department_id.name or '',
|
||||
'is_checked_in': emp.attendance_state == 'checked_in',
|
||||
'card_uid': emp.x_fclk_nfc_card_uid or '',
|
||||
'has_pin': bool(emp.x_fclk_kiosk_pin),
|
||||
'avatar_url': '/web/image/hr.employee.public/%s/avatar_128?unique=%s' % (emp.id, unique),
|
||||
})
|
||||
return {'employees': rows}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/verify_pin', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def kiosk_verify_pin(self, employee_id=0, pin='', **kw):
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'Access denied.'}
|
||||
employee = request.env['hr.employee'].sudo().browse(int(employee_id))
|
||||
if not employee.exists():
|
||||
return {'error': 'not_found'}
|
||||
if not employee.x_fclk_kiosk_pin:
|
||||
return {'needs_setup': True, 'employee_name': employee.name}
|
||||
if employee.x_fclk_kiosk_pin != pin:
|
||||
return {'error': 'invalid_pin'}
|
||||
return {'success': True, 'employee_name': employee.name,
|
||||
'is_checked_in': employee.attendance_state == 'checked_in'}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/set_pin', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def kiosk_set_pin(self, employee_id=0, pin='', **kw):
|
||||
"""First-use PIN creation. Rejects if the employee already has one."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'Access denied.'}
|
||||
employee = request.env['hr.employee'].sudo().browse(int(employee_id))
|
||||
if not employee.exists() or not employee.x_fclk_enable_clock:
|
||||
return {'error': 'not_found'}
|
||||
if employee.x_fclk_kiosk_pin:
|
||||
return {'error': 'already_set'}
|
||||
pin = (pin or '').strip()
|
||||
if not (pin.isdigit() and 4 <= len(pin) <= 6):
|
||||
return {'error': 'bad_pin'}
|
||||
employee.write({'x_fclk_kiosk_pin': pin})
|
||||
return {'success': True, 'employee_name': employee.name}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the tests, verify they PASS** — run the test command. Expected: `TestPinKioskIdentity` passes.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/controllers/clock_kiosk.py fusion_clock/tests/test_clock_kiosk.py fusion_clock/tests/__init__.py
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/controllers/clock_kiosk.py fusion_clock/tests/test_clock_kiosk.py fusion_clock/tests/__init__.py \
|
||||
-m "feat(fusion_clock): PIN kiosk identity endpoints (grid list, verify, first-use set_pin)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Backend — clock with kiosk location + photo gating
|
||||
|
||||
**Files:**
|
||||
- Modify: `controllers/clock_kiosk.py` (rework `kiosk_clock`)
|
||||
- Modify: `tests/test_clock_kiosk.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** — append to `test_clock_kiosk.py`:
|
||||
```python
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestPinKioskClock(HttpCase):
|
||||
|
||||
PNG = ('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwC'
|
||||
'AAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=')
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_kiosk', 'True')
|
||||
cls.location = cls.env['fusion.clock.location'].create({
|
||||
'name': 'PIN Plant 2', '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': 'PIN Op2', 'login': 'pin-op2', 'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
cls.emp = cls.env['hr.employee'].create({
|
||||
'name': 'Quinn Clock', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234',
|
||||
})
|
||||
|
||||
def _clock(self, photo_b64=''):
|
||||
self.authenticate('pin-op2', 'kioskpass123')
|
||||
resp = self.url_open('/fusion_clock/kiosk/clock', data=json.dumps({
|
||||
'jsonrpc': '2.0', 'method': 'call',
|
||||
'params': {'employee_id': self.emp.id, 'photo_b64': photo_b64},
|
||||
}), headers={'Content-Type': 'application/json'})
|
||||
return resp.json().get('result', {})
|
||||
|
||||
def _latest(self):
|
||||
return self.env['hr.attendance'].search(
|
||||
[('employee_id', '=', self.emp.id)], order='check_in desc', limit=1)
|
||||
|
||||
def test_clock_in_uses_kiosk_location(self):
|
||||
res = self._clock()
|
||||
self.assertTrue(res.get('success'))
|
||||
self.assertEqual(res.get('action'), 'clock_in')
|
||||
att = self._latest()
|
||||
self.assertEqual(att.x_fclk_clock_source, 'kiosk')
|
||||
self.assertEqual(att.x_fclk_location_id, self.location)
|
||||
|
||||
def test_photo_stored_only_when_master_on(self):
|
||||
self.ICP.set_param('fusion_clock.enable_photo_verification', 'False')
|
||||
self._clock(self.PNG)
|
||||
self.assertFalse(self._latest().x_fclk_check_in_photo)
|
||||
# new employee for an ON run (avoid debounce/clocked-in state)
|
||||
emp2 = self.env['hr.employee'].create({
|
||||
'name': 'Quinn Two', 'x_fclk_enable_clock': True, 'x_fclk_kiosk_pin': '1234'})
|
||||
self.ICP.set_param('fusion_clock.enable_photo_verification', 'True')
|
||||
self.authenticate('pin-op2', 'kioskpass123')
|
||||
self.url_open('/fusion_clock/kiosk/clock', data=json.dumps({
|
||||
'jsonrpc': '2.0', 'method': 'call',
|
||||
'params': {'employee_id': emp2.id, 'photo_b64': self.PNG}}),
|
||||
headers={'Content-Type': 'application/json'})
|
||||
att2 = self.env['hr.attendance'].search([('employee_id', '=', emp2.id)], limit=1)
|
||||
self.assertTrue(att2.x_fclk_check_in_photo)
|
||||
|
||||
def test_no_location_configured(self):
|
||||
self.env.company.x_fclk_nfc_kiosk_location_id = False
|
||||
res = self._clock()
|
||||
self.assertEqual(res.get('error'), 'no_location_configured')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests, verify they FAIL** — run the test command. Expected: FAIL (current `kiosk_clock` uses `_verify_location` GPS, takes no `photo_b64`, no `no_location_configured`).
|
||||
|
||||
- [ ] **Step 3: Implement** — in `controllers/clock_kiosk.py`, replace the whole `kiosk_clock` method with:
|
||||
```python
|
||||
@http.route('/fusion_clock/kiosk/clock', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def kiosk_clock(self, employee_id=0, photo_b64='', **kw):
|
||||
"""Clock the employee in/out from the shared kiosk. Fixed wall device:
|
||||
uses the company kiosk location, no per-clock GPS geofence."""
|
||||
if not _is_kiosk_operator(request.env.user):
|
||||
return {'error': 'Access denied.'}
|
||||
employee = request.env['hr.employee'].sudo().browse(int(employee_id))
|
||||
if not employee.exists() or not employee.x_fclk_enable_clock:
|
||||
return {'error': 'not_found'}
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
company = request.env.company.sudo()
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
if not location:
|
||||
return {'error': 'no_location_configured'}
|
||||
|
||||
from .clock_api import FusionClockAPI
|
||||
from .clock_nfc_kiosk import _strip_data_url_prefix
|
||||
api = FusionClockAPI()
|
||||
|
||||
photo_enabled = ICP.get_param('fusion_clock.enable_photo_verification', 'False') == 'True'
|
||||
photo_bytes = _strip_data_url_prefix(photo_b64) if (photo_enabled and photo_b64) else b''
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
now = fields.Datetime.now()
|
||||
today = get_local_today(request.env, employee)
|
||||
day_plan = employee._get_fclk_day_plan(today)
|
||||
is_scheduled_off = not day_plan.get('scheduled')
|
||||
|
||||
geo_info = {'latitude': 0, 'longitude': 0, 'browser': 'kiosk',
|
||||
'ip_address': request.httprequest.remote_addr or ''}
|
||||
try:
|
||||
attendance = employee.sudo()._attendance_action_change(geo_info)
|
||||
if not is_checked_in:
|
||||
attendance.sudo().write({
|
||||
'x_fclk_location_id': location.id,
|
||||
'x_fclk_in_distance': 0.0,
|
||||
'x_fclk_clock_source': 'kiosk',
|
||||
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
|
||||
})
|
||||
api._log_activity(employee, 'clock_in', f"Kiosk clock-in at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0, source='kiosk')
|
||||
if is_scheduled_off:
|
||||
api._log_activity(employee, 'unscheduled_shift',
|
||||
f"Kiosk clock-in on an unscheduled day at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0, source='kiosk')
|
||||
else:
|
||||
scheduled_in, _ = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
|
||||
return {'success': True, 'action': 'clock_in', 'employee_name': employee.name,
|
||||
'message': f'{employee.name} clocked in at {location.name}', 'worked_hours': 0.0}
|
||||
else:
|
||||
attendance.sudo().write({
|
||||
'x_fclk_out_distance': 0.0,
|
||||
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
|
||||
})
|
||||
api._apply_break_deduction(attendance, employee)
|
||||
if not is_scheduled_off:
|
||||
_, scheduled_out = api._get_scheduled_times(employee, today)
|
||||
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
|
||||
api._log_activity(employee, 'clock_out',
|
||||
f"Kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0, source='kiosk')
|
||||
return {'success': True, 'action': 'clock_out', 'employee_name': employee.name,
|
||||
'message': f'{employee.name} clocked out from {location.name}',
|
||||
'net_hours': round(attendance.x_fclk_net_hours or 0, 2)}
|
||||
except Exception as e:
|
||||
_logger.error("Fusion Clock PIN kiosk error: %s", str(e))
|
||||
return {'error': str(e)}
|
||||
```
|
||||
Confirm `_strip_data_url_prefix` exists in `controllers/clock_nfc_kiosk.py` (it does — used by the NFC tap). Confirm `kiosk_page` already imports `fields` and `get_local_today` at module top (it does).
|
||||
|
||||
- [ ] **Step 4: Run the tests, verify they PASS** — run the test command. Expected: `TestPinKioskClock` passes.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/controllers/clock_kiosk.py fusion_clock/tests/test_clock_kiosk.py
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/controllers/clock_kiosk.py fusion_clock/tests/test_clock_kiosk.py \
|
||||
-m "feat(fusion_clock): PIN kiosk clock — kiosk location + master-gated selfie"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Settings cleanup, company relabel, app icon
|
||||
|
||||
**Files:**
|
||||
- Modify: `models/res_config_settings.py`, `views/res_config_settings_views.xml`, `data/ir_config_parameter_data.xml`, `models/res_company.py`, `views/clock_menus.xml`
|
||||
|
||||
- [ ] **Step 1: Drop `kiosk_pin_required`** (PIN always required now):
|
||||
- `models/res_config_settings.py`: delete the `fclk_kiosk_pin_required = fields.Boolean(...)` field block AND its line in `_FCLK_BOOL_PARAMS` (`('fclk_kiosk_pin_required', 'fusion_clock.kiosk_pin_required', True),`).
|
||||
- `views/res_config_settings_views.xml`: delete the `<field name="fclk_kiosk_pin_required"/>` and its surrounding `<setting>`/row.
|
||||
- `data/ir_config_parameter_data.xml`: delete the `config_kiosk_pin_required` record.
|
||||
|
||||
- [ ] **Step 2: Relabel the kiosk location** — in `models/res_company.py`, change the field string/help (it now serves NFC + PIN):
|
||||
```python
|
||||
x_fclk_nfc_kiosk_location_id = fields.Many2one(
|
||||
'fusion.clock.location',
|
||||
string='Kiosk Location',
|
||||
help="Clock location bound to the on-site kiosk (NFC and PIN) for this company.",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the PIN Kiosk app icon** — in `views/clock_menus.xml`, after the NFC kiosk app block, add:
|
||||
```xml
|
||||
<record id="action_fusion_clock_kiosk_pin" model="ir.actions.act_url">
|
||||
<field name="name">Fusion Clock PIN Kiosk</field>
|
||||
<field name="url">/fusion_clock/kiosk</field>
|
||||
<field name="target">self</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_fusion_clock_kiosk_pin_app_root"
|
||||
name="Fusion Clock PIN Kiosk"
|
||||
web_icon="fusion_clock,static/description/icon.png"
|
||||
action="action_fusion_clock_kiosk_pin"
|
||||
sequence="47"
|
||||
groups="group_fusion_clock_kiosk_app"/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Apply + verify** — run:
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -20
|
||||
```
|
||||
Expected: no ParseError / no `Invalid field` for `fclk_kiosk_pin_required`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git 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/models/res_company.py fusion_clock/views/clock_menus.xml
|
||||
git diff --cached --name-only
|
||||
git commit --only -- 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/models/res_company.py fusion_clock/views/clock_menus.xml \
|
||||
-m "feat(fusion_clock): drop kiosk_pin_required, relabel kiosk location, add PIN kiosk app icon"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: SCSS — `pin_kiosk.scss` (mirror the NFC kiosk)
|
||||
|
||||
**Files:**
|
||||
- Create: `static/src/scss/pin_kiosk.scss`
|
||||
- Modify: `__manifest__.py` (register in `web.assets_frontend`)
|
||||
|
||||
- [ ] **Step 1: Create `static/src/scss/pin_kiosk.scss`** — mirror `nfc_kiosk.scss` exactly for the shared chrome, **but** scope every rule under `:has(#pin_kiosk_root)` / `.pin-kiosk`, rename the hue var to `--pk-h`, and replace the NFC idle/icon section with the **grid + tiles**. Full file:
|
||||
|
||||
```scss
|
||||
// PIN Clock Kiosk — premium glass + animated mesh, always-dark.
|
||||
// Mirrors nfc_kiosk.scss; scoped under :has(#pin_kiosk_root) so it never leaks.
|
||||
// Brand hue --pk-h is set by JS from the company logo's dominant color.
|
||||
:root {
|
||||
--pk-h: 168;
|
||||
--pk-bg: #0b0d10;
|
||||
--pk-text: #ffffff;
|
||||
--pk-text-muted: #9ba3ad;
|
||||
--pk-success: #18a957;
|
||||
--pk-error: #d9374e;
|
||||
}
|
||||
html:has(#pin_kiosk_root) {
|
||||
overflow: hidden; height: 100%;
|
||||
body { overflow: hidden; height: 100%; margin: 0; padding: 0;
|
||||
background: var(--pk-bg) !important; color: var(--pk-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; }
|
||||
.o_main_navbar, header, footer, .o_header_standard, .o_footer { display: none !important; }
|
||||
}
|
||||
.pin-kiosk {
|
||||
position: fixed; inset: 0; width: 100vw; height: 100vh;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: flex-start;
|
||||
padding: 1.25rem 2rem 2rem; box-sizing: border-box; user-select: none;
|
||||
-webkit-tap-highlight-color: transparent; overflow: hidden; background: var(--pk-bg);
|
||||
|
||||
&::before { content: ""; position: absolute; inset: -15%;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, hsla(var(--pk-h), 75%, 40%, 0.55) 0%, transparent 45%),
|
||||
radial-gradient(circle at 80% 20%, hsla(calc(var(--pk-h) + 40), 65%, 35%, 0.50) 0%, transparent 50%),
|
||||
radial-gradient(circle at 70% 75%, hsla(calc(var(--pk-h) - 25), 70%, 35%, 0.45) 0%, transparent 55%),
|
||||
radial-gradient(circle at 15% 85%, hsla(calc(var(--pk-h) + 80), 60%, 30%, 0.40) 0%, transparent 50%);
|
||||
filter: blur(60px) saturate(140%); animation: pk-mesh 28s ease-in-out infinite alternate; z-index: 0; }
|
||||
&::after { content: ""; position: absolute; inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.45) 100%); z-index: 1; pointer-events: none; }
|
||||
> * { position: relative; z-index: 2; }
|
||||
}
|
||||
@keyframes pk-mesh {
|
||||
0% { transform: translate(0,0) rotate(0) scale(1); }
|
||||
50% { transform: translate(3%,-2%) rotate(2deg) scale(1.05); }
|
||||
100% { transform: translate(-3%,3%) rotate(-1deg) scale(0.98); }
|
||||
}
|
||||
|
||||
// Header chrome
|
||||
.pin-kiosk__logo { max-height: 56px; max-width: 240px; object-fit: contain;
|
||||
background: rgba(255,255,255,0.95); padding: 0.55rem 1rem; border-radius: 0.9rem;
|
||||
border: 2px solid hsla(var(--pk-h), 85%, 72%, 0.95);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.4), 0 0 26px hsla(var(--pk-h), 90%, 60%, 0.5); }
|
||||
.pin-kiosk__clock { margin-top: 0.5rem; font-size: 2.1rem; font-weight: 300; font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em; text-shadow: 0 2px 12px rgba(0,0,0,0.4);
|
||||
.ampm { font-size: 0.9rem; font-weight: 500; color: var(--pk-text-muted); margin-left: 0.3rem; } }
|
||||
.pin-kiosk__date { font-size: 0.8rem; color: var(--pk-text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-top: 0.1rem; }
|
||||
|
||||
// Search
|
||||
.pin-kiosk__search { margin: 1rem 0 0.85rem; width: min(440px, 92%);
|
||||
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); border-radius: 999px;
|
||||
padding: 0.7rem 1.2rem; color: var(--pk-text); font-size: 1rem; outline: none;
|
||||
&::placeholder { color: var(--pk-text-muted); }
|
||||
&:focus { border-color: hsl(var(--pk-h), 80%, 55%); } }
|
||||
|
||||
// Tile grid
|
||||
.pin-kiosk__grid { flex: 1; min-height: 0; overflow-y: auto; width: 100%; max-width: 1100px;
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.85rem; align-content: start; padding-bottom: 1rem; }
|
||||
.pin-kiosk__tile { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 0.85rem 0.4rem;
|
||||
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); border-radius: 1rem;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.25); cursor: pointer; transition: transform 120ms ease, background 150ms ease;
|
||||
&:hover, &:active { background: rgba(255,255,255,0.1); transform: translateY(-2px); } }
|
||||
.pin-kiosk__tile-av { width: 60px; height: 60px; border-radius: 50%; background-size: cover; background-position: center;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.25rem; font-weight: 700; color: #fff;
|
||||
border: 2px solid rgba(255,255,255,0.25); box-shadow: 0 6px 16px rgba(0,0,0,0.35); }
|
||||
.pin-kiosk__tile-nm { font-size: 0.8rem; text-align: center; line-height: 1.15; color: #e7ebf0; max-width: 100px; }
|
||||
|
||||
// Bottom chrome
|
||||
.pin-kiosk__location { position: absolute; bottom: 1.5rem; left: 1.5rem; font-size: 0.85rem; color: var(--pk-text-muted);
|
||||
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); padding: 0.5rem 1rem; border-radius: 999px; }
|
||||
.pin-kiosk__settings { position: absolute; bottom: 1.5rem; right: 1.5rem; width: 2.75rem; height: 2.75rem; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.09); color: var(--pk-text-muted);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.2rem; cursor: pointer; }
|
||||
|
||||
// Glass overlay (PIN pad / setup / result), centered
|
||||
.pin-kiosk__overlay { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,0.55); backdrop-filter: blur(6px); padding: 2rem; animation: pk-fade 200ms ease-out; }
|
||||
@keyframes pk-fade { from { opacity: 0; } to { opacity: 1; } }
|
||||
%pk-glass { background: rgba(255,255,255,0.06); backdrop-filter: blur(24px) saturate(160%);
|
||||
border: 1px solid rgba(255,255,255,0.12); box-shadow: 0 20px 60px rgba(0,0,0,0.5); border-radius: 1.5rem; }
|
||||
.pin-kiosk__panel { @extend %pk-glass; padding: 1.75rem 2rem; width: min(360px, 90%);
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.75rem; }
|
||||
.pin-kiosk__av { width: 64px; height: 64px; border-radius: 50%; background-size: cover; background-position: center;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 1.4rem; font-weight: 700; color: #fff;
|
||||
background-color: hsl(var(--pk-h), 60%, 45%); border: 2px solid rgba(255,255,255,0.25); }
|
||||
.pin-kiosk__name { font-size: 1.25rem; font-weight: 600; }
|
||||
.pin-kiosk__sub { font-size: 0.85rem; color: var(--pk-text-muted); margin-top: -0.3rem; }
|
||||
.pin-kiosk__dots { display: flex; gap: 0.85rem; margin: 0.5rem 0; }
|
||||
.pin-kiosk__dot { width: 0.85rem; height: 0.85rem; border-radius: 50%; border: 2px solid hsla(var(--pk-h),80%,70%,0.8);
|
||||
&.on { background: hsl(var(--pk-h),80%,65%); border-color: hsl(var(--pk-h),80%,65%); } }
|
||||
.pin-kiosk__pad { display: grid; grid-template-columns: repeat(3, 4rem); gap: 0.6rem; }
|
||||
.pin-kiosk__key { height: 3.25rem; border-radius: 0.85rem; background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.12); color: var(--pk-text); font-size: 1.4rem; font-weight: 300; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
&:active { transform: scale(0.95); background: rgba(255,255,255,0.14); }
|
||||
&.ok { background: hsl(var(--pk-h),80%,45%); border-color: transparent; } }
|
||||
.pin-kiosk__cancel { margin-top: 0.3rem; color: var(--pk-text-muted); font-size: 0.85rem; cursor: pointer; background: none; border: none; }
|
||||
.pin-kiosk__err { min-height: 1.1rem; color: var(--pk-error); font-size: 0.9rem; }
|
||||
.pin-kiosk__panel.shake { animation: pk-shake 350ms ease-in-out; }
|
||||
@keyframes pk-shake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-10px)} 40%{transform:translateX(10px)} 60%{transform:translateX(-6px)} 80%{transform:translateX(6px)} }
|
||||
|
||||
// Result card
|
||||
.pin-kiosk__result { @extend %pk-glass; padding: 2.25rem 3rem; display: flex; flex-direction: column; align-items: center;
|
||||
gap: 0.6rem; text-align: center; width: min(420px, 90%);
|
||||
border-color: rgba(24,169,87,0.55); box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 80px rgba(24,169,87,0.35);
|
||||
&--error { border-color: rgba(217,55,78,0.55); box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 60px rgba(217,55,78,0.3); } }
|
||||
.pin-kiosk__check { width: 74px; height: 74px; border-radius: 50%; background: rgba(24,169,87,0.18);
|
||||
border: 2px solid rgba(24,169,87,0.6); display: flex; align-items: center; justify-content: center; font-size: 2rem; color: #34d399; }
|
||||
.pin-kiosk__result .name { font-size: 1.6rem; font-weight: 600; }
|
||||
.pin-kiosk__result .action { font-size: 1.2rem; color: #34d399; font-weight: 500; }
|
||||
.pin-kiosk__result .meta { font-size: 0.9rem; color: var(--pk-text-muted); }
|
||||
|
||||
// Photo capture (reuse the NFC oval-guide pattern)
|
||||
.pin-kiosk__photo { @extend %pk-glass; padding: 1.5rem; width: min(540px,86%); text-align: center;
|
||||
.stage { position: relative; aspect-ratio: 3/4; height: 56vh; max-height: 480px; margin: 0 auto; border-radius: 1rem; overflow: hidden; background: #000; }
|
||||
video, img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
video { transform: scaleX(-1); }
|
||||
.guide { position: absolute; top: 47%; left: 50%; width: 64%; aspect-ratio: 3/4; transform: translate(-50%,-50%);
|
||||
border: 3px dashed rgba(255,255,255,0.92); border-radius: 50%; box-shadow: 0 0 0 9999px rgba(0,0,0,0.5); }
|
||||
.countdown { position: absolute; top: 47%; left: 50%; transform: translate(-50%,-50%); font-size: 5rem; font-weight: 200; color: #fff; text-shadow: 0 2px 24px rgba(0,0,0,0.85); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pin-kiosk::before, .pin-kiosk__panel.shake, .pin-kiosk__result { animation: none; }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register in the manifest** — in `__manifest__.py` `web.assets_frontend`, add after `nfc_kiosk.scss`:
|
||||
```python
|
||||
'fusion_clock/static/src/scss/pin_kiosk.scss',
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Force-compile to verify the SCSS is valid** — run:
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo shell -d fusion-dev --no-http 2>/dev/null <<'PY'
|
||||
env['ir.qweb']._get_asset_bundle('web.assets_frontend').css()
|
||||
print('FRONTEND BUNDLE OK')
|
||||
PY
|
||||
```
|
||||
Expected: `FRONTEND BUNDLE OK`, no Sass error. (If `min()`/mixed-unit or `@extend` errors appear, fix before moving on.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/static/src/scss/pin_kiosk.scss fusion_clock/__manifest__.py
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/static/src/scss/pin_kiosk.scss fusion_clock/__manifest__.py \
|
||||
-m "feat(fusion_clock): PIN kiosk SCSS (glass + brand-gradient, scoped)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Template — rebuild `views/kiosk_templates.xml`
|
||||
|
||||
**Files:**
|
||||
- Modify: `views/kiosk_templates.xml`
|
||||
- Modify: `controllers/clock_kiosk.py` (`kiosk_page` context)
|
||||
|
||||
- [ ] **Step 1: Update `kiosk_page` context** — in `controllers/clock_kiosk.py`, replace the `values = {...}` in `kiosk_page` with:
|
||||
```python
|
||||
company = request.env.company.sudo()
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
values = {
|
||||
'page_name': 'kiosk',
|
||||
'company_name': company.name,
|
||||
'company_logo_url': '/web/image/res.company/%s/logo' % company.id if company.logo else '',
|
||||
'location_name': location.name if location else 'No location configured',
|
||||
'sounds_enabled': ICP.get_param('fusion_clock.enable_sounds', 'True') == 'True',
|
||||
'photo_required': ICP.get_param('fusion_clock.enable_photo_verification', 'False') == 'True',
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `views/kiosk_templates.xml`** with:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="kiosk_page" name="Fusion Clock PIN Kiosk">
|
||||
<t t-call="web.frontend_layout">
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
<t t-set="no_footer" t-value="True"/>
|
||||
<div id="pin_kiosk_root" class="pin-kiosk"
|
||||
t-att-data-logo-url="company_logo_url"
|
||||
t-att-data-location="location_name"
|
||||
t-att-data-sounds="'1' if sounds_enabled else '0'"
|
||||
t-att-data-photo="'1' if photo_required else '0'">
|
||||
<img t-if="company_logo_url" id="pin_kiosk_logo" class="pin-kiosk__logo" t-att-src="company_logo_url" alt="Logo"/>
|
||||
<div class="pin-kiosk__clock" id="pin_kiosk_clock"></div>
|
||||
<div class="pin-kiosk__date" id="pin_kiosk_date"></div>
|
||||
<input type="text" class="pin-kiosk__search" id="pin_kiosk_search" placeholder="Search your name…" autocomplete="off"/>
|
||||
<div class="pin-kiosk__grid" id="pin_kiosk_grid"></div>
|
||||
<div class="pin-kiosk__location" t-esc="location_name"/>
|
||||
<div class="pin-kiosk__settings" id="pin_kiosk_settings">⚙</div>
|
||||
<div id="pin_state_container"></div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Apply + verify the template loads** — run:
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init 2>&1 | tail -15
|
||||
```
|
||||
Expected: no ParseError on `fusion_clock.kiosk_page`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/views/kiosk_templates.xml fusion_clock/controllers/clock_kiosk.py
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/views/kiosk_templates.xml fusion_clock/controllers/clock_kiosk.py \
|
||||
-m "feat(fusion_clock): PIN kiosk template (logo, clock, search, grid, state container)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: JS — rewrite `fusion_clock_kiosk.js` as an Interaction
|
||||
|
||||
**Files:**
|
||||
- Modify: `static/src/js/fusion_clock_kiosk.js`
|
||||
|
||||
- [ ] **Step 1: Replace the file** with an Odoo-19 Interaction. Full implementation:
|
||||
|
||||
```javascript
|
||||
/** @odoo-module **/
|
||||
import { Interaction } from "@web/public/interaction";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { rpc } from "@web/core/network/rpc";
|
||||
|
||||
export class PinKiosk extends Interaction {
|
||||
static selector = "#pin_kiosk_root";
|
||||
|
||||
setup() {
|
||||
this.root = this.el;
|
||||
this.grid = this.el.querySelector("#pin_kiosk_grid");
|
||||
this.searchEl = this.el.querySelector("#pin_kiosk_search");
|
||||
this.stage = this.el.querySelector("#pin_state_container");
|
||||
this.photoRequired = this.el.dataset.photo === "1";
|
||||
this.soundsOn = this.el.dataset.sounds === "1";
|
||||
this.employees = [];
|
||||
this.filtered = [];
|
||||
}
|
||||
|
||||
async willStart() {
|
||||
const res = await rpc("/fusion_clock/kiosk/search", { query: "" });
|
||||
this.employees = res.employees || [];
|
||||
this.filtered = this.employees;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.initBrandHue();
|
||||
this.startClock();
|
||||
this.renderGrid();
|
||||
this.searchEl.addEventListener("input", () => this.onSearch());
|
||||
}
|
||||
|
||||
// ---- brand hue (mirrors fusion_clock_nfc_kiosk.js) ----
|
||||
rgbToHue(r, g, b) {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
|
||||
if (d === 0) return null;
|
||||
let h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
||||
h = Math.round(h * 60); if (h < 0) h += 360; return h;
|
||||
}
|
||||
extractHue(img) {
|
||||
try {
|
||||
const w = img.naturalWidth, h = img.naturalHeight; if (!w || !h) return null;
|
||||
const c = document.createElement("canvas"); c.width = w; c.height = h;
|
||||
const ctx = c.getContext("2d"); ctx.drawImage(img, 0, 0);
|
||||
const data = ctx.getImageData(0, 0, w, h).data;
|
||||
let rs = 0, gs = 0, bs = 0, n = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const r = data[i], g = data[i+1], b = data[i+2], a = data[i+3];
|
||||
if (a < 128) continue;
|
||||
if (Math.max(r,g,b) - Math.min(r,g,b) < 25) continue;
|
||||
rs += r; gs += g; bs += b; n++;
|
||||
}
|
||||
if (n < 20) return null;
|
||||
return this.rgbToHue(Math.round(rs/n), Math.round(gs/n), Math.round(bs/n));
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
initBrandHue() {
|
||||
const img = this.el.querySelector("#pin_kiosk_logo");
|
||||
if (!img) return;
|
||||
const apply = () => { const hue = this.extractHue(img); if (hue != null) document.documentElement.style.setProperty("--pk-h", String(hue)); };
|
||||
if (img.complete) apply(); else img.addEventListener("load", apply);
|
||||
}
|
||||
|
||||
// ---- clock ----
|
||||
startClock() {
|
||||
const tick = () => {
|
||||
const d = new Date();
|
||||
let h = d.getHours(); const m = String(d.getMinutes()).padStart(2, "0");
|
||||
const ap = h >= 12 ? "PM" : "AM"; h = h % 12 || 12;
|
||||
this.el.querySelector("#pin_kiosk_clock").innerHTML = `${h}:${m}<span class="ampm">${ap}</span>`;
|
||||
this.el.querySelector("#pin_kiosk_date").textContent =
|
||||
d.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
|
||||
};
|
||||
tick(); this._clockTimer = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
// ---- grid ----
|
||||
initials(name) { return (name||"").split(" ").filter(Boolean).slice(0,2).map(p=>p[0].toUpperCase()).join(""); }
|
||||
onSearch() {
|
||||
const q = this.searchEl.value.trim().toLowerCase();
|
||||
this.filtered = q ? this.employees.filter(e => e.name.toLowerCase().includes(q)) : this.employees;
|
||||
this.renderGrid();
|
||||
}
|
||||
renderGrid() {
|
||||
this.grid.innerHTML = "";
|
||||
for (const emp of this.filtered) {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "pin-kiosk__tile";
|
||||
const av = document.createElement("div");
|
||||
av.className = "pin-kiosk__tile-av";
|
||||
if (emp.avatar_url) av.style.backgroundImage = `url(${emp.avatar_url})`;
|
||||
av.textContent = emp.avatar_url ? "" : this.initials(emp.name);
|
||||
const nm = document.createElement("div");
|
||||
nm.className = "pin-kiosk__tile-nm"; nm.textContent = emp.name;
|
||||
tile.append(av, nm);
|
||||
tile.addEventListener("click", () => this.onTile(emp));
|
||||
this.grid.appendChild(tile);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PIN / setup overlay ----
|
||||
onTile(emp) {
|
||||
this.current = emp; this.pinBuf = ""; this.attempts = 0;
|
||||
if (emp.has_pin) this.showPin(emp, "Enter your PIN", false);
|
||||
else this.showPin(emp, "Create a PIN", true); // first-use
|
||||
}
|
||||
showPin(emp, sub, isSetup, confirming) {
|
||||
this.stage.innerHTML = "";
|
||||
const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay";
|
||||
const panel = document.createElement("div"); panel.className = "pin-kiosk__panel";
|
||||
panel.innerHTML = `
|
||||
<div class="pin-kiosk__av">${emp.avatar_url ? "" : this.initials(emp.name)}</div>
|
||||
<div class="pin-kiosk__name">${emp.name}</div>
|
||||
<div class="pin-kiosk__sub">${confirming ? "Re-enter to confirm" : sub}</div>
|
||||
<div class="pin-kiosk__dots"></div>
|
||||
<div class="pin-kiosk__err"></div>
|
||||
<div class="pin-kiosk__pad"></div>
|
||||
<button class="pin-kiosk__cancel">✕ Cancel</button>`;
|
||||
if (emp.avatar_url) panel.querySelector(".pin-kiosk__av").style.backgroundImage = `url(${emp.avatar_url})`;
|
||||
const pad = panel.querySelector(".pin-kiosk__pad");
|
||||
const keys = ["1","2","3","4","5","6","7","8","9","⌫","0","✓"];
|
||||
for (const k of keys) {
|
||||
const b = document.createElement("button");
|
||||
b.className = "pin-kiosk__key" + (k === "✓" ? " ok" : "");
|
||||
b.textContent = k;
|
||||
b.addEventListener("click", () => this.onKey(k, emp, isSetup, confirming));
|
||||
pad.appendChild(b);
|
||||
}
|
||||
panel.querySelector(".pin-kiosk__cancel").addEventListener("click", () => this.reset());
|
||||
ov.appendChild(panel); this.stage.appendChild(ov);
|
||||
this._panel = panel; this.renderDots();
|
||||
}
|
||||
renderDots() {
|
||||
const dots = this._panel.querySelector(".pin-kiosk__dots"); dots.innerHTML = "";
|
||||
const len = Math.max(4, this.pinBuf.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const d = document.createElement("span");
|
||||
d.className = "pin-kiosk__dot" + (i < this.pinBuf.length ? " on" : "");
|
||||
dots.appendChild(d);
|
||||
}
|
||||
}
|
||||
err(msg) {
|
||||
const e = this._panel.querySelector(".pin-kiosk__err"); e.textContent = msg;
|
||||
this._panel.classList.add("shake"); setTimeout(() => this._panel.classList.remove("shake"), 360);
|
||||
}
|
||||
onKey(k, emp, isSetup, confirming) {
|
||||
if (k === "⌫") { this.pinBuf = this.pinBuf.slice(0, -1); this.renderDots(); return; }
|
||||
if (k === "✓") { this.submitPin(emp, isSetup, confirming); return; }
|
||||
if (this.pinBuf.length < 6) { this.pinBuf += k; this.renderDots(); }
|
||||
if (this.pinBuf.length >= 4 && !isSetup) { /* allow ✓; no auto-submit */ }
|
||||
}
|
||||
async submitPin(emp, isSetup, confirming) {
|
||||
const pin = this.pinBuf;
|
||||
if (pin.length < 4) return this.err("PIN must be at least 4 digits");
|
||||
if (isSetup && !confirming) { // first entry of new PIN → confirm
|
||||
this._newPin = pin; this.pinBuf = "";
|
||||
return this.showPin(emp, "Create a PIN", true, true);
|
||||
}
|
||||
if (isSetup && confirming) {
|
||||
if (pin !== this._newPin) { this.pinBuf = ""; this.renderDots(); return this.err("PINs didn't match"); }
|
||||
const r = await rpc("/fusion_clock/kiosk/set_pin", { employee_id: emp.id, pin });
|
||||
if (r.error) return this.err("Couldn't save PIN");
|
||||
return this.afterPin(emp);
|
||||
}
|
||||
const v = await rpc("/fusion_clock/kiosk/verify_pin", { employee_id: emp.id, pin });
|
||||
if (v.success) return this.afterPin(emp);
|
||||
this.attempts++; this.pinBuf = ""; this.renderDots();
|
||||
if (this.attempts >= 3) return this.reset();
|
||||
this.err("Wrong PIN — try again");
|
||||
}
|
||||
|
||||
// ---- photo (optional) then clock ----
|
||||
async afterPin(emp) {
|
||||
let photo = "";
|
||||
if (this.photoRequired) {
|
||||
try { photo = await this.capturePhoto(emp); } catch (e) { photo = ""; }
|
||||
}
|
||||
const r = await rpc("/fusion_clock/kiosk/clock", { employee_id: emp.id, photo_b64: photo });
|
||||
this.showResult(emp, r);
|
||||
}
|
||||
showResult(emp, r) {
|
||||
this.stage.innerHTML = "";
|
||||
const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay";
|
||||
const card = document.createElement("div");
|
||||
if (r && r.success) {
|
||||
card.className = "pin-kiosk__result";
|
||||
const act = r.action === "clock_out" ? "Clocked Out" : "Clocked In";
|
||||
card.innerHTML = `<div class="pin-kiosk__check">✓</div>
|
||||
<div class="name">${emp.name}</div><div class="action">${act}</div>
|
||||
<div class="meta">${r.message || ""}</div>`;
|
||||
if (this.soundsOn) this.beep();
|
||||
} else {
|
||||
card.className = "pin-kiosk__result pin-kiosk__result--error";
|
||||
card.innerHTML = `<div class="pin-kiosk__check" style="color:#f87171;background:rgba(217,55,78,.18);border-color:rgba(217,55,78,.6)">!</div>
|
||||
<div class="name">${emp.name}</div><div class="action" style="color:#f87171">Couldn't clock</div>
|
||||
<div class="meta">${(r && r.error) || "Try again"}</div>`;
|
||||
}
|
||||
ov.appendChild(card); this.stage.appendChild(ov);
|
||||
setTimeout(() => this.reset(), 3000);
|
||||
}
|
||||
beep() { try { const a = new (window.AudioContext || window.webkitAudioContext)(); const o = a.createOscillator(); o.frequency.value = 880; o.connect(a.destination); o.start(); o.stop(a.currentTime + 0.12); } catch (e) {} }
|
||||
|
||||
// ---- camera capture (mirrors the NFC kiosk; oval guide + 3s countdown) ----
|
||||
capturePhoto(emp) {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let stream;
|
||||
try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" } }); }
|
||||
catch (e) { return reject(e); }
|
||||
this.stage.innerHTML = "";
|
||||
const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay";
|
||||
const panel = document.createElement("div"); panel.className = "pin-kiosk__photo";
|
||||
panel.innerHTML = `<h2>${emp.name}</h2>
|
||||
<div class="stage"><video autoplay playsinline></video><div class="guide"></div><div class="countdown"></div></div>`;
|
||||
ov.appendChild(panel); this.stage.appendChild(ov);
|
||||
const video = panel.querySelector("video"); video.srcObject = stream;
|
||||
const cd = panel.querySelector(".countdown");
|
||||
let n = 3; cd.textContent = n;
|
||||
const timer = setInterval(() => {
|
||||
n--; if (n > 0) { cd.textContent = n; return; }
|
||||
clearInterval(timer);
|
||||
const c = document.createElement("canvas"); c.width = video.videoWidth; c.height = video.videoHeight;
|
||||
c.getContext("2d").drawImage(video, 0, 0);
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
resolve(c.toDataURL("image/jpeg", 0.8));
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.stage.innerHTML = ""; this.pinBuf = ""; this.current = null; this._newPin = null;
|
||||
this.searchEl.value = ""; this.filtered = this.employees; this.renderGrid();
|
||||
// refresh checked-in state in the background
|
||||
rpc("/fusion_clock/kiosk/search", { query: "" }).then(res => { this.employees = res.employees || []; });
|
||||
}
|
||||
destroy() { if (this._clockTimer) clearInterval(this._clockTimer); }
|
||||
}
|
||||
|
||||
registry.category("public.interactions").add("fusion_clock.pin_kiosk", PinKiosk);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Syntax-check** — run:
|
||||
```bash
|
||||
docker exec odoo-dev-app node --check /mnt/extra-addons/custom/fusion_clock/static/src/js/fusion_clock_kiosk.js 2>&1 | tail -3 || echo "(node unavailable — rely on browser load in Task 7)"
|
||||
```
|
||||
Expected: no syntax error.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/static/src/js/fusion_clock_kiosk.js
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/static/src/js/fusion_clock_kiosk.js \
|
||||
-m "feat(fusion_clock): PIN kiosk Interaction (grid, PIN/setup, photo, clock)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Version bump, full upgrade + tests, manual smoke, deploy
|
||||
|
||||
**Files:**
|
||||
- Modify: `__manifest__.py` (version), `fusion_clock/CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: Bump version** — `__manifest__.py` version → `19.0.4.0.0` (new feature).
|
||||
|
||||
- [ ] **Step 2: Update module docs** — in `fusion_clock/CLAUDE.md`: in the kiosk section note the classic kiosk is now the polished PIN kiosk (photo-tile → PIN → optional selfie, brand-gradient, app icon, opt-in via `enable_kiosk`); remove `fusion_clock.kiosk_pin_required` from the §11 settings-keys list.
|
||||
|
||||
- [ ] **Step 3: Full upgrade + run the suite**
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock \
|
||||
-u fusion_clock --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
||||
```
|
||||
Expected: upgrade succeeds; `test_clock_kiosk` passes; existing tests still pass; `0 failed, 0 error`.
|
||||
|
||||
- [ ] **Step 4: Manual browser smoke (local)** — http://localhost:8082: as a manager, set `enable_kiosk` ON + a Kiosk Location, open `/fusion_clock/kiosk`. Confirm: logo pill + brand gradient + live clock; the photo-tile grid; search filters; tapping a tile opens the PIN pad; a no-PIN employee gets the create+confirm flow; correct PIN → (selfie if Photo Verification ON) → success card → auto-return; wrong PIN shakes. Toggle Photo Verification and confirm the selfie step appears/disappears.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git add -- fusion_clock/__manifest__.py fusion_clock/CLAUDE.md
|
||||
git diff --cached --name-only
|
||||
git commit --only -- fusion_clock/__manifest__.py fusion_clock/CLAUDE.md \
|
||||
-m "chore(fusion_clock): bump 19.0.4.0.0 (PIN kiosk) + docs"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Push both remotes + deploy entech**
|
||||
```bash
|
||||
cd /Users/gurpreet/Github/Odoo-Modules
|
||||
git log origin/main..HEAD --oneline
|
||||
git push origin main && git push gitea main
|
||||
```
|
||||
Then deploy the whole `fusion_clock` dir to entech (tar excluding `.superpowers`/`__pycache__`/`*.pyc`/`.DS_Store` → scp pve-worker5 → pct push 111 → extract → chown odoo:odoo → upgrade as `odoo` user with `--http-port=0 --gevent-port=0`). Verify web/login → 200, version `19.0.4.0.0`, and (read-only) the `/fusion_clock/kiosk` page renders for the operator. Hard-refresh the tablet. (entech can keep using the NFC kiosk; the PIN kiosk is opt-in via `enable_kiosk` + the PIN Kiosk app icon.)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (completed inline)
|
||||
|
||||
- **Spec coverage:** §3.1 flow → Tasks 5/6; §3.2 style → Task 4; §3.3 backend (search/verify/set_pin/clock) → Tasks 1–2; §3.4 JS Interaction → Task 6; §3.5 template → Task 5; §3.6 settings/menu/location/PIN → Task 3 (+ company relabel); photo master-gate → Task 2; tests → Tasks 1–2 + Task 7; deploy → Task 7.
|
||||
- **Placeholder scan:** none — complete code for backend/tests/config/template/JS/SCSS; commands have expected output.
|
||||
- **Type/name consistency:** routes `/fusion_clock/kiosk/{search,verify_pin,set_pin,clock}` match between controller (Tasks 1–2), JS `rpc(...)` calls (Task 6), and tests. Return keys (`employees[].{has_pin,avatar_url}`, `needs_setup`, `invalid_pin`, `already_set`, `bad_pin`, `no_location_configured`, `success/action/message/net_hours`) consistent across controller, JS, tests. DOM ids (`#pin_kiosk_root`, `#pin_kiosk_grid`, `#pin_kiosk_search`, `#pin_state_container`, `#pin_kiosk_logo`, `#pin_kiosk_clock`, `#pin_kiosk_date`) match between template (Task 5), JS (Task 6), and SCSS scoping (Task 4). `--pk-h` used in SCSS + set in JS. `x_fclk_nfc_kiosk_location_id` used consistently as the kiosk location.
|
||||
- **Scope:** single feature; one plan.
|
||||
@@ -0,0 +1,86 @@
|
||||
# NFC Kiosk — Enrollment UX, PIN fix, Speed, Clock-out Hours
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Module:** `fusion_clock` (NFC tap kiosk at `/fusion_clock/kiosk/nfc`)
|
||||
**Status:** Approved design, ready for implementation plan.
|
||||
|
||||
## Context
|
||||
|
||||
The NFC kiosk (`static/src/js/fusion_clock_nfc_kiosk.js`, an IIFE state machine) handles
|
||||
tap-to-clock on a wall tablet at the entech client. Four issues to address, all driven by
|
||||
real shop-floor use (lines of 10–20 people).
|
||||
|
||||
**Implementation approach:** extend the existing IIFE in place. A migration to an Odoo 19
|
||||
`Interaction` (per repo CLAUDE.md guidance) is deliberately out of scope — the file is a
|
||||
large, working state machine on a live client device and the four changes here are
|
||||
surgical; a rewrite would be high-risk for no functional gain. Noted deviation.
|
||||
|
||||
## Requirements & Design
|
||||
|
||||
### 1. PIN entry: stop the per-digit full re-render
|
||||
**Problem:** in `renderEnroll(phase:"password")`, every numpad press calls
|
||||
`renderEnroll(...)` which rebuilds the whole panel via `stateContainer.innerHTML = ...` and
|
||||
replays the 400ms `nfc-state-in` entrance animation → the screen visibly "refreshes" on each
|
||||
digit (entry is preserved, but it flickers).
|
||||
**Design:** a reusable PIN-pad component that renders the panel **once**, then on
|
||||
digit/backspace mutates only the masked `.pin-display` text node + an in-memory buffer.
|
||||
No `innerHTML` rebuild, no re-animation. Used by both the ⚙ enroll PIN and the new
|
||||
Manager-PIN step (§2). OK/Cancel callbacks are parameters.
|
||||
|
||||
### 2. Program a tag from an unknown tap
|
||||
**Problem:** an unknown card tap returns `{error:"card_unknown"}` and shows a red error that
|
||||
auto-dismisses. Programming requires the separate ⚙ flow (enter password → search → **re-tap**).
|
||||
**Design:** the tapped UID is already captured, so program *that* card with no re-tap:
|
||||
1. Unknown tap → **amber** "This card isn't programmed yet" panel with **"Program this card"**
|
||||
and **"Cancel"** buttons. Auto-cancel to idle after ~8s of inactivity.
|
||||
2. **"Program this card"** → **Manager PIN** step (reuses §1 component; credential =
|
||||
`fusion_clock.nfc_enroll_password`, currently `1120`; labelled "Manager PIN" in UI).
|
||||
3. **Employee step**: search-and-pick an existing employee **or** "+ New employee" →
|
||||
enter a name → create a minimal `hr.employee`.
|
||||
4. **Assign**: bind the captured UID to that employee → success confirmation.
|
||||
- The ⚙ enroll mode stays as a proactive path, reusing the same fixed PIN component.
|
||||
|
||||
**Backend:**
|
||||
- Reuse `POST /fusion_clock/kiosk/nfc/enroll` (`employee_id`, `card_uid`, `enroll_password`)
|
||||
for the bind. Already manager/Kiosk-Operator + password gated, sudo data ops.
|
||||
- **New endpoint** `POST /fusion_clock/kiosk/nfc/create_employee` (`name`, `enroll_password`):
|
||||
Kiosk-Operator-gated + password-gated; creates `hr.employee` via **sudo** with
|
||||
`name`, `x_fclk_enable_clock=True`, `company_id = request.env.company.id`; returns
|
||||
`{employee_id, employee_name}` (or `{error}`). JS then calls `enroll` with the captured UID.
|
||||
Minimal fields only — department/contract/etc. are completed later in HR.
|
||||
|
||||
### 3. Faster clock-in/out ("Fast")
|
||||
**Problem:** result card lingers 3s (errors 4s) and entrance animations are 0.4–0.7s →
|
||||
slow throughput for long lines.
|
||||
**Design (JS timers):** success result display **3000 → 1800 ms**; error **4000 → 3000 ms**.
|
||||
**Design (SCSS durations):** `nfc-state-in` 400→200ms; `nfc-success-burst` 700→350ms;
|
||||
`nfc-avatar-in` 600→300ms. Ambient idle wave/chip loop unchanged (does not gate throughput).
|
||||
`prefers-reduced-motion` fallback preserved.
|
||||
|
||||
### 4. Clock-out shows shift hours, clearly
|
||||
**Problem:** clock-out shows `${net_hours_today.toFixed(1)}h today` — mislabelled "today",
|
||||
small, and hidden when it rounds to 0.
|
||||
**Design:** on clock-out always show a prominent **"Worked Xh Ym this shift"** computed from
|
||||
`net_hours_today` (the just-closed attendance's net hours = worked − break). Render h+m;
|
||||
show even when 0 (e.g. "Worked 0h 4m this shift"). Backend already returns the value; this is
|
||||
a JS label/format + SCSS prominence change. Clock-in unchanged.
|
||||
|
||||
## Files
|
||||
- `static/src/js/fusion_clock_nfc_kiosk.js` — PIN component; unknown-tap → program flow;
|
||||
create-employee call; result timers; clock-out hours formatting.
|
||||
- `static/src/scss/nfc_kiosk.scss` — animation durations; amber "unknown card" panel +
|
||||
create-employee styles; prominent clock-out hours.
|
||||
- `controllers/clock_nfc_kiosk.py` — new `nfc_create_employee` endpoint.
|
||||
- `__manifest__.py` — version bump (assets changed).
|
||||
|
||||
## Out of scope / non-goals
|
||||
- No migration of the kiosk JS to an `Interaction`.
|
||||
- No new employee fields beyond name/clock-enabled/company at kiosk-create time.
|
||||
- Classic PIN kiosk (`/fusion_clock/kiosk`) untouched (disabled at entech).
|
||||
|
||||
## Test / verify
|
||||
- Local: `pyflakes` the controller; `xmllint`/manifest parse; review the JS by hand
|
||||
(no local Odoo container available this session).
|
||||
- entech: deploy, upgrade, then on the tablet — PIN entry no longer flickers; unknown tap →
|
||||
program (existing + new employee) binds without re-tap; clock-in/out visibly faster;
|
||||
clock-out shows "Worked Xh Ym this shift".
|
||||
@@ -0,0 +1,105 @@
|
||||
# Fusion Clock — Bi-Weekly Attendance Filter Design
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Module:** `fusion_clock`
|
||||
**Status:** Approved (brainstorming) — ready for implementation plan
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
Operators reviewing **All Attendances** have no quick way to scope the list to a pay period. In Canada most payroll runs **bi-weekly**, so the common need is "show me this two-week pay period's attendances" (and step to the previous/next, or jump to an arbitrary period). The module already computes bi-weekly windows for its reports and already has the configuration — but none of it is exposed as a filter on the attendance list.
|
||||
|
||||
## 2. Existing state (reused, not rebuilt)
|
||||
|
||||
- **Period math already exists:** `fusion.clock.report._calculate_current_period(frequency, anchor_str, reference_date) → (start, end)` (`models/clock_report.py:457`). Handles `weekly` (7d), `biweekly` (14d), `semi_monthly`, `monthly`; uses floor division so dates *before* the anchor resolve correctly; anchor defaults to first-of-month when unset.
|
||||
- **Setting already exists:** Settings → Fusion Clock → Pay Period: **Frequency** (`fusion_clock.pay_period_type`, default `biweekly`) + **Anchor Date** (`fusion_clock.pay_period_start`, `YYYY-MM-DD`). Decision (brainstorming): **reuse this as the single source of truth** — no new setting.
|
||||
- **Attendance search view already inherited:** `view_hr_attendance_search_fusion_clock` (`views/hr_attendance_views.xml`) inherits `hr_attendance.hr_attendance_view_filter` and already adds custom filters — the natural home for the new period filters.
|
||||
- **TZ helpers:** `get_local_day_boundaries(env, date[, employee])` and `get_local_today(env)` in `models/tz_utils.py`.
|
||||
|
||||
Decisions from brainstorming: reuse the Pay Period setting; provide **both** quick filters and a picker; the window **follows the Frequency setting** (one pay period; 2 weeks by default).
|
||||
|
||||
## 3. Design
|
||||
|
||||
### A. Shared period math (DRY)
|
||||
Extract the body of `_calculate_current_period` into a reusable, model-free helper so reports, filters, and the wizard share one implementation and never drift.
|
||||
|
||||
`models/pay_period.py` (new):
|
||||
```python
|
||||
def compute_pay_period(frequency, anchor_str, reference_date) -> (date, date)
|
||||
# identical logic to _calculate_current_period; pure function
|
||||
def period_length_days(frequency) -> int | None
|
||||
# 7 for 'weekly', 14 for 'biweekly'/default, None for calendar-based (semi_monthly/monthly)
|
||||
def current_prev_next(frequency, anchor_str, today) -> dict
|
||||
# {'current': (s,e), 'previous': (s,e), 'next': (s,e)} where
|
||||
# previous = compute_pay_period(..., current_start - 1 day),
|
||||
# next = compute_pay_period(..., current_end + 1 day) # works for ALL frequencies
|
||||
```
|
||||
`fusion.clock.report._calculate_current_period` becomes a thin delegator to `compute_pay_period` (no behaviour change).
|
||||
|
||||
### B. Quick filters on the attendance list
|
||||
On `hr.attendance`, three **non-stored computed Boolean** fields, each with a `search` method (the compute returns `False` — display only; the search method does the work):
|
||||
- `x_fclk_in_current_period`, `x_fclk_in_previous_period`, `x_fclk_in_next_period`
|
||||
|
||||
Each `_search_*(operator, value)`:
|
||||
1. Read `pay_period_type` + `pay_period_start` via `ICP.sudo().get_param`.
|
||||
2. Compute the window with `current_prev_next(...)` keyed on `get_local_today(env)`.
|
||||
3. Convert the date window to UTC bounds with `get_local_day_boundaries` (start → 00:00 local, end → next-day 00:00 / inclusive end-of-day).
|
||||
4. Return `['&', ('check_in', '>=', start_utc), ('check_in', '<', end_excl_utc)]`.
|
||||
|
||||
Add three filters to `view_hr_attendance_search_fusion_clock`:
|
||||
```xml
|
||||
<filter name="fclk_period_current" string="Current Pay Period" domain="[('x_fclk_in_current_period','=',True)]"/>
|
||||
<filter name="fclk_period_previous" string="Previous Pay Period" domain="[('x_fclk_in_previous_period','=',True)]"/>
|
||||
<filter name="fclk_period_next" string="Next Pay Period" domain="[('x_fclk_in_next_period','=',True)]"/>
|
||||
```
|
||||
|
||||
### C. "Pick Pay Period" wizard
|
||||
`wizard/clock_period_picker_wizard.py` (new) — transient `fusion.clock.period.picker`:
|
||||
- `date_start` (Date, required) — default = current period start (`current_prev_next(...)['current'][0]`).
|
||||
- `date_end` (Date, required) — default = current period end; **editable**.
|
||||
- `@api.onchange('date_start')`: if `period_length_days(freq)` is not None → `date_end = date_start + length - 1`; else (calendar frequencies) → `date_end = compute_pay_period(freq, anchor, date_start)[1]`. (User can still override `date_end` → covers "set both, or just the start and auto-calc".)
|
||||
- `action_apply()` returns an `ir.actions.act_window`:
|
||||
```python
|
||||
return {
|
||||
'type': 'ir.actions.act_window', 'name': f"Attendances · {date_start} – {date_end}",
|
||||
'res_model': 'hr.attendance', 'view_mode': 'list,form',
|
||||
'domain': ['&', ('check_in','>=', start_utc), ('check_in','<', end_excl_utc)],
|
||||
'target': 'current',
|
||||
}
|
||||
```
|
||||
(`start_utc`/`end_excl_utc` from `get_local_day_boundaries` on `date_start` / `date_end`.)
|
||||
|
||||
`wizard/clock_period_picker_views.xml` (new): a small form (date_start, date_end, **Apply** + **Cancel**) and an `ir.actions.act_window` opening it as a dialog (`target="new"`).
|
||||
|
||||
### D. Entry points
|
||||
- **Menu:** `views/clock_menus.xml` — add **Fusion Clock → Attendance → Bi-Weekly Period** (`sequence` after All Attendances), `groups="group_fusion_clock_manager,group_fusion_clock_team_lead"`, action = the picker wizard.
|
||||
- **Dashboard tile:** add an **onViewBiweekly()** handler (opens the picker wizard act_window) and a "🗓 Bi-Weekly Period" tile in the dashboard Quick Actions, inside the existing `t-if="state.team"` block (so only leads/managers see it).
|
||||
|
||||
### E. Settings
|
||||
No new setting. Clarify the Anchor Date help/label in `res_config_settings_views.xml` to note it is the bi-weekly week-start used by **both** the reports and the attendance period filter.
|
||||
|
||||
## 4. Permissions
|
||||
Filters, menu, and dashboard tile are gated to **manager + team-lead** (the attendance list itself is already gated to them in `clock_menus.xml`). Search methods read `ir.config_parameter` via `sudo()` (config only — no employee data). The returned domains run through Odoo's normal ACL/record rules, so a team-lead still sees only their own reports' attendance rows. No new data exposure.
|
||||
|
||||
## 5. Edge cases
|
||||
- **No anchor set** → `compute_pay_period` falls back to first-of-month (existing behaviour); filters and picker still resolve a sane window. The picker pre-fills `date_start` with the computed current start so it is never blank.
|
||||
- **Frequency = semi_monthly / monthly** → window follows it; previous/next via `current_start − 1` / `current_end + 1` handles calendar stepping; picker auto-end uses the calendar period end containing the chosen start.
|
||||
- **TZ / DST** → date windows convert through `get_local_day_boundaries`, so a UTC `check_in` is matched against local pay-period days; end is exclusive next-day-00:00 to include the whole last day.
|
||||
- **date_end before date_start** in the picker → `@api.constrains` raises a friendly `ValidationError`.
|
||||
|
||||
## 6. Testing (`tests/test_pay_period.py`, `@tagged('-at_install','post_install','fusion_clock')`)
|
||||
- `compute_pay_period` for weekly / biweekly / semi_monthly / monthly, including a reference date **before** the anchor (negative offset) and exact boundary days.
|
||||
- `current_prev_next` returns contiguous, non-overlapping windows for biweekly (prev_end + 1 day == current_start, current_end + 1 == next_start).
|
||||
- Create attendances spanning two bi-weekly periods; assert the **Current** search filter returns only current-period rows and **Previous** only previous-period rows.
|
||||
- Wizard: default `date_start` == current period start; `onchange` sets `date_end = start + 13` for biweekly; `action_apply` returns an act_window whose domain bounds equal the local-day UTC boundaries of the chosen window.
|
||||
|
||||
## 7. Out of scope (YAGNI)
|
||||
Custom OWL toolbar dropdown on the list (native Filters menu + wizard instead); per-employee differing pay periods; editing the anchor from the picker; saving favourite/named periods; touching the gantt view.
|
||||
|
||||
## 8. Files touched
|
||||
- New: `models/pay_period.py`, `wizard/clock_period_picker_wizard.py`, `wizard/clock_period_picker_views.xml`, `tests/test_pay_period.py`
|
||||
- Modify: `models/__init__.py`, `models/clock_report.py` (delegate), `models/hr_attendance.py` (3 fields + search methods), `wizard/__init__.py`, `views/hr_attendance_views.xml` (3 filters), `views/clock_menus.xml` (menu item), `views/res_config_settings_views.xml` (label text), `static/src/js/fusion_clock_dashboard.js` + `static/src/xml/fusion_clock_dashboard.xml` (tile), `__manifest__.py` (data entry for the wizard view + version bump)
|
||||
|
||||
## 9. Deployment
|
||||
Local test on the dev container (when available), then the standard entech path: bump version, `git commit --only` the explicit paths, push **origin + gitea**, upgrade entech (`pct exec 111`, native `odoo.service`, DB `admin`, `--http-port=0 --gevent-port=0`), verify web 200 + installed version, hard-refresh.
|
||||
@@ -0,0 +1,152 @@
|
||||
# Fusion Clock — Dashboard Redesign (Layered, Role-Aware) Design
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Module:** `fusion_clock`
|
||||
**Status:** Approved (brainstorming) — ready for implementation plan
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The current backend dashboard (`fusion_clock.Dashboard` client action) is **manager/team-lead only** and shows nothing but org/team aggregate counts. A regular employee who opens it gets `Access denied.` It is plain Bootstrap (4 flat summary cards + a roster table + an alerts column), uses a runtime `.o_dark_mode` selector for dark mode (against the repo's compile-time rule), and surfaces none of the per-person information an employee actually wants (their hours, shift, streak, leaves).
|
||||
|
||||
We want one modern dashboard that:
|
||||
- Works for **every** role, showing **only** what that role is permitted to see.
|
||||
- Leads with vibrant gradient KPI cards (Style A, chosen during brainstorming).
|
||||
- Supports **both light and dark** mode correctly (compile-time, per repo rule).
|
||||
- Puts "the most information at fingertips" without leaking other employees' data.
|
||||
|
||||
## 2. Permission Model (the core requirement)
|
||||
|
||||
Three existing groups, already in an implied chain (`security/security.xml`):
|
||||
|
||||
```
|
||||
group_fusion_clock_user ← group_fusion_clock_team_lead ← group_fusion_clock_manager
|
||||
```
|
||||
|
||||
The dashboard renders **bands**, gated by role. The hard rule: **a regular employee's payload contains only their own data — the server never sends another employee's data to a non-lead/non-manager.**
|
||||
|
||||
| Band | Employee | Team lead | Manager |
|
||||
|---|---|---|---|
|
||||
| Header (greeting, date, own clock status) | ✅ own | ✅ own | ✅ own |
|
||||
| Personal KPIs — Today, This Week, OT (week), On-time Streak | ✅ own | ✅ own | ✅ own |
|
||||
| Today's Shift (scheduled window, status, source) | ✅ own | ✅ own | ✅ own |
|
||||
| My Recent Activity / My Leave & Penalties | ✅ own | ✅ own | ✅ own |
|
||||
| **— employee view ends here —** | | | |
|
||||
| Team KPIs — Present / Absent / Late / Pending | ❌ | ✅ direct reports | ✅ org-wide |
|
||||
| Currently Clocked In roster | ❌ | ✅ direct reports | ✅ everyone |
|
||||
| Needs Attention (genuine absences, pending reasons, pending corrections) | ❌ | ✅ their team | ✅ org-wide |
|
||||
| Quick Actions | own (clock/leave/correction/timesheets) | + team views | + Reports / Settings |
|
||||
|
||||
**Scoping rule (server-side, never client-trusted):**
|
||||
- `manager` → `emp_ids = all employees where x_fclk_enable_clock = True`.
|
||||
- `team_lead` → `emp_ids = employees where parent_id == current user's employee` (their direct reports). Their own personal band is computed from their own employee record.
|
||||
- `employee` → `emp_ids = [own employee]`; the **team band is omitted entirely** (`team: null`).
|
||||
|
||||
**Approvals decision:** team leads **see** their team's pending corrections/leaves (counts + an alert row that links to a filtered list) but the **approve action stays manager-gated** by the existing ACL/record rules. Managers see org-wide and can approve. The dashboard adds no new approval capability; it only surfaces and links.
|
||||
|
||||
## 3. Look & Feel (decided in brainstorming)
|
||||
|
||||
- **Card style A — Vibrant full-gradient:** each KPI is its own bold `linear-gradient(135deg, …)` card with white text and a translucent icon chip. Same gradients in light and dark (white-on-gradient reads in both).
|
||||
- **Layout A — Stacked sections:** single column, top-to-bottom: Header → Personal KPI row → Personal detail (2 cards) → `Team / Org` divider → Team KPI row → roster + Needs Attention (2 cards) → Quick Actions. Degrades gracefully: a regular employee simply has nothing rendered below the divider.
|
||||
- **Responsive:** KPI rows are a CSS grid that collapses 4→2→1 columns; the two-up detail rows collapse to one column on narrow screens. Mobile/tablet-first since this is the same view everyone opens.
|
||||
|
||||
### Dark / light (compile-time, per repo rule)
|
||||
|
||||
Branch on `$o-webclient-color-scheme` at SCSS compile time — **no** `.o_dark_mode` / `[data-bs-theme]` / `prefers-color-scheme`. The existing runtime `.o_dark_mode` block for `.fclk-dash-card` is removed.
|
||||
|
||||
- **Gradient KPI cards:** identical hex in both bundles (white text).
|
||||
- **Page background, section cards, borders, body/heading text, muted text:** light vs dark hex chosen via `@if $o-webclient-color-scheme == dark { … !global }`, exposed through CSS custom properties (e.g. `--fclk-dash-page`, `--fclk-dash-card`, `--fclk-dash-border`, `--fclk-dash-text`, `--fclk-dash-muted`) following the repo `_tokens` pattern. Three-layer contrast: page (grayest) → section card → KPI card (brightest).
|
||||
|
||||
## 4. Data Contract
|
||||
|
||||
Single endpoint, reworked: **`POST /fusion_clock/dashboard_data`** (`type='jsonrpc'`, `auth='user'`). Gate changes from manager/lead-only to **any** `group_fusion_clock_user`. Response:
|
||||
|
||||
```python
|
||||
{
|
||||
"role": "employee" | "team_lead" | "manager",
|
||||
"personal": {
|
||||
"employee_name": str,
|
||||
"enable_clock": bool,
|
||||
"is_checked_in": bool,
|
||||
"check_in": str | False, # ISO, when checked in
|
||||
"location_name": str,
|
||||
"pending_reason": bool, # owes an auto-clock-out explanation
|
||||
"today_hours": float, # sum x_fclk_net_hours today
|
||||
"week_hours": float, # sum x_fclk_net_hours this week
|
||||
"overtime_week": float, # employee.x_fclk_overtime_this_week
|
||||
"ontime_streak": int, # employee.x_fclk_ontime_streak
|
||||
"shift": { # from employee._get_fclk_day_plan(local_today)
|
||||
"label": str, # "7:00 AM – 3:30 PM" or ""
|
||||
"hours": float,
|
||||
"source": "schedule"|"shift"|"none",
|
||||
"scheduled_off": bool,
|
||||
"status_note": str # "On time", "Late", "Not scheduled today", "Clock disabled"
|
||||
},
|
||||
"recent_activity": [ # last ~6 closed attendances
|
||||
{"check_in": str, "check_out": str, "worked_hours": float,
|
||||
"overtime_hours": float, "location": str}
|
||||
],
|
||||
"leaves": [ # own, leave_date >= today, soonest first, ~5
|
||||
{"label": str, "state": str} # label via _fclk_date_label()
|
||||
],
|
||||
"penalties": [ # own, current month, recent first, ~5
|
||||
{"type": str, "date": str, "minutes": float}
|
||||
]
|
||||
},
|
||||
"team": null | { # present ONLY for team_lead / manager
|
||||
"scope": "team" | "org",
|
||||
"total_employees": int,
|
||||
"present_count": int, # distinct employees with an attendance today
|
||||
"on_leave_count": int, # approved leave covering today (leave_date <= today <= date_to)
|
||||
"absent_count": int, # genuine no-shows = total - present - on_leave (matches absence cron)
|
||||
"late_count": int, # late_clock_in logs today, scoped
|
||||
"pending_reasons": int, # scoped (owe an auto-clock-out explanation)
|
||||
"pending_approvals": int, # scoped: fusion.clock.correction state='pending'
|
||||
# (leaves are auto-approved — nothing to approve)
|
||||
"clocked_in": [
|
||||
{"employee": str, "check_in": str, "location": str, "late": bool}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation note:** factor two private helpers on the controller — `_dashboard_personal(employee)` (builds the `personal` block above; reuses the same per-employee computations the existing `get_status` already performs for today_hours / week_hours / streak / shift / recent_activity) and `_dashboard_team(emp_ids, scope)` (extracted from the existing `dashboard_data` aggregate logic). `get_status` keeps its **current public response keys unchanged** (the portal `/my/clock` consumes them) — share computation via a small internal helper if convenient, but do not alter `get_status`'s output contract. The public endpoint resolves role → builds `personal` always → builds `team` only for lead/manager. Team/org reads use `sudo()` but are constrained to the server-computed `emp_ids`; personal reads use the caller's own employee. No client input selects scope.
|
||||
|
||||
## 5. Files Touched
|
||||
|
||||
- `controllers/clock_api.py` — rework `dashboard_data`; add `_dashboard_personal` + `_dashboard_team`; `get_status` refactored to reuse `_dashboard_personal` (no behavioural change to the portal).
|
||||
- `static/src/js/fusion_clock_dashboard.js` — state holds `role` / `personal` / `team`; conditional render; action handlers: `onOpenClock` (act_url `/my/clock`), `onRequestLeave`/`onRequestCorrection` (act_url to portal), `onViewTimesheets`, plus existing `onViewAttendances`/`onViewCorrections`/`onViewActivityLogs`/`onViewPenalties`, and manager-only `onViewReports`/`onViewShiftPlanner`. Header is **status display + "Open My Clock"** button — clocking itself stays in the existing systray widget / portal (we do not re-implement the clock flow here).
|
||||
- `static/src/xml/fusion_clock_dashboard.xml` — full rewrite to the stacked layout with `t-if="state.team"` gating the team band.
|
||||
- `static/src/scss/fusion_clock.scss` — replace the `.fclk-dash-card*` block with gradient KPI cards + stacked layout + section-card tokens; add compile-time dark branching; delete the runtime `.o_dark_mode` dash block.
|
||||
- `views/clock_menus.xml` — Dashboard `menuitem` groups: `group_fusion_clock_manager,group_fusion_clock_team_lead` → **`group_fusion_clock_user`**.
|
||||
- `__manifest__.py` — version bump (3.13.2 → 3.14.0) to rebuild asset bundles.
|
||||
- `tests/test_dashboard.py` — **new**, permission-focused.
|
||||
|
||||
## 6. Error Handling & Edge Cases
|
||||
|
||||
- **No employee record** for the user → `{"error": "No employee profile is linked to your account."}`; client shows a friendly empty state (not a raw error).
|
||||
- **`x_fclk_enable_clock = False`** → dashboard still renders; shift card `status_note = "Clock disabled"`, KPIs show 0/own values; no team band unless lead/manager.
|
||||
- **Not scheduled / day off today** → shift card shows "Not scheduled today" (ties into the already-shipped schedule-driven resolver `_get_fclk_day_plan`). This is also why we never nag — consistent with the schedule-driven attendance work.
|
||||
- **Team lead with no direct reports** → `team` present, roster empty, counts 0, friendly "No direct reports yet."
|
||||
- **Manager with employees but none clocked in** → roster empty state "No one is clocked in right now."
|
||||
|
||||
## 7. Testing
|
||||
|
||||
`tests/test_dashboard.py`, tagged `@tagged('-at_install','post_install','fusion_clock')`. Create a manager, a team lead, two direct reports of that lead, and one unrelated employee; give each enabled clock + an attendance.
|
||||
|
||||
- **Employee payload** → `role == 'employee'`, `team is None`, `personal.employee_name` is their own, and the payload contains **no** other employee's name (assert the unrelated employee's name is absent anywhere in the JSON).
|
||||
- **Team lead payload** → `role == 'team_lead'`, `team.scope == 'team'`, roster/counts include **only** the two direct reports, exclude the unrelated employee and the manager.
|
||||
- **Manager payload** → `role == 'manager'`, `team.scope == 'org'`, counts cover all enabled employees.
|
||||
- **Personal stats** → today_hours / week_hours / streak / shift label reflect the caller's own records.
|
||||
- **No-employee user** → returns the `error` key, not a traceback.
|
||||
|
||||
Run: `docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_clock -u fusion_clock --stop-after-init --http-port=0 --gevent-port=0`.
|
||||
|
||||
## 8. Out of Scope (YAGNI)
|
||||
|
||||
Charts/trend graphs, date-range pickers, CSV export from the dashboard, websocket/live auto-refresh (the manual Refresh button stays), user-configurable card order/favourites, and any new approval workflow (leads still can't approve from here). These can be added later if asked.
|
||||
|
||||
## 9. Deployment
|
||||
|
||||
Standard entech path after local test: bump version (done in §5), `git commit --only -- <explicit dashboard paths>` (shared working tree), push to **both** `origin` and `gitea`, then upgrade entech (`pct exec 111` native `odoo.service`, DB `admin`, `--http-port=0 --gevent-port=0`). Asset bundle rebuilds on version bump; hard-refresh / clear iOS website data to bust cache.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Fusion Clock — PIN Kiosk Design
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Module:** `fusion_clock`
|
||||
**Status:** Approved (brainstorming) — ready for implementation plan
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem / goal
|
||||
|
||||
The module has a premium **NFC kiosk** but only a bare-Bootstrap **classic PIN kiosk** (`/fusion_clock/kiosk`) with no logo, gradient, or polish, reachable only by direct URL. We want a properly designed, **opt-in PIN kiosk** as an additional feature for clients who want simple PIN entry instead of NFC — matching the NFC kiosk's look and quality. This makes the existing `enable_kiosk` setting meaningful (it becomes this feature's on/off).
|
||||
|
||||
## 2. Decisions (from brainstorming)
|
||||
|
||||
- **Flow:** photo-tile grid (with search) → tap your tile → enter PIN → clock. (Not PIN-as-identifier; not name-search.)
|
||||
- **PIN always required.** Drop the `kiosk_pin_required` toggle.
|
||||
- **Set PIN on first use:** an employee with no PIN is walked through creating + confirming one on first tap.
|
||||
- **Selfie respects the global Photo Verification master toggle** (`enable_photo_verification`): ON → guided selfie after PIN; OFF → none.
|
||||
- **Style:** always-dark glass + brand-hue mesh gradient, logo pill, live clock/date — matching the NFC kiosk.
|
||||
|
||||
## 3. Design
|
||||
|
||||
### 3.1 Screens / flow
|
||||
1. **Grid** (`#pin_kiosk_root`): company logo pill (top-centre), live clock + date, a search box, and a responsive grid of employee **photo tiles** (avatar + name) over the animated brand-tinted mesh gradient. Bottom chrome: location pill (left), operator ⚙ + lock (right).
|
||||
2. **Tap a tile** → **PIN pad**: glass panel with the person's avatar + name, masked PIN dots, a big touch numpad (`⌫ 0 ✓`), Cancel.
|
||||
- If the employee **has no PIN** → **first-use setup**: "Create a PIN" → enter → "Re-enter to confirm" → saved, then proceed to clock.
|
||||
- Wrong PIN → shake + clear + retry (max 3 attempts, then back to grid).
|
||||
3. **Selfie** (only if `enable_photo_verification` master is ON) → guided capture with the oval face-guide + countdown (reuse the NFC kiosk's capture).
|
||||
4. **Result**: green-glow success card (avatar, name, "Clocked In/Out", time · location), auto-returns to the grid after ~3 s. Error → red shake card.
|
||||
|
||||
### 3.2 Style
|
||||
New `static/src/scss/pin_kiosk.scss`, **scoped to `:has(#pin_kiosk_root)`** (never leaks to other pages — same discipline as `nfc_kiosk.scss`). Mirrors the NFC kiosk's tokens/patterns: dark page, animated mesh `::before`, vignette `::after`, frosted logo pill, clock/date, `%glass` panels, numpad, result card (success/error), photo panel + oval guide, reduced-motion fallback. Brand hue in its **own** CSS var `--pk-h` (don't collide with `--nfc-h`); deliberately a parallel file, not shared, to avoid coupling the two kiosks.
|
||||
|
||||
### 3.3 Backend — rework `controllers/clock_kiosk.py`
|
||||
All routes `auth='user'`, gated by `_is_kiosk_operator` (Clock Manager **or** Kiosk Operator group — unchanged). Page additionally gated by `enable_kiosk`.
|
||||
|
||||
- **`GET /fusion_clock/kiosk`** — render the new template. Context: `company_name`, `company_logo_url` (for display + hue extraction), `location_name`, `sounds_enabled`, `photo_required` (= `enable_photo_verification` master). Redirect to `/my` if `enable_kiosk` off or not an operator (as today).
|
||||
- **`POST /fusion_clock/kiosk/search`** (extend the existing — keep the name; the NFC kiosk's `employee_search` delegates to it) — add `avatar_url` (via `hr.employee.public`, `?unique=write_date` cache-buster) and `has_pin` (bool) to each row, alongside the current `id/name/department/is_checked_in/card_uid`.
|
||||
- **`POST /fusion_clock/kiosk/verify_pin`** (rework) — if `not employee.x_fclk_kiosk_pin` → `{'needs_setup': True}`; else compare and return `{'success': True, ...}` or `{'error': 'invalid_pin'}`.
|
||||
- **`POST /fusion_clock/kiosk/set_pin`** (NEW) — first-use: validate a 4-digit numeric PIN, reject if the employee already has one (`already_set`), else write `x_fclk_kiosk_pin` (sudo) and return success.
|
||||
- **`POST /fusion_clock/kiosk/clock`** (rework) — accept `photo_b64`. Use the **configured kiosk location** (`company.x_fclk_nfc_kiosk_location_id`) — a fixed wall device, so NO per-clock GPS geofence (matches the NFC kiosk); return `no_location_configured` if unset. Clock via `_attendance_action_change`; write source `'kiosk'`, location, logs, penalties (as today). If `enable_photo_verification` master ON and `photo_b64` present → store on `x_fclk_check_in_photo` (in) / `x_fclk_check_out_photo` (out), stripping the data-URL prefix. Unscheduled-day → `unscheduled_shift` log (as today). Module-level tap debounce (~5 s, like NFC).
|
||||
|
||||
### 3.4 Frontend — rewrite `static/src/js/fusion_clock_kiosk.js`
|
||||
Rebuild as a proper Odoo-19 **Interaction** (`@web/public/interaction`, registered in `registry.category("public.interactions")`) — not the old IIFE. State machine: `grid → pin | setup → (photo) → result → grid`. Reuse the NFC kiosk's **dominant-hue extraction** (set `--pk-h` from the logo) and **guided photo capture** (camera + oval guide + countdown) — replicate those helpers cleanly in this file (parallel, not imported, to keep the two kiosks decoupled). Search filters the tile grid client-side; tile tap loads the PIN/setup panel; numpad drives the dots; success plays a sound if `sounds_enabled`.
|
||||
|
||||
### 3.5 Template — rebuild `views/kiosk_templates.xml`
|
||||
Root `<div id="pin_kiosk_root" class="pin-kiosk">` inside `web.frontend_layout` (no header/footer), carrying `data-*` for `company_logo_url`, `location_name`, `sounds_enabled`, `photo_required`. Contains the logo pill, clock/date, search, a grid container, and a JS-driven `#pin_state_container` (mirroring the NFC `#nfc_state_container`).
|
||||
|
||||
### 3.6 Access, settings, menu
|
||||
- **Operator account:** runs as the shared **Kiosk Operator** (`group_fusion_clock_kiosk_app`) or a manager — same as NFC. Screen lock + operator ⚙ for parity.
|
||||
- **App icon:** add a **"Fusion Clock PIN Kiosk"** `ir.actions.act_url` → `/fusion_clock/kiosk` + a `menuitem` gated to `group_fusion_clock_kiosk_app` (parallel to the NFC app icon). Clients enable `enable_kiosk` and point the tablet at it.
|
||||
- **Settings:** `enable_kiosk` is the on/off (kept). **Remove `kiosk_pin_required`** (field + view row + seed + `_FCLK_BOOL_PARAMS` entry) — PIN is always required. The **kiosk location** reuses `res.company.x_fclk_nfc_kiosk_location_id`; relabel its field string to **"Kiosk Location"** (it now serves NFC + PIN) and note in help it applies to both.
|
||||
- **PIN storage:** existing `hr.employee.x_fclk_kiosk_pin` (Char, 4-digit, manager-editable, server-verified). Kept plaintext to match the existing field and keep it simple; hashing noted as optional future hardening (it's a low-stakes attribution PIN, manager-only field, never sent to other clients).
|
||||
|
||||
## 4. Reuse / dependencies
|
||||
- Keep `FusionClockKiosk.kiosk_search` working — the NFC kiosk's `employee_search` delegates to it (`clock_nfc_kiosk.py:409`).
|
||||
- Reuse (replicate) the NFC kiosk's hue extraction (`extractDominantHue`/`applyBrandHue`) and photo capture.
|
||||
- Reuse `hr.employee.public` for avatars (kiosk operator can't read `hr.employee` images — established for the NFC kiosk).
|
||||
- Reuse the master photo gate added in `19.0.3.16.1`.
|
||||
|
||||
## 5. Edge cases
|
||||
- **No PIN** → first-use setup (enter + confirm; `set_pin`).
|
||||
- **Wrong PIN** → shake + retry, max 3 → back to grid.
|
||||
- **Employee not clock-enabled** → not listed; defensive re-check in `clock`.
|
||||
- **No kiosk location configured** → `no_location_configured` message.
|
||||
- **Photo master OFF** → skip the selfie step entirely; clock directly.
|
||||
- **Unscheduled day** → `unscheduled_shift` activity log (parity with NFC).
|
||||
- **Double-tap** → debounced.
|
||||
|
||||
## 6. Testing (`tests/test_clock_kiosk.py`, `@tagged('-at_install','post_install','fusion_clock')`)
|
||||
- `search` returns `avatar_url` + `has_pin`; only clock-enabled employees.
|
||||
- `verify_pin`: correct PIN → success; wrong → `invalid_pin`; no PIN → `needs_setup`.
|
||||
- `set_pin`: sets a 4-digit PIN; rejects non-numeric / wrong length; rejects when one already exists (`already_set`).
|
||||
- `clock`: clock-in then clock-out (source `'kiosk'`, kiosk location); photo stored only when master ON and `photo_b64` present, not when OFF; `no_location_configured` when unset.
|
||||
- Page redirects when `enable_kiosk` off / non-operator.
|
||||
|
||||
## 7. Out of scope (YAGNI)
|
||||
Offline mode; switching locations on one device; PIN hashing (noted as future); removing or changing the NFC kiosk; multi-company kiosk selection.
|
||||
|
||||
## 8. Files touched
|
||||
- Modify: `controllers/clock_kiosk.py`, `views/kiosk_templates.xml`, `static/src/js/fusion_clock_kiosk.js`, `models/res_config_settings.py` (drop `kiosk_pin_required`), `views/res_config_settings_views.xml` (drop row, relabel location), `data/ir_config_parameter_data.xml` (drop seed), `models/res_company.py` (relabel field string), `views/clock_menus.xml` (PIN kiosk app icon), `__manifest__.py` (register `pin_kiosk.scss` + version bump), `fusion_clock/CLAUDE.md` (kiosk section + settings keys).
|
||||
- Create: `static/src/scss/pin_kiosk.scss`, `tests/test_clock_kiosk.py`.
|
||||
|
||||
## 9. Deployment
|
||||
Local test on the dev container, then the standard entech path: bump version, `git commit --only` explicit paths, push **origin + gitea**, upgrade entech, verify web 200 + version + (read-only) the page renders for the operator. Bump version so the new SCSS/JS bundle rebuilds; hard-refresh the tablet.
|
||||
24
fusion_clock/migrations/19.0.3.12.1/post-migrate.py
Normal file
24
fusion_clock/migrations/19.0.3.12.1/post-migrate.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Backfill schedule state on upgrade to 19.0.3.12.0.
|
||||
|
||||
Before this version there was no draft/posted concept — every dated
|
||||
``fusion.clock.schedule`` entry was authoritative and drove reminders, absence
|
||||
checks and penalties. The new ``state`` field defaults to 'draft', and the
|
||||
schedule resolver now only acts on POSTED entries. Without this backfill, every
|
||||
pre-existing schedule entry would silently become draft on upgrade and stop
|
||||
driving automation. Mark all pre-existing entries 'posted' to preserve prior
|
||||
behaviour. (Runs only on upgrade, never on a fresh install.)
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return
|
||||
cr.execute("""
|
||||
UPDATE fusion_clock_schedule
|
||||
SET state = 'posted',
|
||||
posted_date = COALESCE(posted_date, now())
|
||||
WHERE state IS NULL OR state = 'draft'
|
||||
""")
|
||||
17
fusion_clock/migrations/19.0.3.13.0/post-migrate.py
Normal file
17
fusion_clock/migrations/19.0.3.13.0/post-migrate.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
"""Backfill leave-request end dates on upgrade to 19.0.3.13.0.
|
||||
|
||||
Leave requests gained a `date_to` (end of a multi-day range). Existing
|
||||
single-day requests have no end date; set it to the start date so they keep
|
||||
being treated as one-day leaves by the absence check and reports.
|
||||
"""
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
if not version:
|
||||
return
|
||||
cr.execute(
|
||||
"UPDATE fusion_clock_leave_request SET date_to = leave_date WHERE date_to IS NULL"
|
||||
)
|
||||
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])
|
||||
@@ -1,9 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import pay_period
|
||||
from . import clock_location
|
||||
from . import hr_attendance
|
||||
from . import hr_employee
|
||||
from . import clock_penalty
|
||||
from . import clock_break_rule
|
||||
from . import clock_report
|
||||
from . import res_config_settings
|
||||
from . import clock_activity_log
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user