Compare commits
14 Commits
main
...
d7bbeb49b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7bbeb49b7 | ||
|
|
2737bc481c | ||
|
|
0e595e6129 | ||
|
|
a0f783ab14 | ||
|
|
82a13b2ce5 | ||
|
|
0230670bdc | ||
|
|
86e89ca419 | ||
|
|
749c0335fa | ||
|
|
092423d7de | ||
|
|
9c52fac9ba | ||
|
|
d2f8934a53 | ||
|
|
113427f7e2 | ||
|
|
3559eb1fd5 | ||
|
|
9f28dce160 |
18
CLAUDE.md
18
CLAUDE.md
@@ -83,24 +83,6 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
||||
- Local URL: http://localhost:8069
|
||||
- Test before deploying. Edit existing files — don't create unnecessary new ones.
|
||||
|
||||
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab
|
||||
When a Python action opens an attachment, route it through `fusion_pdf_preview` instead of returning `ir.actions.act_url` with `download=true` or `target=new`. The preview dialog gives operators preview + print + download in one place and writes an audit log; non-PDF attachments fall back to the legacy download path automatically.
|
||||
|
||||
The drop-in replacement is the new helper on `ir.attachment`:
|
||||
```python
|
||||
return att.action_fusion_preview(title='My Doc')
|
||||
# vs. the old pattern:
|
||||
# return {'type': 'ir.actions.act_url',
|
||||
# 'url': '/web/content/%s?download=true' % att.id,
|
||||
# 'target': 'new'}
|
||||
```
|
||||
|
||||
The helper auto-detects mimetype: PDFs go to the dialog, everything else (ZPL, CSV, XML, images) stays on download. So a callsite that today serves CSV today and a PDF tomorrow doesn't need a code change — same call, different routing.
|
||||
|
||||
If you need to invoke the client action directly (rare — only when you don't have a recordset handy), the tag is `fusion_pdf_preview.open_attachment` and the params are `{attachment_id, title, model_name, record_ids, report_name}`. See `fusion_pdf_preview/static/src/js/open_attachment_action.js`.
|
||||
|
||||
Existing reports (`ir.actions.report` of type `qweb-pdf`) are intercepted automatically by `fusion_pdf_preview/static/src/js/pdf_preview.js`; the helper above is for the *other* pattern — attachments opened by custom buttons.
|
||||
|
||||
## Supabase Knowledge Base
|
||||
Before starting unfamiliar work, check Supabase for context:
|
||||
```bash
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,300 +0,0 @@
|
||||
# NFC Clock Kiosk — Design
|
||||
|
||||
**Date:** 2026-05-13
|
||||
**Module:** `fusion_clock`
|
||||
**Status:** Approved design — pending implementation plan
|
||||
**Pilot scope:** 1 station per company
|
||||
|
||||
## Problem
|
||||
|
||||
`fusion_clock` already supports shared-device clock-in/out via a PIN kiosk at `/fusion_clock/kiosk`. Shop-floor employees find name search + PIN entry slow, and shared PINs make buddy-punching trivial. The company is rolling out Ubiquiti UniFi Access NFC readers for door entry, so every employee already carries an NFC card. We want a "tap-and-go" kiosk that:
|
||||
|
||||
- Takes ~2 seconds (vs ~10 seconds for name search + PIN)
|
||||
- Reuses the same physical Ubiquiti-issued card the employee uses for doors
|
||||
- Works with gloves, dirty hands, or wet hands (touchscreens fail here)
|
||||
- Captures a silent photo at every tap so managers can spot-check buddy-punching attempts
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Tap-to-clock**: NFC card tap on a wall-mounted Android tablet → attendance state toggles in Odoo within ~1 second of the tap
|
||||
2. **Single-credential**: same card the employee uses for door access also clocks them in
|
||||
3. **Silent photo verification**: front camera snaps a frame on every tap; manager dashboard shows photos for spot-check
|
||||
4. **Self-contained kiosk**: lockable into a single-purpose device, no escape, auto-restart on crash, no Odoo navbar visible
|
||||
5. **Reuses existing fusion_clock backend**: geofencing, penalty rules, activity log, attendance lifecycle — all unchanged
|
||||
6. **One-time setup**: enroll once, then employees never touch a setup flow again
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Multi-station / multi-zone clocking (future — pilot is 1 station per company)
|
||||
- Per-station geolocation (one location per company; tablet is implicitly at the company location)
|
||||
- Offline mode (v1 fails loudly on network loss; offline replay is future work)
|
||||
- Phone-as-credential support (NFC HCE on Android is fragile; iPhone NFC is closed)
|
||||
- QR code alternate credential (deferred to v1.1 if iPhone-only employees push back)
|
||||
- Native Android kiosk app (overkill for a 1-2 station pilot; Web NFC is sufficient)
|
||||
|
||||
## Architecture decision
|
||||
|
||||
**Option B: Separate kiosk page, shared backend.**
|
||||
|
||||
A new route `/fusion_clock/kiosk/nfc` and a new lean template optimized for tap-and-go. The new controller (`controllers/clock_nfc_kiosk.py`) calls into the existing `FusionClockAPI` helpers (`_verify_location`, `_attendance_action_change`, `_log_activity`, `_check_and_create_penalty`, `_apply_break_deduction`) so all geofencing/penalty/activity logic is shared with the PIN kiosk. The existing `/fusion_clock/kiosk` route is untouched.
|
||||
|
||||
**Why not extend the existing kiosk (Option A):** existing PIN kiosk page would get tap-mode JS interleaved with PIN-mode JS, increasing the regression surface for both modes.
|
||||
|
||||
**Why not native Android app (Option C):** maintaining a Kotlin app + Play Console signing/distribution doubles the dev effort for marginal UX gain. Web NFC + Chrome kiosk is production-proven (gyms, warehouses, healthcare check-in).
|
||||
|
||||
## Hardware decision
|
||||
|
||||
**Per company:** 1× Samsung Galaxy Tab Active 5 Pro (10.1") on an official Samsung Pogo charging dock, wall-mounted. Reasoning:
|
||||
|
||||
- Built-in NFC antenna on the back, dead-center
|
||||
- IP68, MIL-STD-810H, drop-resistant (shop-floor durable)
|
||||
- Replaceable battery (avoids battery-swelling failure mode in 24/7-tethered devices)
|
||||
- Knox enables true kiosk lockdown
|
||||
- Pogo dock = magnetic constant power, no cable to yank
|
||||
- 10.1" screen visible from a few feet away (vs 8" on regular Active 5)
|
||||
|
||||
Cards: same Ubiquiti-issued NFC cards employees already carry. Web NFC reads the card's UID via `NDEFReader`'s `serialNumber` field, which works on raw MIFARE access cards even though they have no NDEF data.
|
||||
|
||||
## Data model
|
||||
|
||||
### `hr.employee` — new field
|
||||
- `x_fclk_nfc_card_uid` — `Char`, indexed, unique constraint when not null
|
||||
- Stores card UID as canonical hex (uppercase, colon-separated, MSB first), e.g., `04:A2:B5:62:C1:80`
|
||||
- Editable by HR managers; visible on the employee form in the existing "Clock Settings" section near the existing PIN field
|
||||
|
||||
### `res.company` — new field
|
||||
- `x_fclk_nfc_kiosk_location_id` — `Many2one` to `fusion.clock.location`
|
||||
- Designates which fusion.clock.location is bound to the NFC kiosk for this company
|
||||
- Required when `fusion_clock.enable_nfc_kiosk = True`; the tap endpoint returns `no_location_configured` if it's empty
|
||||
- Editable in the NFC Clock Kiosk settings section (per-company since this is multi-company-aware)
|
||||
|
||||
### `hr.attendance` — new fields
|
||||
- `x_fclk_check_in_photo` — `Binary`, `attachment=True`. Frame captured at clock-in.
|
||||
- `x_fclk_check_out_photo` — `Binary`, `attachment=True`. Frame captured at clock-out.
|
||||
- `x_fclk_clock_source` — extend existing `Selection` field to include `'nfc_kiosk'`.
|
||||
|
||||
### `ir.config_parameter` — new entries
|
||||
- `fusion_clock.enable_nfc_kiosk` — Boolean, default `False`. Master switch.
|
||||
- `fusion_clock.nfc_photo_required` — Boolean, default `True`. If False, photo is best-effort and tap still succeeds without one.
|
||||
- `fusion_clock.nfc_enroll_password` — Char, default empty. Short password the manager types to enter Enroll Mode on the kiosk. If empty, falls back to manager-group membership of the kiosk service user.
|
||||
- `fusion_clock.nfc_kiosk_debug` — Boolean, default `False`. Enables a hidden mock-tap keyboard shortcut for development.
|
||||
|
||||
### `res.config.settings` — new view section
|
||||
"NFC Clock Kiosk" section in the Clock settings page exposing the four `ir.config_parameter` toggles above.
|
||||
|
||||
**No new models.** All data piggybacks on existing `hr.employee`, `hr.attendance`, `fusion.clock.activity.log`.
|
||||
|
||||
## Backend — controller and endpoints
|
||||
|
||||
**New file:** `controllers/clock_nfc_kiosk.py`
|
||||
|
||||
All endpoints under `/fusion_clock/kiosk/nfc/...`. All require `fusion_clock.group_fusion_clock_manager` on the logged-in kiosk service user. All gated on `fusion_clock.enable_nfc_kiosk == 'True'`.
|
||||
|
||||
**Kiosk service user:** an Odoo `res.users` record created per-company specifically for the tablet to log in as. Member of `fusion_clock.group_fusion_clock_manager`. Long random password stored in the tablet's saved-credentials. Distinct from any human user so its session can be revoked independently if the tablet is stolen. Setup is documented in the provisioning script below; no new code creates this user (it's a manual one-time creation in HR Settings).
|
||||
|
||||
### `GET /fusion_clock/kiosk/nfc` — page render
|
||||
- Renders the NFC kiosk QWeb template
|
||||
- Resolves the kiosk's location from `request.env.company.x_fclk_nfc_kiosk_location_id` and passes its name to the template for display ("Clock at: Westin Plant 1")
|
||||
- Returns redirect to `/my` if the kiosk is disabled or the user lacks the manager group
|
||||
|
||||
### `POST /fusion_clock/kiosk/nfc/tap` — clock toggle
|
||||
- `type='jsonrpc'`, `auth='user'`
|
||||
- Input: `{ card_uid: "04:A2:B5:62:C1:80", photo_b64: "data:image/jpeg;base64,..." (optional) }`
|
||||
- Logic:
|
||||
1. Normalize UID (uppercase, colon-separated, reject malformed input)
|
||||
2. Lookup `hr.employee` by `x_fclk_nfc_card_uid` (sudo). Not found → `{error: "card_unknown", message: "Card not enrolled"}`. Log to `fusion.clock.activity.log` with the unknown UID.
|
||||
3. If `x_fclk_enable_clock` is False → `{error: "clock_disabled"}`
|
||||
4. Resolve location from `request.env.company.x_fclk_nfc_kiosk_location_id`. If empty → `{error: "no_location_configured"}`
|
||||
5. Server-side debounce: if same UID was tapped within the last 5 seconds, return `{error: "debounce"}` silently
|
||||
6. Call `FusionClockAPI._attendance_action_change(geo_info)` with `geo_info = { browser: 'nfc_kiosk', ip_address: <remote_addr>, latitude: 0, longitude: 0 }` to toggle attendance state
|
||||
7. Write `x_fclk_clock_source = 'nfc_kiosk'`, `x_fclk_location_id = <resolved>`, distance fields = 0
|
||||
8. If `photo_b64` present, decode and save to `x_fclk_check_in_photo` (clock-in) or `x_fclk_check_out_photo` (clock-out)
|
||||
9. If `nfc_photo_required = True` and photo is missing/decode-failed → reject the tap with `{error: "photo_required"}`
|
||||
10. Reuse `_check_and_create_penalty`, `_apply_break_deduction`, `_log_activity` calls (same as PIN kiosk)
|
||||
11. Return `{ success: true, action: 'clock_in' | 'clock_out', employee_name, employee_avatar_url, message, net_hours_today }`
|
||||
|
||||
### `POST /fusion_clock/kiosk/nfc/enroll` — card enrollment
|
||||
- `type='jsonrpc'`, `auth='user'`
|
||||
- Input: `{ employee_id: 42, card_uid: "04:A2:B5:62:C1:80", enroll_password: "1234" }`
|
||||
- Logic:
|
||||
1. Verify `enroll_password` matches `fusion_clock.nfc_enroll_password` (or accept if config is empty AND caller is in manager group)
|
||||
2. Normalize UID
|
||||
3. Check no other employee has this UID → `{error: "card_already_assigned", existing_employee: "<name>"}`
|
||||
4. Write `x_fclk_nfc_card_uid` on the target employee
|
||||
5. Log to `fusion.clock.activity.log` ("Manager X enrolled card UID Y to employee Z")
|
||||
6. Return `{ success: true, employee_name, card_uid }`
|
||||
|
||||
### `POST /fusion_clock/kiosk/nfc/employee_search` — pick employee for enroll
|
||||
- Reuses the existing `/fusion_clock/kiosk/search` controller method by importing it; does not duplicate logic.
|
||||
|
||||
## Frontend — kiosk page UX
|
||||
|
||||
**Files:**
|
||||
- `views/kiosk_nfc_templates.xml` — QWeb template for the page
|
||||
- `static/src/js/fusion_clock_nfc_kiosk.js` — Web NFC + camera + state machine
|
||||
- `static/src/css/nfc_kiosk.css` — high-contrast shop-floor styling (always dark)
|
||||
|
||||
**Visual:** always-dark, high-contrast, no Odoo navbar. Shop-floor lighting washes out light backgrounds.
|
||||
|
||||
### State machine
|
||||
|
||||
```
|
||||
┌─── (3s timeout) ─────────────────────────┐
|
||||
▼ │
|
||||
┌─────────────────────────┐ tap detected ┌────────────────────┐
|
||||
│ IDLE │ ────────────────► │ PROCESSING │
|
||||
│ "Tap card to clock │ │ spinner, "Reading"│
|
||||
│ in or out" │ └────────────────────┘
|
||||
│ big clock, date, │ │
|
||||
│ company name │ success / error
|
||||
└─────────────────────────┘ ▼
|
||||
▲ ┌─────────────────────────┐
|
||||
│ │ RESULT │
|
||||
│ │ green: "Welcome John, │
|
||||
└─── (3s) ──────────────────│ CLOCKED IN, 8:02 AM" │
|
||||
│ red: "Card not │
|
||||
│ enrolled" │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
### IDLE state
|
||||
- Top: company name + current time (HH:MM, updates every second) + date
|
||||
- Center: large NFC icon + "Tap your card to clock in or out", subtle pulse animation
|
||||
- Bottom-right corner: tiny "⚙" icon (gateway to Enroll Mode)
|
||||
|
||||
### PROCESSING state
|
||||
- Brief spinner + "Reading card…"
|
||||
- Mostly imperceptible at typical network latency
|
||||
|
||||
### RESULT state — success
|
||||
- Green panel
|
||||
- Large employee avatar on the left
|
||||
- "John Smith" — name in big text
|
||||
- "CLOCKED IN at 8:02 AM" or "CLOCKED OUT — 8.1h today"
|
||||
- Auto-return to IDLE after 3s
|
||||
|
||||
### RESULT state — error
|
||||
- Red panel
|
||||
- `card_unknown` → "Card not recognized. See your manager."
|
||||
- `network_error` → "No connection. Please try again."
|
||||
- `debounce` → silent (no UI change to avoid double-tap confusion)
|
||||
- `photo_required` → "Camera unavailable. Ask IT to check the kiosk."
|
||||
- Auto-return to IDLE after 4s
|
||||
|
||||
### Web NFC implementation
|
||||
- One-time activation button on first page load: "Tap here to enable NFC reader" (Web NFC requires a user gesture before `scan()` is permitted)
|
||||
- After activation, `NDEFReader.scan()` runs continuously
|
||||
- `reading` event fires for any tap; we extract `event.serialNumber` (works for raw MIFARE access cards even with no NDEF data)
|
||||
- UID format: hex bytes joined by colons, uppercased
|
||||
- If `scan()` throws, restart with a 1-second backoff
|
||||
|
||||
### Camera implementation
|
||||
- `getUserMedia({ video: { facingMode: 'user' } })` activated alongside NFC
|
||||
- Hidden `<video>` element streams continuously
|
||||
- On tap, grab one frame to a `<canvas>`, encode as JPEG quality 0.7 (~30–60 KB), POST as base64 in the same JSON payload as the UID
|
||||
- If `nfc_photo_required = True` and camera is unavailable → tap is rejected ("Camera unavailable") rather than silently degrading
|
||||
|
||||
### Enroll Mode
|
||||
- Tap the bottom-right "⚙" → on-screen numpad password entry → match against `fusion_clock.nfc_enroll_password` → enter Enroll Mode
|
||||
- Enroll Mode UI:
|
||||
1. Search input → employee list (uses `/fusion_clock/kiosk/nfc/employee_search`)
|
||||
2. Manager picks employee → "Now tap John Smith's card on the back of the tablet"
|
||||
3. Tap detected → POST to `/enroll` → "✓ Card 04:A2:B5:62:C1:80 enrolled to John Smith. Enroll another?"
|
||||
4. "Done" button → exit Enroll Mode → back to IDLE
|
||||
- 60-second inactivity timeout in Enroll Mode → auto-exit to IDLE (so an unattended kiosk doesn't stay open in admin mode)
|
||||
|
||||
### One-time setup flow (first load on a new tablet)
|
||||
1. "Welcome to Fusion Clock NFC Kiosk." — large tap-to-continue button (this gesture activates Web NFC)
|
||||
2. Browser permission prompts: NFC, then Camera. Page text guides the manager through each.
|
||||
3. Test prompt: "Tap any card to verify reader is working" → shows the UID detected → "Reader OK ✓"
|
||||
4. "Setup complete." → enters IDLE
|
||||
- After setup, page auto-resumes IDLE on every reload (Web NFC permission is sticky per origin, so no re-prompts)
|
||||
|
||||
### Mock-tap debug mode
|
||||
- Gated by `fusion_clock.nfc_kiosk_debug = True`
|
||||
- When enabled, hidden keyboard shortcut `Ctrl+Shift+T` fires a mock tap with a configurable UID stored in localStorage
|
||||
- Off in production; useful for dev iteration on the UI state machine without hardware, and for support troubleshooting
|
||||
|
||||
## Edge cases & failure modes
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| Card not enrolled | Red screen "Card not recognized. See your manager." Activity logged with the unknown UID. No attendance change. |
|
||||
| Employee disabled (`x_fclk_enable_clock=False`) | "Clock disabled for this account." Activity logged. |
|
||||
| Card lost/damaged | Manager opens employee form, clears `x_fclk_nfc_card_uid`, issues new card, re-enrolls via kiosk Enroll Mode. |
|
||||
| Card already assigned during enroll | "This card is already assigned to Jane Doe. Unenroll first." No silent overwrite. |
|
||||
| Tablet offline / WiFi drops | Fail loudly: "No connection. Use the portal on your phone." No local cache in v1. |
|
||||
| Same card tapped twice within 5s | Server-side debounce. Second tap silently ignored. |
|
||||
| MIFARE clone attack | UIDs can be cloned with cheap hardware. Mitigation = the photo. Manager dashboard surfaces photos for spot-check. Cards alone are not treated as secure. |
|
||||
| Tablet stolen | Knox remote wipe + revoke kiosk service user credentials in Odoo (instantly invalidates that tablet's session). |
|
||||
| Power outage | Tab Active battery covers brief outages. Full reboot → Chrome+Fully Kiosk auto-launch the kiosk URL. Setup is sticky → goes straight to IDLE. |
|
||||
| Tablet clock drift | Irrelevant. All timestamps come from `fields.Datetime.now()` server-side. Tablet clock is for display only. |
|
||||
| UID format mismatch (Ubiquiti vs Web NFC byte order) | Normalize on the server: uppercase, colon-separated, MSB first. Reject malformed UIDs at the endpoint. |
|
||||
| Camera unavailable while `nfc_photo_required=True` | Tap rejected with "Camera unavailable" — forces a real fix instead of silent degradation. |
|
||||
|
||||
## Hardware checklist (per company)
|
||||
|
||||
- Samsung Galaxy Tab Active 5 Pro (10.1") — ~$700 USD
|
||||
- Samsung official Pogo charging dock — ~$100
|
||||
- Wall mount bracket compatible with Tab Active 5 Pro (The Joy Factory, Maclocks, or Heckler) — ~$80
|
||||
- USB-C 30W PSU + cable — ~$25
|
||||
- Fully Kiosk Browser commercial license (~€10 one-time) OR Samsung Knox Configure (~$30/year/device)
|
||||
- "TAP HERE" decal for the back of the tablet — DIY/printed sticker
|
||||
|
||||
**Total**: ~$915 per company, one-time.
|
||||
|
||||
## Provisioning script (one-time per tablet)
|
||||
|
||||
**Prerequisite — Odoo side (one-time per company):**
|
||||
- Create a `res.users` named e.g. `kiosk-westin@<domain>`, member of `fusion_clock.group_fusion_clock_manager`
|
||||
- Generate a long random password; store it in a password manager
|
||||
- Set `res.company.x_fclk_nfc_kiosk_location_id` for that company to the desired `fusion.clock.location`
|
||||
- Toggle `fusion_clock.enable_nfc_kiosk = True` and `fusion_clock.nfc_photo_required` per policy
|
||||
- Set `fusion_clock.nfc_enroll_password` to a 4-digit Enroll Mode password
|
||||
|
||||
**Tablet side:**
|
||||
1. Factory reset
|
||||
2. Sign in with company Google account
|
||||
3. Install Fully Kiosk Browser from Play Store
|
||||
4. In Fully Kiosk: set kiosk URL → `https://<odoo-domain>/fusion_clock/kiosk/nfc`, enable "hide bars", "auto-restart on crash", "keep screen on while charging", "auto-reload daily at 3am"
|
||||
5. Open kiosk URL once in normal Chrome → log in as the kiosk service user (saved credentials) → walk through the one-time setup flow (activate NFC, allow camera, test-tap a card)
|
||||
6. Lock tablet into kiosk mode via Fully Kiosk's "Start Kiosk" button
|
||||
7. Mount on dock
|
||||
|
||||
## Testing plan
|
||||
|
||||
### Python unit tests (`tests/test_clock_nfc_kiosk.py`)
|
||||
- Tap with valid UID → attendance toggled, photo saved, activity logged
|
||||
- Tap with unknown UID → `card_unknown` error, no attendance row
|
||||
- Tap when `x_fclk_enable_clock=False` → `clock_disabled` error
|
||||
- Double-tap same UID within 5s → second is debounced
|
||||
- Enroll with conflicting UID → `card_already_assigned`, no overwrite
|
||||
- Enroll with wrong password → 403
|
||||
- Tap with no `fusion.clock.location` configured for company → `no_location_configured`
|
||||
- UID normalization: lowercase input → stored uppercase
|
||||
|
||||
### Manual smoke tests (real tablet or Android phone for dev)
|
||||
- Cold boot → IDLE within 5s
|
||||
- Tap → RESULT within 1s
|
||||
- Photo attached to attendance record (verify in backend)
|
||||
- Enroll Mode password gate works; 60s timeout exits cleanly
|
||||
- WiFi disconnect → tap shows "No connection"; reconnect → tap works again
|
||||
- Tap own card 5x in fast succession → only one state change (debounce holds)
|
||||
|
||||
### Dev shortcut
|
||||
- Test the entire flow on any Android phone with NFC + Chrome before touching tablet hardware
|
||||
- For pre-card testing: use any contactless credit/debit card or transit pass (Web NFC reads only the UID, not card data — safe)
|
||||
- Mock-tap debug mode (`Ctrl+Shift+T`) lets the UI state machine be tested without any hardware
|
||||
|
||||
### Soak test (before declaring pilot ready)
|
||||
- 24h continuous on the dock
|
||||
- Periodic taps every few hours
|
||||
- Verify Chrome memory stable (DevTools), NFC reader still active, no zombie permissions prompts
|
||||
|
||||
## Future considerations
|
||||
|
||||
- **Offline mode** — local IndexedDB cache + replay queue when network returns. Adds complexity (conflict resolution, clock-skew handling) for marginal benefit at 1 station. Defer until pilot proves it's a real problem.
|
||||
- **Multi-station** — if a single station becomes a bottleneck at shift change, add a second tablet at the same company. No code changes needed; just provision another tablet pointing at the same URL.
|
||||
- **QR-code-on-portal alternate credential** — for iPhone-only employees who don't want to carry a card. Adds `BarcodeDetector` to the kiosk page alongside `NDEFReader`, plus a "My Clock Code" page in the portal that shows a rotating short-lived QR. Defer to v1.1.
|
||||
- **Ubiquiti webhook integration** — subscribe to UniFi Access tap events on a designated "clock door" reader so an entry tap doubles as clock-in. Saves the tablet purchase but loses the photo verification and the screen feedback. Probably not worth it but easy to add later.
|
||||
- **Native Android kiosk app** — only if the pilot scales to 50+ stations and Web NFC's quirks become operationally painful. Today, not worth it.
|
||||
@@ -1,284 +0,0 @@
|
||||
# ADP Application Received — Bundled Pages 11 & 12 (Design)
|
||||
|
||||
**Date:** 2026-05-19
|
||||
**Module:** `fusion_claims`
|
||||
**Owner:** Gurpreet
|
||||
**Status:** Approved (ready for implementation plan)
|
||||
|
||||
## Problem
|
||||
|
||||
When marking an ADP application as Received, the `Application Received` wizard requires two separate PDF uploads:
|
||||
|
||||
1. **Original ADP Application** (`x_fc_original_application`)
|
||||
2. **Signed Pages 11 & 12** (`x_fc_signed_pages_11_12`)
|
||||
|
||||
In day-to-day operations the office or the client often scans (or emails) the **entire** ADP application as a single PDF — already including signed pages 11 & 12. Today, staff have to manually split pages 11 & 12 out of the bundled PDF and upload them again as a separate file, even though the same signatures are already present in the original PDF.
|
||||
|
||||
The wizard must continue to support the existing flows (separate signed-pages file, remote signing via Page 11 signing request), but it should also accept the bundled case without manual splitting.
|
||||
|
||||
## Goals
|
||||
|
||||
- Allow staff to mark Application Received with **one** PDF when pages 11 & 12 are inside it.
|
||||
- Preserve the two existing modes (separate file, remote signing).
|
||||
- Keep downstream audit/case-close checks correct without rewriting every consumer.
|
||||
- Make the wizard easier to use and slightly safer (real PDF detection, friendlier messages).
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- PDF page extraction or splitting (explicitly rejected by user — "no split").
|
||||
- Capturing Page 11 signer identity in the bundled / separate-file modes (existing gap; out of scope).
|
||||
- Re-architecting the document-attachment model to de-duplicate identical binaries (out of scope).
|
||||
- Changes to the remote signing wizard or `fusion.page11.sign.request` model.
|
||||
|
||||
## High-Level Approach
|
||||
|
||||
Add a **single boolean flag** on `sale.order` that records whether pages 11 & 12 are inside the original application PDF. Introduce a **computed helper field** that downstream consumers read instead of `x_fc_signed_pages_11_12` directly. Add a **three-mode radio** at the top of the Application Received wizard.
|
||||
|
||||
Minimal blast radius:
|
||||
- One new boolean, one new computed field on `sale.order`.
|
||||
- Wizard view + Python rewritten to drive logic off the radio mode.
|
||||
- Four downstream call sites change which field they read (no logic change).
|
||||
- Three small complementary fixes folded in (status-gate text, PDF magic-bytes check, page-count indicator).
|
||||
|
||||
## Data Model
|
||||
|
||||
### `sale.order` — new fields
|
||||
|
||||
```python
|
||||
x_fc_pages_11_12_in_original = fields.Boolean(
|
||||
string='Pages 11 & 12 in Original Application',
|
||||
default=False,
|
||||
tracking=True,
|
||||
help='True when the original application PDF already contains the signed pages 11 & 12.',
|
||||
)
|
||||
|
||||
x_fc_has_signed_pages_11_12 = fields.Boolean(
|
||||
string='Has Signed Pages 11 & 12',
|
||||
compute='_compute_has_signed_pages_11_12',
|
||||
store=True,
|
||||
help='True if pages 11 & 12 are satisfied — either bundled, uploaded separately, '
|
||||
'or signed via remote signing request.',
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'x_fc_signed_pages_11_12',
|
||||
'x_fc_pages_11_12_in_original',
|
||||
'page11_sign_request_ids.state',
|
||||
)
|
||||
def _compute_has_signed_pages_11_12(self):
|
||||
for order in self:
|
||||
order.x_fc_has_signed_pages_11_12 = bool(
|
||||
order.x_fc_pages_11_12_in_original
|
||||
or order.x_fc_signed_pages_11_12
|
||||
or order.page11_sign_request_ids.filtered(lambda r: r.state == 'signed')
|
||||
)
|
||||
```
|
||||
|
||||
### Existing fields — unchanged meaning
|
||||
|
||||
- `x_fc_original_application` — original (or bundled) PDF.
|
||||
- `x_fc_signed_pages_11_12` — separate signed-pages file when one exists. Stays optional.
|
||||
- `page11_sign_request_ids` — remote signing requests. Unchanged.
|
||||
|
||||
### Audit trail field
|
||||
|
||||
`x_fc_trail_has_signed_pages` already exists at [models/sale_order.py:3248](../../fusion_claims/models/sale_order.py:3248). Its compute body changes from `bool(order.x_fc_signed_pages_11_12)` to `order.x_fc_has_signed_pages_11_12`.
|
||||
|
||||
### Migration
|
||||
|
||||
None. Existing records get `x_fc_pages_11_12_in_original = False` by default; their existing `x_fc_signed_pages_11_12` binary continues to satisfy the new computed gate. Stored compute will populate `x_fc_has_signed_pages_11_12` for legacy rows on first read or recompute.
|
||||
|
||||
## Wizard Changes — `fusion_claims.application.received.wizard`
|
||||
|
||||
### New fields
|
||||
|
||||
```python
|
||||
intake_mode = fields.Selection(
|
||||
[
|
||||
('bundled', 'Pages 11 & 12 are INCLUDED in the original application'),
|
||||
('separate', 'Pages 11 & 12 are a SEPARATE file'),
|
||||
('remote', 'Pages 11 & 12 will be SIGNED REMOTELY'),
|
||||
],
|
||||
string='Intake Mode',
|
||||
required=True,
|
||||
default='bundled',
|
||||
)
|
||||
|
||||
original_page_count = fields.Integer(
|
||||
string='Original PDF Page Count',
|
||||
compute='_compute_original_page_count',
|
||||
)
|
||||
```
|
||||
|
||||
`signed_pages_11_12` and `signed_pages_filename` keep their current definitions — they're only required in `separate` mode now.
|
||||
|
||||
The existing computed fields `has_pending_page11_request` and `has_signed_page11` ([wizard/application_received_wizard.py:44-49](../../fusion_claims/wizard/application_received_wizard.py:44)) **stay** — they drive the "request pending" / "remote signature complete" banners now only shown when `intake_mode == 'remote'`.
|
||||
|
||||
### `default_get` — pick an initial mode from existing state
|
||||
|
||||
```python
|
||||
# When re-opening the wizard on an order that already has some data:
|
||||
if order.x_fc_pages_11_12_in_original:
|
||||
res['intake_mode'] = 'bundled'
|
||||
elif order.x_fc_signed_pages_11_12:
|
||||
res['intake_mode'] = 'separate'
|
||||
elif order.page11_sign_request_ids.filtered(lambda r: r.state in ('sent', 'signed')):
|
||||
res['intake_mode'] = 'remote'
|
||||
else:
|
||||
res['intake_mode'] = 'bundled' # new default for fresh records
|
||||
```
|
||||
|
||||
### View behaviour (declarative `invisible` on group containers)
|
||||
|
||||
| Mode | Original upload | Signed Pages 11 & 12 upload | Remote-sign banner / button |
|
||||
|---|---|---|---|
|
||||
| `bundled` | shown, required | hidden | hidden |
|
||||
| `separate` | shown, required | shown, required | hidden |
|
||||
| `remote` | shown, required | hidden | shown (existing `action_request_page11_signature` button) |
|
||||
|
||||
Page count is displayed read-only next to the original-application filename once a PDF is loaded. If `pdfrw` fails to parse, show *"(could not read PDF)"* — does not block confirmation.
|
||||
|
||||
### `action_confirm` (new shape)
|
||||
|
||||
```python
|
||||
def action_confirm(self):
|
||||
self.ensure_one()
|
||||
order = self.sale_order_id
|
||||
|
||||
if order.x_fc_adp_application_status not in ('assessment_completed', 'waiting_for_application'):
|
||||
raise UserError(
|
||||
"Can only mark application received from 'Assessment Completed' "
|
||||
"or 'Waiting for Application' status."
|
||||
)
|
||||
|
||||
if not self.original_application:
|
||||
raise UserError("Please upload the Original ADP Application.")
|
||||
|
||||
self._validate_pdf_bytes(self.original_application, 'Original ADP Application')
|
||||
|
||||
vals = {
|
||||
'x_fc_adp_application_status': 'application_received',
|
||||
'x_fc_original_application': self.original_application,
|
||||
'x_fc_original_application_filename': self.original_application_filename,
|
||||
'x_fc_pages_11_12_in_original': (self.intake_mode == 'bundled'),
|
||||
}
|
||||
|
||||
if self.intake_mode == 'separate':
|
||||
if not (self.signed_pages_11_12 or order.x_fc_signed_pages_11_12):
|
||||
raise UserError("Pages 11 & 12 file is required for Separate-file mode.")
|
||||
if self.signed_pages_11_12:
|
||||
self._validate_pdf_bytes(self.signed_pages_11_12, 'Signed Pages 11 & 12')
|
||||
vals['x_fc_signed_pages_11_12'] = self.signed_pages_11_12
|
||||
vals['x_fc_signed_pages_filename'] = self.signed_pages_filename
|
||||
|
||||
elif self.intake_mode == 'remote':
|
||||
has_request = order.page11_sign_request_ids.filtered(
|
||||
lambda r: r.state in ('sent', 'signed')
|
||||
)
|
||||
if not has_request:
|
||||
raise UserError(
|
||||
"Remote-signing request not found. Click 'Request Remote Signature' "
|
||||
"first, or pick a different mode."
|
||||
)
|
||||
# bundled flag stays False — signature lives in the request's signed_pdf
|
||||
|
||||
order.with_context(skip_status_validation=True).write(vals)
|
||||
self._post_chatter(order)
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
```
|
||||
|
||||
When `intake_mode == 'bundled'`, any pre-existing `x_fc_signed_pages_11_12` from a prior wizard run is left alone (we don't clear it). The bundled flag plus the existing separate file together are harmless — the computed gate is `OR`.
|
||||
|
||||
### PDF magic-bytes check
|
||||
|
||||
```python
|
||||
def _validate_pdf_bytes(self, b64_data, label):
|
||||
import base64
|
||||
if not b64_data:
|
||||
return
|
||||
try:
|
||||
head = base64.b64decode(b64_data)[:5]
|
||||
except Exception:
|
||||
raise UserError(f"{label}: could not decode uploaded file.")
|
||||
if head != b'%PDF-':
|
||||
raise UserError(f"{label} must be a PDF file (content check failed).")
|
||||
```
|
||||
|
||||
The existing filename `.pdf` check stays in place as a defence-in-depth `@api.constrains`.
|
||||
|
||||
### Chatter message — mode-aware
|
||||
|
||||
| Mode | Headline | Detail line |
|
||||
|---|---|---|
|
||||
| `bundled` | *Application Received — bundled* | "Pages 11 & 12 included in original PDF" |
|
||||
| `separate` | *Application Received — separate files* | "Original + separate signed pages uploaded" |
|
||||
| `remote` | *Application Received — remote signature pending* | "Page 11 sent for remote signature (`N` request(s) outstanding)" where `N` is the count of `page11_sign_request_ids` in state `sent` or `signed`. |
|
||||
|
||||
Notes from the wizard, if any, are appended below as today.
|
||||
|
||||
## Downstream Consumer Changes
|
||||
|
||||
These are mechanical: change which field they read. **No logic changes.**
|
||||
|
||||
| File | Line | Old | New |
|
||||
|---|---|---|---|
|
||||
| [wizard/ready_for_submission_wizard.py:95](../../fusion_claims/wizard/ready_for_submission_wizard.py:95) | `_compute_field_status` | `bool(order.x_fc_original_application and order.x_fc_signed_pages_11_12)` | `bool(order.x_fc_original_application and order.x_fc_has_signed_pages_11_12)` |
|
||||
| [wizard/ready_for_submission_wizard.py:148](../../fusion_claims/wizard/ready_for_submission_wizard.py:148) | gate check | `if not order.x_fc_signed_pages_11_12` | `if not order.x_fc_has_signed_pages_11_12` |
|
||||
| [wizard/case_close_verification_wizard.py](../../fusion_claims/wizard/case_close_verification_wizard.py) | wherever pages-11-12 gate is checked | `x_fc_signed_pages_11_12` | `x_fc_has_signed_pages_11_12` |
|
||||
| [models/sale_order.py:3248](../../fusion_claims/models/sale_order.py:3248) | `x_fc_trail_has_signed_pages` compute | `bool(order.x_fc_signed_pages_11_12)` | `order.x_fc_has_signed_pages_11_12` |
|
||||
|
||||
The `x_fc_signed_pages_11_12` field stays in the data model. Any download / preview / "open document" button that points at the literal binary stays as-is — bundled-mode orders simply won't have this field populated, and the UI should hide the "Open signed pages" button when the field is empty (it already does — Odoo hides empty binary widgets by default).
|
||||
|
||||
## Error / Edge Cases
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|---|---|
|
||||
| User toggles from `separate` to `bundled` after uploading a separate file | Wizard does not clear the upload field. On confirm, only the original application is written; bundled flag goes to True. The separate-file binary in the wizard is discarded (it was never written). |
|
||||
| User picks `remote` but has no sent/signed request | Block with the message above; user must click *Request Remote Signature* first. |
|
||||
| User picks `bundled` but the PDF is short (e.g. 4 pages) | Page-count indicator shows *"(4 pages)"* as a visual hint, but **does not block**. The 14-page ADP form is the norm but the system can't reliably enforce it across form versions. |
|
||||
| Legacy record without `x_fc_pages_11_12_in_original` set | Defaults to False. As long as `x_fc_signed_pages_11_12` is present, `x_fc_has_signed_pages_11_12` is True — gate still passes. |
|
||||
| Stored compute not populated for legacy rows | Triggered on first read or via a one-line `_recompute` on module load is **not** required — Odoo computes on first access. If users hit issues, a one-off psql `UPDATE` can be run manually. |
|
||||
| Remote signing completes after `bundled` mode was used | `_compute_has_signed_pages_11_12` already ORs in `page11_sign_request_ids.state == 'signed'` — harmless overlap; trail stays correct. |
|
||||
| Uploaded file is not really a PDF (wrong content) | Magic-byte check raises a UserError; record is not changed. |
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests — wizard (`tests/test_application_received_wizard.py`, new)
|
||||
|
||||
- `test_bundled_mode_marks_received_with_only_original`
|
||||
- `test_separate_mode_requires_signed_pages`
|
||||
- `test_remote_mode_requires_sent_or_signed_request`
|
||||
- `test_invalid_pdf_bytes_rejected`
|
||||
- `test_chatter_message_mentions_intake_mode`
|
||||
|
||||
### Unit tests — downstream gates
|
||||
|
||||
- `test_ready_for_submission_passes_with_bundled_flag` (no `x_fc_signed_pages_11_12` set)
|
||||
- `test_case_close_audit_accepts_bundled_flag`
|
||||
- `test_trail_has_signed_pages_true_when_bundled`
|
||||
|
||||
### Manual smoke test on local dev DB
|
||||
|
||||
```bash
|
||||
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims --stop-after-init
|
||||
```
|
||||
|
||||
Then in the UI:
|
||||
1. Take an order in *Waiting for Application*.
|
||||
2. Click *Mark Application Received* → pick **Bundled** → upload a single PDF → confirm.
|
||||
3. Confirm chatter shows the bundled message and `x_fc_pages_11_12_in_original = True`.
|
||||
4. Click *Mark Ready for Submission* — the document gate should pass.
|
||||
5. Repeat on another order with **Separate** mode to confirm the old flow still works.
|
||||
6. Repeat on a third order with **Remote** mode after triggering a signing request.
|
||||
|
||||
## Rollout
|
||||
|
||||
- Bump `version` in [fusion_claims/__manifest__.py](../../fusion_claims/__manifest__.py).
|
||||
- `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims --stop-after-init`.
|
||||
- Reload browser with cache clear (per CLAUDE.md asset-bundle-cache rule).
|
||||
- No production deploy steps unique to this change.
|
||||
|
||||
## Open Questions (none blocking implementation)
|
||||
|
||||
- Should bundled-mode capture Page 11 signer identity (signer name, relationship) the way the remote flow does? Currently neither bundled nor separate-file modes do — existing gap, deferred.
|
||||
- Should the bundled-mode chatter automatically attach a one-line note like *"Operator confirms pages 11 & 12 are within the original application"* with the user's name? The default chatter post already records the user. Leaving as-is.
|
||||
File diff suppressed because it is too large
Load Diff
1197
entech-website-design.html
Normal file
1197
entech-website-design.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
fusion_accounting/.DS_Store
vendored
BIN
fusion_accounting/.DS_Store
vendored
Binary file not shown.
BIN
fusion_accounting/fusion_accounting/.DS_Store
vendored
BIN
fusion_accounting/fusion_accounting/.DS_Store
vendored
Binary file not shown.
BIN
fusion_accounting/fusion_accounting_ai/.DS_Store
vendored
BIN
fusion_accounting/fusion_accounting_ai/.DS_Store
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
fusion_accounting/fusion_accounting_assets/.DS_Store
vendored
BIN
fusion_accounting/fusion_accounting_assets/.DS_Store
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
fusion_accounting/fusion_accounting_core/.DS_Store
vendored
BIN
fusion_accounting/fusion_accounting_core/.DS_Store
vendored
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.
BIN
fusion_accounting/fusion_accounting_ocr/.DS_Store
vendored
BIN
fusion_accounting/fusion_accounting_ocr/.DS_Store
vendored
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user