Compare commits
52 Commits
7a02382623
...
backup/pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1414ef2c1c | ||
|
|
42e8fe3d21 | ||
|
|
bad73fcea8 | ||
|
|
94249ba67d | ||
|
|
2abd859a29 | ||
|
|
98cb42d2e5 | ||
|
|
878d05685c | ||
|
|
bd2c037a97 | ||
|
|
44636e47fb | ||
|
|
06c49ecec6 | ||
|
|
37deaedf0d | ||
|
|
30f7f18472 | ||
|
|
66e9749853 | ||
|
|
c9be68a575 | ||
|
|
19d692afe7 | ||
|
|
0351dcd497 | ||
|
|
03fd3d7c1c | ||
|
|
f4c9ed3d24 | ||
|
|
ef885c66dc | ||
|
|
148aa5cba8 | ||
|
|
661c8ae227 | ||
|
|
a24a1ddf1a | ||
|
|
f05cacec22 | ||
|
|
9239ee2822 | ||
|
|
4733885211 | ||
|
|
8e708bf2c4 | ||
|
|
caf240daec | ||
|
|
4bed8ab2c5 | ||
|
|
50c209b8d3 | ||
|
|
65a1c4b17e | ||
|
|
91d3a3f9d1 | ||
|
|
70f855d91b | ||
|
|
85eddba546 | ||
|
|
48d3e48e61 | ||
|
|
f07e1bcce1 | ||
|
|
e7c6960de9 | ||
|
|
ad64b0b4c9 | ||
|
|
cd763fa1d7 | ||
|
|
f40f44aafd | ||
|
|
63bf271725 | ||
|
|
974b8a5152 | ||
|
|
0a32ed2da7 | ||
|
|
e4681a58c6 | ||
|
|
135cbd3a5c | ||
|
|
3182ca3c39 | ||
|
|
677e460438 | ||
|
|
c7b794f604 | ||
|
|
64c61dcca8 | ||
|
|
649b75d4a1 | ||
|
|
8aa817b1a0 | ||
|
|
80d1cc5639 | ||
|
|
2db789d7dd |
2801
docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md
Normal file
2801
docs/superpowers/plans/2026-05-13-nfc-clock-kiosk-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
300
docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
Normal file
300
docs/superpowers/specs/2026-05-13-nfc-clock-kiosk-design.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# 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.
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Clock',
|
||||
'version': '19.0.2.0.0',
|
||||
'version': '19.0.3.0.0',
|
||||
'category': 'Human Resources/Attendances',
|
||||
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
|
||||
'description': """
|
||||
@@ -76,12 +76,15 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
|
||||
'views/portal_timesheet_templates.xml',
|
||||
'views/portal_report_templates.xml',
|
||||
'views/kiosk_templates.xml',
|
||||
'views/kiosk_nfc_templates.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'fusion_clock/static/src/css/portal_clock.css',
|
||||
'fusion_clock/static/src/scss/nfc_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',
|
||||
],
|
||||
'web.assets_backend': [
|
||||
'fusion_clock/static/src/scss/fusion_clock.scss',
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
from . import portal_clock
|
||||
from . import clock_api
|
||||
from . import clock_kiosk
|
||||
from . import clock_nfc_kiosk
|
||||
|
||||
249
fusion_clock/controllers/clock_nfc_kiosk.py
Normal file
249
fusion_clock/controllers/clock_nfc_kiosk.py
Normal file
@@ -0,0 +1,249 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
from odoo import fields, http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')
|
||||
|
||||
_DEBOUNCE_WINDOW_SECONDS = 5.0
|
||||
_recent_taps = {} # {card_uid: monotonic_ts}
|
||||
_recent_taps_lock = threading.Lock()
|
||||
|
||||
|
||||
def _is_debounced(uid):
|
||||
"""Return True if this UID was tapped within the debounce window."""
|
||||
now = time.monotonic()
|
||||
with _recent_taps_lock:
|
||||
last = _recent_taps.get(uid, 0)
|
||||
if now - last < _DEBOUNCE_WINDOW_SECONDS:
|
||||
return True
|
||||
_recent_taps[uid] = now
|
||||
# Opportunistic GC: drop entries older than 60s
|
||||
stale_keys = [k for k, t in _recent_taps.items() if now - t > 60]
|
||||
for k in stale_keys:
|
||||
_recent_taps.pop(k, None)
|
||||
return False
|
||||
|
||||
|
||||
def _strip_data_url_prefix(b64):
|
||||
"""Strip 'data:image/...;base64,' prefix from a data URL, returning raw base64."""
|
||||
if not b64:
|
||||
return b''
|
||||
if isinstance(b64, str) and b64.startswith('data:'):
|
||||
comma = b64.find(',')
|
||||
if comma >= 0:
|
||||
return b64[comma + 1:].encode('ascii', errors='ignore')
|
||||
return b64.encode('ascii', errors='ignore') if isinstance(b64, str) else b64
|
||||
|
||||
|
||||
class FusionClockNfcKiosk(http.Controller):
|
||||
"""NFC tap-to-clock kiosk controller. Reuses FusionClockAPI helpers."""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_uid(uid):
|
||||
"""Normalize an NFC card UID to canonical hex (uppercase, colon-separated).
|
||||
|
||||
Returns None if the input is empty or not valid hex.
|
||||
"""
|
||||
if not uid:
|
||||
return None
|
||||
cleaned = uid.strip().upper().replace('-', '').replace(':', '').replace(' ', '')
|
||||
if not cleaned or not _UID_HEX_PATTERN.match(cleaned):
|
||||
return None
|
||||
if len(cleaned) % 2 != 0:
|
||||
return None
|
||||
return ':'.join(cleaned[i:i+2] for i in range(0, len(cleaned), 2))
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc', type='http', auth='user', website=True)
|
||||
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'):
|
||||
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
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
company_logo_url = (
|
||||
'/web/image/res.company/%s/logo' % company.id if company.logo else ''
|
||||
)
|
||||
values = {
|
||||
'page_name': 'nfc_kiosk',
|
||||
'company_name': company.name,
|
||||
'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',
|
||||
'debug_enabled': ICP.get_param('fusion_clock.nfc_kiosk_debug', 'False') == 'True',
|
||||
}
|
||||
return request.render('fusion_clock.nfc_kiosk_page', values)
|
||||
|
||||
@staticmethod
|
||||
def _check_enroll_password(env, supplied):
|
||||
"""Verify the enroll-mode password. Empty config = always-allow for managers."""
|
||||
configured = env['ir.config_parameter'].sudo().get_param('fusion_clock.nfc_enroll_password', '')
|
||||
if not configured:
|
||||
return True
|
||||
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."""
|
||||
user = request.env.user
|
||||
if not user.has_group('fusion_clock.group_fusion_clock_manager'):
|
||||
return {'error': 'access_denied'}
|
||||
|
||||
if not self._check_enroll_password(request.env, enroll_password):
|
||||
return {'error': 'invalid_password'}
|
||||
|
||||
normalized = self._normalize_uid(card_uid)
|
||||
if not normalized:
|
||||
return {'error': 'invalid_uid'}
|
||||
|
||||
Employee = request.env['hr.employee'].sudo()
|
||||
target = Employee.browse(int(employee_id or 0))
|
||||
if not target.exists():
|
||||
return {'error': 'employee_not_found'}
|
||||
|
||||
existing = Employee.search([
|
||||
('x_fclk_nfc_card_uid', '=', normalized),
|
||||
('id', '!=', target.id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return {
|
||||
'error': 'card_already_assigned',
|
||||
'existing_employee': existing.name,
|
||||
}
|
||||
|
||||
target.x_fclk_nfc_card_uid = normalized
|
||||
|
||||
# Activity log (uses 'card_enrollment' + 'nfc_kiosk' selections added in Task 2)
|
||||
request.env['fusion.clock.activity.log'].sudo().create({
|
||||
'employee_id': target.id,
|
||||
'log_type': 'card_enrollment',
|
||||
'description': f"NFC card {normalized} enrolled by {user.name}",
|
||||
'source': 'nfc_kiosk',
|
||||
})
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'employee_name': target.name,
|
||||
'card_uid': normalized,
|
||||
}
|
||||
|
||||
@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'):
|
||||
return {'error': 'access_denied'}
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('fusion_clock.enable_nfc_kiosk', 'False') != 'True':
|
||||
return {'error': 'kiosk_disabled'}
|
||||
|
||||
normalized = self._normalize_uid(card_uid)
|
||||
if not normalized:
|
||||
return {'error': 'invalid_uid'}
|
||||
|
||||
if _is_debounced(normalized):
|
||||
return {'error': 'debounce'}
|
||||
|
||||
photo_required = 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''
|
||||
|
||||
company = request.env.company
|
||||
location = company.x_fclk_nfc_kiosk_location_id
|
||||
if not location:
|
||||
return {'error': 'no_location_configured'}
|
||||
|
||||
Employee = request.env['hr.employee'].sudo()
|
||||
employee = Employee.search([('x_fclk_nfc_card_uid', '=', normalized)], limit=1)
|
||||
if not employee:
|
||||
_logger.warning("[nfc-kiosk] Unknown NFC card tapped: %s", normalized)
|
||||
return {'error': 'card_unknown', 'message': 'Card not enrolled. See your manager.'}
|
||||
|
||||
if not employee.x_fclk_enable_clock:
|
||||
return {'error': 'clock_disabled', 'message': 'Clock disabled for this account.'}
|
||||
|
||||
from .clock_api import FusionClockAPI
|
||||
api = FusionClockAPI()
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
now = fields.Datetime.now()
|
||||
today = now.date()
|
||||
|
||||
geo_info = {
|
||||
'latitude': 0,
|
||||
'longitude': 0,
|
||||
'browser': 'nfc_kiosk',
|
||||
'ip_address': request.httprequest.remote_addr or '',
|
||||
}
|
||||
|
||||
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': 'nfc_kiosk',
|
||||
'x_fclk_check_in_photo': photo_bytes if photo_bytes else False,
|
||||
})
|
||||
api._log_activity(
|
||||
employee, 'clock_in',
|
||||
f"NFC kiosk clock-in at {location.name}",
|
||||
attendance=attendance, location=location,
|
||||
latitude=0, longitude=0, distance=0,
|
||||
source='nfc_kiosk',
|
||||
)
|
||||
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,
|
||||
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
||||
'message': f'{employee.name} clocked in at {location.name}',
|
||||
'net_hours_today': 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)
|
||||
_, 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"NFC 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='nfc_kiosk',
|
||||
)
|
||||
return {
|
||||
'success': True,
|
||||
'action': 'clock_out',
|
||||
'employee_name': employee.name,
|
||||
'employee_avatar_url': f'/web/image/hr.employee/{employee.id}/avatar_128',
|
||||
'message': f'{employee.name} clocked out',
|
||||
'net_hours_today': round(attendance.x_fclk_net_hours or 0, 2),
|
||||
}
|
||||
|
||||
@http.route('/fusion_clock/kiosk/nfc/employee_search', type='jsonrpc', auth='user', methods=['POST'])
|
||||
def nfc_employee_search(self, query='', **kw):
|
||||
"""Delegate to the existing kiosk search to avoid duplication."""
|
||||
from .clock_kiosk import FusionClockKiosk
|
||||
return FusionClockKiosk().kiosk_search(query=query)
|
||||
@@ -145,4 +145,22 @@
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
|
||||
<!-- NFC Clock Kiosk -->
|
||||
<record id="config_enable_nfc_kiosk" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.enable_nfc_kiosk</field>
|
||||
<field name="value">False</field>
|
||||
</record>
|
||||
<record id="config_nfc_photo_required" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.nfc_photo_required</field>
|
||||
<field name="value">True</field>
|
||||
</record>
|
||||
<record id="config_nfc_enroll_password" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.nfc_enroll_password</field>
|
||||
<field name="value"></field>
|
||||
</record>
|
||||
<record id="config_nfc_kiosk_debug" model="ir.config_parameter">
|
||||
<field name="key">fusion_clock.nfc_kiosk_debug</field>
|
||||
<field name="value">False</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -10,3 +10,4 @@ from . import clock_activity_log
|
||||
from . import clock_leave_request
|
||||
from . import clock_shift
|
||||
from . import clock_correction
|
||||
from . import res_company
|
||||
|
||||
@@ -34,6 +34,8 @@ class FusionClockActivityLog(models.Model):
|
||||
('correction_request', 'Correction Request'),
|
||||
('ip_fallback', 'IP Fallback Used'),
|
||||
('streak_milestone', 'Streak Milestone'),
|
||||
('card_enrollment', 'Card Enrollment'),
|
||||
('unknown_card_tap', 'Unknown Card Tap'),
|
||||
],
|
||||
string='Log Type',
|
||||
required=True,
|
||||
@@ -71,6 +73,7 @@ class FusionClockActivityLog(models.Model):
|
||||
('systray', 'Systray'),
|
||||
('backend_fab', 'Backend FAB'),
|
||||
('kiosk', 'Kiosk'),
|
||||
('nfc_kiosk', 'NFC Kiosk'),
|
||||
('system', 'System (Cron)'),
|
||||
],
|
||||
string='Source',
|
||||
|
||||
@@ -130,6 +130,7 @@ class HrAttendance(models.Model):
|
||||
('systray', 'Systray'),
|
||||
('backend_fab', 'Backend FAB'),
|
||||
('kiosk', 'Kiosk'),
|
||||
('nfc_kiosk', 'NFC Kiosk'),
|
||||
('manual', 'Manual'),
|
||||
('auto', 'Auto Clock-Out'),
|
||||
],
|
||||
@@ -147,6 +148,16 @@ class HrAttendance(models.Model):
|
||||
digits=(10, 2),
|
||||
help="Distance from location center at clock-out, in meters.",
|
||||
)
|
||||
x_fclk_check_in_photo = fields.Binary(
|
||||
string='Check-In Photo',
|
||||
attachment=True,
|
||||
help="Front-camera photo captured at NFC kiosk clock-in.",
|
||||
)
|
||||
x_fclk_check_out_photo = fields.Binary(
|
||||
string='Check-Out Photo',
|
||||
attachment=True,
|
||||
help="Front-camera photo captured at NFC kiosk clock-out.",
|
||||
)
|
||||
x_fclk_break_minutes = fields.Float(
|
||||
string='Break (min)',
|
||||
default=0.0,
|
||||
|
||||
@@ -47,6 +47,25 @@ class HrEmployee(models.Model):
|
||||
groups="fusion_clock.group_fusion_clock_manager",
|
||||
)
|
||||
|
||||
# NFC card (kiosk identification)
|
||||
x_fclk_nfc_card_uid = fields.Char(
|
||||
string='NFC Card UID',
|
||||
index=True,
|
||||
copy=False,
|
||||
groups="fusion_clock.group_fusion_clock_manager",
|
||||
help="Hex UID of the NFC card assigned to this employee. "
|
||||
"Format: uppercase, colon-separated, e.g. 04:A2:B5:62:C1:80. "
|
||||
"Same card the employee uses for door access.",
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
(
|
||||
'fclk_nfc_card_uid_unique',
|
||||
'UNIQUE(x_fclk_nfc_card_uid)',
|
||||
'This NFC card is already assigned to another employee.',
|
||||
),
|
||||
]
|
||||
|
||||
# On-time streak
|
||||
x_fclk_ontime_streak = fields.Integer(
|
||||
string='On-Time Streak',
|
||||
|
||||
17
fusion_clock/models/res_company.py
Normal file
17
fusion_clock/models/res_company.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
x_fclk_nfc_kiosk_location_id = fields.Many2one(
|
||||
'fusion.clock.location',
|
||||
string='NFC Kiosk Location',
|
||||
domain="[('company_id', '=', id)]",
|
||||
help="Designates which fusion.clock.location is bound to the NFC kiosk "
|
||||
"for this company. Required when NFC kiosk is enabled.",
|
||||
)
|
||||
@@ -232,6 +232,43 @@ class ResConfigSettings(models.TransientModel):
|
||||
help="Custom column names for CSV export (JSON format). Leave blank for defaults.",
|
||||
)
|
||||
|
||||
# ── NFC Clock Kiosk ────────────────────────────────────────────────
|
||||
fclk_enable_nfc_kiosk = fields.Boolean(
|
||||
string='Enable NFC Clock Kiosk',
|
||||
config_parameter='fusion_clock.enable_nfc_kiosk',
|
||||
default=False,
|
||||
help="Enable the tap-to-clock NFC kiosk page at /fusion_clock/kiosk/nfc.",
|
||||
)
|
||||
fclk_nfc_photo_required = fields.Boolean(
|
||||
string='Require Photo on Tap',
|
||||
config_parameter='fusion_clock.nfc_photo_required',
|
||||
default=True,
|
||||
help="If enabled, the kiosk rejects taps when the front camera is unavailable. "
|
||||
"Recommended for buddy-punch deterrence.",
|
||||
)
|
||||
fclk_nfc_enroll_password = fields.Char(
|
||||
string='Enroll Mode Password',
|
||||
config_parameter='fusion_clock.nfc_enroll_password',
|
||||
help="Short password the manager types on the kiosk to enter Enroll Mode. "
|
||||
"Leave empty to fall back to manager-group membership only.",
|
||||
)
|
||||
fclk_nfc_kiosk_debug = fields.Boolean(
|
||||
string='Debug Mode (overlay + mock-tap)',
|
||||
config_parameter='fusion_clock.nfc_kiosk_debug',
|
||||
default=False,
|
||||
help="Enables two dev/troubleshooting features on the NFC kiosk page: "
|
||||
"(1) a green-text debug overlay at the top of the screen logging every NFC and tap event in real time, "
|
||||
"and (2) a Ctrl+Shift+T keyboard shortcut that simulates a tap with a configurable UID. "
|
||||
"Turn OFF in production — the overlay is intrusive for end users.",
|
||||
)
|
||||
fclk_nfc_kiosk_location_id = fields.Many2one(
|
||||
related='company_id.x_fclk_nfc_kiosk_location_id',
|
||||
readonly=False,
|
||||
string='NFC Kiosk Location',
|
||||
help="Which clock location is bound to the NFC kiosk for this company. "
|
||||
"Required when the kiosk is enabled.",
|
||||
)
|
||||
|
||||
def set_values(self):
|
||||
super().set_values()
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
|
||||
599
fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
Normal file
599
fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js
Normal file
@@ -0,0 +1,599 @@
|
||||
/* @odoo-module */
|
||||
|
||||
// NFC Clock Kiosk — Web NFC + camera + state machine.
|
||||
// Loaded as a frontend asset on /fusion_clock/kiosk/nfc only (the
|
||||
// element #nfc_kiosk_root only exists on that page, so the module is
|
||||
// inert elsewhere).
|
||||
|
||||
(function() {
|
||||
"use strict";
|
||||
|
||||
const root = document.getElementById("nfc_kiosk_root");
|
||||
if (!root) return; // not on the kiosk page
|
||||
|
||||
const stateContainer = document.getElementById("nfc_state_container");
|
||||
const photoRequired = root.dataset.photoRequired === "1";
|
||||
const debugEnabled = root.dataset.debugEnabled === "1";
|
||||
const locationConfigured = root.dataset.locationConfigured === "1";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Debug overlay (visible only when fusion_clock.nfc_kiosk_debug = True)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let _debugOverlayEl = null;
|
||||
function debugLog(msg) {
|
||||
try { console.log("[nfc-kiosk-debug]", msg); } catch (e) {}
|
||||
if (!debugEnabled) return;
|
||||
if (!_debugOverlayEl) {
|
||||
_debugOverlayEl = document.createElement("div");
|
||||
_debugOverlayEl.style.cssText = "position:fixed;top:0;left:0;right:0;background:rgba(0,0,0,0.9);color:#0f0;font-family:monospace;font-size:11px;padding:0.5rem;max-height:35vh;overflow-y:auto;z-index:9999;line-height:1.3;border-bottom:1px solid #0f0;";
|
||||
document.body.appendChild(_debugOverlayEl);
|
||||
}
|
||||
const line = document.createElement("div");
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
line.textContent = "[" + ts + "] " + msg;
|
||||
_debugOverlayEl.appendChild(line);
|
||||
while (_debugOverlayEl.childNodes.length > 40) {
|
||||
_debugOverlayEl.removeChild(_debugOverlayEl.firstChild);
|
||||
}
|
||||
_debugOverlayEl.scrollTop = _debugOverlayEl.scrollHeight;
|
||||
}
|
||||
debugLog("page loaded; debugEnabled=" + debugEnabled + " photoRequired=" + photoRequired + " NDEFReader=" + ("NDEFReader" in window));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Dominant-hue extraction from company logo
|
||||
// Sets the CSS variable --nfc-h on <html> so SCSS can interpolate
|
||||
// the entire palette from the brand color. Falls back to default
|
||||
// (220 = aurora-blue) if no logo or extraction fails.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function rgbToHue(r, g, b) {
|
||||
const rN = r / 255, gN = g / 255, bN = b / 255;
|
||||
const max = Math.max(rN, gN, bN), min = Math.min(rN, gN, bN);
|
||||
const d = max - min;
|
||||
if (d === 0) return null; // grayscale, no hue info
|
||||
let h;
|
||||
if (max === rN) h = ((gN - bN) / d) % 6;
|
||||
else if (max === gN) h = (bN - rN) / d + 2;
|
||||
else h = (rN - gN) / d + 4;
|
||||
h = Math.round(h * 60);
|
||||
if (h < 0) h += 360;
|
||||
return h;
|
||||
}
|
||||
|
||||
function extractDominantHue(img) {
|
||||
try {
|
||||
const c = document.createElement("canvas");
|
||||
const w = c.width = Math.min(img.naturalWidth, 200);
|
||||
const h = c.height = Math.min(img.naturalHeight, 200);
|
||||
const ctx = c.getContext("2d", { willReadFrequently: true });
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
const data = ctx.getImageData(0, 0, w, h).data;
|
||||
let r = 0, g = 0, b = 0, count = 0;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const a = data[i + 3];
|
||||
if (a < 128) continue; // skip transparent
|
||||
const red = data[i], green = data[i + 1], blue = data[i + 2];
|
||||
const lum = (red + green + blue) / 3;
|
||||
if (lum > 235 || lum < 25) continue; // skip near-white/near-black
|
||||
const range = Math.max(red, green, blue) - Math.min(red, green, blue);
|
||||
if (range < 25) continue; // skip near-grays
|
||||
r += red; g += green; b += blue; count++;
|
||||
}
|
||||
if (count < 50) {
|
||||
debugLog("hue extraction: too few colored pixels (" + count + "), using default");
|
||||
return null;
|
||||
}
|
||||
const avgR = Math.round(r / count), avgG = Math.round(g / count), avgB = Math.round(b / count);
|
||||
const hue = rgbToHue(avgR, avgG, avgB);
|
||||
debugLog("hue extracted: rgb(" + avgR + "," + avgG + "," + avgB + ") → h=" + hue);
|
||||
return hue;
|
||||
} catch (e) {
|
||||
debugLog("hue extraction failed: " + e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyBrandHue(hue) {
|
||||
if (hue == null) return;
|
||||
document.documentElement.style.setProperty("--nfc-h", String(hue));
|
||||
}
|
||||
|
||||
const logoImg = document.getElementById("nfc_company_logo");
|
||||
if (logoImg) {
|
||||
const tryExtract = () => {
|
||||
const hue = extractDominantHue(logoImg);
|
||||
applyBrandHue(hue);
|
||||
};
|
||||
if (logoImg.complete && logoImg.naturalWidth) {
|
||||
tryExtract();
|
||||
} else {
|
||||
logoImg.addEventListener("load", tryExtract);
|
||||
logoImg.addEventListener("error", () => debugLog("logo failed to load"));
|
||||
}
|
||||
} else {
|
||||
debugLog("no company logo on page; using default hue");
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// State machine
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
const STATE = { SETUP: "setup", IDLE: "idle", PROCESSING: "processing", RESULT: "result", ENROLL: "enroll" };
|
||||
let currentState = STATE.SETUP;
|
||||
|
||||
function setState(next, payload) {
|
||||
currentState = next;
|
||||
if (next === STATE.IDLE) renderIdle();
|
||||
else if (next === STATE.PROCESSING) renderProcessing();
|
||||
else if (next === STATE.RESULT) renderResult(payload);
|
||||
else if (next === STATE.ENROLL) renderEnroll(payload);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Rendering helpers
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function renderIdle() {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__idle">
|
||||
<svg class="nfc-kiosk__icon-svg" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle class="nfc-wave nfc-wave-3" cx="100" cy="100" r="98"
|
||||
stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<circle class="nfc-wave nfc-wave-2" cx="100" cy="100" r="78"
|
||||
stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<circle class="nfc-wave nfc-wave-1" cx="100" cy="100" r="58"
|
||||
stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<rect class="nfc-chip" x="68" y="68" width="64" height="64"
|
||||
rx="11" fill="currentColor"/>
|
||||
</svg>
|
||||
<div class="nfc-kiosk__prompt">Tap your card to clock in or out</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderProcessing() {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__processing">
|
||||
<span>Reading card</span>
|
||||
<span class="dots"><span></span><span></span><span></span></span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderResult(payload) {
|
||||
const isError = payload && payload.error;
|
||||
const cls = isError ? "nfc-kiosk__result--error" : "nfc-kiosk__result--success";
|
||||
|
||||
if (isError) {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__result ${cls}">
|
||||
<div class="nfc-kiosk__result-text">
|
||||
<div class="name">${escapeHtml(payload.message || "Error")}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => setState(STATE.IDLE), 4000);
|
||||
} else {
|
||||
const avatar = payload.employee_avatar_url || "";
|
||||
const action = payload.action === "clock_in" ? "CLOCKED IN" : "CLOCKED OUT";
|
||||
const hours = payload.action === "clock_out" && payload.net_hours_today
|
||||
? `${payload.net_hours_today.toFixed(1)}h today`
|
||||
: "";
|
||||
const time = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__result ${cls}">
|
||||
<div class="nfc-kiosk__avatar" style="background-image:url('${avatar}')"></div>
|
||||
<div class="nfc-kiosk__result-text">
|
||||
<div class="name">${escapeHtml(payload.employee_name)}</div>
|
||||
<div class="action">${action} at ${time}</div>
|
||||
${hours ? `<div class="hours">${hours}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
setTimeout(() => setState(STATE.IDLE), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Enroll Mode
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let enrollPassword = "";
|
||||
let enrollSelectedEmployee = null;
|
||||
let enrollIdleTimer = null;
|
||||
|
||||
function resetEnrollIdleTimer() {
|
||||
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
|
||||
enrollIdleTimer = setTimeout(() => {
|
||||
// 60s of inactivity in Enroll Mode → exit
|
||||
exitEnrollMode();
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
function exitEnrollMode() {
|
||||
if (enrollIdleTimer) clearTimeout(enrollIdleTimer);
|
||||
enrollIdleTimer = null;
|
||||
enrollPassword = "";
|
||||
enrollSelectedEmployee = null;
|
||||
setState(STATE.IDLE);
|
||||
}
|
||||
|
||||
function renderEnroll(payload) {
|
||||
const phase = (payload && payload.phase) || "password";
|
||||
resetEnrollIdleTimer();
|
||||
|
||||
if (phase === "password") {
|
||||
const masked = "•".repeat(enrollPassword.length);
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel">
|
||||
<h2>Enter Enroll Mode Password</h2>
|
||||
<div class="pin-display">${masked}</div>
|
||||
<div class="numpad">
|
||||
${[1,2,3,4,5,6,7,8,9].map(n => `<button data-n="${n}">${n}</button>`).join("")}
|
||||
<button data-n="back">⌫</button>
|
||||
<button data-n="0">0</button>
|
||||
<button data-n="ok">OK</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" id="enroll_cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
stateContainer.querySelectorAll(".numpad button").forEach(btn => {
|
||||
btn.addEventListener("click", async () => {
|
||||
resetEnrollIdleTimer();
|
||||
const n = btn.dataset.n;
|
||||
if (n === "back") enrollPassword = enrollPassword.slice(0, -1);
|
||||
else if (n === "ok") {
|
||||
if (enrollPassword.length === 0) return;
|
||||
renderEnroll({ phase: "search" });
|
||||
return;
|
||||
}
|
||||
else enrollPassword += n;
|
||||
renderEnroll({ phase: "password" });
|
||||
});
|
||||
});
|
||||
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "search") {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel">
|
||||
<h2>Pick the employee to enroll</h2>
|
||||
<input class="employee-search" id="enroll_search" placeholder="Search by name…" autocomplete="off"/>
|
||||
<div class="employee-list" id="enroll_list"></div>
|
||||
<div class="actions">
|
||||
<button class="cancel" id="enroll_cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
const searchEl = document.getElementById("enroll_search");
|
||||
const listEl = document.getElementById("enroll_list");
|
||||
let debounceTimer = null;
|
||||
searchEl.addEventListener("input", () => {
|
||||
resetEnrollIdleTimer();
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
const result = await postJson("/fusion_clock/kiosk/nfc/employee_search", { query: searchEl.value });
|
||||
listEl.innerHTML = (result.employees || []).map(e =>
|
||||
`<div class="employee-row" data-id="${e.id}" data-name="${escapeHtml(e.name)}">${escapeHtml(e.name)}<small style="opacity:.6"> · ${escapeHtml(e.department || "")}</small></div>`
|
||||
).join("");
|
||||
listEl.querySelectorAll(".employee-row").forEach(row => {
|
||||
row.addEventListener("click", () => {
|
||||
enrollSelectedEmployee = { id: parseInt(row.dataset.id, 10), name: row.dataset.name };
|
||||
renderEnroll({ phase: "tap" });
|
||||
});
|
||||
});
|
||||
}, 200);
|
||||
});
|
||||
searchEl.focus();
|
||||
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "tap") {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
|
||||
<h2>Now tap ${escapeHtml(enrollSelectedEmployee.name)}'s card</h2>
|
||||
<div class="nfc-kiosk__icon" style="font-size:5rem">⌐■</div>
|
||||
<p style="color:#9ba3ad">Hold the card to the back of the tablet</p>
|
||||
<div class="actions">
|
||||
<button class="cancel" id="enroll_cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("enroll_cancel").addEventListener("click", exitEnrollMode);
|
||||
return;
|
||||
}
|
||||
|
||||
if (phase === "result") {
|
||||
const ok = !payload.error;
|
||||
const msg = ok
|
||||
? `✓ Card ${escapeHtml(payload.card_uid)} enrolled to ${escapeHtml(payload.employee_name)}`
|
||||
: (payload.error === "invalid_password"
|
||||
? "Wrong password. Try again."
|
||||
: payload.error === "card_already_assigned"
|
||||
? `This card is already assigned to ${escapeHtml(payload.existing_employee || "another employee")}.`
|
||||
: `Enroll failed: ${escapeHtml(payload.error)}`);
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__enroll-overlay">
|
||||
<div class="nfc-kiosk__enroll-panel" style="text-align:center">
|
||||
<h2 style="color:${ok ? "#18a957" : "#d9374e"}">${msg}</h2>
|
||||
<div class="actions" style="justify-content:center">
|
||||
<button class="confirm" id="enroll_another">Enroll another</button>
|
||||
<button class="cancel" id="enroll_done">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById("enroll_another").addEventListener("click", () => {
|
||||
enrollSelectedEmployee = null;
|
||||
renderEnroll({ phase: ok ? "search" : "password" });
|
||||
});
|
||||
document.getElementById("enroll_done").addEventListener("click", exitEnrollMode);
|
||||
}
|
||||
}
|
||||
|
||||
async function _onEnrollTap(uid) {
|
||||
if (!enrollSelectedEmployee) return;
|
||||
const result = await postJson("/fusion_clock/kiosk/nfc/enroll", {
|
||||
employee_id: enrollSelectedEmployee.id,
|
||||
card_uid: uid,
|
||||
enroll_password: enrollPassword,
|
||||
});
|
||||
renderEnroll({ phase: "result", ...result });
|
||||
}
|
||||
|
||||
// ⚙ button → enter Enroll Mode
|
||||
const settingsBtn = document.getElementById("nfc_settings_btn");
|
||||
if (settingsBtn) {
|
||||
settingsBtn.addEventListener("click", () => {
|
||||
if (currentState !== STATE.IDLE) return;
|
||||
enrollPassword = "";
|
||||
enrollSelectedEmployee = null;
|
||||
setState(STATE.ENROLL, { phase: "password" });
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s || "").replace(/[&<>"']/g, c => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
}[c]));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Clock display (centered top: time with AM/PM + date)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
let hours = now.getHours();
|
||||
const ampm = hours >= 12 ? "PM" : "AM";
|
||||
hours = hours % 12;
|
||||
if (hours === 0) hours = 12; // 0 → 12 in 12-hour clock
|
||||
const hh = String(hours).padStart(2, "0");
|
||||
const mm = String(now.getMinutes()).padStart(2, "0");
|
||||
const dateStr = now.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" });
|
||||
const timeEl = document.getElementById("nfc_clock_time");
|
||||
const dateEl = document.getElementById("nfc_clock_date");
|
||||
if (timeEl) {
|
||||
// Render hh:mm + AM/PM as separate spans so SCSS can style them differently
|
||||
timeEl.innerHTML = `${hh}:${mm}<span class="ampm">${ampm}</span>`;
|
||||
}
|
||||
if (dateEl) dateEl.textContent = dateStr;
|
||||
}
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Setup wizard
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Web NFC reader
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let ndefReader = null;
|
||||
let nfcReady = false;
|
||||
|
||||
async function startNfcReader() {
|
||||
debugLog("startNfcReader: NDEFReader in window = " + ("NDEFReader" in window));
|
||||
if (!("NDEFReader" in window)) {
|
||||
throw new Error("Web NFC not supported on this browser/device. Use Chrome on Android.");
|
||||
}
|
||||
ndefReader = new NDEFReader();
|
||||
debugLog("startNfcReader: ndefReader created, calling scan()...");
|
||||
await ndefReader.scan();
|
||||
debugLog("startNfcReader: scan() resolved ✓");
|
||||
ndefReader.addEventListener("reading", onNfcReading);
|
||||
ndefReader.addEventListener("readingerror", (ev) => {
|
||||
debugLog("readingerror event fired");
|
||||
console.warn("[nfc-kiosk] reading error; reader still active");
|
||||
});
|
||||
nfcReady = true;
|
||||
debugLog("startNfcReader: listeners attached, nfcReady=true");
|
||||
}
|
||||
|
||||
function onNfcReading(event) {
|
||||
// event.serialNumber is the card UID — works for raw MIFARE access cards
|
||||
const rawSerial = event.serialNumber || "";
|
||||
const uid = rawSerial.toUpperCase();
|
||||
const recCount = (event.message && event.message.records) ? event.message.records.length : 0;
|
||||
debugLog("reading event: serialNumber=" + JSON.stringify(rawSerial) + " (len=" + rawSerial.length + ") records=" + recCount + " state=" + currentState);
|
||||
if (!uid) {
|
||||
debugLog(" → IGNORED: empty serialNumber");
|
||||
return;
|
||||
}
|
||||
if (currentState === STATE.ENROLL) {
|
||||
debugLog(" → routing to _onEnrollTap");
|
||||
window.__nfcKiosk._onEnrollTap && window.__nfcKiosk._onEnrollTap(uid);
|
||||
return;
|
||||
}
|
||||
if (currentState !== STATE.IDLE) {
|
||||
debugLog(" → IGNORED: not in IDLE (state=" + currentState + ")");
|
||||
return;
|
||||
}
|
||||
debugLog(" → calling handleTap(" + uid + ")");
|
||||
handleTap(uid);
|
||||
}
|
||||
|
||||
async function handleTap(uid) {
|
||||
debugLog("handleTap: uid=" + uid);
|
||||
setState(STATE.PROCESSING);
|
||||
let photoB64 = "";
|
||||
try {
|
||||
photoB64 = await capturePhoto();
|
||||
debugLog("handleTap: photo captured, size=" + photoB64.length);
|
||||
} catch (e) {
|
||||
debugLog("handleTap: photo capture failed: " + e.message);
|
||||
console.warn("[nfc-kiosk] camera capture failed", e);
|
||||
}
|
||||
try {
|
||||
debugLog("handleTap: POST /fusion_clock/kiosk/nfc/tap...");
|
||||
const result = await postJson("/fusion_clock/kiosk/nfc/tap", { card_uid: uid, photo_b64: photoB64 });
|
||||
debugLog("handleTap: response = " + JSON.stringify(result).slice(0, 200));
|
||||
if (result.error === "debounce") {
|
||||
setState(STATE.IDLE);
|
||||
return;
|
||||
}
|
||||
setState(STATE.RESULT, result);
|
||||
} catch (e) {
|
||||
debugLog("handleTap: POST failed: " + e.message);
|
||||
setState(STATE.RESULT, { error: "network", message: "No connection. Please try again." });
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(url, params) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", method: "call", params }),
|
||||
});
|
||||
const json = await res.json();
|
||||
return json.result || {};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Camera
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let cameraStream = null;
|
||||
const videoEl = document.getElementById("nfc_camera_feed");
|
||||
const canvasEl = document.getElementById("nfc_camera_canvas");
|
||||
|
||||
async function startCamera() {
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error("Camera not supported on this browser/device.");
|
||||
}
|
||||
cameraStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 480 } },
|
||||
audio: false,
|
||||
});
|
||||
videoEl.srcObject = cameraStream;
|
||||
await videoEl.play();
|
||||
}
|
||||
|
||||
async function capturePhoto() {
|
||||
if (!videoEl || !canvasEl || !videoEl.videoWidth) return "";
|
||||
const w = videoEl.videoWidth;
|
||||
const h = videoEl.videoHeight;
|
||||
canvasEl.width = w;
|
||||
canvasEl.height = h;
|
||||
const ctx = canvasEl.getContext("2d");
|
||||
ctx.drawImage(videoEl, 0, 0, w, h);
|
||||
return canvasEl.toDataURL("image/jpeg", 0.7);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Wake Lock — keeps the screen on while the kiosk page is active.
|
||||
// Released automatically on tab close/navigation; re-acquired on
|
||||
// visibilitychange when the page comes back to the foreground.
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
let wakeLock = null;
|
||||
|
||||
async function acquireWakeLock() {
|
||||
if (!("wakeLock" in navigator)) {
|
||||
debugLog("wakeLock: API not supported on this browser");
|
||||
return;
|
||||
}
|
||||
if (wakeLock) {
|
||||
debugLog("wakeLock: already held, skipping");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
wakeLock = await navigator.wakeLock.request("screen");
|
||||
debugLog("wakeLock: acquired ✓ (screen will stay on)");
|
||||
wakeLock.addEventListener("release", () => {
|
||||
debugLog("wakeLock: released by browser/OS");
|
||||
wakeLock = null;
|
||||
});
|
||||
} catch (e) {
|
||||
debugLog("wakeLock: request failed: " + (e && e.message));
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", async () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
debugLog("visibility: visible — re-acquiring wakeLock");
|
||||
await acquireWakeLock();
|
||||
} else {
|
||||
debugLog("visibility: " + document.visibilityState);
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Setup wizard activation
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
const setupBtn = document.getElementById("nfc_setup_start");
|
||||
if (setupBtn) {
|
||||
setupBtn.addEventListener("click", async () => {
|
||||
debugLog("setup button clicked");
|
||||
try {
|
||||
await startNfcReader();
|
||||
debugLog("setup: NFC ready, starting camera...");
|
||||
try {
|
||||
await startCamera();
|
||||
debugLog("setup: camera ready ✓");
|
||||
} catch (camErr) {
|
||||
debugLog("setup: camera failed: " + camErr.message);
|
||||
if (photoRequired) throw camErr;
|
||||
console.warn("[nfc-kiosk] camera unavailable, continuing (photo not required)", camErr);
|
||||
}
|
||||
await acquireWakeLock();
|
||||
setState(STATE.IDLE);
|
||||
} catch (e) {
|
||||
stateContainer.innerHTML = `
|
||||
<div class="nfc-kiosk__setup">
|
||||
<h2 style="color:#d9374e">Setup failed</h2>
|
||||
<p>${escapeHtml(e.message)}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Mock-tap debug shortcut (only when fusion_clock.nfc_kiosk_debug = True)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
if (debugEnabled) {
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === "T" || e.key === "t")) {
|
||||
e.preventDefault();
|
||||
const stored = localStorage.getItem("nfc_mock_uid") || "04:DE:AD:BE:EF:01";
|
||||
const uid = prompt(`Mock-tap UID (last used: ${stored}):`, stored);
|
||||
if (!uid) return;
|
||||
localStorage.setItem("nfc_mock_uid", uid);
|
||||
if (currentState === STATE.ENROLL) {
|
||||
_onEnrollTap(uid.toUpperCase());
|
||||
} else if (currentState === STATE.IDLE) {
|
||||
handleTap(uid.toUpperCase());
|
||||
}
|
||||
}
|
||||
});
|
||||
console.info("[nfc-kiosk] mock-tap debug enabled — Ctrl+Shift+T to fire a tap");
|
||||
}
|
||||
|
||||
window.__nfcKiosk = {
|
||||
setState, STATE, photoRequired, debugEnabled, locationConfigured,
|
||||
handleTap, _onEnrollTap, // handleTap for mock-tap debug (Task 19)
|
||||
};
|
||||
})();
|
||||
593
fusion_clock/static/src/scss/nfc_kiosk.scss
Normal file
593
fusion_clock/static/src/scss/nfc_kiosk.scss
Normal file
@@ -0,0 +1,593 @@
|
||||
// NFC Clock Kiosk — premium glass + animated mesh, always-dark.
|
||||
//
|
||||
// CRITICAL: All styles in this file are scoped under `:has(#nfc_kiosk_root)`
|
||||
// to prevent leaking into other frontend pages. The previous version applied
|
||||
// `html,body { overflow:hidden; height:100vh }` and `header,footer{display:none}`
|
||||
// globally, which broke website scrolling and chrome on every frontend page.
|
||||
//
|
||||
// The single CSS custom property `--nfc-h` (hue, 0–360) is set by JS after
|
||||
// extracting the dominant color from the company logo. All colors interpolate
|
||||
// from that hue via HSL, so the entire palette adapts to the customer brand.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Defaults (overridden by JS once logo dominant-hue is extracted)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
:root {
|
||||
--nfc-h: 220; // fallback aurora-blue hue
|
||||
--nfc-bg: #0b0d10;
|
||||
--nfc-text: #ffffff;
|
||||
--nfc-text-muted: #9ba3ad;
|
||||
--nfc-success: #18a957;
|
||||
--nfc-error: #d9374e;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Page-level styling — ONLY when the kiosk is on the page
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
html:has(#nfc_kiosk_root) {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--nfc-bg) !important;
|
||||
color: var(--nfc-text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
// Hide site chrome on the kiosk page only
|
||||
.o_main_navbar, header, footer, .o_header_standard, .o_footer { display: none !important; }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Kiosk root container with animated mesh gradient background
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
.nfc-kiosk {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overflow: hidden;
|
||||
background: var(--nfc-bg);
|
||||
|
||||
// Animated mesh gradient (drifts behind everything)
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -15%;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, hsla(var(--nfc-h), 75%, 40%, 0.55) 0%, transparent 45%),
|
||||
radial-gradient(circle at 80% 20%, hsla(calc(var(--nfc-h) + 40), 65%, 35%, 0.50) 0%, transparent 50%),
|
||||
radial-gradient(circle at 70% 75%, hsla(calc(var(--nfc-h) - 25), 70%, 35%, 0.45) 0%, transparent 55%),
|
||||
radial-gradient(circle at 15% 85%, hsla(calc(var(--nfc-h) + 80), 60%, 30%, 0.40) 0%, transparent 50%);
|
||||
filter: blur(60px) saturate(140%);
|
||||
animation: nfc-mesh-drift 28s ease-in-out infinite alternate;
|
||||
z-index: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
// Subtle vignette on top so edges don't feel washed out
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.45) 100%);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> * { position: relative; z-index: 2; }
|
||||
}
|
||||
|
||||
@keyframes nfc-mesh-drift {
|
||||
0% { transform: translate(0%, 0%) rotate(0deg) scale(1); }
|
||||
50% { transform: translate(3%, -2%) rotate(2deg) scale(1.05); }
|
||||
100% { transform: translate(-3%, 3%) rotate(-1deg) scale(0.98); }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Header chrome — logo, time, date, location, settings
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Logo centered at the top, on a subtle frosted-glass pill — just enough lift
|
||||
// to keep dark logos readable on the dark gradient.
|
||||
.nfc-kiosk__logo {
|
||||
position: absolute;
|
||||
top: 1.25rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-height: 52px;
|
||||
max-width: 220px;
|
||||
object-fit: contain;
|
||||
background: rgba(255, 255, 255, 0.20);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
padding: 0.5rem 0.85rem;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
box-shadow:
|
||||
0 6px 24px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
box-sizing: content-box;
|
||||
animation: nfc-logo-in 1.2s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes nfc-logo-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(-12px) scale(0.94); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
.nfc-kiosk__company {
|
||||
position: absolute;
|
||||
top: 5.75rem;
|
||||
left: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--nfc-text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
// When a logo is present, the company-name text is redundant — hide it.
|
||||
.nfc-kiosk__logo ~ .nfc-kiosk__company { display: none; }
|
||||
|
||||
// Clock + date stack vertically below the logo, all centered.
|
||||
.nfc-kiosk__time {
|
||||
position: absolute;
|
||||
top: 6.25rem; // sits below the logo (logo bottom ≈ 90px)
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 2.5rem;
|
||||
font-weight: 300;
|
||||
color: var(--nfc-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
text-shadow: 0 2px 12px rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
white-space: nowrap;
|
||||
|
||||
.ampm {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--nfc-text-muted);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
}
|
||||
|
||||
.nfc-kiosk__date {
|
||||
position: absolute;
|
||||
top: 9.75rem; // sits below the time
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.85rem;
|
||||
color: var(--nfc-text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nfc-kiosk__location {
|
||||
position: absolute;
|
||||
bottom: 1.5rem;
|
||||
left: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--nfc-text-muted);
|
||||
background: rgba(255,255,255,0.04);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.nfc-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.04);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
color: var(--nfc-text-muted);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 200ms ease, border-color 200ms ease, transform 200ms ease;
|
||||
|
||||
&:hover, &:active {
|
||||
color: var(--nfc-text);
|
||||
border-color: rgba(255,255,255,0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Reusable glass panel
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
%nfc-glass {
|
||||
background: rgba(255,255,255,0.05);
|
||||
backdrop-filter: blur(24px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(160%);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.5),
|
||||
inset 0 1px 0 rgba(255,255,255,0.08);
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// State container — base fade-in for whatever child renders
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
#nfc_state_container > * {
|
||||
animation: nfc-state-in 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes nfc-state-in {
|
||||
from { opacity: 0; transform: scale(0.96) translateY(10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// IDLE — large NFC icon + prompt
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
.nfc-kiosk__idle {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.nfc-kiosk__icon-svg {
|
||||
width: 14rem;
|
||||
height: 14rem;
|
||||
overflow: visible; // let waves expand past viewBox without clipping
|
||||
color: hsl(var(--nfc-h), 80%, 65%);
|
||||
filter: drop-shadow(0 0 30px hsla(var(--nfc-h), 80%, 55%, 0.6));
|
||||
|
||||
.nfc-chip {
|
||||
animation: nfc-chip-pulse 2.5s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.nfc-wave {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box; // scale around the wave's own center, not viewBox origin
|
||||
opacity: 0;
|
||||
animation: nfc-wave-emit 2.5s ease-out infinite;
|
||||
}
|
||||
.nfc-wave-1 { animation-delay: 0s; }
|
||||
.nfc-wave-2 { animation-delay: 0.6s; }
|
||||
.nfc-wave-3 { animation-delay: 1.2s; }
|
||||
}
|
||||
|
||||
@keyframes nfc-chip-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes nfc-wave-emit {
|
||||
0% { transform: scale(0.6); opacity: 0; }
|
||||
25% { opacity: 0.85; }
|
||||
80% { opacity: 0.25; }
|
||||
100% { transform: scale(1.35); opacity: 0; }
|
||||
}
|
||||
|
||||
.nfc-kiosk__prompt {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 300;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--nfc-text);
|
||||
text-shadow: 0 2px 20px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// PROCESSING — pulsing dots
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
.nfc-kiosk__processing {
|
||||
@extend %nfc-glass;
|
||||
padding: 2.5rem 3.5rem;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
color: var(--nfc-text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.dots {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
|
||||
span {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--nfc-h), 80%, 65%);
|
||||
animation: nfc-dot-bounce 1.2s ease-in-out infinite;
|
||||
|
||||
&:nth-child(2) { animation-delay: 0.15s; }
|
||||
&:nth-child(3) { animation-delay: 0.3s; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nfc-dot-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.7); opacity: 0.5; }
|
||||
40% { transform: scale(1.0); opacity: 1.0; }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// RESULT — glass card with avatar + name + action
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
.nfc-kiosk__result {
|
||||
@extend %nfc-glass;
|
||||
width: 80vw;
|
||||
max-width: 720px;
|
||||
padding: 2.5rem 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
position: relative;
|
||||
|
||||
&--success {
|
||||
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),
|
||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
animation: nfc-success-burst 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
&--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),
|
||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
animation: nfc-shake 350ms ease-in-out, nfc-state-in 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nfc-success-burst {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.88);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.5),
|
||||
0 0 0 rgba(24,169,87,0),
|
||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.5),
|
||||
0 0 140px rgba(24,169,87,0.7),
|
||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
box-shadow:
|
||||
0 20px 60px rgba(0,0,0,0.5),
|
||||
0 0 80px rgba(24,169,87,0.35),
|
||||
inset 0 1px 0 rgba(255,255,255,0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nfc-shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-10px); }
|
||||
40% { transform: translateX(10px); }
|
||||
60% { transform: translateX(-6px); }
|
||||
80% { transform: translateX(6px); }
|
||||
}
|
||||
|
||||
.nfc-kiosk__avatar {
|
||||
width: 7rem;
|
||||
height: 7rem;
|
||||
border-radius: 50%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: rgba(255,255,255,0.15);
|
||||
flex-shrink: 0;
|
||||
border: 2px solid rgba(255,255,255,0.2);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
animation: nfc-avatar-in 600ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
@keyframes nfc-avatar-in {
|
||||
from { opacity: 0; transform: scale(0.4); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.nfc-kiosk__result-text {
|
||||
flex: 1;
|
||||
|
||||
.name { font-size: 2.25rem; font-weight: 600; letter-spacing: -0.02em; }
|
||||
.action { font-size: 1.5rem; margin-top: 0.5rem; opacity: 0.95; font-weight: 400; }
|
||||
.hours { font-size: 1.05rem; opacity: 0.75; margin-top: 0.5rem; }
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// SETUP wizard
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
.nfc-kiosk__setup {
|
||||
@extend %nfc-glass;
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
padding: 3.5rem 3rem;
|
||||
|
||||
h2 { font-size: 2rem; margin-bottom: 1rem; font-weight: 300; letter-spacing: -0.01em; }
|
||||
p { color: var(--nfc-text-muted); margin-bottom: 2rem; line-height: 1.5; }
|
||||
|
||||
button {
|
||||
font-size: 1.25rem;
|
||||
padding: 1rem 2.5rem;
|
||||
background: hsl(var(--nfc-h), 80%, 55%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 8px 32px hsla(var(--nfc-h), 80%, 50%, 0.4);
|
||||
transition: transform 200ms ease, box-shadow 200ms ease;
|
||||
|
||||
&:hover, &:active {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 36px hsla(var(--nfc-h), 80%, 50%, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// ENROLL Mode overlay — glass panel with numpad / search / tap-prompt
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
.nfc-kiosk__enroll-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
animation: nfc-overlay-in 250ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes nfc-overlay-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.nfc-kiosk__enroll-panel {
|
||||
@extend %nfc-glass;
|
||||
padding: 2.5rem;
|
||||
width: 80vw;
|
||||
max-width: 720px;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--nfc-text);
|
||||
}
|
||||
|
||||
.numpad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
|
||||
button {
|
||||
font-size: 2rem;
|
||||
padding: 1.5rem 0;
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--nfc-text);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, transform 100ms ease;
|
||||
font-weight: 300;
|
||||
|
||||
&:hover { background: rgba(255,255,255,0.1); }
|
||||
&:active { background: rgba(255,255,255,0.15); transform: scale(0.96); }
|
||||
}
|
||||
}
|
||||
|
||||
.pin-display {
|
||||
font-size: 2.5rem;
|
||||
letter-spacing: 0.5rem;
|
||||
text-align: center;
|
||||
margin: 1rem 0 1.5rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-height: 3rem;
|
||||
color: hsl(var(--nfc-h), 80%, 70%);
|
||||
}
|
||||
|
||||
.employee-search {
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem;
|
||||
font-size: 1.15rem;
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: var(--nfc-text);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
|
||||
&:focus { border-color: hsl(var(--nfc-h), 80%, 55%); }
|
||||
&::placeholder { color: var(--nfc-text-muted); }
|
||||
}
|
||||
|
||||
.employee-list {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
|
||||
.employee-row {
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
cursor: pointer;
|
||||
font-size: 1.05rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background 150ms ease;
|
||||
|
||||
&:hover, &:active { background: rgba(255,255,255,0.06); }
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
|
||||
button {
|
||||
font-size: 1rem;
|
||||
padding: 0.85rem 1.75rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
transition: transform 150ms ease, opacity 150ms ease;
|
||||
|
||||
&:hover, &:active { transform: scale(0.97); }
|
||||
}
|
||||
.cancel { background: rgba(255,255,255,0.08); color: var(--nfc-text); }
|
||||
.confirm {
|
||||
background: hsl(var(--nfc-h), 80%, 55%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 20px hsla(var(--nfc-h), 80%, 50%, 0.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Reduced-motion fallback — respect users who prefer no animation
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nfc-kiosk::before { animation: none; }
|
||||
.nfc-kiosk__icon-svg .nfc-wave,
|
||||
.nfc-kiosk__icon-svg .nfc-chip,
|
||||
#nfc_state_container > *,
|
||||
.nfc-kiosk__logo,
|
||||
.nfc-kiosk__result--success,
|
||||
.nfc-kiosk__result--error,
|
||||
.nfc-kiosk__avatar { animation: none; }
|
||||
}
|
||||
4
fusion_clock/tests/__init__.py
Normal file
4
fusion_clock/tests/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import test_nfc_models
|
||||
from . import test_clock_nfc_kiosk
|
||||
413
fusion_clock/tests/test_clock_nfc_kiosk.py
Normal file
413
fusion_clock/tests/test_clock_nfc_kiosk.py
Normal file
@@ -0,0 +1,413 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests.common import HttpCase, tagged
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestNfcKioskController(HttpCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.location = cls.env['fusion.clock.location'].create({
|
||||
'name': 'Test Plant',
|
||||
'latitude': 43.65,
|
||||
'longitude': -79.38,
|
||||
'radius': 100,
|
||||
})
|
||||
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
|
||||
cls.kiosk_user = cls.env['res.users'].create({
|
||||
'name': 'NFC Kiosk User',
|
||||
'login': 'nfc-kiosk-test',
|
||||
'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
|
||||
def test_kiosk_page_redirects_when_disabled(self):
|
||||
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'False')
|
||||
self.authenticate('nfc-kiosk-test', 'kioskpass123')
|
||||
response = self.url_open('/fusion_clock/kiosk/nfc', allow_redirects=False)
|
||||
self.assertIn(response.status_code, (301, 302, 303))
|
||||
|
||||
def test_kiosk_page_renders_when_enabled(self):
|
||||
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
self.authenticate('nfc-kiosk-test', 'kioskpass123')
|
||||
response = self.url_open('/fusion_clock/kiosk/nfc')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('nfc_kiosk_root', response.text)
|
||||
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.addons.fusion_clock.controllers.clock_nfc_kiosk import FusionClockNfcKiosk
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestUidNormalization(TransactionCase):
|
||||
|
||||
def test_lowercase_input_uppercased(self):
|
||||
self.assertEqual(
|
||||
FusionClockNfcKiosk._normalize_uid('04:a2:b5:62:c1:80'),
|
||||
'04:A2:B5:62:C1:80',
|
||||
)
|
||||
|
||||
def test_no_separator_input_gets_colons(self):
|
||||
self.assertEqual(
|
||||
FusionClockNfcKiosk._normalize_uid('04A2B562C180'),
|
||||
'04:A2:B5:62:C1:80',
|
||||
)
|
||||
|
||||
def test_dash_separator_replaced(self):
|
||||
self.assertEqual(
|
||||
FusionClockNfcKiosk._normalize_uid('04-A2-B5-62-C1-80'),
|
||||
'04:A2:B5:62:C1:80',
|
||||
)
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
self.assertEqual(
|
||||
FusionClockNfcKiosk._normalize_uid(' 04:A2:B5:62:C1:80 '),
|
||||
'04:A2:B5:62:C1:80',
|
||||
)
|
||||
|
||||
def test_empty_input_returns_none(self):
|
||||
self.assertIsNone(FusionClockNfcKiosk._normalize_uid(''))
|
||||
self.assertIsNone(FusionClockNfcKiosk._normalize_uid(None))
|
||||
|
||||
def test_invalid_chars_returns_none(self):
|
||||
self.assertIsNone(FusionClockNfcKiosk._normalize_uid('not-a-uid'))
|
||||
self.assertIsNone(FusionClockNfcKiosk._normalize_uid('04:A2:ZZ:62:C1:80'))
|
||||
|
||||
def test_odd_length_returns_none(self):
|
||||
self.assertIsNone(FusionClockNfcKiosk._normalize_uid('04A2B562C18'))
|
||||
|
||||
|
||||
import json
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestEnrollEndpoint(HttpCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
cls.ICP.set_param('fusion_clock.nfc_enroll_password', '1234')
|
||||
cls.kiosk_user = cls.env['res.users'].create({
|
||||
'name': 'Enroll Kiosk User',
|
||||
'login': 'nfc-kiosk-enroll',
|
||||
'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
cls.alice = cls.env['hr.employee'].create({'name': 'Alice E', 'x_fclk_enable_clock': True})
|
||||
cls.bob = cls.env['hr.employee'].create({'name': 'Bob E', 'x_fclk_enable_clock': True})
|
||||
|
||||
def _call(self, payload):
|
||||
self.authenticate('nfc-kiosk-enroll', 'kioskpass123')
|
||||
response = self.url_open(
|
||||
'/fusion_clock/kiosk/nfc/enroll',
|
||||
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return response.json().get('result', {})
|
||||
|
||||
def test_enroll_success(self):
|
||||
result = self._call({
|
||||
'employee_id': self.alice.id,
|
||||
'card_uid': '04:a2:b5:62:c1:80',
|
||||
'enroll_password': '1234',
|
||||
})
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertEqual(result.get('card_uid'), '04:A2:B5:62:C1:80')
|
||||
self.alice.invalidate_recordset()
|
||||
self.assertEqual(self.alice.x_fclk_nfc_card_uid, '04:A2:B5:62:C1:80')
|
||||
|
||||
def test_enroll_wrong_password(self):
|
||||
result = self._call({
|
||||
'employee_id': self.alice.id,
|
||||
'card_uid': '04:A2:B5:62:C1:81',
|
||||
'enroll_password': 'wrong',
|
||||
})
|
||||
self.assertEqual(result.get('error'), 'invalid_password')
|
||||
self.alice.invalidate_recordset()
|
||||
self.assertFalse(self.alice.x_fclk_nfc_card_uid)
|
||||
|
||||
def test_enroll_card_already_assigned(self):
|
||||
self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:82'
|
||||
result = self._call({
|
||||
'employee_id': self.bob.id,
|
||||
'card_uid': '04:A2:B5:62:C1:82',
|
||||
'enroll_password': '1234',
|
||||
})
|
||||
self.assertEqual(result.get('error'), 'card_already_assigned')
|
||||
self.assertEqual(result.get('existing_employee'), 'Alice E')
|
||||
self.bob.invalidate_recordset()
|
||||
self.assertFalse(self.bob.x_fclk_nfc_card_uid)
|
||||
|
||||
def test_enroll_invalid_uid(self):
|
||||
result = self._call({
|
||||
'employee_id': self.alice.id,
|
||||
'card_uid': 'not-a-uid',
|
||||
'enroll_password': '1234',
|
||||
})
|
||||
self.assertEqual(result.get('error'), 'invalid_uid')
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestTapEndpointHappyPath(HttpCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
|
||||
cls.location = cls.env['fusion.clock.location'].create({
|
||||
'name': 'Tap Plant',
|
||||
'latitude': 43.65,
|
||||
'longitude': -79.38,
|
||||
'radius': 100,
|
||||
})
|
||||
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
|
||||
cls.kiosk_user = cls.env['res.users'].create({
|
||||
'name': 'Tap Kiosk User',
|
||||
'login': 'nfc-kiosk-tap',
|
||||
'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
cls.alice = cls.env['hr.employee'].create({
|
||||
'name': 'Alice T',
|
||||
'x_fclk_enable_clock': True,
|
||||
'x_fclk_nfc_card_uid': '04:A2:B5:62:C1:90',
|
||||
})
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Clear module-level debounce cache so tests don't inherit state from other classes
|
||||
from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module
|
||||
nfc_kiosk_module._recent_taps.clear()
|
||||
|
||||
def _tap(self, card_uid='04:A2:B5:62:C1:90', photo_b64=''):
|
||||
self.authenticate('nfc-kiosk-tap', 'kioskpass123')
|
||||
response = self.url_open(
|
||||
'/fusion_clock/kiosk/nfc/tap',
|
||||
data=json.dumps({
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'call',
|
||||
'params': {'card_uid': card_uid, 'photo_b64': photo_b64},
|
||||
}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return response.json().get('result', {})
|
||||
|
||||
def test_first_tap_clocks_in(self):
|
||||
result = self._tap()
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertEqual(result.get('action'), 'clock_in')
|
||||
self.assertEqual(result.get('employee_name'), 'Alice T')
|
||||
attendance = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', self.alice.id),
|
||||
], order='check_in desc', limit=1)
|
||||
self.assertTrue(attendance)
|
||||
self.assertEqual(attendance.x_fclk_clock_source, 'nfc_kiosk')
|
||||
self.assertEqual(attendance.x_fclk_location_id, self.location)
|
||||
self.assertFalse(attendance.check_out)
|
||||
|
||||
def test_second_tap_clocks_out(self):
|
||||
self._tap()
|
||||
# Wait for debounce window (5s) to elapse
|
||||
import time
|
||||
time.sleep(6)
|
||||
result = self._tap()
|
||||
self.assertTrue(result.get('success'))
|
||||
self.assertEqual(result.get('action'), 'clock_out')
|
||||
attendance = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', self.alice.id),
|
||||
], order='check_in desc', limit=1)
|
||||
self.assertTrue(attendance.check_out)
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestTapEndpointErrors(HttpCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
cls.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
|
||||
cls.location = cls.env['fusion.clock.location'].create({
|
||||
'name': 'Err Plant',
|
||||
'latitude': 43.65,
|
||||
'longitude': -79.38,
|
||||
'radius': 100,
|
||||
})
|
||||
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
|
||||
cls.kiosk_user = cls.env['res.users'].create({
|
||||
'name': 'Err Kiosk User',
|
||||
'login': 'nfc-kiosk-err',
|
||||
'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
cls.disabled_emp = cls.env['hr.employee'].create({
|
||||
'name': 'Disabled E',
|
||||
'x_fclk_enable_clock': False,
|
||||
'x_fclk_nfc_card_uid': '04:A2:B5:62:DE:AD',
|
||||
})
|
||||
cls.active_emp = cls.env['hr.employee'].create({
|
||||
'name': 'Active E',
|
||||
'x_fclk_enable_clock': True,
|
||||
'x_fclk_nfc_card_uid': '04:A2:B5:62:AC:01',
|
||||
})
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Clear module-level debounce cache so tests don't bleed into each other
|
||||
from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module
|
||||
nfc_kiosk_module._recent_taps.clear()
|
||||
# Reset ICP to known-good defaults before each test
|
||||
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
self.env.company.x_fclk_nfc_kiosk_location_id = self.location.id
|
||||
|
||||
def _tap(self, card_uid):
|
||||
self.authenticate('nfc-kiosk-err', 'kioskpass123')
|
||||
response = self.url_open(
|
||||
'/fusion_clock/kiosk/nfc/tap',
|
||||
data=json.dumps({
|
||||
'jsonrpc': '2.0', 'method': 'call',
|
||||
'params': {'card_uid': card_uid, 'photo_b64': ''},
|
||||
}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return response.json().get('result', {})
|
||||
|
||||
def test_unknown_card(self):
|
||||
result = self._tap('04:00:00:00:00:00')
|
||||
self.assertEqual(result.get('error'), 'card_unknown')
|
||||
|
||||
def test_disabled_employee(self):
|
||||
result = self._tap('04:A2:B5:62:DE:AD')
|
||||
self.assertEqual(result.get('error'), 'clock_disabled')
|
||||
|
||||
def test_no_location_configured(self):
|
||||
self.env.company.x_fclk_nfc_kiosk_location_id = False
|
||||
result = self._tap('04:A2:B5:62:AC:01')
|
||||
self.assertEqual(result.get('error'), 'no_location_configured')
|
||||
|
||||
def test_kiosk_disabled(self):
|
||||
self.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'False')
|
||||
result = self._tap('04:A2:B5:62:AC:01')
|
||||
self.assertEqual(result.get('error'), 'kiosk_disabled')
|
||||
|
||||
def test_invalid_uid(self):
|
||||
result = self._tap('not-a-uid')
|
||||
self.assertEqual(result.get('error'), 'invalid_uid')
|
||||
|
||||
def test_debounce_silent_second_tap(self):
|
||||
first = self._tap('04:A2:B5:62:AC:01')
|
||||
self.assertTrue(first.get('success'))
|
||||
second = self._tap('04:A2:B5:62:AC:01')
|
||||
self.assertEqual(second.get('error'), 'debounce')
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestTapPhotoHandling(HttpCase):
|
||||
|
||||
SAMPLE_PNG_DATAURL = (
|
||||
'data:image/png;base64,'
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAA'
|
||||
'C0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
cls.location = cls.env['fusion.clock.location'].create({
|
||||
'name': 'Photo Plant',
|
||||
'latitude': 43.65,
|
||||
'longitude': -79.38,
|
||||
'radius': 100,
|
||||
})
|
||||
cls.env.company.x_fclk_nfc_kiosk_location_id = cls.location.id
|
||||
cls.kiosk_user = cls.env['res.users'].create({
|
||||
'name': 'Photo Kiosk User',
|
||||
'login': 'nfc-kiosk-photo',
|
||||
'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
cls.emp = cls.env['hr.employee'].create({
|
||||
'name': 'Photo Emp',
|
||||
'x_fclk_enable_clock': True,
|
||||
'x_fclk_nfc_card_uid': '04:A2:B5:62:F0:01',
|
||||
})
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Avoid debounce contamination from other test classes
|
||||
from odoo.addons.fusion_clock.controllers import clock_nfc_kiosk as nfc_kiosk_module
|
||||
nfc_kiosk_module._recent_taps.clear()
|
||||
|
||||
def _tap(self, photo_b64=''):
|
||||
self.authenticate('nfc-kiosk-photo', 'kioskpass123')
|
||||
response = self.url_open(
|
||||
'/fusion_clock/kiosk/nfc/tap',
|
||||
data=json.dumps({
|
||||
'jsonrpc': '2.0', 'method': 'call',
|
||||
'params': {'card_uid': '04:A2:B5:62:F0:01', 'photo_b64': photo_b64},
|
||||
}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
return response.json().get('result', {})
|
||||
|
||||
def test_photo_saved_on_clock_in(self):
|
||||
self.ICP.set_param('fusion_clock.nfc_photo_required', 'True')
|
||||
result = self._tap(self.SAMPLE_PNG_DATAURL)
|
||||
self.assertTrue(result.get('success'))
|
||||
attendance = self.env['hr.attendance'].search([
|
||||
('employee_id', '=', self.emp.id),
|
||||
], order='check_in desc', limit=1)
|
||||
self.assertTrue(attendance.x_fclk_check_in_photo)
|
||||
|
||||
def test_photo_required_rejects_when_missing(self):
|
||||
self.ICP.set_param('fusion_clock.nfc_photo_required', 'True')
|
||||
result = self._tap(photo_b64='')
|
||||
self.assertEqual(result.get('error'), 'photo_required')
|
||||
|
||||
def test_photo_optional_succeeds_without_photo(self):
|
||||
self.ICP.set_param('fusion_clock.nfc_photo_required', 'False')
|
||||
result = self._tap(photo_b64='')
|
||||
self.assertTrue(result.get('success'))
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestEmployeeSearch(HttpCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.ICP = cls.env['ir.config_parameter'].sudo()
|
||||
cls.ICP.set_param('fusion_clock.enable_nfc_kiosk', 'True')
|
||||
cls.kiosk_user = cls.env['res.users'].create({
|
||||
'name': 'Search Kiosk User',
|
||||
'login': 'nfc-kiosk-search',
|
||||
'password': 'kioskpass123',
|
||||
'group_ids': [(4, cls.env.ref('fusion_clock.group_fusion_clock_manager').id)],
|
||||
})
|
||||
cls.env['hr.employee'].create({'name': 'Searchable Steve', 'x_fclk_enable_clock': True})
|
||||
|
||||
def test_search_returns_matching_employees(self):
|
||||
self.authenticate('nfc-kiosk-search', 'kioskpass123')
|
||||
response = self.url_open(
|
||||
'/fusion_clock/kiosk/nfc/employee_search',
|
||||
data=json.dumps({
|
||||
'jsonrpc': '2.0', 'method': 'call',
|
||||
'params': {'query': 'Steve'},
|
||||
}),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
result = response.json().get('result', {})
|
||||
self.assertIn('employees', result)
|
||||
names = [e['name'] for e in result['employees']]
|
||||
self.assertIn('Searchable Steve', names)
|
||||
103
fusion_clock/tests/test_nfc_models.py
Normal file
103
fusion_clock/tests/test_nfc_models.py
Normal file
@@ -0,0 +1,103 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from psycopg2 import IntegrityError
|
||||
from odoo.tools.misc import mute_logger
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestNfcModels(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.Employee = cls.env['hr.employee']
|
||||
cls.alice = cls.Employee.create({'name': 'Alice NFC', 'x_fclk_enable_clock': True})
|
||||
cls.bob = cls.Employee.create({'name': 'Bob NFC', 'x_fclk_enable_clock': True})
|
||||
|
||||
def test_card_uid_is_writable(self):
|
||||
self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
|
||||
self.assertEqual(self.alice.x_fclk_nfc_card_uid, '04:A2:B5:62:C1:80')
|
||||
|
||||
def test_card_uid_is_unique_when_set(self):
|
||||
self.alice.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
|
||||
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
|
||||
with self.env.cr.savepoint():
|
||||
self.bob.x_fclk_nfc_card_uid = '04:A2:B5:62:C1:80'
|
||||
self.bob.flush_recordset(['x_fclk_nfc_card_uid'])
|
||||
|
||||
def test_card_uid_can_be_null_for_multiple_employees(self):
|
||||
self.alice.x_fclk_nfc_card_uid = False
|
||||
self.bob.x_fclk_nfc_card_uid = False
|
||||
self.assertFalse(self.alice.x_fclk_nfc_card_uid)
|
||||
self.assertFalse(self.bob.x_fclk_nfc_card_uid)
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestNfcAttendanceFields(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.employee = cls.env['hr.employee'].create({
|
||||
'name': 'NFC Test Employee',
|
||||
'x_fclk_enable_clock': True,
|
||||
})
|
||||
|
||||
def test_clock_source_includes_nfc_kiosk(self):
|
||||
attendance = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': '2026-05-13 08:00:00',
|
||||
'x_fclk_clock_source': 'nfc_kiosk',
|
||||
})
|
||||
self.assertEqual(attendance.x_fclk_clock_source, 'nfc_kiosk')
|
||||
|
||||
def test_photo_fields_accept_binary(self):
|
||||
# 1x1 transparent PNG as base64
|
||||
png_b64 = (
|
||||
b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAA'
|
||||
b'C0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
||||
)
|
||||
attendance = self.env['hr.attendance'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'check_in': '2026-05-13 08:00:00',
|
||||
'x_fclk_check_in_photo': png_b64,
|
||||
})
|
||||
self.assertTrue(attendance.x_fclk_check_in_photo)
|
||||
|
||||
def test_activity_log_accepts_new_selections(self):
|
||||
log = self.env['fusion.clock.activity.log'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'log_type': 'card_enrollment',
|
||||
'source': 'nfc_kiosk',
|
||||
'description': 'Test enrollment log',
|
||||
})
|
||||
self.assertEqual(log.log_type, 'card_enrollment')
|
||||
self.assertEqual(log.source, 'nfc_kiosk')
|
||||
|
||||
log2 = self.env['fusion.clock.activity.log'].create({
|
||||
'employee_id': self.employee.id,
|
||||
'log_type': 'unknown_card_tap',
|
||||
'source': 'nfc_kiosk',
|
||||
'description': 'Test unknown card log',
|
||||
})
|
||||
self.assertEqual(log2.log_type, 'unknown_card_tap')
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install', 'fusion_clock')
|
||||
class TestNfcKioskCompanyField(TransactionCase):
|
||||
|
||||
def test_company_has_nfc_kiosk_location(self):
|
||||
company = self.env['res.company'].create({'name': 'NFC Test Co Plant'})
|
||||
location = self.env['fusion.clock.location'].create({
|
||||
'name': 'Plant 1',
|
||||
'latitude': 43.65,
|
||||
'longitude': -79.38,
|
||||
'radius': 100,
|
||||
})
|
||||
company.x_fclk_nfc_kiosk_location_id = location.id
|
||||
self.assertEqual(company.x_fclk_nfc_kiosk_location_id, location)
|
||||
|
||||
def test_company_field_defaults_to_false(self):
|
||||
new_company = self.env['res.company'].create({'name': 'Test Co NFC'})
|
||||
self.assertFalse(new_company.x_fclk_nfc_kiosk_location_id)
|
||||
54
fusion_clock/views/kiosk_nfc_templates.xml
Normal file
54
fusion_clock/views/kiosk_nfc_templates.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="nfc_kiosk_page" name="NFC Clock Kiosk">
|
||||
<t t-call="web.frontend_layout">
|
||||
<t t-set="no_header" t-value="True"/>
|
||||
<t t-set="no_footer" t-value="True"/>
|
||||
<t t-set="head">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"/>
|
||||
</t>
|
||||
|
||||
<div id="nfc_kiosk_root" class="nfc-kiosk"
|
||||
t-att-data-photo-required="'1' if photo_required else '0'"
|
||||
t-att-data-debug-enabled="'1' if debug_enabled else '0'"
|
||||
t-att-data-location-configured="'1' if location_configured else '0'"
|
||||
t-att-data-company-logo-url="company_logo_url or ''">
|
||||
|
||||
<!-- Company logo (also drives the dominant-hue palette via JS) -->
|
||||
<img t-if="company_logo_url"
|
||||
class="nfc-kiosk__logo"
|
||||
id="nfc_company_logo"
|
||||
t-att-src="company_logo_url"
|
||||
crossorigin="anonymous"
|
||||
alt="Company logo"/>
|
||||
|
||||
<!-- Static chrome (always visible) -->
|
||||
<div class="nfc-kiosk__company" t-esc="company_name"/>
|
||||
<div class="nfc-kiosk__time" id="nfc_clock_time">--:--</div>
|
||||
<div class="nfc-kiosk__date" id="nfc_clock_date">—</div>
|
||||
<div class="nfc-kiosk__location">
|
||||
<span t-if="location_configured">Clock at: <t t-esc="location_name"/></span>
|
||||
<span t-else="" style="color:#d9374e">⚠ No location configured</span>
|
||||
</div>
|
||||
<button class="nfc-kiosk__settings" id="nfc_settings_btn" title="Enroll Mode">⚙</button>
|
||||
|
||||
<!-- Dynamic state container (JS swaps inner HTML based on state) -->
|
||||
<div id="nfc_state_container">
|
||||
<!-- Initial: One-time setup wizard -->
|
||||
<div class="nfc-kiosk__setup">
|
||||
<h2>Welcome to Fusion Clock NFC Kiosk</h2>
|
||||
<p>Tap the button below to enable the NFC reader and camera. This is a one-time setup for this device.</p>
|
||||
<button id="nfc_setup_start">Tap to enable NFC reader</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden video + canvas for camera capture -->
|
||||
<video id="nfc_camera_feed" autoplay="autoplay" playsinline="playsinline" muted="muted"
|
||||
style="position:absolute; width:1px; height:1px; opacity:0; pointer-events:none;"/>
|
||||
<canvas id="nfc_camera_canvas" style="display:none;"/>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -242,6 +242,34 @@
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- NFC Clock Kiosk -->
|
||||
<!-- ============================================================ -->
|
||||
<block title="NFC Clock Kiosk" name="fclk_nfc_kiosk">
|
||||
<setting id="fclk_nfc_enable" string="Enable NFC Kiosk"
|
||||
help="Tap-to-clock kiosk for shop-floor tablets at /fusion_clock/kiosk/nfc">
|
||||
<field name="fclk_enable_nfc_kiosk"/>
|
||||
<div class="content-group" invisible="not fclk_enable_nfc_kiosk">
|
||||
<div class="row mt16">
|
||||
<label for="fclk_nfc_kiosk_location_id" string="Location" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_nfc_kiosk_location_id"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_nfc_photo_required" string="Require Photo" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_nfc_photo_required"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_nfc_enroll_password" string="Enroll Password" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_nfc_enroll_password" password="True"/>
|
||||
</div>
|
||||
<div class="row mt8">
|
||||
<label for="fclk_nfc_kiosk_debug" string="Debug Overlay" class="col-lg-5 o_light_label"/>
|
||||
<field name="fclk_nfc_kiosk_debug"/>
|
||||
</div>
|
||||
</div>
|
||||
</setting>
|
||||
</block>
|
||||
|
||||
</app>
|
||||
</xpath>
|
||||
</field>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,439 @@
|
||||
# Sticker — Multi-part, Per-box, Internal/External Variants
|
||||
|
||||
**Date:** 2026-05-13
|
||||
**Module(s):** `fusion_plating_jobs`, `fusion_plating_reports`
|
||||
**Author:** Gurpreet (Nexa Systems Inc.)
|
||||
**Status:** Approved — ready for implementation plan
|
||||
|
||||
## Summary
|
||||
|
||||
The box sticker (printed at SO level and at fp.job level) currently
|
||||
mishandles three real-world scenarios on multi-line orders:
|
||||
|
||||
1. **Silent thickness/SN merge bug.** When two SO lines share
|
||||
`(recipe, part, coating)` but differ in thickness or serial,
|
||||
the current `_create_fp_jobs` grouping collapses them into one
|
||||
`fp.job`. The job inherits the FIRST line's thickness/SN — the
|
||||
other line's values are silently dropped from the sticker (and
|
||||
eventually from the CoC).
|
||||
2. **No per-box stickers.** A line with `qty = 5` prints one
|
||||
sticker showing `Qty: 5`. Operators want one physical label per
|
||||
box, with a `1 / 5`, `2 / 5`, ... indicator.
|
||||
3. **No Internal variant.** The sticker always prints the
|
||||
customer-facing description (`_line.name`) in the Notes column.
|
||||
The shop floor wants a parallel variant that shows the
|
||||
internal ops description (`_line.x_fc_internal_description`,
|
||||
from Sub 2) instead.
|
||||
|
||||
This spec covers all three as a single piece of work — they touch
|
||||
the same files and ship together.
|
||||
|
||||
## Goals / non-goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- Multi-thickness / multi-SN lines split into separate `fp.job`
|
||||
records with correct WO-XXXXX-NN naming.
|
||||
- SO sticker and Job sticker render one page per physical box,
|
||||
with a `Box X / N` indicator replacing the current `Qty: N`.
|
||||
- New "Internal" variant for each sticker that prints the internal
|
||||
description in the Notes column. Existing variant becomes
|
||||
"External".
|
||||
- Both variants share the same inner template — only the Notes
|
||||
source differs.
|
||||
- Existing action XML IDs unchanged so bookmarks and binding
|
||||
records keep working.
|
||||
|
||||
**Non-goals**
|
||||
|
||||
- Per-physical-box serial number tracking (today's `x_fc_serial_id`
|
||||
is one per line, shared across all boxes in that line — that's
|
||||
fine).
|
||||
- Box-count override (today: 1 sticker per qty unit; if the shop
|
||||
packs 5 parts into 1 box, that's an operational choice the
|
||||
sticker doesn't try to encode).
|
||||
- Migration of pre-existing single-line, single-thickness jobs —
|
||||
they remain as-is.
|
||||
|
||||
## Current state (post Sub 11)
|
||||
|
||||
### Backend — `fusion_plating_jobs/models/sale_order.py`
|
||||
|
||||
```python
|
||||
# Inside _create_fp_jobs(), the grouping key:
|
||||
key = (recipe.id, part_id, coating_id)
|
||||
groups[key] = groups.get(key, ...) | line
|
||||
```
|
||||
|
||||
Lines that share ALL THREE collapse into one `fp.job`. Sub 11's
|
||||
comment explicitly calls out the part_id+coating_id check ("sharing
|
||||
only the recipe is not enough — would put Part A's number on a cert
|
||||
covering both") but doesn't extend the same reasoning to thickness
|
||||
or SN. The thickness Many2one (`x_fc_thickness_id`) and serial
|
||||
Many2one (`x_fc_serial_id`) were added in Sub 5, after the grouping
|
||||
logic was last touched.
|
||||
|
||||
### Sticker — `fusion_plating_reports/report/report_fp_wo_sticker.xml`
|
||||
|
||||
Two outer templates wrap a shared inner:
|
||||
|
||||
- `report_fp_so_sticker` (bound to `sale.order` via
|
||||
`action_report_fp_so_sticker`) — iterates
|
||||
`so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)`,
|
||||
renders one inner per line.
|
||||
- `report_fp_job_sticker_template` (in
|
||||
`fusion_plating_jobs/report/report_fp_job_sticker.xml`, bound to
|
||||
`fp.job` via `action_report_fp_job_sticker`) — iterates `docs`,
|
||||
renders one inner per job.
|
||||
|
||||
Neither outer accounts for `qty > 1` — each line/job produces
|
||||
exactly one inner render.
|
||||
|
||||
The inner template `report_fp_wo_sticker_inner` sets variables and
|
||||
renders one page. The Notes content is fixed:
|
||||
|
||||
```xml
|
||||
<t t-set="_notes_content" t-value="(_line and _line.name)
|
||||
or (_part and _part.name)
|
||||
or '-'"/>
|
||||
```
|
||||
|
||||
There is no way for an outer to override this — it's a hard read of
|
||||
`_line.name`.
|
||||
|
||||
## Architecture — the three changes
|
||||
|
||||
### Change 1 — Backend split: extend grouping key
|
||||
|
||||
In `fusion_plating_jobs/models/sale_order.py`, in the method that
|
||||
builds the `groups` dict (currently `_create_fp_jobs` around line
|
||||
424–441), extend the key tuple:
|
||||
|
||||
```python
|
||||
# Before
|
||||
key = (recipe.id, part_id, coating_id)
|
||||
|
||||
# After
|
||||
thickness_id = (
|
||||
'x_fc_thickness_id' in line._fields
|
||||
and line.x_fc_thickness_id.id
|
||||
) or False
|
||||
serial_id = (
|
||||
'x_fc_serial_id' in line._fields
|
||||
and line.x_fc_serial_id.id
|
||||
) or False
|
||||
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
|
||||
```
|
||||
|
||||
**Effect:** Lines that previously merged silently across different
|
||||
thicknesses or SNs now split into separate fp.jobs. WO-XXXXX-NN
|
||||
suffixes apply normally (driven by the existing
|
||||
`ordered_keys = sorted(...)` block — no change needed there).
|
||||
|
||||
**Backwards compat:** Single-line SOs and same-(thickness, SN)
|
||||
multi-line SOs collapse identically to before. No data migration
|
||||
required.
|
||||
|
||||
### Change 2 — Per-box render in the inner template
|
||||
|
||||
`fusion_plating_reports/report/report_fp_wo_sticker.xml`, in the
|
||||
`report_fp_wo_sticker_inner` template:
|
||||
|
||||
1. Move the variable-resolution + style block OUT of the per-page
|
||||
render (these don't change per box, so they don't need to repeat).
|
||||
2. Wrap the `<div class="fp-sticker">` body in a box loop:
|
||||
|
||||
```xml
|
||||
<t t-foreach="range(int(_qty_total or 1))" t-as="_box_idx0">
|
||||
<t t-set="_box_idx" t-value="_box_idx0 + 1"/>
|
||||
<div class="fp-sticker">
|
||||
... existing structure ...
|
||||
</div>
|
||||
</t>
|
||||
```
|
||||
|
||||
3. Change the Qty row's value column to show `X / N` when
|
||||
`_qty_total > 1`:
|
||||
|
||||
```xml
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Qty:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span class="fp-sticker-strong">
|
||||
<t t-if="_qty_total and _qty_total > 1">
|
||||
<span t-esc="_box_idx"/> / <span t-esc="int(_qty_total)"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
|
||||
</t>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
```
|
||||
|
||||
**Outer templates supply `_qty_total`:**
|
||||
|
||||
- SO outer: `_qty_total = line.product_uom_qty`
|
||||
- Job outer: `_qty_total = job.qty`
|
||||
|
||||
If `_qty_total` is missing/zero, fall back to `1` so single-box
|
||||
behavior is unchanged.
|
||||
|
||||
### Change 3 — Internal/External variants
|
||||
|
||||
#### 3a. Inner template: override-or-fallback on `_notes_content`
|
||||
|
||||
In `report_fp_wo_sticker_inner`, change the `_notes_content` set
|
||||
from a hard read to override-or-fallback (matches the existing
|
||||
pattern for `_so`, `_part`, etc.):
|
||||
|
||||
```xml
|
||||
<!-- Was: -->
|
||||
<t t-set="_notes_content" t-value="(_line and _line.name)
|
||||
or (_part and _part.name)
|
||||
or '-'"/>
|
||||
|
||||
<!-- After: -->
|
||||
<t t-set="_notes_content" t-value="_notes_content
|
||||
or (_line and _line.name)
|
||||
or (_part and _part.name)
|
||||
or '-'"/>
|
||||
```
|
||||
|
||||
External outer templates don't set `_notes_content` → falls through
|
||||
to `_line.name` (unchanged External behavior).
|
||||
|
||||
Internal outer templates pre-set `_notes_content` before
|
||||
t-calling the inner:
|
||||
|
||||
```xml
|
||||
<t t-set="_notes_content" t-value="(_line and 'x_fc_internal_description' in _line._fields
|
||||
and _line.x_fc_internal_description) or '-'"/>
|
||||
```
|
||||
|
||||
#### 3b. New outer templates + action records
|
||||
|
||||
**SO Internal** — in `fusion_plating_reports/report/report_fp_wo_sticker.xml`:
|
||||
|
||||
```xml
|
||||
<template id="report_fp_so_sticker_internal">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="so">
|
||||
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
|
||||
t-as="line">
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<t t-set="_order_id" t-value="so.name"/>
|
||||
<t t-set="_scan_id" t-value="line.id"/>
|
||||
<t t-set="_scan_path" t-value="'/fp/so-line/'"/>
|
||||
<t t-set="_so" t-value="so"/>
|
||||
<t t-set="_line" t-value="line"/>
|
||||
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
|
||||
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
|
||||
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
|
||||
<t t-set="_qty" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_partner_name" t-value="so.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<!-- Override: read internal description instead of line.name -->
|
||||
<t t-set="_notes_content" t-value="('x_fc_internal_description' in line._fields
|
||||
and line.x_fc_internal_description) or '-'"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
```
|
||||
|
||||
**SO External** — existing `report_fp_so_sticker` template gets one
|
||||
addition: `<t t-set="_qty_total" t-value="line.product_uom_qty"/>`.
|
||||
No other logic change (no `_notes_content` set = External default).
|
||||
|
||||
**Job Internal** — in `fusion_plating_jobs/report/report_fp_job_sticker.xml`:
|
||||
|
||||
```xml
|
||||
<template id="report_fp_job_sticker_internal_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<t t-set="_order_id" t-value="job.name"/>
|
||||
<t t-set="_scan_id" t-value="job.id"/>
|
||||
<t t-set="_scan_path" t-value="'/fp/job/'"/>
|
||||
<t t-set="_mo" t-value="False"/>
|
||||
<t t-set="_so" t-value="job.sale_order_id"/>
|
||||
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
|
||||
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
|
||||
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
|
||||
<t t-set="_process" t-value="job.recipe_id or False"/>
|
||||
<t t-set="_due" t-value="job.date_deadline or False"/>
|
||||
<t t-set="_qty" t-value="job.qty"/>
|
||||
<t t-set="_qty_total" t-value="job.qty"/>
|
||||
<t t-set="_partner_name" t-value="job.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<!-- Override: read internal description from first linked SO line -->
|
||||
<t t-set="_notes_content" t-value="(job.sale_order_line_ids[:1]
|
||||
and 'x_fc_internal_description' in job.sale_order_line_ids[:1]._fields
|
||||
and job.sale_order_line_ids[:1].x_fc_internal_description) or '-'"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Job External** — existing `report_fp_job_sticker_template`
|
||||
template gets one addition: `<t t-set="_qty_total" t-value="job.qty"/>`.
|
||||
|
||||
**Action records — labels + new XML IDs**
|
||||
|
||||
In `fusion_plating_reports/report/report_actions.xml`:
|
||||
|
||||
```xml
|
||||
<!-- Existing record — rename label only -->
|
||||
<record id="action_report_fp_so_sticker" model="ir.actions.report">
|
||||
<field name="name">External Sticker</field> <!-- was: "WO Box Sticker" -->
|
||||
...
|
||||
</record>
|
||||
|
||||
<!-- New record -->
|
||||
<record id="action_report_fp_so_sticker_internal" model="ir.actions.report">
|
||||
<field name="name">Internal Sticker</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_so_sticker_internal</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_so_sticker_internal</field>
|
||||
<field name="print_report_name">'Internal Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
|
||||
</record>
|
||||
```
|
||||
|
||||
In `fusion_plating_jobs/report/report_fp_job_sticker.xml`:
|
||||
|
||||
```xml
|
||||
<!-- Existing record — rename label only -->
|
||||
<record id="action_report_fp_job_sticker" model="ir.actions.report">
|
||||
<field name="name">External Job Sticker</field> <!-- was: "Job Sticker" -->
|
||||
...
|
||||
</record>
|
||||
|
||||
<!-- New record -->
|
||||
<record id="action_report_fp_job_sticker_internal" model="ir.actions.report">
|
||||
<field name="name">Internal Job Sticker</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
</record>
|
||||
```
|
||||
|
||||
## Files touched
|
||||
|
||||
| # | File | Change |
|
||||
|---|------|--------|
|
||||
| 1 | `fusion_plating_jobs/models/sale_order.py` | Extend grouping key in `_create_fp_jobs` (+5 lines) |
|
||||
| 2 | `fusion_plating_reports/report/report_fp_wo_sticker.xml` | Inner template: box loop, Qty row logic, `_notes_content` fallback chain. SO outer: add `_qty_total`. NEW: SO Internal outer template. |
|
||||
| 3 | `fusion_plating_reports/report/report_actions.xml` | Rename existing SO action label. NEW: SO Internal action record. |
|
||||
| 4 | `fusion_plating_jobs/report/report_fp_job_sticker.xml` | Job outer: add `_qty_total`. Rename existing job action label. NEW: Job Internal outer template + action record. |
|
||||
| 5 | `fusion_plating_jobs/__manifest__.py` | Version bump |
|
||||
| 6 | `fusion_plating_reports/__manifest__.py` | Version bump |
|
||||
|
||||
## Migration
|
||||
|
||||
None required.
|
||||
|
||||
- **New grouping key (`_create_fp_jobs`)** is purely additive —
|
||||
existing jobs are protected by the existing
|
||||
`if existing: return` idempotency guard. Single-line and
|
||||
same-(thickness, SN) multi-line SOs collapse identically to
|
||||
before.
|
||||
- **Existing XML IDs unchanged** — bookmarks / `binding_model_id`
|
||||
records keep working. Only the visible label flips.
|
||||
- **New variants** appear in the Print menu on next module
|
||||
upgrade with no data work.
|
||||
|
||||
## Testing
|
||||
|
||||
### Scenario 1 — Multi-thickness split (new fp.jobs)
|
||||
|
||||
Create a new SO with two lines:
|
||||
- Line 10: Part A, Coating X, Thickness 0.3-0.5 mils, qty 2
|
||||
- Line 20: Part A, Coating X, Thickness 0.5-1.0 mils, qty 1
|
||||
|
||||
Confirm SO → 2 fp.jobs are created:
|
||||
- `WO-XXXXX-01`: qty 2, thickness 0.3-0.5
|
||||
- `WO-XXXXX-02`: qty 1, thickness 0.5-1.0
|
||||
|
||||
Print each job's External sticker → confirm correct thickness on each.
|
||||
|
||||
### Scenario 2 — Per-box rendering
|
||||
|
||||
Take Scenario 1's SO, click "Print → External Sticker" on the SO.
|
||||
|
||||
Confirm: 3-page PDF.
|
||||
- Page 1: Line 10 box 1 → Qty row shows `1 / 2`
|
||||
- Page 2: Line 10 box 2 → Qty row shows `2 / 2`
|
||||
- Page 3: Line 20 box 1 → Qty row shows `1`
|
||||
|
||||
### Scenario 3 — Internal variant
|
||||
|
||||
On the same SO, click "Print → Internal Sticker".
|
||||
|
||||
Confirm: same 3 pages, same WO#/PO#/Customer/Part#/SN/Thickness/Qty,
|
||||
but the Notes column shows `x_fc_internal_description` from each
|
||||
line instead of `name`.
|
||||
|
||||
If `x_fc_internal_description` is blank on a line, Notes shows `-`.
|
||||
|
||||
### Scenario 4 — Regression check (existing single-line)
|
||||
|
||||
Re-print SO-30019 (1 line, qty 1) → External sticker prints
|
||||
single-page, no `X / N` indicator, Notes shows `_line.name` as
|
||||
before. Internal variant: single-page, Notes shows `x_fc_internal_description`
|
||||
or `-`.
|
||||
|
||||
### Scenario 5 — Job-level multi-box
|
||||
|
||||
Take any existing fp.job with `qty = 3`. Print External Job Sticker.
|
||||
|
||||
Confirm: 3 pages, `1/3`, `2/3`, `3/3`. Internal Job Sticker also 3
|
||||
pages with the line's internal description in Notes.
|
||||
|
||||
### Scenario 6 — Action menu visibility
|
||||
|
||||
On a sale order Print menu: both "External Sticker" and
|
||||
"Internal Sticker" appear. On an fp.job Print menu: both
|
||||
"External Job Sticker" and "Internal Job Sticker" appear.
|
||||
|
||||
## Out-of-scope items (deferred)
|
||||
|
||||
- **Per-box SN registry.** Today `x_fc_serial_id` is one per line.
|
||||
If the customer needs unique SNs per physical box (5 parts =
|
||||
5 SNs), build out an `fp.box.serial` registry that links to the
|
||||
line. Out of scope for this spec — would need workflow design
|
||||
(UI for assigning, where SNs print, etc.).
|
||||
- **Box count ≠ qty.** Some shops pack multiple parts per box.
|
||||
Today this spec assumes 1 sticker per qty unit. If needed,
|
||||
add an `x_fc_box_count` field on the line that defaults to qty
|
||||
but can be overridden, and the sticker loops over box_count
|
||||
instead. Defer until requested.
|
||||
- **Sticker preview UI in the form view.** No live preview today;
|
||||
operators print + visually verify. Defer.
|
||||
|
||||
## Open questions
|
||||
|
||||
None — all decisions locked at spec time:
|
||||
|
||||
| Q | Decision |
|
||||
|---|---|
|
||||
| Add SN to grouping key? | **Yes.** Same reasoning as thickness — silent merge of different SNs is a compliance hole. |
|
||||
| Per-box indicator location? | **Replace Qty row value.** Operator's confirmation: "we can use the quantity field portion for the box, there is room we can use rather than creating another line below and making everything smaller." |
|
||||
| Box indicator format? | **`1 / 5`** (slash, spaces around for legibility at 50pt). When qty=1, show plain `1` (no slash) — matches current behavior. |
|
||||
| Label naming convention? | **Prefix.** `External Sticker` / `Internal Sticker` (SO Print menu), `External Job Sticker` / `Internal Job Sticker` (fp.job Print menu). |
|
||||
| Migration for existing jobs? | **None.** Idempotency guard in `_create_fp_jobs` protects them. |
|
||||
| Existing action XML IDs? | **Unchanged.** Only labels rename — bookmarks/binding records survive. |
|
||||
| Fractional qty? | Cast to `int(qty)` — current behavior preserved. |
|
||||
| Qty=0 line? | Already filtered out by `lambda l: l.x_fc_part_catalog_id` (no part → no sticker). |
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating',
|
||||
'version': '19.0.18.15.15',
|
||||
'version': '19.0.18.15.16',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
|
||||
'description': """
|
||||
|
||||
@@ -13,6 +13,7 @@ for the design rationale.
|
||||
"""
|
||||
import re
|
||||
|
||||
from markupsafe import Markup
|
||||
from odoo import fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.translate import _
|
||||
@@ -115,9 +116,9 @@ class FpParentNumberedMixin(models.AbstractModel):
|
||||
(new_name, new_index, self.id),
|
||||
)
|
||||
self.invalidate_recordset(['name', 'x_fc_doc_index'])
|
||||
so.message_post(body=_(
|
||||
so.message_post(body=Markup(_(
|
||||
'Issued <strong>%s</strong> to %s #%s.'
|
||||
) % (new_name, self._name, self.id))
|
||||
)) % (new_name, self._name, self.id))
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Native Jobs',
|
||||
'version': '19.0.8.22.10',
|
||||
'version': '19.0.8.27.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
|
||||
@@ -279,9 +279,9 @@ class SaleOrder(models.Model):
|
||||
'name': f'SO-{parent_int}',
|
||||
'x_fc_parent_number': parent_int,
|
||||
})
|
||||
so.message_post(body=_(
|
||||
so.message_post(body=Markup(_(
|
||||
'Confirmed quote <strong>%s</strong> as <strong>%s</strong>.'
|
||||
) % (old_name, so.name))
|
||||
)) % (old_name, so.name))
|
||||
result = super().action_confirm()
|
||||
for so in self:
|
||||
so._fp_auto_create_job()
|
||||
@@ -412,15 +412,13 @@ class SaleOrder(models.Model):
|
||||
_logger.info('SO %s: no plating lines, skipping job creation.', self.name)
|
||||
return
|
||||
|
||||
# Group by (recipe, part, coating). Lines that share ALL THREE
|
||||
# collapse into one WO. Sharing only the recipe is not enough —
|
||||
# the WO header captures part_id and coating_config_id from
|
||||
# first_line, and downstream the CoC prints the WO header's
|
||||
# part_number on the customer-facing cert. Bundling Part A +
|
||||
# Part B under one WO because they happen to share a recipe
|
||||
# would put Part A's number on a cert covering both, which is
|
||||
# a compliance bug (silent mis-attestation).
|
||||
# No-recipe lines get their own group each.
|
||||
# Group by (recipe, part, coating, thickness, serial). Lines that
|
||||
# share ALL FIVE collapse into one WO. Same compliance reasoning
|
||||
# as part_id + coating_id: bundling lines with different thicknesses
|
||||
# or different serials under one WO would carry the first line's
|
||||
# values onto the cert + sticker — silent mis-attestation. Sub 5
|
||||
# added thickness_id + serial_id; this extends the grouping logic
|
||||
# to honour them. No-recipe lines still get their own group each.
|
||||
groups = {}
|
||||
unrecipe_idx = 0
|
||||
for line in plating_lines:
|
||||
@@ -433,8 +431,16 @@ class SaleOrder(models.Model):
|
||||
'x_fc_coating_config_id' in line._fields
|
||||
and line.x_fc_coating_config_id.id
|
||||
) or False
|
||||
thickness_id = (
|
||||
'x_fc_thickness_id' in line._fields
|
||||
and line.x_fc_thickness_id.id
|
||||
) or False
|
||||
serial_id = (
|
||||
'x_fc_serial_id' in line._fields
|
||||
and line.x_fc_serial_id.id
|
||||
) or False
|
||||
if recipe:
|
||||
key = (recipe.id, part_id, coating_id)
|
||||
key = (recipe.id, part_id, coating_id, thickness_id, serial_id)
|
||||
else:
|
||||
unrecipe_idx += 1
|
||||
key = ('no_recipe', unrecipe_idx)
|
||||
|
||||
@@ -29,12 +29,12 @@
|
||||
</record>
|
||||
|
||||
<record id="action_report_fp_job_sticker" model="ir.actions.report">
|
||||
<field name="name">Job Sticker</field>
|
||||
<field name="name">External Job Sticker</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_template</field>
|
||||
<field name="print_report_name">'Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="print_report_name">'External Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
@@ -60,6 +60,7 @@
|
||||
<t t-set="_process" t-value="job.recipe_id or False"/>
|
||||
<t t-set="_due" t-value="job.date_deadline or False"/>
|
||||
<t t-set="_qty" t-value="job.qty"/>
|
||||
<t t-set="_qty_total" t-value="job.qty"/>
|
||||
<t t-set="_partner_name" t-value="job.partner_id.name"/>
|
||||
<!-- The fp.job's own name (WH/JOB/00033) is already
|
||||
printed in the header as "WO #...", so suppress
|
||||
@@ -70,4 +71,48 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Internal Job sticker — same fields as External, but the Notes
|
||||
column reads x_fc_internal_description from the first linked
|
||||
SO line (Sub 5 thickness+serial grouping means same-x_fc lines
|
||||
share a job, so first-line is representative). -->
|
||||
<record id="action_report_fp_job_sticker_internal" model="ir.actions.report">
|
||||
<field name="name">Internal Job Sticker</field>
|
||||
<field name="model">fp.job</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="report_file">fusion_plating_jobs.report_fp_job_sticker_internal_template</field>
|
||||
<field name="print_report_name">'Internal Job Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="fusion_plating.model_fp_job"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_job_sticker"/>
|
||||
</record>
|
||||
|
||||
<template id="report_fp_job_sticker_internal_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="job">
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<t t-set="_order_id" t-value="job.name"/>
|
||||
<t t-set="_scan_id" t-value="job.id"/>
|
||||
<t t-set="_scan_path" t-value="'/fp/job/'"/>
|
||||
<t t-set="_mo" t-value="False"/>
|
||||
<t t-set="_so" t-value="job.sale_order_id"/>
|
||||
<t t-set="_line" t-value="job.sale_order_line_ids[:1]"/>
|
||||
<t t-set="_part" t-value="('part_catalog_id' in job._fields and job.part_catalog_id) or False"/>
|
||||
<t t-set="_coating" t-value="('coating_config_id' in job._fields and job.coating_config_id) or False"/>
|
||||
<t t-set="_process" t-value="job.recipe_id or False"/>
|
||||
<t t-set="_due" t-value="job.date_deadline or False"/>
|
||||
<t t-set="_qty" t-value="job.qty"/>
|
||||
<t t-set="_qty_total" t-value="job.qty"/>
|
||||
<t t-set="_partner_name" t-value="job.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<!-- Internal override: read x_fc_internal_description from
|
||||
the first linked SO line. -->
|
||||
<t t-set="_notes_content" t-value="(job.sale_order_line_ids[:1]
|
||||
and 'x_fc_internal_description' in job.sale_order_line_ids[:1]._fields
|
||||
and job.sale_order_line_ids[:1].x_fc_internal_description) or '-'"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -323,6 +323,82 @@ class TestSoConfirmHook(TransactionCase):
|
||||
else:
|
||||
self.skipTest('x_fc_part_catalog_id field not present')
|
||||
|
||||
def test_so_confirm_splits_by_thickness(self):
|
||||
"""Two lines with same recipe+part+coating but DIFFERENT thicknesses
|
||||
must produce TWO fp.jobs — silent merge was a compliance bug (the
|
||||
second thickness's CoC would carry the first thickness).
|
||||
|
||||
The bug only manifests when lines hit the `if recipe:` branch in
|
||||
_fp_auto_create_job — without a resolved recipe, the no_recipe
|
||||
branch already splits per line. We seed a recipe via
|
||||
part.default_process_id so both lines resolve to the same recipe
|
||||
and reach the buggy grouping path.
|
||||
"""
|
||||
SOL = self.env['sale.order.line']
|
||||
Part = self.env['fp.part.catalog']
|
||||
Node = self.env['fusion.plating.process.node']
|
||||
Thick = self.env['fp.coating.thickness']
|
||||
if 'x_fc_part_catalog_id' not in SOL._fields \
|
||||
or 'x_fc_thickness_id' not in SOL._fields \
|
||||
or 'default_process_id' not in Part._fields:
|
||||
self.skipTest('Sub 5 + recipe-on-part fields not present')
|
||||
|
||||
# Two distinct existing thicknesses. Creating them from scratch
|
||||
# requires a coating_config → process_type chain that's too noisy
|
||||
# for a unit test; reuse what's seeded.
|
||||
thicknesses = Thick.search([], limit=2)
|
||||
if len(thicknesses) < 2:
|
||||
self.skipTest('need >= 2 fp.coating.thickness records seeded')
|
||||
thick_a, thick_b = thicknesses[0], thicknesses[1]
|
||||
|
||||
# Any existing top-level recipe works — the test only needs both
|
||||
# lines to resolve to the SAME recipe so they collide on the key.
|
||||
recipe = Node.search([('parent_id', '=', False)], limit=1)
|
||||
if not recipe:
|
||||
self.skipTest('no fusion.plating.process.node records to anchor a recipe')
|
||||
|
||||
partner_for_part = self.env['res.partner'].create({'name': 'SplitPartner'})
|
||||
part = Part.create({
|
||||
'name': 'SplitPart', 'part_number': 'SP-1',
|
||||
'partner_id': partner_for_part.id,
|
||||
'default_process_id': recipe.id,
|
||||
})
|
||||
|
||||
so = self.env['sale.order'].create({
|
||||
'partner_id': self.partner.id,
|
||||
'client_order_ref': 'TEST-PO-SPLIT',
|
||||
})
|
||||
SOL.create({
|
||||
'order_id': so.id, 'product_id': self.product.id,
|
||||
'product_uom_qty': 2.0, 'price_unit': 10.0,
|
||||
'x_fc_part_catalog_id': part.id,
|
||||
'x_fc_thickness_id': thick_a.id,
|
||||
})
|
||||
SOL.create({
|
||||
'order_id': so.id, 'product_id': self.product.id,
|
||||
'product_uom_qty': 1.0, 'price_unit': 10.0,
|
||||
'x_fc_part_catalog_id': part.id,
|
||||
'x_fc_thickness_id': thick_b.id,
|
||||
})
|
||||
so.action_confirm()
|
||||
|
||||
jobs = self.env['fp.job'].search([('sale_order_id', '=', so.id)])
|
||||
self.assertEqual(
|
||||
len(jobs), 2,
|
||||
'Lines with different thicknesses must spawn separate fp.jobs '
|
||||
'(both lines share recipe+part+coating, only thickness differs)',
|
||||
)
|
||||
# Each job's linked SO line should carry its own thickness
|
||||
thicknesses_on_jobs = set()
|
||||
for job in jobs:
|
||||
for line in job.sale_order_line_ids:
|
||||
if line.x_fc_thickness_id:
|
||||
thicknesses_on_jobs.add(line.x_fc_thickness_id.id)
|
||||
self.assertEqual(
|
||||
thicknesses_on_jobs, {thick_a.id, thick_b.id},
|
||||
'The two distinct thicknesses must each appear on its own job',
|
||||
)
|
||||
|
||||
|
||||
class TestJobLifecycleHooks(TransactionCase):
|
||||
def setUp(self):
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
{
|
||||
'name': 'Fusion Plating — Quality (QMS)',
|
||||
'version': '19.0.4.13.0',
|
||||
'version': '19.0.4.14.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'Native QMS for plating shops: NCR, CAPA, calibration, AVL, FAIR, '
|
||||
'internal audits, customer specs, document control. CE + EE compatible.',
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
# Part of the Fusion Plating product family.
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from markupsafe import Markup
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
@@ -22,9 +25,9 @@ class FpContractReview(models.Model):
|
||||
"""Contract Review (QA-005).
|
||||
|
||||
Per-part, two-section QA review: Section 2.0 Planning / Production
|
||||
Review (signed by a QA Assistant) and Section 3.0 Quality Review
|
||||
(signed by a QA Manager). Both sections must be signed for the
|
||||
review to be complete.
|
||||
Review (Planning Review stage, signed by a Planning Signer) and
|
||||
Section 3.0 Quality Review (QA Review stage, signed by a QA Signer).
|
||||
Both sections must be signed for the review to be complete.
|
||||
|
||||
The review is always optional. It never blocks MO/SO/WO progression.
|
||||
Its purpose is an audit artefact and a printable 1:1 of the paper
|
||||
@@ -79,7 +82,7 @@ class FpContractReview(models.Model):
|
||||
qty = fields.Integer(string='Qty')
|
||||
due_date = fields.Date(string='Due')
|
||||
|
||||
# ---- Section 2.0 — Planning / Production Review (QA Assistant) ---------
|
||||
# ---- Section 2.0 — Planning / Production Review (Planning Review) ------
|
||||
|
||||
s20_acceptable_lead_time = fields.Boolean(string='Acceptable Lead Time')
|
||||
s20_capacity_to_process = fields.Boolean(string='Capacity to Process')
|
||||
@@ -118,7 +121,7 @@ class FpContractReview(models.Model):
|
||||
copy=False,
|
||||
)
|
||||
|
||||
# ---- Section 3.0 — Quality Review (QA Manager) -------------------------
|
||||
# ---- Section 3.0 — Quality Review (QA Review) --------------------------
|
||||
|
||||
s30_source_control_docs = fields.Boolean(string="Source Control Documents (Customer Spec's)")
|
||||
s30_quality_clauses_supplied = fields.Boolean(string='Quality Clause(s) supplied')
|
||||
@@ -180,8 +183,9 @@ class FpContractReview(models.Model):
|
||||
|
||||
state = fields.Selection(
|
||||
[('draft', 'Draft'),
|
||||
('assistant_review', 'QA Assistant Review'),
|
||||
('manager_review', 'QA Manager Review'),
|
||||
('assistant_review', 'Planning Review'),
|
||||
('manager_review', 'QA Review'),
|
||||
('awaiting_info', 'Awaiting Client Info'),
|
||||
('complete', 'Complete'),
|
||||
('dismissed', 'Dismissed')],
|
||||
default='draft',
|
||||
@@ -189,6 +193,56 @@ class FpContractReview(models.Model):
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
# ---- "Failed QA — Awaiting Client Info" workflow ------------------------
|
||||
# When a QA Signer (Brett or whoever the company has rostered) finds a
|
||||
# client requirement that fails during the QA Review, they mark the
|
||||
# review failed. The state moves to `awaiting_info`, an activity is
|
||||
# scheduled for every QA Signer to follow up, and a smart button on
|
||||
# the form gives them a one-click email composer to ping the client.
|
||||
# When the client replies, the QA Signer captures notes in
|
||||
# `special_instructions` and marks complete — the notes print on the
|
||||
# final QA-005 PDF for the audit trail.
|
||||
qa_failure_reason = fields.Html(
|
||||
string='QA Failure Reason',
|
||||
copy=False,
|
||||
help='What client requirement failed and why we need more info. '
|
||||
'Captured here before flipping the review to '
|
||||
'"Awaiting Client Info" so every QA Signer sees the same '
|
||||
'context. Pre-fills the client email composer.',
|
||||
)
|
||||
info_requested_date = fields.Datetime(
|
||||
string='Info Requested Date',
|
||||
readonly=True,
|
||||
copy=False,
|
||||
help='Stamped automatically the first time the client email '
|
||||
'composer is sent.',
|
||||
)
|
||||
info_received_date = fields.Datetime(
|
||||
string='Info Received Date',
|
||||
copy=False,
|
||||
help='Manually stamped when the QA Signer marks the review '
|
||||
'complete after receiving the client info.',
|
||||
)
|
||||
special_instructions = fields.Html(
|
||||
string='Special Instructions',
|
||||
copy=False,
|
||||
help='Free-form notes captured by the QA Signer when they close '
|
||||
'out the review. Prints at the bottom of the QA-005 PDF '
|
||||
'so the audit record carries the agreed resolution.',
|
||||
)
|
||||
client_email_count = fields.Integer(
|
||||
compute='_compute_client_email_count',
|
||||
help='Smart-button counter — number of emails posted to chatter '
|
||||
'against this review. Always non-zero after the first send.',
|
||||
)
|
||||
|
||||
@api.depends('message_ids', 'message_ids.message_type')
|
||||
def _compute_client_email_count(self):
|
||||
for rec in self:
|
||||
rec.client_email_count = len(rec.message_ids.filtered(
|
||||
lambda m: m.message_type == 'email'
|
||||
))
|
||||
|
||||
# ---- Constraints --------------------------------------------------------
|
||||
|
||||
_sql_constraints = [
|
||||
@@ -351,6 +405,133 @@ class FpContractReview(models.Model):
|
||||
'fusion_plating_quality.action_report_contract_review'
|
||||
).report_action(self)
|
||||
|
||||
# ---- "Failed QA — Awaiting Client Info" workflow ------------------------
|
||||
def action_mark_qa_failed(self):
|
||||
"""QA Signer marks the review failed because a client requirement
|
||||
is missing or unclear. Captures the reason, flips state to
|
||||
`awaiting_info`, and schedules a follow-up activity for every QA
|
||||
Signer rostered on the company (so the work doesn't fall through
|
||||
the cracks if Brett is on vacation)."""
|
||||
self.ensure_one()
|
||||
if self.state not in ('manager_review', 'assistant_review'):
|
||||
raise UserError(_(
|
||||
'Only a review at the QA Review (or Planning Review) stage '
|
||||
'can be flagged as failed. Current state: %s.'
|
||||
) % dict(self._fields['state'].selection).get(self.state, self.state))
|
||||
# Reuse the section-30 signer roster — the same group of people
|
||||
# who can sign QA can flag a QA failure.
|
||||
self._check_signer(30)
|
||||
if not self.qa_failure_reason or not self.qa_failure_reason.strip():
|
||||
raise UserError(_(
|
||||
'Capture the QA Failure Reason before flagging the '
|
||||
'review failed — the reason pre-fills the client email '
|
||||
'and is required for the audit trail.'
|
||||
))
|
||||
self.write({'state': 'awaiting_info'})
|
||||
self.message_post(body=Markup(_(
|
||||
'<b>QA Review failed</b> by %(user)s. Awaiting client '
|
||||
'information.<br/><b>Reason:</b><br/>%(reason)s'
|
||||
)) % {
|
||||
'user': self.env.user.name,
|
||||
'reason': Markup(self.qa_failure_reason or ''),
|
||||
})
|
||||
# Schedule activity for every QA Signer (any of them can pick it up).
|
||||
signers = self.company_id._fp_get_qa_signers(30)
|
||||
if not signers:
|
||||
# Fall back to the user who flagged it, so the activity is
|
||||
# not orphaned on shops that haven't configured a roster.
|
||||
signers = self.env.user
|
||||
try:
|
||||
activity_type = self.env.ref('mail.mail_activity_data_todo')
|
||||
except ValueError:
|
||||
activity_type = self.env['mail.activity.type'].search(
|
||||
[('category', '=', 'default')], limit=1)
|
||||
for user in signers:
|
||||
self.activity_schedule(
|
||||
activity_type_id=activity_type.id if activity_type else False,
|
||||
summary=_('Follow up on QA-005 — client info required'),
|
||||
note=self.qa_failure_reason or '',
|
||||
user_id=user.id,
|
||||
date_deadline=fields.Date.context_today(self) +
|
||||
timedelta(days=2),
|
||||
)
|
||||
return True
|
||||
|
||||
def action_open_client_email_wizard(self):
|
||||
"""Smart-button target — opens the email composer wizard pre-filled
|
||||
with the customer's contact email + a body templated from the
|
||||
QA failure reason. The wizard handles the actual mail.mail send
|
||||
and stamps `info_requested_date` on this review."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Email Client — Request Info'),
|
||||
'res_model': 'fp.contract.review.client.email.wizard',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_review_id': self.id,
|
||||
'default_recipient_email':
|
||||
self.customer_id.email or '',
|
||||
'default_recipient_name':
|
||||
self.customer_id.name or '',
|
||||
},
|
||||
}
|
||||
|
||||
def action_view_client_emails(self):
|
||||
"""Drill-down behind the smart button counter — shows the chatter
|
||||
messages of type=email for this review."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Client Emails — %s') % self.name,
|
||||
'res_model': 'mail.message',
|
||||
'view_mode': 'list,form',
|
||||
'domain': [
|
||||
('model', '=', 'fp.contract.review'),
|
||||
('res_id', '=', self.id),
|
||||
('message_type', '=', 'email'),
|
||||
],
|
||||
}
|
||||
|
||||
def action_complete_after_info(self):
|
||||
"""Close out a review that was in `awaiting_info` once the client
|
||||
info has been received and `special_instructions` captured. Stamps
|
||||
Section 3.0 sign-off with the current user + timestamp so the QA
|
||||
review is fully closed and the QA-005 PDF carries a complete
|
||||
audit trail."""
|
||||
self.ensure_one()
|
||||
if self.state != 'awaiting_info':
|
||||
raise UserError(_(
|
||||
'Only a review in "Awaiting Client Info" can be marked '
|
||||
'complete via this action.'
|
||||
))
|
||||
self._check_signer(30)
|
||||
now = fields.Datetime.now()
|
||||
vals = {
|
||||
'state': 'complete',
|
||||
'info_received_date': self.info_received_date or now,
|
||||
's30_signed_by': self.env.user.id,
|
||||
's30_signed_date': now,
|
||||
's30_locked': True,
|
||||
}
|
||||
self.write(vals)
|
||||
# Mark the activity as done so the follow-up disappears from
|
||||
# everyone's inbox once the case is closed.
|
||||
self.activity_feedback(
|
||||
['mail.mail_activity_data_todo'],
|
||||
feedback=_('Client info received — review closed.'),
|
||||
)
|
||||
self.message_post(body=Markup(_(
|
||||
'<b>QA Review completed</b> by %(user)s after receiving '
|
||||
'client information.<br/>'
|
||||
'<b>Special Instructions captured:</b><br/>%(notes)s'
|
||||
)) % {
|
||||
'user': self.env.user.name,
|
||||
'notes': Markup(self.special_instructions or '') or _('(none)'),
|
||||
})
|
||||
return True
|
||||
|
||||
# ---- Helpers ------------------------------------------------------------
|
||||
|
||||
def _check_signer(self, section):
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
{
|
||||
'name': 'Fusion Plating — Reports',
|
||||
'version': '19.0.10.3.0',
|
||||
'version': '19.0.10.16.0',
|
||||
'category': 'Manufacturing/Plating',
|
||||
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
|
||||
'depends': [
|
||||
|
||||
@@ -324,12 +324,26 @@
|
||||
about page size; the output PDF is multi-page if the SO has
|
||||
multiple plating lines. -->
|
||||
<record id="action_report_fp_so_sticker" model="ir.actions.report">
|
||||
<field name="name">WO Box Sticker</field>
|
||||
<field name="name">External Sticker</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_so_sticker</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_so_sticker</field>
|
||||
<field name="print_report_name">'WO Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="print_report_name">'External Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
|
||||
</record>
|
||||
|
||||
<!-- SO Internal sticker — same layout, prints internal description
|
||||
instead of the customer-facing line.name. Shop-floor variant. -->
|
||||
<record id="action_report_fp_so_sticker_internal" model="ir.actions.report">
|
||||
<field name="name">Internal Sticker</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_plating_reports.report_fp_so_sticker_internal</field>
|
||||
<field name="report_file">fusion_plating_reports.report_fp_so_sticker_internal</field>
|
||||
<field name="print_report_name">'Internal Sticker - %s' % (object.name or '').replace('/', '-')</field>
|
||||
<field name="binding_model_id" ref="sale.model_sale_order"/>
|
||||
<field name="binding_type">report</field>
|
||||
<field name="paperformat_id" ref="paperformat_fp_wo_sticker"/>
|
||||
|
||||
@@ -60,6 +60,22 @@
|
||||
else (_mo and _mo.product_qty) or 0"/>
|
||||
<t t-set="_po_number" t-value="_po_number or (_so and _so.x_fc_po_number) or '-'"/>
|
||||
<t t-set="_partner_name" t-value="_partner_name or (_so and _so.partner_id.name) or '-'"/>
|
||||
<!-- Customer short-code for shop-floor "secrecy cover" — operators
|
||||
see "ABC-MANU" instead of "ABC Manufacturing Inc", so visiting
|
||||
customers / unauthorised passers-by can't immediately tell whose
|
||||
parts are on which rack. Rule: first 3 chars of word[0] + "-"
|
||||
+ first 4 chars of word[1], all uppercase. Single-word names:
|
||||
just the first 3 chars. Strips non-alphanumeric per word so
|
||||
punctuation in "St. John's Mfg." doesn't poison the slice. -->
|
||||
<t t-set="_partner_words"
|
||||
t-value="[''.join(c for c in w if c.isalnum())
|
||||
for w in (_partner_name or '').split()
|
||||
if ''.join(c for c in w if c.isalnum())]"/>
|
||||
<t t-set="_partner_display" t-value="
|
||||
(_partner_words[0][:3].upper() + '-' + _partner_words[1][:4].upper())
|
||||
if len(_partner_words) >= 2
|
||||
else (_partner_words[0][:3].upper() if _partner_words else (_partner_name or '-'))
|
||||
"/>
|
||||
<!-- _mo_ref controls the muted "(WH/MO/00033)" suffix next to PO.
|
||||
Outer can pass '' to hide it (e.g. fp.job already shows its
|
||||
own name in the header). Defaults to _mo.name. -->
|
||||
@@ -69,10 +85,31 @@
|
||||
or (_so and _so.x_fc_internal_note
|
||||
and _so.x_fc_internal_note.striptags()[:100])
|
||||
or '-'"/>
|
||||
<!-- Serial number — Sub 5 added x_fc_serial_id (M2O fp.serial) on
|
||||
the SO line. The serial record's `name` is the printable label. -->
|
||||
<t t-set="_serial_number" t-value="(_line and 'x_fc_serial_id' in _line._fields and _line.x_fc_serial_id and _line.x_fc_serial_id.name) or '-'"/>
|
||||
<!-- Thickness — Sub 5 added x_fc_thickness_id (M2O fp.coating.thickness)
|
||||
on the SO line. `display_name` is the human-readable range, e.g.
|
||||
"0.3–0.5 mils". The en-dash (U+2013) in display_name mojibakes
|
||||
to "â€"" through wkhtmltopdf's font path on entech, so we
|
||||
swap en-dash + em-dash for a plain hyphen-minus before
|
||||
rendering. ASCII-only printable for any QR-label printer. -->
|
||||
<t t-set="_thickness_dn" t-value="_line and 'x_fc_thickness_id' in _line._fields and _line.x_fc_thickness_id and _line.x_fc_thickness_id.display_name"/>
|
||||
<t t-set="_thickness" t-value="(_thickness_dn and _thickness_dn.replace(u'–', '-').replace(u'—', '-')) or '-'"/>
|
||||
<!-- Notes content — outer can pre-set this (e.g. the Internal
|
||||
variant passes line.x_fc_internal_description). Otherwise
|
||||
falls back to line.name (customer-facing description per
|
||||
Sub 2 Q6), then to part.name. -->
|
||||
<t t-set="_notes_content" t-value="_notes_content
|
||||
or (_line and _line.name)
|
||||
or (_part and _part.name)
|
||||
or '-'"/>
|
||||
<!-- Inline the QR as base64 data URI so wkhtmltopdf doesn't need
|
||||
to fetch /report/barcode/ over the network during rendering. -->
|
||||
to fetch /report/barcode/ over the network during rendering.
|
||||
600x600 source at 300dpi print = ~515ppi effective — high-def
|
||||
scan reliability for the 4x6" label. -->
|
||||
<t t-set="_qr_src" t-value="env['ir.actions.report'].barcode_data_uri(
|
||||
'QR', _scan_url, width=300, height=300)"/>
|
||||
'QR', _scan_url, width=600, height=600)"/>
|
||||
|
||||
<style>
|
||||
@page { margin: 0; size: 152mm 102mm; }
|
||||
@@ -82,10 +119,9 @@
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
/* Boxy professional layout: thick outer border, horizontal row
|
||||
borders, vertical label/value divider. Absolute positioning +
|
||||
% row heights force the content to fill the full page in
|
||||
wkhtmltopdf (which ignores vh/vw/flex). ------------------- */
|
||||
/* 3-cell header (Logo | WO# | QR) + 2-region body (fields left,
|
||||
Notes column right). Absolute positioning + % heights/widths
|
||||
are mandatory — wkhtmltopdf ignores vh/vw/flex. ----------- */
|
||||
.fp-sticker {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
color: #000;
|
||||
@@ -97,14 +133,14 @@
|
||||
page-break-after: always;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
/* ---- HEADER band — grew to 40% to fit 2x WO# + logo + bigger QR. */
|
||||
/* ---- HEADER band: 3 horizontal cells, divided by vertical
|
||||
rules. Logo / WO# / QR. 32% to fit the +30% QR. ---- */
|
||||
.fp-sticker-head-wrap {
|
||||
position: absolute;
|
||||
left: 0; right: 0; top: 0;
|
||||
height: 40%;
|
||||
height: 32%;
|
||||
border-bottom: 2px solid #000;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
table.fp-sticker-head {
|
||||
width: 100%;
|
||||
@@ -112,82 +148,71 @@
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.fp-sticker-head td { padding: 0; vertical-align: middle; }
|
||||
col.fp-col-head-left { width: 66%; }
|
||||
col.fp-col-head-right { width: 34%; }
|
||||
td.fp-sticker-head-left {
|
||||
overflow: hidden;
|
||||
border-right: 2px solid #000;
|
||||
}
|
||||
td.fp-sticker-head-right {
|
||||
col.fp-col-head-logo { width: 28%; }
|
||||
col.fp-col-head-wo { width: 44%; }
|
||||
col.fp-col-head-qr { width: 28%; }
|
||||
table.fp-sticker-head td {
|
||||
padding: 0;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Left column nested 2-row table: logo on top, WO# below.
|
||||
Horizontal divider between rows mirrors body row borders. */
|
||||
table.fp-sticker-head-left-stack {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.fp-sticker-head-left-stack tr.fp-row-logo { height: 50%; }
|
||||
table.fp-sticker-head-left-stack tr.fp-row-wo { height: 50%; }
|
||||
table.fp-sticker-head-left-stack td {
|
||||
padding: 0 14px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* Logo cell + WO# cell each get explicit vertical-align so the
|
||||
content sits in the middle of its half of the header band. */
|
||||
table.fp-sticker-head-left-stack tr.fp-row-logo td,
|
||||
table.fp-sticker-head-left-stack tr.fp-row-wo td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
table.fp-sticker-head-left-stack tr + tr td {
|
||||
border-top: 1px solid #000;
|
||||
}
|
||||
td.fp-sticker-head-logo { border-right: 2px solid #000; padding: 0 6px; }
|
||||
td.fp-sticker-head-wo { border-right: 2px solid #000; }
|
||||
.fp-sticker-logo {
|
||||
/* Logo bumped 40% (116 → 162px height, 520 → 728px width). */
|
||||
max-height: 162px;
|
||||
max-width: 728px;
|
||||
display: block;
|
||||
max-height: 135px;
|
||||
max-width: 95%;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.fp-sticker-wo {
|
||||
font-size: 72pt;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.2mm;
|
||||
letter-spacing: 0.1mm;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
/* QR wrapper crops the white quiet-zone around the QR pattern
|
||||
so it doesn't visually float on a white square inside the
|
||||
cell. The PNG from Odoo's barcode generator carries a
|
||||
~12% border (4 modules of quiet-zone) on each side; we
|
||||
render the image larger than the wrapper and offset it so
|
||||
the wrapper clips that border out. ---------------------- */
|
||||
/* QR wrapper crops the ~12% quiet-zone the barcode generator
|
||||
adds around the QR pattern. We render the image larger than
|
||||
the wrapper and offset so the wrapper clips that border out.
|
||||
Wrapper 365px = ~30.9mm at 300dpi (30% larger than the
|
||||
previous 280px). 600x600 source = high-def at print scale. ---- */
|
||||
.fp-sticker-qr-wrap {
|
||||
width: 380px;
|
||||
height: 380px;
|
||||
width: 365px;
|
||||
height: 365px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fp-sticker-qr {
|
||||
width: 510px;
|
||||
height: 510px;
|
||||
width: 480px;
|
||||
height: 480px;
|
||||
position: absolute;
|
||||
top: -65px;
|
||||
left: -65px;
|
||||
top: -58px;
|
||||
left: -58px;
|
||||
margin: 0;
|
||||
display: block;
|
||||
}
|
||||
/* ---- BODY band (7 rows, each 14.28% of the band) ---- */
|
||||
/* ---- BODY band: left fields region + right Notes region. ---- */
|
||||
.fp-sticker-body-wrap {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
top: 40%; bottom: 0;
|
||||
top: 32%; bottom: 0;
|
||||
}
|
||||
.fp-body-left {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 64%;
|
||||
border-right: 2px solid #000;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.fp-body-right {
|
||||
position: absolute;
|
||||
left: 64%; right: 0; top: 0; bottom: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
table.fp-sticker-body {
|
||||
width: 100%;
|
||||
@@ -197,18 +222,18 @@
|
||||
}
|
||||
table.fp-sticker-body tr { height: 14.28%; }
|
||||
table.fp-sticker-body tr + tr td { border-top: 1px solid #000; }
|
||||
col.fp-col-label { width: 32%; }
|
||||
col.fp-col-value { width: 68%; }
|
||||
col.fp-col-label { width: 38%; }
|
||||
col.fp-col-value { width: 62%; }
|
||||
table.fp-sticker-body td {
|
||||
vertical-align: middle;
|
||||
padding: 0 14px;
|
||||
font-size: 38pt;
|
||||
line-height: 1.1;
|
||||
padding: 0 8px;
|
||||
font-size: 50pt;
|
||||
line-height: 1.0;
|
||||
}
|
||||
td.fp-sticker-label {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
border-right: 2px solid #000;
|
||||
border-right: 1px solid #000;
|
||||
background-color: #f1f2f4;
|
||||
}
|
||||
td.fp-sticker-value {
|
||||
@@ -217,43 +242,55 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fp-sticker-strong { font-weight: 700; }
|
||||
.fp-sticker-muted { color: #555; font-size: 28pt; }
|
||||
.fp-sticker-muted { color: #555; font-size: 30pt; }
|
||||
/* Notes column on the right side of the body. */
|
||||
.fp-notes-label {
|
||||
font-weight: 700;
|
||||
font-size: 48pt;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
.fp-notes-content {
|
||||
font-size: 36pt;
|
||||
line-height: 1.1;
|
||||
white-space: pre-line;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Per-box loop: renders one sticker page per physical box in
|
||||
the line/job qty. When _qty_total is missing/0/1, falls
|
||||
back to a single render (no "X / N" indicator). -->
|
||||
<t t-foreach="range(int(_qty_total or 1))" t-as="_box_idx0">
|
||||
<t t-set="_box_idx" t-value="_box_idx0 + 1"/>
|
||||
<div class="fp-sticker">
|
||||
<!-- 3-cell header: Logo | WO# | QR -->
|
||||
<div class="fp-sticker-head-wrap">
|
||||
<table class="fp-sticker-head">
|
||||
<colgroup>
|
||||
<col class="fp-col-head-left"/>
|
||||
<col class="fp-col-head-right"/>
|
||||
<col class="fp-col-head-logo"/>
|
||||
<col class="fp-col-head-wo"/>
|
||||
<col class="fp-col-head-qr"/>
|
||||
</colgroup>
|
||||
<tr>
|
||||
<td class="fp-sticker-head-left">
|
||||
<td class="fp-sticker-head-logo">
|
||||
<!-- env.company.logo is often blank while logo_web
|
||||
is populated from the company partner's image.
|
||||
Fall back across both + partner.image_1920. -->
|
||||
is populated from the partner's image. Fall
|
||||
back across both + partner.image_1920. -->
|
||||
<t t-set="_logo" t-value="env.company.logo
|
||||
or env.company.logo_web
|
||||
or env.company.partner_id.image_1920
|
||||
or False"/>
|
||||
<table class="fp-sticker-head-left-stack">
|
||||
<tr class="fp-row-logo">
|
||||
<td>
|
||||
<img t-if="_logo"
|
||||
class="fp-sticker-logo"
|
||||
t-att-src="image_data_uri(_logo)"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="fp-row-wo">
|
||||
<td>
|
||||
<div class="fp-sticker-wo">
|
||||
WO #<span t-esc="_order_id"/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<img t-if="_logo"
|
||||
class="fp-sticker-logo"
|
||||
t-att-src="image_data_uri(_logo)"/>
|
||||
</td>
|
||||
<td class="fp-sticker-head-right">
|
||||
<td class="fp-sticker-head-wo">
|
||||
<div class="fp-sticker-wo">
|
||||
<span t-esc="_order_id"/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fp-sticker-qr-wrap" t-if="_qr_src">
|
||||
<img class="fp-sticker-qr"
|
||||
t-att-src="_qr_src"/>
|
||||
@@ -263,92 +300,95 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Body: 7-row field table on the left, full-height Notes
|
||||
column on the right showing the customer-facing description. -->
|
||||
<div class="fp-sticker-body-wrap">
|
||||
<table class="fp-sticker-body">
|
||||
<colgroup>
|
||||
<col class="fp-col-label"/>
|
||||
<col class="fp-col-value"/>
|
||||
</colgroup>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">PO (RO):</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span class="fp-sticker-strong"
|
||||
t-esc="_po_number"/>
|
||||
<t t-if="_mo_ref">
|
||||
<span class="fp-sticker-muted">
|
||||
(<span t-esc="_mo_ref"/>)
|
||||
</span>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Customer:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span t-esc="_partner_name"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Process:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<t t-if="_process">
|
||||
<span t-esc="_process.name"/>
|
||||
</t>
|
||||
<t t-elif="_coating">
|
||||
<span t-esc="_coating.name"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Part Number:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<t t-if="_part">
|
||||
<span class="fp-sticker-strong"
|
||||
t-esc="_part.part_number"/>
|
||||
<t t-if="_part.revision">
|
||||
<!-- Some parts store the revision with a
|
||||
"Rev " prefix already (e.g. "Rev 1"),
|
||||
others store just the value ("1", "A").
|
||||
Strip a leading "Rev " (case insensitive)
|
||||
so we don't print "Rev Rev 1". -->
|
||||
<t t-set="_rev_clean" t-value="_part.revision.strip()"/>
|
||||
<t t-if="_rev_clean.lower().startswith('rev ')">
|
||||
<t t-set="_rev_clean" t-value="_rev_clean[4:].strip()"/>
|
||||
<div class="fp-body-left">
|
||||
<table class="fp-sticker-body">
|
||||
<colgroup>
|
||||
<col class="fp-col-label"/>
|
||||
<col class="fp-col-value"/>
|
||||
</colgroup>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">PO #:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span class="fp-sticker-strong"
|
||||
t-esc="_po_number"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">SN #:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span t-esc="_serial_number"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Customer:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span t-esc="_partner_display"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Part #:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<t t-if="_part">
|
||||
<span class="fp-sticker-strong"
|
||||
t-esc="_part.part_number"/>
|
||||
<t t-if="_part.revision">
|
||||
<!-- Strip "Rev " prefix if the field
|
||||
value already includes it, so we
|
||||
don't print "Rev Rev 1". -->
|
||||
<t t-set="_rev_clean" t-value="_part.revision.strip()"/>
|
||||
<t t-if="_rev_clean.lower().startswith('rev ')">
|
||||
<t t-set="_rev_clean" t-value="_rev_clean[4:].strip()"/>
|
||||
</t>
|
||||
<span class="fp-sticker-muted">
|
||||
Rev <span t-esc="_rev_clean"/>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
<span class="fp-sticker-muted">
|
||||
Rev <span t-esc="_rev_clean"/>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Due Date:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<t t-if="_due">
|
||||
<span t-esc="_due.strftime('%b %d, %Y')"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Thickness:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span t-esc="_thickness"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Qty:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span class="fp-sticker-strong">
|
||||
<t t-if="_qty_total and int(_qty_total) > 1">
|
||||
<span t-esc="_box_idx"/> / <span t-esc="int(_qty_total)"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
|
||||
</t>
|
||||
</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Due Date:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<t t-if="_due">
|
||||
<span t-esc="_due.strftime('%b %d, %Y')"/>
|
||||
</t>
|
||||
<t t-else="">-</t>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Qty:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<span class="fp-sticker-strong">
|
||||
<span t-esc="int(_qty) if _qty == int(_qty) else _qty"/>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="fp-sticker-label">Notes:</td>
|
||||
<td class="fp-sticker-value">
|
||||
<t t-esc="_internal_note"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="fp-body-right">
|
||||
<div class="fp-notes-label">Notes:</div>
|
||||
<div class="fp-notes-content">
|
||||
<t t-esc="_notes_content"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- =====================================================
|
||||
@@ -370,6 +410,8 @@
|
||||
<t t-set="_mo_ref" t-value="False"/>
|
||||
<t t-set="_internal_note" t-value="False"/>
|
||||
<t t-set="_scan_path" t-value="False"/>
|
||||
<t t-set="_notes_content" t-value="False"/>
|
||||
<t t-set="_qty_total" t-value="False"/>
|
||||
</template>
|
||||
|
||||
<!-- ========== Outer template — mrp.workorder entry ========== -->
|
||||
@@ -407,18 +449,19 @@
|
||||
skipped — they don't go through plating so they don't need a
|
||||
box sticker.
|
||||
|
||||
The "WO #" header shows "<SO>/<line seq>" so the sticker
|
||||
remains identifiable before the fp.job is generated. The QR
|
||||
encodes /fp/so-line/<line.id> — the controller can decide
|
||||
whether to land on the parent SO, the line, or (later) the
|
||||
spawned job. -->
|
||||
The "WO#" header shows the SO name (e.g. SO-30019). The body
|
||||
carries the part-specific fields (Part #, Customer, etc.) which
|
||||
disambiguate multi-line SOs without needing a sequence suffix.
|
||||
The QR encodes /fp/so-line/<line.id> — the controller can
|
||||
decide whether to land on the parent SO, the line, or (later)
|
||||
the spawned job. -->
|
||||
<template id="report_fp_so_sticker">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="so">
|
||||
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
|
||||
t-as="line">
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<t t-set="_order_id" t-value="so.name + ' / ' + str(line.sequence or line.id)"/>
|
||||
<t t-set="_order_id" t-value="so.name"/>
|
||||
<t t-set="_scan_id" t-value="line.id"/>
|
||||
<t t-set="_scan_path" t-value="'/fp/so-line/'"/>
|
||||
<t t-set="_mo" t-value="False"/>
|
||||
@@ -428,6 +471,7 @@
|
||||
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
|
||||
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
|
||||
<t t-set="_qty" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_partner_name" t-value="so.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
@@ -436,4 +480,37 @@
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- ========== Outer template — sale.order Internal variant ==========
|
||||
Same layout + iteration as report_fp_so_sticker, but pre-sets
|
||||
_notes_content from x_fc_internal_description (Sub 2 internal
|
||||
description field) so the Notes column shows the ops-facing
|
||||
description instead of line.name. -->
|
||||
<template id="report_fp_so_sticker_internal">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="so">
|
||||
<t t-foreach="so.order_line.filtered(lambda l: l.x_fc_part_catalog_id)"
|
||||
t-as="line">
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_defaults"/>
|
||||
<t t-set="_order_id" t-value="so.name"/>
|
||||
<t t-set="_scan_id" t-value="line.id"/>
|
||||
<t t-set="_scan_path" t-value="'/fp/so-line/'"/>
|
||||
<t t-set="_mo" t-value="False"/>
|
||||
<t t-set="_so" t-value="so"/>
|
||||
<t t-set="_line" t-value="line"/>
|
||||
<t t-set="_part" t-value="line.x_fc_part_catalog_id"/>
|
||||
<t t-set="_coating" t-value="line.x_fc_coating_config_id"/>
|
||||
<t t-set="_due" t-value="line.x_fc_part_deadline or so.commitment_date or False"/>
|
||||
<t t-set="_qty" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_qty_total" t-value="line.product_uom_qty"/>
|
||||
<t t-set="_partner_name" t-value="so.partner_id.name"/>
|
||||
<t t-set="_mo_ref" t-value="''"/>
|
||||
<!-- Internal override: read x_fc_internal_description -->
|
||||
<t t-set="_notes_content" t-value="('x_fc_internal_description' in line._fields
|
||||
and line.x_fc_internal_description) or '-'"/>
|
||||
<t t-call="fusion_plating_reports.report_fp_wo_sticker_inner"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
258
fusion_whitelabels/data/fusion_whitelabels_mail_templates.xml
Normal file
258
fusion_whitelabels/data/fusion_whitelabels_mail_templates.xml
Normal file
@@ -0,0 +1,258 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
<!--
|
||||
Override auth_signup.set_password_email
|
||||
Original: heavy Odoo branding in body and subject
|
||||
(Welcome to Odoo, "connect to Odoo", "Odoo Tour", "Powered by Odoo", etc.)
|
||||
Whitelabeled: company-branded invite, no Odoo references.
|
||||
-->
|
||||
<record id="auth_signup.set_password_email" model="mail.template">
|
||||
<field name="subject">{{ object.create_uid.name }} from {{ object.company_id.name }} invites you to your account</field>
|
||||
<field name="body_html" type="html">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #FFFFFF; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: #FFFFFF; color: #454748; border-collapse:separate;">
|
||||
<tbody>
|
||||
<!-- HEADER -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="middle">
|
||||
<span style="font-size: 10px;">Welcome to <t t-out="object.company_id.name or ''">YourCompany</t></span><br/>
|
||||
<span style="font-size: 20px; font-weight: bold;">
|
||||
<t t-out="object.name or ''">Marc Demo</t>
|
||||
</span>
|
||||
</td><td valign="middle" align="right" t-if="not object.company_id.uses_default_logo">
|
||||
<img t-attf-src="/logo.png?company={{ object.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="object.company_id.name"/>
|
||||
</td></tr>
|
||||
<tr><td colspan="2" style="text-align:center;">
|
||||
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- CONTENT -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="top" style="font-size: 13px;">
|
||||
<div>
|
||||
Dear <t t-out="object.name or ''">Marc Demo</t>,<br/><br/>
|
||||
You have been invited by <t t-out="object.create_uid.name or ''">Admin</t> at <t t-out="object.company_id.name or ''">YourCompany</t> to access your account.
|
||||
<div style="margin: 16px 0px 16px 0px;">
|
||||
<a t-att-href="object.partner_id._get_signup_url()"
|
||||
t-attf-style="background-color: {{object.company_id.email_secondary_color or '#875A7B'}}; padding: 8px 16px 8px 16px; text-decoration: none; color: {{object.company_id.email_primary_color or '#FFFFFF'}}; border-radius: 5px; font-size:13px;">
|
||||
Accept invitation
|
||||
</a>
|
||||
</div>
|
||||
<b>This link will remain valid for <t t-out="int(int(object.env['ir.config_parameter'].sudo().get_param('auth_signup.signup.validity.hours',144))/24)"></t> days.</b><br/>
|
||||
<t t-set="website_url" t-value="object.get_base_url()"></t>
|
||||
Sign-in URL: <b><a t-att-href='website_url' t-out="website_url or ''">https://yourcompany.com</a></b><br/>
|
||||
Your sign-in email: <b><a t-attf-href="/web/login?login={{ object.email }}" target="_blank" t-out="object.email or ''">user@example.com</a></b><br/><br/>
|
||||
Welcome aboard!<br/>
|
||||
--<br/>The <t t-out="object.company_id.name or ''">YourCompany</t> Team
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="text-align:center;">
|
||||
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- FOOTER -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="middle" align="left">
|
||||
<t t-out="object.company_id.name or ''">YourCompany</t>
|
||||
</td></tr>
|
||||
<tr><td valign="middle" align="left" style="opacity: 0.7;">
|
||||
<t t-out="object.company_id.phone or ''">+1 650-123-4567</t>
|
||||
<t t-if="object.company_id.email">
|
||||
| <a t-att-href="'mailto:%s' % object.company_id.email" style="text-decoration:none; color: #454748;" t-out="object.company_id.email or ''">info@yourcompany.com</a>
|
||||
</t>
|
||||
<t t-if="object.company_id.website">
|
||||
| <a t-att-href="'%s' % object.company_id.website" style="text-decoration:none; color: #454748;" t-out="object.company_id.website or ''">http://www.example.com</a>
|
||||
</t>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!--
|
||||
Override auth_signup.mail_template_user_signup_account_created
|
||||
(sent to portal users who self-registered)
|
||||
Just removes the "Powered by Odoo" footer block.
|
||||
-->
|
||||
<record id="auth_signup.mail_template_user_signup_account_created" model="mail.template">
|
||||
<field name="body_html" type="html">
|
||||
<table border="0" cellpadding="0" cellspacing="0" style="padding-top: 16px; background-color: #FFFFFF; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;"><tr><td align="center">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="padding: 16px; background-color: #FFFFFF; color: #454748; border-collapse:separate;">
|
||||
<tbody>
|
||||
<!-- HEADER -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="middle">
|
||||
<span style="font-size: 10px;">Your Account</span><br/>
|
||||
<span style="font-size: 20px; font-weight: bold;">
|
||||
<t t-out="object.name or ''">Marc Demo</t>
|
||||
</span>
|
||||
</td><td valign="middle" align="right" t-if="not object.company_id.uses_default_logo">
|
||||
<img t-attf-src="/logo.png?company={{ object.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;" t-att-alt="object.company_id.name"/>
|
||||
</td></tr>
|
||||
<tr><td colspan="2" style="text-align:center;">
|
||||
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- CONTENT -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="top" style="font-size: 13px;">
|
||||
<div>
|
||||
Dear <t t-out="object.name or ''">Marc Demo</t>,<br/><br/>
|
||||
Your account has been successfully created!<br/>
|
||||
Your login is <strong><t t-out="object.email or ''">mark.brown23@example.com</t></strong><br/>
|
||||
To gain access to your account, you can use the following link:
|
||||
<div style="margin: 16px 0px 16px 0px;">
|
||||
<a t-attf-href="/web/login?auth_login={{object.email}}"
|
||||
t-attf-style="background-color: {{object.company_id.email_secondary_color or '#875A7B'}}; padding: 8px 16px 8px 16px; text-decoration: none; color: {{object.company_id.email_primary_color or '#FFFFFF'}}; border-radius: 5px; font-size:13px;">
|
||||
Go to My Account
|
||||
</a>
|
||||
</div>
|
||||
Thanks,<br/>
|
||||
<t t-if="user.signature">
|
||||
<br/>
|
||||
<div>--<br/><t t-out="user.signature or ''">Mitchell Admin</t></div>
|
||||
</t>
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="text-align:center;">
|
||||
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- FOOTER -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590" style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="middle" align="left">
|
||||
<t t-out="object.company_id.name or ''">YourCompany</t>
|
||||
</td></tr>
|
||||
<tr><td valign="middle" align="left" style="opacity: 0.7;">
|
||||
<t t-out="object.company_id.phone or ''">+1 650-123-4567</t>
|
||||
<t t-if="object.company_id.email">
|
||||
| <a t-attf-href="mailto:{{object.company_id.email}}" style="text-decoration:none; color: #454748;" t-out="object.company_id.email or ''">info@yourcompany.com</a>
|
||||
</t>
|
||||
<t t-if="object.company_id.website">
|
||||
| <a t-att-href="object.company_id.website" style="text-decoration:none; color: #454748;" t-out="object.company_id.website or ''">http://www.example.com</a>
|
||||
</t>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!--
|
||||
Override auth_signup.portal_set_password_email
|
||||
(sent to new portal users when admin invites them)
|
||||
Just removes the "Powered by Odoo" footer block.
|
||||
-->
|
||||
<record id="auth_signup.portal_set_password_email" model="mail.template">
|
||||
<field name="body_html" type="html">
|
||||
<table border="0" cellpadding="0" cellspacing="0"
|
||||
style="padding-top: 16px; background-color: #F1F1F1; font-family:Verdana, Arial,sans-serif; color: #454748; width: 100%; border-collapse:separate;">
|
||||
<tr><td align="center">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590"
|
||||
style="padding: 16px; background-color: white; color: #454748; border-collapse:separate;">
|
||||
<tbody>
|
||||
<!-- HEADER -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590"
|
||||
style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr>
|
||||
<td valign="middle">
|
||||
<span style="font-size: 10px;">Your Account</span><br/>
|
||||
<span style="font-size: 20px; font-weight: bold;" t-out="object.name or ''">Marc Demo</span>
|
||||
</td>
|
||||
<td valign="middle" align="right" t-if="not object.company_id.uses_default_logo">
|
||||
<img t-attf-src="/logo.png?company={{ object.company_id.id }}" style="padding: 0px; margin: 0px; height: auto; width: 80px;"
|
||||
t-att-alt="object.company_id.name"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td colspan="2" style="text-align:center;">
|
||||
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin:16px 0px 16px 0px;"/>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- CONTENT -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590"
|
||||
style="min-width: 590px; background-color: white; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="top" style="font-size: 13px;">
|
||||
<div>
|
||||
Dear <t t-out="object.name or ''">Marc Demo</t>,<br/><br/>
|
||||
Welcome to <t t-out="object.company_id.name">YourCompany</t>'s Portal!<br/><br/>
|
||||
An account has been created for you with the following login: <t t-out="object.login">demo</t><br/><br/>
|
||||
Click on the button below to pick a password and activate your account.
|
||||
<div style="margin: 16px 0px 16px 0px; text-align: center;">
|
||||
<a t-att-href="object.partner_id._get_signup_url()"
|
||||
t-attf-style="display: inline-block; padding: 10px; text-decoration: none; font-size: 12px; background-color: {{object.company_id.email_secondary_color or '#875A7B'}}; color: {{object.company_id.email_primary_color or '#FFFFFF'}}; border-radius: 5px;">
|
||||
<strong>Activate Account</strong>
|
||||
</a>
|
||||
</div>
|
||||
<t t-out="ctx.get('welcome_message') or ''">Welcome to our company's portal.</t>
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="text-align:center;">
|
||||
<hr width="100%" style="background-color:rgb(204,204,204);border:medium none;clear:both;display:block;font-size:0px;min-height:1px;line-height:0; margin: 16px 0px 16px 0px;"/>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- FOOTER -->
|
||||
<tr>
|
||||
<td align="center" style="min-width: 590px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="590"
|
||||
style="min-width: 590px; background-color: white; font-size: 11px; padding: 0px 8px 0px 8px; border-collapse:separate;">
|
||||
<tr><td valign="middle" align="left">
|
||||
<t t-out="object.company_id.name or ''">YourCompany</t>
|
||||
</td></tr>
|
||||
<tr><td valign="middle" align="left" style="opacity: 0.7;">
|
||||
<t t-out="object.company_id.phone or ''">+1 650-123-4567</t>
|
||||
<t t-if="object.company_id.email">
|
||||
| <a t-attf-href="mailto:{{ object.company_id.email }}" style="text-decoration: none; color: #454748;" t-out="object.company_id.email or ''">info@yourcompany.com</a>
|
||||
</t>
|
||||
<t t-if="object.company_id.website">
|
||||
| <a t-att-href="object.company_id.website" style="text-decoration: none; color: #454748;" t-out="object.company_id.website or ''">http://www.example.com</a>
|
||||
</t>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, SUPERUSER_ID
|
||||
|
||||
from odoo.addons.fusion_whitelabels import _apply_mail_overrides
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
_apply_mail_overrides(env)
|
||||
Reference in New Issue
Block a user