Compare commits

..

27 Commits

Author SHA1 Message Date
gsinghpal
005daade55 changes 2026-05-23 07:53:41 -04:00
gsinghpal
27e12dd544 chore(shopfloor): register fp_rpc.js asset + bump to 19.0.30.2.0 (P6.3.6)
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
Adds the Phase 6.3 fpRpc wrapper to the web.assets_backend bundle.
Placed before its consumers so the `import { fpRpc } from "./services/fp_rpc"`
calls in job_workspace, shopfloor_landing, manager_dashboard, and
hold_composer resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:47:51 -04:00
gsinghpal
5f03080374 feat(shopfloor): switch action-path RPCs to fpRpc + wire plant_overview/move_card (P6.3.5)
JobWorkspace, ShopfloorLanding, ManagerDashboard, and the embedded
FpHoldComposer now call fpRpc() for write-path endpoints (start/finish
step, hold create, sign-off, milestone advance, work-centre move,
assign-worker, assign-tank, manager takeover). fpRpc auto-injects
tablet_tech_id from the tech_store so the server can rebind env via
env_for_tablet_tech() and credit the right user.

Read-path RPCs (workspace/load, landing/kanban, manager/overview,
manager/funnel, manager/approval_inbox, manager/at_risk, shopfloor/scan)
stay as plain rpc() — no audit benefit, no need for the extra plumbing.

Also wires tablet_tech_id into /fp/shopfloor/plant_overview/move_card
which I missed in P6.3.3 — surfaced when grepping JS for write callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:47:20 -04:00
gsinghpal
efaf16dffb feat(shopfloor): propagate tablet_tech_id to shopfloor + manager action endpoints (P6.3.3 + P6.3.4)
10 endpoints in shopfloor_controller (log_chemistry, start_bake, end_bake,
start_wo, stop_wo, bump_qty_done, bump_qty_scrapped, log_thickness_reading,
quality_hold, mark_gate) and 3 in manager_controller (assign_worker,
assign_tank, take_over) now accept a `tablet_tech_id` kwarg. Each rebinds
env via env_for_tablet_tech() so writes carry the correct uid even when
the OS session belongs to the persistent tablet user.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:43:44 -04:00
gsinghpal
e4000374ca feat(fusion_plating_shopfloor): wire tablet_tech_id into workspace endpoints (P6.3.2)
hold, sign_off, advance_milestone each accept tablet_tech_id and
rebind env via env_for_tablet_tech. Writes (Hold.create, button_finish,
action_advance_next_milestone) now carry the tech-of-record's uid.
load endpoint is read-only and untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:37:58 -04:00
gsinghpal
fee4219703 feat(fusion_plating_shopfloor): fpRpc wrapper + env_for_tablet_tech helper (P6.3.1)
Client-side fpRpc() is a drop-in for rpc() that automatically injects
tablet_tech_id from the tech_store into every action call. Read-only
endpoints can keep using plain rpc().

Server-side env_for_tablet_tech(env, tablet_tech_id) returns an env
scoped via with_user() when the id is a valid active user; otherwise
returns the original env unchanged. Controllers call this at the top
of action methods so all subsequent writes carry the right uid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:37:02 -04:00
gsinghpal
6ca9a58a8c chore(fusion_plating_shopfloor): bump 19.0.30.1.0 for Phase 6.2 — lock screen
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
Frontend lock screen ships:
- tech_store + activity_tracker shared OWL services
- FpPinPad, FpIdleWarning, FpPinSetup components
- FpTabletLock outer wrapper
- Wired into Landing/Workspace/Manager + Hand-Off button in each header
- fp_tablet_pin_setup client action for Preferences self-service
2026-05-23 00:33:42 -04:00
gsinghpal
d86c120969 feat(fusion_plating_shopfloor): FpPinSetup client action for self-service PIN (P6.2.6)
Registers fp_tablet_pin_setup as an ir.actions.client tag. Triggered
from res.users preferences via action_open_tablet_pin_setup (added
to res_users.py in P6.1.1). Three-stage flow:

  loading → check if user has existing PIN via search_count
  old     → enter current PIN (skipped if first-time)
  new     → choose new PIN
  confirm → enter new PIN again
  done    → success toast + auto-close 1.5s later

Each stage reuses FpPinPad with a different onSubmit + title. On
mismatch / server error, resets to the first stage with a notification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:33:28 -04:00
gsinghpal
85609f99cd feat(fusion_plating_shopfloor): wire FpTabletLock + Hand-Off into Landing/Workspace/Manager (P6.2.5)
Three OWL client actions all wrap their root in <FpTabletLock>:

  ShopfloorLanding   wraps o_fp_landing
  JobWorkspace       wraps o_fp_ws
  ManagerDashboard   wraps o_fp_manager

Each adds FpTabletLock to static components, imports tech_store, and
gains a handOff() method that calls techStore.lock(). The Hand-Off
button (yellow, lock icon) lands next to the scan/QR controls in each
header — pressing it instantly returns the tablet to the tile grid
without waiting for the idle timer.

Component composition (per spec §6.5):
  FpTabletLock
    if isLocked → tile grid + FpPinPad
    else → existing client action (via <t t-slot="default"/>) + FpIdleWarning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:32:52 -04:00
gsinghpal
29821bd541 feat(fusion_plating_shopfloor): FpTabletLock outer wrapper component (P6.2.4)
Top-level wrapper that renders lock screen (tile grid + PIN pad) when
no tech is signed in, and renders <t t-slot="default"/> otherwise.
Drives the auto-lock countdown via the activity_tracker service +
sends a /fp/tablet/ping heartbeat every 60s while a tech is signed in.

Tiles fetch from /fp/tablet/tiles using the localStorage station id
(set by ShopfloorLanding on QR pair / station picker selection).

State machine for the lock screen body:
  loadingTiles → tiles list → tile tapped → PinPad → unlock RPC
                                          ↑
                                          onPinCancel → back to tiles

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:29:24 -04:00
gsinghpal
1fdafd34d1 feat(fusion_plating_shopfloor): FpIdleWarning overlay (P6.2.3)
Fixed-position yellow-border overlay + countdown toast shown during
the last N (default 30) seconds before auto-lock. Pure props-driven —
secondsRemaining is the only input; parent (FpTabletLock) decides
when to mount and unmount. Box-shadow pulse animation runs CSS-only
so OWL doesn't need to re-render every tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:28:30 -04:00
gsinghpal
9584953467 feat(fusion_plating_shopfloor): FpPinPad numeric keypad component (P6.2.2)
Reusable 4-digit PIN pad. Auto-submits on the 4th digit via the
onSubmit prop. On wrong PIN, shake animation + dots clear + error
banner (caller controls the message via the returned {ok:false, error}).

Used by FpTabletLock (unlock flow) and FpPinSetup (set/change flow).

Dark-mode SCSS branch follows the same $o-webclient-color-scheme
pattern as the rest of the shopfloor components.

Also registers tech_store + activity_tracker services in the asset
bundle (assets/web.assets_backend) before the pin_pad files, since
the pin_pad/tablet_lock components consume them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:28:01 -04:00
gsinghpal
52097ca59b feat(fusion_plating_shopfloor): tech_store + activity_tracker OWL services (P6.2.1)
Two registry-level services:

tech_store    Shared reactive state holding currentTechId after a
              successful PIN unlock. Other components subscribe via
              useService("fp_shopfloor_tech_store") and read
              currentTechId to inject into action RPCs. setTech(id, name)
              on unlock; lock() on auto-lock / Hand-Off.

activity_tracker  Document-level event tracker for pointerdown / touchstart
              / keydown / visibilitychange. Mouse-move alone deliberately
              EXCLUDED — a tool resting on a tablet would otherwise keep
              the session alive indefinitely. Public API:
                bump(), getSecondsUntilLock(), getWarnThresholdSec()
              Reads thresholds from ir.config_parameter at start +
              every 5 min (so manager edits propagate within a shift).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:27:13 -04:00
gsinghpal
1d6184dd2f chore(fusion_plating_shopfloor): bump 19.0.30.0.0 for Phase 6.1 — PIN backend
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
Backend foundation for the tablet PIN gate:
- res.users PIN fields + hash helpers (PBKDF2-SHA256, 200k iter, salted)
- 5 endpoints: /fp/tablet/{tiles,unlock,set_pin,reset_pin_for,ping}
- Per-user lockout (5 fails → 5 min, both configurable)
- Station roster + per-station idle override
- ir.config_parameter defaults
- Preferences Set/Change PIN button + Manager Reset button

Phase 6.2 (frontend lock screen) is next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:17:37 -04:00
gsinghpal
88a473e7eb feat(fusion_plating_shopfloor): Preferences Set/Change PIN + Manager Reset button (P6.1.7)
Two view inheritances on res.users:

(a) Preferences form — adds a 'Tablet PIN' group with a 'Set / Change
    Tablet PIN' button that triggers action_open_tablet_pin_setup → the
    fp_tablet_pin_setup OWL client action (Phase 6.2). Shows PIN Last
    Set as read-only context.

(b) Standard res.users form — header button 'Reset Tablet PIN' visible
    only to the fusion_plating manager group; hidden when no PIN is set
    (via the set_date invisible field reference). Confirms before clearing.
    Calls the clear_tablet_pin method from the model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:17:20 -04:00
gsinghpal
08ababc2c7 feat(fusion_plating_shopfloor): station roster + idle override + tablet config defaults (P6.1.6)
Adds two fields to fusion.plating.shopfloor.station:
- x_fc_authorised_user_ids (Many2many → res.users): restricts the
  tablet lock-screen tile grid to a specific roster per station.
  Empty = all operator-group users shown.
- x_fc_idle_lock_minutes (Integer, nullable): per-station override
  for the auto-lock idle threshold; null = use system parameter.

Plus data/fp_tablet_config_data.xml registers four ir.config_parameter
defaults (noupdate=1 — manager can override via Settings → Technical
→ Parameters):
  fp.shopfloor.tablet_idle_lock_minutes = 5
  fp.shopfloor.tablet_pin_fail_threshold = 5
  fp.shopfloor.tablet_pin_fail_lockout_minutes = 5
  fp.shopfloor.tablet_warn_seconds_before_lock = 30

Form view surfaces both new fields in a dedicated 'Tablet PIN Gate'
group.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:16:52 -04:00
gsinghpal
59ad77839a feat(fusion_plating_shopfloor): /fp/tablet/tiles + /fp/tablet/ping endpoints (P6.1.4-P6.1.5)
Tiles returns the lock-screen grid: operator-group users, sorted
clocked-in-first then alphabetical, with avatar URL + has_pin flag.
Honours station.x_fc_authorised_user_ids when non-empty (Phase 6.1.6
adds that field). Ping is a lightweight ack used by FpTabletLock as
a heartbeat — logs current_tech_id at DEBUG for forensic visibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:15:40 -04:00
gsinghpal
a594431eb6 feat(fusion_plating_shopfloor): /fp/tablet/unlock with per-user lockout (P6.1.3)
Verifies PIN, resets failure counter on success, increments + locks out
on 5 consecutive failures (configurable via ir.config_parameter
fp.shopfloor.tablet_pin_fail_threshold + tablet_pin_fail_lockout_minutes,
both defaulting to 5).

Returns informative payloads:
  ok=true            current_tech_id, current_tech_name
  needs_setup=true   user has no PIN yet
  locked_until       lockout in effect (rejects even correct PIN)
  attempts_remaining failed but not yet locked

Logs INFO on success, WARNING on failure (with running counter +
locked flag).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:15:01 -04:00
gsinghpal
58d02598da feat(fusion_plating_shopfloor): /fp/tablet/set_pin + /fp/tablet/reset_pin_for endpoints (P6.1.2)
set_pin is self-service: requires old PIN if a hash exists, validates
4-digit format. reset_pin_for is manager-only (enforced server-side
via has_group); clears the hash + posts to chatter.

Both endpoints log INFO on success and WARNING on access-control denials.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:14:18 -04:00
gsinghpal
395bd4949e feat(fusion_plating_shopfloor): res.users tablet PIN fields + hash helpers (P6.1.1)
PBKDF2-SHA256 + 16-byte salt + 200k iterations on res.users. Format
of the stored hash string is <salt_hex>$<digest_hex>. Field is
manager-readable only (groups=group_fusion_plating_manager); helpers
that need to read or write it use .sudo() internally so operator-level
callers can still set/verify their own PIN.

Adds set_tablet_pin / verify_tablet_pin / clear_tablet_pin model
methods + action_open_tablet_pin_setup that triggers the OWL setup
modal (Phase 6.2). Tests cover hash uniqueness, verify, clear with
chatter post, and the 4-digit format guard.

Tests verified on entech: -u fusion_plating_shopfloor --test-tags fp_tablet_pin

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:13:33 -04:00
gsinghpal
a6546ac858 docs(fusion_plating_shopfloor): implementation plan for Phase 6 PIN gate
3-sub-phase TDD plan executing the spec at
docs/superpowers/specs/2026-05-22-shopfloor-pin-gate-design.md:

- Phase 6.1 (Backend): res.users PIN fields + PBKDF2-SHA256 hash
  helpers, 5 /fp/tablet/* endpoints (tiles/unlock/set_pin/reset_pin_for/
  ping), per-user lockout after 5 failures, station roster +
  idle-override fields, ir.config_parameter defaults, Preferences
  Set/Change PIN button, manager Reset PIN header button. Tests
  cover hash safety, lockout edge cases, manager-only enforcement,
  tile filtering.

- Phase 6.2 (Frontend lock screen): tech_store + activity_tracker
  OWL services, FpPinPad + FpIdleWarning + FpPinSetup components,
  FpTabletLock outer wrapper, wire into Landing/Workspace/Manager
  Dashboard with Hand-Off button injection.

- Phase 6.3 (Audit propagation): fpRpc wrapper auto-injects
  tablet_tech_id, env_for_tablet_tech server helper, all action
  endpoints (workspace + shopfloor + manager) accept the kwarg and
  rebind env via env.with_user() so writes carry the right operator.

Each sub-phase ships independently per spec §9. Plan follows the
established workflow: write tests + commit, verify on entech (local
docker doesn't have fusion_plating mounted).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 00:05:45 -04:00
gsinghpal
233e5e6e72 docs(fusion_plating_shopfloor): Phase 6 PIN gate + auto-lock spec
Sequel to the 2026-05-22 tablet redesign (Phases 1-5). Adds a tile-grid
lock screen + 4-digit PIN per tech + 5-min auto-lock + audit propagation
so multiple techs sharing one tablet get correctly-attributed actions.

Key design choices:
- 4-digit PIN (industry norm), PBKDF2-SHA256 with 200k iterations
- Per-user lockout after 5 failures (not per-tablet)
- Single Odoo session + tablet_tech_id kwarg for audit (no JS reload on
  every tech switch)
- Manager-side reset only (no SMS/email infra)
- Server-side step timer keeps running on lock (auto-pause cron is
  the upper-bound safety net)

Three sub-phases (6.1 backend / 6.2 frontend lock / 6.3 audit kwarg
propagation), each independently deployable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:48:46 -04:00
gsinghpal
b06a5b2d12 fix(fusion_plating_jobs): delete orphan Plant Overview menu record
Phase 3 removed the menu_fp_shopfloor_plant_overview menuitem from
fp_menu.xml, but Odoo doesn't auto-delete orphan records when XML
disappears — the menu stayed in the database. Combined with P3.5's
action retarget (action_fp_plant_overview tag → fp_shopfloor_landing),
clicking it landed on the same Landing component as Workstation —
hence the duplicate menu items both opening the same screen.

Adds <delete model='ir.ui.menu' id='...'> in legacy_menu_hide.xml so
future -u runs scrub the orphan. Drops the now-defunct group_ids
block for the deleted menu. The action record stays (bookmark
back-compat).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:07:34 -04:00
gsinghpal
3ef67c6beb fix(fusion_plating_shopfloor): import fields in manager_controller
The Phase 4 endpoints (/fp/manager/funnel, approval_inbox, at_risk)
all use fields.Datetime.now() but the controller only imported http
+ request. Hitting the Workflow Funnel tab on Manager Desk threw:

  NameError: name 'fields' is not defined

Funnel auto-loads on dashboard mount → infinite spinner + 'Funnel:
Odoo Server Error' notification. Same bug would have hit at_risk
and approval_inbox on first navigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 23:01:44 -04:00
gsinghpal
4a304e02f3 fix(fusion_plating_jobs): restore quick_look_qty + instruction_attachment_ids
Same regression as the previous commit — b0070afc removed all 4 quick_look
related fields, my first fix only caught 2 of them. Restoring the remaining
2 so the quick-look view fully validates.
2026-05-22 22:47:43 -04:00
gsinghpal
0d08d2d135 fix(fusion_plating_jobs): restore quick_look_partner_id + quick_look_part_catalog_id
Commit b0070afc removed these two related fields from fp.job.step but
the view fp_job_step_quick_look_views.xml still references them. The
mismatch was dormant because entech never ran -u between b0070afc
and the 2026-05-22 deploy. Re-running -u during the Phase 1-4 deploy
caught it:

  Field "quick_look_part_catalog_id" does not exist in model
  "fp.job.step"

Restoring both as related fields (zero-cost, fixes the view without
touching XML).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:46:18 -04:00
gsinghpal
f9cb1b11ce fix(fusion_plating_jobs): drop ir.cron numbercall/doall — removed in Odoo 19
Caught during entech deploy of the Phase 2 auto-pause cron. Odoo 19
ir.cron no longer accepts numbercall or doall fields; the load fails
with:

  ValueError: Invalid field 'numbercall' in 'ir.cron'

Removed both from ir_cron_autopause_stale_steps. The other crons in
the same file (nudge stale paused / in_progress) already used the
minimal field set — matching that pattern now.

Also added a CLAUDE.md section so future-Claude doesn't reintroduce
the speculative fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:43:34 -04:00
92 changed files with 7997 additions and 110 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(ls /k/Github/Odoo-Modules/ | grep -i -E \"shopfloor|tablet|fusion_plating\")"
]
}
}

358
fusion_clock/CLAUDE.md Normal file
View File

@@ -0,0 +1,358 @@
# Fusion Clock - Claude Code Instructions
> Read together with the repo-root `../CLAUDE.md` for global Odoo 19 rules, asset-cache handling, Supabase KB notes, and shared Fusion conventions. This file is only for the `fusion_clock` module.
## 1. What This Module Is
- **Name**: Fusion Clock.
- **Version**: `19.0.3.3.0`.
- **Category**: Human Resources/Attendances.
- **License**: OPL-1, Nexa Systems Inc.
- **Purpose**: complete time and attendance app built on Odoo `hr.attendance`.
- **Top-level menu**: `Fusion Clock`.
- **Main surfaces**:
- Portal clock page at `/my/clock`.
- Portal timesheets at `/my/clock/timesheets`.
- Portal reports at `/my/clock/reports`.
- Shared PIN kiosk at `/fusion_clock/kiosk`.
- NFC tap kiosk at `/fusion_clock/kiosk/nfc`.
- Backend systray clock widget.
- Backend manager/team-lead dashboard client action.
Core behaviours: geofenced clock-in/out, IP whitelist fallback, shift scheduling, break deduction, penalties, overtime, auto clock-out, absence detection, leave requests, correction workflow, payroll CSV export, PDF reports, weekly summaries, shared kiosk, NFC kiosk with photo capture, and activity audit logs.
## 2. Dependencies
Declared in `__manifest__.py`:
```
hr_attendance, hr, portal, mail, resource
```
External Python used directly:
- `pytz` for timezone-safe local day boundaries.
- `requests` for Google Geocoding, OpenStreetMap/Nominatim fallback, and IP metadata.
- `dateutil.relativedelta` inside pay-period calculations.
External browser APIs:
- Browser geolocation.
- `ipapi.co` fallback geolocation in frontend/backend clock widgets.
- Google Maps/Places when `fusion_clock.google_maps_api_key` is configured.
- Web NFC and camera APIs for the NFC kiosk.
## 3. Naming And Field Prefixes
This module uses the module-specific prefix **`x_fclk_*`** on inherited Odoo models, not `x_fc_*`.
Examples:
- `hr.employee.x_fclk_enable_clock`
- `hr.employee.x_fclk_nfc_card_uid`
- `hr.attendance.x_fclk_clock_source`
- `res.company.x_fclk_nfc_kiosk_location_id`
New inherited fields in this module should keep the `x_fclk_*` prefix unless there is a strong migration reason not to.
## 4. Model Map
Custom models:
| Model | File | Purpose |
|---|---|---|
| `fusion.clock.location` | `models/clock_location.py` | Geofenced/IP-whitelisted clock locations. |
| `fusion.clock.shift` | `models/clock_shift.py` | Shift start/end/break schedule assigned to employees. |
| `fusion.clock.penalty` | `models/clock_penalty.py` | Late clock-in / early clock-out penalty records. |
| `fusion.clock.activity.log` | `models/clock_activity_log.py` | Append-style audit log for clock activity, geofence misses, absences, NFC enrolment, corrections. |
| `fusion.clock.leave.request` | `models/clock_leave_request.py` | Portal leave requests, auto-approved but office-notified. |
| `fusion.clock.correction` | `models/clock_correction.py` | Timesheet correction requests with approve/reject workflow. |
| `fusion.clock.report` | `models/clock_report.py` | Employee or batch pay-period report with PDF/CSV export and email send. |
| `fusion.clock.nfc.enrollment.wizard` | `wizard/clock_nfc_enrollment_wizard.py` | Backend NFC card enrolment/reassignment wizard. |
Inherited models:
- `hr.employee`: enable clock, default location, shift, kiosk PIN, NFC UID, pending reason flag, streaks, absence/overtime counters, and One2many links.
- `hr.attendance`: clock source, location, distances, photos, break minutes, net hours, penalties, auto clock-out flag, overtime fields.
- `res.config.settings`: all `fusion_clock.*` settings.
- `res.company`: NFC kiosk location binding.
Timezone helpers live in `models/tz_utils.py`. Use `get_local_today()` and `get_local_day_boundaries()` for attendance domains instead of comparing UTC dates directly.
## 5. Clocking Flow
Primary API endpoint: `/fusion_clock/clock_action` in `controllers/clock_api.py`.
Clock-in flow:
1. Resolve current user to `hr.employee`.
2. Block if `x_fclk_enable_clock` is false.
3. If `x_fclk_pending_reason` is true, return `requires_reason`.
4. Verify location against allowed active `fusion.clock.location` records.
5. Call Odoo's `_attendance_action_change()`.
6. Write location, distance, source, and optional photo to `hr.attendance`.
7. Log `clock_in`.
8. Create `late_in` penalty when outside grace.
9. Increment/reset on-time streak; log milestone at 5, 10, 20, 50, 100.
10. Notify office user for very-late clock-ins.
Clock-out flow:
1. Verify location again.
2. Call `_attendance_action_change()`.
3. Write out-distance.
4. Apply break deduction when configured.
5. Create `early_out` penalty when outside grace.
6. Log `clock_out`.
7. Log overtime if computed overtime is positive.
Location verification uses GPS when coordinates are available and geocoded locations exist. IP whitelist matching is attempted when a client IP is available. Error types include `no_locations`, `gps_unavailable`, `no_geocoded`, and `outside`.
## 6. Kiosk And NFC
Classic kiosk:
- Page: `/fusion_clock/kiosk`
- JSON routes:
- `/fusion_clock/kiosk/search`
- `/fusion_clock/kiosk/verify_pin`
- `/fusion_clock/kiosk/clock`
- Requires `fusion_clock.group_fusion_clock_manager`.
- Controlled by `fusion_clock.enable_kiosk` and `fusion_clock.kiosk_pin_required`.
- Uses `hr.employee.x_fclk_kiosk_pin`.
NFC kiosk:
- Page: `/fusion_clock/kiosk/nfc`
- JSON routes:
- `/fusion_clock/kiosk/nfc/enroll`
- `/fusion_clock/kiosk/nfc/tap`
- `/fusion_clock/kiosk/nfc/employee_search`
- Requires `fusion_clock.group_fusion_clock_manager`.
- Controlled by:
- `fusion_clock.enable_nfc_kiosk`
- `fusion_clock.nfc_photo_required`
- `fusion_clock.nfc_enroll_password`
- `fusion_clock.nfc_kiosk_debug`
- `res.company.x_fclk_nfc_kiosk_location_id`
- Card UID canonical format is uppercase colon-separated hex, e.g. `04:A2:B5:62:C1:80`.
- Normalization lives in `FusionClockNfcKiosk._normalize_uid()` and is reused by the backend wizard.
- Tap debounce is module-level memory in `controllers/clock_nfc_kiosk.py`: same UID within 5 seconds returns `debounce`.
- Photo data URLs are stripped before writing binary fields.
- NFC clock-ins write `x_fclk_check_in_photo`; NFC clock-outs write `x_fclk_check_out_photo`.
Important: unknown-card taps currently return `card_unknown`; the `unknown_card_tap` log type exists but is not written by the endpoint.
## 7. Reports And Payroll Export
`fusion.clock.report` supports:
- Employee reports when `employee_id` is set.
- Batch reports when `employee_id` is empty.
- PDF generation through QWeb reports:
- `fusion_clock.action_report_clock_employee`
- `fusion_clock.action_report_clock_batch`
- CSV export via `action_export_csv()`.
- Custom CSV headings via JSON in `fusion_clock.csv_column_mapping`.
- Email send with generated PDF attached.
Pay period types:
```
weekly, biweekly, semi_monthly, monthly
```
The anchor date setting is `fusion_clock.pay_period_start` as a string in `YYYY-MM-DD` format.
Historical report generation is exposed through the `Generate Historical Reports` menu action and creates draft reports for completed attendance periods. The scheduled report cron only generates when yesterday is the period end.
## 8. Scheduled Automation
Configured in `data/ir_cron_data.xml`:
| Cron | Model method | Frequency |
|---|---|---|
| Fusion Clock: Auto Clock-Out | `hr.attendance._cron_fusion_auto_clock_out()` | Every 15 minutes |
| Fusion Clock: Generate Period Reports | `fusion.clock.report._cron_generate_period_reports()` | Daily |
| Fusion Clock: Daily Absence Check | `hr.attendance._cron_fusion_check_absences()` | Daily |
| Fusion Clock: Employee Reminders | `hr.attendance._cron_fusion_employee_reminders()` | Every 15 minutes |
| Fusion Clock: Weekly Summary | `hr.attendance._cron_fusion_weekly_summary()` | Daily, internally sends Mondays |
Auto clock-out closes open attendances after scheduled end plus grace, capped by max shift hours. It sets `x_fclk_pending_reason` so the employee must explain before clocking in again.
Absence detection checks enabled employees, skips weekends and global resource calendar leaves, and logs `absent` when no attendance or leave request exists.
## 9. Security
Groups:
- `group_fusion_clock_user`
- `group_fusion_clock_team_lead`
- `group_fusion_clock_manager`
Admin is auto-assigned to manager in `security/security.xml`.
Access pattern:
- Users and portal users can read their own clock data.
- Team leads can read direct reports for penalties, activity logs, corrections, and dashboard data.
- Managers have full model access and all configuration/kiosk/report menus.
- Portal rules are defined for `hr.attendance`, `fusion.clock.location`, `fusion.clock.report`, `fusion.clock.penalty`, `fusion.clock.activity.log`, `fusion.clock.leave.request`, `fusion.clock.correction`, and `fusion.clock.shift`.
Backend dashboard access is checked in `/fusion_clock/dashboard_data`: manager sees all enabled employees; team lead sees employees where `parent_id` is the current user's employee.
## 10. Frontend Assets
Frontend bundle:
- `static/src/css/portal_clock.css`
- `static/src/scss/nfc_kiosk.scss`
- `static/src/js/fusion_clock_portal.js`
- `static/src/js/fusion_clock_kiosk.js`
- `static/src/js/fusion_clock_nfc_kiosk.js`
Backend bundle:
- `static/src/scss/fusion_clock.scss`
- `static/src/js/fusion_clock_systray.js`
- `static/src/xml/systray_clock.xml`
- `static/src/js/fusion_clock_dashboard.js`
- `static/src/xml/fusion_clock_dashboard.xml`
- `static/src/js/fusion_clock_location_map.js`
- `static/src/js/fusion_clock_location_places.js`
- `static/src/xml/fusion_clock_location.xml`
Patterns:
- Public portal/kiosk JS should use `Interaction` from `@web/public/interaction` and register in `registry.category("public.interactions")`.
- Backend OWL client actions and field widgets use standalone `rpc()` from `@web/core/network/rpc`.
- `fusion_clock_systray.js` is a systray OWL component registered as `fusion_clock.ClockSystray`.
- `fusion_clock_dashboard.js` is a client action registered as `fusion_clock.Dashboard`.
- Location widgets are registered field widgets: `fclk_location_map` and `fclk_places_autocomplete`.
Known technical debt:
- `static/src/js/fusion_clock_nfc_kiosk.js` is currently an isolated IIFE. If touching it, prefer migrating to an Odoo 19 `Interaction` instead of expanding the IIFE pattern.
- `static/src/css/portal_clock.css` and `static/src/scss/fusion_clock.scss` contain runtime dark-mode selectors/media rules. For backend SCSS changes, follow the repo-root Odoo 19 compile-time dark bundle guidance.
- `fusion_clock.scss` uses some Bootstrap CSS vars for status accents. Avoid relying on Bootstrap vars for card/background/border surfaces in new dashboard work.
## 11. Settings Keys
Important `ir.config_parameter` keys:
```
fusion_clock.default_clock_in_time
fusion_clock.default_clock_out_time
fusion_clock.default_break_minutes
fusion_clock.auto_deduct_break
fusion_clock.break_threshold_hours
fusion_clock.enable_auto_clockout
fusion_clock.grace_period_minutes
fusion_clock.max_shift_hours
fusion_clock.enable_penalties
fusion_clock.penalty_grace_minutes
fusion_clock.penalty_deduction_minutes
fusion_clock.enable_overtime
fusion_clock.daily_overtime_threshold
fusion_clock.weekly_overtime_threshold
fusion_clock.office_user_id
fusion_clock.very_late_threshold_minutes
fusion_clock.max_monthly_absences
fusion_clock.enable_employee_notifications
fusion_clock.reminder_before_shift_minutes
fusion_clock.reminder_before_end_minutes
fusion_clock.send_weekly_summary
fusion_clock.enable_ip_fallback
fusion_clock.enable_photo_verification
fusion_clock.google_maps_api_key
fusion_clock.enable_kiosk
fusion_clock.kiosk_pin_required
fusion_clock.enable_correction_requests
fusion_clock.enable_sounds
fusion_clock.pay_period_type
fusion_clock.pay_period_start
fusion_clock.auto_generate_reports
fusion_clock.send_employee_reports
fusion_clock.report_recipient_user_ids
fusion_clock.report_recipient_emails
fusion_clock.csv_column_mapping
fusion_clock.enable_nfc_kiosk
fusion_clock.nfc_photo_required
fusion_clock.nfc_enroll_password
fusion_clock.nfc_kiosk_debug
```
`fclk_report_recipient_user_ids` is a Many2many on settings but is persisted manually as comma-separated user IDs in `fusion_clock.report_recipient_user_ids`.
## 12. Routes
HTTP pages:
```
/my/clock
/my/clock/timesheets
/my/clock/reports
/my/clock/reports/<report_id>/download
/fusion_clock/kiosk
/fusion_clock/kiosk/nfc
```
JSON-RPC endpoints:
```
/fusion_clock/verify_location
/fusion_clock/clock_action
/fusion_clock/submit_reason
/fusion_clock/request_leave
/fusion_clock/request_correction
/fusion_clock/get_status
/fusion_clock/get_locations
/fusion_clock/get_settings
/fusion_clock/dashboard_data
/fusion_clock/kiosk/search
/fusion_clock/kiosk/verify_pin
/fusion_clock/kiosk/clock
/fusion_clock/kiosk/nfc/enroll
/fusion_clock/kiosk/nfc/tap
/fusion_clock/kiosk/nfc/employee_search
```
All new JSON endpoints must use `type="jsonrpc"`, not deprecated `type="json"`.
## 13. Gotchas
- Always use local-day helpers for date domains. UTC midnight boundaries will break attendance totals around timezone offsets.
- `hr.employee._get_fclk_scheduled_times(date)` returns naive UTC datetimes suitable for Odoo comparisons.
- Break deduction is stored as minutes in `hr.attendance.x_fclk_break_minutes`; penalties add to that same field.
- `x_fclk_net_hours` is computed from Odoo `worked_hours` minus break minutes.
- Daily overtime currently compares net hours to employee scheduled hours or daily threshold; weekly threshold is configured but not used in `hr.attendance._compute_overtime_hours()`.
- `fusion_clock.enable_ip_fallback` exists in settings, but server-side `_verify_location()` attempts IP whitelist matching whenever a client IP is present.
- NFC kiosk needs a company-level `x_fclk_nfc_kiosk_location_id`; without it taps return `no_location_configured`.
- Kiosk routes are authenticated (`auth='user'`) and manager-gated; wall tablets need a manager-authorised kiosk user.
- Portal report download manually streams the PDF binary rather than using `fusion_pdf_preview`.
- If CSS/assets change, bump `__manifest__.py` version so Odoo rebuilds bundles.
## 14. Tests
Tests are post-install tagged:
```
@tagged('-at_install', 'post_install', 'fusion_clock')
```
Coverage currently focuses on NFC:
- `tests/test_nfc_models.py`: employee UID uniqueness, attendance NFC source/photo fields, company kiosk location field.
- `tests/test_clock_nfc_kiosk.py`: kiosk page gating, UID normalization, enroll endpoint, tap happy path, tap errors, photo-required handling, employee search.
Run locally:
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --test-tags fusion_clock --stop-after-init
```
For a normal module upgrade:
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_clock --stop-after-init
```

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Clock', 'name': 'Fusion Clock',
'version': '19.0.3.3.0', 'version': '19.0.3.5.6',
'category': 'Human Resources/Attendances', 'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export', 'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """ 'description': """
@@ -70,6 +70,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/clock_correction_views.xml', 'views/clock_correction_views.xml',
'views/clock_dashboard_views.xml', 'views/clock_dashboard_views.xml',
'views/hr_employee_views.xml', 'views/hr_employee_views.xml',
'views/clock_schedule_views.xml',
# Wizards (must load before clock_menus.xml since menu references wizard action) # Wizards (must load before clock_menus.xml since menu references wizard action)
'wizard/clock_nfc_enrollment_views.xml', 'wizard/clock_nfc_enrollment_views.xml',
'views/clock_menus.xml', 'views/clock_menus.xml',
@@ -89,15 +90,22 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js', 'fusion_clock/static/src/js/fusion_clock_nfc_kiosk.js',
], ],
'web.assets_backend': [ 'web.assets_backend': [
'fusion_clock/static/src/scss/_fusion_clock_shift_planner_tokens.scss',
'fusion_clock/static/src/scss/fusion_clock_shift_planner.scss',
'fusion_clock/static/src/scss/fusion_clock.scss', 'fusion_clock/static/src/scss/fusion_clock.scss',
'fusion_clock/static/src/js/fusion_clock_systray.js', 'fusion_clock/static/src/js/fusion_clock_systray.js',
'fusion_clock/static/src/xml/systray_clock.xml', 'fusion_clock/static/src/xml/systray_clock.xml',
'fusion_clock/static/src/js/fusion_clock_dashboard.js', 'fusion_clock/static/src/js/fusion_clock_dashboard.js',
'fusion_clock/static/src/xml/fusion_clock_dashboard.xml', 'fusion_clock/static/src/xml/fusion_clock_dashboard.xml',
'fusion_clock/static/src/js/fusion_clock_shift_planner.js',
'fusion_clock/static/src/xml/fusion_clock_shift_planner.xml',
'fusion_clock/static/src/js/fusion_clock_location_map.js', 'fusion_clock/static/src/js/fusion_clock_location_map.js',
'fusion_clock/static/src/js/fusion_clock_location_places.js', 'fusion_clock/static/src/js/fusion_clock_location_places.js',
'fusion_clock/static/src/xml/fusion_clock_location.xml', 'fusion_clock/static/src/xml/fusion_clock_location.xml',
], ],
'web.assets_web_dark': [
'fusion_clock/static/src/scss/fusion_clock_shift_planner.dark.scss',
],
}, },
'installable': True, 'installable': True,
'auto_install': False, 'auto_install': False,

Binary file not shown.

View File

@@ -4,3 +4,4 @@ from . import portal_clock
from . import clock_api from . import clock_api
from . import clock_kiosk from . import clock_kiosk
from . import clock_nfc_kiosk from . import clock_nfc_kiosk
from . import shift_planner

View File

@@ -5,6 +5,7 @@
import base64 import base64
import math import math
import logging import logging
import pytz
from datetime import datetime, timedelta from datetime import datetime, timedelta
from odoo import http, fields, _ from odoo import http, fields, _
from odoo.http import request from odoo.http import request
@@ -108,6 +109,9 @@ class FusionClockAPI(http.Controller):
ICP = request.env['ir.config_parameter'].sudo() ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True': if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
return return
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
return
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5')) grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15')) deduction = float(ICP.get_param('fusion_clock.penalty_deduction_minutes', '15'))
@@ -161,7 +165,16 @@ class FusionClockAPI(http.Controller):
worked = attendance.worked_hours or 0.0 worked = attendance.worked_hours or 0.0
if worked >= threshold: if worked >= threshold:
break_min = employee._get_fclk_break_minutes() local_date = get_local_today(request.env, employee)
if attendance.check_in:
tz_name = (
employee.resource_id.tz
or (employee.user_id.partner_id.tz if employee.user_id else False)
or employee.company_id.partner_id.tz
or 'UTC'
)
local_date = pytz.UTC.localize(attendance.check_in).astimezone(pytz.timezone(tz_name)).date()
break_min = employee._get_fclk_break_minutes(local_date)
current = attendance.x_fclk_break_minutes or 0.0 current = attendance.x_fclk_break_minutes or 0.0
# Set to whichever is higher: configured break or existing (penalty-inflated) value # Set to whichever is higher: configured break or existing (penalty-inflated) value
new_val = max(break_min, current) new_val = max(break_min, current)
@@ -268,6 +281,8 @@ class FusionClockAPI(http.Controller):
now = fields.Datetime.now() now = fields.Datetime.now()
today = get_local_today(request.env, employee) today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
geo_info = { geo_info = {
'latitude': latitude, 'latitude': latitude,
@@ -307,6 +322,34 @@ class FusionClockAPI(http.Controller):
source=source, source=source,
) )
if is_scheduled_off:
self._log_activity(
employee, 'unscheduled_shift',
f"Clocked in on a scheduled OFF day at {location.name}.",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source=source,
)
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
if office_user_id:
request.env['hr.attendance'].sudo()._fclk_notify_office(
office_user_id,
f"Unscheduled Shift: {employee.name}",
f"{employee.name} clocked in on a scheduled OFF day.",
'hr.attendance',
attendance.id,
)
return {
'success': True,
'action': 'clock_in',
'attendance_id': attendance.id,
'check_in': fields.Datetime.to_string(attendance.check_in),
'location_name': location.name,
'location_address': location.address or '',
'message': f'Clocked in at {location.name} (unscheduled shift)',
'streak': employee.x_fclk_ontime_streak,
}
# Check for late clock-in penalty # Check for late clock-in penalty
scheduled_in, _ = self._get_scheduled_times(employee, today) scheduled_in, _ = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now) self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
@@ -359,6 +402,7 @@ class FusionClockAPI(http.Controller):
self._apply_break_deduction(attendance, employee) self._apply_break_deduction(attendance, employee)
# Check for early clock-out penalty # Check for early clock-out penalty
if not is_scheduled_off:
_, scheduled_out = self._get_scheduled_times(employee, today) _, scheduled_out = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
@@ -518,6 +562,13 @@ class FusionClockAPI(http.Controller):
'pending_reason': employee.x_fclk_pending_reason, 'pending_reason': employee.x_fclk_pending_reason,
'ontime_streak': employee.x_fclk_ontime_streak, 'ontime_streak': employee.x_fclk_ontime_streak,
} }
local_today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(local_today)
result.update({
'scheduled_shift': day_plan.get('label') or '',
'scheduled_hours': round(day_plan.get('hours') or 0.0, 2),
'scheduled_off': bool(day_plan.get('is_off')),
})
if is_checked_in: if is_checked_in:
att = request.env['hr.attendance'].sudo().search([ att = request.env['hr.attendance'].sudo().search([
@@ -533,7 +584,6 @@ class FusionClockAPI(http.Controller):
'location_id': att.x_fclk_location_id.id or False, 'location_id': att.x_fclk_location_id.id or False,
}) })
local_today = get_local_today(request.env, employee)
today_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee) today_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee)
today_atts = request.env['hr.attendance'].sudo().search([ today_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id), ('employee_id', '=', employee.id),

View File

@@ -5,6 +5,7 @@
import logging import logging
from odoo import http, fields, _ from odoo import http, fields, _
from odoo.http import request from odoo.http import request
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -93,7 +94,9 @@ class FusionClockKiosk(http.Controller):
is_checked_in = employee.attendance_state == 'checked_in' is_checked_in = employee.attendance_state == 'checked_in'
now = fields.Datetime.now() now = fields.Datetime.now()
today = now.date() today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
geo_info = { geo_info = {
'latitude': latitude, 'latitude': latitude,
@@ -120,6 +123,15 @@ class FusionClockKiosk(http.Controller):
source='kiosk', source='kiosk',
) )
if is_scheduled_off:
api._log_activity(
employee, 'unscheduled_shift',
f"Kiosk clock-in on a scheduled OFF day at {location.name}",
attendance=attendance, location=location,
latitude=latitude, longitude=longitude, distance=distance,
source='kiosk',
)
else:
scheduled_in, _ = api._get_scheduled_times(employee, today) scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now) api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
@@ -135,6 +147,7 @@ class FusionClockKiosk(http.Controller):
}) })
api._apply_break_deduction(attendance, employee) api._apply_break_deduction(attendance, employee)
if not is_scheduled_off:
_, scheduled_out = api._get_scheduled_times(employee, today) _, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)

View File

@@ -8,6 +8,7 @@ import time
import threading import threading
from odoo import fields, http from odoo import fields, http
from odoo.http import request from odoo.http import request
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
_UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$') _UID_HEX_PATTERN = re.compile(r'^[0-9A-F]+$')
@@ -183,7 +184,9 @@ class FusionClockNfcKiosk(http.Controller):
is_checked_in = employee.attendance_state == 'checked_in' is_checked_in = employee.attendance_state == 'checked_in'
now = fields.Datetime.now() now = fields.Datetime.now()
today = now.date() today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
geo_info = { geo_info = {
'latitude': 0, 'latitude': 0,
@@ -208,6 +211,15 @@ class FusionClockNfcKiosk(http.Controller):
latitude=0, longitude=0, distance=0, latitude=0, longitude=0, distance=0,
source='nfc_kiosk', source='nfc_kiosk',
) )
if is_scheduled_off:
api._log_activity(
employee, 'unscheduled_shift',
f"NFC kiosk clock-in on a scheduled OFF day at {location.name}",
attendance=attendance, location=location,
latitude=0, longitude=0, distance=0,
source='nfc_kiosk',
)
else:
scheduled_in, _ = api._get_scheduled_times(employee, today) scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now) api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
return { return {
@@ -224,6 +236,7 @@ class FusionClockNfcKiosk(http.Controller):
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False, 'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
}) })
api._apply_break_deduction(attendance, employee) api._apply_break_deduction(attendance, employee)
if not is_scheduled_off:
_, scheduled_out = api._get_scheduled_times(employee, today) _, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now) api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
api._log_activity( api._log_activity(

View File

@@ -100,7 +100,9 @@ class FusionClockPortal(CustomerPortal):
], limit=1) ], limit=1)
# Today stats # Today stats
today_start, _ = get_local_day_boundaries(request.env, get_local_today(request.env, employee), employee) today = get_local_today(request.env, employee)
today_schedule = employee._get_fclk_day_plan(today)
today_start, _ = get_local_day_boundaries(request.env, today, employee)
today_atts = request.env['hr.attendance'].sudo().search([ today_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id), ('employee_id', '=', employee.id),
('check_in', '>=', today_start), ('check_in', '>=', today_start),
@@ -109,7 +111,6 @@ class FusionClockPortal(CustomerPortal):
today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts) today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
# Week stats # Week stats
today = get_local_today(request.env, employee)
week_start = today - timedelta(days=today.weekday()) week_start = today - timedelta(days=today.weekday())
week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee) week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
week_atts = request.env['hr.attendance'].sudo().search([ week_atts = request.env['hr.attendance'].sudo().search([
@@ -151,6 +152,7 @@ class FusionClockPortal(CustomerPortal):
'current_attendance': current_attendance, 'current_attendance': current_attendance,
'today_hours': round(today_hours, 1), 'today_hours': round(today_hours, 1),
'week_hours': round(week_hours, 1), 'week_hours': round(week_hours, 1),
'today_schedule': today_schedule,
'recent_attendances': recent, 'recent_attendances': recent,
'google_maps_key': google_maps_key, 'google_maps_key': google_maps_key,
'enable_sounds': enable_sounds, 'enable_sounds': enable_sounds,

View File

@@ -0,0 +1,269 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import io
from collections import defaultdict
from datetime import timedelta
from odoo import fields, http, _
from odoo.exceptions import ValidationError
from odoo.http import request
class FusionClockShiftPlanner(http.Controller):
"""Backend JSON-RPC API for the Excel-style weekly shift planner."""
def _check_manager(self):
return request.env.user.has_group('fusion_clock.group_fusion_clock_manager')
def _week_start(self, week_start=None):
date_obj = fields.Date.to_date(week_start) if week_start else fields.Date.today()
return date_obj - timedelta(days=date_obj.weekday())
def _manager_employees(self):
return request.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True),
('company_id', 'in', request.env.user.company_ids.ids),
], order='department_id, name')
def _load_week_data(self, week_start=None):
start = self._week_start(week_start)
days = [start + timedelta(days=i) for i in range(7)]
employees = self._manager_employees()
Schedule = request.env['fusion.clock.schedule'].sudo()
schedules = Schedule.search([
('employee_id', 'in', employees.ids),
('schedule_date', '>=', start),
('schedule_date', '<=', days[-1]),
])
schedule_map = {
(schedule.employee_id.id, schedule.schedule_date): schedule
for schedule in schedules
}
grouped = defaultdict(list)
for employee in employees:
grouped[employee.department_id.id or 0].append(employee)
departments = []
employee_rows = []
for department_id, department_employees in grouped.items():
department = department_employees[0].department_id
departments.append({
'id': department_id,
'name': department.name if department else _('No Department'),
'employee_ids': [emp.id for emp in department_employees],
})
for employee in department_employees:
cells = {}
for day in days:
cells[str(day)] = Schedule.fclk_cell_payload(
employee,
day,
schedule_map.get((employee.id, day)),
)
employee_rows.append({
'id': employee.id,
'name': employee.name,
'department_id': department_id,
'department_name': department.name if department else _('No Department'),
'job_title': employee.job_title or '',
'cells': cells,
})
shifts = request.env['fusion.clock.shift'].sudo().search([
('active', '=', True),
('company_id', 'in', request.env.user.company_ids.ids),
], order='sequence, name')
return {
'week_start': str(start),
'week_end': str(days[-1]),
'days': [{
'date': str(day),
'weekday': day.strftime('%a').upper(),
'label': day.strftime('%d-%b'),
} for day in days],
'departments': departments,
'employees': employee_rows,
'shifts': [{
'id': shift.id,
'name': shift.name,
'start_time': shift.start_time,
'end_time': shift.end_time,
'break_minutes': shift.break_minutes,
'hours': shift.scheduled_hours,
'hours_display': Schedule.fclk_hours_display(shift.scheduled_hours),
'label': '%s - %s' % (
Schedule.fclk_float_to_display(shift.start_time),
Schedule.fclk_float_to_display(shift.end_time),
),
'option_label': '%s (%s - %s)' % (
shift.name,
Schedule.fclk_float_to_display(shift.start_time),
Schedule.fclk_float_to_display(shift.end_time),
),
} for shift in shifts],
}
@http.route('/fusion_clock/shift_planner/load', type='jsonrpc', auth='user', methods=['POST'])
def load(self, week_start=None, **kw):
if not self._check_manager():
return {'error': 'Access denied.'}
return self._load_week_data(week_start)
@http.route('/fusion_clock/shift_planner/save', type='jsonrpc', auth='user', methods=['POST'])
def save(self, week_start=None, changes=None, **kw):
if not self._check_manager():
return {'error': 'Access denied.'}
employees = self._manager_employees()
employee_map = {employee.id: employee for employee in employees}
Schedule = request.env['fusion.clock.schedule'].sudo()
errors = []
saved = 0
for change in changes or []:
employee_id = int(change.get('employee_id') or 0)
employee = employee_map.get(employee_id)
date_str = change.get('date')
if not employee:
errors.append({
'employee_id': employee_id,
'date': date_str,
'message': 'Employee not found or not allowed.',
})
continue
try:
Schedule.fclk_apply_planner_cell(employee, date_str, change, request.env.user)
saved += 1
except ValidationError as exc:
errors.append({
'employee_id': employee_id,
'date': date_str,
'message': str(exc.args[0] if exc.args else exc),
})
if errors:
return {'success': False, 'saved': saved, 'errors': errors}
return {
'success': True,
'saved': saved,
'data': self._load_week_data(week_start),
}
@http.route('/fusion_clock/shift_planner/copy_previous_week', type='jsonrpc', auth='user', methods=['POST'])
def copy_previous_week(self, week_start=None, **kw):
if not self._check_manager():
return {'error': 'Access denied.'}
start = self._week_start(week_start)
prev_start = start - timedelta(days=7)
employees = self._manager_employees()
Schedule = request.env['fusion.clock.schedule'].sudo()
prev_schedules = Schedule.search([
('employee_id', 'in', employees.ids),
('schedule_date', '>=', prev_start),
('schedule_date', '<=', prev_start + timedelta(days=6)),
])
prev_map = {
(schedule.employee_id.id, schedule.schedule_date): schedule
for schedule in prev_schedules
}
before_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([])
for employee in employees:
for offset in range(7):
source_date = prev_start + timedelta(days=offset)
target_date = start + timedelta(days=offset)
source = prev_map.get((employee.id, source_date))
if not source:
payload = {'input': ''}
elif source.is_off:
payload = {'input': 'OFF'}
elif source.shift_id:
payload = {'shift_id': source.shift_id.id, 'input': source.fclk_display_value()}
else:
payload = {
'input': source.fclk_display_value(),
'start_time': source.start_time,
'end_time': source.end_time,
'break_minutes': source.break_minutes,
}
Schedule.fclk_apply_planner_cell(employee, target_date, payload, request.env.user)
after_count = request.env['fusion.clock.schedule.audit'].sudo().search_count([])
return {
'success': True,
'changed': after_count - before_count,
'data': self._load_week_data(start),
}
@http.route('/fusion_clock/shift_planner/export_xlsx', type='jsonrpc', auth='user', methods=['POST'])
def export_xlsx(self, week_start=None, **kw):
if not self._check_manager():
return {'error': 'Access denied.'}
data = self._load_week_data(week_start)
output = io.BytesIO()
import xlsxwriter
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
sheet = workbook.add_worksheet('Shift Planner')
fmt_day = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#b7dff5', 'border': 1})
fmt_sub = workbook.add_format({'bold': True, 'align': 'center', 'bg_color': '#d8e9bd', 'border': 1})
fmt_employee = workbook.add_format({'bold': True, 'border': 1})
fmt_shift = workbook.add_format({'border': 1})
fmt_hours = workbook.add_format({'border': 1, 'align': 'center', 'bg_color': '#f5d39b'})
fmt_department = workbook.add_format({'bold': True, 'bg_color': '#eeeeee', 'border': 1})
sheet.set_column(0, 0, 22)
for col in range(1, 15, 2):
sheet.set_column(col, col, 24)
sheet.set_column(col + 1, col + 1, 9)
sheet.write(0, 0, 'EMPLOYEE', fmt_day)
col = 1
for day in data['days']:
sheet.merge_range(0, col, 0, col + 1, day['weekday'], fmt_day)
sheet.merge_range(1, col, 1, col + 1, day['label'], fmt_day)
sheet.write(2, col, 'Shift', fmt_sub)
sheet.write(2, col + 1, 'Hours', fmt_sub)
col += 2
sheet.write(2, 0, 'EMPLOYEE', fmt_sub)
row = 3
employee_by_id = {emp['id']: emp for emp in data['employees']}
for department in data['departments']:
sheet.merge_range(row, 0, row, 14, department['name'], fmt_department)
row += 1
for employee_id in department['employee_ids']:
employee = employee_by_id[employee_id]
sheet.write(row, 0, employee['name'], fmt_employee)
col = 1
for day in data['days']:
cell = employee['cells'][day['date']]
sheet.write(row, col, cell.get('label') or '', fmt_shift)
sheet.write(row, col + 1, cell.get('hours_display') or '0:00', fmt_hours)
col += 2
row += 1
workbook.close()
output.seek(0)
filename = 'shift_planner_%s.xlsx' % data['week_start']
attachment = request.env['ir.attachment'].sudo().create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(output.read()),
'mimetype': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
})
return {
'success': True,
'attachment_id': attachment.id,
'filename': filename,
'url': '/web/content/%s?download=true' % attachment.id,
}

View File

@@ -9,5 +9,6 @@ from . import res_config_settings
from . import clock_activity_log from . import clock_activity_log
from . import clock_leave_request from . import clock_leave_request
from . import clock_shift from . import clock_shift
from . import clock_schedule
from . import clock_correction from . import clock_correction
from . import res_company from . import res_company

View File

@@ -34,6 +34,7 @@ class FusionClockActivityLog(models.Model):
('correction_request', 'Correction Request'), ('correction_request', 'Correction Request'),
('ip_fallback', 'IP Fallback Used'), ('ip_fallback', 'IP Fallback Used'),
('streak_milestone', 'Streak Milestone'), ('streak_milestone', 'Streak Milestone'),
('unscheduled_shift', 'Unscheduled Shift'),
('card_enrollment', 'Card Enrollment'), ('card_enrollment', 'Card Enrollment'),
('unknown_card_tap', 'Unknown Card Tap'), ('unknown_card_tap', 'Unknown Card Tap'),
], ],
@@ -108,6 +109,7 @@ class FusionClockActivityLog(models.Model):
'correction_request': 'Correction Request', 'correction_request': 'Correction Request',
'ip_fallback': 'IP Fallback Used', 'ip_fallback': 'IP Fallback Used',
'streak_milestone': 'Streak Milestone', 'streak_milestone': 'Streak Milestone',
'unscheduled_shift': 'Unscheduled Shift',
} }
@api.depends('latitude', 'longitude') @api.depends('latitude', 'longitude')

View File

@@ -0,0 +1,414 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import re
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class FusionClockSchedule(models.Model):
_name = 'fusion.clock.schedule'
_description = 'Clock Shift Schedule Entry'
_order = 'schedule_date, employee_id'
_rec_name = 'display_name'
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
required=True,
index=True,
ondelete='cascade',
)
schedule_date = fields.Date(
string='Date',
required=True,
index=True,
)
shift_id = fields.Many2one(
'fusion.clock.shift',
string='Shift Template',
ondelete='set null',
)
is_off = fields.Boolean(
string='Off',
default=False,
index=True,
)
start_time = fields.Float(
string='Start Time',
default=9.0,
)
end_time = fields.Float(
string='End Time',
default=17.0,
)
break_minutes = fields.Float(
string='Break (min)',
default=30.0,
)
planned_hours = fields.Float(
string='Hours',
compute='_compute_planned_hours',
store=True,
)
note = fields.Char(string='Note')
company_id = fields.Many2one(
'res.company',
string='Company',
related='employee_id.company_id',
store=True,
readonly=True,
)
department_id = fields.Many2one(
'hr.department',
string='Department',
related='employee_id.department_id',
store=True,
readonly=True,
)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
_employee_date_unique = models.Constraint(
'UNIQUE(employee_id, schedule_date)',
'Only one shift schedule is allowed per employee per day.',
)
@api.depends('is_off', 'start_time', 'end_time', 'break_minutes')
def _compute_planned_hours(self):
for rec in self:
if rec.is_off:
rec.planned_hours = 0.0
continue
raw_hours = (rec.end_time or 0.0) - (rec.start_time or 0.0)
rec.planned_hours = round(max(raw_hours - ((rec.break_minutes or 0.0) / 60.0), 0.0), 2)
@api.depends('employee_id', 'schedule_date', 'is_off', 'start_time', 'end_time')
def _compute_display_name(self):
for rec in self:
emp = rec.employee_id.name or ''
date_str = str(rec.schedule_date) if rec.schedule_date else ''
rec.display_name = f"{emp} - {date_str} - {rec.fclk_display_value()}"
@api.constrains('is_off', 'start_time', 'end_time', 'break_minutes')
def _check_schedule_times(self):
for rec in self:
if rec.break_minutes < 0:
raise ValidationError(_("Break minutes cannot be negative."))
if rec.is_off:
continue
if rec.start_time < 0 or rec.start_time >= 24:
raise ValidationError(_("Start time must be between 00:00 and 23:59."))
if rec.end_time <= 0 or rec.end_time > 24:
raise ValidationError(_("End time must be between 00:01 and 24:00."))
if rec.end_time <= rec.start_time:
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
shift_minutes = (rec.end_time - rec.start_time) * 60.0
if rec.break_minutes >= shift_minutes:
raise ValidationError(_("Break duration must be shorter than the scheduled shift."))
@api.onchange('shift_id')
def _onchange_shift_id(self):
for rec in self:
if rec.shift_id:
rec.is_off = False
rec.start_time = rec.shift_id.start_time
rec.end_time = rec.shift_id.end_time
rec.break_minutes = rec.shift_id.break_minutes
@api.model
def fclk_float_to_display(self, value):
value = float(value or 0.0)
hour = int(value)
minute = int(round((value - hour) * 60))
if minute == 60:
hour += 1
minute = 0
suffix = 'am' if hour < 12 or hour == 24 else 'pm'
display_hour = hour % 12
if display_hour == 0:
display_hour = 12
return f"{display_hour}:{minute:02d} {suffix}"
def fclk_display_value(self):
self.ensure_one()
if self.is_off:
return 'OFF'
return (
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.start_time)} - "
f"{self.env['fusion.clock.schedule'].fclk_float_to_display(self.end_time)}"
)
@api.model
def fclk_hours_display(self, hours):
hours = float(hours or 0.0)
whole = int(hours)
minutes = int(round((hours - whole) * 60))
if minutes == 60:
whole += 1
minutes = 0
return f"{whole}:{minutes:02d}"
@api.model
def _fclk_parse_time_part(self, raw):
text = (raw or '').strip().lower().replace('.', '')
match = re.match(r'^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$', text)
if not match:
raise ValidationError(_("Could not understand time '%s'.") % raw)
hour = int(match.group(1))
minute = int(match.group(2) or 0)
meridiem = match.group(3)
if minute < 0 or minute > 59:
raise ValidationError(_("Minutes must be between 00 and 59."))
if meridiem:
if hour < 1 or hour > 12:
raise ValidationError(_("12-hour times must use hours from 1 to 12."))
if meridiem == 'am':
hour = 0 if hour == 12 else hour
else:
hour = 12 if hour == 12 else hour + 12
elif hour > 24:
raise ValidationError(_("Hours must be between 0 and 24."))
return hour + (minute / 60.0)
@api.model
def fclk_parse_planner_input(self, input_value, default_break_minutes=30.0):
text = (input_value or '').strip()
if not text:
return {'clear': True}
if text.upper() == 'OFF':
return {
'clear': False,
'is_off': True,
'shift_id': False,
'start_time': 0.0,
'end_time': 0.0,
'break_minutes': 0.0,
}
normalized = (
text.replace('', '-')
.replace('', '-')
.replace(' to ', '-')
.replace(' TO ', '-')
)
parts = [p.strip() for p in normalized.split('-', 1)]
if len(parts) != 2 or not parts[0] or not parts[1]:
raise ValidationError(_("Enter a shift as '9-5', '9:00-5:30', '9:00 am - 5:30 pm', or OFF."))
start = self._fclk_parse_time_part(parts[0])
end = self._fclk_parse_time_part(parts[1])
if end <= start and end + 12 <= 24:
end += 12
if end <= start:
raise ValidationError(_("End time must be after start time. Overnight shifts are not supported yet."))
return {
'clear': False,
'is_off': False,
'shift_id': False,
'start_time': start,
'end_time': end,
'break_minutes': float(default_break_minutes or 0.0),
}
@api.model
def fclk_values_from_planner_payload(self, payload, employee):
payload = payload or {}
if 'start_time' in payload and 'end_time' in payload and not payload.get('shift_id'):
if payload.get('is_off'):
return {
'clear': False,
'is_off': True,
'shift_id': False,
'start_time': 0.0,
'end_time': 0.0,
'break_minutes': 0.0,
}
return {
'clear': False,
'is_off': False,
'shift_id': False,
'start_time': float(payload.get('start_time') or 0.0),
'end_time': float(payload.get('end_time') or 0.0),
'break_minutes': float(payload.get('break_minutes') or 0.0),
}
shift_id = int(payload.get('shift_id') or 0)
if shift_id:
shift = self.env['fusion.clock.shift'].sudo().browse(shift_id)
if not shift.exists():
raise ValidationError(_("Selected shift template no longer exists."))
return {
'clear': False,
'shift_id': shift.id,
'is_off': False,
'start_time': shift.start_time,
'end_time': shift.end_time,
'break_minutes': shift.break_minutes,
}
default_break = employee._get_fclk_break_minutes() if employee else 30.0
return self.fclk_parse_planner_input(payload.get('input', ''), default_break)
@api.model
def fclk_snapshot(self, schedule):
if not schedule:
return ''
return schedule.fclk_display_value()
@api.model
def fclk_apply_planner_cell(self, employee, schedule_date, payload, user=None):
self = self.sudo()
employee = employee.sudo()
date_obj = fields.Date.to_date(schedule_date)
if not employee.exists() or not date_obj:
raise ValidationError(_("Invalid employee or schedule date."))
existing = self.search([
('employee_id', '=', employee.id),
('schedule_date', '=', date_obj),
], limit=1)
old_value = self.fclk_snapshot(existing)
parsed = self.fclk_values_from_planner_payload(payload, employee)
if parsed.get('clear'):
if existing:
existing.unlink()
new_schedule = self.browse()
new_value = ''
else:
vals = {
'employee_id': employee.id,
'schedule_date': date_obj,
'shift_id': parsed.get('shift_id') or False,
'is_off': bool(parsed.get('is_off')),
'start_time': parsed.get('start_time') or 0.0,
'end_time': parsed.get('end_time') or 0.0,
'break_minutes': parsed.get('break_minutes') or 0.0,
'note': payload.get('note') or False,
}
if existing:
existing.write(vals)
new_schedule = existing
else:
new_schedule = self.create(vals)
new_value = new_schedule.fclk_display_value()
if old_value != new_value:
self.env['fusion.clock.schedule.audit'].sudo().create({
'schedule_id': new_schedule.id if new_schedule else False,
'employee_id': employee.id,
'schedule_date': date_obj,
'old_value': old_value,
'new_value': new_value,
'changed_by_id': (user or self.env.user).id,
'changed_at': fields.Datetime.now(),
'company_id': employee.company_id.id,
'department_id': employee.department_id.id,
})
return new_schedule
@api.model
def fclk_cell_payload(self, employee, date_obj, schedule=None):
schedule = schedule or self.search([
('employee_id', '=', employee.id),
('schedule_date', '=', date_obj),
], limit=1)
Schedule = self.env['fusion.clock.schedule']
if schedule:
return {
'schedule_id': schedule.id,
'source': 'schedule',
'input': schedule.fclk_display_value(),
'label': schedule.fclk_display_value(),
'is_off': schedule.is_off,
'shift_id': schedule.shift_id.id or False,
'start_time': schedule.start_time,
'end_time': schedule.end_time,
'break_minutes': schedule.break_minutes,
'hours': schedule.planned_hours,
'hours_display': Schedule.fclk_hours_display(schedule.planned_hours),
'note': schedule.note or '',
}
plan = employee._get_fclk_day_plan(date_obj)
return {
'schedule_id': False,
'source': plan.get('source') or 'fallback',
'input': plan.get('label') or '',
'label': plan.get('label') or '',
'is_off': plan.get('is_off', False),
'shift_id': False,
'start_time': plan.get('start_time') or 0.0,
'end_time': plan.get('end_time') or 0.0,
'break_minutes': plan.get('break_minutes') or 0.0,
'hours': plan.get('hours') or 0.0,
'hours_display': Schedule.fclk_hours_display(plan.get('hours') or 0.0),
'note': '',
}
class FusionClockScheduleAudit(models.Model):
_name = 'fusion.clock.schedule.audit'
_description = 'Clock Schedule Change Audit'
_order = 'changed_at desc, id desc'
_rec_name = 'display_name'
schedule_id = fields.Many2one(
'fusion.clock.schedule',
string='Schedule',
ondelete='set null',
index=True,
)
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
required=True,
index=True,
ondelete='cascade',
)
schedule_date = fields.Date(
string='Schedule Date',
required=True,
index=True,
)
old_value = fields.Char(string='Old Value')
new_value = fields.Char(string='New Value')
changed_by_id = fields.Many2one(
'res.users',
string='Changed By',
required=True,
ondelete='restrict',
)
changed_at = fields.Datetime(
string='Changed At',
default=fields.Datetime.now,
required=True,
index=True,
)
company_id = fields.Many2one(
'res.company',
string='Company',
index=True,
)
department_id = fields.Many2one(
'hr.department',
string='Department',
index=True,
)
display_name = fields.Char(
compute='_compute_display_name',
store=True,
)
@api.depends('employee_id', 'schedule_date', 'old_value', 'new_value')
def _compute_display_name(self):
for rec in self:
rec.display_name = "%s - %s: %s -> %s" % (
rec.employee_id.name or '',
rec.schedule_date or '',
rec.old_value or 'blank',
rec.new_value or 'blank',
)

View File

@@ -227,7 +227,18 @@ class HrAttendance(models.Model):
continue continue
employee = att.employee_id employee = att.employee_id
scheduled_hours = employee._get_fclk_scheduled_hours() if employee else daily_threshold scheduled_hours = daily_threshold
if employee:
local_date = get_local_today(self.env, employee)
if att.check_in:
tz_name = (
employee.resource_id.tz
or (employee.user_id.partner_id.tz if employee.user_id else False)
or employee.company_id.partner_id.tz
or 'UTC'
)
local_date = pytz.UTC.localize(att.check_in).astimezone(pytz.timezone(tz_name)).date()
scheduled_hours = employee._get_fclk_scheduled_hours(local_date)
net = att.x_fclk_net_hours or 0.0 net = att.x_fclk_net_hours or 0.0
if net > scheduled_hours: if net > scheduled_hours:
@@ -264,10 +275,13 @@ class HrAttendance(models.Model):
employee = att.employee_id employee = att.employee_id
emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC') emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date() check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
deadline = scheduled_out + timedelta(minutes=grace_min)
max_deadline = check_in + timedelta(hours=max_shift) max_deadline = check_in + timedelta(hours=max_shift)
day_plan = employee._get_fclk_day_plan(check_in_date)
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
effective_deadline = max_deadline
else:
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
deadline = scheduled_out + timedelta(minutes=grace_min)
effective_deadline = min(deadline, max_deadline) effective_deadline = min(deadline, max_deadline)
if now > effective_deadline: if now > effective_deadline:
@@ -283,7 +297,7 @@ class HrAttendance(models.Model):
# Apply break deduction # Apply break deduction
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0')) threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
if (att.worked_hours or 0) >= threshold: if (att.worked_hours or 0) >= threshold:
break_min = employee._get_fclk_break_minutes() break_min = employee._get_fclk_break_minutes(check_in_date)
att.sudo().write({'x_fclk_break_minutes': break_min}) att.sudo().write({'x_fclk_break_minutes': break_min})
att.sudo().message_post( att.sudo().message_post(
@@ -346,6 +360,9 @@ class HrAttendance(models.Model):
if yesterday.weekday() >= 5: if yesterday.weekday() >= 5:
continue continue
day_plan = emp._get_fclk_day_plan(yesterday)
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
continue
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp) day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
@@ -423,6 +440,9 @@ class HrAttendance(models.Model):
if today.weekday() >= 5: if today.weekday() >= 5:
continue continue
day_plan = emp._get_fclk_day_plan(today)
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
continue
if emp.x_fclk_last_reminder_date == today: if emp.x_fclk_last_reminder_date == today:
continue continue

View File

@@ -120,11 +120,82 @@ class HrEmployee(models.Model):
help="Tracks the last date a reminder was sent to avoid duplicates.", help="Tracks the last date a reminder was sent to avoid duplicates.",
) )
def _get_fclk_break_minutes(self): def _get_fclk_schedule_for_date(self, date):
"""Return effective break minutes for this employee. """Return this employee's dated Fusion Clock schedule for a local date."""
Priority: employee override > shift > global setting. self.ensure_one()
date_obj = fields.Date.to_date(date)
if not date_obj:
return self.env['fusion.clock.schedule']
return self.env['fusion.clock.schedule'].sudo().search([
('employee_id', '=', self.id),
('schedule_date', '=', date_obj),
], limit=1)
def _get_fclk_day_plan(self, date):
"""Return the effective plan for a local date.
Dated schedules are the source of truth. If none exists, the legacy
employee shift/global settings remain the fallback.
""" """
self.ensure_one() self.ensure_one()
Schedule = self.env['fusion.clock.schedule'].sudo()
schedule = self._get_fclk_schedule_for_date(date)
if schedule:
return {
'source': 'schedule',
'schedule_id': schedule.id,
'is_off': schedule.is_off,
'start_time': schedule.start_time,
'end_time': schedule.end_time,
'break_minutes': schedule.break_minutes,
'hours': schedule.planned_hours,
'label': schedule.fclk_display_value(),
}
if self.x_fclk_shift_id:
shift = self.x_fclk_shift_id
hours = max((shift.end_time - shift.start_time) - (shift.break_minutes / 60.0), 0.0)
return {
'source': 'fallback',
'schedule_id': False,
'is_off': False,
'start_time': shift.start_time,
'end_time': shift.end_time,
'break_minutes': shift.break_minutes,
'hours': hours,
'label': '%s - %s' % (
Schedule.fclk_float_to_display(shift.start_time),
Schedule.fclk_float_to_display(shift.end_time),
),
}
ICP = self.env['ir.config_parameter'].sudo()
start_time = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
end_time = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
break_minutes = float(ICP.get_param('fusion_clock.default_break_minutes', '30'))
hours = max((end_time - start_time) - (break_minutes / 60.0), 0.0)
return {
'source': 'fallback',
'schedule_id': False,
'is_off': False,
'start_time': start_time,
'end_time': end_time,
'break_minutes': break_minutes,
'hours': hours,
'label': '%s - %s' % (
Schedule.fclk_float_to_display(start_time),
Schedule.fclk_float_to_display(end_time),
),
}
def _get_fclk_break_minutes(self, date=None):
"""Return effective break minutes for this employee.
Priority: dated schedule > employee override > shift > global setting.
"""
self.ensure_one()
if date:
plan = self._get_fclk_day_plan(date)
if plan.get('source') == 'schedule' and not plan.get('is_off'):
return plan.get('break_minutes') or 0.0
if self.x_fclk_break_minutes > 0: if self.x_fclk_break_minutes > 0:
return self.x_fclk_break_minutes return self.x_fclk_break_minutes
if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0: if self.x_fclk_shift_id and self.x_fclk_shift_id.break_minutes > 0:
@@ -138,7 +209,7 @@ class HrEmployee(models.Model):
def _get_fclk_scheduled_times(self, date): def _get_fclk_scheduled_times(self, date):
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date. """Return (scheduled_in_dt, scheduled_out_dt) for a given date.
Uses employee shift if assigned, otherwise global settings. Uses dated schedule first, employee shift second, then global settings.
The configured hours are interpreted in the employee's local The configured hours are interpreted in the employee's local
timezone and converted to naive-UTC datetimes so they can be timezone and converted to naive-UTC datetimes so they can be
compared with Odoo's UTC-based ``fields.Datetime.now()``. compared with Odoo's UTC-based ``fields.Datetime.now()``.
@@ -146,13 +217,9 @@ class HrEmployee(models.Model):
import pytz import pytz
self.ensure_one() self.ensure_one()
if self.x_fclk_shift_id: plan = self._get_fclk_day_plan(date)
in_hour = self.x_fclk_shift_id.start_time in_hour = plan.get('start_time') or 0.0
out_hour = self.x_fclk_shift_id.end_time out_hour = plan.get('end_time') or 0.0
else:
ICP = self.env['ir.config_parameter'].sudo()
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0'))
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
in_h = int(in_hour) in_h = int(in_hour)
in_m = int((in_hour - in_h) * 60) in_m = int((in_hour - in_h) * 60)
@@ -179,16 +246,13 @@ class HrEmployee(models.Model):
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None) scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
return scheduled_in, scheduled_out return scheduled_in, scheduled_out
def _get_fclk_scheduled_hours(self): def _get_fclk_scheduled_hours(self, date=None):
"""Return the expected work hours for this employee's shift.""" """Return the expected work hours for this employee's shift."""
self.ensure_one() self.ensure_one()
if self.x_fclk_shift_id: plan = self._get_fclk_day_plan(date or get_local_today(self.env, self))
return self.x_fclk_shift_id.scheduled_hours if plan.get('is_off'):
ICP = self.env['ir.config_parameter'].sudo() return 0.0
in_hour = float(ICP.get_param('fusion_clock.default_clock_in_time', '9.0')) return plan.get('hours') or 0.0
out_hour = float(ICP.get_param('fusion_clock.default_clock_out_time', '17.0'))
break_hrs = self._get_fclk_break_minutes() / 60.0
return max((out_hour - in_hour) - break_hrs, 0.0)
def _compute_absence_counts(self): def _compute_absence_counts(self):
ActivityLog = self.env['fusion.clock.activity.log'].sudo() ActivityLog = self.env['fusion.clock.activity.log'].sudo()

View File

@@ -11,6 +11,9 @@ access_fusion_clock_leave_request_user,fusion.clock.leave.request.user,model_fus
access_fusion_clock_leave_request_manager,fusion.clock.leave.request.manager,model_fusion_clock_leave_request,group_fusion_clock_manager,1,1,1,1 access_fusion_clock_leave_request_manager,fusion.clock.leave.request.manager,model_fusion_clock_leave_request,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_shift_user,fusion.clock.shift.user,model_fusion_clock_shift,group_fusion_clock_user,1,0,0,0 access_fusion_clock_shift_user,fusion.clock.shift.user,model_fusion_clock_shift,group_fusion_clock_user,1,0,0,0
access_fusion_clock_shift_manager,fusion.clock.shift.manager,model_fusion_clock_shift,group_fusion_clock_manager,1,1,1,1 access_fusion_clock_shift_manager,fusion.clock.shift.manager,model_fusion_clock_shift,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_schedule_user,fusion.clock.schedule.user,model_fusion_clock_schedule,group_fusion_clock_user,1,0,0,0
access_fusion_clock_schedule_manager,fusion.clock.schedule.manager,model_fusion_clock_schedule,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_schedule_audit_manager,fusion.clock.schedule.audit.manager,model_fusion_clock_schedule_audit,group_fusion_clock_manager,1,0,0,0
access_fusion_clock_correction_user,fusion.clock.correction.user,model_fusion_clock_correction,group_fusion_clock_user,1,0,0,0 access_fusion_clock_correction_user,fusion.clock.correction.user,model_fusion_clock_correction,group_fusion_clock_user,1,0,0,0
access_fusion_clock_correction_manager,fusion.clock.correction.manager,model_fusion_clock_correction,group_fusion_clock_manager,1,1,1,1 access_fusion_clock_correction_manager,fusion.clock.correction.manager,model_fusion_clock_correction,group_fusion_clock_manager,1,1,1,1
access_fusion_clock_location_portal,fusion.clock.location.portal,model_fusion_clock_location,base.group_portal,1,0,0,0 access_fusion_clock_location_portal,fusion.clock.location.portal,model_fusion_clock_location,base.group_portal,1,0,0,0
@@ -22,4 +25,5 @@ access_fusion_clock_correction_portal,fusion.clock.correction.portal,model_fusio
access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0 access_hr_attendance_portal,hr.attendance.portal,hr_attendance.model_hr_attendance,base.group_portal,1,0,0,0
access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0 access_hr_employee_portal_clock,hr.employee.portal.clock,hr.model_hr_employee,base.group_portal,1,0,0,0
access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0 access_fusion_clock_shift_portal,fusion.clock.shift.portal,model_fusion_clock_shift,base.group_portal,1,0,0,0
access_fusion_clock_schedule_portal,fusion.clock.schedule.portal,model_fusion_clock_schedule,base.group_portal,1,0,0,0
access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1 access_fusion_clock_nfc_enrollment_wizard_manager,fusion.clock.nfc.enrollment.wizard.manager,model_fusion_clock_nfc_enrollment_wizard,group_fusion_clock_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_fusion_clock_leave_request_manager fusion.clock.leave.request.manager model_fusion_clock_leave_request group_fusion_clock_manager 1 1 1 1
12 access_fusion_clock_shift_user fusion.clock.shift.user model_fusion_clock_shift group_fusion_clock_user 1 0 0 0
13 access_fusion_clock_shift_manager fusion.clock.shift.manager model_fusion_clock_shift group_fusion_clock_manager 1 1 1 1
14 access_fusion_clock_schedule_user fusion.clock.schedule.user model_fusion_clock_schedule group_fusion_clock_user 1 0 0 0
15 access_fusion_clock_schedule_manager fusion.clock.schedule.manager model_fusion_clock_schedule group_fusion_clock_manager 1 1 1 1
16 access_fusion_clock_schedule_audit_manager fusion.clock.schedule.audit.manager model_fusion_clock_schedule_audit group_fusion_clock_manager 1 0 0 0
17 access_fusion_clock_correction_user fusion.clock.correction.user model_fusion_clock_correction group_fusion_clock_user 1 0 0 0
18 access_fusion_clock_correction_manager fusion.clock.correction.manager model_fusion_clock_correction group_fusion_clock_manager 1 1 1 1
19 access_fusion_clock_location_portal fusion.clock.location.portal model_fusion_clock_location base.group_portal 1 0 0 0
25 access_hr_attendance_portal hr.attendance.portal hr_attendance.model_hr_attendance base.group_portal 1 0 0 0
26 access_hr_employee_portal_clock hr.employee.portal.clock hr.model_hr_employee base.group_portal 1 0 0 0
27 access_fusion_clock_shift_portal fusion.clock.shift.portal model_fusion_clock_shift base.group_portal 1 0 0 0
28 access_fusion_clock_schedule_portal fusion.clock.schedule.portal model_fusion_clock_schedule base.group_portal 1 0 0 0
29 access_fusion_clock_nfc_enrollment_wizard_manager fusion.clock.nfc.enrollment.wizard.manager model_fusion_clock_nfc_enrollment_wizard group_fusion_clock_manager 1 1 1 1

View File

@@ -174,6 +174,49 @@
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/> <field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record> </record>
<!-- ================================================================
Record Rules - Dated Schedules
================================================================ -->
<record id="rule_schedule_user" model="ir.rule">
<field name="name">Schedule: User sees own</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_schedule_team_lead" model="ir.rule">
<field name="name">Schedule: Team Lead sees direct reports</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">['|', ('employee_id.user_id', '=', user.id), ('employee_id.parent_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_team_lead'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_schedule_manager" model="ir.rule">
<field name="name">Schedule: Manager full access</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
</record>
<record id="rule_schedule_audit_manager" model="ir.rule">
<field name="name">Schedule Audit: Manager reads all</field>
<field name="model_id" ref="model_fusion_clock_schedule_audit"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="groups" eval="[(4, ref('group_fusion_clock_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- ================================================================ <!-- ================================================================
Record Rules - Correction Request Record Rules - Correction Request
================================================================ --> ================================================================ -->
@@ -286,4 +329,15 @@
<field name="perm_unlink" eval="False"/> <field name="perm_unlink" eval="False"/>
</record> </record>
<record id="rule_schedule_portal" model="ir.rule">
<field name="name">Schedule: Portal user sees own</field>
<field name="model_id" ref="model_fusion_clock_schedule"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo> </odoo>

View File

@@ -219,6 +219,63 @@ body:has(.fclk-app) .o_footer {
opacity: 0.5; opacity: 0.5;
} }
/* ---- Scheduled Shift Card ---- */
.fclk-schedule-card {
display: flex;
align-items: center;
gap: 12px;
background: var(--fclk-card);
border: 1px solid var(--fclk-card-border);
border-radius: 14px;
padding: 14px 16px;
margin: -14px 0 28px;
box-shadow: var(--fclk-shadow);
}
.fclk-schedule-icon {
width: 38px;
height: 38px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: rgba(59, 130, 246, 0.12);
color: var(--fclk-blue);
font-size: 16px;
}
.fclk-schedule-info {
min-width: 0;
flex: 1;
}
.fclk-schedule-label {
color: var(--fclk-text-muted);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.fclk-schedule-value {
color: var(--fclk-text);
font-size: 14px;
font-weight: 650;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fclk-schedule-hours {
color: var(--fclk-text);
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* ---- Timer Section ---- */ /* ---- Timer Section ---- */
.fclk-timer-section { .fclk-timer-section {
text-align: center; text-align: center;

View File

@@ -0,0 +1,741 @@
/** @odoo-module **/
import { Component, onPatched, onWillStart, useExternalListener, useRef, useState } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class FusionClockShiftPlanner extends Component {
static template = "fusion_clock.ShiftPlanner";
static props = [];
setup() {
this.notification = useService("notification");
this.dirtyCells = {};
this.root = useRef("root");
this.editorRef = useRef("shiftEditor");
this.activeCellAnchor = null;
this.activeEditorEmployee = null;
this.activeEditorDay = null;
this.timeOptions = this._buildTimeOptions();
this.state = useState({
loading: true,
saving: false,
weekStart: "",
weekEnd: "",
days: [],
departments: [],
employees: [],
shifts: [],
error: "",
dirtyCount: 0,
invalidCount: 0,
collapsed: {},
editor: {
open: false,
employeeId: false,
employeeName: "",
date: "",
dayLabel: "",
startValue: "9.00",
endValue: "17.00",
breakMinutes: 30,
hoursDisplay: "7:30",
error: "",
top: 0,
left: 0,
},
});
onWillStart(async () => {
await this.loadWeek();
});
useExternalListener(
window,
"click",
(ev) => this.onGlobalClick(ev),
{ capture: true }
);
useExternalListener(window, "resize", () => this._positionActiveEditor());
useExternalListener(window, "scroll", () => this._positionActiveEditor(), true);
onPatched(() => {
this._positionActiveEditor();
});
}
async loadWeek(weekStart = null) {
this.state.loading = true;
this.state.error = "";
try {
const data = await rpc("/fusion_clock/shift_planner/load", { week_start: weekStart });
if (data.error) {
this.state.error = data.error;
} else {
this._applyData(data);
}
} catch (error) {
this.state.error = error.message || "Failed to load shift planner.";
}
this.state.loading = false;
}
_applyData(data) {
this.dirtyCells = {};
this.state.weekStart = data.week_start;
this.state.weekEnd = data.week_end;
this.state.days = data.days || [];
this.state.departments = data.departments || [];
this.state.employees = data.employees || [];
this.state.shifts = data.shifts || [];
this.state.dirtyCount = 0;
this.state.invalidCount = 0;
this.state.error = "";
this.closeCellEditor();
}
get weekTitle() {
if (!this.state.weekStart || !this.state.weekEnd) {
return "";
}
return `${this.state.weekStart} to ${this.state.weekEnd}`;
}
getDepartmentEmployees(department) {
const ids = new Set(department.employee_ids || []);
return this.state.employees.filter((employee) => ids.has(employee.id));
}
isCollapsed(department) {
return !!this.state.collapsed[department.id];
}
toggleDepartment(department) {
this.state.collapsed[department.id] = !this.state.collapsed[department.id];
this.closeCellEditor();
}
async previousWeek() {
await this.loadWeek(this._dateAdd(this.state.weekStart, -7));
}
async nextWeek() {
await this.loadWeek(this._dateAdd(this.state.weekStart, 7));
}
async currentWeek() {
await this.loadWeek();
}
async copyPreviousWeek() {
if (!window.confirm("Copy the previous week into this week? Current saved cells for the week may be replaced.")) {
return;
}
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/copy_previous_week", {
week_start: this.state.weekStart,
});
if (result.error) {
this.notification.add(result.error, { type: "danger" });
} else {
this._applyData(result.data);
this.notification.add(`Copied previous week (${result.changed || 0} changes).`, { type: "success" });
}
} catch (error) {
this.notification.add(error.message || "Could not copy previous week.", { type: "danger" });
}
this.state.saving = false;
}
async save() {
this._recountInvalid();
if (this.state.invalidCount) {
this.notification.add("Fix invalid shift cells before saving.", { type: "danger" });
return;
}
const changes = Object.values(this.dirtyCells);
if (!changes.length) {
this.notification.add("No shift changes to save.", { type: "info" });
return;
}
this.state.saving = true;
try {
const result = await rpc("/fusion_clock/shift_planner/save", {
week_start: this.state.weekStart,
changes,
});
if (result.error) {
this.notification.add(result.error, { type: "danger" });
} else if (!result.success) {
this._markServerErrors(result.errors || []);
this.notification.add("Some shift cells could not be saved.", { type: "danger" });
} else {
this._applyData(result.data);
this.notification.add(`Saved ${result.saved || 0} shift changes.`, { type: "success" });
}
} catch (error) {
this.notification.add(error.message || "Could not save shift planner.", { type: "danger" });
}
this.state.saving = false;
}
async exportXlsx() {
try {
const result = await rpc("/fusion_clock/shift_planner/export_xlsx", {
week_start: this.state.weekStart,
});
if (result.error) {
this.notification.add(result.error, { type: "danger" });
return;
}
window.location = result.url;
} catch (error) {
this.notification.add(error.message || "Could not export shift planner.", { type: "danger" });
}
}
openCellEditor(employee, day, ev) {
if (this.state.loading || this.state.saving) {
return;
}
const anchor = ev.currentTarget.closest(".fclk-planner__shift-cell") || ev.currentTarget;
this.activeCellAnchor = anchor;
this.activeEditorEmployee = employee;
this.activeEditorDay = day;
const cell = employee.cells[day.date] || {};
const fallback = this._defaultTimes(employee, day);
const start = cell.is_off ? fallback.start : (cell.start_time || fallback.start);
const end = cell.is_off ? fallback.end : (cell.end_time || fallback.end);
const breakMinutes = cell.is_off ? 0 : (cell.break_minutes || fallback.breakMinutes || 30);
const hours = cell.is_off ? 0 : Math.max(end - start - breakMinutes / 60, 0);
this.state.editor.open = true;
this.state.editor.employeeId = employee.id;
this.state.editor.employeeName = employee.name;
this.state.editor.date = day.date;
this.state.editor.dayLabel = `${day.weekday} ${day.label}`;
this.state.editor.startValue = this._timeValue(start);
this.state.editor.endValue = this._timeValue(end);
this.state.editor.breakMinutes = breakMinutes;
this.state.editor.hoursDisplay = cell.hours_display || this._formatHours(hours);
this.state.editor.error = cell.error || "";
this._positionActiveEditor(anchor);
}
closeCellEditor() {
this.state.editor.open = false;
this.activeCellAnchor = null;
this.activeEditorEmployee = null;
this.activeEditorDay = null;
}
onGlobalClick(ev) {
if (!this.state.editor.open) {
return;
}
const target = ev.target;
const clickedEditor = this.editorRef.el && this.editorRef.el.contains(target);
const clickedCell = this.activeCellAnchor && this.activeCellAnchor.contains(target);
if (!clickedEditor && !clickedCell) {
this.closeCellEditor();
}
}
isActiveCell(employee, day) {
return this.state.editor.open
&& this.state.editor.employeeId === employee.id
&& this.state.editor.date === day.date;
}
onCellInput(employee, day, ev) {
this._setCellFromInput(employee, day, ev.target.value, ev.target);
}
onCellKeydown(employee, day, ev) {
if (ev.key === "Escape") {
ev.preventDefault();
this.closeCellEditor();
return;
}
if (ev.key === "Tab") {
this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget);
this.closeCellEditor();
return;
}
if (ev.key === "Enter") {
ev.preventDefault();
this._setCellFromInput(employee, day, ev.currentTarget.value, ev.currentTarget);
if (!employee.cells[day.date]?.error) {
this.closeCellEditor();
this._focusRelativeCell(ev.currentTarget, ev.shiftKey ? -this.state.days.length : this.state.days.length);
}
}
}
selectQuickShift(option) {
const context = this._activeEditorContext();
if (!context) {
return;
}
let parsed;
if (option.type === "template") {
parsed = {
is_off: false,
shift_id: option.shiftId,
start_time: option.start,
end_time: option.end,
break_minutes: option.breakMinutes,
hours: option.hours,
hours_display: option.hoursDisplay,
label: option.input,
normalized_input: option.input,
};
} else {
parsed = this._parseInput(option.input, context.cell);
}
this._applyParsedToCell(context.employee, context.day, parsed, option.input);
this._syncEditorFromCell(context.employee, context.day);
this.closeCellEditor();
}
clearActiveCell() {
const context = this._activeEditorContext();
if (!context) {
return;
}
this._setCellFromInput(context.employee, context.day, "");
this.closeCellEditor();
}
onEditorStartChange(ev) {
this.state.editor.startValue = ev.target.value;
this.applyEditorRange(false);
}
onEditorEndChange(ev) {
this.state.editor.endValue = ev.target.value;
this.applyEditorRange(false);
}
applyEditorRange(close = true) {
const context = this._activeEditorContext();
if (!context) {
return;
}
const start = Number(this.state.editor.startValue);
let end = Number(this.state.editor.endValue);
if (end <= start) {
end = Math.min(start + 0.5, 24);
this.state.editor.endValue = this._timeValue(end);
}
const parsed = this._rangeToParsed(start, end, this.state.editor.breakMinutes || 0);
if (parsed.error) {
context.cell.error = parsed.error;
this.state.editor.error = parsed.error;
} else {
this._applyParsedToCell(context.employee, context.day, parsed, parsed.label);
this._syncEditorFromCell(context.employee, context.day);
}
this._recountInvalid();
if (close && !parsed.error) {
this.closeCellEditor();
}
}
_setCellFromInput(employee, day, input, target = null) {
const cell = employee.cells[day.date];
cell.input = input;
const parsed = this._parseInput(input, cell);
this._applyParsedToCell(employee, day, parsed, input);
if (!parsed.error && target && parsed.normalized_input !== undefined) {
target.value = parsed.normalized_input;
}
this._syncEditorFromCell(employee, day);
}
_applyParsedToCell(employee, day, parsed, input) {
const cell = employee.cells[day.date];
cell.error = parsed.error || "";
if (parsed.error) {
cell.input = input;
this.state.editor.error = parsed.error;
this._markDirty(employee, day);
this._recountInvalid();
return;
}
cell.is_off = parsed.is_off || false;
cell.shift_id = parsed.shift_id || false;
cell.start_time = parsed.start_time || 0;
cell.end_time = parsed.end_time || 0;
cell.break_minutes = parsed.break_minutes || 0;
cell.hours = parsed.hours || 0;
cell.hours_display = parsed.hours_display || "0:00";
cell.label = parsed.label || "";
cell.input = parsed.normalized_input !== undefined ? parsed.normalized_input : input;
this.state.editor.error = "";
this._markDirty(employee, day);
this._recountInvalid();
}
_markDirty(employee, day) {
const cell = employee.cells[day.date];
const key = `${employee.id}:${day.date}`;
const payload = {
employee_id: employee.id,
date: day.date,
input: cell.input,
shift_id: cell.shift_id || false,
note: cell.note || "",
};
if ((cell.input || "").trim()) {
payload.is_off = !!cell.is_off;
payload.start_time = cell.start_time || 0;
payload.end_time = cell.end_time || 0;
payload.break_minutes = cell.break_minutes || 0;
}
this.dirtyCells[key] = payload;
this.state.dirtyCount = Object.keys(this.dirtyCells).length;
}
_markServerErrors(errors) {
for (const error of errors) {
const employee = this.state.employees.find((emp) => emp.id === error.employee_id);
const cell = employee && employee.cells[error.date];
if (cell) {
cell.error = error.message;
}
}
this._recountInvalid();
}
_recountInvalid() {
let invalid = 0;
for (const employee of this.state.employees) {
for (const day of this.state.days) {
if (employee.cells[day.date]?.error) {
invalid++;
}
}
}
this.state.invalidCount = invalid;
}
_parseInput(value, currentCell = {}) {
const text = (value || "").trim();
if (!text) {
return {
is_off: false,
shift_id: false,
start_time: 0,
end_time: 0,
break_minutes: 0,
label: "",
hours: 0,
hours_display: "0:00",
normalized_input: "",
};
}
if (text.toUpperCase() === "OFF") {
return {
is_off: true,
shift_id: false,
start_time: 0,
end_time: 0,
break_minutes: 0,
hours: 0,
hours_display: "0:00",
label: "OFF",
normalized_input: "OFF",
};
}
const lowerText = text.toLowerCase();
const template = this.state.shifts.find((shift) =>
[shift.option_label, shift.label, shift.name].some((value) => (value || "").toLowerCase() === lowerText)
);
if (template) {
return {
is_off: false,
shift_id: template.id,
start_time: template.start_time,
end_time: template.end_time,
break_minutes: template.break_minutes,
hours: template.hours,
hours_display: template.hours_display,
label: template.label,
normalized_input: template.label,
};
}
try {
const parsed = this._parseTypedShift(text, currentCell);
return parsed;
} catch (error) {
return { error: error.message };
}
}
_parseTypedShift(value, currentCell = {}) {
const normalized = value.replaceAll("", "-").replaceAll("—", "-").replace(/\s+to\s+/i, "-");
const parts = normalized.split("-");
if (parts.length !== 2 || !parts[0].trim() || !parts[1].trim()) {
throw new Error("Use 9-5, 9:00-5:30, 9:00 am - 5:30 pm, or OFF.");
}
const start = this._parseTimePart(parts[0]);
let end = this._parseTimePart(parts[1]);
if (end <= start && end + 12 <= 24) {
end += 12;
}
if (end <= start) {
throw new Error("End must be after start.");
}
const breakMinutes = currentCell.break_minutes || 30;
const hours = Math.max(end - start - breakMinutes / 60, 0);
const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`;
return {
is_off: false,
shift_id: false,
start_time: start,
end_time: end,
break_minutes: breakMinutes,
hours,
hours_display: this._formatHours(hours),
label,
normalized_input: label,
};
}
_rangeToParsed(start, end, breakMinutes) {
if (Number.isNaN(start) || Number.isNaN(end)) {
return { error: "Choose a start and end time." };
}
if (end <= start) {
return { error: "End must be after start." };
}
const hours = Math.max(end - start - breakMinutes / 60, 0);
const label = `${this._formatFloatTime(start)} - ${this._formatFloatTime(end)}`;
return {
is_off: false,
shift_id: false,
start_time: start,
end_time: end,
break_minutes: breakMinutes,
hours,
hours_display: this._formatHours(hours),
label,
normalized_input: label,
};
}
_parseTimePart(raw) {
const text = raw.trim().toLowerCase().replaceAll(".", "");
const match = text.match(/^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?$/);
if (!match) {
throw new Error(`Could not read "${raw.trim()}".`);
}
let hour = Number(match[1]);
const minute = Number(match[2] || 0);
const meridiem = match[3];
if (minute < 0 || minute > 59) {
throw new Error("Minutes must be 00-59.");
}
if (meridiem) {
if (hour < 1 || hour > 12) {
throw new Error("Use 1-12 with am/pm.");
}
if (meridiem === "am") {
hour = hour === 12 ? 0 : hour;
} else {
hour = hour === 12 ? 12 : hour + 12;
}
}
if (hour < 0 || hour > 24) {
throw new Error("Hours must be 0-24.");
}
return hour + minute / 60;
}
_formatFloatTime(value) {
let hour = Math.floor(value);
let minute = Math.round((value - hour) * 60);
if (minute === 60) {
hour += 1;
minute = 0;
}
const suffix = hour < 12 || hour === 24 ? "am" : "pm";
let displayHour = hour % 12;
if (displayHour === 0) {
displayHour = 12;
}
return `${displayHour}:${String(minute).padStart(2, "0")} ${suffix}`;
}
_formatHours(value) {
let hour = Math.floor(value);
let minute = Math.round((value - hour) * 60);
if (minute === 60) {
hour += 1;
minute = 0;
}
return `${hour}:${String(minute).padStart(2, "0")}`;
}
_timeValue(value) {
const rounded = Math.round(Number(value || 0) * 4) / 4;
return rounded.toFixed(2);
}
_buildTimeOptions() {
const options = [];
for (let minutes = 0; minutes <= 24 * 60; minutes += 15) {
const value = minutes / 60;
options.push({
value: this._timeValue(value),
label: this._formatFloatTime(value),
});
}
return options;
}
_defaultTimes(employee, day) {
const dayIndex = this.state.days.findIndex((item) => item.date === day.date);
if (dayIndex > 0) {
const previousDay = this.state.days[dayIndex - 1];
const previousCell = employee.cells[previousDay.date];
if (previousCell && !previousCell.is_off && previousCell.start_time && previousCell.end_time) {
return {
start: previousCell.start_time,
end: previousCell.end_time,
breakMinutes: previousCell.break_minutes || 30,
};
}
}
const firstShift = this.state.shifts[0];
if (firstShift) {
return {
start: firstShift.start_time,
end: firstShift.end_time,
breakMinutes: firstShift.break_minutes || 30,
};
}
return { start: 9, end: 17, breakMinutes: 30 };
}
get quickShiftOptions() {
const options = [{
key: "off",
type: "input",
input: "OFF",
label: "OFF",
detail: "0:00",
}];
const seen = new Set(["OFF"]);
for (const shift of this.state.shifts) {
if (seen.has(shift.label)) {
continue;
}
seen.add(shift.label);
options.push({
key: `shift-${shift.id}`,
type: "template",
shiftId: shift.id,
input: shift.label,
label: shift.name || shift.label,
detail: `${shift.label} - ${shift.hours_display}`,
start: shift.start_time,
end: shift.end_time,
breakMinutes: shift.break_minutes,
hours: shift.hours,
hoursDisplay: shift.hours_display,
});
}
for (const input of ["9:00 am - 5:00 pm", "7:00 am - 3:30 pm", "8:00 am - 4:30 pm", "11:00 am - 7:30 pm", "12:00 pm - 8:30 pm"]) {
if (seen.has(input)) {
continue;
}
const parsed = this._parseInput(input, { break_minutes: 30 });
seen.add(input);
options.push({
key: `common-${input}`,
type: "input",
input,
label: input,
detail: parsed.hours_display || "0:00",
});
}
return options.slice(0, 10);
}
_activeEditorContext() {
if (!this.state.editor.open || !this.activeEditorEmployee || !this.activeEditorDay) {
return null;
}
return {
employee: this.activeEditorEmployee,
day: this.activeEditorDay,
cell: this.activeEditorEmployee.cells[this.activeEditorDay.date],
};
}
_syncEditorFromCell(employee, day) {
if (!this.isActiveCell(employee, day)) {
return;
}
const cell = employee.cells[day.date] || {};
if (!cell.is_off && cell.start_time && cell.end_time) {
this.state.editor.startValue = this._timeValue(cell.start_time);
this.state.editor.endValue = this._timeValue(cell.end_time);
}
this.state.editor.breakMinutes = cell.break_minutes || 0;
this.state.editor.hoursDisplay = cell.hours_display || "0:00";
this.state.editor.error = cell.error || "";
}
_focusRelativeCell(input, offset) {
const inputs = Array.from(document.querySelectorAll(".fclk-planner__shift-input"));
const index = inputs.indexOf(input);
const next = inputs[index + offset];
if (next) {
next.focus();
next.select();
}
}
_positionActiveEditor(anchor = null) {
if (!this.state.editor.open) {
return;
}
const target = anchor || this.activeCellAnchor;
if (!target || !target.isConnected) {
this.closeCellEditor();
return;
}
const rect = target.getBoundingClientRect();
const editorWidth = Math.min(380, window.innerWidth - 16);
const editorHeight = this.editorRef.el?.offsetHeight || 300;
let left = Math.max(8, Math.min(rect.left, window.innerWidth - editorWidth - 8));
let top = rect.bottom + 8;
if (top + editorHeight > window.innerHeight - 8) {
top = Math.max(8, rect.top - editorHeight - 8);
}
left = Math.round(left);
top = Math.round(top);
if (this.state.editor.left !== left) {
this.state.editor.left = left;
}
if (this.state.editor.top !== top) {
this.state.editor.top = top;
}
}
_dateAdd(dateString, days) {
const date = new Date(`${dateString}T12:00:00`);
date.setDate(date.getDate() + days);
return date.toISOString().slice(0, 10);
}
}
registry.category("actions").add("fusion_clock.ShiftPlanner", FusionClockShiftPlanner);

View File

@@ -0,0 +1,77 @@
$o-webclient-color-scheme: bright !default;
$_fclk-planner-page: #f3f4f6;
$_fclk-planner-panel: #eef1f4;
$_fclk-planner-card: #ffffff;
$_fclk-planner-text: #1f2937;
$_fclk-planner-muted: #6b7280;
$_fclk-planner-border: #d8dadd;
$_fclk-planner-border-strong: #9ca3af;
$_fclk-planner-day: #b7dff5;
$_fclk-planner-subhead: #d8e9bd;
$_fclk-planner-hours: #f5d39b;
$_fclk-planner-fallback: #fff8e5;
$_fclk-planner-row-hover: #f9fafb;
$_fclk-planner-error: #dc2626;
$_fclk-planner-focus: #2563eb;
$_fclk-planner-shadow: rgba(15, 23, 42, 0.08);
$_fclk-planner-editor: #111827;
$_fclk-planner-editor-text: #f9fafb;
$_fclk-planner-editor-muted: #cbd5e1;
$_fclk-planner-editor-border: #374151;
$_fclk-planner-editor-control: #ffffff;
$_fclk-planner-editor-control-text: #111827;
$_fclk-planner-editor-chip: #1f2937;
$_fclk-planner-editor-chip-hover: #334155;
@if $o-webclient-color-scheme == dark {
$_fclk-planner-page: #171a1f !global;
$_fclk-planner-panel: #20242b !global;
$_fclk-planner-card: #262b33 !global;
$_fclk-planner-text: #f3f4f6 !global;
$_fclk-planner-muted: #a3aab8 !global;
$_fclk-planner-border: #3b424c !global;
$_fclk-planner-border-strong: #647082 !global;
$_fclk-planner-day: #21465f !global;
$_fclk-planner-subhead: #394b2d !global;
$_fclk-planner-hours: #6f4f22 !global;
$_fclk-planner-fallback: #393326 !global;
$_fclk-planner-row-hover: #2b313a !global;
$_fclk-planner-error: #f87171 !global;
$_fclk-planner-focus: #60a5fa !global;
$_fclk-planner-shadow: rgba(0, 0, 0, 0.32) !global;
$_fclk-planner-editor: #0f172a !global;
$_fclk-planner-editor-text: #f9fafb !global;
$_fclk-planner-editor-muted: #cbd5e1 !global;
$_fclk-planner-editor-border: #475569 !global;
$_fclk-planner-editor-control: #1f2937 !global;
$_fclk-planner-editor-control-text: #f9fafb !global;
$_fclk-planner-editor-chip: #1e293b !global;
$_fclk-planner-editor-chip-hover: #334155 !global;
}
:root {
--fclk-planner-page: #{$_fclk-planner-page};
--fclk-planner-panel: #{$_fclk-planner-panel};
--fclk-planner-card: #{$_fclk-planner-card};
--fclk-planner-text: #{$_fclk-planner-text};
--fclk-planner-muted: #{$_fclk-planner-muted};
--fclk-planner-border: #{$_fclk-planner-border};
--fclk-planner-border-strong: #{$_fclk-planner-border-strong};
--fclk-planner-day: #{$_fclk-planner-day};
--fclk-planner-subhead: #{$_fclk-planner-subhead};
--fclk-planner-hours: #{$_fclk-planner-hours};
--fclk-planner-fallback: #{$_fclk-planner-fallback};
--fclk-planner-row-hover: #{$_fclk-planner-row-hover};
--fclk-planner-error: #{$_fclk-planner-error};
--fclk-planner-focus: #{$_fclk-planner-focus};
--fclk-planner-shadow: #{$_fclk-planner-shadow};
--fclk-planner-editor: #{$_fclk-planner-editor};
--fclk-planner-editor-text: #{$_fclk-planner-editor-text};
--fclk-planner-editor-muted: #{$_fclk-planner-editor-muted};
--fclk-planner-editor-border: #{$_fclk-planner-editor-border};
--fclk-planner-editor-control: #{$_fclk-planner-editor-control};
--fclk-planner-editor-control-text: #{$_fclk-planner-editor-control-text};
--fclk-planner-editor-chip: #{$_fclk-planner-editor-chip};
--fclk-planner-editor-chip-hover: #{$_fclk-planner-editor-chip-hover};
}

View File

@@ -0,0 +1,25 @@
:root {
--fclk-planner-page: #171a1f;
--fclk-planner-panel: #20242b;
--fclk-planner-card: #262b33;
--fclk-planner-text: #f3f4f6;
--fclk-planner-muted: #a3aab8;
--fclk-planner-border: #3b424c;
--fclk-planner-border-strong: #647082;
--fclk-planner-day: #21465f;
--fclk-planner-subhead: #394b2d;
--fclk-planner-hours: #6f4f22;
--fclk-planner-fallback: #393326;
--fclk-planner-row-hover: #2b313a;
--fclk-planner-error: #f87171;
--fclk-planner-focus: #60a5fa;
--fclk-planner-shadow: rgba(0, 0, 0, 0.32);
--fclk-planner-editor: #0f172a;
--fclk-planner-editor-text: #f9fafb;
--fclk-planner-editor-muted: #cbd5e1;
--fclk-planner-editor-border: #475569;
--fclk-planner-editor-control: #1f2937;
--fclk-planner-editor-control-text: #f9fafb;
--fclk-planner-editor-chip: #1e293b;
--fclk-planner-editor-chip-hover: #334155;
}

View File

@@ -0,0 +1,447 @@
.fclk-planner {
min-height: 100%;
background: var(--fclk-planner-page, #f3f4f6);
color: var(--fclk-planner-text, #1f2937);
display: flex;
flex-direction: column;
}
.fclk-planner__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
background: var(--fclk-planner-card, #ffffff);
border-bottom: 1px solid var(--fclk-planner-border, #d8dadd);
box-shadow: 0 1px 3px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08));
}
.fclk-planner__title {
margin: 0;
font-size: 20px;
font-weight: 650;
line-height: 1.2;
}
.fclk-planner__subtitle {
color: var(--fclk-planner-muted, #6b7280);
font-size: 13px;
margin-top: 3px;
}
.fclk-planner__actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.fclk-planner__warning {
margin: 12px 16px 0;
padding: 10px 12px;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 6px;
color: #9a3412;
font-size: 13px;
}
.fclk-planner__loading {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 340px;
color: var(--fclk-planner-muted, #6b7280);
}
.fclk-planner__table-wrap {
flex: 1;
margin: 16px;
overflow: auto;
background: var(--fclk-planner-panel, #eef1f4);
border: 1px solid var(--fclk-planner-border, #d8dadd);
border-radius: 6px;
box-shadow: 0 6px 20px var(--fclk-planner-shadow, rgba(15, 23, 42, 0.08));
}
.fclk-planner__table {
--fclk-planner-shift-width: 135px;
--fclk-planner-hours-width: 55px;
--fclk-planner-days-width: 1330px;
width: 100%;
min-width: 1600px;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
background: var(--fclk-planner-card, #ffffff);
font-size: 13px;
}
.fclk-planner__employee-col {
width: calc(100% - var(--fclk-planner-days-width));
}
.fclk-planner__shift-col {
width: var(--fclk-planner-shift-width);
}
.fclk-planner__hours-col {
width: var(--fclk-planner-hours-width);
}
.fclk-planner__table th,
.fclk-planner__table td {
border-right: 1px solid var(--fclk-planner-border-strong, #9ca3af);
border-bottom: 1px solid var(--fclk-planner-border-strong, #9ca3af);
}
.fclk-planner__employee-head,
.fclk-planner__day-head,
.fclk-planner__sub-head {
position: sticky;
top: 0;
z-index: 6;
color: var(--fclk-planner-text, #1f2937);
}
.fclk-planner__employee-head {
left: 0;
z-index: 8;
width: calc(100% - var(--fclk-planner-days-width));
background: var(--fclk-planner-day, #b7dff5);
text-align: left;
padding: 10px 12px;
border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af);
}
.fclk-planner__day-head {
background: var(--fclk-planner-day, #b7dff5);
text-align: center;
padding: 6px 8px;
font-weight: 700;
}
.fclk-planner__sub-head {
top: 47px;
background: var(--fclk-planner-subhead, #d8e9bd);
text-align: left;
padding: 5px 8px;
font-weight: 650;
}
.fclk-planner__hours-head {
width: var(--fclk-planner-hours-width);
text-align: center;
padding-left: 2px;
padding-right: 2px;
}
.fclk-planner__weekday {
font-size: 14px;
line-height: 1.1;
}
.fclk-planner__date {
font-size: 12px;
font-weight: 500;
margin-top: 2px;
}
.fclk-planner__department-row td {
background: var(--fclk-planner-panel, #eef1f4);
padding: 0;
position: sticky;
left: 0;
z-index: 5;
}
.fclk-planner__department-toggle {
width: 100%;
min-height: 34px;
display: flex;
align-items: center;
gap: 8px;
border: 0;
background: transparent;
color: var(--fclk-planner-text, #1f2937);
font-weight: 650;
padding: 7px 12px;
text-align: left;
}
.fclk-planner__department-count {
color: var(--fclk-planner-muted, #6b7280);
font-weight: 500;
font-size: 12px;
}
.fclk-planner__employee-row {
background: var(--fclk-planner-card, #ffffff);
}
.fclk-planner__employee-row:hover {
background: var(--fclk-planner-row-hover, #f9fafb);
}
.fclk-planner__employee-cell {
position: sticky;
left: 0;
z-index: 4;
width: calc(100% - var(--fclk-planner-days-width));
background: inherit;
padding: 8px 12px;
border-left: 1px solid var(--fclk-planner-border-strong, #9ca3af);
}
.fclk-planner__employee-name {
font-weight: 650;
line-height: 1.2;
}
.fclk-planner__employee-role {
margin-top: 2px;
color: var(--fclk-planner-muted, #6b7280);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fclk-planner__shift-cell {
width: var(--fclk-planner-shift-width);
min-height: 42px;
padding: 4px;
vertical-align: top;
background: var(--fclk-planner-card, #ffffff);
}
.fclk-planner__shift-cell--fallback {
background: var(--fclk-planner-fallback, #fff8e5);
}
.fclk-planner__shift-cell--error {
background: #fef2f2;
}
.fclk-planner__shift-cell--active {
box-shadow: inset 0 0 0 2px var(--fclk-planner-focus, #2563eb);
}
.fclk-planner__shift-input {
width: 100%;
height: 32px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--fclk-planner-text, #1f2937);
padding: 4px 6px;
font-size: 13px;
line-height: 1.2;
outline: none;
white-space: nowrap;
}
.fclk-planner__shift-input:focus {
background: var(--fclk-planner-card, #ffffff);
border-color: var(--fclk-planner-focus, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.16);
}
.fclk-planner__cell-error {
color: var(--fclk-planner-error, #dc2626);
font-size: 11px;
line-height: 1.2;
padding: 3px 5px 0;
}
.fclk-planner__hours-cell {
width: var(--fclk-planner-hours-width);
background: var(--fclk-planner-hours, #f5d39b);
text-align: center;
font-variant-numeric: tabular-nums;
font-weight: 650;
vertical-align: middle;
padding: 6px 2px;
}
.fclk-planner__cell-editor {
position: fixed;
z-index: 1080;
width: calc(100vw - 16px);
max-width: 380px;
padding: 14px;
color: var(--fclk-planner-editor-text, #f9fafb);
background: var(--fclk-planner-editor, #111827);
border: 1px solid var(--fclk-planner-editor-border, #374151);
border-radius: 8px;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.32);
}
.fclk-planner__cell-editor::before {
content: "";
position: absolute;
top: -7px;
left: 28px;
width: 14px;
height: 14px;
background: var(--fclk-planner-editor, #111827);
border-left: 1px solid var(--fclk-planner-editor-border, #374151);
border-top: 1px solid var(--fclk-planner-editor-border, #374151);
transform: rotate(45deg);
}
.fclk-planner__editor-head {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.fclk-planner__editor-name {
font-size: 14px;
font-weight: 700;
line-height: 1.2;
}
.fclk-planner__editor-day {
margin-top: 2px;
color: var(--fclk-planner-editor-muted, #cbd5e1);
font-size: 12px;
}
.fclk-planner__editor-hours {
min-width: 56px;
padding: 5px 8px;
text-align: center;
color: #111827;
background: var(--fclk-planner-hours, #f5d39b);
border-radius: 6px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.fclk-planner__quick-grid {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.fclk-planner__quick-chip {
min-height: 46px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
padding: 7px 9px;
color: var(--fclk-planner-editor-text, #f9fafb);
background: var(--fclk-planner-editor-chip, #1f2937);
border: 1px solid var(--fclk-planner-editor-border, #374151);
border-radius: 6px;
text-align: left;
}
.fclk-planner__quick-chip:hover,
.fclk-planner__quick-chip:focus {
background: var(--fclk-planner-editor-chip-hover, #334155);
outline: none;
}
.fclk-planner__quick-label {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 650;
line-height: 1.15;
}
.fclk-planner__quick-detail {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--fclk-planner-editor-muted, #cbd5e1);
font-size: 11px;
line-height: 1.15;
}
.fclk-planner__time-row {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.fclk-planner__time-field {
display: flex;
flex-direction: column;
gap: 5px;
margin: 0;
color: var(--fclk-planner-editor-muted, #cbd5e1);
font-size: 12px;
font-weight: 650;
}
.fclk-planner__time-field select {
width: 100%;
height: 34px;
color: var(--fclk-planner-editor-control-text, #111827);
background: var(--fclk-planner-editor-control, #ffffff);
border: 1px solid var(--fclk-planner-editor-border, #374151);
border-radius: 6px;
padding: 4px 8px;
font-size: 13px;
}
.fclk-planner__editor-error {
position: relative;
z-index: 1;
margin-top: 10px;
padding: 7px 8px;
color: #991b1b;
background: #fee2e2;
border-radius: 6px;
font-size: 12px;
line-height: 1.25;
}
.fclk-planner__editor-actions {
position: relative;
z-index: 1;
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
@media (max-width: 900px) {
.fclk-planner__toolbar {
align-items: flex-start;
flex-direction: column;
}
.fclk-planner__actions {
justify-content: flex-start;
}
.fclk-planner__table-wrap {
margin: 10px;
}
.fclk-planner__cell-editor {
width: calc(100vw - 16px);
}
}

View File

@@ -0,0 +1,198 @@
<?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve">
<t t-name="fusion_clock.ShiftPlanner">
<div class="o_action fclk-planner" t-ref="root">
<div class="fclk-planner__toolbar">
<div>
<h2 class="fclk-planner__title">Shift Planner</h2>
<div class="fclk-planner__subtitle"><t t-esc="weekTitle"/></div>
</div>
<div class="fclk-planner__actions">
<button class="btn btn-light" t-on-click="() => this.previousWeek()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-chevron-left"/>
</button>
<button class="btn btn-light" t-on-click="() => this.currentWeek()" t-att-disabled="state.loading or state.saving">This Week</button>
<button class="btn btn-light" t-on-click="() => this.nextWeek()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-chevron-right"/>
</button>
<button class="btn btn-outline-secondary" t-on-click="() => this.copyPreviousWeek()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-copy me-1"/> Copy Previous Week
</button>
<button class="btn btn-outline-secondary" t-on-click="() => this.exportXlsx()" t-att-disabled="state.loading or state.saving">
<i class="fa fa-file-excel-o me-1"/> Export XLSX
</button>
<button class="btn btn-primary" t-on-click="() => this.save()" t-att-disabled="state.loading or state.saving or !state.dirtyCount">
<t t-if="state.saving"><i class="fa fa-spinner fa-spin me-1"/></t>
<t t-else=""><i class="fa fa-save me-1"/></t>
Save
<t t-if="state.dirtyCount">(<t t-esc="state.dirtyCount"/>)</t>
</button>
</div>
</div>
<t t-if="state.error">
<div class="alert alert-danger mx-3 mt-3"><t t-esc="state.error"/></div>
</t>
<t t-if="state.invalidCount">
<div class="fclk-planner__warning">
<i class="fa fa-exclamation-triangle me-1"/>
<t t-esc="state.invalidCount"/> invalid cells need attention.
</div>
</t>
<t t-if="state.loading">
<div class="fclk-planner__loading">
<i class="fa fa-spinner fa-spin fa-2x"/>
<span>Loading shift planner...</span>
</div>
</t>
<t t-if="!state.loading and !state.error">
<div class="fclk-planner__table-wrap">
<table class="fclk-planner__table">
<colgroup>
<col class="fclk-planner__employee-col"/>
<t t-foreach="state.days" t-as="day" t-key="'col_' + day.date">
<col class="fclk-planner__shift-col"/>
<col class="fclk-planner__hours-col"/>
</t>
</colgroup>
<thead>
<tr>
<th class="fclk-planner__employee-head" rowspan="2">Employee</th>
<t t-foreach="state.days" t-as="day" t-key="day.date">
<th class="fclk-planner__day-head" colspan="2">
<div class="fclk-planner__weekday"><t t-esc="day.weekday"/></div>
<div class="fclk-planner__date"><t t-esc="day.label"/></div>
</th>
</t>
</tr>
<tr>
<t t-foreach="state.days" t-as="day" t-key="'sub_' + day.date">
<th class="fclk-planner__sub-head">Shift</th>
<th class="fclk-planner__sub-head fclk-planner__hours-head">Hours</th>
</t>
</tr>
</thead>
<tbody>
<t t-foreach="state.departments" t-as="department" t-key="department.id">
<tr class="fclk-planner__department-row">
<td t-att-colspan="1 + state.days.length * 2">
<button class="fclk-planner__department-toggle" t-on-click="() => this.toggleDepartment(department)">
<i t-att-class="isCollapsed(department) ? 'fa fa-chevron-right' : 'fa fa-chevron-down'"/>
<span><t t-esc="department.name"/></span>
<span class="fclk-planner__department-count">
<t t-esc="department.employee_ids.length"/> employees
</span>
</button>
</td>
</tr>
<t t-if="!isCollapsed(department)">
<t t-foreach="getDepartmentEmployees(department)" t-as="employee" t-key="employee.id">
<tr class="fclk-planner__employee-row">
<td class="fclk-planner__employee-cell">
<div class="fclk-planner__employee-name"><t t-esc="employee.name"/></div>
<div class="fclk-planner__employee-role" t-if="employee.job_title">
<t t-esc="employee.job_title"/>
</div>
</td>
<t t-foreach="state.days" t-as="day" t-key="employee.id + '_' + day.date">
<t t-set="cell" t-value="employee.cells[day.date]"/>
<td t-att-class="'fclk-planner__shift-cell ' + (cell.error ? 'fclk-planner__shift-cell--error ' : '') + (cell.source === 'fallback' ? 'fclk-planner__shift-cell--fallback ' : '') + (this.isActiveCell(employee, day) ? 'fclk-planner__shift-cell--active' : '')"
t-on-click="(ev) => this.openCellEditor(employee, day, ev)">
<input class="fclk-planner__shift-input"
t-att-value="cell.input"
t-att-title="cell.error || cell.label"
t-on-focus="(ev) => this.openCellEditor(employee, day, ev)"
t-on-change="(ev) => this.onCellInput(employee, day, ev)"
t-on-keydown="(ev) => this.onCellKeydown(employee, day, ev)"/>
<div class="fclk-planner__cell-error" t-if="cell.error">
<t t-esc="cell.error"/>
</div>
</td>
<td class="fclk-planner__hours-cell">
<t t-esc="cell.hours_display || '0:00'"/>
</td>
</t>
</tr>
</t>
</t>
</t>
</tbody>
</table>
</div>
<div t-if="state.editor.open"
t-ref="shiftEditor"
class="fclk-planner__cell-editor"
t-att-style="'top: ' + state.editor.top + 'px; left: ' + state.editor.left + 'px;'">
<div class="fclk-planner__editor-head">
<div class="fclk-planner__editor-person">
<div class="fclk-planner__editor-name"><t t-esc="state.editor.employeeName"/></div>
<div class="fclk-planner__editor-day"><t t-esc="state.editor.dayLabel"/></div>
</div>
<div class="fclk-planner__editor-hours">
<span><t t-esc="state.editor.hoursDisplay"/></span>
</div>
</div>
<div class="fclk-planner__quick-grid">
<t t-foreach="quickShiftOptions" t-as="option" t-key="option.key">
<button type="button"
class="fclk-planner__quick-chip"
t-on-click="() => this.selectQuickShift(option)">
<span class="fclk-planner__quick-label"><t t-esc="option.label"/></span>
<span class="fclk-planner__quick-detail"><t t-esc="option.detail"/></span>
</button>
</t>
</div>
<div class="fclk-planner__time-row">
<label class="fclk-planner__time-field">
<span>Start</span>
<select t-on-change="(ev) => this.onEditorStartChange(ev)">
<t t-foreach="timeOptions" t-as="option" t-key="'start_' + option.value">
<option t-att-value="option.value"
t-att-selected="option.value === state.editor.startValue">
<t t-esc="option.label"/>
</option>
</t>
</select>
</label>
<label class="fclk-planner__time-field">
<span>End</span>
<select t-on-change="(ev) => this.onEditorEndChange(ev)">
<t t-foreach="timeOptions" t-as="option" t-key="'end_' + option.value">
<option t-att-value="option.value"
t-att-selected="option.value === state.editor.endValue">
<t t-esc="option.label"/>
</option>
</t>
</select>
</label>
</div>
<div class="fclk-planner__editor-error" t-if="state.editor.error">
<t t-esc="state.editor.error"/>
</div>
<div class="fclk-planner__editor-actions">
<button type="button"
class="btn btn-sm btn-light"
t-on-click="() => this.clearActiveCell()">
<i class="fa fa-eraser me-1"/> Clear
</button>
<button type="button"
class="btn btn-sm btn-primary"
t-on-click="() => this.applyEditorRange(true)">
<i class="fa fa-check me-1"/> Done
</button>
</div>
</div>
</t>
</div>
</t>
</templates>

View File

@@ -2,3 +2,4 @@
from . import test_nfc_models from . import test_nfc_models
from . import test_clock_nfc_kiosk from . import test_clock_nfc_kiosk
from . import test_shift_planner

View File

@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
import json
from datetime import date, timedelta
from psycopg2 import IntegrityError
from odoo import fields
from odoo.exceptions import ValidationError
from odoo.tests.common import HttpCase, TransactionCase, tagged
from odoo.tools.misc import mute_logger
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestShiftPlannerModels(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Schedule = cls.env['fusion.clock.schedule'].sudo()
cls.Shift = cls.env['fusion.clock.shift'].sudo()
cls.employee = cls.env['hr.employee'].sudo().create({
'name': 'Planner Model Employee',
'company_id': cls.env.company.id,
'x_fclk_enable_clock': True,
})
cls.default_shift = cls.Shift.create({
'name': 'Default Planner Shift',
'start_time': 8.0,
'end_time': 16.5,
'break_minutes': 30,
'company_id': cls.env.company.id,
})
cls.employee.x_fclk_shift_id = cls.default_shift.id
cls.schedule_date = date(2026, 1, 5)
def test_unique_employee_date_schedule(self):
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': self.schedule_date,
'is_off': True,
})
with self.assertRaises(IntegrityError), mute_logger('odoo.sql_db'):
with self.env.cr.savepoint():
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': self.schedule_date,
'is_off': True,
})
def test_off_schedule_has_zero_hours(self):
schedule = self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': date(2026, 1, 6),
'is_off': True,
})
self.assertEqual(schedule.planned_hours, 0)
self.assertEqual(schedule.fclk_display_value(), 'OFF')
def test_working_schedule_computes_hours_minus_break(self):
schedule = self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': date(2026, 1, 7),
'start_time': 9.0,
'end_time': 17.5,
'break_minutes': 30,
})
self.assertEqual(schedule.planned_hours, 8.0)
self.assertEqual(self.Schedule.fclk_hours_display(schedule.planned_hours), '8:00')
def test_invalid_same_day_range_is_rejected(self):
with self.assertRaises(ValidationError):
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': date(2026, 1, 8),
'start_time': 17.0,
'end_time': 9.0,
'break_minutes': 30,
})
def test_apply_planner_cell_creates_audit(self):
schedule_date = date(2026, 1, 9)
self.Schedule.fclk_apply_planner_cell(
self.employee,
schedule_date,
{'input': '9:00 am - 5:30 pm'},
self.env.user,
)
audit = self.env['fusion.clock.schedule.audit'].sudo().search([
('employee_id', '=', self.employee.id),
('schedule_date', '=', schedule_date),
], limit=1)
self.assertTrue(audit)
self.assertFalse(audit.old_value)
self.assertEqual(audit.new_value, '9:00 am - 5:30 pm')
def test_dated_schedule_overrides_employee_shift_and_fallback_remains(self):
planned_date = date(2026, 1, 12)
self.Schedule.create({
'employee_id': self.employee.id,
'schedule_date': planned_date,
'start_time': 10.0,
'end_time': 18.0,
'break_minutes': 60,
})
planned = self.employee._get_fclk_day_plan(planned_date)
fallback = self.employee._get_fclk_day_plan(planned_date + timedelta(days=1))
self.assertEqual(planned['source'], 'schedule')
self.assertEqual(planned['start_time'], 10.0)
self.assertEqual(planned['hours'], 7.0)
self.assertEqual(fallback['source'], 'fallback')
self.assertEqual(fallback['start_time'], 8.0)
@tagged('-at_install', 'post_install', 'fusion_clock')
class TestShiftPlannerApi(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
manager_group = cls.env.ref('fusion_clock.group_fusion_clock_manager')
user_group = cls.env.ref('fusion_clock.group_fusion_clock_user')
cls.manager_user = cls.env['res.users'].sudo().create({
'name': 'Planner Manager',
'login': 'planner-manager',
'password': 'plannerpass',
'company_id': cls.env.company.id,
'company_ids': [(6, 0, [cls.env.company.id])],
'group_ids': [(6, 0, [manager_group.id])],
})
cls.employee_user = cls.env['res.users'].sudo().create({
'name': 'Planner Employee User',
'login': 'planner-employee-user',
'password': 'plannerpass',
'company_id': cls.env.company.id,
'company_ids': [(6, 0, [cls.env.company.id])],
'group_ids': [(6, 0, [user_group.id])],
'tz': 'UTC',
})
cls.employee = cls.env['hr.employee'].sudo().create({
'name': 'Planner API Employee',
'user_id': cls.employee_user.id,
'company_id': cls.env.company.id,
'x_fclk_enable_clock': True,
})
cls.shift = cls.env['fusion.clock.shift'].sudo().create({
'name': 'API Morning',
'start_time': 7.0,
'end_time': 15.5,
'break_minutes': 30,
'company_id': cls.env.company.id,
})
cls.week_start = '2026-01-19'
def _json_call(self, route, payload, login='planner-manager'):
self.authenticate(login, 'plannerpass')
response = self.url_open(
route,
data=json.dumps({'jsonrpc': '2.0', 'method': 'call', 'params': payload}),
headers={'Content-Type': 'application/json'},
)
return response.json().get('result', {})
def test_manager_can_load_save_and_export_planner(self):
load_result = self._json_call('/fusion_clock/shift_planner/load', {
'week_start': self.week_start,
})
self.assertIn(self.employee.id, [row['id'] for row in load_result['employees']])
save_result = self._json_call('/fusion_clock/shift_planner/save', {
'week_start': self.week_start,
'changes': [{
'employee_id': self.employee.id,
'date': self.week_start,
'input': '9-5',
'shift_id': False,
}],
})
self.assertTrue(save_result.get('success'))
schedule = self.env['fusion.clock.schedule'].sudo().search([
('employee_id', '=', self.employee.id),
('schedule_date', '=', fields.Date.to_date(self.week_start)),
], limit=1)
self.assertTrue(schedule)
self.assertEqual(schedule.start_time, 9.0)
self.assertEqual(schedule.end_time, 17.0)
export_result = self._json_call('/fusion_clock/shift_planner/export_xlsx', {
'week_start': self.week_start,
})
self.assertTrue(export_result.get('success'))
self.assertTrue(export_result.get('url', '').startswith('/web/content/'))
self.assertTrue(self.env['ir.attachment'].sudo().browse(export_result['attachment_id']).exists())
def test_copy_previous_week(self):
previous_monday = fields.Date.to_date(self.week_start) - timedelta(days=7)
self.env['fusion.clock.schedule'].sudo().create({
'employee_id': self.employee.id,
'schedule_date': previous_monday,
'shift_id': self.shift.id,
'start_time': self.shift.start_time,
'end_time': self.shift.end_time,
'break_minutes': self.shift.break_minutes,
})
result = self._json_call('/fusion_clock/shift_planner/copy_previous_week', {
'week_start': self.week_start,
})
self.assertTrue(result.get('success'))
copied = self.env['fusion.clock.schedule'].sudo().search([
('employee_id', '=', self.employee.id),
('schedule_date', '=', fields.Date.to_date(self.week_start)),
], limit=1)
self.assertEqual(copied.shift_id, self.shift)
def test_non_manager_cannot_mutate_planner(self):
result = self._json_call('/fusion_clock/shift_planner/save', {
'week_start': self.week_start,
'changes': [],
}, login='planner-employee-user')
self.assertEqual(result.get('error'), 'Access denied.')
def test_off_day_clock_in_succeeds_and_logs_unscheduled_shift(self):
today = fields.Date.today()
location = self.env['fusion.clock.location'].sudo().create({
'name': 'Planner Test Location',
'latitude': 43.65,
'longitude': -79.38,
'radius': 100,
'company_id': self.env.company.id,
'all_employees': True,
})
self.env['fusion.clock.schedule'].sudo().create({
'employee_id': self.employee.id,
'schedule_date': today,
'is_off': True,
})
result = self._json_call('/fusion_clock/clock_action', {
'latitude': location.latitude,
'longitude': location.longitude,
'source': 'portal',
}, login='planner-employee-user')
self.assertTrue(result.get('success'))
self.assertEqual(result.get('action'), 'clock_in')
self.assertIn('unscheduled', result.get('message', ''))
log = self.env['fusion.clock.activity.log'].sudo().search([
('employee_id', '=', self.employee.id),
('log_type', '=', 'unscheduled_shift'),
], limit=1)
self.assertTrue(log)

View File

@@ -16,6 +16,34 @@
sequence="5" sequence="5"
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/> groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
<!-- Scheduling -->
<menuitem id="menu_fusion_clock_scheduling"
name="Scheduling"
parent="menu_fusion_clock_root"
sequence="8"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_shift_planner"
name="Shift Planner"
parent="menu_fusion_clock_scheduling"
action="action_fusion_clock_shift_planner"
sequence="5"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_scheduled_shifts"
name="Scheduled Shifts"
parent="menu_fusion_clock_scheduling"
action="action_fusion_clock_schedule"
sequence="10"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_schedule_audit"
name="Schedule Audit"
parent="menu_fusion_clock_scheduling"
action="action_fusion_clock_schedule_audit"
sequence="20"
groups="group_fusion_clock_manager"/>
<!-- Attendance Sub-Menu --> <!-- Attendance Sub-Menu -->
<menuitem id="menu_fusion_clock_attendance" <menuitem id="menu_fusion_clock_attendance"
name="Attendance" name="Attendance"

View File

@@ -0,0 +1,128 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_fusion_clock_shift_planner" model="ir.actions.client">
<field name="name">Shift Planner</field>
<field name="tag">fusion_clock.ShiftPlanner</field>
</record>
<record id="view_fusion_clock_schedule_list" model="ir.ui.view">
<field name="name">fusion.clock.schedule.list</field>
<field name="model">fusion.clock.schedule</field>
<field name="arch" type="xml">
<list>
<field name="schedule_date"/>
<field name="employee_id"/>
<field name="department_id"/>
<field name="is_off"/>
<field name="shift_id"/>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="break_minutes"/>
<field name="planned_hours"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_fusion_clock_schedule_form" model="ir.ui.view">
<field name="name">fusion.clock.schedule.form</field>
<field name="model">fusion.clock.schedule</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="employee_id"/>
<field name="schedule_date"/>
<field name="is_off"/>
<field name="shift_id"/>
</group>
<group>
<field name="start_time" widget="float_time"/>
<field name="end_time" widget="float_time"/>
<field name="break_minutes"/>
<field name="planned_hours" readonly="1"/>
</group>
</group>
<group>
<field name="note"/>
<field name="department_id" readonly="1"/>
<field name="company_id" readonly="1" groups="base.group_multi_company"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_fusion_clock_schedule_search" model="ir.ui.view">
<field name="name">fusion.clock.schedule.search</field>
<field name="model">fusion.clock.schedule</field>
<field name="arch" type="xml">
<search>
<field name="employee_id"/>
<field name="department_id"/>
<field name="schedule_date"/>
<filter name="off" string="OFF" domain="[('is_off', '=', True)]"/>
<filter name="working" string="Working" domain="[('is_off', '=', False)]"/>
<filter name="group_department" string="Department" context="{'group_by': 'department_id'}"/>
<filter name="group_date" string="Date" context="{'group_by': 'schedule_date'}"/>
</search>
</field>
</record>
<record id="action_fusion_clock_schedule" model="ir.actions.act_window">
<field name="name">Scheduled Shifts</field>
<field name="res_model">fusion.clock.schedule</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_fusion_clock_schedule_audit_list" model="ir.ui.view">
<field name="name">fusion.clock.schedule.audit.list</field>
<field name="model">fusion.clock.schedule.audit</field>
<field name="arch" type="xml">
<list create="0" edit="0" delete="0">
<field name="changed_at"/>
<field name="employee_id"/>
<field name="schedule_date"/>
<field name="old_value"/>
<field name="new_value"/>
<field name="changed_by_id"/>
<field name="department_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</list>
</field>
</record>
<record id="view_fusion_clock_schedule_audit_form" model="ir.ui.view">
<field name="name">fusion.clock.schedule.audit.form</field>
<field name="model">fusion.clock.schedule.audit</field>
<field name="arch" type="xml">
<form create="0" edit="0" delete="0">
<sheet>
<group>
<group>
<field name="changed_at"/>
<field name="changed_by_id"/>
<field name="employee_id"/>
<field name="schedule_date"/>
</group>
<group>
<field name="old_value"/>
<field name="new_value"/>
<field name="department_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_fusion_clock_schedule_audit" model="ir.actions.act_window">
<field name="name">Schedule Audit</field>
<field name="res_model">fusion.clock.schedule.audit</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -142,6 +142,28 @@
</div> </div>
</div> </div>
<!-- Scheduled Shift -->
<div class="fclk-schedule-card">
<div class="fclk-schedule-icon">
<i class="fa fa-calendar-check-o"/>
</div>
<div class="fclk-schedule-info">
<div class="fclk-schedule-label">Today's Shift</div>
<div class="fclk-schedule-value">
<t t-if="today_schedule.get('is_off')">OFF</t>
<t t-else="">
<t t-esc="today_schedule.get('label') or 'Not scheduled'"/>
</t>
</div>
</div>
<div class="fclk-schedule-hours">
<t t-if="today_schedule.get('is_off')">0:00</t>
<t t-else="">
<t t-esc="'%.1f' % (today_schedule.get('hours') or 0.0)"/>h
</t>
</div>
</div>
<!-- Timer Section --> <!-- Timer Section -->
<div class="fclk-timer-section"> <div class="fclk-timer-section">
<div class="fclk-timer-label" id="fclk-timer-label"> <div class="fclk-timer-label" id="fclk-timer-label">

View File

@@ -166,6 +166,33 @@ These modules have **source code in this repo** but are **intentionally NOT inst
| `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. | | `fusion_plating_culture` | `state=uninstalled`, dir removed from entech disk | Soft people-ops feature (peer kudos / "Fundamental of the Week"); zero data entered; not a client priority. Top-level "Culture" menu confused operators. | Ask the client whether they want it before reinstalling. If yes: re-sync folder + `-i fusion_plating_culture` + seed a value set. |
| `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. | | `fusion_plating_sensors` | deleted entirely (not in repo anymore) | Duplicated `fusion_plating_iot`'s scope but with no working alerting logic. Its valuables (sensor_type taxonomy, dashboard, location flexibility) were ported into `fusion_iot/fusion_plating_iot/`. | N/A — gone. Any new sensor work goes in `fusion_iot/fusion_plating_iot/`. |
## Shop-floor action endpoints — credit the correct tech via `tablet_tech_id`
The tablet sits on a long-lived "shopfloor service" Odoo session shared by many techs. The actual tech-of-record is established via the PIN unlock (Phase 6); their id lives in the OWL `fp_shopfloor_tech_store` service and is sent as `tablet_tech_id` on every action RPC.
When writing a NEW shop-floor controller endpoint that **writes** (creates a record, calls a `button_*` method, posts to chatter):
1. Add `tablet_tech_id=None` as a kwarg on the route handler.
2. At the top, call: `env = env_for_tablet_tech(request.env, tablet_tech_id)` (from `fusion_plating_shopfloor/controllers/_tablet_audit.py`).
3. Use `env` (not `request.env`) for all subsequent writes. `env.with_user(...)` is applied internally so `create_uid` / `write_uid` / chatter authorship carry the right uid.
4. Read-only endpoints (load / kanban / funnel / overview) don't need this — leave them as `request.env`.
On the client side: use `fpRpc()` from `services/fp_rpc.js` (drop-in for `rpc()`) for action calls. It auto-injects `tablet_tech_id`. Read calls can keep using plain `rpc()`.
If `tablet_tech_id` is missing or invalid, `env_for_tablet_tech` falls back to the session uid — old callers and pre-Phase-6.3 endpoints continue working.
## Removing menus/records — Odoo does NOT auto-delete orphans
Deleting a `<menuitem>` (or any `<record>`) from a data XML file does NOT remove the corresponding database row. The XML loader only updates records it sees; orphans persist in `ir.ui.menu` / `ir.model.data` until you delete them explicitly. Symptom: the menu still appears in the UI after `-u`. Fix — add a `<delete>` directive in a data file with `noupdate="0"`:
```xml
<delete model="ir.ui.menu" id="module_name.menu_xmlid_to_remove"/>
```
Caught 2026-05-22 when the Phase 3 Plant Overview menu kept showing alongside the new Workstation menu after deploy.
## Odoo 19 ir.cron — `numbercall` and `doall` are gone
The legacy `numbercall=-1` (run-forever) and `doall=False` (catch-up-missed) fields were removed from `ir.cron` in Odoo 19. Including them in `<record model="ir.cron">` data XML produces:
```
ValueError: Invalid field 'numbercall' in 'ir.cron'
```
Use only: `name`, `model_id`, `state`, `code` (or `function`/`model`), `interval_number`, `interval_type`, `active`. Caught during the 2026-05-22 entech deploy of the auto-pause cron.
## Critical Rules — Odoo 19 ## Critical Rules — Odoo 19
1. **NEVER code from memory** — Read reference files from the server first. 1. **NEVER code from memory** — Read reference files from the server first.
2. **Backend OWL**: `static template`, `static props = ["*"]`, standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. 2. **Backend OWL**: `static template`, `static props = ["*"]`, standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,358 @@
# Shop Floor PIN Gate + Auto-Lock — Design Spec
**Date:** 2026-05-22
**Status:** Awaiting user review
**Phase:** 6 (sequel to Phases 1-5 of 2026-05-22-shopfloor-tablet-redesign)
**Module owners:** `fusion_plating_shopfloor`, `fusion_plating_jobs`
**Target client:** EN Technologies (Fusion Plating)
---
## 1. Context
Phases 1-5 of the tablet redesign shipped on 2026-05-22 (entech LXC 111). They assume a single user is "logged in" to a tablet for the duration of use. Real shop floors don't work that way:
- A single tablet sits at the **EN Plating tank** (or de-rack table, masking station, QC bench).
- Multiple technicians rotate through the station during a shift.
- A tech walks away mid-shift; the next tech walks up — and without a lock, the new tech is operating under the previous tech's identity.
- Every step start, finish, scrap, hold, signature, and milestone advance gets attributed to the wrong person.
- AS9100 / Nadcap audit trails break. Operators sign off on each other's work without knowing it.
The fix needs to be **fast** (PIN in < 2 seconds), **familiar** (matches iPad / debit-card UX techs already know), and **silent on the timer side** (locking the tablet must not pause a part in a tank).
## 2. Goals
- Each tech identifies themselves with a personal 4-digit PIN.
- Tablet auto-locks after a configurable idle period (default 5 min).
- Quick-switch UX: tap your face tile → enter PIN → unlocked. No typing usernames.
- All shop-floor actions (step start/finish, holds, sign-offs, milestone advances) carry the correct tech identity for audit.
- No interruption to in-progress step timers — the server keeps counting.
- Manager can reset a forgotten PIN; no SMS/email infrastructure required.
- Per-station roster: a tablet at EN Plating only shows techs trained on EN Plating.
## 3. Non-goals (v1)
- Multi-factor authentication (TOTP, SMS).
- Biometric unlock (Face ID, fingerprint).
- NFC badges or RFID readers.
- Self-service PIN reset via email/SMS (manager-side only).
- "Remember me" cross-device sessions.
- Camera-based presence detection / liveness checks.
## 4. UX
### 4.1 Lock screen — tile grid
Rendered first thing when the tablet boots, after any auto-lock, and after the Hand-Off button. Replaces the current Landing/Workspace/Dashboard view entirely.
- 3-5 tiles per row, sized for touch (~120×140 px each).
- Sort: clocked-in techs first, alphabetical within bucket.
- Each tile: avatar + name + small green dot if clocked in.
- A station with `x_fc_authorised_user_ids` configured shows only those techs; otherwise all techs in the operator group.
- "Other..." chip at the end opens a username search for off-roster cases (cross-trained tech covering an unfamiliar station).
- Tap a tile → PIN pad slides up as a modal.
### 4.2 PIN pad
- Numeric 0-9 in a 3×4 grid + Clear + Submit.
- 4 dot placeholders fill as digits are typed.
- Auto-submit on the 4th digit (no Enter required).
- Wrong PIN → quick shake animation (CSS keyframe), dots clear, tile grid stays.
- After 5 sequential failures for one tech, that tech is locked for 5 minutes. Other techs can still unlock the tablet.
- "Forgot?" link surfaces a friendly message: "Ask a manager to reset your PIN."
### 4.3 Hand-Off button
- Top-right corner of every authenticated view (Landing, Workspace, Manager Dashboard), next to QR scan.
- Big icon + label: 🔒 **Hand Off**.
- Tap → confirm dialog "Lock this tablet now?" → instant lock to tile grid.
- Confirm dialog prevents accidental locks; can be skipped in v2 with rapid double-tap.
### 4.4 Idle warning
- At 30 seconds before auto-lock, a yellow pulsing border appears around the entire viewport.
- A toast slides in: "Locking in 28s · tap anywhere to stay".
- Countdown decrements in 1s ticks until 0.
- Any pointer/touch event clears the warning and resets the timer.
- At 0s, the tile grid replaces the current view.
### 4.5 Session continuity (state preservation on lock)
| State | On lock | On unlock |
|---|---|---|
| In-progress step timer | Server-side timer keeps running. No pause event fired. | Resumes accurate elapsed time. |
| OWL state (scroll, expanded step) | Preserved in memory | Restored |
| HoldComposer modal open | Preserved (dialog still mounted under the lock overlay) | Available immediately |
| SignaturePad open mid-stroke | **Thrown away.** Signature flow restarts. | Fresh signature required. |
| QR scan drawer open | Preserved | Available |
| Refresh interval (15s/8s polling) | Paused | Resumed |
### 4.6 Profile preferences — set / change PIN
- New "Tablet PIN" group on `res.users` preferences (user-facing form).
- Single button: **Set Tablet PIN** or **Change PIN** (label flips depending on whether a hash exists).
- Tapping it opens a modal with 3 PIN inputs:
- Current PIN (only if a PIN is set; skipped on first-time set)
- New PIN
- Confirm new PIN
- All three use the same `FpPinPad` component as the unlock screen.
- Subtext shows "Last changed: 2026-05-22" or "Cleared by manager".
### 4.7 Manager-side reset
- New header button on `res.users` form: **Reset Tablet PIN** (visible only to `group_fusion_plating_manager` and above).
- Tap → confirm dialog → posts to user's chatter ("Tablet PIN reset by Manager X on 2026-05-22") + clears the hash.
- Tech sets a new PIN on next unlock attempt.
## 5. Backend
### 5.1 Model fields on `res.users` (in `fusion_plating_shopfloor/models/res_users.py` — new file)
| Field | Type | Notes |
|---|---|---|
| `x_fc_tablet_pin_hash` | Char | Hash (SHA-256 + per-user salt) of the PIN. Stored as `<salt>$<hash>`. `groups='fusion_plating.group_fusion_plating_manager'` — non-manager users cannot even read other users' hash field. |
| `x_fc_tablet_pin_set_date` | Datetime | When the current hash was set. NULL if PIN was cleared by manager. |
| `x_fc_tablet_pin_failed_count` | Integer (0) | Sequential failed attempts since last success. Resets to 0 on a correct PIN. |
| `x_fc_tablet_locked_until` | Datetime | Lockout expiry. NULL when not locked. |
### 5.2 Extras on `fusion.plating.shopfloor.station`
| Field | Type | Notes |
|---|---|---|
| `x_fc_authorised_user_ids` | Many2many → res.users | If non-empty, the tile grid restricts to these users. Empty = "all operators". |
| `x_fc_idle_lock_minutes` | Integer, nullable | Per-station override; null = use global default. |
### 5.3 `ir.config_parameter` defaults
| Key | Default | Purpose |
|---|---|---|
| `fp.shopfloor.tablet_idle_lock_minutes` | `5` | Global idle threshold |
| `fp.shopfloor.tablet_pin_fail_threshold` | `5` | Failures before lockout |
| `fp.shopfloor.tablet_pin_fail_lockout_minutes` | `5` | Lockout duration |
| `fp.shopfloor.tablet_warn_seconds_before_lock` | `30` | When the yellow border appears |
### 5.4 HTTP endpoints (`/fp/tablet/*`)
All `type='jsonrpc'`, `auth='user'`. Auth = the tablet's persistent Odoo session (a "shopfloor service" account or any non-locked user).
| Endpoint | Body | Returns |
|---|---|---|
| `POST /fp/tablet/tiles` | `{station_id?}` | `{ok, tiles: [{user_id, name, avatar_url, is_clocked_in, has_pin}, ...]}`. Respects `station.x_fc_authorised_user_ids`. |
| `POST /fp/tablet/unlock` | `{user_id, pin}` | `{ok: true, current_tech_id, current_tech_name}` or `{ok: false, error, locked_until?, attempts_remaining}`. |
| `POST /fp/tablet/set_pin` | `{old_pin?, new_pin}` | Caller's PIN only. `old_pin` required if a hash exists. `{ok, error?}`. |
| `POST /fp/tablet/reset_pin_for` | `{user_id}` | Manager-only; manager group enforced server-side. Clears target user's hash + posts chatter. |
| `POST /fp/tablet/ping` | `{current_tech_id}` | Bumps a server-side "last active" timestamp for forensics. Called on every successful tech action. |
### 5.5 Hash algorithm
```python
import hashlib, secrets
def _hash_pin(pin: str, salt: bytes = None) -> str:
salt = salt or secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac('sha256', pin.encode('utf-8'), salt, 200_000)
return f"{salt.hex()}${digest.hex()}"
def _verify_pin(pin: str, stored: str) -> bool:
salt_hex, expected_hex = stored.split('$', 1)
salt = bytes.fromhex(salt_hex)
digest = hashlib.pbkdf2_hmac('sha256', pin.encode('utf-8'), salt, 200_000)
return digest.hex() == expected_hex
```
200,000 PBKDF2 iterations gives ~50ms verify time on entech-class hardware — fast enough for tech UX, slow enough to make a brute-force attack expensive even with the database stolen.
### 5.6 Audit propagation (Phase 6.3)
All existing shop-floor endpoints that take an action (`/fp/shopfloor/start_wo`, `stop_wo`, `bump_qty_done`, `bump_qty_scrapped`, `log_chemistry`, `log_thickness_reading`, `quality_hold`, `mark_gate`, `start_bake`, `end_bake`, `/fp/workspace/{hold,sign_off,advance_milestone}`) gain an **optional** `tablet_tech_id` kwarg.
When the OWL component passes `tablet_tech_id`:
- Server verifies the id corresponds to a recent successful `/fp/tablet/unlock` (within session's idle window).
- All chatter posts use that user's name instead of `env.uid`.
- All writes to records set `create_uid` / `write_uid` to that user (via `with_user(...)` context manager).
- If `tablet_tech_id` is missing or stale, server falls back to `env.uid` (the tablet's session user) for back-compat.
This keeps the audit trail honest without forcing a full Odoo session swap on every PIN unlock (which would clear all OWL state and JS bundle cache).
## 6. Frontend architecture
### 6.1 New OWL components
| Component | File | Purpose |
|---|---|---|
| `FpTabletLock` | `static/src/js/tablet_lock.js` | Top-level wrapper around Landing/Workspace/Manager. Renders tile grid when locked; renders children when unlocked. |
| `FpPinPad` | `static/src/js/components/pin_pad.js` | Numeric pad modal. Used by FpTabletLock unlock AND Profile set-PIN flow. |
| `FpPinSetup` | `static/src/js/components/pin_setup.js` | Modal for set/change PIN. Wraps 3 instances of FpPinPad (old + new + confirm). |
| `FpIdleWarning` | `static/src/js/components/idle_warning.js` | Yellow-border + countdown toast component shown at T-30s. |
### 6.2 Activity tracker service
- Registered as `fp_shopfloor_activity` in the OWL `services` registry.
- Tracks `lastActiveAt` (epoch ms).
- Listens at document level: `pointerdown`, `touchstart`, `keydown`, `visibilitychange`.
- Public API: `bumpActivity()`, `getSecondsUntilLock()`, `subscribe(cb)`, `lock()`.
- Bumps server-side on every `ping` (debounced to once per 30s).
### 6.3 Auto-lock flow
```js
// inside FpTabletLock setup()
this.activity = useService("fp_shopfloor_activity");
this._tick = setInterval(() => {
const remaining = this.activity.getSecondsUntilLock();
if (remaining <= 0) {
this.state.locked = true;
this.state.currentTechId = null;
} else if (remaining <= this.warnThresholdSec) {
this.state.idleWarning = remaining;
} else if (this.state.idleWarning) {
this.state.idleWarning = null; // user tapped, reset
}
}, 1000);
```
### 6.4 RPC plumbing
A tiny client-side helper wraps `rpc()` so every shop-floor call automatically includes `tablet_tech_id`:
```js
// fp_rpc.js
import { rpc as baseRpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
const techStore = registry.category("services").get("fp_shopfloor_tech_store");
export function fpRpc(url, params = {}) {
if (techStore.currentTechId) {
params = { ...params, tablet_tech_id: techStore.currentTechId };
}
return baseRpc(url, params);
}
```
Landing, Workspace, Manager Dashboard switch from `rpc(...)` to `fpRpc(...)` for action calls. Read-only calls (load, tiles, kanban) don't need the kwarg.
### 6.5 Component composition
```
FpTabletLock (NEW outer wrapper, mounted by every client action)
├── if locked → FpPinPad (tile grid + entry)
├── if idle warning → FpIdleWarning overlay
└── else → existing client action (Landing | Workspace | Manager Dashboard)
+ Hand-Off button injected into existing headers
```
The "tablet locked" boolean lives in a shared OWL service (`fp_shopfloor_tech_store`) — every client action checks it on mount and subscribes for changes.
## 7. Edge cases
| Case | Handling |
|---|---|
| No tech has set a PIN yet | Tile shows "PIN required" overlay. Tap tile → guided "you must set a PIN before using this tablet" → set-PIN flow → unlock. |
| Manager just reset a tech's PIN | Tile still shows; tap → "PIN was cleared by a manager — set a new one" → set-PIN flow → unlock. |
| Tablet boots with no station paired | Tile grid shows + a "Pair this station" CTA. Station QR scan works before any tech is logged in. |
| Network drop mid-unlock | Spinner + Retry button after 5s. Backend tolerates duplicate unlocks (idempotent on success — counter just stays at 0). |
| Tech mid-step when tablet locks | Step timer keeps running on server. Auto-pause cron (Phase 2) is the upper-bound safety net. |
| Tech A's PIN locked for 5 min — can tech B unlock? | Yes. Lockout is per-user, not per-tablet. |
| Tech keeps tablet active by setting a heavy weight on it | Activity = pointer/touch/key events only, not mouse-move. A weight doesn't fire those events. Still locks after 5 min. |
| Tech is mid-RPC when lock fires | RPC completes (server keeps running). Response is dropped silently — UI is already showing the tile grid. |
| Two tabs / windows on the same browser | Each tab has its own FpTabletLock state. They lock independently. Acceptable for v1; not a real shop scenario. |
| Manager wants to act AS a tech | Out of scope. Manager unlocks with their own PIN; their actions carry their own uid. |
## 8. Testing
### 8.1 Python tests (`fusion_plating_shopfloor/tests/test_tablet_pin.py`)
| Test | Verifies |
|---|---|
| `test_set_pin_first_time` | User with no hash can set PIN; resulting hash is salted and length > 32. |
| `test_set_pin_change_requires_old` | Setting a new PIN when one exists requires correct old_pin; wrong old_pin rejected. |
| `test_unlock_correct_pin_resets_failure_count` | Failed → failed → correct → counter is 0. |
| `test_unlock_5_wrong_locks_user` | 5 wrong attempts → 6th returns `locked_until`. 7th still rejected. |
| `test_lockout_expires_after_threshold` | After 5 min sim time elapsed → next attempt allowed again. |
| `test_reset_pin_for_requires_manager` | Operator → AccessError. Supervisor → AccessError. Manager → success. |
| `test_reset_pin_clears_hash_and_posts_chatter` | After reset: hash is False, set_date is False, chatter has "PIN reset by Manager X". |
| `test_tiles_filtered_by_station_roster` | Station with authorised_user_ids → tiles is subset. Empty list → all operator-group users. |
| `test_audit_kwarg_used_in_step_finish` | RPC with `tablet_tech_id=N` → step's `write_uid == N` (not env.uid). |
| `test_audit_kwarg_invalid_falls_back_to_session` | Invalid `tablet_tech_id` → write_uid == env.uid, no error. |
### 8.2 Manual QA
`docs/qa/2026-05-22-shopfloor-pin-gate-qa.md` walkthrough:
1. Tech A sets PIN via Preferences
2. Tech A unlocks tablet → starts a step
3. 5 min idle elapses → tablet locks
4. Tech B unlocks → finishes Tech A's step
5. Audit chatter shows: started by A at T+0, finished by B at T+6
6. Manager taps Reset PIN on Tech A's res.users form
7. Tech A unlocks → set-PIN flow
8. Tech A fails PIN 5 times → lockout kicks in
9. Tech A waits 5 min → unlocks successfully
## 9. Build sequence (3 sub-phases)
Each ships independently and can be rolled back independently.
| Sub-phase | Ships | Independently deployable? |
|---|---|---|
| **6.1 — Backend** | model fields on res.users + station extras + ir.config_parameter defaults + 5 `/fp/tablet/*` endpoints + Profile prefs Set/Change PIN button + Manager Reset PIN button on res.users form | Yes — works silently behind the scenes. Techs can set PINs but the lock screen doesn't render yet. |
| **6.2 — Frontend lock screen** | FpTabletLock wrapper + FpPinPad + FpIdleWarning + activity tracker service + Hand-Off button injection into existing headers | Yes — lock screen goes live. Audit credit still defaults to tablet session user without 6.3. |
| **6.3 — Audit propagation** | `tablet_tech_id` optional kwarg on all existing action endpoints + `fpRpc()` wrapper + Landing/Workspace/Manager updated to use it | Yes — refines the audit trail. Without it, actions are recorded against the tablet's session uid. |
## 10. Backwards compatibility
- Any tablet that hasn't been upgraded to Phase 6.2 continues to work unauthenticated (no lock screen). Once 6.2 lands, ALL tablets start showing the lock screen.
- Endpoints from Phases 1-5 keep their existing signatures. `tablet_tech_id` is purely additive.
- Setting / changing the PIN is opt-in per user. A tech without a PIN sees a "set one to continue" prompt; they can't dismiss it.
- No model migration required — all new fields default to NULL.
- `ir.config_parameter` defaults are read at runtime, no install-time setup needed.
## 11. Rollback strategy
| Sub-phase | Rollback |
|---|---|
| 6.1 | Disable endpoints in `controllers/__init__.py`. Model fields are additive, safe to drop. |
| 6.2 | Hide `FpTabletLock` via a feature flag (`ir.config_parameter` `fp.shopfloor.tablet_lock_enabled`, default true; set false to bypass). Existing client actions render directly again. |
| 6.3 | Stop sending `tablet_tech_id` from `fpRpc()` — server falls back to `env.uid`. |
## 12. Out of scope for v1
- Biometric (Face ID, fingerprint)
- NFC badges
- TOTP / SMS / email-based reset
- "Remember me" cross-device sessions
- Per-tech idle threshold (only per-station + global)
- Lock-screen widgets (weather, time, KPIs) — keep the tile grid focused
- Camera-based presence / liveness
- Pre-fetched tile grid (each unlock call fetches fresh)
- Different PIN lengths per tech (4 digits for everyone)
## 13. Decisions log
| Decision | Rationale |
|---|---|
| 4-digit PIN over 6-digit | Speed. Industry norm. Lockout + per-user-fail-counter makes 10,000 combos secure enough. |
| PBKDF2-SHA256, 200k iterations | ~50ms verify on entech hardware. Safe against rainbow tables; brute-force-resistant even with DB stolen. |
| Hash field is manager-readable only | Operators can't even view other users' hash. Reduces lateral attack surface. |
| Per-user lockout, not per-tablet | A bad-actor wrong-PIN'ing one user shouldn't deny service to other techs on the same tablet. |
| 5 minute idle default | Compromise: long enough for legitimate idle-watching of a tank, short enough that a walk-away is caught. Configurable per-station. |
| Server-side step timer keeps running on lock | Locking is UI; nothing should pause physical processes. Auto-pause cron is the deeper safety net. |
| Single Odoo session, PIN overlay credits via `tablet_tech_id` kwarg | No JS bundle reload, no state loss, no flicker. Audit kwarg keeps the trail honest. |
| Manager-only reset (no self-service) | Plating shops rarely have per-tech email/SMS. Manager is always present. Lower infra. |
| 30s warning before lock | Compromise: catches "I was right there" cases without being annoyingly chatty. |
| `tablet_tech_id` is opt-in additive kwarg | Lets 6.3 ship after 6.2 without breaking anything; lets older callers continue working unchanged. |
## 14. Future v2 candidates
- NFC badge tap (cheap USB readers, ~$30)
- Personal QR badge on lanyard (no hardware beyond what we already have)
- Per-tech idle threshold (long-shift senior techs vs cross-trained probationers)
- Lock-screen KPIs (shop output today, hot WOs visible without unlocking)
- "Switch tech without re-PIN" — keep both signed in for hand-off audit on the same step
- Mobile app companion with biometric unlock
---
**Next step:** user reviews this spec. Once approved, transition to `superpowers:writing-plans` to produce the phased implementation plan.

View File

@@ -44,8 +44,6 @@
<field name="code">model._cron_autopause_stale_steps()</field> <field name="code">model._cron_autopause_stale_steps()</field>
<field name="interval_number">30</field> <field name="interval_number">30</field>
<field name="interval_type">minutes</field> <field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="active" eval="True"/> <field name="active" eval="True"/>
</record> </record>
</odoo> </odoo>

View File

@@ -1200,6 +1200,33 @@ class FpJobStep(models.Model):
# quick-look modal. The modal is bound via context= on the parent # quick-look modal. The modal is bound via context= on the parent
# job form's <field name="step_ids"/> — no TransientModel needed. # job form's <field name="step_ids"/> — no TransientModel needed.
# Job-level context for the quick-look modal — restored after commit
# b0070afc accidentally removed these while still referencing them in
# fp_job_step_quick_look_views.xml (entech caught the mismatch during
# the 2026-05-22 Phase 1-4 deploy).
quick_look_partner_id = fields.Many2one(
'res.partner',
string='Customer',
related='job_id.partner_id',
readonly=True,
)
quick_look_part_catalog_id = fields.Many2one(
'fp.part.catalog',
string='Part',
related='job_id.part_catalog_id',
readonly=True,
)
quick_look_qty = fields.Float(
string='Order Qty',
related='job_id.qty',
readonly=True,
)
quick_look_instruction_attachment_ids = fields.Many2many(
'ir.attachment',
string='Instruction Images',
related='recipe_node_id.instruction_attachment_ids',
readonly=True,
)
quick_look_instructions = fields.Html( quick_look_instructions = fields.Html(
string='Operator Instructions', string='Operator Instructions',
related='recipe_node_id.description', related='recipe_node_id.description',

View File

@@ -9,20 +9,26 @@
site that needs to bring legacy menus back can simply add a site that needs to bring legacy menus back can simply add a
user to the group. --> user to the group. -->
<!-- Reset group_ids on the 3 shopfloor menus that used to be <!-- Reset group_ids on the shopfloor menus that used to be
hidden — they are now the canonical UIs and should be visible hidden — they are now the canonical UIs and should be visible
to all users (subject to the original groups= attribute on to all users (subject to the original groups= attribute on
each menuitem in fusion_plating_shopfloor/views/fp_menu.xml). --> each menuitem in fusion_plating_shopfloor/views/fp_menu.xml). -->
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_manager" model="ir.ui.menu"> <record id="fusion_plating_shopfloor.menu_fp_shopfloor_manager" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [ref('fusion_plating.group_fusion_plating_manager')])]"/> <field name="group_ids" eval="[(6, 0, [ref('fusion_plating.group_fusion_plating_manager')])]"/>
</record> </record>
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [])]"/>
</record>
<record id="fusion_plating_shopfloor.menu_fp_shopfloor_tablet" model="ir.ui.menu"> <record id="fusion_plating_shopfloor.menu_fp_shopfloor_tablet" model="ir.ui.menu">
<field name="group_ids" eval="[(6, 0, [])]"/> <field name="group_ids" eval="[(6, 0, [])]"/>
</record> </record>
<!-- Phase 3 tablet redesign (2026-05-22) — the standalone Plant
Overview menu is superseded by Workstation > All Plant toggle.
The fp_menu.xml record was removed but the database row
persists (Odoo doesn't auto-delete orphan records). Force-
delete here so the menu disappears from the Shop Floor tree.
The action record (action_fp_plant_overview) is kept and
retargeted to fp_shopfloor_landing for bookmark back-compat. -->
<delete model="ir.ui.menu" id="fusion_plating_shopfloor.menu_fp_shopfloor_plant_overview"/>
<!-- bridge_mrp Production Priorities reference removed post-Sub 11 <!-- bridge_mrp Production Priorities reference removed post-Sub 11
(the bridge module is uninstalled and its menu xmlid no longer (the bridge module is uninstalled and its menu xmlid no longer
resolves). fp.job has its own priority field on the header. --> resolves). fp.job has its own priority field on the header. -->

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Plating — Shop Floor', 'name': 'Fusion Plating — Shop Floor',
'version': '19.0.29.0.0', 'version': '19.0.30.2.0',
'category': 'Manufacturing/Plating', 'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, ' 'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.', 'first-piece inspection gates.',
@@ -45,7 +45,9 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/fp_sequence_data.xml', 'data/fp_sequence_data.xml',
'data/fp_cron_data.xml', 'data/fp_cron_data.xml',
'data/fp_tablet_config_data.xml',
'views/fp_shopfloor_station_views.xml', 'views/fp_shopfloor_station_views.xml',
'views/res_users_views.xml',
'views/fp_bake_oven_views.xml', 'views/fp_bake_oven_views.xml',
'views/fp_bake_window_views.xml', 'views/fp_bake_window_views.xml',
'views/fp_first_piece_gate_views.xml', 'views/fp_first_piece_gate_views.xml',
@@ -80,6 +82,24 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss', 'fusion_plating_shopfloor/static/src/scss/components/_kanban_card.scss',
'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml', 'fusion_plating_shopfloor/static/src/xml/components/kanban_card.xml',
'fusion_plating_shopfloor/static/src/js/components/kanban_card.js', 'fusion_plating_shopfloor/static/src/js/components/kanban_card.js',
# ---- Phase 6.2 tablet PIN gate ----
'fusion_plating_shopfloor/static/src/js/services/tech_store.js',
'fusion_plating_shopfloor/static/src/js/services/activity_tracker.js',
# Phase 6.3 — fpRpc wrapper. MUST load before any consumer
# (job_workspace, shopfloor_landing, manager_dashboard,
# hold_composer) so `import { fpRpc }` resolves.
'fusion_plating_shopfloor/static/src/js/services/fp_rpc.js',
'fusion_plating_shopfloor/static/src/scss/components/_pin_pad.scss',
'fusion_plating_shopfloor/static/src/xml/components/pin_pad.xml',
'fusion_plating_shopfloor/static/src/js/components/pin_pad.js',
'fusion_plating_shopfloor/static/src/scss/components/_idle_warning.scss',
'fusion_plating_shopfloor/static/src/xml/components/idle_warning.xml',
'fusion_plating_shopfloor/static/src/js/components/idle_warning.js',
'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss',
'fusion_plating_shopfloor/static/src/xml/tablet_lock.xml',
'fusion_plating_shopfloor/static/src/js/tablet_lock.js',
'fusion_plating_shopfloor/static/src/xml/components/pin_setup.xml',
'fusion_plating_shopfloor/static/src/js/components/pin_setup.js',
# ---- Job Workspace (Phase 1 — tablet redesign) ---- # ---- Job Workspace (Phase 1 — tablet redesign) ----
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss', 'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml', 'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',

View File

@@ -8,3 +8,4 @@ from . import tank_status
from . import move_controller from . import move_controller
from . import workspace_controller from . import workspace_controller
from . import landing_controller from . import landing_controller
from . import tablet_controller

View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Helper for audit-credit propagation (Phase 6.3 tablet redesign).
Controllers that accept an optional `tablet_tech_id` kwarg use this
helper to switch their `env` to the tech-of-record before performing
writes. The result: chatter posts + create_uid/write_uid carry the
unlocked tech's identity, not the tablet's persistent session user.
"""
import logging
_logger = logging.getLogger(__name__)
def env_for_tablet_tech(env, tablet_tech_id):
"""Return an env scoped to `tablet_tech_id` if it's a valid user;
otherwise return the original env unchanged.
Validation: the user must exist and be active. We deliberately do
NOT cross-check that they actually unlocked recently — the OWL
component is the source of truth for "who's at the tablet right
now", and the only path that produces a tablet_tech_id is a
successful /fp/tablet/unlock followed by an active session in the
OWL tech_store.
"""
if not tablet_tech_id:
return env
try:
tech_id = int(tablet_tech_id)
except (TypeError, ValueError):
return env
User = env['res.users'].sudo()
tech = User.browse(tech_id)
if not tech.exists() or not tech.active:
_logger.warning(
"tablet_tech_id %s invalid (not found or inactive); "
"falling back to session uid %s",
tablet_tech_id, env.uid,
)
return env
return env(user=tech_id)

View File

@@ -21,10 +21,12 @@ import logging
from markupsafe import Markup from markupsafe import Markup
from odoo import http from odoo import fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_format from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.http import request from odoo.http import request
from ._tablet_audit import env_for_tablet_tech
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -387,7 +389,8 @@ class FpManagerDashboardController(http.Controller):
# Assign a worker to a step # Assign a worker to a step
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user') @http.route('/fp/manager/assign_worker', type='jsonrpc', auth='user')
def assign_worker(self, step_id=None, user_id=None, workorder_id=None, **kwargs): def assign_worker(self, step_id=None, user_id=None, workorder_id=None,
tablet_tech_id=None, **kwargs):
"""Assign an operator to a step. ``step_id`` is the canonical """Assign an operator to a step. ``step_id`` is the canonical
kwarg; ``workorder_id`` is accepted as a deprecated alias for kwarg; ``workorder_id`` is accepted as a deprecated alias for
one release so any caller we missed doesn't break. one release so any caller we missed doesn't break.
@@ -400,7 +403,8 @@ class FpManagerDashboardController(http.Controller):
step_id = workorder_id step_id = workorder_id
if not step_id: if not step_id:
return {'ok': False, 'error': 'step_id required'} return {'ok': False, 'error': 'step_id required'}
step = request.env['fp.job.step'].browse(int(step_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = env['fp.job.step'].browse(int(step_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': 'Step not found.'} return {'ok': False, 'error': 'Step not found.'}
step.assigned_user_id = int(user_id) if user_id else False step.assigned_user_id = int(user_id) if user_id else False
@@ -415,7 +419,8 @@ class FpManagerDashboardController(http.Controller):
# Reassign or swap tank on a step # Reassign or swap tank on a step
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user') @http.route('/fp/manager/assign_tank', type='jsonrpc', auth='user')
def assign_tank(self, step_id=None, tank_id=None, workorder_id=None, **kwargs): def assign_tank(self, step_id=None, tank_id=None, workorder_id=None,
tablet_tech_id=None, **kwargs):
"""Swap the tank on a step. ``step_id`` is the canonical kwarg; """Swap the tank on a step. ``step_id`` is the canonical kwarg;
``workorder_id`` is accepted as a deprecated alias. ``workorder_id`` is accepted as a deprecated alias.
""" """
@@ -427,7 +432,8 @@ class FpManagerDashboardController(http.Controller):
step_id = workorder_id step_id = workorder_id
if not step_id: if not step_id:
return {'ok': False, 'error': 'step_id required'} return {'ok': False, 'error': 'step_id required'}
step = request.env['fp.job.step'].browse(int(step_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = env['fp.job.step'].browse(int(step_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': 'Step not found.'} return {'ok': False, 'error': 'Step not found.'}
step.tank_id = int(tank_id) if tank_id else False step.tank_id = int(tank_id) if tank_id else False
@@ -442,7 +448,8 @@ class FpManagerDashboardController(http.Controller):
# Manager takes over a step (no-show coverage) # Manager takes over a step (no-show coverage)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@http.route('/fp/manager/take_over', type='jsonrpc', auth='user') @http.route('/fp/manager/take_over', type='jsonrpc', auth='user')
def take_over(self, step_id=None, workorder_id=None, **kwargs): def take_over(self, step_id=None, workorder_id=None,
tablet_tech_id=None, **kwargs):
"""Manager takes over a step. ``step_id`` is the canonical kwarg; """Manager takes over a step. ``step_id`` is the canonical kwarg;
``workorder_id`` is accepted as a deprecated alias. ``workorder_id`` is accepted as a deprecated alias.
""" """
@@ -454,10 +461,11 @@ class FpManagerDashboardController(http.Controller):
step_id = workorder_id step_id = workorder_id
if not step_id: if not step_id:
return {'ok': False, 'error': 'step_id required'} return {'ok': False, 'error': 'step_id required'}
step = request.env['fp.job.step'].browse(int(step_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = env['fp.job.step'].browse(int(step_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': 'Step not found.'} return {'ok': False, 'error': 'Step not found.'}
user = request.env.user user = env.user
previous = step.assigned_user_id.name or '' previous = step.assigned_user_id.name or ''
step.assigned_user_id = user.id step.assigned_user_id = user.id
step.message_post( step.message_post(

View File

@@ -20,6 +20,8 @@ from odoo.addons.fusion_plating.models.fp_tz import (
from odoo.exceptions import UserError from odoo.exceptions import UserError
from odoo.http import request from odoo.http import request
from ._tablet_audit import env_for_tablet_tech
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -255,11 +257,13 @@ class FpShopfloorController(http.Controller):
# Quick chemistry log from the tablet # Quick chemistry log from the tablet
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@http.route('/fp/shopfloor/log_chemistry', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/log_chemistry', type='jsonrpc', auth='user')
def log_chemistry(self, bath_id, readings, shift=None, notes=None): def log_chemistry(self, bath_id, readings, shift=None, notes=None,
tablet_tech_id=None):
"""Create a fusion.plating.bath.log with one line per reading.""" """Create a fusion.plating.bath.log with one line per reading."""
env = env_for_tablet_tech(request.env, tablet_tech_id)
if not bath_id: if not bath_id:
raise UserError("bath_id required") raise UserError("bath_id required")
bath = request.env['fusion.plating.bath'].browse(int(bath_id)) bath = env['fusion.plating.bath'].browse(int(bath_id))
if not bath.exists(): if not bath.exists():
raise UserError(f"Bath {bath_id} not found") raise UserError(f"Bath {bath_id} not found")
@@ -274,7 +278,7 @@ class FpShopfloorController(http.Controller):
'value': float(value) if value not in (None, '') else 0.0, 'value': float(value) if value not in (None, '') else 0.0,
})) }))
log = request.env['fusion.plating.bath.log'].create({ log = env['fusion.plating.bath.log'].create({
'bath_id': bath.id, 'bath_id': bath.id,
'shift': shift or False, 'shift': shift or False,
'notes': notes or False, 'notes': notes or False,
@@ -291,10 +295,11 @@ class FpShopfloorController(http.Controller):
# Bake window controls # Bake window controls
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@http.route('/fp/shopfloor/start_bake', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/start_bake', type='jsonrpc', auth='user')
def start_bake(self, bake_window_id, oven_id=None): def start_bake(self, bake_window_id, oven_id=None, tablet_tech_id=None):
# action_start_bake raises UserError for S6 missed_window. Wrap # action_start_bake raises UserError for S6 missed_window. Wrap
# the same way as start_wo so operator gets a clean flash. # the same way as start_wo so operator gets a clean flash.
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
bw = env['fusion.plating.bake.window'].browse(int(bake_window_id))
if not bw.exists(): if not bw.exists():
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'} return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
if oven_id: if oven_id:
@@ -306,12 +311,13 @@ class FpShopfloorController(http.Controller):
return { return {
'ok': True, 'ok': True,
'state': bw.state, 'state': bw.state,
'bake_start_time': fp_format(request.env, bw.bake_start_time), 'bake_start_time': fp_format(env, bw.bake_start_time),
} }
@http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/end_bake', type='jsonrpc', auth='user')
def end_bake(self, bake_window_id): def end_bake(self, bake_window_id, tablet_tech_id=None):
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
bw = env['fusion.plating.bake.window'].browse(int(bake_window_id))
if not bw.exists(): if not bw.exists():
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'} return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
try: try:
@@ -321,7 +327,7 @@ class FpShopfloorController(http.Controller):
return { return {
'ok': True, 'ok': True,
'state': bw.state, 'state': bw.state,
'bake_end_time': fp_format(request.env, bw.bake_end_time), 'bake_end_time': fp_format(env, bw.bake_end_time),
'bake_duration_hours': bw.bake_duration_hours, 'bake_duration_hours': bw.bake_duration_hours,
} }
@@ -340,8 +346,15 @@ class FpShopfloorController(http.Controller):
step = request.env['fp.job.step'].browse(int(sid)) step = request.env['fp.job.step'].browse(int(sid))
return step if step.exists() else False return step if step.exists() else False
def _resolve_step_in_env(self, env, step_id=None, workorder_id=None):
sid = step_id if step_id else workorder_id
if not sid:
return False
step = env['fp.job.step'].browse(int(sid))
return step if step.exists() else False
@http.route('/fp/shopfloor/start_wo', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/start_wo', type='jsonrpc', auth='user')
def start_wo(self, workorder_id=None, step_id=None): def start_wo(self, workorder_id=None, step_id=None, tablet_tech_id=None):
"""Start the timer on a fp.job.step (called from the tablet). """Start the timer on a fp.job.step (called from the tablet).
button_start() can raise UserError for any guarded condition button_start() can raise UserError for any guarded condition
@@ -350,7 +363,8 @@ class FpShopfloorController(http.Controller):
the explicit state check, so the tablet flashes a clean toast the explicit state check, so the tablet flashes a clean toast
instead of popping a stack-trace dialog at the operator. instead of popping a stack-trace dialog at the operator.
""" """
step = self._resolve_step(step_id, workorder_id) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = self._resolve_step_in_env(env, step_id, workorder_id)
if not step: if not step:
return {'ok': False, 'error': 'Step not found'} return {'ok': False, 'error': 'Step not found'}
if not _step_can_start(step): if not _step_can_start(step):
@@ -369,7 +383,8 @@ class FpShopfloorController(http.Controller):
} }
@http.route('/fp/shopfloor/stop_wo', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/stop_wo', type='jsonrpc', auth='user')
def stop_wo(self, workorder_id=None, step_id=None, finish=False): def stop_wo(self, workorder_id=None, step_id=None, finish=False,
tablet_tech_id=None):
"""Finish the timer on a fp.job.step. """Finish the timer on a fp.job.step.
finish=True calls button_finish(); other values are no-ops for finish=True calls button_finish(); other values are no-ops for
@@ -380,7 +395,8 @@ class FpShopfloorController(http.Controller):
not provided). Wrapped same as start_wo so the operator gets a not provided). Wrapped same as start_wo so the operator gets a
clean flash, not a stack-trace dialog. clean flash, not a stack-trace dialog.
""" """
step = self._resolve_step(step_id, workorder_id) env = env_for_tablet_tech(request.env, tablet_tech_id)
step = self._resolve_step_in_env(env, step_id, workorder_id)
if not step: if not step:
return {'ok': False, 'error': 'Step not found'} return {'ok': False, 'error': 'Step not found'}
if finish: if finish:
@@ -409,11 +425,12 @@ class FpShopfloorController(http.Controller):
# both with a single tap. Scrap auto-spawns a hold via fp.job.write # both with a single tap. Scrap auto-spawns a hold via fp.job.write
# (S17 hook, no extra wiring needed here). # (S17 hook, no extra wiring needed here).
@http.route('/fp/shopfloor/bump_qty_done', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/bump_qty_done', type='jsonrpc', auth='user')
def bump_qty_done(self, job_id, delta=1): def bump_qty_done(self, job_id, delta=1, tablet_tech_id=None):
"""Increment job.qty_done by `delta` (defaults to +1). """Increment job.qty_done by `delta` (defaults to +1).
Returns the new totals so the tablet can update without a full refresh. Returns the new totals so the tablet can update without a full refresh.
""" """
job = request.env['fp.job'].browse(int(job_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': 'Job not found'} return {'ok': False, 'error': 'Job not found'}
try: try:
@@ -433,13 +450,15 @@ class FpShopfloorController(http.Controller):
} }
@http.route('/fp/shopfloor/bump_qty_scrapped', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/bump_qty_scrapped', type='jsonrpc', auth='user')
def bump_qty_scrapped(self, job_id, delta=1, reason=None): def bump_qty_scrapped(self, job_id, delta=1, reason=None,
tablet_tech_id=None):
"""Increment job.qty_scrapped by `delta`. The S17 write-hook on """Increment job.qty_scrapped by `delta`. The S17 write-hook on
fp.job auto-spawns a fusion.plating.quality.hold for the delta; fp.job auto-spawns a fusion.plating.quality.hold for the delta;
the operator can edit the description on that hold later. the operator can edit the description on that hold later.
`reason` is optional — passed through to the hold's description. `reason` is optional — passed through to the hold's description.
""" """
job = request.env['fp.job'].browse(int(job_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': 'Job not found'} return {'ok': False, 'error': 'Job not found'}
try: try:
@@ -470,20 +489,22 @@ class FpShopfloorController(http.Controller):
position_label=None, reading_number=None, position_label=None, reading_number=None,
equipment_model=None, calibration_std_ref=None, equipment_model=None, calibration_std_ref=None,
microscope_image=None, microscope_image=None,
microscope_image_filename=None): microscope_image_filename=None,
tablet_tech_id=None):
"""Record a single Fischerscope reading against a job. """Record a single Fischerscope reading against a job.
`job_id` is the canonical kwarg; `production_id` is accepted as an `job_id` is the canonical kwarg; `production_id` is accepted as an
alias for older clients. The reading auto-links to an existing alias for older clients. The reading auto-links to an existing
CoC certificate for the job when one exists. CoC certificate for the job when one exists.
""" """
Reading = request.env.get('fp.thickness.reading') env = env_for_tablet_tech(request.env, tablet_tech_id)
Reading = env.get('fp.thickness.reading')
if Reading is None: if Reading is None:
return {'ok': False, 'error': 'Certificates module not installed'} return {'ok': False, 'error': 'Certificates module not installed'}
target_id = job_id or production_id target_id = job_id or production_id
if not target_id: if not target_id:
return {'ok': False, 'error': 'job_id required'} return {'ok': False, 'error': 'job_id required'}
job = request.env['fp.job'].browse(int(target_id)) job = env['fp.job'].browse(int(target_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': f'Job {target_id} not found'} return {'ok': False, 'error': f'Job {target_id} not found'}
@@ -508,7 +529,7 @@ class FpShopfloorController(http.Controller):
'ni_percent': float(ni_percent or 0.0), 'ni_percent': float(ni_percent or 0.0),
'p_percent': float(p_percent or 0.0), 'p_percent': float(p_percent or 0.0),
'position_label': position_label or '', 'position_label': position_label or '',
'operator_id': request.env.user.id, 'operator_id': env.user.id,
} }
if equipment_model: if equipment_model:
@@ -516,7 +537,7 @@ class FpShopfloorController(http.Controller):
if calibration_std_ref: if calibration_std_ref:
vals['calibration_std_ref'] = calibration_std_ref vals['calibration_std_ref'] = calibration_std_ref
if microscope_image: if microscope_image:
att = request.env['ir.attachment'].create({ att = env['ir.attachment'].create({
'name': microscope_image_filename or f'thickness_{reading_number}.jpg', 'name': microscope_image_filename or f'thickness_{reading_number}.jpg',
'datas': microscope_image, 'datas': microscope_image,
'res_model': 'fp.thickness.reading', 'res_model': 'fp.thickness.reading',
@@ -525,7 +546,7 @@ class FpShopfloorController(http.Controller):
vals['microscope_image_id'] = att.id vals['microscope_image_id'] = att.id
# Auto-link to an existing CoC if there is one for this job. # Auto-link to an existing CoC if there is one for this job.
Cert = request.env.get('fp.certificate') Cert = env.get('fp.certificate')
if Cert is not None: if Cert is not None:
if 'x_fc_job_id' in Cert._fields: if 'x_fc_job_id' in Cert._fields:
cert_field = 'x_fc_job_id' cert_field = 'x_fc_job_id'
@@ -557,7 +578,8 @@ class FpShopfloorController(http.Controller):
part_ref=None, qty_on_hold=0, qty_original=0, part_ref=None, qty_on_hold=0, qty_original=0,
hold_reason='other', description=None, hold_reason='other', description=None,
mark_for_scrap=False, facility_id=None, mark_for_scrap=False, facility_id=None,
work_center_id=None, current_process_node=None): work_center_id=None, current_process_node=None,
tablet_tech_id=None):
"""Create a quality hold record, splitting qty from the original lot. """Create a quality hold record, splitting qty from the original lot.
The hold is linked to the fp.job and (when provided) the The hold is linked to the fp.job and (when provided) the
@@ -566,7 +588,8 @@ class FpShopfloorController(http.Controller):
if not qty_on_hold or int(qty_on_hold) <= 0: if not qty_on_hold or int(qty_on_hold) <= 0:
raise UserError("qty_on_hold must be a positive integer.") raise UserError("qty_on_hold must be a positive integer.")
Hold = request.env['fusion.plating.quality.hold'] env = env_for_tablet_tech(request.env, tablet_tech_id)
Hold = env['fusion.plating.quality.hold']
vals = { vals = {
'part_ref': part_ref or '', 'part_ref': part_ref or '',
@@ -583,7 +606,7 @@ class FpShopfloorController(http.Controller):
if work_center_id: if work_center_id:
vals['work_center_id'] = int(work_center_id) vals['work_center_id'] = int(work_center_id)
if portal_job_id: if portal_job_id:
pj = request.env['fusion.plating.portal.job'].browse( pj = env['fusion.plating.portal.job'].browse(
int(portal_job_id), int(portal_job_id),
) )
if pj.exists(): if pj.exists():
@@ -594,7 +617,7 @@ class FpShopfloorController(http.Controller):
# via fusion_plating_jobs (Phase 3) as `x_fc_job_id` / `x_fc_step_id`. # via fusion_plating_jobs (Phase 3) as `x_fc_job_id` / `x_fc_step_id`.
step_target_id = step_id or workorder_id step_target_id = step_id or workorder_id
if step_target_id: if step_target_id:
step = request.env['fp.job.step'].browse(int(step_target_id)) step = env['fp.job.step'].browse(int(step_target_id))
if step.exists(): if step.exists():
if 'x_fc_step_id' in Hold._fields: if 'x_fc_step_id' in Hold._fields:
vals['x_fc_step_id'] = step.id vals['x_fc_step_id'] = step.id
@@ -605,7 +628,7 @@ class FpShopfloorController(http.Controller):
# set it through the step. # set it through the step.
if (job_id and 'x_fc_job_id' in Hold._fields if (job_id and 'x_fc_job_id' in Hold._fields
and not vals.get('x_fc_job_id')): and not vals.get('x_fc_job_id')):
j = request.env['fp.job'].browse(int(job_id)) j = env['fp.job'].browse(int(job_id))
if j.exists(): if j.exists():
vals['x_fc_job_id'] = j.id vals['x_fc_job_id'] = j.id
@@ -995,8 +1018,9 @@ class FpShopfloorController(http.Controller):
# Mark a first-piece gate result from the tablet # Mark a first-piece gate result from the tablet
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
@http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user') @http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user')
def mark_gate(self, gate_id, result): def mark_gate(self, gate_id, result, tablet_tech_id=None):
gate = request.env['fusion.plating.first.piece.gate'].browse(int(gate_id)) env = env_for_tablet_tech(request.env, tablet_tech_id)
gate = env['fusion.plating.first.piece.gate'].browse(int(gate_id))
if not gate.exists(): if not gate.exists():
return {'ok': False, 'error': 'Gate not found.'} return {'ok': False, 'error': 'Gate not found.'}
try: try:
@@ -1084,21 +1108,23 @@ class FpShopfloorController(http.Controller):
@http.route('/fp/shopfloor/plant_overview/move_card', @http.route('/fp/shopfloor/plant_overview/move_card',
type='jsonrpc', auth='user') type='jsonrpc', auth='user')
def plant_overview_move_card(self, card_id, source_model=None, def plant_overview_move_card(self, card_id, source_model=None,
target_workcenter_id=None): target_workcenter_id=None,
tablet_tech_id=None):
"""Move a step card to a different work centre (drag & drop). """Move a step card to a different work centre (drag & drop).
`source_model` is accepted for backward compatibility but ignored — `source_model` is accepted for backward compatibility but ignored —
Plant Overview now only ever serves fp.job.step cards. A target Plant Overview now only ever serves fp.job.step cards. A target
of 0 / falsy clears the work centre. of 0 / falsy clears the work centre.
""" """
Step = request.env['fp.job.step'] env = env_for_tablet_tech(request.env, tablet_tech_id)
Step = env['fp.job.step']
step = Step.browse(int(card_id)) step = Step.browse(int(card_id))
if not step.exists(): if not step.exists():
return {'ok': False, 'error': f'Step {card_id} not found.'} return {'ok': False, 'error': f'Step {card_id} not found.'}
wc_id = int(target_workcenter_id) if target_workcenter_id else False wc_id = int(target_workcenter_id) if target_workcenter_id else False
if wc_id: if wc_id:
wc = request.env['fp.work.centre'].browse(wc_id) wc = env['fp.work.centre'].browse(wc_id)
if not wc.exists(): if not wc.exists():
return {'ok': False, return {'ok': False,
'error': f'Work centre {target_workcenter_id} not found.'} 'error': f'Work centre {target_workcenter_id} not found.'}
@@ -1108,7 +1134,7 @@ class FpShopfloorController(http.Controller):
_logger.info( _logger.info(
'Plant Overview: moved step %s (%s) → WC %s by uid %s', 'Plant Overview: moved step %s (%s) → WC %s by uid %s',
step.id, step.name, wc_id or 'unassigned', step.id, step.name, wc_id or 'unassigned',
request.env.uid, env.uid,
) )
except Exception as exc: except Exception as exc:
_logger.exception('Plant Overview move_card failed') _logger.exception('Plant Overview move_card failed')

View File

@@ -0,0 +1,211 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""JSON-RPC endpoints for the tablet PIN gate (Phase 6 tablet redesign).
POST /fp/tablet/tiles — list of tiles for the lock screen
POST /fp/tablet/unlock — verify PIN + clear/increment failure counter
POST /fp/tablet/set_pin — self-service set/change PIN
POST /fp/tablet/reset_pin_for — manager-only reset of another user's PIN
POST /fp/tablet/ping — bump server-side last-active timestamp
Spec: docs/superpowers/specs/2026-05-22-shopfloor-pin-gate-design.md
"""
import logging
from datetime import timedelta
from odoo import _, fields, http
from odoo.exceptions import UserError
from odoo.http import request
_logger = logging.getLogger(__name__)
def _is_manager(env):
"""True if calling user is in the fusion_plating manager group."""
return env.user.has_group('fusion_plating.group_fusion_plating_manager')
class FpTabletController(http.Controller):
"""Tablet PIN gate endpoints. All require an authenticated Odoo
session (the tablet logs in once as a 'shopfloor service' user).
"""
# ======================================================================
# /fp/tablet/set_pin — self-service set or change
# ======================================================================
@http.route('/fp/tablet/set_pin', type='jsonrpc', auth='user')
def set_pin(self, new_pin, old_pin=None):
env = request.env
user = env.user
existing_hash = user.sudo().x_fc_tablet_pin_hash
if existing_hash:
if not old_pin:
return {'ok': False, 'error': _('Current PIN is required to change it.')}
if not user.verify_tablet_pin(old_pin):
return {'ok': False, 'error': _('Current PIN is incorrect.')}
try:
user.set_tablet_pin(new_pin)
except UserError as e:
return {'ok': False, 'error': str(e.args[0]) if e.args else str(e)}
_logger.info(
"Tablet PIN set/changed for uid %s by self", user.id,
)
return {'ok': True}
# ======================================================================
# /fp/tablet/reset_pin_for — manager-only
# ======================================================================
@http.route('/fp/tablet/reset_pin_for', type='jsonrpc', auth='user')
def reset_pin_for(self, user_id):
env = request.env
if not _is_manager(env):
_logger.warning(
"Non-manager uid %s attempted to reset PIN for user %s",
env.uid, user_id,
)
return {'ok': False, 'error': _('Manager privilege required.')}
target = env['res.users'].browse(int(user_id))
if not target.exists():
return {'ok': False, 'error': _('User not found.')}
target.clear_tablet_pin()
_logger.info(
"Tablet PIN reset for uid %s by manager uid %s",
target.id, env.uid,
)
return {'ok': True}
# ======================================================================
# /fp/tablet/unlock — verify PIN + manage failure counter / lockout
# ======================================================================
@http.route('/fp/tablet/unlock', type='jsonrpc', auth='user')
def unlock(self, user_id, pin):
env = request.env
Users = env['res.users'].sudo() # need sudo to read hash field
target = Users.browse(int(user_id))
if not target.exists():
return {'ok': False, 'error': _('User not found.')}
# No PIN set yet — caller must set one first
if not target.x_fc_tablet_pin_hash:
return {
'ok': False,
'error': _('No PIN set. Set one in Preferences first.'),
'needs_setup': True,
}
# Currently locked out?
now = fields.Datetime.now()
if target.x_fc_tablet_locked_until and target.x_fc_tablet_locked_until > now:
return {
'ok': False,
'error': _('Account locked. Try again in a few minutes.'),
'locked_until': target.x_fc_tablet_locked_until.isoformat(),
}
if target.verify_tablet_pin(pin):
# Reset failure state on success
target.write({
'x_fc_tablet_pin_failed_count': 0,
'x_fc_tablet_locked_until': False,
})
_logger.info(
"Tablet unlocked by uid %s (session uid %s)",
target.id, env.uid,
)
return {
'ok': True,
'current_tech_id': target.id,
'current_tech_name': target.name,
}
# Wrong PIN — increment and check threshold
new_count = (target.x_fc_tablet_pin_failed_count or 0) + 1
threshold = int(env['ir.config_parameter'].sudo().get_param(
'fp.shopfloor.tablet_pin_fail_threshold', 5,
))
lockout_min = int(env['ir.config_parameter'].sudo().get_param(
'fp.shopfloor.tablet_pin_fail_lockout_minutes', 5,
))
vals = {'x_fc_tablet_pin_failed_count': new_count}
if new_count >= threshold:
vals['x_fc_tablet_locked_until'] = now + timedelta(minutes=lockout_min)
target.write(vals)
_logger.warning(
"Tablet PIN failure for uid %s (count=%d, locked=%s)",
target.id, new_count, bool(vals.get('x_fc_tablet_locked_until')),
)
if vals.get('x_fc_tablet_locked_until'):
return {
'ok': False,
'error': _('Too many failed attempts. Locked for %d minutes.') % lockout_min,
'locked_until': vals['x_fc_tablet_locked_until'].isoformat(),
}
return {
'ok': False,
'error': _('Incorrect PIN.'),
'attempts_remaining': threshold - new_count,
}
# ======================================================================
# /fp/tablet/tiles — lock-screen tile grid
# ======================================================================
@http.route('/fp/tablet/tiles', type='jsonrpc', auth='user')
def tiles(self, station_id=None):
env = request.env
op_group = env.ref(
'fusion_plating.group_fusion_plating_operator',
raise_if_not_found=False,
)
if not op_group:
return {'ok': False, 'error': 'operator group missing'}
# Determine candidate users — station roster wins if non-empty
users = op_group.user_ids
if station_id:
Station = env['fusion.plating.shopfloor.station']
station = Station.browse(int(station_id))
if (station.exists()
and 'x_fc_authorised_user_ids' in station._fields
and station.x_fc_authorised_user_ids):
users = station.x_fc_authorised_user_ids
# has_pin needs sudo-read on the hash field
clocked_ids = set()
if 'hr.employee' in env and hasattr(
env['hr.employee'], '_fp_clocked_in_user_ids',
):
clocked_ids = env['hr.employee']._fp_clocked_in_user_ids() or set()
users_sorted = users.sorted('name')
users_sudo = users_sorted.sudo()
tiles = []
for u, u_sudo in zip(users_sorted, users_sudo):
tiles.append({
'user_id': u.id,
'name': u.name,
'avatar_url': f'/web/image/res.users/{u.id}/avatar_128',
'is_clocked_in': u.id in clocked_ids,
'has_pin': bool(u_sudo.x_fc_tablet_pin_hash),
})
# Clocked-in first, then alphabetical within bucket
tiles.sort(key=lambda t: (not t['is_clocked_in'], t['name']))
return {'ok': True, 'tiles': tiles}
# ======================================================================
# /fp/tablet/ping — heartbeat used by the OWL component on every action
# ======================================================================
@http.route('/fp/tablet/ping', type='jsonrpc', auth='user')
def ping(self, current_tech_id=None):
"""Lightweight heartbeat. Used by the OWL component to confirm
the server-side session is alive AND to log the tech-of-record
every few minutes so the server has forensic visibility into
which tech was 'driving' the tablet at any moment.
"""
if current_tech_id:
_logger.debug(
"Tablet ping: session uid %s carrying tablet_tech_id=%s",
request.env.uid, current_tech_id,
)
return {'ok': True, 'server_time': fields.Datetime.now().isoformat()}

View File

@@ -23,6 +23,8 @@ from odoo import fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_format from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.http import request from odoo.http import request
from ._tablet_audit import env_for_tablet_tech
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -190,8 +192,8 @@ class FpWorkspaceController(http.Controller):
@http.route('/fp/workspace/hold', type='jsonrpc', auth='user') @http.route('/fp/workspace/hold', type='jsonrpc', auth='user')
def hold(self, job_id, reason='other', qty_on_hold=1, description='', def hold(self, job_id, reason='other', qty_on_hold=1, description='',
part_ref='', step_id=None, mark_for_scrap=False, part_ref='', step_id=None, mark_for_scrap=False,
photo_data=None, photo_filename=None): photo_data=None, photo_filename=None, tablet_tech_id=None):
env = request.env env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id)) job = env['fp.job'].browse(int(job_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': f'Job {job_id} not found'} return {'ok': False, 'error': f'Job {job_id} not found'}
@@ -253,8 +255,8 @@ class FpWorkspaceController(http.Controller):
# /fp/workspace/sign_off — capture signature + finish step atomically # /fp/workspace/sign_off — capture signature + finish step atomically
# ====================================================================== # ======================================================================
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user') @http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
def sign_off(self, step_id, signature_data_uri): def sign_off(self, step_id, signature_data_uri, tablet_tech_id=None):
env = request.env env = env_for_tablet_tech(request.env, tablet_tech_id)
sig = (signature_data_uri or '').strip() sig = (signature_data_uri or '').strip()
if not sig: if not sig:
_logger.warning("workspace/sign_off: empty signature for step %s", step_id) _logger.warning("workspace/sign_off: empty signature for step %s", step_id)
@@ -302,8 +304,8 @@ class FpWorkspaceController(http.Controller):
# /fp/workspace/advance_milestone — fire next_milestone_action # /fp/workspace/advance_milestone — fire next_milestone_action
# ====================================================================== # ======================================================================
@http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user') @http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
def advance_milestone(self, job_id): def advance_milestone(self, job_id, tablet_tech_id=None):
env = request.env env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id)) job = env['fp.job'].browse(int(job_id))
if not job.exists(): if not job.exists():
return {'ok': False, 'error': f'Job {job_id} not found'} return {'ok': False, 'error': f'Job {job_id} not found'}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Phase 6 tablet PIN gate — default knobs.
All overridable via Settings → Technical → Parameters → System Parameters.
-->
<odoo noupdate="1">
<record id="ir_config_param_tablet_idle_lock_minutes" model="ir.config_parameter">
<field name="key">fp.shopfloor.tablet_idle_lock_minutes</field>
<field name="value">5</field>
</record>
<record id="ir_config_param_tablet_pin_fail_threshold" model="ir.config_parameter">
<field name="key">fp.shopfloor.tablet_pin_fail_threshold</field>
<field name="value">5</field>
</record>
<record id="ir_config_param_tablet_pin_fail_lockout_minutes" model="ir.config_parameter">
<field name="key">fp.shopfloor.tablet_pin_fail_lockout_minutes</field>
<field name="value">5</field>
</record>
<record id="ir_config_param_tablet_warn_seconds_before_lock" model="ir.config_parameter">
<field name="key">fp.shopfloor.tablet_warn_seconds_before_lock</field>
<field name="value">30</field>
</record>
</odoo>

View File

@@ -8,3 +8,4 @@ from . import fp_bake_window
from . import fp_first_piece_gate from . import fp_first_piece_gate
from . import fp_operator_queue from . import fp_operator_queue
from . import fp_tank from . import fp_tank
from . import res_users

View File

@@ -73,6 +73,26 @@ class FpShopfloorStation(models.Model):
string='Notes', string='Notes',
) )
# Phase 6 tablet PIN gate — per-station roster + idle override.
x_fc_authorised_user_ids = fields.Many2many(
'res.users',
relation='fp_shopfloor_station_authorised_user_rel',
column1='station_id',
column2='user_id',
string='Authorised Operators',
help='If set, the tablet lock screen only shows tiles for these '
'users. Empty = all operator-group users are shown. Use to '
'restrict a tablet at a specialised station (e.g. EN Plating) '
'to techs trained on that station.',
)
x_fc_idle_lock_minutes = fields.Integer(
string='Idle Lock (minutes)',
help='Per-station override for the auto-lock idle threshold. '
'Leave blank to use the global default '
'(ir.config_parameter fp.shopfloor.tablet_idle_lock_minutes, '
'default 5).',
)
_sql_constraints = [ _sql_constraints = [
( (
'fp_shopfloor_station_code_uniq', 'fp_shopfloor_station_code_uniq',

View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Tablet-PIN extensions on res.users (Phase 6 tablet redesign).
Adds the 4-digit PIN gate fields + helpers used by /fp/tablet/* endpoints
and the FpTabletLock OWL component. PIN is stored as a salted PBKDF2-SHA256
hash; never plaintext.
"""
import hashlib
import secrets
from odoo import _, fields, models
from odoo.exceptions import UserError
# PBKDF2 iteration count. ~50ms verify on entech-class hardware. Safe
# against brute-force even if the DB leaks.
_PBKDF2_ITERATIONS = 200_000
class ResUsers(models.Model):
_inherit = 'res.users'
x_fc_tablet_pin_hash = fields.Char(
string='Tablet PIN (hashed)',
groups='fusion_plating.group_fusion_plating_manager',
help='PBKDF2-SHA256 hash + salt of the user\'s 4-digit tablet '
'PIN. Format: <salt_hex>$<digest_hex>. Never readable to '
'non-managers; never logged.',
)
x_fc_tablet_pin_set_date = fields.Datetime(
string='Tablet PIN Set Date',
help='When the current PIN was last set or changed.',
)
x_fc_tablet_pin_failed_count = fields.Integer(
string='Failed PIN Attempts',
default=0,
help='Sequential failed unlock attempts since the last success. '
'Resets to 0 on a correct PIN.',
)
x_fc_tablet_locked_until = fields.Datetime(
string='Tablet Lockout Until',
help='Wall-clock time at which the per-user lockout expires. '
'Null when not locked. Set after the configured fail '
'threshold (default 5) is reached.',
)
@staticmethod
def _hash_tablet_pin(pin, salt=None):
"""Hash `pin` with optional salt. Returns "salt_hex$digest_hex"."""
if salt is None:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac(
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
)
return f"{salt.hex()}${digest.hex()}"
@staticmethod
def _verify_tablet_pin_hash(pin, stored):
"""Constant-time verify of `pin` against a stored hash string."""
if not stored or '$' not in stored:
return False
salt_hex, expected_hex = stored.split('$', 1)
try:
salt = bytes.fromhex(salt_hex)
except ValueError:
return False
digest = hashlib.pbkdf2_hmac(
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
)
return secrets.compare_digest(digest.hex(), expected_hex)
def set_tablet_pin(self, pin):
"""Set or change this user's tablet PIN. Requires sudo OR self.
Caller is responsible for verifying the OLD pin separately if a
hash already exists — this method just writes the new one.
"""
self.ensure_one()
if not pin or not pin.isdigit() or len(pin) != 4:
raise UserError(_('Tablet PIN must be exactly 4 digits.'))
self.sudo().write({
'x_fc_tablet_pin_hash': self._hash_tablet_pin(pin),
'x_fc_tablet_pin_set_date': fields.Datetime.now(),
'x_fc_tablet_pin_failed_count': 0,
'x_fc_tablet_locked_until': False,
})
return True
def verify_tablet_pin(self, pin):
"""Return True if `pin` matches this user's stored hash."""
self.ensure_one()
if not pin:
return False
# sudo: even non-manager callers may need to verify their OWN PIN.
# The hash field has manager-only read; sudo bypasses that.
return self._verify_tablet_pin_hash(pin, self.sudo().x_fc_tablet_pin_hash)
def clear_tablet_pin(self):
"""Manager-side reset. Clears hash so target must set a new PIN.
Posts to chatter for audit.
"""
self.ensure_one()
manager_name = self.env.user.name
self.sudo().write({
'x_fc_tablet_pin_hash': False,
'x_fc_tablet_pin_set_date': False,
'x_fc_tablet_pin_failed_count': 0,
'x_fc_tablet_locked_until': False,
})
self.message_post(
body=_('Tablet PIN reset by %s. User must set a new PIN '
'on next unlock attempt.') % manager_name,
)
return True
def action_open_tablet_pin_setup(self):
"""Trigger the FpPinSetup OWL modal from the Preferences form.
The Phase 6.2 OWL component intercepts this action tag.
"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_tablet_pin_setup',
'name': 'Set Tablet PIN',
'target': 'new',
}

View File

@@ -14,7 +14,7 @@
import { Component, useState } from "@odoo/owl"; import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog"; import { Dialog } from "@web/core/dialog/dialog";
import { rpc } from "@web/core/network/rpc"; import { fpRpc } from "../services/fp_rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
// Hold reasons kept here so the picker doesn't need a server roundtrip. // Hold reasons kept here so the picker doesn't need a server roundtrip.
@@ -75,7 +75,7 @@ export class FpHoldComposer extends Component {
} }
this.state.submitting = true; this.state.submitting = true;
try { try {
const res = await rpc("/fp/workspace/hold", { const res = await fpRpc("/fp/workspace/hold", {
job_id: this.props.jobId, job_id: this.props.jobId,
step_id: this.props.stepId || null, step_id: this.props.stepId || null,
part_ref: this.props.partRef || "", part_ref: this.props.partRef || "",

View File

@@ -0,0 +1,18 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpIdleWarning (shared OWL service)
//
// Yellow-border overlay + countdown toast shown during the last
// (default 30) seconds before auto-lock. Any pointer/touch event on
// the document elsewhere resets the activity tracker, which causes
// this component's parent (FpTabletLock) to hide the warning.
// =============================================================================
import { Component } from "@odoo/owl";
export class FpIdleWarning extends Component {
static template = "fusion_plating_shopfloor.IdleWarning";
static props = {
secondsRemaining: { type: Number },
};
}

View File

@@ -0,0 +1,72 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpPinPad (shared OWL service)
//
// Numeric 4-digit PIN pad. Auto-submits on the 4th digit via onSubmit
// callback. Used by FpTabletLock unlock flow AND FpPinSetup change flow.
//
// Props:
// onSubmit : (pin: string) => Promise<{ok: boolean, error?: string}>
// title : optional header text
// subtitle : optional smaller text
// onCancel : optional cancel callback (e.g. close modal)
// =============================================================================
import { Component, useState } from "@odoo/owl";
export class FpPinPad extends Component {
static template = "fusion_plating_shopfloor.PinPad";
static props = {
onSubmit: { type: Function },
title: { type: String, optional: true },
subtitle: { type: String, optional: true },
onCancel: { type: Function, optional: true },
};
setup() {
this.state = useState({
pin: "",
submitting: false,
error: "",
shake: false,
});
}
async _press(digit) {
if (this.state.submitting) return;
if (this.state.pin.length >= 4) return;
this.state.pin = this.state.pin + digit;
this.state.error = "";
if (this.state.pin.length === 4) {
await this._submit();
}
}
_clear() {
this.state.pin = "";
this.state.error = "";
}
async _submit() {
this.state.submitting = true;
try {
const result = await this.props.onSubmit(this.state.pin);
if (result && !result.ok) {
this.state.error = result.error || "Incorrect PIN";
this.state.shake = true;
setTimeout(() => { this.state.shake = false; }, 400);
this.state.pin = "";
}
} catch (err) {
this.state.error = err.message || String(err);
this.state.pin = "";
} finally {
this.state.submitting = false;
}
}
get dots() {
// Render 4 dot slots: filled if typed, empty otherwise
return [0, 1, 2, 3].map((i) => this.state.pin.length > i);
}
}

View File

@@ -0,0 +1,97 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpPinSetup (client action `fp_tablet_pin_setup`)
//
// Modal flow for setting OR changing the user's tablet PIN. Triggered
// from res.users preferences via action_open_tablet_pin_setup. Three
// stages: (1) old PIN (only if has_pin), (2) new PIN, (3) confirm new.
// =============================================================================
import { Component, useState, onMounted } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { user } from "@web/core/user";
import { FpPinPad } from "./pin_pad";
export class FpPinSetup extends Component {
static template = "fusion_plating_shopfloor.PinSetup";
static components = { FpPinPad };
static props = ["*"];
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.state = useState({
stage: "loading", // 'loading' | 'old' | 'new' | 'confirm' | 'done'
newPin: "",
hasExistingPin: false,
});
onMounted(() => this._init());
}
async _init() {
// Cheap probe: search_count on the user's own record filtered
// by pin_set_date. Non-manager users can read their own set_date
// (not the hash). If the count is 1, they have a PIN; 0 = no PIN.
try {
const has = await rpc("/web/dataset/call_kw", {
model: "res.users",
method: "search_count",
args: [[
["id", "=", user.userId],
["x_fc_tablet_pin_set_date", "!=", false],
]],
kwargs: {},
});
this.state.hasExistingPin = has > 0;
} catch (e) {
this.state.hasExistingPin = false;
}
this.state.stage = this.state.hasExistingPin ? "old" : "new";
}
async onOldPinSubmit(pin) {
// Stash for the final call; set_pin verifies it server-side
this._oldPin = pin;
this.state.stage = "new";
return { ok: true };
}
async onNewPinSubmit(pin) {
this.state.newPin = pin;
this.state.stage = "confirm";
return { ok: true };
}
async onConfirmPinSubmit(pin) {
if (pin !== this.state.newPin) {
return { ok: false, error: "PINs don't match. Try again." };
}
const params = { new_pin: this.state.newPin };
if (this._oldPin) params.old_pin = this._oldPin;
const res = await rpc("/fp/tablet/set_pin", params);
if (res && res.ok) {
this.notification.add("Tablet PIN updated.", { type: "success" });
this.state.stage = "done";
setTimeout(() => this._close(), 1500);
return { ok: true };
}
// Reset back to start on hard error so user can retry cleanly
this.notification.add((res && res.error) || "Failed to set PIN", { type: "danger" });
this._oldPin = null;
this.state.newPin = "";
this.state.stage = this.state.hasExistingPin ? "old" : "new";
return { ok: false, error: (res && res.error) || "Failed" };
}
_close() {
this.action.doAction({ type: "ir.actions.act_window_close" });
}
onCancel() {
this._close();
}
}
registry.category("actions").add("fp_tablet_pin_setup", FpPinSetup);

View File

@@ -20,21 +20,24 @@
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc"; import { rpc } from "@web/core/network/rpc";
import { fpRpc } from "./services/fp_rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { WorkflowChip } from "./components/workflow_chip"; import { WorkflowChip } from "./components/workflow_chip";
import { GateViz } from "./components/gate_viz"; import { GateViz } from "./components/gate_viz";
import { FpSignaturePad } from "./components/signature_pad"; import { FpSignaturePad } from "./components/signature_pad";
import { FpHoldComposer } from "./components/hold_composer"; import { FpHoldComposer } from "./components/hold_composer";
import { FpTabletLock } from "./tablet_lock";
export class FpJobWorkspace extends Component { export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace"; static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"]; static props = ["*"];
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer }; static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock };
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
this.action = useService("action"); this.action = useService("action");
this.dialog = useService("dialog"); this.dialog = useService("dialog");
this.techStore = useService("fp_shopfloor_tech_store");
this.state = useState({ this.state = useState({
data: null, data: null,
@@ -76,6 +79,11 @@ export class FpJobWorkspace extends Component {
this.action.doAction({ type: "ir.actions.act_window_close" }); this.action.doAction({ type: "ir.actions.act_window_close" });
} }
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
this.techStore.lock();
}
onJumpToBlocker({ model, id }) { onJumpToBlocker({ model, id }) {
// If the predecessor is in this same workspace, just scroll to it // If the predecessor is in this same workspace, just scroll to it
const inThisJob = (this.state.data.steps || []).find((s) => s.id === id); const inThisJob = (this.state.data.steps || []).find((s) => s.id === id);
@@ -109,7 +117,7 @@ export class FpJobWorkspace extends Component {
// ---- Step actions ------------------------------------------------------ // ---- Step actions ------------------------------------------------------
async onStartStep(stepId) { async onStartStep(stepId) {
try { try {
const res = await rpc("/fp/shopfloor/start_wo", { workorder_id: stepId }); const res = await fpRpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
if (res && res.ok) { if (res && res.ok) {
this.notification.add("Step started.", { type: "success" }); this.notification.add("Step started.", { type: "success" });
await this.refresh(); await this.refresh();
@@ -128,7 +136,7 @@ export class FpJobWorkspace extends Component {
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`, contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
onSubmit: async (dataUri) => { onSubmit: async (dataUri) => {
try { try {
const res = await rpc("/fp/workspace/sign_off", { const res = await fpRpc("/fp/workspace/sign_off", {
step_id: step.id, step_id: step.id,
signature_data_uri: dataUri, signature_data_uri: dataUri,
}); });
@@ -147,7 +155,7 @@ export class FpJobWorkspace extends Component {
} }
// Plain finish — no signature required // Plain finish — no signature required
try { try {
const res = await rpc("/fp/shopfloor/stop_wo", { const res = await fpRpc("/fp/shopfloor/stop_wo", {
workorder_id: step.id, finish: true, workorder_id: step.id, finish: true,
}); });
if (res && res.ok) { if (res && res.ok) {
@@ -194,7 +202,7 @@ export class FpJobWorkspace extends Component {
async onAdvanceMilestone() { async onAdvanceMilestone() {
try { try {
const res = await rpc("/fp/workspace/advance_milestone", { const res = await fpRpc("/fp/workspace/advance_milestone", {
job_id: this.state.jobId, job_id: this.state.jobId,
}); });
if (res && res.ok) { if (res && res.ok) {

View File

@@ -15,17 +15,20 @@
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc"; import { rpc } from "@web/core/network/rpc";
import { fpRpc } from "./services/fp_rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner"; import { QrScanner } from "./qr_scanner";
import { FpTabletLock } from "./tablet_lock";
export class ManagerDashboard extends Component { export class ManagerDashboard extends Component {
static template = "fusion_plating_shopfloor.ManagerDashboard"; static template = "fusion_plating_shopfloor.ManagerDashboard";
static props = ["*"]; static props = ["*"];
static components = { QrScanner }; static components = { QrScanner, FpTabletLock };
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
this.action = useService("action"); this.action = useService("action");
this.techStore = useService("fp_shopfloor_tech_store");
this.state = useState({ this.state = useState({
overview: null, overview: null,
@@ -148,6 +151,11 @@ export class ManagerDashboard extends Component {
this.state.mode = this.state.mode === "quick" ? "detailed" : "quick"; this.state.mode = this.state.mode === "quick" ? "detailed" : "quick";
} }
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
this.techStore.lock();
}
toggleCard(jobId) { toggleCard(jobId) {
this.state.expandedJobId = this.state.expandedJobId === jobId ? null : jobId; this.state.expandedJobId = this.state.expandedJobId === jobId ? null : jobId;
} }
@@ -201,7 +209,7 @@ export class ManagerDashboard extends Component {
async onAssignWorker(step, userIdRaw) { async onAssignWorker(step, userIdRaw) {
const userId = parseInt(userIdRaw) || null; const userId = parseInt(userIdRaw) || null;
try { try {
const res = await rpc("/fp/manager/assign_worker", { const res = await fpRpc("/fp/manager/assign_worker", {
step_id: step.id, user_id: userId, step_id: step.id, user_id: userId,
}); });
if (res && res.ok) { if (res && res.ok) {
@@ -219,7 +227,7 @@ export class ManagerDashboard extends Component {
async onAssignTank(step, tankIdRaw) { async onAssignTank(step, tankIdRaw) {
const tankId = parseInt(tankIdRaw) || null; const tankId = parseInt(tankIdRaw) || null;
try { try {
const res = await rpc("/fp/manager/assign_tank", { const res = await fpRpc("/fp/manager/assign_tank", {
step_id: step.id, tank_id: tankId, step_id: step.id, tank_id: tankId,
}); });
if (res && res.ok) { if (res && res.ok) {
@@ -236,7 +244,7 @@ export class ManagerDashboard extends Component {
async onTakeOver(step) { async onTakeOver(step) {
try { try {
const res = await rpc("/fp/manager/take_over", { const res = await fpRpc("/fp/manager/take_over", {
step_id: step.id, step_id: step.id,
}); });
if (res && res.ok) { if (res && res.ok) {

View File

@@ -0,0 +1,73 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Activity Tracker (shared OWL service)
//
// Watches the document for pointer/touch/keydown/visibility events and
// tracks lastActiveAt. FpTabletLock reads getSecondsUntilLock() once per
// second to drive the idle warning + auto-lock transitions.
//
// Threshold reads from ir.config_parameter at service start; refreshes
// every 5 min in case the manager changed it.
// =============================================================================
import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry";
const DEFAULT_IDLE_MIN = 5;
const DEFAULT_WARN_SEC = 30;
export const fpShopfloorActivityTracker = {
async start() {
let lastActiveAt = Date.now();
let idleThresholdMs = DEFAULT_IDLE_MIN * 60 * 1000;
let warnThresholdSec = DEFAULT_WARN_SEC;
async function refreshThreshold() {
try {
const minutes = await rpc("/web/dataset/call_kw", {
model: "ir.config_parameter",
method: "get_param",
args: ["fp.shopfloor.tablet_idle_lock_minutes", String(DEFAULT_IDLE_MIN)],
kwargs: {},
});
idleThresholdMs = (parseInt(minutes, 10) || DEFAULT_IDLE_MIN) * 60 * 1000;
const warn = await rpc("/web/dataset/call_kw", {
model: "ir.config_parameter",
method: "get_param",
args: ["fp.shopfloor.tablet_warn_seconds_before_lock", String(DEFAULT_WARN_SEC)],
kwargs: {},
});
warnThresholdSec = parseInt(warn, 10) || DEFAULT_WARN_SEC;
} catch (e) {
// keep defaults if RPC fails (e.g. no session yet)
}
}
await refreshThreshold();
setInterval(refreshThreshold, 5 * 60 * 1000);
// Activity = explicit user input. Mouse-move alone DOES NOT count
// because something brushing the screen (a stray glove, a tool
// resting on the tablet) could otherwise keep the session alive.
const bump = () => { lastActiveAt = Date.now(); };
document.addEventListener("pointerdown", bump, { capture: true });
document.addEventListener("touchstart", bump, { capture: true, passive: true });
document.addEventListener("keydown", bump, { capture: true });
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") bump();
});
return {
bump,
getSecondsUntilLock() {
return Math.max(0, Math.floor((lastActiveAt + idleThresholdMs - Date.now()) / 1000));
},
getWarnThresholdSec() { return warnThresholdSec; },
getIdleThresholdMs() { return idleThresholdMs; },
getLastActiveAt() { return lastActiveAt; },
};
},
};
registry
.category("services")
.add("fp_shopfloor_activity", fpShopfloorActivityTracker);

View File

@@ -0,0 +1,42 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — fpRpc() wrapper
//
// Drop-in replacement for the standard `rpc()` import. Automatically
// injects the current tablet_tech_id from the tech_store into every
// call, so server-side endpoints can attribute the action to the right
// user via env.with_user() (see env_for_tablet_tech in
// controllers/_tablet_audit.py).
//
// USE for any RPC that WRITES (start step, finish step, hold create,
// sign-off, milestone advance). For read-only loads (kanban, workspace
// load, manager funnel), plain rpc() is fine.
//
// Example:
// import { fpRpc } from "../services/fp_rpc";
// await fpRpc("/fp/shopfloor/start_wo", { workorder_id: stepId });
//
// =============================================================================
import { rpc as baseRpc } from "@web/core/network/rpc";
function _getTechStore() {
// Lazy-resolve via the global debug API — avoids circular service init
try {
const env = odoo.__WOWL_DEBUG__?.root?.env;
if (env && env.services && env.services.fp_shopfloor_tech_store) {
return env.services.fp_shopfloor_tech_store;
}
} catch (e) {
// ignore
}
return null;
}
export function fpRpc(url, params = {}) {
const techStore = _getTechStore();
if (techStore && techStore.currentTechId) {
params = { ...params, tablet_tech_id: techStore.currentTechId };
}
return baseRpc(url, params);
}

View File

@@ -0,0 +1,42 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Tech Store (shared OWL service)
//
// Holds the "current tech of record" for the locked tablet. Set by
// FpTabletLock on successful PIN unlock; cleared on auto-lock / Hand-Off.
// Other components read currentTechId via useService("fp_shopfloor_tech_store")
// and pass it through fpRpc() so server actions credit the right user.
// =============================================================================
import { reactive } from "@odoo/owl";
import { registry } from "@web/core/registry";
export const fpShopfloorTechStore = {
start() {
const state = reactive({
currentTechId: null,
currentTechName: "",
lockedAt: null,
});
return {
get currentTechId() { return state.currentTechId; },
get currentTechName() { return state.currentTechName; },
get isLocked() { return !state.currentTechId; },
setTech(id, name) {
state.currentTechId = id;
state.currentTechName = name;
state.lockedAt = null;
},
lock() {
state.currentTechId = null;
state.currentTechName = "";
state.lockedAt = Date.now();
},
state, // exposed for OWL reactive subscriptions
};
},
};
registry
.category("services")
.add("fp_shopfloor_tech_store", fpShopfloorTechStore);

View File

@@ -20,9 +20,11 @@
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl"; import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc"; import { rpc } from "@web/core/network/rpc";
import { fpRpc } from "./services/fp_rpc";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner"; import { QrScanner } from "./qr_scanner";
import { FpKanbanCard } from "./components/kanban_card"; import { FpKanbanCard } from "./components/kanban_card";
import { FpTabletLock } from "./tablet_lock";
const LS_STATION_ID = "fp_landing_station_id"; const LS_STATION_ID = "fp_landing_station_id";
const LS_MODE = "fp_landing_mode"; const LS_MODE = "fp_landing_mode";
@@ -31,11 +33,12 @@ const REFRESH_MS = 15000;
export class FpShopfloorLanding extends Component { export class FpShopfloorLanding extends Component {
static template = "fusion_plating_shopfloor.ShopfloorLanding"; static template = "fusion_plating_shopfloor.ShopfloorLanding";
static props = ["*"]; static props = ["*"];
static components = { QrScanner, FpKanbanCard }; static components = { QrScanner, FpKanbanCard, FpTabletLock };
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
this.action = useService("action"); this.action = useService("action");
this.techStore = useService("fp_shopfloor_tech_store");
this.state = useState({ this.state = useState({
mode: localStorage.getItem(LS_MODE) || "all_plant", mode: localStorage.getItem(LS_MODE) || "all_plant",
@@ -120,6 +123,12 @@ export class FpShopfloorLanding extends Component {
this.refresh(); this.refresh();
} }
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
// Tech walking away: lock the tablet so the next operator must PIN in
this.techStore.lock();
}
// ---- Search ------------------------------------------------------------ // ---- Search ------------------------------------------------------------
onSearchInput(ev) { onSearchInput(ev) {
this.state.search = ev.target.value; this.state.search = ev.target.value;
@@ -246,7 +255,7 @@ export class FpShopfloorLanding extends Component {
this._movesInFlight += 1; this._movesInFlight += 1;
this._lastDropAt = Date.now(); this._lastDropAt = Date.now();
try { try {
const res = await rpc("/fp/shopfloor/plant_overview/move_card", { const res = await fpRpc("/fp/shopfloor/plant_overview/move_card", {
card_id: dragged.id, card_id: dragged.id,
target_workcenter_id: col.work_center_id, target_workcenter_id: col.work_center_id,
}); });

View File

@@ -0,0 +1,135 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — FpTabletLock (top-level wrapper)
//
// Mounted by Landing / Workspace / Manager Dashboard as their outermost
// element. Renders the lock screen (tile grid + PIN pad) when no tech
// is signed in; renders <t t-slot="default"/> (the wrapped client
// action) otherwise. Also drives the auto-lock countdown + idle warning.
//
// Usage in a parent template:
//
// <FpTabletLock>
// <t t-set-slot="default">
// <div class="o_fp_landing"> ...your existing tree... </div>
// </t>
// </FpTabletLock>
//
// =============================================================================
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { FpPinPad } from "./components/pin_pad";
import { FpIdleWarning } from "./components/idle_warning";
export class FpTabletLock extends Component {
static template = "fusion_plating_shopfloor.TabletLock";
static components = { FpPinPad, FpIdleWarning };
static props = {
slots: { type: Object, optional: true },
};
setup() {
this.techStore = useService("fp_shopfloor_tech_store");
this.activity = useService("fp_shopfloor_activity");
this.notification = useService("notification");
this.state = useState({
tiles: [],
selectedTileUserId: null,
idleSecondsRemaining: null,
loadingTiles: false,
});
onMounted(async () => {
await this._loadTiles();
this._tick = setInterval(() => this._checkIdle(), 1000);
// Heartbeat ping every 60s — for forensic visibility
this._ping = setInterval(() => {
if (this.techStore.currentTechId) {
rpc("/fp/tablet/ping", { current_tech_id: this.techStore.currentTechId })
.catch(() => {});
}
}, 60000);
});
onWillUnmount(() => {
if (this._tick) clearInterval(this._tick);
if (this._ping) clearInterval(this._ping);
});
}
get isLocked() {
return this.techStore.isLocked;
}
async _loadTiles() {
this.state.loadingTiles = true;
try {
const stationId = parseInt(localStorage.getItem("fp_landing_station_id")) || null;
const res = await rpc("/fp/tablet/tiles", { station_id: stationId });
if (res && res.ok) {
this.state.tiles = res.tiles;
}
} catch (err) {
// Quiet fail — tile grid stays empty; user gets prompted
} finally {
this.state.loadingTiles = false;
}
}
_checkIdle() {
if (!this.techStore.currentTechId) {
this.state.idleSecondsRemaining = null;
return;
}
const remaining = this.activity.getSecondsUntilLock();
const warnThreshold = this.activity.getWarnThresholdSec();
if (remaining <= 0) {
this.handOff();
} else if (remaining <= warnThreshold) {
this.state.idleSecondsRemaining = remaining;
} else if (this.state.idleSecondsRemaining !== null) {
this.state.idleSecondsRemaining = null;
}
}
onTileClick(userId) {
this.state.selectedTileUserId = userId;
}
_selectedTileName() {
const tile = this.state.tiles.find(t => t.user_id === this.state.selectedTileUserId);
return tile ? tile.name : "";
}
async unlock(pin) {
try {
const res = await rpc("/fp/tablet/unlock", {
user_id: this.state.selectedTileUserId,
pin,
});
if (res && res.ok) {
this.techStore.setTech(res.current_tech_id, res.current_tech_name);
this.activity.bump();
this.state.selectedTileUserId = null;
return { ok: true };
}
return { ok: false, error: (res && res.error) || "Unlock failed" };
} catch (err) {
return { ok: false, error: err.message || String(err) };
}
}
onPinCancel() {
this.state.selectedTileUserId = null;
}
handOff() {
this.techStore.lock();
this.state.selectedTileUserId = null;
this.state.idleSecondsRemaining = null;
this._loadTiles();
}
}

View File

@@ -0,0 +1,35 @@
// =============================================================================
// FpIdleWarning — yellow-border countdown overlay before auto-lock
// =============================================================================
.o_fp_idle_warning_overlay {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
box-shadow: inset 0 0 0 4px #ff9f0a;
animation: o_fp_idle_pulse 1s ease-in-out infinite alternate;
}
@keyframes o_fp_idle_pulse {
from { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 0.6); }
to { box-shadow: inset 0 0 0 4px rgba(255, 159, 10, 1); }
}
.o_fp_idle_warning_toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
background: #1d1d1f;
color: #ffd585;
padding: 0.6rem 1.2rem;
border-radius: 8px;
font-size: 0.9rem;
z-index: 9999;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
strong { color: #ffb84d; margin: 0 0.2rem; }
> i { margin-right: 0.4rem; }
}

View File

@@ -0,0 +1,89 @@
// =============================================================================
// FpPinPad — numeric keypad for tablet lock screen + PIN setup
// Dark-mode aware via $o-webclient-color-scheme branch.
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_pin-bg-hex: #ffffff;
$_pin-key-bg-hex: #f3f4f6;
$_pin-key-hover-hex: #e5e7eb;
$_pin-border-hex: #d8dadd;
$_pin-dot-hex: #d8dadd;
$_pin-dot-fill-hex: #1d1d1f;
@if $o-webclient-color-scheme == dark {
$_pin-bg-hex: #22262d !global;
$_pin-key-bg-hex: #2d3138 !global;
$_pin-key-hover-hex: #3a3f48 !global;
$_pin-border-hex: #424245 !global;
$_pin-dot-fill-hex: #f5f5f7 !global;
}
.o_fp_pin_pad {
background: $_pin-bg-hex;
border-radius: 12px;
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.8rem;
min-width: 280px;
}
.o_fp_pin_title { font-size: 1.1rem; font-weight: 600; }
.o_fp_pin_subtitle { font-size: 0.85rem; color: var(--text-secondary, #666); text-align: center; }
.o_fp_pin_dots {
display: flex;
gap: 0.8rem;
margin: 0.5rem 0;
}
.o_fp_pin_dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: $_pin-dot-hex;
transition: background 0.1s ease;
&.filled { background: $_pin-dot-fill-hex; }
}
.o_fp_pin_error {
color: #ff3b30;
font-size: 0.85rem;
min-height: 1.2rem;
}
.o_fp_pin_grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
width: 100%;
}
.o_fp_pin_key {
background: $_pin-key-bg-hex;
border: 1px solid $_pin-border-hex;
border-radius: 10px;
padding: 1rem 0;
font-size: 1.5rem;
font-weight: 500;
cursor: pointer;
transition: background 0.1s ease, transform 0.05s ease;
&:hover { background: $_pin-key-hover-hex; }
&:active { transform: scale(0.97); }
&:disabled { opacity: 0.5; cursor: wait; }
}
.o_fp_pin_key_clear { font-size: 0.95rem; color: var(--text-secondary, #666); }
.o_fp_pin_key_cancel { font-size: 0.95rem; color: var(--text-secondary, #666); }
@keyframes o_fp_pin_shake_kf {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-8px); }
50% { transform: translateX(8px); }
75% { transform: translateX(-4px); }
}
.o_fp_pin_shake { animation: o_fp_pin_shake_kf 0.4s ease; }

View File

@@ -0,0 +1,96 @@
// =============================================================================
// FpTabletLock — lock screen with tile grid + PIN pad overlay
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_lock-bg-hex: #f3f4f6;
$_lock-card-hex: #ffffff;
$_lock-border-hex: #d8dadd;
$_lock-ink-hex: #1d1d1f;
@if $o-webclient-color-scheme == dark {
$_lock-bg-hex: #1a1d21 !global;
$_lock-card-hex: #22262d !global;
$_lock-border-hex: #424245 !global;
$_lock-ink-hex: #f5f5f7 !global;
}
.o_fp_tablet_lock {
position: fixed;
inset: 0;
background: $_lock-bg-hex;
color: $_lock-ink-hex;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
z-index: 9000;
overflow-y: auto;
}
.o_fp_tablet_lock_header {
h1 {
font-size: 1.4rem;
font-weight: 600;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.6rem;
}
}
.o_fp_tablet_lock_loading, .o_fp_tablet_lock_empty {
margin: 2rem auto;
color: var(--text-secondary, #666);
}
.o_fp_tablet_lock_tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
max-width: 900px;
width: 100%;
}
.o_fp_tablet_lock_tile {
background: $_lock-card-hex;
border: 2px solid $_lock-border-hex;
border-radius: 12px;
padding: 1rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
transition: border-color 0.1s ease, transform 0.05s ease;
&:hover { border-color: #0071e3; }
&:active { transform: scale(0.98); }
}
.o_fp_tablet_lock_tile_avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.o_fp_tablet_lock_tile_name {
font-weight: 600;
text-align: center;
}
.o_fp_tablet_lock_tile_clocked {
color: #34c759;
font-size: 0.75rem;
}
.o_fp_tablet_lock_tile_nopin {
color: #ff9f0a;
font-size: 0.75rem;
}
.o_fp_tablet_lock_pinwrap {
margin-top: 2rem;
}

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.IdleWarning">
<div class="o_fp_idle_warning_overlay">
<div class="o_fp_idle_warning_toast">
<i class="fa fa-clock-o"/>
Locking in <strong t-esc="props.secondsRemaining"/>s · tap anywhere to stay
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.PinPad">
<div t-att-class="'o_fp_pin_pad' + (state.shake ? ' o_fp_pin_shake' : '')">
<div t-if="props.title" class="o_fp_pin_title" t-esc="props.title"/>
<div t-if="props.subtitle" class="o_fp_pin_subtitle" t-esc="props.subtitle"/>
<div class="o_fp_pin_dots">
<t t-foreach="dots" t-as="filled" t-key="filled_index">
<span t-att-class="'o_fp_pin_dot' + (filled ? ' filled' : '')"/>
</t>
</div>
<div t-if="state.error" class="o_fp_pin_error" t-esc="state.error"/>
<div class="o_fp_pin_grid">
<t t-foreach="[1,2,3,4,5,6,7,8,9]" t-as="d" t-key="d">
<button class="o_fp_pin_key"
t-on-click="() => this._press(String(d))"
t-att-disabled="state.submitting">
<t t-esc="d"/>
</button>
</t>
<button class="o_fp_pin_key o_fp_pin_key_clear"
t-on-click="_clear">Clear</button>
<button class="o_fp_pin_key"
t-on-click="() => this._press('0')"
t-att-disabled="state.submitting">0</button>
<button t-if="props.onCancel"
class="o_fp_pin_key o_fp_pin_key_cancel"
t-on-click="() => this.props.onCancel()">Cancel</button>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.PinSetup">
<div class="o_fp_pin_setup">
<div t-if="state.stage === 'loading'" class="o_fp_pin_setup_loading">
<i class="fa fa-spinner fa-spin"/> Loading…
</div>
<FpPinPad t-if="state.stage === 'old'"
onSubmit.bind="onOldPinSubmit"
title="'Enter your current PIN'"
onCancel.bind="onCancel"/>
<FpPinPad t-if="state.stage === 'new'"
onSubmit.bind="onNewPinSubmit"
title="'Choose a new 4-digit PIN'"
onCancel.bind="onCancel"/>
<FpPinPad t-if="state.stage === 'confirm'"
onSubmit.bind="onConfirmPinSubmit"
title="'Confirm your new PIN'"
subtitle="'Enter it again to confirm'"
onCancel.bind="onCancel"/>
<div t-if="state.stage === 'done'" class="o_fp_pin_setup_done">
<i class="fa fa-check-circle text-success fa-3x"/>
<h3>PIN updated</h3>
</div>
</div>
</t>
</templates>

View File

@@ -2,6 +2,8 @@
<templates xml:space="preserve"> <templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.JobWorkspace"> <t t-name="fusion_plating_shopfloor.JobWorkspace">
<FpTabletLock>
<t t-set-slot="default">
<div class="o_fp_ws"> <div class="o_fp_ws">
<!-- Loading state --> <!-- Loading state -->
@@ -20,6 +22,12 @@
<button class="btn btn-link o_fp_ws_back" t-on-click="onBack"> <button class="btn btn-link o_fp_ws_back" t-on-click="onBack">
<i class="fa fa-arrow-left"/> Back <i class="fa fa-arrow-left"/> Back
</button> </button>
<!-- Phase 6.2 — Hand-Off: lock the tablet -->
<button class="btn btn-sm btn-warning ms-2"
t-on-click="handOff"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off
</button>
<span class="o_fp_ws_wo"><t t-esc="state.data.job.display_wo_name"/></span> <span class="o_fp_ws_wo"><t t-esc="state.data.job.display_wo_name"/></span>
<span class="o_fp_ws_dot"> · </span> <span class="o_fp_ws_dot"> · </span>
<span class="o_fp_ws_cust"><t t-esc="state.data.job.partner_name"/></span> <span class="o_fp_ws_cust"><t t-esc="state.data.job.partner_name"/></span>
@@ -226,5 +234,7 @@
</t> </t>
</div> </div>
</t> </t>
</FpTabletLock>
</t>
</templates> </templates>

View File

@@ -7,6 +7,8 @@
<templates xml:space="preserve"> <templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ManagerDashboard"> <t t-name="fusion_plating_shopfloor.ManagerDashboard">
<FpTabletLock>
<t t-set-slot="default">
<div class="o_fp_manager"> <div class="o_fp_manager">
<!-- ============ Hero ============ --> <!-- ============ Hero ============ -->
@@ -45,6 +47,12 @@
<i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/> <i t-att-class="'fa fa-refresh' + (state.isFetching ? ' fa-spin' : '')"/>
</button> </button>
<QrScanner cssClass="'btn'"/> <QrScanner cssClass="'btn'"/>
<!-- Phase 6.2 — Hand-Off: lock the tablet -->
<button class="btn btn-warning"
t-on-click="handOff"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off
</button>
<button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')" <button t-att-class="'btn ' + (state.mode === 'quick' ? 'btn-primary' : '')"
t-on-click="toggleMode"> t-on-click="toggleMode">
<t t-if="state.mode === 'quick'">Quick View</t> <t t-if="state.mode === 'quick'">Quick View</t>
@@ -584,5 +592,7 @@
</div> </div>
</div> </div>
</t> </t>
</FpTabletLock>
</t>
</templates> </templates>

View File

@@ -2,6 +2,8 @@
<templates xml:space="preserve"> <templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ShopfloorLanding"> <t t-name="fusion_plating_shopfloor.ShopfloorLanding">
<FpTabletLock>
<t t-set-slot="default">
<div class="o_fp_landing"> <div class="o_fp_landing">
<!-- Loading state --> <!-- Loading state -->
@@ -62,6 +64,13 @@
</button> </button>
<QrScanner cssClass="'btn btn-sm btn-outline-secondary'" label="'Camera'"/> <QrScanner cssClass="'btn btn-sm btn-outline-secondary'" label="'Camera'"/>
<!-- Phase 6.2 — Hand-Off: lock the tablet for the next operator -->
<button class="btn btn-sm btn-warning"
t-on-click="handOff"
title="Lock the tablet for the next operator">
<i class="fa fa-lock"/> Hand Off
</button>
<!-- Refresh indicator --> <!-- Refresh indicator -->
<span class="o_fp_landing_refresh text-muted"> <span class="o_fp_landing_refresh text-muted">
<i class="fa fa-clock-o"/> <t t-esc="state.lastRefresh"/> <i class="fa fa-clock-o"/> <t t-esc="state.lastRefresh"/>
@@ -159,5 +168,7 @@
</t> </t>
</div> </div>
</t> </t>
</FpTabletLock>
</t>
</templates> </templates>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.TabletLock">
<t t-if="isLocked">
<div class="o_fp_tablet_lock">
<div class="o_fp_tablet_lock_header">
<h1><i class="fa fa-lock"/> Tap your name to unlock</h1>
</div>
<div t-if="state.loadingTiles" class="o_fp_tablet_lock_loading">
<i class="fa fa-spinner fa-spin"/> Loading…
</div>
<div t-elif="!state.selectedTileUserId" class="o_fp_tablet_lock_tiles">
<t t-if="!state.tiles.length">
<div class="o_fp_tablet_lock_empty">
No operators configured.
</div>
</t>
<t t-foreach="state.tiles" t-as="tile" t-key="tile.user_id">
<button class="o_fp_tablet_lock_tile"
t-on-click="() => this.onTileClick(tile.user_id)">
<img class="o_fp_tablet_lock_tile_avatar"
t-att-src="tile.avatar_url"
t-att-alt="tile.name"/>
<div class="o_fp_tablet_lock_tile_name" t-esc="tile.name"/>
<span t-if="tile.is_clocked_in" class="o_fp_tablet_lock_tile_clocked">
● Clocked in
</span>
<span t-if="!tile.has_pin" class="o_fp_tablet_lock_tile_nopin">
PIN required
</span>
</button>
</t>
</div>
<div t-else="" class="o_fp_tablet_lock_pinwrap">
<FpPinPad onSubmit.bind="unlock"
title="_selectedTileName()"
subtitle="'Enter your 4-digit PIN'"
onCancel.bind="onPinCancel"/>
</div>
</div>
</t>
<t t-else="">
<t t-slot="default"/>
<FpIdleWarning t-if="state.idleSecondsRemaining !== null"
secondsRemaining="state.idleSecondsRemaining"/>
</t>
</t>
</templates>

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from . import test_workspace_controller from . import test_workspace_controller
from . import test_landing_kanban from . import test_landing_kanban
from . import test_tablet_pin

View File

@@ -0,0 +1,216 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
"""Phase 6 — Tablet PIN gate tests."""
import json
from odoo.tests.common import HttpCase, TransactionCase, tagged
def _rpc(case, url, **params):
"""Helper for HTTP/JSON-RPC tests — wraps Odoo's url_open."""
res = case.url_open(
url,
data=json.dumps({'jsonrpc': '2.0', 'params': params}),
headers={'Content-Type': 'application/json'},
)
return res.json()['result']
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletPinHash(TransactionCase):
"""P6.1.1 — model fields + hash helpers + set/verify/clear methods."""
def setUp(self):
super().setUp()
self.user = self.env['res.users'].create({
'name': 'Pin Test',
'login': 'pintest@example.com',
})
def test_set_pin_stores_salted_hash(self):
self.user.sudo().set_tablet_pin('1234')
stored = self.user.sudo().x_fc_tablet_pin_hash
self.assertTrue(stored)
self.assertIn('$', stored, 'hash must include salt separator')
# Hash is non-deterministic — setting same PIN twice gives different stored values
self.user.sudo().set_tablet_pin('1234')
self.assertNotEqual(stored, self.user.sudo().x_fc_tablet_pin_hash)
def test_verify_correct_pin(self):
self.user.sudo().set_tablet_pin('1234')
self.assertTrue(self.user.sudo().verify_tablet_pin('1234'))
def test_verify_wrong_pin(self):
self.user.sudo().set_tablet_pin('1234')
self.assertFalse(self.user.sudo().verify_tablet_pin('0000'))
def test_verify_pin_no_hash_set(self):
self.assertFalse(self.user.sudo().verify_tablet_pin('1234'))
def test_set_pin_rejects_invalid_format(self):
from odoo.exceptions import UserError
for bad in ('123', '12345', 'abcd', '', None):
with self.assertRaises(UserError):
self.user.sudo().set_tablet_pin(bad)
def test_clear_pin_wipes_hash_and_posts_chatter(self):
self.user.sudo().set_tablet_pin('1234')
self.user.clear_tablet_pin()
self.user.invalidate_recordset(['x_fc_tablet_pin_hash'])
self.assertFalse(self.user.sudo().x_fc_tablet_pin_hash)
self.assertFalse(self.user.sudo().x_fc_tablet_pin_set_date)
# Check chatter
last_msg = self.user.message_ids[:1]
self.assertIn('reset by', (last_msg.body or '').lower())
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletSetPin(HttpCase):
"""P6.1.2 — /fp/tablet/set_pin endpoint."""
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
def test_set_pin_first_time(self):
res = _rpc(self, '/fp/tablet/set_pin', new_pin='1234')
self.assertTrue(res['ok'])
def test_set_pin_change_requires_old(self):
admin = self.env.ref('base.user_admin')
admin.set_tablet_pin('1234')
# Change without old — rejected
res = _rpc(self, '/fp/tablet/set_pin', new_pin='5678')
self.assertFalse(res['ok'])
# Change with wrong old — rejected
res = _rpc(self, '/fp/tablet/set_pin', old_pin='9999', new_pin='5678')
self.assertFalse(res['ok'])
# Change with correct old — accepted
res = _rpc(self, '/fp/tablet/set_pin', old_pin='1234', new_pin='5678')
self.assertTrue(res['ok'])
def test_set_pin_rejects_non_4_digit(self):
for bad in ('123', '12345', 'abcd', ''):
res = _rpc(self, '/fp/tablet/set_pin', new_pin=bad)
self.assertFalse(res['ok'], f'PIN {bad!r} should be rejected')
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletResetPinFor(HttpCase):
"""P6.1.2 — /fp/tablet/reset_pin_for endpoint."""
def setUp(self):
super().setUp()
self.target = self.env['res.users'].create({
'name': 'Reset Target', 'login': 'reset@example.com',
})
self.target.sudo().set_tablet_pin('1234')
def test_reset_requires_manager_group(self):
# Plain operator can't reset
op_group = self.env.ref('fusion_plating.group_fusion_plating_operator')
self.env['res.users'].create({
'name': 'Op', 'login': 'op@example.com',
'password': 'op@example.com',
'group_ids': [(6, 0, [op_group.id])],
})
self.authenticate('op@example.com', 'op@example.com')
res = _rpc(self, '/fp/tablet/reset_pin_for', user_id=self.target.id)
self.assertFalse(res['ok'])
def test_reset_as_admin_clears_hash(self):
self.authenticate('admin', 'admin')
res = _rpc(self, '/fp/tablet/reset_pin_for', user_id=self.target.id)
self.assertTrue(res['ok'])
self.target.invalidate_recordset(['x_fc_tablet_pin_hash'])
self.assertFalse(self.target.sudo().x_fc_tablet_pin_hash)
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletUnlock(HttpCase):
"""P6.1.3 — /fp/tablet/unlock endpoint + lockout."""
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.target = self.env['res.users'].create({
'name': 'Unlock Target', 'login': 'unlock@example.com',
})
self.target.sudo().set_tablet_pin('1234')
def _unlock(self, pin):
return _rpc(self, '/fp/tablet/unlock',
user_id=self.target.id, pin=pin)
def test_unlock_correct_pin(self):
res = self._unlock('1234')
self.assertTrue(res['ok'])
self.assertEqual(res['current_tech_id'], self.target.id)
self.assertEqual(res['current_tech_name'], 'Unlock Target')
def test_unlock_correct_pin_resets_fail_counter(self):
self._unlock('0000') # fail once
self._unlock('1234') # succeed
self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count'])
self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 0)
def test_unlock_wrong_pin_increments_counter(self):
self._unlock('0000')
self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count'])
self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 1)
def test_lockout_after_5_fails(self):
for _ in range(5):
self._unlock('0000')
res = self._unlock('0000') # 6th
self.assertFalse(res['ok'])
self.assertIn('locked', res['error'].lower())
self.target.invalidate_recordset(['x_fc_tablet_locked_until'])
self.assertTrue(self.target.sudo().x_fc_tablet_locked_until)
def test_lockout_blocks_even_correct_pin(self):
for _ in range(5):
self._unlock('0000')
# Even the correct PIN now rejected
res = self._unlock('1234')
self.assertFalse(res['ok'])
self.assertIn('locked', res['error'].lower())
def test_unlock_no_pin_set(self):
self.target.clear_tablet_pin()
res = self._unlock('1234')
self.assertFalse(res['ok'])
self.assertTrue(res.get('needs_setup'))
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletTiles(HttpCase):
"""P6.1.4 — /fp/tablet/tiles endpoint."""
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.op_group = self.env.ref('fusion_plating.group_fusion_plating_operator')
self.alice = self.env['res.users'].create({
'name': 'Alice Tile', 'login': 'alice_tile@example.com',
'group_ids': [(6, 0, [self.op_group.id])],
})
self.bob = self.env['res.users'].create({
'name': 'Bob Tile', 'login': 'bob_tile@example.com',
'group_ids': [(6, 0, [self.op_group.id])],
})
self.alice.sudo().set_tablet_pin('1111')
def test_tiles_returns_all_operators_without_station(self):
res = _rpc(self, '/fp/tablet/tiles')
self.assertTrue(res['ok'])
names = [t['name'] for t in res['tiles']]
self.assertIn('Alice Tile', names)
self.assertIn('Bob Tile', names)
def test_tile_has_pin_flag(self):
res = _rpc(self, '/fp/tablet/tiles')
alice_tile = next(t for t in res['tiles'] if t['name'] == 'Alice Tile')
bob_tile = next(t for t in res['tiles'] if t['name'] == 'Bob Tile')
self.assertTrue(alice_tile['has_pin'])
self.assertFalse(bob_tile['has_pin'])

View File

@@ -49,6 +49,13 @@
<field name="active"/> <field name="active"/>
</group> </group>
</group> </group>
<group string="Tablet PIN Gate (Phase 6)">
<field name="x_fc_authorised_user_ids"
widget="many2many_tags"
options="{'no_create': True}"/>
<field name="x_fc_idle_lock_minutes"
placeholder="default 5 (system parameter)"/>
</group>
<separator string="Notes"/> <separator string="Notes"/>
<field name="notes"/> <field name="notes"/>
</sheet> </sheet>

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Phase 6 tablet PIN gate — surfaces:
(a) self-service "Set/Change PIN" on the user's Preferences form
(b) manager-only "Reset Tablet PIN" header button on res.users form
-->
<odoo>
<!-- =================================================================
(a) Preferences form — Set/Change Tablet PIN
The actual modal is OWL-side (FpPinSetup, Phase 6.2). Here we
just surface a status indicator + the button under a group.
================================================================= -->
<record id="view_users_form_preferences_tablet_pin" model="ir.ui.view">
<field name="name">res.users.preferences.tablet.pin</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<group string="Tablet PIN" name="fp_tablet_pin_group">
<field name="x_fc_tablet_pin_set_date" readonly="1"
string="PIN Last Set"/>
<button name="action_open_tablet_pin_setup"
type="object"
string="Set / Change Tablet PIN"
class="btn-secondary"
icon="fa-key"/>
</group>
</xpath>
</field>
</record>
<!-- =================================================================
(b) res.users form — Manager-only "Reset Tablet PIN" header
================================================================= -->
<record id="view_users_form_reset_tablet_pin" model="ir.ui.view">
<field name="name">res.users.form.reset.tablet.pin</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="clear_tablet_pin"
type="object"
string="Reset Tablet PIN"
class="btn-warning"
icon="fa-eraser"
groups="fusion_plating.group_fusion_plating_manager"
invisible="not x_fc_tablet_pin_set_date"
confirm="Reset this user's tablet PIN? They'll need to set a new one on their next unlock."/>
<field name="x_fc_tablet_pin_set_date" invisible="1"/>
</xpath>
</field>
</record>
</odoo>