Compare commits

..

43 Commits

Author SHA1 Message Date
gsinghpal
d89546bec7 fix(shopfloor): Back button + logo frame shape
Two fixes from live testing of the 2026-05-24 redesign:

1. Job Workspace Back button routed to deprecated component.
   onBack() hardcoded tag: 'fp_shopfloor_landing' so tapping a card on
   the new plant kanban -> opening the workspace -> clicking Back
   dropped the user into the OLD per-step kanban (the legacy OWL
   component the data-record redirects don't reach because doAction
   bypasses the data layer).
   Fix: change the hardcoded tag to 'fp_plant_kanban'. Grep
   confirmed it's the only such reference in JS.

2. Logo frame shape — wider, shorter, logo bigger.
   140x140 square -> 280x110 rectangle. Better fit for horizontal
   company logos (mark + name + tagline laid out left-to-right).
   Uniform 18px padding on all sides so the image breathes evenly.
   Image area is ~244x74 (vs old ~104x104), so a typical horizontal
   logo renders ~50% wider. border-radius 28->22 for the flatter
   rect; letter-mark placeholder font 52->44 to fit the shorter
   frame.

Also augmented CLAUDE.md 'Legacy-action redirect' rule with a new
'grep JS for hardcoded doAction' clause — the XML-record redirect
trick only covers ir.actions.client data; OWL components with inline
this.action.doAction({tag: ...}) calls bypass the data layer entirely
and need a separate sweep.

Asset cache cleared (3 stale attachments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:11:51 -04:00
gsinghpal
818dfa3882 fix(shopfloor): bigger logo frame on the tablet lock screen
User feedback after live testing: the 84px logo frame felt too small
and the image inside used only a fraction of the frame area.

Bumped the frame to 140px (1.67x) — image scales with the container
via the existing max-width/max-height: 100% rule. Proportional
adjustments to padding (14→18), border-radius (20→28), margin-bottom
(12→16), and the letter-mark placeholder font (32→52).

SCSS-only change. Asset cache cleared (3 stale attachments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 22:06:17 -04:00
gsinghpal
772107d25b feat(shopfloor): tablet lock-screen redesign — frontend + manifest
LS-T2..T6 of the tablet lock-screen redesign (LS-T1 backend shipped
separately in c6137100).

Files:
  - _tablet_lock_tokens.scss  (new — design tokens, dark/light branches
                               via $o-webclient-color-scheme, registered
                               first in manifest per project rule 8)
  - tablet_lock.scss          (full rewrite — gradient bg, glassmorphic
                               tiles, 4 entrance keyframes, hover lift,
                               click press, clocked-in pulse,
                               prefers-reduced-motion gate)
  - tablet_lock.xml           (extended — logo + clock + prompt blocks
                               wrapping the existing tile loop; tile
                               inner shape updated for avatar gradient,
                               has_photo conditional, is_clocked_in
                               modifier)
  - tablet_lock.js            (extended — state.clockText / dateText /
                               company, setInterval(60s) clock tick,
                               _formatTime / _formatDate / tileStyle /
                               avatarClass helpers per project rule 20)
  - __manifest__.py           (19.0.31.0.0 -> 19.0.32.0.0, registered
                               new tokens SCSS BEFORE tablet_lock.scss)

Verified live on entech:
  - Module upgrade clean, registry loaded in 15.5s
  - 6 stale asset attachments cleared
  - Helpers in tablet_controller.py emit company payload + initials +
    gradients correctly (Garry Singh -> GS, EN Tech -> ET, uid=5 ->
    pink gradient)
  - res.company.logo present (has_logo: True)
  - All animations gated by prefers-reduced-motion per spec §6

CLAUDE.md updated with new Critical Rule 22 about Odoo 19 HTML fields
auto-wrapping plain-string writes — caught during Task 1 testing when
the original 'tagline equality' test failed because res.company.report
_header is an HTML field that wraps writes with <p> tags.

Closes the 6-task plan in
  docs/superpowers/plans/2026-05-24-tablet-lock-screen-redesign-plan.md
Spec: docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:56:32 -04:00
gsinghpal
c61371005a feat(shopfloor): extend /fp/tablet/tiles payload with company block
LS-T1 of the tablet lock-screen redesign.

Adds 3 module-level helpers in tablet_controller.py:
  _initials_from(name)       — first/last initials for letter-mark fallback
  _avatar_gradient_for(uid)  — deterministic per-user color (8 gradients)
  _lock_company_payload(env) — company name + tagline + logo URL block

Endpoint /fp/tablet/tiles now returns:
  {ok, company:{id,name,tagline,logo_url,has_logo,initials},
   tiles:[{user_id, name, initials, avatar_url, has_photo,
           avatar_gradient, is_clocked_in, has_pin}, ...]}

Tagline reuses res.company.report_header (the existing invoice-letterhead
field) — no new model field. Falls back to 'Shop Floor Terminal' when
empty.

10 tests pass (initials edge cases, gradient determinism, payload shape).
The 'tagline matches input string' assertion was intentionally NOT added
— see new CLAUDE.md Critical Rule 22 about Odoo 19 HTML field
auto-wrapping that makes such an equality test brittle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:52:17 -04:00
gsinghpal
7a0bd67fc0 docs(shopfloor): implementation plan for tablet lock-screen redesign
6 tasks covering the visual + interaction redesign:

  Task 1 — Backend: 3 module-level helpers in tablet_controller.py
           (_initials_from, _avatar_gradient_for, _lock_company_payload)
           + extended /fp/tablet/tiles payload + 3 test classes (TDD)
  Task 2 — Create _tablet_lock_tokens.scss design tokens (light + dark
           branches via $o-webclient-color-scheme)
  Task 3 — Full rewrite of tablet_lock.scss (gradient bg, glassmorphic
           tiles, 4 entrance keyframes, hover lift, click press,
           clocked-in pulse, prefers-reduced-motion gate)
  Task 4 — Extend tablet_lock.xml with logo + clock + prompt blocks
           wrapping the existing tile loop
  Task 5 — Extend tablet_lock.js with state.clockText / state.dateText /
           state.company + setInterval clock tick + _formatTime /
           _formatDate / tileStyle / avatarClass helpers (all per
           project rule 20 — coercion lives in JS, not in templates)
  Task 6 — Register the new tokens SCSS in manifest BEFORE
           tablet_lock.scss (per rule 8), bump version 19.0.32.0.0,
           deploy + verify

Each task has TDD-style steps with full code blocks. Self-review
confirms 1-to-1 coverage of every spec section + correct deferral of
every §12 Phase 2 item.

Plan: docs/superpowers/plans/2026-05-24-tablet-lock-screen-redesign-plan.md
Spec: docs/superpowers/specs/2026-05-24-tablet-lock-screen-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:44:24 -04:00
gsinghpal
efc420b4ce docs(shopfloor): tablet lock-screen redesign spec
Hybrid Industrial Bold + Premium Glassmorphism direction approved
during brainstorming. Adds company branding (logo from
res.company.logo with letter-mark fallback), real-time clock, tighter
3-column tile grid for ~10-15 operator small shops, dual dark/light
mode via compile-time $o-webclient-color-scheme branch, 7-animation
catalogue gated by prefers-reduced-motion.

Backend touch: extend /fp/tablet/tiles payload with company block +
per-tile initials/avatar_gradient/has_photo. Two small helper
functions in tablet_controller. No DB migration.

Frontend touch: new _tablet_lock_tokens.scss (loads first), full
rewrite of tablet_lock.scss, extend XML + JS for clock + company.

Mockup: .superpowers/brainstorm/1983-1779585812/content/lock-final.html
(in-repo since the brainstorm session used --project-dir).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:38:11 -04:00
gsinghpal
fd2b2908f3 fix(shopfloor): plant-view card sizing — match the mockup proportions
User feedback after live testing: cards were too cramped on the 9-column
board. Restoring the Variant C mockup proportions and letting the board
scroll horizontally on smaller viewports (user explicitly accepted
side-scrolling).

Changes:
  - .board grid: repeat(9, 1fr) → repeat(9, minmax(300px, 1fr))
    plus overflow-x: auto. Each column ~300px so the card has room to
    breathe. ~6 columns visible on 1920px desktop, ~4 on 1366px tablet,
    smooth horizontal scroll for the rest.
  - .col-scroll: gap 4→8, max-height eased so cards aren't packed.
  - .o_fp_plant_card: padding 8/10→12, gap 4→6, base font 11→12.
  - card-wo: 13→16 (matches mockup header size).
  - card-step: 12→14.
  - chips: padding 1/6→2/8, font 10→11, radius 10→12.
  - mini-timeline blocks: 8→16px tall (current step 11→22px), labels
    8→9px. Matches the mockup's punchy timeline strip.
  - progress bar: max-width 60→100, height 3→4.
  - operator pill / icon-row: bumped to match card scale.

No backend changes. SCSS-only. Asset cache cleared (3 attachments).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:20:01 -04:00
gsinghpal
eb1fd50add fix(shopfloor): legacy client actions redirect to fp_plant_kanban
The plant-view rollout left two legacy ir.actions.client data records
still claiming tag='fp_shopfloor_landing':
  - action_fp_plant_overview        (Plant Overview)
  - action_fp_shopfloor_tablet      (Shop Floor — Tablet Station)

The landing-action resolver dispatched the new view correctly when the
user clicked the Plating root menu, but bookmarks / breadcrumbs /
QR-scan landings / direct URLs still routed through these legacy
actions and loaded the per-step kanban (OWL component is still
registered for back-compat).

Flipping their tag to fp_plant_kanban means every entry point now
opens the new view. The legacy fp_shopfloor_landing OWL component
stays registered (no code removed) but no XMLID points at it
anymore — safe to delete in a future cleanup.

Also documented this as a durable convention in CLAUDE.md under
'Legacy-action redirect (general rule for OWL component swaps)'.

Verified on entech:
  - action 1129 (Shop Floor)     tag: fp_shopfloor_landing → fp_plant_kanban
  - action 1133 (Plant Overview) tag: fp_shopfloor_landing → fp_plant_kanban
  - 3 stale asset bundles cleared
  - Module re-upgraded clean, registry rebuilt in 15.7s

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 21:14:33 -04:00
gsinghpal
a60506a645 feat(shopfloor): Phase 5 — flip default to v2 plant view + docs
PV-Phase5 of the plant-view redesign. Final phase — flips the default
of x_fc_shopfloor_layout from 'legacy' to 'v2' and updates CLAUDE.md
with the new architecture rule.

Verified on entech:
  - HTTP 200 on /web/login
  - Shopfloor module loads cleanly with all 19 new frontend files
  - /fp/landing/plant_kanban returns the assembled payload with 9
    columns + denormalized cards
  - Card state distribution: 22 contract_review + 8 no_parts + 1 running
    (sample data only — dev system)
  - Asset bundle re-compiled (9 stale attachments cleared)
  - ir.config_parameter['fusion_plating_shopfloor.layout'] = 'v2' set

To switch back to legacy: Settings → Fusion Plating → Shop Floor
Layout, or UPDATE ir_config_parameter SET value='legacy' WHERE
key='fusion_plating_shopfloor.layout'.

CLAUDE.md gets a new ~80-line section documenting:
  - Why the redesign (per-step kanban produced duplicate cards)
  - 9-column layout + step-kind → area mapping (spec D3, D4, D5)
  - 13-state catalog + precedence dispatch in _compute_card_state
  - Backend single-endpoint payload shape (/fp/landing/plant_kanban)
  - Frontend OWL component tree + critical implementation gotchas
    (rule 20 OWL scope, rule 8 SCSS @import, dark-mode compile-time)
  - How to switch back to legacy

Closes the 20-task plan in
  docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md

Spec: docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:59:44 -04:00
gsinghpal
8b9b4d60ad feat(shopfloor): Phase 4 — plant-view kanban frontend (OWL + SCSS + XML)
PV-Phase4 of the plant-view redesign. 19 new files implementing the
6-component OWL tree plus design tokens.

Components (each = JS + XML + SCSS triple):
  - FpMiniTimeline    — 9-step bar consuming mini_timeline_json
  - FpPlantCard       — Variant C card; 13 state-* CSS classes; tap
                        opens fp_job_workspace
  - FpColumnHeader    — column label + count badge + 'You're here'
                        badge when paired
  - FpKpiTile         — clickable KPI button with urgent/warn/good
                        variants and active state
  - FpFilterChip      — toggleable chip
  - FpPlantKanban     — top-level orchestrator: 10s polling, mode
                        toggle, search + 6 filter chips, board with
                        9 fixed columns, localStorage filter persistence

SCSS:
  - _plant_tokens.scss (loads first, exposes $plant-* vars to every
    later file — required because Odoo 19 forbids @import in custom
    SCSS, manifest order IS the concat order)
  - Dark mode via $o-webclient-color-scheme compile-time branch

Manifest registers all assets in dependency order: tokens → component
SCSS → component XML → leaf JS → top-level JS. Mirrors the existing
project pattern.

Critical patterns honored:
  - Project rule 20 (no String/Number/parseInt in OWL templates):
    all coercion in JS, string literals in foreach arrays.
  - No t-out without markup() (none in this batch — all card text is
    pre-formatted by the controller).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:57:55 -04:00
gsinghpal
a90eace4d0 feat(shopfloor): Phase 3 — plant_kanban endpoint + dispatch
PV-Phase3 of the plant-view redesign.

- /fp/landing/plant_kanban JSONRPC endpoint returns {kpis, columns,
  cards} in one payload. One card per fp.job; cards denormalized so
  the OWL component doesn't fan out RPCs. Server-side filter handling
  for All / Mine / Running / Blocked / Overdue / FAIR. Within-column
  sort by (overdue, _SORT_PRIORITY[card_state], due_date).
- fusion_plating_shopfloor.action_fp_plant_kanban client action
  registered alongside the existing fp_shopfloor_landing action.
- fp_landing_data.xml resolver extended to read the layout flag and
  dispatch to v2 when x_fc_shopfloor_layout='v2' (default still legacy).

Card payload (23 fields): WO, customer, PN+rev, qty, PO, recipe, spec,
tags, current step + work centre, state chip, mini_timeline, operator,
icons (signoff / bake / tracking / etc.), progress.

State-chip mapping per spec §6.1 — one map keyed by card_state with
running-time elapsed, idle-hours, and operator-name interpolation.

Verified live — card payload sample on WO-30036 (contract_review state)
produces all expected keys + 9-element mini_timeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:51:36 -04:00
gsinghpal
7c2ae84e32 feat(jobs): Phase 2 — card_state + mini_timeline + precedence helpers
PV-Phase2 of the plant-view redesign.

Implements the 13-state classifier on fp.job:
  - card_state Char field, stored + indexed for fast filtering
  - _compute_card_state with explicit precedence dispatch matching
    spec §6.2 / §9.3 exactly (no_parts → on_hold → awaiting_signoff
    → awaiting_qc → bake_due → predecessor_locked → idle_warning →
    done → contract_review → running/_mine → ready/_mine)

Six precedence helpers, each isolated for testability:
  _fp_inbound_not_received, _fp_has_open_hold, _fp_has_pending_qc,
  _fp_bake_window_due_soon, _fp_is_mine + _fp_has_unfinished_predecessors
  on fp.job.step.

mini_timeline_json compute: 9-element array (one per column) with
state in {done, current, upcoming} and an optional 'variant' on the
current marker keyed to card_state for renderer color mapping.

Verified live:
  - 14 jobs in contract_review (no active step yet)
  - 8 in no_parts (confirmed + draft fp.receiving)
  - 1 running (WO-30051 with Pre-Measurements at Plating column)
  - mini_timeline JSON renders the full 9-area structure with the
    plating slot marked current+variant=running.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:48:14 -04:00
gsinghpal
63d692b322 feat(plating): Phase 1 — plant-view kanban data model foundation
PV-T1: fp.work.centre.area_kind Selection (9 floor columns)
PV-T2: fp.job.step.area_kind compute + _STEP_KIND_TO_AREA fallback
       (covers all 30+ step kinds in the project library, plus the
       spec D4 rule that de_mask folds into de_racking)
PV-T3: fp.job.step.last_activity_at + write hook + message_post
       override + fp.job.step.move.create() hook + _fp_is_idle helper
PV-T4: res.users.paired_work_centre_ids M2M (single-station for MVP,
       forward-compatible for Phase 2 multi-station picker)
PV-T5: res.config.settings.x_fc_shopfloor_layout feature flag backed
       by ir.config_parameter for the landing-action resolver

Migrations:
  fusion_plating 19.0.21.0.0      — backfill area_kind from kind
  fusion_plating_jobs 19.0.10.24.0 — backfill last_activity_at

Deployed + verified on entech:
  - 9/9 fp.work.centre rows have area_kind set
  - 400/400 fp.job.step rows have area_kind + last_activity_at
  - paired_work_centre_ids M2M relation table created
  - All 271 modules loaded cleanly, registry rebuilt in 27s

Part of the 2026-05-23 Shop Floor plant-view kanban redesign.
Plan: docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md
Spec: docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:43:15 -04:00
gsinghpal
1a3ca8704e feat(plating): session 2026-05-23 deploys — F1/F7/S22/S23 + UI fixes
Consolidated commit of session work already deployed to entech and
verified via the deep audit + the persona walk:

S22 — Signoff gate (fp.job.step.requires_signoff was 100% unenforced,
42/42 done steps had NULL signoff_user_id). Three-piece fix:
_fp_autosign_if_required (captures finisher on button_finish),
_fp_check_signoff_complete (raises UserError if NULL after autosign),
action_signoff (explicit supervisor pre-sign). Bypass:
fp_skip_signoff_gate=True.

S23 — Transition-form gate (same dormant-field shape as S22, caught
preventively before recipe authors flipped requires_transition_form
on). Model helpers on fp.job.step.move + controller gate in
move_controller (parts commit) + pre-reject in rack commit.

F7 — Chatter standardization: _fp_create_qc_check_if_needed,
_fp_fire_notification, _fp_create_delivery silent failures now also
post to job chatter instead of only logging to file.

UI fixes:
- Critical Rule 20 documented + applied: OWL templates only expose
  Math as a global. Calling String(d) inside t-on-click throws
  'v2 is not a function'. Fixed pin_pad.xml (string array instead of
  number array with String() coercion). Also swept parseInt/
  parseFloat in recipe_tree_editor + simple_recipe_editor.
- Notes panel HTML escape fix: chatter messages off /fp/workspace/load
  were rendered via t-out, escaping the HTML. Wrap with markup() in
  job_workspace.js refresh() before assigning to state.

Versions:
  fusion_plating         19.0.20.8.0 → 19.0.20.9.0
  fusion_plating_jobs    19.0.10.20.0 → 19.0.10.23.0
  fusion_plating_shopfloor 19.0.30.2.0 → 19.0.30.5.0

All deployed to entech (LXC 111) and verified live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:37:17 -04:00
gsinghpal
d6ebcb6233 docs(shopfloor): implementation plan for plant-view kanban redesign
20 tasks across 5 phases:
  1. Data model foundation (area_kind, last_activity_at, paired
     work centres, feature flag) — 5 tasks
  2. Card state computation + mini-timeline (precedence helpers,
     card_state compute, mini_timeline_json) — 3 tasks
  3. Backend endpoint + landing dispatch — 2 tasks
  4. Frontend components bottom-up (tokens, mini-timeline, card,
     column header, KPI tile, filter chip, top-level action) —
     7 tasks
  5. QA + flip default — 3 tasks

Each task has TDD-style steps (write failing test → run → implement
→ run → commit) with full code blocks and exact file paths. Bakes
in project-specific patterns from CLAUDE.md (OWL template scope
rule 20, t-out markup wrap, no SCSS @import, dark-mode compile-
time branch).

Self-review pass confirms 1-to-1 coverage of every spec section
and explicit deferral of every §13 Phase 2 item.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:34:42 -04:00
gsinghpal
48805b5988 docs(shopfloor): plant-view kanban redesign spec
Replaces per-step-grouped kanban with department-grouped (9 fixed columns).
One card per fp.job; recipe step count no longer drives layout width.

- 9 fixed columns in process sequence: Receiving / Masking / Blasting /
  Racking / Plating / Baking / De-Racking / Final inspection / Shipping
- new fp.work.centre.area_kind Selection + step_id.area_kind related
- 13 mutually-exclusive card states with explicit precedence list and
  matching _compute_card_state dispatcher
- Variant C card: WO header, customer/PN/qty/PO, recipe/spec, tag chips,
  current step + tank + state chip, 9-step mini-timeline, progress +
  operator pill + icon row
- /fp/landing/plant_kanban endpoint returns columns + denormalized cards
- MVP uses existing single-station pairing UX; M2M field structure is
  forward-compatible for cross-trained operators (Phase 2)
- Feature flag x_fc_shopfloor_layout for parallel rollout

Deferred to Phase 2: drag-drop, sibling grouping, bottleneck heatmap,
manager-specific KPIs, phone breakpoint, sort customization,
quick-action sheet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 20:22:17 -04:00
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
133 changed files with 16882 additions and 261 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',
'version': '19.0.3.3.0',
'version': '19.0.3.5.6',
'category': 'Human Resources/Attendances',
'summary': 'Complete Employee T&A with Geofencing, Shifts, Penalties, Overtime, Kiosk, Dashboard & Payroll Export',
'description': """
@@ -70,6 +70,7 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil
'views/clock_correction_views.xml',
'views/clock_dashboard_views.xml',
'views/hr_employee_views.xml',
'views/clock_schedule_views.xml',
# Wizards (must load before clock_menus.xml since menu references wizard action)
'wizard/clock_nfc_enrollment_views.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',
],
'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/js/fusion_clock_systray.js',
'fusion_clock/static/src/xml/systray_clock.xml',
'fusion_clock/static/src/js/fusion_clock_dashboard.js',
'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_places.js',
'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,
'auto_install': False,

Binary file not shown.

View File

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

View File

@@ -5,6 +5,7 @@
import base64
import math
import logging
import pytz
from datetime import datetime, timedelta
from odoo import http, fields, _
from odoo.http import request
@@ -108,6 +109,9 @@ class FusionClockAPI(http.Controller):
ICP = request.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_clock.enable_penalties', 'True') != 'True':
return
day_plan = employee._get_fclk_day_plan(get_local_today(request.env, employee))
if day_plan.get('source') == 'schedule' and day_plan.get('is_off'):
return
grace = float(ICP.get_param('fusion_clock.penalty_grace_minutes', '5'))
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
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
# Set to whichever is higher: configured break or existing (penalty-inflated) value
new_val = max(break_min, current)
@@ -268,6 +281,8 @@ class FusionClockAPI(http.Controller):
now = fields.Datetime.now()
today = get_local_today(request.env, employee)
day_plan = employee._get_fclk_day_plan(today)
is_scheduled_off = day_plan.get('source') == 'schedule' and day_plan.get('is_off')
geo_info = {
'latitude': latitude,
@@ -307,6 +322,34 @@ class FusionClockAPI(http.Controller):
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
scheduled_in, _ = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
@@ -359,8 +402,9 @@ class FusionClockAPI(http.Controller):
self._apply_break_deduction(attendance, employee)
# Check for early clock-out penalty
_, scheduled_out = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
if not is_scheduled_off:
_, scheduled_out = self._get_scheduled_times(employee, today)
self._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
# Log clock-out
self._log_activity(
@@ -518,6 +562,13 @@ class FusionClockAPI(http.Controller):
'pending_reason': employee.x_fclk_pending_reason,
'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:
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,
})
local_today = get_local_today(request.env, employee)
today_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee)
today_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),

View File

@@ -5,6 +5,7 @@
import logging
from odoo import http, fields, _
from odoo.http import request
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
_logger = logging.getLogger(__name__)
@@ -93,7 +94,9 @@ class FusionClockKiosk(http.Controller):
is_checked_in = employee.attendance_state == 'checked_in'
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 = {
'latitude': latitude,
@@ -120,8 +123,17 @@ class FusionClockKiosk(http.Controller):
source='kiosk',
)
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
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)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
return {
'success': True,
@@ -135,8 +147,9 @@ class FusionClockKiosk(http.Controller):
})
api._apply_break_deduction(attendance, employee)
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
if not is_scheduled_off:
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
api._log_activity(
employee, 'clock_out',

View File

@@ -8,6 +8,7 @@ import time
import threading
from odoo import fields, http
from odoo.http import request
from odoo.addons.fusion_clock.models.tz_utils import get_local_today
_logger = logging.getLogger(__name__)
_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'
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 = {
'latitude': 0,
@@ -208,8 +211,17 @@ class FusionClockNfcKiosk(http.Controller):
latitude=0, longitude=0, distance=0,
source='nfc_kiosk',
)
scheduled_in, _ = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
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)
api._check_and_create_penalty(employee, attendance, 'late_in', scheduled_in, now)
return {
'success': True,
'action': 'clock_in',
@@ -224,8 +236,9 @@ class FusionClockNfcKiosk(http.Controller):
'x_fclk_check_out_photo': photo_bytes if photo_bytes else False,
})
api._apply_break_deduction(attendance, employee)
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
if not is_scheduled_off:
_, scheduled_out = api._get_scheduled_times(employee, today)
api._check_and_create_penalty(employee, attendance, 'early_out', scheduled_out, now)
api._log_activity(
employee, 'clock_out',
f"NFC kiosk clock-out from {location.name}. Net: {attendance.x_fclk_net_hours:.1f}h",

View File

@@ -100,7 +100,9 @@ class FusionClockPortal(CustomerPortal):
], limit=1)
# 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([
('employee_id', '=', employee.id),
('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)
# Week stats
today = get_local_today(request.env, employee)
week_start = today - timedelta(days=today.weekday())
week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
week_atts = request.env['hr.attendance'].sudo().search([
@@ -151,6 +152,7 @@ class FusionClockPortal(CustomerPortal):
'current_attendance': current_attendance,
'today_hours': round(today_hours, 1),
'week_hours': round(week_hours, 1),
'today_schedule': today_schedule,
'recent_attendances': recent,
'google_maps_key': google_maps_key,
'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_leave_request
from . import clock_shift
from . import clock_schedule
from . import clock_correction
from . import res_company

View File

@@ -34,6 +34,7 @@ class FusionClockActivityLog(models.Model):
('correction_request', 'Correction Request'),
('ip_fallback', 'IP Fallback Used'),
('streak_milestone', 'Streak Milestone'),
('unscheduled_shift', 'Unscheduled Shift'),
('card_enrollment', 'Card Enrollment'),
('unknown_card_tap', 'Unknown Card Tap'),
],
@@ -108,6 +109,7 @@ class FusionClockActivityLog(models.Model):
'correction_request': 'Correction Request',
'ip_fallback': 'IP Fallback Used',
'streak_milestone': 'Streak Milestone',
'unscheduled_shift': 'Unscheduled Shift',
}
@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
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
if net > scheduled_hours:
@@ -264,11 +275,14 @@ class HrAttendance(models.Model):
employee = att.employee_id
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()
_, 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)
effective_deadline = min(deadline, max_deadline)
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)
if now > effective_deadline:
clock_out_time = min(effective_deadline, now)
@@ -283,7 +297,7 @@ class HrAttendance(models.Model):
# Apply break deduction
threshold = float(ICP.get_param('fusion_clock.break_threshold_hours', '4.0'))
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().message_post(
@@ -346,6 +360,9 @@ class HrAttendance(models.Model):
if yesterday.weekday() >= 5:
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)
@@ -423,6 +440,9 @@ class HrAttendance(models.Model):
if today.weekday() >= 5:
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:
continue

View File

@@ -120,11 +120,82 @@ class HrEmployee(models.Model):
help="Tracks the last date a reminder was sent to avoid duplicates.",
)
def _get_fclk_break_minutes(self):
"""Return effective break minutes for this employee.
Priority: employee override > shift > global setting.
def _get_fclk_schedule_for_date(self, date):
"""Return this employee's dated Fusion Clock schedule for a local date."""
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()
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:
return self.x_fclk_break_minutes
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):
"""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
timezone and converted to naive-UTC datetimes so they can be
compared with Odoo's UTC-based ``fields.Datetime.now()``.
@@ -146,13 +217,9 @@ class HrEmployee(models.Model):
import pytz
self.ensure_one()
if self.x_fclk_shift_id:
in_hour = self.x_fclk_shift_id.start_time
out_hour = self.x_fclk_shift_id.end_time
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'))
plan = self._get_fclk_day_plan(date)
in_hour = plan.get('start_time') or 0.0
out_hour = plan.get('end_time') or 0.0
in_h = int(in_hour)
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)
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."""
self.ensure_one()
if self.x_fclk_shift_id:
return self.x_fclk_shift_id.scheduled_hours
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'))
break_hrs = self._get_fclk_break_minutes() / 60.0
return max((out_hour - in_hour) - break_hrs, 0.0)
plan = self._get_fclk_day_plan(date or get_local_today(self.env, self))
if plan.get('is_off'):
return 0.0
return plan.get('hours') or 0.0
def _compute_absence_counts(self):
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_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_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_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
@@ -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_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_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
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'))]"/>
</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
================================================================ -->
@@ -286,4 +329,15 @@
<field name="perm_unlink" eval="False"/>
</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>

View File

@@ -219,6 +219,63 @@ body:has(.fclk-app) .o_footer {
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 ---- */
.fclk-timer-section {
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_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"
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 -->
<menuitem id="menu_fusion_clock_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>
<!-- 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 -->
<div class="fclk-timer-section">
<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_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
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")`.
@@ -201,6 +228,9 @@ These modules have **source code in this repo** but are **intentionally NOT inst
Both are test-data scaffolding; neither weakens assertions and neither must appear in production code paths.
18. **Portal list pages — no pagination, 500-record cap**: All FP portal list routes (quote requests, jobs, certifications, deliveries) load up to 500 records and rely on client-side JS filtering. Do NOT re-add `portal_pager` to these routes. The `fp_portal_list_controls` macro + `fp_portal_list_search.js` handle filtering, counting, and the sort dropdown. Hidden `<td class="d-none">` cells inside each row carry extra searchable text (part number, customer PO, contact) that isn't displayed but is matched by the JS.
19. **QWeb `t-value` is Python, not Jinja**: `t-value="orders|length"` does NOT call a filter — Python parses `|` as bitwise/recordset OR, so on a non-empty recordset it tries `recordset | length_var` and raises `TypeError: unsupported operand types in: sale.order(…) | None` (when `length` is undefined) or returns a merged recordset (when `length` happens to be another recordset). Use `len(orders)` or `bool(orders)` or `(orders and orders[0]) or False` — explicit Python. Same trap applies to `|default`, `|first`, `|join`, etc. — none of these Jinja filters exist in QWeb. Bit us 2026-05-18 on `fp_sale_order_portal.xml` injecting `result_total` into the list-controls macro.
20. **OWL templates expose `Math` but NOT `String` / `Number` / `Array` / `Object` / `Boolean` / `JSON` / `parseInt` / `parseFloat`**: writing `t-on-click="() => this._press(String(d))"` (or similar coercion inside any template expression) throws `Uncaught TypeError: v2 is not a function` at click time — `v2` is OWL's compiled reference to a global that doesn't exist in template scope. The click handler dies before its body runs, so the bug looks like "nothing happens when I press" (no error in the UI, only DevTools shows the trace). **Fixes, in order of preference**: (a) eliminate the coercion entirely — store data in the right type up front, e.g. `t-foreach="['1','2','3']"` instead of `[1,2,3]` so `d` is already a string. (b) Use a JS-side coercion: pass the raw value to the handler and call `String(digit)` inside the component method. (c) Use a pure-expression workaround like string concatenation: `'' + d` does work because `+` is an operator, not a function. **Do NOT try to monkey-patch `String` onto the component (e.g. `this.String = String`) or onto `env` — leaks the global into every component and is fragile across OWL upgrades.** Bit us 2026-05-23 on `pin_pad.xml` — operators couldn't tap PIN digits at all because the click handler died on `String(d)`; the SCSS, reactivity, and `_press` method were all fine, the template scope was the entire bug. Same trap applies to OWL templates anywhere in the codebase: `move_parts_dialog.xml`, `manager_dashboard.xml`, `fp_record_inputs_dialog.xml`, etc. — grep all `t-on-click`, `t-att-*`, and `t-out` expressions for `String(`, `Number(`, `Array(`, `parseInt(`, `parseFloat(`, `JSON.` before merging.
21. **`ir.actions.act_window_close` is a no-op when the current action was opened with `target: "current"`**: replacing the current action wipes the breadcrumb backstack, so there's nothing to close back to. The user clicks "Back" and nothing happens (no error, no navigation). This bites every OWL client-action surface that calls another client action via `doAction({..., target: "current"})` — the destination has no way to return to the source. **Fix pattern for "Back" buttons in OWL client actions**: navigate EXPLICITLY to the landing/parent action by tag, e.g. `this.action.doAction({ type: "ir.actions.client", tag: "fp_shopfloor_landing", target: "current" })` — works regardless of how the action was reached (kanban tap, QR scan, smart button, direct URL). **Do NOT rely on `act_window_close`, `history.back()`, or `this.env.config.breadcrumbs`** — all three are unreliable across navigation paths. Bit us 2026-05-23 on the Job Workspace Back button after the kanban opened the workspace with `target: "current"`. The same pattern applies to every other "Back" button in shopfloor / manager / portal OWL surfaces — explicit destination via `tag:` is the only robust answer.
22. **Odoo 19 HTML fields auto-wrap plain-string writes**: writing `co.report_header = 'Plating & Finishing'` to an HTML field (like `res.company.report_header`, `res.partner.comment`, `mail.template.body_html`, `product.template.description_sale`) stores `<p>Plating &amp; Finishing</p>` after Odoo's HTML sanitizer runs. Equality tests against the raw input string FAIL (`payload['tagline'] != 'Plating & Finishing'`). **Three implications**: (a) **In tests**, don't `assertEqual` against the literal string you wrote — strip tags first, OR write the wrapped form (`<p>Plating & Finishing</p>`), OR write an explicit `Markup('<p>...</p>')` so the round-trip stays stable. (b) **In display code**, render HTML fields with `t-out` (QWeb) or `markup(...)` (OWL) — `t-esc` would render the literal `<p>` tags as text. (c) **In comparison logic**, normalize first: `from markupsafe import escape; escape(input_str)` produces the same shape the field stores. Bit us 2026-05-24 testing the lock-screen tagline source (`_lock_company_payload` reads `res.company.report_header`); the test that wrote a plain string and asserted equality failed because the value came back wrapped. The fix was to delete the brittle equality test — the helper's responsibility is just "use the field's value when present, else fall back," which is covered by the empty-field test. Generalizes to ANY HTML-typed Odoo field. Distinct from the `mail.template.body_html is Markup + jsonb` gotcha noted earlier in this file — that's about Markup objects vs strings; this is about the sanitizer wrapping plain strings on write.
## Naming
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)
@@ -391,6 +421,141 @@ Plan: [docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md](docs
- Don't add `web.assets_web_dark` entries to the manifest — Odoo 19 auto-compiles `web.assets_backend` SCSS into both bundles
- Don't bypass `_fp_should_block_predecessors()` when computing step blockers — keep `blocker_kind=predecessor` logic in sync with `can_start`
## Shop Floor — Plant View kanban (2026-05-23 redesign)
**Default Shop Floor surface** for new installs (gated by feature flag
`ir.config_parameter['fusion_plating_shopfloor.layout']`, values `legacy`
or `v2`). Legacy per-step kanban (`fp_shopfloor_landing`) remains
accessible by flipping the flag back to `legacy` in Settings → Fusion
Plating.
**Why redesign:** the per-step kanban produced one card per recipe step
per column, so a 14-step recipe spawned 9+ cards for ONE job across the
board. With 17 active jobs the board showed 100+ duplicate cards across
narrow columns. The new design is **one card per `fp.job`** at the
**department level** — recipe step count no longer drives layout width.
**Spec:** `docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md`
**Plan:** `docs/superpowers/plans/2026-05-23-shopfloor-plant-view-plan.md`
### Layout — 9 fixed columns in process sequence
`Receiving → Masking → Blasting → Racking → Plating → Baking →
De-Racking → Final inspection → Shipping`
Columns are first-class — they always render in this exact order, never
reorder, never collapse when empty. Driven by `fp.work.centre.area_kind`
Selection (added 2026-05-23). Each `fp.job.step.area_kind` is computed
(stored) from `work_centre.area_kind` with a fallback to a step-kind
dispatch table (`_STEP_KIND_TO_AREA` in `fusion_plating_jobs/models/fp_job_step.py`).
**Spec D3:** all wet-line steps (Soak Clean, Electroclean, Acid Dip,
Etch, Desmut, Zincate, Rinse, E-Nickel, Chrome, Anodize, Black Oxide,
Drying) roll up into the **Plating** column. The tank chip on the card
distinguishes them.
**Spec D4:** De-Masking folds into De-Racking (no separate column).
**Spec D5:** Contract Review (paperwork) cards live in Receiving with a
purple "📋 QA-005" chip — they're admin gates, not physical work.
### Card state catalog — 13 mutually-exclusive states
`fp.job.card_state` is a stored Char computed in `_compute_card_state`
(see `fusion_plating_jobs/models/fp_job.py`). Explicit precedence
dispatch matching spec §6.2 — first match wins:
`no_parts → on_hold → awaiting_signoff → awaiting_qc → bake_due →
predecessor_locked → idle_warning → done → contract_review →
running_mine/running → ready_mine/ready`
Each state has a distinct background tint + left-border color + chip +
mini-timeline marker color. See `_plant_card.scss` for the mapping. The
"mine" variants (`ready_mine`, `running_mine`) light up only when the
active step's work centre is in `res.users.paired_work_centre_ids` (the
M2M holds one row in MVP, mirrors the existing single-station picker).
### Backend — single endpoint, denormalized payload
`/fp/landing/plant_kanban` (controller in
`fusion_plating_shopfloor/controllers/plant_kanban.py`) returns
`{ok, mode, paired_station, kpis, columns, cards}` in one JSONRPC call.
Frontend has zero per-card RPCs — every card field comes pre-formatted
from the controller's `_render_card`. State-chip text (with elapsed
times, operator names, hours-idle) is interpolated server-side.
### Frontend — OWL component tree
```
FpPlantKanban (client action 'fp_plant_kanban')
└── FpTabletLock (PIN gate wrapper)
├── PlantHeader (KPIs + filter chips + mode toggle + station picker)
└── Board (9 × Column)
├── FpColumnHeader (with 'You're here' badge for paired column)
└── FpPlantCard[] (each with FpMiniTimeline)
```
Polls every 10s. Filter state persists in localStorage. All 13 card
states styled via `.state-<name>` CSS modifier classes on a single
shared `.o_fp_plant_card` base. The mini-timeline renders 9 colored
dots driven by `fp.job.mini_timeline_json` (Python emits the array
shape — frontend just maps state → CSS class).
### Critical implementation gotchas (project rules applied)
- **OWL templates only expose `Math` as a JS global** (Rule 20). All
coercion (String, Number, parseInt) MUST happen in JS — `tag_chip_class()`
/ `progress_style` etc. live in plant_card.js, not in the XML.
- **SCSS @import is forbidden** (Rule 8). `_plant_tokens.scss` loads
FIRST in the manifest's `web.assets_backend`; subsequent component
partials get the `$plant-*` vars via the concatenated bundle.
- **Dark mode** via `$o-webclient-color-scheme == dark` compile-time
branch in `_plant_tokens.scss` (NOT runtime class selectors).
### How to switch back to legacy
```sql
UPDATE ir_config_parameter SET value = 'legacy'
WHERE key = 'fusion_plating_shopfloor.layout';
```
Or use Settings → Fusion Plating → Shop Floor Layout. Both surfaces
write the same `ir.config_parameter` key.
### Legacy-action redirect (general rule for OWL component swaps)
When replacing an OWL client-action component with a new one, **don't
just register the new action's XMLID**. There are usually 2-5 legacy
`ir.actions.client` data records scattered across the module pointing
at the old tag (`action_fp_plant_overview`, `action_fp_shopfloor_tablet`,
etc. — every "old bookmarks keep working" record). The landing-action
resolver only sees one entry point. Bookmarks, breadcrumbs, QR-scan
landings, and "Plant Overview" / "Tablet Station" menu items go
through the OTHER actions and load the old component.
**Fix: change every legacy data record's `tag` to the new tag.** Grep
the views/ and data/ dirs for the old tag, and update each `<field
name="tag">` to the new one. The old OWL component stays registered
(no code removed), but no `ir.actions.client` row points at it
anymore. Caught 2026-05-23 when the plant-view rollout dispatched
the resolver correctly but a user clicking via the legacy "Shop Floor"
menu still saw the per-step kanban — `action_fp_shopfloor_tablet`
and `action_fp_plant_overview` were both still hard-coded to
`fp_shopfloor_landing` tag.
**Also grep JS for hardcoded `doAction({tag: ...})` calls** — XML
data records are only half the story. OWL components that wire up
"Back" buttons / navigation often hardcode the destination tag in
JS (e.g. `this.action.doAction({type: "ir.actions.client", tag:
"fp_shopfloor_landing", target: "current"})`). These bypass the
data layer entirely, so the redirect trick above doesn't cover
them. Caught 2026-05-24 — the Job Workspace `onBack()` still
pointed at `fp_shopfloor_landing`, so tapping a card in the new
plant kanban → opening the workspace → clicking Back dropped the
user into the deprecated per-step kanban. Fix: `grep -rn
'tag: ["\x27]<old_tag>' static/src/js/` before considering the
swap complete; rewrite every match to point at the new tag.
## Deployment
### odoo-entech (LXC 111 on pve-worker5)
@@ -1105,6 +1270,9 @@ Each script is self-contained — builds a fresh SO + job, walks the scenario, a
| **S19** | Lisa uploads Fischerscope X-Ray thickness PDF to QC; CoC ships without it as page 2 — and even after the back-end merge worked, operators couldn't *see* in the cert form whether the merge would happen | Existing merge logic lived in uninstalled `fusion_plating_bridge_mrp` (keyed off `mrp.production` — gone with Sub 11). Post-Sub-11 cert path rendered CoC only; Fischerscope PDF stayed orphaned on the QC record. Even after Phase 1 fix shipped, the cert form had **zero** indicator that a thickness PDF was on file or had been merged → user reported "I did not see anything in the certification issue" | **Phase 1 (back-end merge):** Ported merge to `fp.certificate._fp_merge_thickness_into_pdf`. New `_fp_render_and_attach_pdf` wraps cert PDF generation: renders the CoC via QWeb, then looks up the linked `fusion.plating.quality.check` (`x_fc_job_id → fp.job → QC`), finds the most recent passed QC with `thickness_report_pdf_id`, merges via `pypdf.PdfWriter.append()` (PyPDF2 `PdfMerger` fallback), posts chatter audit `Fischerscope thickness report from QC <name> appended to CoC PDF.`. Hooked into `action_issue` so the multi-page PDF lands on `attachment_id` automatically. **Phase 2 (UI surface):** Added 3 computed fields on `fp.certificate` (in `fusion_plating_jobs`): `x_fc_thickness_qc_id` (linked QC), `x_fc_thickness_pdf_id` (Fischerscope PDF), `x_fc_thickness_status` (`none` / `pending` / `merged`). Cert form now shows: (1) coloured banner above the title — blue "Will Append on Issue" / green "Merged" / amber "No PDF — operator action required"; (2) two new smart buttons (Plating Job, Fischerscope status); (3) new "Thickness Report (Fischerscope)" notebook tab with clickable PDF preview + step-by-step instructions when none uploaded | `fusion_plating_certificates 19.0.5.2.0`, `fusion_plating_jobs 19.0.6.20.0` | `bt_s19_fischer_merge.py` (asserts both pre-Issue `pending` + post-Issue `merged` status flips) |
| **S20** | Tablet Station UX hardening — three real-world UX gaps surfaced during a persona walk on the Tablet + Manager Desk client actions | (a) **Scrap reason dropped**: `/fp/shopfloor/bump_qty_scrapped` accepted operator's typed reason via `window.prompt`, passed it through context as `fp_scrap_reason` — but `fp.job.write` never read it, so the auto-spawned Hold's description had the generic "OPERATOR: replace this text with the actual reason" placeholder instead of what Carlos typed. Audit trail lost what just happened on the floor. (b) **KPI/panel mismatch**: tablet KPI strip showed plant-wide totals ("Quality Holds: 12") but the Holds panel below was scoped to the operator's own jobs (might show 0). Operator stares at a big red 12, scrolls down, sees nothing — confused/distrustful. (c) **UserError stack-trace leak**: when `start_wo` hit an S14 predecessor lock (or any other `button_start`-side guard), the raw `UserError` propagated through the JSON-RPC handler and operator got a Python stack-trace dialog instead of the nice `setMessage("...", "danger")` flash. Same hole on `stop_wo`, `start_bake`, `end_bake`, `mark_gate`, `bump_qty_done`, `bump_qty_scrapped`. | (a) `fp.job.write` now reads `self.env.context.get('fp_scrap_reason')` and prepends `Operator reason: <text>` to the Hold description so the audit row captures what the operator actually typed. (b) Tablet KPI strip now reuses `my_job_ids_for_kpi` (the operator's own steps) for `awaiting_bakes`, `bake_in_progress`, `missed`, `open_holds` — same scope as the panels below, so the strip never lies. Manager dashboard keeps its own plant-wide KPI set. (c) Wrapped every action endpoint in `try: ... except UserError as e: return {'ok': False, 'error': str(e.args[0])}` — operator now gets the clean `setMessage` flash with the real guard text ("Step 'X' requires predecessors done first…") instead of a stack-trace popup. | `fusion_plating_jobs 19.0.6.22.0`, `fusion_plating_shopfloor 19.0.24.4.0` | persona walk via `sim_tablet_actions.py` + `sim_reverify.py` (asserts: typed reason ends up in hold.description, KPI=panel for holds, `start_wo` returns `{ok:False, error:"..."}` for locked step) |
| **S20** | **Tablet usability pass** — operators were squinting at the tablet, scanning back-and-forth between recipe binders and the screen because the tablet showed step names but no targets, no live timer, no predecessor visibility. QC fail left parts in limbo with no Hold record. Manager Desk showed feel-good KPIs but hid the compliance bombs (missed bakes, stale steps, locked steps, holds, pending QC missing PDF) | Tablet `My Queue` rows had no `instructions`, `thickness_target`, `dwell_time_minutes`, `bake_setpoint_temp`, `requires_signoff` — operators kept scanning the QR code just to read the bake temperature. Steps with `requires_predecessor_done=True` (S14) showed a green Start that always failed with a UserError. Active step "duration" was a stale number that only refreshed every 30s. Holds and bake windows showed plant-wide noise from other crews. **No banner alerted Carlos when his job had a pending QC** (Lisa was not called → QC sat for hours). **No way to bump qty_done or scrap from the tablet** → S17 hold auto-spawn never fired because operators didn't update the field. **`action_fail` on QC marked the check failed but spawned no Hold** — AS9100 disposition trail broken. **Manager Desk KPIs were missing 7 compliance metrics**: stale paused/in-progress steps (cron data), missed bake windows, open holds, predecessor-locked steps, pending QCs, QCs missing Fischerscope PDF, draft cert pipeline | **Carlos's Shopfloor Tablet** — every queue row now carries the recipe-author fields (instructions snippet, thickness target chip, dwell-time chip, bake-temp chip, sign-off badge) so operators read the targets inline. Predecessor-blocked steps render with a 🔒 lock icon, an "Awaiting [step name]" notice, and a disabled `Locked` button (no more Start-then-fail). Active step now shows a **live ticking HH:MM:SS clock** (1s interval, computed from `date_started_iso` JS-side; flips to red on >1.5× planned duration) plus `+1 Done` and `Scrap` buttons that hit two new endpoints (`/fp/shopfloor/bump_qty_done`, `/fp/shopfloor/bump_qty_scrapped` — scrap prompts for reason and S17 auto-spawns the Hold). New **Pending QC banner** lists open QCs for my jobs with line-progress + Fischerscope-PDF status badge, and a tap deep-links into Lisa's mobile QC checklist. Holds and bake windows are now **scoped to my jobs first** (fall back to facility-wide for managers). **QC checklist** — `action_fail` now auto-creates a `fusion.plating.quality.hold` with `hold_reason='qc_failure'` (new selection value), populated description listing the failed checks, idempotent on retry. **Manager Desk** — 7 new clickable compliance KPI tiles: Missed Bakes (S15), Open Holds (S17 + QC fail), Stale Steps (S10/S16 cron data), Locked Steps (S14), Pending QC + "X need PDF" (S19 + missing-Fischerscope), Draft Certs + "Y today" (cert pipeline). Each tile drills into a list filtered to the relevant exception | `fusion_plating_shopfloor 19.0.24.3.0`, `fusion_plating_quality 19.0.4.8.0` | `sim_tablet_walk.py`, `sim_timer_pred_test.py`, `sim_qc_fail_hold.py`, `sim_manager_qc_fail.py` (one-off persona walkthroughs) |
| **S21** | Riya finished steps on WO-30051 without filling in mandatory recipe-author prompts — Incoming Inspection skipped "Take and Upload Photos" (1/5 missed), Check Sulfamate Nickel Area skipped both masking-verification booleans (2/3 missed). AS9100 audit trail broken on a per-step basis. | (a) `_fp_has_uncaptured_step_inputs` returned False as soon as ANY move with input values existed since `date_started` — too coarse, let operators clear the dialog re-open by saving a single prompt. (b) `button_finish` had NO gate enforcing required step_input coverage — only Contract Review + Receiving gates fired. (c) OWL `Record Inputs` dialog `onSave()` had no client-side check for required prompts either, so operators got zero feedback when leaving fields blank. (d) Also caught: `fp_job_step.py` had **two `def button_finish` in the same class** — Python silently kept only the second definition, so the bake.window auto-spawn + duration-overrun warning at line 596 had been dead code for the entire WO-30051 era. | **Server gate** — new `_fp_check_step_inputs_complete()` on `fp.job.step` raises `UserError` listing every missing required step_input by name. Hooked into `button_finish` ahead of the existing Contract Review + Receiving gates. New helper `_fp_missing_required_step_inputs()` returns the recordset of required prompts with NO recorded value across any move from this step (centralised — used by both the gate and the dialog re-open helper). `_fp_has_uncaptured_step_inputs()` tightened to delegate to the new helper. **Client gate** — `onSave()` on `FpRecordInputsDialog` mirrors the server check when `advanceAfter=true` (Finish & Next path) so operators see a sticky red "Cannot finish step — N required prompts missing: ..." notification instantly rather than after a server roundtrip. Partial saves via the per-row Record button (`advanceAfter=false`) remain unblocked — operators can still capture progress and come back to fill the rest. **Manager bypass** — `fp_skip_required_inputs_gate=True` (documented deviations / paper-form catch-up); posts chatter audit naming the user. **Dead-code merge** — the duplicate `button_finish` at line 596 was deleted; its bake.window auto-spawn + duration-overrun chatter logic was folded into the canonical `button_finish` (which now runs in order: required-inputs gate → CR gate → receiving gate → `super()` → post-finish side effects). **Critical lesson — never put two `def <name>` in the same `models.Model` class body**. Python silently keeps the last one; the earlier definition becomes dead code with no warning. Always grep for duplicates after any structural edit on a long model file. | `fusion_plating_jobs 19.0.10.22.0` | Smoke: `step._fp_missing_required_step_inputs()` on any in_progress step returns the prompt recordset that would block finish. Server: try `step.button_finish()` on a step with required prompts unrecorded — should raise UserError listing them. Manager bypass: `step.with_context(fp_skip_required_inputs_gate=True).button_finish()` succeeds + posts audit. |
| **S22** | Deep-audit finding F1 (2026-05-23) — `fp.job.step.requires_signoff` was 100% unenforced on entech: 42 of 42 done steps with the field set had `signoff_user_id IS NULL`. Recipe authors believed they'd gated aerospace / Nadcap steps; reality was the field was decorative. Pre-Sub-11 the `mrp.workorder.x_fc_signoff_user_id` had working logic, but Sub 11's MRP cutout removed bridge_mrp without porting the gate. | `signoff_user_id` was defined `readonly=True` on `fp.job.step` (from `fusion_plating/models/fp_job_step.py`) but **no code anywhere wrote to it**. No autosign on finish, no UI button, no `action_signoff`. Deep audit caught this because the 42/42 = 100% NULL ratio is the dead giveaway — when a "required" field has zero non-NULL rows across 42 records, the field's enforcement code is missing entirely. | **Three-piece fix on `fp.job.step`**: (1) `_fp_autosign_if_required()` — auto-sets `signoff_user_id = env.user.id` for the user clicking Finish, idempotent (preserves a supervisor's pre-sign via `action_signoff`). (2) `_fp_check_signoff_complete()` — raises `UserError` when `requires_signoff=True` and `signoff_user_id` is still NULL after the autosign helper has run (i.e. migration scripts, background crons with no env.user). (3) `action_signoff()` — explicit sign-off action for the case where a supervisor reviews and signs BEFORE the operator clicks Finish. Same-user re-click is a no-op; a DIFFERENT user re-signing overwrites the prior signer and posts a chatter reassignment ("Sign-off on step X reassigned from A to B"). Both helpers hook into `button_finish` AFTER `_fp_check_step_inputs_complete` and BEFORE the Contract-Review gate. **Manager bypass** — `fp_skip_signoff_gate=True` (documented deviations); posts chatter naming the user. **Lesson — for ANY "required" Boolean field that gates downstream behaviour, ALWAYS deep-audit the enforcement path: search the codebase for writes to the gated field, not just the boolean.** If zero writes exist, the gate is structural / decorative only. Grep the codebase periodically for `_check_*` helpers whose triggering field has no inverse writer. | `fusion_plating_jobs 19.0.10.23.0` | Verified end-to-end on entech: autosign sets signoff_user_id, gate raises UserError with the right message, bypass posts chatter audit, action_signoff sets + posts chatter, and the S21 required-inputs gate still fires (no regression). |
| **S23 (shipped)** | Deep-audit bonus finding (2026-05-23) — `fp.job.step.requires_transition_form` had the same dormant-field shape as S22's signoff bug. The bypass context flag `fp_skip_transition_form` was already wired into the move controller's audit trail, but **no actual gate ever fired** because `_blockers_for_move` only enumerated `rack_required` + `predecessor_lock`. 0 of 286 moves on entech had this set (recipe authors hadn't enabled it), so no current audit gap — but the next recipe author who flips the toggle would discover the same cosmetic-only behaviour Riya found on S21. Caught preventively rather than reactively. NB: numbering conflicts with the open-scenarios list (also lists S23) — accept; the open list will be renumbered in a future doc-cleanup pass. | (a) `_blockers_for_move` in `fusion_plating_shopfloor/controllers/move_controller.py` had no `transition_form_required` case, only rack + predecessor. (b) The Move Rack controller `_do_move_rack_commit` didn't capture transition prompts at all — even if `requires_transition_form` were enforced on Move Parts, rack moves silently bypassed it. (c) The model layer `fp.job.step.move` had no helper to compute "missing required transition inputs", so any backend caller (wizards, scripts) had no way to enforce the contract. | **Model layer** — added two helpers to `fp.job.step.move` (canonical location): `_fp_missing_required_transition_inputs()` returns the recordset of required transition_input prompts on `to_step.recipe_node_id` that have no captured value on the move. `_fp_check_transition_inputs_complete()` raises `UserError` listing the missing prompts, manager bypass via `fp_skip_transition_form=True` (consistent with the existing audit-trail flag, NOT a new flag name), posts chatter on the move record on bypass. **Controller wiring** — `move_parts_commit` calls the gate AFTER `_capture_prompt_value` (so the operator gets credit for whatever they filled in; rollback unwinds the move + values on failure). `move_rack_commit` pre-rejects with a clear message ("use Move Parts so the form can be filled in") because rack moves have no per-batch prompt-capture UI. **Design choice** — gate is invoked explicitly by callers rather than via `create()` override; values are written in a separate call after the move row, so a model-level `create()` hook would always misfire. Future backend wizards / scripts MUST call `_fp_check_transition_inputs_complete()` after capturing prompt values, or pass `fp_skip_transition_form=True` if intentionally bypassing. **Two-layer pattern lesson** — when a recipe-author flag (here `requires_transition_form`) has BOTH a quick path (Move Rack — no form UI) AND a rich path (Move Parts — full form UI), the quick path MUST either implement the form OR reject the operation. A silent quick-path bypass defeats the whole gate. | `fusion_plating 19.0.20.9.0`, `fusion_plating_shopfloor 19.0.30.3.0` | Verified live on entech: helpers callable, move-parts commit raises on missing required prompts, move-rack commit rejects up-front when `to_step.requires_transition_form=True`, manager bypass via context flag posts move-chatter audit. |
### Manager-bypass context flags
@@ -1118,6 +1286,9 @@ When you need to override a guard (documented customer deviation, emergency rewo
| `fp_skip_bake_gate=True` | bake.window pending check on `button_mark_done` (S15) |
| `fp_skip_predecessor_check=True` | requires_predecessor_done check on `button_start` (S14) |
| `fp_skip_missed_window=True` | missed_window block on `bake.window.action_start_bake` (S6) |
| `fp_skip_required_inputs_gate=True` | required step_input prompts check on `fp.job.step.button_finish` (S21). Posts chatter audit naming the user. |
| `fp_skip_signoff_gate=True` | `requires_signoff` + `signoff_user_id` check on `fp.job.step.button_finish` (S22). Posts chatter audit naming the user. Note: button_finish auto-sets signoff_user_id to the finisher first (via `_fp_autosign_if_required`); this bypass only matters when even the autosign can't fire (migration scripts, background crons with no env.user). |
| `fp_skip_transition_form=True` | `requires_transition_form` + required transition_input coverage check on `fp.job.step.move._fp_check_transition_inputs_complete` (S23). Also drops the existing rack-vs-transition-form pre-reject on `move_rack_commit`. Posts chatter audit on the move record. Manager-only — controller checks the `fusion_plating.group_fusion_plating_manager` membership before honoring the flag. |
### Daily / hourly crons added by battle tests
@@ -1129,13 +1300,13 @@ When you need to override a guard (documented customer deviation, emergency rewo
### Open scenarios — flagged for next session
- **S21** — Operator clocks two steps simultaneously across different jobs (multi-tasking conflict)
- **S22** — Bath chemistry drift mid-step — operator measures bath while plating, value out of spec; no alert on the step
- **S23** — Wrong recipe attached — Carlos sees mismatch with the part he's holding; recovery path?
- **S24** — Customer orders 100 parts spread across 3 jobs; one job's recipe gets edited — does it propagate to siblings?
- **S25** — Hold-aging cron + 3-day escalation (flagged in original audit, not yet built)
- **S26** — Calibration + permit-expiry cron (flagged in original audit, not yet built)
- **S27** — FAIR detection on first-shipment to a new customer/part combo (flagged in original audit, not yet built)
- **S23** — Bath chemistry drift mid-step — operator measures bath while plating, value out of spec; no alert on the step (renumbered from S22 when S22 was claimed for the signoff gate)
- **S24** — Wrong recipe attached — Carlos sees mismatch with the part he's holding; recovery path?
- **S25** — Customer orders 100 parts spread across 3 jobs; one job's recipe gets edited — does it propagate to siblings?
- **S26** — Hold-aging cron + 3-day escalation (flagged in original audit, not yet built)
- **S27** — Calibration + permit-expiry cron (flagged in original audit, not yet built)
- **S28** — FAIR detection on first-shipment to a new customer/part combo (flagged in original audit, not yet built)
- **S29** — Operator clocks two steps simultaneously across different jobs (multi-tasking conflict; renumbered from S21 → S28 → S29)
### Tablet UI / persona-coverage gaps (S20 audit follow-ups)

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,779 @@
# Shop Floor Plant View — Redesign
**Date:** 2026-05-23
**Status:** Design — approved through brainstorming, awaiting plan
**Replaces:** the current Shop Floor kanban (per-step grouping, one card per step)
**Affects:** `fusion_plating_shopfloor` (primary), `fusion_plating` (work centre taxonomy), `fusion_plating_jobs` (active-step + workflow-state computes)
---
## 1. Problem
The current Shop Floor kanban groups cards by individual `fp.job.step.work_centre_id`. Every ready/pending step of a job spawns a separate card in its respective column. A 14-step recipe (e.g. `ENP-ALUM-BASIC` on WO-30019) produces **9 cards across 9 columns for ONE job**. With 17 active jobs on the floor, the board shows 100+ cards across 10+ narrow columns, most of which contain duplicates of the same WO.
Confirmed by the user via screenshots taken 2026-05-23:
> "the same job is appearing in multiple places, there can be 20 steps in any job and we cannot just make 20 columns for those jobs"
Net effect:
- Operators can't scan the board — duplicates drown the signal
- Recipes with many steps (15+) make the board explode horizontally
- "Where is WO-30019 right now?" is impossible to answer at a glance
- The mode toggle (Station / All Plant) is cosmetic — both produce the same cluttered output
The redesign re-anchors the kanban on **one card per job** at the **department level**, and scales to any recipe step count.
---
## 2. Goals & non-goals
### Goals
1. **Every active fp.job appears in EXACTLY ONE column** at all times. No duplication.
2. **Fixed 9-column layout** that doesn't grow with recipe step count.
3. **Columns always render in process sequence** (Receiving → … → Shipping), regardless of card distribution. Empty columns still show.
4. **Operator paired to a station sees their work highlighted** but can also see the whole plant — "Where is everything right now?" is the central operator question.
5. **Every floor state the audit + battle-test catalog exposes is visually distinguishable on the card** (13 states total).
6. **Scales infinitely**: a 5-step recipe and a 30-step recipe both produce single cards moving across the same 9 columns.
7. **Tablet-first** — readable on a 1080p wall-mounted tablet without horizontal scroll.
### Non-goals
- **Replacing the Job Workspace** (the full-screen single-WO surface). The kanban is the entry point; the Workspace remains the place where work happens. Card tap opens the Workspace.
- **Replacing the Manager Dashboard** (`fp_manager_dashboard` with workflow funnel + at-risk + heatmap). The kanban's "Manager" mode is a filter on the same board; the dedicated dashboard stays separate.
- **Drag-and-drop step advancement** from the kanban. State transitions happen inside the Workspace or via Move dialogs. The kanban reflects state, doesn't drive it.
- **Per-tank columns**. Tanks are surfaced as chips on the card, not as columns.
---
## 3. Decisions locked during brainstorming (2026-05-23)
| # | Decision |
|---|---|
| D1 | **Plant-wide view with mine highlighted** is the operator default (over "filter to my station only"). Operators help each other and cover stations; visibility matters more than filtering. |
| D2 | **9 fixed columns** by process area (Receiving, Masking, Blasting, Racking, Plating, Baking, De-Racking, Final inspection, Shipping). |
| D3 | **All wet steps roll up into Plating** — Soak Clean, Electroclean, Acid Dip, Etch, Desmut, Zincate, Rinse, Water Break Test, E-Nickel Plating, Chrome, Anodize, Black Oxide, Drying. The tank chip on the card distinguishes them. |
| D4 | **De-Masking folds into De-Racking** — same operator action in this shop's workflow; no separate column. |
| D5 | **Contract Review (paperwork) cards live in Receiving** with a purple paperwork chip. Same for any pre-physical-work admin gate. |
| D6 | **Variant C card design** — full-width vertical card with WO header, customer/PN/qty/PO line, recipe + spec, tag chips (Rush/FAIR/VIP), current step name, tank + state chip row, 9-column mini-timeline, progress bar + operator pill + icons. |
| D7 | **13 card states** distinguishable by background tint, left-border color, state chip text/color, and timeline marker color. Full catalog in §6. |
| D8 | **Columns appear in sequence and never reorder** — even empty columns show. The sequence is the visual mental model of the floor. |
---
## 4. Column layout
### 4.1 Fixed column sequence
The board always renders these 9 columns in this exact order, left-to-right:
```
1. Receiving 2. Masking 3. Blasting 4. Racking 5. Plating
6. Baking 7. De-Racking 8. Final inspection 9. Shipping
```
Columns are first-class entities, not derived from data. If no jobs are in Blasting, the column still appears with a "0" badge — it's a placeholder reminding the operator where Blasting sits in the flow.
### 4.2 Step-kind → column mapping
Each `fp.job.step` routes to exactly one column based on its `recipe_node_id.default_kind`. The mapping table:
| Column | Step kinds routed here |
|---|---|
| **Receiving** | `incoming_inspection`, `contract_review`, `gating`, `ready_for_processing`, any step where `state = 'pending'` and the job's first physical step hasn't started |
| **Masking** | `masking` |
| **Blasting** | `blasting`, `bead_blast`, `media_blast` |
| **Racking** | `racking` |
| **Plating** | `soak_clean`, `electroclean`, `acid_dip`, `etch`, `desmut`, `zincate`, `rinse`, `water_break_test`, `e_nickel_plate`, `chrome`, `anodize`, `black_oxide`, `drying`, `activation`, any step whose `work_centre.kind = 'wet_line'` |
| **Baking** | `bake`, `oven_bake`, `post_bake_relief` |
| **De-Racking** | `de_rack`, `de_mask`, `unrack` |
| **Final inspection** | `post_plate_inspection`, `final_inspection`, `thickness_qc`, `fair`, `dimensional_check`, any step whose `work_centre.kind = 'inspect'` |
| **Shipping** | `shipping`, `pack_ship` |
### 4.3 Implementation — `area_kind` field
Add a new Selection field on `fp.work.centre`:
```python
area_kind = fields.Selection([
('receiving', 'Receiving'),
('masking', 'Masking'),
('blasting', 'Blasting'),
('racking', 'Racking'),
('plating', 'Plating'),
('baking', 'Baking'),
('de_racking', 'De-Racking'),
('inspection', 'Final inspection'),
('shipping', 'Shipping'),
], string='Floor Column', help='Which Shop Floor column this work centre belongs to. Drives the plant-view kanban.')
```
`fp.job.step` already carries a `recipe_node_id` and (optionally) a `work_centre_id`. The kanban grouping resolves a step's column via:
```
step.area_kind = step.work_centre_id.area_kind
or _DEFAULT_KIND_BY_RECIPE_KIND.get(step.recipe_node_id.default_kind)
or 'plating' # safe catch-all for unmapped wet steps
```
A `post_init_hook` backfills `area_kind` on existing `fp.work.centre` records by matching their `kind` (`wet_line`/`bake`/`mask`/`rack`/`inspect`) against the new taxonomy. Unmapped centres get flagged for manual review.
### 4.4 Column visibility rules
- Always show all 9 columns in order.
- Show the column-header count even when zero (`0` in grey, less prominent).
- The operator's paired-station column gets a yellow tint + "📍 You're here" badge — see §7.
---
## 5. Card design — Variant C
### 5.1 Anatomy
```
┌─────────────────────────────────────────────┐
│ WO-30049 ⭐ Due May 16 · 3d │ ← WO + due
│ ABC Manufacturing │ ← customer
│ PN 9876699373 Rev A · Qty 5 · PO 4501882 │ ← part/qty/PO
│ Recipe: ENP-ALUM-BASIC · AMS-2404 Type II │ ← recipe + spec
│ [RUSH] [FAIR] │ ← tag chips
│ Racking │ ← current step name
│ [Rack Station 1] [● Ready] │ ← tank + state chips
│ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ ← mini-timeline
│ Rec Mask Blast [Rack] Plat Bake D-R Insp Ship│ ← timeline labels
├─────────────────────────────────────────────┤
│ Step 4/14 ▓▓░░░░░░░░░ [GS] 🔏 │ ← progress + operator + icons
└─────────────────────────────────────────────┘
```
### 5.2 Field-by-field
| Element | Source | Notes |
|---|---|---|
| WO # | `fp.job.display_wo_name` | Big, bold. `⭐` suffix appears when card is at operator's paired station. Tappable — opens Job Workspace. |
| Due date | `fp.job.commitment_date` | Format "Due May 16 · 3d" (relative). Turns red + `⚠` when overdue. |
| Customer | `fp.job.partner_id.name` | Single line; truncate with ellipsis if too long. |
| PN / Qty / PO | `fp.job.part_catalog_id.part_number` + `.revision` · `fp.job.qty` · `fp.job.sale_order_id.x_fc_po_number` | One line, comma-separated. |
| Recipe + spec | `fp.job.recipe_id.name` · `fp.job.customer_spec_id.code` | Muted small text. |
| Tag chips | derived | Multi: Rush (partner flag) / FAIR (customer_spec.x_fc_requires_first_article) / VIP (partner flag) / AS9100 (job aerospace flag). Only renders when applicable. |
| Current step name | `fp.job.active_step_id.name` or first ready step | Operator-facing label of the step the job is at. |
| Tank chip | `fp.job.active_step_id.work_centre_id.code` or `.tank_id.code` | Blue chip. Specific tank/station. |
| State chip | computed (§6) | One of 13 states. Color matches state. |
| Mini-timeline | derived (§8) | 9-step bar showing the journey across columns. |
| Step X / Y | `fp.job.active_step_id.sequence` / `count(fp.job.step_ids)` | Recipe progress, not the same as the 9-col timeline. |
| Progress bar | computed | Filled to `active_step.sequence / total_steps`. Color matches state. |
| Operator pill | `fp.job.active_step_id.assigned_user_id` | Initials avatar. Hidden when ready (no operator engaged yet). |
| Icon row | derived | Compact status flags (see §5.3). |
### 5.3 Icon row catalog
| Icon | Meaning | Trigger |
|---|---|---|
| 🔏 | Sign-off required | `step.requires_signoff` AND not yet signed |
| ⏰ | Bake window approaching | upstream wet step done, `bake_required_by - now < 1h` |
| 🔥 | Bake compliance gate | active step kind = `bake` |
| 💬 | Recent chatter activity | `job.message_post` in last 24h |
| 🔒 | Predecessor locked | `step.requires_predecessor_done` AND upstream not done |
| 📋 | Required inputs unrecorded | `step._fp_missing_required_step_inputs()` returns non-empty |
| 📷 | Photo required but missing | step has a `photo` input prompt unrecorded |
| 🚚 | Inbound shipment tracking | `state = no_parts` AND `x_fc_receiving.x_fc_carrier_tracking` present |
| 📜 | Cert ready / issued | `state = done` AND `fp.certificate.state in ('issued','sent')` |
| ↳ | Jump to blocker | tappable; navigates to the predecessor step in the Workspace |
Icons only render when their condition is true. Max 3-4 visible per card; overflow into a `⋯` tooltip.
---
## 6. Card states — exhaustive catalog
13 mutually-exclusive states, computed server-side per job. Each card carries exactly one state; precedence rules below resolve conflicts.
### 6.1 State definitions
| # | State | Background | Left border | State chip | Timeline marker | Triggered when |
|---|---|---|---|---|---|---|
| 1 | `ready_mine` | `#fffaeb` (yellow) | `#f0a500` (yellow, 4px) | "● Ready to start" (teal) | `current` (yellow) | `active_step.state = 'ready'` AND `active_step.work_centre_id IN operator_paired_stations` |
| 2 | `running_mine` | `#fffaeb` (yellow) | `#f0a500` (yellow, 4px) | "▶ Running 8m" (yellow) | `current` (yellow) | `active_step.state = 'in_progress'` AND `active_step.work_centre_id IN operator_paired_stations` |
| 3 | `ready` | `#ffffff` (white) | none | "● Ready" (teal) | `current` (yellow) | `active_step.state = 'ready'` AND NOT mine |
| 4 | `running` | `#ffffff` (white) | none | "▶ Running 3m" (yellow) | `current` (yellow) | `active_step.state = 'in_progress'` AND NOT mine |
| 5 | `on_hold` | `#fff5f5` (red) | `#dc3545` (red, 4px) | "🔴 Quality Hold" (red) | `current.hold` (red) | `fusion.plating.quality.hold` exists on the job with `state = 'open'` |
| 6 | `predecessor_locked` | `#f8f9fa` (grey) | none | "🔒 Waiting on Blasting" (grey) | `current.locked` (grey) | `step._fp_should_block_predecessors()` returns True AND any earlier-sequence step not done/skipped/cancelled |
| 7 | `bake_due` | `#fff8e1` (orange) | `#ff9800` (orange, 4px) | "⏰ Bake window in 23m" (orange) | `current.bake` (orange) | `fusion.plating.bake.window` for this job has `bake_required_by - now < 1h` AND `state = 'awaiting_bake'` |
| 8 | `awaiting_signoff` | `#f5f0ff` (purple) | `#6f42c1` (purple, 4px) | "🔏 Awaiting QA sign-off" (purple) | `current.signoff` (purple) | `step.requires_signoff` AND `step.state = 'done'` AND `step.signoff_user_id IS NULL` (S22 gate) |
| 9 | `idle_warning` | `#fef9e7` (amber) | `#e6a800` (amber, 4px) | "⏸ Idle 14h · Carlos" (amber) | `current.idle` (amber) | `step.state = 'in_progress'` AND `now - step.last_activity_at > 8h` (S16 cron) |
| 10 | `awaiting_qc` | `#e7f5fc` (cyan) | `#17a2b8` (cyan, 4px) | "🔬 QC pending · 2/6 items" (cyan) | `current.qc` (cyan) | `fusion.plating.quality.check` exists with `state IN ('draft','in_progress')` AND no other higher-precedence state |
| 11 | `no_parts` | `#f5f5f5` (grey, dashed) | `#6c757d` (grey, 4px, dashed) | "📦 Parts in transit · 2d" (grey) | `current.no_parts` (grey) | `fp.job.state = 'confirmed'` AND inbound `fp.receiving.state = 'draft'` AND no step has started yet |
| 12 | `contract_review` | `#ffffff` (white) | none | "📋 QA-005 Awaiting QA Manager" (purple) | `current.paperwork` (purple) | `active_step.recipe_node_id.default_kind = 'contract_review'` AND not complete |
| 13 | `done` | `#f0f9f4` (green) | `#28a745` (green, 4px) | "✓ Ready for pickup" (green) | `current.done` (green) | active step is in `Shipping` column AND `fp.job.state = 'done'` |
### 6.2 Precedence rules
When multiple state triggers fire simultaneously, the resolver iterates through this **explicit precedence list** and takes the first match:
```
1. no_parts (can't co-occur with anything else; checked first)
2. on_hold (compliance bomb — always wins over operational states)
3. awaiting_signoff (S22 gate — blocks advancement even when step.state='done')
4. awaiting_qc (quality gate — sticky until QC closes)
5. bake_due (time-sensitive compliance window)
6. predecessor_locked (soft block on a step that's data-ready but workflow-locked)
7. idle_warning (long-running supersedes plain running)
8. done (terminal state — only reached if none of the above apply)
9. contract_review (paperwork — used at job entry before physical work)
10. running_mine (more specific than running)
11. ready_mine (more specific than ready)
12. running (operational default for active work)
13. ready (operational default for next-up work)
```
The numeric ordering here is the dispatch order in `_fp_resolve_card_state`, not a severity ranking. Examples:
- Job both on-hold AND awaiting-signoff → `on_hold` (rule 2 fires before rule 3)
- Job both bake-due AND running_mine → `bake_due` (rule 5 fires before rule 10)
- Job in_progress for 14h at the operator's station → `idle_warning` (rule 7 fires before rule 10)
Implementation in §9.3 mirrors this list exactly — keep them synchronized.
### 6.3 Mine resolution
A card is "mine" when **any of the following** is true:
1. `active_step.work_centre_id.id IN operator.paired_work_centre_ids` (operator paired to that specific station)
2. `active_step.assigned_user_id == operator.id` (job is personally assigned to operator)
3. `active_step.area_kind == operator.preferred_area_kind` (operator's profile lists this department, for cross-trained operators)
For **MVP, use rule 1 only**. Rules 2-3 are post-MVP enhancements.
The `res.users.paired_work_centre_ids` is a Many2many on the data model (so it's forward-compatible with cross-trained operators), but the **MVP pairing UX keeps the existing single-station dropdown** (`fp_shopfloor_tech_store.currentStationId`). On unlock, the M2M holds exactly one record — the selected station. A Phase 2 enhancement adds a multi-select picker so cross-trained operators can pair to 2-4 stations at once; the resolver above already supports that without further code change.
---
## 7. Sticky header
The header pins to the top of the kanban and remains visible during scroll.
### 7.1 Layout
```
┌────────────────────────────────────────────────────────────────────────────┐
│ 🏭 Shop Floor [📍 Racking — Garry Singh ▾] [Station|All Plant|Manager]│
│ [📷 Scan QR] [🔓 Hand Off] [⚙] │
├────────────────────────────────────────────────────────────────────────────┤
│ [17 Active] [3 At My Station] [2 Bakes Due ≤2h] [1 On Hold] [2 Overdue]│
├────────────────────────────────────────────────────────────────────────────┤
│ [🔎 Search WO #, customer, part #, PO…] │
│ [All] [My Station] [Running] [Blocked] [Overdue] [FAIR] │
└────────────────────────────────────────────────────────────────────────────┘
```
### 7.2 KPI strip — 5 tiles, clickable filters
| Tile | Source | Click behavior |
|---|---|---|
| **Active Jobs** | `count(fp.job WHERE state IN ('confirmed','in_progress'))` | Filter chip "All" → shows everything |
| **At My Station** | count of cards with `state IN ('ready_mine','running_mine')` | Filter chip "My Station" → only mine |
| **Bakes Due ≤2h** | count of cards with `state = 'bake_due'` AND `bake_required_by - now < 2h` | Highlights orange cards |
| **On Hold** | count of cards with `state = 'on_hold'` | Filter to red cards; clicking opens Quality Holds list |
| **Overdue** | count of cards where `commitment_date < today` AND `state != 'done'` | Filter to overdue |
Each tile is a button. Active tile shows a darker border + filled chip indicator.
### 7.3 Filter chips
Below KPIs, a row of toggleable filter chips. Multiple can be active (intersected with AND):
- **All** (default; clears others)
- **My Station** (cards where `state IN ('ready_mine','running_mine')`)
- **Running** (`active_step.state = 'in_progress'`)
- **Blocked** (`state IN ('on_hold','predecessor_locked','awaiting_signoff','awaiting_qc','no_parts')`)
- **Overdue** (`commitment_date < today` AND `state != 'done'`)
- **FAIR** (partner or spec requires FAIR; flagged via tag)
Chip state persists per operator per browser session (localStorage), so an operator who always filters to "My Station" doesn't have to re-set it each shift.
### 7.4 Station picker
The `[📍 Racking — Garry Singh ▾]` button:
- Shows the operator's current paired station + their name
- Dropdown lets them switch to a different station they're certified on (from their `paired_work_centre_ids`)
- "All stations" option clears pairing
- Disabled when the operator hasn't signed in (lock screen takes precedence)
### 7.5 Mode toggle
Three modes:
| Mode | Behavior |
|---|---|
| **Station** | Cards at the paired station's column get the yellow `mine` treatment. Column header shows "📍 You're here". Other columns visible but neutral. |
| **All Plant** | No "mine" highlight anywhere. Pure plant overview. Use case: supervisor walking the floor without paired station. |
| **Manager** | Same as All Plant + adds bottleneck heatmap row at top (`fp.work.centre.bottleneck_score` driven). KPI strip swaps to manager-specific tiles (Late Risk, Avg Wait, etc.). |
Manager mode is gated by `fusion_plating.group_fusion_plating_manager`.
---
## 8. Mini-timeline derivation
The 9-step bar on each card is **not** the recipe step count — it's a fixed 9-element array keyed by the 9 columns. Logic:
```python
def _compute_mini_timeline(self):
"""Returns list of 9 dicts, one per column, with state in {'done','current','upcoming','hold','locked','bake','signoff','idle','qc','no_parts','done','paperwork'}."""
timeline = []
job_steps = self.step_ids.sorted('sequence')
active = self.active_step_id
active_area = active.area_kind if active else None
for area in COLUMN_SEQUENCE: # ['receiving', 'masking', 'blasting', ...]
steps_in_area = job_steps.filtered(lambda s: s.area_kind == area)
if not steps_in_area:
# area not used by this recipe — still show as 'upcoming' to keep alignment
timeline.append({'area': area, 'state': 'upcoming'})
continue
if all(s.state in ('done', 'skipped') for s in steps_in_area):
timeline.append({'area': area, 'state': 'done'})
elif area == active_area:
# The card's state determines the current marker color
timeline.append({'area': area, 'state': 'current', 'variant': self.card_state})
else:
timeline.append({'area': area, 'state': 'upcoming'})
return timeline
```
Notes:
- Recipes that skip a column (e.g. a job that doesn't need Masking) still render that column slot as "upcoming" grey — visual alignment matters more than perfect accuracy.
- The `variant` field on the current marker tells the renderer which color to use (matches the card-state color: yellow / red / orange / purple / etc.).
---
## 9. Backend changes
### 9.1 New / modified fields
| Model | Field | Type | Purpose |
|---|---|---|---|
| `fp.work.centre` | `area_kind` | Selection (9 values) | Routes each work centre to one of the 9 columns |
| `fp.job.step` | `area_kind` | Char, computed, stored, indexed | Related from `work_centre_id.area_kind` with fallback to `recipe_node_id.default_kind` lookup |
| `fp.job` | `card_state` | Char, computed, stored, indexed | The 13-state classifier; computed via `_compute_card_state` with the precedence rules in §6.2 |
| `fp.job` | `mini_timeline_json` | Text, computed | JSON-serialized output of `_compute_mini_timeline` |
| `fp.job.step` | `last_activity_at` | Datetime, indexed | Updated on any state transition / move / chatter post; drives idle-warning detection (S16) |
| `res.users` | `paired_work_centre_ids` | M2M `fp.work.centre` | Operator's certified stations; resolved on PIN unlock |
`area_kind` Selection values (used by both `fp.work.centre` and `fp.job.step`):
```python
COLUMN_SEQUENCE = [
('receiving', 'Receiving'),
('masking', 'Masking'),
('blasting', 'Blasting'),
('racking', 'Racking'),
('plating', 'Plating'),
('baking', 'Baking'),
('de_racking', 'De-Racking'),
('inspection', 'Final inspection'),
('shipping', 'Shipping'),
]
```
### 9.2 New endpoint — `/fp/landing/plant_kanban`
Replaces the existing `/fp/landing/kanban`. Returns:
```json
{
"ok": true,
"mode": "station",
"paired_station": {"id": 12, "name": "Rack Station 1", "area_kind": "racking"},
"kpis": {
"active_jobs": 17,
"at_my_station": 3,
"bakes_due_soon": 2,
"on_hold": 1,
"overdue": 2
},
"columns": [
{
"area_kind": "receiving",
"label": "Receiving",
"is_mine": false,
"card_ids": [2885, 2886, 2887]
},
{
"area_kind": "masking",
"label": "Masking",
"is_mine": false,
"card_ids": [2884]
},
...
],
"cards": {
"2885": {
"wo_name": "WO-30049",
"is_mine": true,
"card_state": "ready_mine",
"due_date": "2026-05-16",
"due_label": "Due May 16 · 3d",
"is_overdue": false,
"customer": "ABC Manufacturing",
"part_number": "9876699373",
"part_revision": "A",
"qty": 5,
"po_number": "4501882",
"recipe_name": "ENP-ALUM-BASIC",
"spec_code": "AMS-2404 Type II",
"tags": ["rush", "fair"],
"step_name": "Racking",
"step_seq": 4,
"step_total": 14,
"tank_label": "Rack Station 1",
"state_chip": {"label": "● Ready to start", "kind": "ready"},
"operator": {"id": null, "name": null, "initials": null},
"duration_label": null,
"icons": ["signoff_required"],
"mini_timeline": [
{"area": "receiving", "state": "done"},
{"area": "masking", "state": "done"},
{"area": "blasting", "state": "done"},
{"area": "racking", "state": "current", "variant": "ready_mine"},
...
]
},
...
}
}
```
Design choices:
- **Two-tier structure** (`columns` + `cards`) keeps payload small when 2 cards happen to be at the same step — no per-column-per-card duplication.
- **`card_state` is server-computed** — frontend just maps state → CSS class.
- **`mini_timeline` is server-computed** — frontend renders the 9 dots without knowing the recipe shape.
- **Operator info is denormalized** — initials, name, color hash all in the payload so the frontend doesn't fan out RPCs.
### 9.3 State computation — `_compute_card_state`
Matches the precedence list in §6.2 exactly. Both must stay in sync.
```python
def _compute_card_state(self):
for job in self:
# Edge: job has no active step (all pending or all done)
if not job.active_step_id:
# rule 1
if job.state == 'confirmed' and job._fp_inbound_not_received():
job.card_state = 'no_parts'
else:
# Fallback to first pending step's kind; otherwise contract_review
job.card_state = 'contract_review'
continue
step = job.active_step_id
# rule 1 — no_parts (even with an active step, if inbound is still draft)
if job._fp_inbound_not_received():
job.card_state = 'no_parts'
continue
# rule 2 — on_hold
if job._fp_has_open_hold():
job.card_state = 'on_hold'
continue
# rule 3 — awaiting_signoff (S22)
if (step.requires_signoff and step.state == 'done'
and not step.signoff_user_id):
job.card_state = 'awaiting_signoff'
continue
# rule 4 — awaiting_qc
if job._fp_has_pending_qc():
job.card_state = 'awaiting_qc'
continue
# rule 5 — bake_due
if job._fp_bake_window_due_soon():
job.card_state = 'bake_due'
continue
# rule 6 — predecessor_locked
if (step._fp_should_block_predecessors()
and step._fp_has_unfinished_predecessors()):
job.card_state = 'predecessor_locked'
continue
# rule 7 — idle_warning (S16)
if step.state == 'in_progress' and step._fp_is_idle(threshold_hours=8):
job.card_state = 'idle_warning'
continue
# rule 8 — done (terminal, only reached when nothing above fires)
if step.area_kind == 'shipping' and job.state == 'done':
job.card_state = 'done'
continue
# rule 9 — contract_review
if step.recipe_node_id.default_kind == 'contract_review':
job.card_state = 'contract_review'
continue
# rules 10/12 — running (mine vs not)
if step.state == 'in_progress':
job.card_state = ('running_mine' if job._fp_is_mine()
else 'running')
continue
# rules 11/13 — ready (mine vs not)
if step.state == 'ready':
job.card_state = ('ready_mine' if job._fp_is_mine()
else 'ready')
continue
# Safe default
job.card_state = 'ready'
```
Each `_fp_*` helper is a small method on `fp.job` (or `fp.job.step`) that encapsulates one precedence check. Centralizing them this way means future audits can extend the catalog without touching the dispatch.
### 9.4 Helpers
| Helper | Returns | Source data |
|---|---|---|
| `_fp_inbound_not_received()` | bool | `fp.receiving` linked via SO; `state = 'draft'` |
| `_fp_has_open_hold()` | bool | `fusion.plating.quality.hold` with `state = 'open'` linked via `job_id` |
| `_fp_has_pending_qc()` | bool | `fusion.plating.quality.check` with `state IN ('draft','in_progress')` linked via `job_id` |
| `_fp_bake_window_due_soon()` | bool | `fusion.plating.bake.window` linked, `bake_required_by - now < 1h`, `state = 'awaiting_bake'` |
| `step._fp_is_idle(threshold_hours=8)` | bool | `now - last_activity_at > threshold` |
| `_fp_is_mine()` | bool | `active_step.work_centre_id IN env.user.paired_work_centre_ids` |
---
## 10. Frontend changes
### 10.1 OWL component structure
New / modified files in `fusion_plating_shopfloor/static/src/`:
```
js/
plant_kanban.js (new — replaces shopfloor_landing.js)
components/
plant_card.js (new — Variant C card component)
mini_timeline.js (new — 9-step horizontal bar)
column_header.js (new — column header with "📍 You're here" badge)
kpi_tile.js (new — clickable KPI button)
filter_chip.js (new — toggleable filter chip)
xml/
plant_kanban.xml (new)
components/
plant_card.xml (new)
mini_timeline.xml (new)
column_header.xml (new)
kpi_tile.xml (new)
filter_chip.xml (new)
scss/
plant_kanban.scss (new — board layout + sticky header)
components/
_plant_card.scss (new — 13 card-state styles)
_mini_timeline.scss (new — timeline dots)
_column_header.scss (new)
_kpi_tile.scss (new)
_filter_chip.scss (new)
```
### 10.2 Component tree
```
FpPlantKanban (top-level client action)
├── FpTabletLock (existing wrapper for PIN gate)
└── (when unlocked)
├── PlantHeader
│ ├── StationPicker
│ ├── ModeToggle
│ ├── ToolbarButtons (Scan / Hand Off / Settings)
│ ├── KpiStrip (5 × KpiTile)
│ └── FilterRow (search input + 6 × FilterChip)
└── Board
└── 9 × Column
├── ColumnHeader
└── PlantCard[]
├── CardHeader (WO, due)
├── CardBody (customer, PN, recipe, tags)
├── CardStep (step name + chips)
├── MiniTimeline
└── CardFooter (progress + operator + icons)
```
### 10.3 Card state CSS
All 13 states share the base `.plant-card` class with state-specific modifier classes:
```scss
.plant-card {
background: $card-bg;
border: 1px solid $border-color;
border-radius: 8px;
// ... base layout
&.state-ready_mine, &.state-running_mine {
background: #fffaeb;
border-left: 4px solid #f0a500;
padding-left: 9px;
}
&.state-on_hold {
background: #fff5f5;
border-left: 4px solid #dc3545;
padding-left: 9px;
}
&.state-bake_due {
background: #fff8e1;
border-left: 4px solid #ff9800;
padding-left: 9px;
}
&.state-awaiting_signoff {
background: #f5f0ff;
border-left: 4px solid #6f42c1;
padding-left: 9px;
}
&.state-idle_warning {
background: #fef9e7;
border-left: 4px solid #e6a800;
padding-left: 9px;
}
&.state-awaiting_qc {
background: #e7f5fc;
border-left: 4px solid #17a2b8;
padding-left: 9px;
}
&.state-predecessor_locked {
background: #f8f9fa;
}
&.state-no_parts {
background: #f5f5f5;
border: 1px dashed #999;
border-left: 4px solid #6c757d;
padding-left: 9px;
}
&.state-done {
background: #f0f9f4;
border-left: 4px solid #28a745;
padding-left: 9px;
}
// state-ready, state-running, state-contract_review: default neutral white
}
```
Dark-mode SCSS branch follows the project pattern (`$o-webclient-color-scheme == dark` block) with adjusted hex values.
### 10.4 Auto-refresh
Polling every 10s via `setInterval`. On each tick:
1. Fetch `/fp/landing/plant_kanban` with current mode + filter state in the request payload.
2. Diff against current state.
3. Apply changes to OWL reactive state — cards that moved columns animate the transition (fade-out from old column, fade-in at new column over 200ms).
Hand-Off, mode toggle, station-picker, and filter chip changes trigger an immediate refresh.
### 10.5 Card tap behavior
Single tap on a card → opens Job Workspace (`fp_job_workspace` client action) with the WO pre-loaded. No quick-action sheet on tablet (would compete with the Workspace's own action rail).
Card has a small "" icon in the top-right that opens a quick-info popover (for supervisor walk-bys who want details without leaving the kanban). Post-MVP.
---
## 11. Migration & rollout
### 11.1 Database migration
```python
# fusion_plating/migrations/19.0.21.0.0/post-migrate.py
def migrate(cr, version):
"""Backfill fp.work.centre.area_kind from existing kind values."""
cr.execute("""
UPDATE fp_work_centre
SET area_kind = CASE kind
WHEN 'wet_line' THEN 'plating'
WHEN 'bake' THEN 'baking'
WHEN 'mask' THEN 'masking'
WHEN 'rack' THEN 'racking'
WHEN 'inspect' THEN 'inspection'
ELSE 'plating'
END
WHERE area_kind IS NULL
""")
# Log unmapped centres for manual review
cr.execute("""
SELECT id, name FROM fp_work_centre WHERE area_kind IS NULL
""")
for row in cr.fetchall():
_logger.warning("Work centre %s (%s) has no area_kind — defaulted to 'plating'", row[0], row[1])
```
### 11.2 Feature flag
New config setting `x_fc_shopfloor_layout` on `res.config.settings`:
- `legacy` (default during rollout) — existing landing
- `v2` — new plant view
Once validated on entech, default flips to `v2` and legacy code can be removed in a follow-up cleanup.
The client action `fp_shopfloor_landing` resolver chooses which OWL component to mount based on this setting.
### 11.3 Rollout sequence
1. Ship migration + backend (`area_kind`, `card_state`, `mini_timeline_json`, helpers, endpoint) under the v2 flag.
2. Ship OWL components under the v2 flag. Both screens coexist.
3. QA on entech: flip `x_fc_shopfloor_layout = 'v2'`, validate end-to-end.
4. Run battle-test scenarios (S1-S23) against the new view to confirm no regression.
5. Flip default to `v2` site-wide.
6. After 2 weeks of stable v2, remove legacy code.
---
## 12. Testing strategy
### 12.1 Unit tests
- `test_card_state_computation` — for each of the 13 states, construct an `fp.job` in that exact data shape, assert `card_state` resolves correctly
- `test_card_state_precedence` — overlay multiple triggers (e.g. on-hold + bake-due), assert precedence rules produce the documented winner
- `test_area_kind_routing` — for each step kind in the mapping table, assert it routes to the correct column
- `test_mini_timeline` — for a 14-step recipe at various points, assert the 9-element output matches expectations (including skipped columns rendered as upcoming)
- `test_one_card_per_job_invariant` — across a realistic 17-job board, assert no two entries in `cards{}` share the same `fp.job.id`
### 12.2 Persona walks
Re-run the battle-test scenarios that drove this redesign:
- **S20 walk** — operator persona traversal of the tablet. Confirm: card density readable, "mine" highlight obvious, can find a specific WO in <5s via search.
- **S22 / S23 simulations** — finish a step that needs sign-off / transition form, confirm the card transitions to `awaiting_signoff` / `awaiting_qc` state correctly.
- **20-step-recipe regression** — load a synthetic job with 25+ recipe steps, confirm it occupies one and only one card on the board.
### 12.3 Visual snapshot tests
Per state, a Playwright/headless-chromium snapshot of a single card at fixed viewport. Diff against checked-in golden images on every PR. Catches accidental CSS regressions.
---
## 13. Open questions (deferred)
These don't block MVP but should be tracked for the follow-up plan.
| # | Question | Suggested resolution |
|---|---|---|
| Q1 | Drag-and-drop card between columns? | **No for MVP.** State transitions happen via the Workspace action rail or Move dialogs. The kanban reflects state, doesn't drive it. |
| Q2 | Empty-column auto-collapse? | **No.** Column position = mental model. Collapsing breaks the sequence. |
| Q3 | Sort within column? | **MVP: most urgent first** — overdue → bake-due → ready → running → idle → locked → done. Post-MVP: operator-toggleable. |
| Q4 | Card tap → quick-action sheet vs. open Workspace? | **MVP: open Workspace.** Quick-action sheet is a post-MVP enhancement. |
| Q5 | Manager mode KPI tile swap? | **Phase 2.** MVP ships with the same 5 KPI tiles in all modes. Phase 2 adds manager-specific tiles (late-risk %, avg wait per station, bottleneck score). |
| Q6 | Sibling jobs (WO-30029-01 / -02) visual grouping? | **No special treatment for MVP.** Each is its own card. If siblings clutter, post-MVP adds a "group siblings" toggle. |
| Q7 | Bottleneck heatmap row in manager mode? | **Phase 2.** Reuses existing `fp.work.centre.bottleneck_score`. |
| Q8 | Mobile (phone) breakpoint? | **Phase 2.** MVP optimized for 1080p tablet. Phone view = collapse to single-column scroll. |
---
## 14. Summary
| Question | Answer |
|---|---|
| Layout | 9 fixed columns in sequence (Receiving → … → Shipping) |
| Card model | One card per `fp.job`, always in the column matching the active step's `area_kind` |
| Card density | Variant C — full info with mini-timeline |
| State catalog | 13 mutually-exclusive states with precedence rules |
| Operator focus | Plant-wide view, paired-station column + "mine" cards highlighted |
| Backend touch | New `area_kind` Selection, new `card_state` compute, new `/fp/landing/plant_kanban` endpoint |
| Frontend touch | New OWL component tree under `fp_plant_kanban` client action |
| Rollout | Feature flag `x_fc_shopfloor_layout`, parallel deployment, flip default after entech validation |
| Recipe-step scaling | Doesn't matter — 5-step or 50-step recipes both produce one card moving across 9 fixed columns |
The redesign solves the "one job in N columns" problem by re-anchoring grouping at the department level and decoupling the kanban from recipe step count. Every floor scenario in the audit + battle-test catalog (S1-S23) maps to one of the 13 documented states.
Implementation plan to follow.

View File

@@ -0,0 +1,527 @@
# Tablet Lock Screen Redesign
**Date:** 2026-05-24
**Status:** Design — approved through brainstorming, awaiting plan
**Affects:** `fusion_plating_shopfloor` (FpTabletLock OWL component + tablet_controller)
**Scope:** Visual + interaction redesign only. PIN gate, unlock RPC, lockout timer, idle warning all unchanged.
---
## 1. Problem
The current FpTabletLock tile screen looks like a placeholder. Two operators per row stretch their tiles across 900px max-width; the screen is mostly empty whitespace; there's no branding; the "Tap your name to unlock" prompt is the only header; no animations; no clock/date. Functionally correct but feels unfinished on a wall-mounted shop-floor tablet.
User feedback after live testing (2026-05-24):
> "i want company logo and other nice customization, add some animation, reduce the card width so its just enough, there may be many employees, i do not want a lot of scrolling but not cramped at the same time"
Target: tablet that looks like a deliberately-designed shop terminal, fits ~10-15 operators per screen without scrolling, brands the device with the company logo, and has subtle motion that signals "alive."
---
## 2. Goals & non-goals
### Goals
1. **Brand the screen** — pull the company logo from `res.company.logo`, surface the company name + tagline.
2. **Tighter tile grid** — 3 columns max-width 480px, ~140px tile width. Fits 6 tiles per visible row; small shops (10-15 ops) show everything without scroll.
3. **Real-time clock + date** — operators glance at the lock screen for the time; big tabular-nums clock front-and-center.
4. **Subtle motion** — staggered entrance, hover lift, clocked-in pulse. Doesn't distract; signals freshness.
5. **Dark + light mode parity** — single SCSS source, branches at compile time via `$o-webclient-color-scheme`. No JS-side theme code.
6. **Accessibility**`prefers-reduced-motion` respected, touch targets ≥ 44px, contrast WCAG AA in both modes.
### Non-goals
- **Replacing the PIN gate.** The 4-digit PIN flow (FpPinPad component, hash + lockout, /fp/tablet/unlock endpoint) stays identical.
- **Multi-tenant theming.** Each company sees its own logo via `res.company.logo`; we don't build a theme editor for accent colors. The amber accent is a hardcoded brand token in this design.
- **Search box on the lock screen.** For ~10-15 operators, scanning the grid is faster than typing. Search returns as a Phase 2 enhancement if a customer scales to 25+ ops.
- **Custom tile sort.** Existing rule stays: clocked-in operators first, then alphabetical.
- **Welcome animations / video / mascot.** Subtle motion only.
---
## 3. Decisions locked during brainstorming
| # | Decision |
|---|---|
| D1 | **Hybrid A+B vibe** — Industrial Bold structure (dark gradient bg, bold tabular clock, amber accent) wearing Premium Glassmorphism finish (frosted-glass tiles with backdrop-filter, smooth cubic-bezier hover). |
| D2 | **Company logo** sourced from `res.company.logo` (Odoo's standard company logo binary field) via `/web/image/res.company/<id>/logo`. Letter-mark fallback when no logo is uploaded — built from `res.company.name` initials. |
| D3 | **Company name + tagline** below the logo. Name = `res.company.name`. Tagline = `res.company.report_header` (existing field, also drives invoice letterheads — natural reuse) with fallback "Shop Floor Terminal" if empty. |
| D4 | **3-column tile grid**, max-width 480px on the grid container. Tile ~140px wide. Avatar 52px circular with status pulse-dot overlay. |
| D5 | **Dark + light mode parity.** Same OWL component + same XML; SCSS branches at compile time on `$o-webclient-color-scheme`. No runtime theme code. |
| D6 | **Animation catalogue** (full list in §6) — entrance stagger, hover lift, click scale, pulse on clocked-in dot, real-time clock update. `prefers-reduced-motion` disables all of these. |
| D7 | **Sort order unchanged** — clocked-in operators first, then alphabetical by name. |
| D8 | **No search box** for MVP — scoped for the ~10-15-operator small-shop case. |
---
## 4. Layout
The screen is a full-viewport flex column, centered, with this vertical sequence:
```
┌──────────────────────────────────────────────────┐
│ │
│ ┌─────────┐ │ ← logo frame (84×84)
│ │ LOGO │ (rounded 20, glass) │ glassmorphic
│ └─────────┘ │
│ Company Name │ ← logo-text (19px, 700)
│ PLATING · ESTD 1985 │ ← logo-sub (11px upper)
│ │
│ 21:09 │ ← clock (40px, 800, tabular)
│ SATURDAY · MAY 23 │ ← clock-date (12px upper)
│ │
│ [ 🔒 TAP YOUR NAME ] │ ← prompt pill
│ │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ GS ● │ │ JM │ │ CV ● │ │
│ │Garry │ │Johnny│ │Carlos│ │ ← 3-column tile grid,
│ │CIN │ │PIN │ │CIN │ │ max-width 480px
│ └──────┘ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ LB ● │ │ RB ● │ │ KP ● │ │
│ │ Lisa │ │ Ravi │ │ Kris │ │
│ │ CIN │ │ CIN │ │ CIN │ │
│ └──────┘ └──────┘ └──────┘ │
│ │
└──────────────────────────────────────────────────┘
```
Spacing between sections: 22px gap. Logo block top margin: 28px. Outer padding: 28px 20px.
### 4.1 Logo block
```html
<div class="o_fp_lock_logo_block">
<div class="o_fp_lock_logo_frame">
<img t-att-src="logoUrl" t-att-alt="companyName" t-if="logoUrl"/>
<div t-else="" class="o_fp_lock_logo_placeholder" t-esc="companyInitials"/>
</div>
<div class="o_fp_lock_logo_text" t-esc="companyName"/>
<div class="o_fp_lock_logo_sub" t-esc="companyTagline"/>
</div>
```
- `logoUrl`: `/web/image/res.company/<id>/logo` — Odoo serves the binary directly. Always 200 if the field is populated (even 1×1 transparent on empty record), so probe the field server-side before emitting the URL.
- `companyInitials`: first 1-2 letters of `res.company.name` (e.g. "EN" for "EN Technologies", "ABC" capped to 2 chars). Computed server-side, sent in the tiles-endpoint payload.
- `companyTagline`: from `res.company.report_header` field; defaults to "Shop Floor Terminal" when empty.
The logo frame is a 84×84 rounded-20 glassmorphic container — same frosted treatment as the tiles. Looks great whether the logo is a sharp PNG, transparent SVG, or the letter-mark fallback.
### 4.2 Clock block
```html
<div class="o_fp_lock_clock_block">
<div class="o_fp_lock_clock" t-esc="state.clockText"/>
<div class="o_fp_lock_clock_date" t-esc="state.dateText"/>
</div>
```
- `state.clockText`: `HH:MM` (24h, configurable via `intl.DateTimeFormat`). Updates every minute via `setInterval` in `tablet_lock.js`.
- `state.dateText`: `WEEKDAY · MMM D` uppercase (e.g. "SATURDAY · MAY 23"). Recomputed on date change.
- Tabular numbers so digits don't jitter when changing.
- Initial render uses `new Date()` synchronously so there's no flash of empty content.
### 4.3 Prompt
A small pill, not a header:
```html
<div class="o_fp_lock_prompt">🔒 Tap your name</div>
```
Amber-tinted background (matches brand accent), uppercase with 0.18em letter-spacing. Sits between the clock and the tile grid as a visual anchor.
### 4.4 Tile grid
```html
<div class="o_fp_lock_tiles">
<t t-foreach="state.tiles" t-as="tile" t-key="tile.user_id">
<button class="o_fp_lock_tile"
t-att-style="'animation-delay: ' + tile.animDelay + 'ms'"
t-on-click="() => this.onTileClick(tile.user_id)">
<div t-att-class="tile.is_clocked_in ? 'o_fp_lock_avatar is-clocked' : 'o_fp_lock_avatar'"
t-att-style="'background: ' + tile.avatar_gradient">
<img t-if="tile.has_photo" t-att-src="tile.avatar_url" t-att-alt="tile.name"/>
<span t-else="" t-esc="tile.initials"/>
</div>
<div class="o_fp_lock_name" t-esc="tile.name"/>
<div t-if="tile.is_clocked_in" class="o_fp_lock_status status-clocked">Clocked in</div>
<div t-elif="!tile.has_pin" class="o_fp_lock_status status-pin">PIN required</div>
</button>
</t>
</div>
```
- Grid: `grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 480px`.
- Animation delay computed JS-side per tile (50ms × index, capped at 300ms) so the stagger ripples without dragging.
- Avatar gradient (per-tile color): server-computed as `user.id % len(_AVATAR_GRADIENTS)` (8 colors). Deterministic — same operator gets the same color across sessions, so operators learn their own tile color. See §7.3 for the gradient list.
- `has_photo` is true when `res.users.image_128` is non-empty. Falls back to initials when empty.
---
## 5. Color system
All colors live in `_tablet_lock_tokens.scss` (new file, loaded before `tablet_lock.scss`). Same pattern as the plant-view tokens shipped earlier.
### Light-mode defaults
| Token | Hex | Purpose |
|---|---|---|
| `$_lock-bg-top` | `#fafafa` | Gradient top |
| `$_lock-bg-bottom` | `#f0f0f3` | Gradient bottom |
| `$_lock-accent` | `rgba(240,165,0,0.12)` | Top-radial ambient glow |
| `$_lock-accent-2` | `rgba(99,102,241,0.06)` | Bottom-radial ambient glow |
| `$_lock-text` | `#1d1f1e` | Primary text |
| `$_lock-muted` | `#71717a` | Secondary text |
| `$_lock-prompt` | `#b45309` | Prompt text |
| `$_lock-prompt-bg` | `rgba(240,165,0,0.10)` | Prompt pill bg |
| `$_lock-prompt-border` | `rgba(240,165,0,0.25)` | Prompt pill border |
| `$_lock-tile-bg` | `rgba(255,255,255,0.7)` | Tile bg (frosted) |
| `$_lock-tile-border` | `rgba(0,0,0,0.05)` | Tile border |
| `$_lock-tile-hover-bg` | `rgba(255,255,255,0.95)` | Tile hover bg |
| `$_lock-tile-hover-border` | `rgba(240,165,0,0.5)` | Tile hover border |
| `$_lock-tile-hover-shadow` | `0 12px 24px rgba(240,165,0,0.18)` | Tile hover shadow |
| `$_lock-frame-bg` | `rgba(255,255,255,0.85)` | Logo frame bg |
| `$_lock-status-clocked` | `#16a34a` | Clocked-in green |
| `$_lock-status-pin` | `#d97706` | PIN required amber |
| `$_lock-pulse-dot-border` | `#fff` | Pulse-dot ring |
### Dark-mode overrides
| Token | Hex |
|---|---|
| `$_lock-bg-top` | `#1a1d21` (gradient base) |
| `$_lock-bg-bottom` | `#2d3138` |
| `$_lock-accent` | `rgba(240,165,0,0.08)` |
| `$_lock-accent-2` | `rgba(99,102,241,0.06)` |
| `$_lock-text` | `#f5f5f7` |
| `$_lock-muted` | `#adb5bd` |
| `$_lock-prompt` | `#f0a500` |
| `$_lock-prompt-bg` | `rgba(240,165,0,0.08)` |
| `$_lock-prompt-border` | `rgba(240,165,0,0.20)` |
| `$_lock-tile-bg` | `rgba(255,255,255,0.06)` |
| `$_lock-tile-border` | `rgba(255,255,255,0.08)` |
| `$_lock-tile-hover-bg` | `rgba(240,165,0,0.10)` |
| `$_lock-tile-hover-border` | `rgba(240,165,0,0.4)` |
| `$_lock-tile-hover-shadow` | `0 12px 24px rgba(240,165,0,0.15), 0 0 0 1px rgba(240,165,0,0.2)` |
| `$_lock-frame-bg` | `rgba(255,255,255,0.08)` |
| `$_lock-status-clocked` | `#34c759` (brighter — needs to pop on dark) |
| `$_lock-status-pin` | `#ff9f0a` |
| `$_lock-pulse-dot-border` | `#2d3138` (so the dot reads as overlapping the dark tile, not floating) |
The full-screen background is a stack of two radial gradients (the ambient accent glows) over a linear gradient (the base), per `lock-final.html` from brainstorm:
```scss
background:
radial-gradient(ellipse at top, $_lock-accent, transparent 50%),
radial-gradient(ellipse at bottom, $_lock-accent-2, transparent 50%),
linear-gradient(135deg, $_lock-bg-top 0%, $_lock-bg-bottom 100%);
```
---
## 6. Animation catalogue
All animations use `cubic-bezier(0.4, 0, 0.2, 1)` for consistency (the "standard easing" curve). Every animation is gated by `@media (prefers-reduced-motion: no-preference)` — operators who set reduced motion in OS preferences see the same screen with no movement.
| # | Name | What it does | Duration | Trigger |
|---|---|---|---|---|
| 1 | `lockLogoEnter` | Logo block fades down + slides in | 500ms | onMount |
| 2 | `lockClockEnter` | Clock + prompt fade up | 500ms (100ms delay) | onMount |
| 3 | `lockTileEnter` | Each tile fades + slides up + scales from 0.96 | 400ms (50ms staggered per index, max 6) | onMount |
| 4 | `lockTileHover` | Lift translateY(-3px) + colored shadow + border glow | 250ms | hover/focus |
| 5 | `lockTilePress` | Quick scale(0.97) | 50ms | active/click |
| 6 | `lockPulseDot` | Green clocked-in dot pulses (ring expands + fades) | 2s loop | clocked-in state present |
| 7 | `lockClockTick` | (no animation — just text content update each minute) | — | `setInterval(60000)` |
### Reduced-motion override
```scss
@media (prefers-reduced-motion: reduce) {
.o_fp_lock_logo_block,
.o_fp_lock_clock_block,
.o_fp_lock_prompt,
.o_fp_lock_tile,
.o_fp_lock_avatar.is-clocked::after {
animation: none !important;
transition: none !important;
}
}
```
### Stagger cap
For very large operator counts the per-tile delay caps at 300ms (6 tiles × 50ms) so the screen doesn't take 3 seconds to settle. Compute `animDelay = Math.min(index * 50, 300)` JS-side.
---
## 7. Backend changes
### 7.1 Extend `/fp/tablet/tiles` payload
Currently returns:
```json
{"ok": true, "tiles": [{user_id, name, avatar_url, is_clocked_in, has_pin}, ...]}
```
After redesign:
```json
{
"ok": true,
"company": {
"id": 1,
"name": "EN Technologies",
"tagline": "Plating & Finishing",
"logo_url": "/web/image/res.company/1/logo",
"has_logo": true,
"initials": "EN"
},
"tiles": [
{
"user_id": 5,
"name": "Garry Singh",
"initials": "GS",
"avatar_url": "/web/image/res.users/5/avatar_128",
"has_photo": true,
"is_clocked_in": true,
"has_pin": true,
"avatar_gradient": "linear-gradient(135deg, #ef4444, #dc2626)"
},
...
]
}
```
New fields per tile:
- `initials`: server-computed from `res.users.name` (first letter of first + last word, capped 2 chars).
- `has_photo`: true when `res.users.image_128` is non-empty (avoids the 1×1 default-image flash).
- `avatar_gradient`: deterministic from hash of user.id. Same gradient across sessions so operators recognize "their" tile color.
The company block is one query: `env.company.id`. Read `name`, `report_header`, check `logo` non-empty.
### 7.2 `_lock_company_payload` helper
A small module-level helper in `tablet_controller.py`:
```python
def _lock_company_payload(env):
"""Returns the company info block for the lock screen."""
co = env.company
return {
'id': co.id,
'name': co.name or '',
'tagline': co.report_header or _('Shop Floor Terminal'),
'logo_url': f'/web/image/res.company/{co.id}/logo',
'has_logo': bool(co.logo),
'initials': _initials_from(co.name),
}
def _initials_from(name):
"""First letter of first + last word, capped at 2 chars uppercase."""
if not name:
return '?'
words = name.strip().split()
if len(words) == 1:
return words[0][:2].upper()
return (words[0][0] + words[-1][0]).upper()
```
### 7.3 `_avatar_gradient_for` helper
```python
_AVATAR_GRADIENTS = [
'linear-gradient(135deg, #ef4444, #dc2626)', # red
'linear-gradient(135deg, #f59e0b, #d97706)', # amber
'linear-gradient(135deg, #10b981, #059669)', # emerald
'linear-gradient(135deg, #3b82f6, #2563eb)', # blue
'linear-gradient(135deg, #8b5cf6, #7c3aed)', # violet
'linear-gradient(135deg, #ec4899, #db2777)', # pink
'linear-gradient(135deg, #14b8a6, #0d9488)', # teal
'linear-gradient(135deg, #f97316, #ea580c)', # orange
]
def _avatar_gradient_for(user_id):
return _AVATAR_GRADIENTS[user_id % len(_AVATAR_GRADIENTS)]
```
8 colors, modulo user_id — same operator gets the same color forever. Sufficient variety for a small shop (10-15 ops have <2 collisions on average).
---
## 8. Frontend changes
### 8.1 Files modified
| File | Change |
|---|---|
| `static/src/scss/_tablet_lock_tokens.scss` | **new** — design tokens (loads first) |
| `static/src/scss/tablet_lock.scss` | full rewrite — gradient bg, logo block, clock block, prompt, tile grid, animations, dark/light branches |
| `static/src/xml/tablet_lock.xml` | wrap existing tile loop with new logo + clock + prompt blocks; add fallback structures |
| `static/src/js/tablet_lock.js` | add `state.clockText` + `state.dateText` + `_tickClock` setInterval; add `state.company`; consume new payload fields |
### 8.2 OWL component reactivity for the clock
The clock updates every 60 seconds:
```javascript
setup() {
// ... existing setup ...
this.state = useState({
// ... existing state ...
clockText: this._formatTime(new Date()),
dateText: this._formatDate(new Date()),
company: null,
});
onMounted(() => {
// ... existing onMounted ...
this._clockInterval = setInterval(() => {
const now = new Date();
this.state.clockText = this._formatTime(now);
this.state.dateText = this._formatDate(now);
}, 60000);
});
onWillUnmount(() => {
// ... existing cleanup ...
if (this._clockInterval) clearInterval(this._clockInterval);
});
}
_formatTime(d) {
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
_formatDate(d) {
return d.toLocaleDateString(undefined, {
weekday: 'long', month: 'short', day: 'numeric'
}).toUpperCase().replace(',', ' ·');
}
```
**Per project rule 20:** all the date/number formatting happens in JS (`_formatTime`, `_formatDate`). The template only renders `state.clockText` / `state.dateText` via `t-esc`. No `String()` / `Number()` / `padStart` calls inside the XML.
### 8.3 Stagger delay computed JS-side
In `_loadTiles`, after fetching, decorate each tile with its `animDelay`:
```javascript
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.company = res.company || null;
this.state.tiles = res.tiles.map((tile, idx) => ({
...tile,
animDelay: Math.min(idx * 50, 300), // cap at 300ms
}));
}
} catch (err) {
// Existing quiet fail
} finally {
this.state.loadingTiles = false;
}
}
```
### 8.4 Manifest registration
Adding two SCSS files. Per project rule 8 (SCSS @import forbidden), tokens must register BEFORE the consumer:
```python
# In fusion_plating_shopfloor/__manifest__.py, the lock screen block:
'fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss', # NEW — load first
'fusion_plating_shopfloor/static/src/scss/tablet_lock.scss', # existing — rewritten
'fusion_plating_shopfloor/static/src/xml/tablet_lock.xml', # existing — extended
'fusion_plating_shopfloor/static/src/js/tablet_lock.js', # existing — extended
```
The tokens file lives in `scss/` (not `scss/components/`) because it's session-level — one tokens file for the whole lock-screen experience.
---
## 9. Accessibility
- **Touch targets**: avatar 52px + 14px padding = 80px tile content; tile itself extends to grid cell width ~140px × 110px tall. Both axes well above the 44×44 WCAG minimum.
- **Focus rings**: visible 2px solid amber outline on `:focus-visible`. Distinguishes keyboard navigation from mouse hover.
- **Contrast**:
- Dark mode: white text on `#1a1d21` background = 16.7:1 (AAA).
- Light mode: `#1d1f1e` text on `#fafafa` background = 17.8:1 (AAA).
- Amber prompt text on its tinted bg: 5.2:1 (AA passes).
- **Reduced motion**: full media-query gate documented in §6.
- **Alt text**: logo `<img alt="Company Name">` so screen readers announce the brand on focus.
- **Keyboard navigation**: tab order = logo (skip) → tiles in DOM order → first tile receives initial focus on mount.
---
## 10. Testing strategy
### 10.1 Unit / integration
- `test_tablet_tiles_endpoint_includes_company` — call `/fp/tablet/tiles`, assert response has `company` block with required keys.
- `test_initials_from_helper` — edge cases: empty name, single-word name, multi-word name with hyphens.
- `test_avatar_gradient_deterministic` — same user.id returns same gradient across calls.
### 10.2 Visual snapshot tests
Per state, a Playwright snapshot of the lock screen at `1366×768` (typical tablet) in both light and dark mode. Snapshots checked in; PR diff catches accidental CSS regressions.
### 10.3 Persona walks
- **Cold start** — operator approaches tablet with no recent session. Clock displays current time; tiles fade in; clicking own tile opens PIN pad immediately (no visible loading state).
- **Mid-shift unlock** — operator returns after auto-lock. Same flow; their tile shows the pulsing clocked-in dot.
- **No logo configured** — companies that haven't set `res.company.logo`. Letter-mark renders cleanly; layout unchanged.
- **Reduced motion** — toggle the OS preference; verify all animations disabled, layout still works.
---
## 11. Migration & rollout
No database migration needed — this is a presentation-layer change reusing existing fields (`res.company.logo`, `res.company.report_header`, `res.users.image_128`).
### Rollout sequence
1. Add tokens SCSS + extend tablet_controller payload — backend deploy.
2. Rewrite tablet_lock.scss + extend XML + extend JS — frontend deploy + asset cache bust.
3. Verify on entech: open the tablet lock URL on a real iPad and a desktop browser.
4. Iterate on visual details (logo padding, gradient intensity, accent color) based on shop-floor feedback.
No feature flag — the redesign is a strict visual improvement, no behavioral changes. Reverting is `git revert <commit>` if needed.
---
## 12. Open questions (deferred)
| # | Question | Resolution |
|---|---|---|
| Q1 | Search box for 25+ operator shops? | **Phase 2.** MVP scoped to ~10-15 ops. Re-evaluate when a customer scales. |
| Q2 | Custom accent color per company? | **Phase 2.** Amber is hardcoded in tokens for MVP. Could be a `res.company.x_fc_shopfloor_accent` field later. |
| Q3 | Weather / news widget on lock screen? | **No.** Out of scope; clutters the screen. Operators don't need it. |
| Q4 | Multi-language toggle visible on lock screen? | **No for MVP.** Existing user.lang flow handles this server-side; lock screen renders in the user's language once they're identified post-PIN. |
| Q5 | Operator photo upload UX? | **Existing flow stays** — managers upload via Preferences → My Profile. Lock screen consumes whatever's there. |
| Q6 | Animation when transitioning tile → PIN pad? | **Phase 2 polish.** Currently the existing FpPinPad just appears; could add a crossfade. Subjective; ship clean first. |
---
## 13. Summary
| Question | Answer |
|---|---|
| Layout | Vertical centered flex column: logo (84px) → clock (40px) → prompt pill → 3-column tile grid (max 480px) |
| Card model | One tile per `res.users` with tablet PIN configured (existing rule); deterministic per-user color gradient |
| Card density | 3 columns, ~140px tiles — fits ~9-12 visible without scroll on a 1366×768 tablet |
| Animation | 7 named animations (entrance stagger, hover lift, click press, status pulse) all bezier-eased, all gated by `prefers-reduced-motion` |
| Dark / Light mode | Single SCSS source with compile-time `$o-webclient-color-scheme` branch — same component, two bundles, no JS theme code |
| Backend touch | Extend `/fp/tablet/tiles` payload with `company` block + per-tile `initials`/`avatar_gradient`/`has_photo`. Two small helper functions. |
| Frontend touch | New `_tablet_lock_tokens.scss`. Full rewrite of `tablet_lock.scss`. Extend XML + JS for clock + company block. |
| Rollout | No DB migration. Plain code deploy + asset cache bust. No feature flag. |
The redesign solves the "looks like a placeholder" feel by branding the screen with the company logo, adding a real-time clock, tightening the tile grid for the small-shop case, and layering glassmorphic finishes + cubic-bezier animations on a hybrid Industrial Bold + Premium structure. Dark and light modes share one source.
Implementation plan to follow.

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.20.8.0',
'version': '19.0.21.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """

View File

@@ -24,15 +24,37 @@
<field name="model_id" ref="base.model_res_users"/>
<field name="state">code</field>
<field name="code"><![CDATA[
# Resolve in priority order: user pref → company default → Sale Orders fallback.
# Resolve in priority order:
# 1. user.x_fc_plating_landing_action_id (per-user override)
# 2. company.x_fc_default_landing_action_id (company default)
# 3. Shop Floor plant-view kanban (when x_fc_shopfloor_layout='v2')
# 4. Sale Orders (when v2 flag unset / legacy)
# 5. Process recipes (configurator absent)
user = env.user
target = False
if 'x_fc_plating_landing_action_id' in user._fields and user.x_fc_plating_landing_action_id:
target = user.x_fc_plating_landing_action_id.sudo()
elif 'x_fc_default_landing_action_id' in env.company._fields and env.company.x_fc_default_landing_action_id:
target = env.company.x_fc_default_landing_action_id.sudo()
if not target:
target = env.ref('fusion_plating_configurator.action_fp_sale_orders', raise_if_not_found=False)
# 2026-05-23 — plant-view dispatch. Read the layout flag and pick the
# appropriate Shop Floor action. Falls through to Sale Orders if no
# client action is registered (e.g. shopfloor module not installed).
layout = env['ir.config_parameter'].sudo().get_param(
'fusion_plating_shopfloor.layout', default='legacy',
)
if layout == 'v2':
target = env.ref(
'fusion_plating_shopfloor.action_fp_plant_kanban',
raise_if_not_found=False,
)
# Legacy or v2-missing → fall through to Sale Orders
if not target:
target = env.ref(
'fusion_plating_configurator.action_fp_sale_orders',
raise_if_not_found=False,
)
if target:
action = target.sudo().read()[0]

View File

@@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# 19.0.21.0.0 — Plant-view Shop Floor kanban redesign.
# Backfill fp.work.centre.area_kind from the existing `kind` taxonomy so
# every routing station has a defined Floor Column on day 1. Admins can
# override afterwards via Configuration → Shop Setup → Routing Stations.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
"""Backfill area_kind on existing fp.work.centre rows.
Mapping is intentionally permissive: every existing kind maps to a
sensible default. Unmapped (e.g. 'other') falls to 'plating' as the
safe wet-shop catch-all and is logged for review.
"""
cr.execute("""
UPDATE fp_work_centre
SET area_kind = CASE kind
WHEN 'wet_line' THEN 'plating'
WHEN 'bake' THEN 'baking'
WHEN 'mask' THEN 'masking'
WHEN 'rack' THEN 'racking'
WHEN 'inspect' THEN 'inspection'
ELSE 'plating'
END
WHERE area_kind IS NULL
""")
# Log any rows that landed on the catch-all so the admin can review.
cr.execute("""
SELECT id, name, code, kind
FROM fp_work_centre
WHERE area_kind = 'plating'
AND kind = 'other'
""")
rows = cr.fetchall()
if rows:
_logger.warning(
"%d fp.work.centre rows had kind='other' and were defaulted "
"to area_kind='plating'; review and adjust if needed: %s",
len(rows),
', '.join(
'%s (id=%s, code=%s)' % (r[1], r[0], r[2])
for r in rows[:10]
),
)
_logger.info("Backfilled area_kind on fp.work.centre")

View File

@@ -3,7 +3,10 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
from markupsafe import Markup
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FpJobStepMove(models.Model):
@@ -74,6 +77,113 @@ class FpJobStepMove(models.Model):
string='Transition Input Values',
)
@api.model_create_multi
def create(self, vals_list):
"""Stamp last_activity_at on from_step + to_step so the plant-view
idle gate (S16) sees moves as activity. Without this, a step that
only ever gets moves (no chatter, no state edits) eventually
trips the 8-hour idle warning falsely.
"""
moves = super().create(vals_list)
Step = self.env['fp.job.step']
step_ids = set()
for m in moves:
if m.from_step_id:
step_ids.add(m.from_step_id.id)
if m.to_step_id:
step_ids.add(m.to_step_id.id)
if step_ids:
Step.browse(list(step_ids)).sudo().with_context(
tracking_disable=True,
).write({'last_activity_at': fields.Datetime.now()})
return moves
# ------------------------------------------------------------------
# S23 — required transition-input gate
# ------------------------------------------------------------------
# When the destination step has requires_transition_form=True, the
# recipe author wants chain-of-custody attestations captured on the
# move (location, photo, customer WO #, etc.). Same dormant-field
# shape as S22's signoff bug — the field existed but nothing enforced
# it. Callers (tablet controllers, future backend wizards) MUST call
# _fp_check_transition_inputs_complete() after writing values to
# transition_input_value_ids.
#
# We can't gate on create() because values are written in a separate
# call after the move row. Model-level enforcement would require
# either a deferred-commit pattern or a write hook; explicit caller
# invocation is the simplest contract.
def _fp_missing_required_transition_inputs(self):
"""Return the recordset of required transition_input prompts on
the to_step's recipe node that have NO captured value on this
move. Centralised helper — used by the gate below and by future
diagnostics."""
self.ensure_one()
Prompt = self.env['fusion.plating.process.node.input']
to_step = self.to_step_id
if not to_step or not to_step.recipe_node_id:
return Prompt
if not to_step.requires_transition_form:
return Prompt
prompts = to_step.recipe_node_id.input_ids
if 'kind' in prompts._fields:
prompts = prompts.filtered(
lambda i: i.kind == 'transition_input')
if 'collect' in prompts._fields:
prompts = prompts.filtered(lambda i: i.collect)
required_prompts = prompts.filtered(lambda i: i.required)
if not required_prompts:
return Prompt
recorded_input_ids = set(
self.transition_input_value_ids.mapped('node_input_id.id')
)
return required_prompts.filtered(
lambda p: p.id not in recorded_input_ids
)
def _fp_check_transition_inputs_complete(self):
"""Raise UserError when the destination step has
requires_transition_form=True and required transition_input
prompts haven't been recorded on this move. Audit gate — same
shape as fp.job.step._fp_check_step_inputs_complete (S21) and
._fp_check_signoff_complete (S22).
Manager bypass via context fp_skip_transition_form=True
(consistent with the existing audit-trail flag on the tablet
controllers). Bypasses are posted to chatter on the move
record naming the user.
"""
if self.env.context.get('fp_skip_transition_form'):
for move in self:
if not move.to_step_id.requires_transition_form:
continue
move.message_post(body=Markup(_(
'Transition-form gate bypassed by %s. '
'Documented deviation — required prompts not '
'recorded on this move.'
)) % self.env.user.name)
return
for move in self:
missing = move._fp_missing_required_transition_inputs()
if not missing:
continue
names = ', '.join(
'"%s"' % (p.name or '').strip() for p in missing
)
raise UserError(_(
'Move to step "%(step)s" cannot be committed — '
'%(n)s required transition prompt(s) not recorded: '
'%(names)s. Fill them in the Move dialog before '
'committing. Managers can override via context flag '
'fp_skip_transition_form=True for documented '
'deviations.'
) % {
'step': move.to_step_id.name,
'n': len(missing),
'names': names,
})
class FpJobStepMoveInputValue(models.Model):
"""Captured value for one transition-input prompt.

View File

@@ -48,6 +48,26 @@ class FpWorkCentre(models.Model):
required=True,
default='other',
)
area_kind = fields.Selection(
[
('receiving', 'Receiving'),
('masking', 'Masking'),
('blasting', 'Blasting'),
('racking', 'Racking'),
('plating', 'Plating'),
('baking', 'Baking'),
('de_racking', 'De-Racking'),
('inspection', 'Final inspection'),
('shipping', 'Shipping'),
],
string='Floor Column',
help='Which Shop Floor column this work centre belongs to. '
'Drives the plant-view kanban grouping — any job whose '
'active step uses this work centre routes into this column. '
'See docs/superpowers/specs/2026-05-23-shopfloor-plant-view-'
'design.md §4.2 for the mapping rules.',
index=True,
)
cost_per_hour = fields.Monetary(
currency_field='currency_id',
help='Used for fp.job.step cost rollups.',

View File

@@ -321,7 +321,7 @@
<label>Estimated Duration (min)</label>
<input type="number" class="form-control" min="0" step="1"
t-att-value="state.selectedNode.estimated_duration || 0"
t-on-change="(ev) => { state.selectedNode.estimated_duration = parseFloat(ev.target.value) || 0; }"/>
t-on-change="(ev) => { state.selectedNode.estimated_duration = (+ev.target.value) || 0; }"/>
</div>
<div class="o_fp_re_field">
@@ -380,7 +380,7 @@
<label for="fp_re_workflow_state">Triggers Workflow State</label>
<select id="fp_re_workflow_state"
class="form-select"
t-on-change="(ev) => { state.selectedNode.triggers_workflow_state_id = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
t-on-change="(ev) => { state.selectedNode.triggers_workflow_state_id = ev.target.value ? (+ev.target.value) : false; }">
<option value=""
t-att-selected="!state.selectedNode.triggers_workflow_state_id">
— None (use default-kind matching) —

View File

@@ -199,7 +199,7 @@
t-if="state.workflowStates and state.workflowStates.length">
<label>Triggers Workflow State</label>
<select class="form-select"
t-on-change="(ev) => { state.editTriggersWorkflowStateId = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
t-on-change="(ev) => { state.editTriggersWorkflowStateId = ev.target.value ? (+ev.target.value) : false; }">
<option value="" t-att-selected="!state.editTriggersWorkflowStateId">— None (use Step Type) —</option>
<t t-foreach="state.workflowStates" t-as="ws" t-key="ws.id">
<option t-att-value="ws.id"
@@ -598,7 +598,7 @@
t-if="state.workflowStates and state.workflowStates.length">
<label class="form-label">Triggers Workflow State</label>
<select class="form-select"
t-on-change="(ev) => { state.libraryEditor.triggers_workflow_state_id = ev.target.value ? parseInt(ev.target.value, 10) : false; }">
t-on-change="(ev) => { state.libraryEditor.triggers_workflow_state_id = ev.target.value ? (+ev.target.value) : false; }">
<option value=""
t-att-selected="!state.libraryEditor.triggers_workflow_state_id">
— None (use default-kind matching) —

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.10.20.0',
'version': '19.0.10.24.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',

View File

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

View File

@@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# 19.0.10.24.0 — Plant-view Shop Floor kanban redesign.
# Backfill fp.job.step.last_activity_at from write_date so existing
# in-progress steps don't immediately trip the S16 idle-warning gate
# (8 hours since last activity) on first compute after deploy.
import logging
_logger = logging.getLogger(__name__)
def migrate(cr, version):
cr.execute("""
UPDATE fp_job_step
SET last_activity_at = write_date
WHERE last_activity_at IS NULL
""")
cr.execute("SELECT count(*) FROM fp_job_step WHERE last_activity_at IS NULL")
remaining = cr.fetchone()[0]
if remaining:
_logger.warning(
"%d fp.job.step rows still have NULL last_activity_at after "
"backfill (no write_date?). These will trip the idle gate "
"on first compute.", remaining,
)
_logger.info("Backfilled last_activity_at on fp.job.step from write_date")

View File

@@ -10,6 +10,8 @@
# qc_check_id is deferred to Task 2.7 (the underlying QC model still
# lives in fusion_plating_bridge_mrp; we'll address its sourcing then).
import datetime
import json
import logging
from markupsafe import Markup
@@ -20,6 +22,15 @@ from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# Plant-view kanban — fixed 9-column sequence (spec §4.1). The order
# here is the visual order on the board AND the order in the
# mini-timeline strip. Never reorder; columns are first-class identity.
_COLUMN_SEQUENCE = [
'receiving', 'masking', 'blasting', 'racking', 'plating',
'baking', 'de_racking', 'inspection', 'shipping',
]
class FpJob(models.Model):
_inherit = 'fp.job'
@@ -138,6 +149,213 @@ class FpJob(models.Model):
'defensive). Drives JobWorkspace landing focus.',
)
# ===== 2026-05-23 Plant-view kanban — card_state + mini_timeline ====
card_state = fields.Char(
string='Card State (plant view)',
compute='_compute_card_state',
store=True,
index=True,
help='One of 13 mutually-exclusive states driving the plant-view '
'kanban card chrome. See spec §6 for the catalog and the '
'explicit precedence dispatch. Stored for fast filter '
'queries (count by state, filter "blocked", etc.).',
)
mini_timeline_json = fields.Text(
string='Mini-Timeline (JSON)',
compute='_compute_mini_timeline_json',
help='Serialized 9-element array, one per Shop Floor column, '
'each {area, state, variant?}. Card UI reads this to render '
'the bottom timeline strip without knowing recipe shape.',
)
# ----- Precedence helpers (spec §6.2 + §9.4) -----------------------
# Each returns a bool. _compute_card_state calls them in precedence
# order and the first truthy one wins. Centralized here so future
# audit-found states can be added by writing one new helper + one new
# rule in the dispatcher.
def _fp_inbound_not_received(self):
"""no_parts — job confirmed, customer's parts in transit."""
self.ensure_one()
if self.state != 'confirmed':
return False
so = self.sale_order_id
if not so or 'x_fc_receiving_ids' not in so._fields:
return False
return any(r.state == 'draft' for r in so.x_fc_receiving_ids)
def _fp_has_open_hold(self):
"""on_hold — fusion.plating.quality.hold open on this job."""
self.ensure_one()
if 'fusion.plating.quality.hold' not in self.env:
return False
Hold = self.env['fusion.plating.quality.hold']
return bool(Hold.search_count([
('job_id', '=', self.id),
('state', '=', 'open'),
]))
def _fp_has_pending_qc(self):
"""awaiting_qc — quality check in draft / in_progress on this job."""
self.ensure_one()
if 'fusion.plating.quality.check' not in self.env:
return False
QC = self.env['fusion.plating.quality.check']
return bool(QC.search_count([
('job_id', '=', self.id),
('state', 'in', ('draft', 'in_progress')),
]))
def _fp_bake_window_due_soon(self, threshold_hours=1):
"""bake_due — bake.window awaiting_bake with deadline < threshold."""
self.ensure_one()
if 'fusion.plating.bake.window' not in self.env:
return False
Window = self.env['fusion.plating.bake.window']
cutoff = fields.Datetime.now() + datetime.timedelta(hours=threshold_hours)
domain = [
('state', '=', 'awaiting_bake'),
('bake_required_by', '<=', cutoff),
]
# bake.window's link to a job varies across installs — fall back
# to SO when no direct fp.job link exists.
if 'job_id' in Window._fields:
domain.append(('job_id', '=', self.id))
elif self.sale_order_id and 'sale_order_id' in Window._fields:
domain.append(('sale_order_id', '=', self.sale_order_id.id))
else:
return False
return bool(Window.search_count(domain))
def _fp_is_mine(self, user=None):
"""*_mine variants — active step's work centre is in operator's
paired stations. MVP holds 1 row in paired_work_centre_ids; Phase 2
multi-station picker can populate multiple."""
self.ensure_one()
user = user or self.env.user
step = self.active_step_id
if not step or not step.work_centre_id:
return False
if 'paired_work_centre_ids' not in user._fields:
return False
return step.work_centre_id.id in user.paired_work_centre_ids.ids
# ----- card_state compute -------------------------------------------
@api.depends(
'state',
'active_step_id',
'active_step_id.state',
'active_step_id.requires_signoff',
'active_step_id.signoff_user_id',
'active_step_id.last_activity_at',
'active_step_id.area_kind',
'active_step_id.recipe_node_id.default_kind',
)
def _compute_card_state(self):
"""Dispatch matching spec §6.2 / §9.3 explicit precedence list."""
for job in self:
# Edge: no active step (all pending or all done)
if not job.active_step_id:
if (job.state == 'confirmed'
and job._fp_inbound_not_received()):
job.card_state = 'no_parts'
else:
job.card_state = 'contract_review'
continue
step = job.active_step_id
# Rule 1 — no_parts
if job._fp_inbound_not_received():
job.card_state = 'no_parts'
continue
# Rule 2 — on_hold
if job._fp_has_open_hold():
job.card_state = 'on_hold'
continue
# Rule 3 — awaiting_signoff (S22)
if (step.requires_signoff and step.state == 'done'
and not step.signoff_user_id):
job.card_state = 'awaiting_signoff'
continue
# Rule 4 — awaiting_qc
if job._fp_has_pending_qc():
job.card_state = 'awaiting_qc'
continue
# Rule 5 — bake_due
if job._fp_bake_window_due_soon():
job.card_state = 'bake_due'
continue
# Rule 6 — predecessor_locked
if (step._fp_should_block_predecessors()
and step._fp_has_unfinished_predecessors()):
job.card_state = 'predecessor_locked'
continue
# Rule 7 — idle_warning (S16)
if (step.state == 'in_progress'
and step._fp_is_idle(threshold_hours=8)):
job.card_state = 'idle_warning'
continue
# Rule 8 — done
if step.area_kind == 'shipping' and job.state == 'done':
job.card_state = 'done'
continue
# Rule 9 — contract_review
if (step.recipe_node_id
and step.recipe_node_id.default_kind == 'contract_review'):
job.card_state = 'contract_review'
continue
# Rules 10/12 — running (mine vs not)
if step.state == 'in_progress':
job.card_state = ('running_mine' if job._fp_is_mine()
else 'running')
continue
# Rules 11/13 — ready (mine vs not)
if step.state == 'ready':
job.card_state = ('ready_mine' if job._fp_is_mine()
else 'ready')
continue
# Safe default
job.card_state = 'ready'
# ----- mini-timeline compute ----------------------------------------
@api.depends(
'step_ids.state',
'step_ids.area_kind',
'active_step_id',
'card_state',
)
def _compute_mini_timeline_json(self):
"""9-element JSON array, one per Shop Floor column."""
for job in self:
active_area = (job.active_step_id.area_kind
if job.active_step_id else None)
timeline = []
for area in _COLUMN_SEQUENCE:
steps_in_area = job.step_ids.filtered(
lambda s: s.area_kind == area,
)
if not steps_in_area:
# Recipe doesn't visit this area — show as upcoming
# to keep visual alignment across cards
timeline.append({'area': area, 'state': 'upcoming'})
continue
if all(s.state in ('done', 'skipped') for s in steps_in_area):
timeline.append({'area': area, 'state': 'done'})
elif area == active_area:
timeline.append({
'area': area,
'state': 'current',
'variant': job.card_state or '',
})
else:
timeline.append({'area': area, 'state': 'upcoming'})
job.mini_timeline_json = json.dumps(timeline)
@api.depends(
'date_deadline',
'step_ids.state',
@@ -1485,25 +1703,26 @@ class FpJob(models.Model):
def _fp_create_portal_job(self):
"""Create the fusion.plating.portal.job mirror record.
Initial state derived from the fp.job state via the same map
used by write() — so a job that's already 'in_progress' when
the portal mirror is created (e.g. a manual catch-up create)
doesn't reset to 'received'.
Seeded with 'received' then handed to
`fusion.plating.portal.job._fp_recompute_portal_state` — that
helper is the single source of truth for portal state and
derives it from the WO + shipment + invoice signals, so a
catch-up create on an already-in-progress job lands on the
right state rather than stuck on 'received'.
"""
self.ensure_one()
if self.portal_job_id:
return # already exists — idempotent
Portal = self.env['fusion.plating.portal.job'].sudo()
initial_state = self._FP_JOB_STATE_TO_PORTAL_STATE.get(
self.state, 'received',
)
portal = Portal.create({
'name': self.name,
'partner_id': self.partner_id.id,
'state': initial_state,
'state': 'received',
'x_fc_job_id': self.id,
})
self.portal_job_id = portal.id
if hasattr(portal, '_fp_recompute_portal_state'):
portal._fp_recompute_portal_state()
def _fp_create_qc_check_if_needed(self):
"""If customer has x_fc_requires_qc=True, spawn a QC check via
@@ -1528,9 +1747,17 @@ class FpJob(models.Model):
try:
QC.create_for_job(self)
except Exception as e:
# F7 — surface silent failures on the job's chatter so the
# operator sees the gap and creates the QC manually. Logging
# to /var/log/odoo/odoo-server.log alone meant nobody noticed
# (2CM's WH/JOB/00002 silently lost its QC check this way).
_logger.warning(
"Job %s: create_for_job failed: %s", self.name, e,
)
self.message_post(body=_(
'QC check auto-create failed: %(e)s. '
'Create the QC check manually from the Quality menu.'
) % {'e': e})
# ------------------------------------------------------------------
# button_mark_done — Task 2.8
@@ -1745,10 +1972,18 @@ class FpJob(models.Model):
# partner_id is the customer.
Template._dispatch(event, self, partner=self.partner_id)
except Exception as e:
# F7 — surface on chatter. A missed customer notification
# (e.g. "your parts have shipped") is invisible to the
# operator until the customer complains; the chatter post
# gives accounting / sales a recoverable signal.
_logger.warning(
"Job %s: notification %s dispatch failed: %s",
self.name, event, e,
)
self.message_post(body=_(
'Notification dispatch failed for event "%(ev)s": %(e)s. '
'Send manually if the customer expected an update.'
) % {'ev': event, 'e': e})
def _fp_create_delivery(self):
"""Create a draft fusion.plating.delivery linked to this job.
@@ -1787,9 +2022,16 @@ class FpJob(models.Model):
delivery = Delivery.create(vals)
self.delivery_id = delivery.id
except Exception as e:
# F7 — surface on chatter. Without this, the operator sees
# "Job marked done" but no delivery record exists, and the
# next milestone advance fails silently.
_logger.warning(
"Job %s: failed to auto-create delivery: %s", self.name, e,
)
self.message_post(body=_(
'Delivery auto-create failed: %(e)s. '
'Create the delivery manually from the Logistics menu.'
) % {'e': e})
def _fp_resolve_delivery_defaults(self, Delivery):
"""Build the create-vals for a fresh delivery, OR the

View File

@@ -17,6 +17,62 @@ from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# Mapping from fp.process.node.default_kind → fp.work.centre.area_kind.
# Used as fallback by fp.job.step.area_kind compute when the step has no
# work_centre_id or its work_centre has no area_kind set. Authoritative
# source per the plant-view spec §4.2.
# 2026-05-23 — Shop Floor plant-view redesign.
_STEP_KIND_TO_AREA = {
# Receiving (admin / pre-physical-work)
'receiving': 'receiving',
'incoming_inspection': 'receiving',
'contract_review': 'receiving',
'gating': 'receiving',
'ready_for_processing': 'receiving',
# Masking
'masking': 'masking',
# Blasting
'blasting': 'blasting',
'bead_blast': 'blasting',
'media_blast': 'blasting',
# Racking
'racking': 'racking',
# Plating (everything wet — rolled up into one column per spec D3)
'soak_clean': 'plating',
'electroclean': 'plating',
'acid_dip': 'plating',
'etch': 'plating',
'desmut': 'plating',
'zincate': 'plating',
'rinse': 'plating',
'water_break_test': 'plating',
'activation': 'plating',
'e_nickel_plate': 'plating',
'chrome': 'plating',
'anodize': 'plating',
'black_oxide': 'plating',
'drying': 'plating',
# Baking
'bake': 'baking',
'oven_bake': 'baking',
'post_bake_relief': 'baking',
# De-Racking (folds in de-masking per spec D4)
'de_rack': 'de_racking',
'de_mask': 'de_racking',
'unrack': 'de_racking',
# Final inspection (post-plate inspection / FAIR / thickness QC)
'inspection': 'inspection',
'final_inspection': 'inspection',
'post_plate_inspection':'inspection',
'thickness_qc': 'inspection',
'fair': 'inspection',
'dimensional_check': 'inspection',
# Shipping
'shipping': 'shipping',
'pack_ship': 'shipping',
}
class FpJobStep(models.Model):
_inherit = 'fp.job.step'
@@ -53,6 +109,16 @@ class FpJobStep(models.Model):
# Free-flow recipe — only the legacy per-step flag still gates.
return bool(self.requires_predecessor_done)
def _fp_has_unfinished_predecessors(self):
"""True when an earlier-sequence step on the same job is not yet
in a terminal state. Composes with _fp_should_block_predecessors
to drive the plant-view predecessor_locked card state."""
self.ensure_one()
return bool(self.job_id.step_ids.filtered(
lambda s: s.sequence < self.sequence
and s.state not in ('done', 'skipped', 'cancelled')
))
can_start = fields.Boolean(
string='Can Start',
compute='_compute_can_start',
@@ -85,6 +151,73 @@ class FpJobStep(models.Model):
)
step.can_start = not bool(blocking)
# ===== 2026-05-23 plant-view redesign — area_kind + activity =========
area_kind = fields.Selection(
[
('receiving', 'Receiving'),
('masking', 'Masking'),
('blasting', 'Blasting'),
('racking', 'Racking'),
('plating', 'Plating'),
('baking', 'Baking'),
('de_racking', 'De-Racking'),
('inspection', 'Final inspection'),
('shipping', 'Shipping'),
],
string='Floor Column',
compute='_compute_area_kind',
store=True,
index=True,
help='Which Shop Floor column this step belongs to. Resolved as: '
'(1) work_centre.area_kind if set; else (2) fallback to '
'_STEP_KIND_TO_AREA[recipe_node.default_kind]; else (3) the '
'safe catch-all "plating". Drives plant-view kanban grouping.',
)
@api.depends('work_centre_id.area_kind', 'recipe_node_id.default_kind')
def _compute_area_kind(self):
for step in self:
if step.work_centre_id and step.work_centre_id.area_kind:
step.area_kind = step.work_centre_id.area_kind
continue
kind = step.recipe_node_id.default_kind if step.recipe_node_id else False
if kind and kind in _STEP_KIND_TO_AREA:
step.area_kind = _STEP_KIND_TO_AREA[kind]
continue
step.area_kind = 'plating'
last_activity_at = fields.Datetime(
string='Last Activity',
index=True,
help='Stamped on any state transition, move-out from this step, '
'or chatter post. Drives the S16 idle-warning card state '
'(in_progress with no activity for 8+ hours).',
)
def _fp_is_idle(self, threshold_hours=8):
"""True when this step is in_progress AND last_activity_at is older
than `threshold_hours`. Drives the idle_warning card state."""
self.ensure_one()
if self.state != 'in_progress':
return False
if not self.last_activity_at:
return False
delta = fields.Datetime.now() - self.last_activity_at
return delta.total_seconds() > threshold_hours * 3600
def message_post(self, **kwargs):
"""Override: stamp last_activity_at so an operator note counts as
activity (defeats false-positive idle warnings during long bakes
where the only sign of life is the periodic operator note)."""
res = super().message_post(**kwargs)
try:
self.sudo().with_context(tracking_disable=True).write({
'last_activity_at': fields.Datetime.now(),
})
except Exception as exc:
_logger.debug("last_activity_at stamp on message_post failed: %s", exc)
return res
# Gate visualizer — drives the OWL GateViz component on the tablet.
# Returns kind of blocker + human reason + optional (model, id) jump
# target. Reuses _fp_should_block_predecessors so this stays in sync
@@ -286,6 +419,14 @@ class FpJobStep(models.Model):
if new_uid == old_uid:
continue
post_for.append((step, old_uid, new_uid))
# Plant-view: stamp last_activity_at on every state transition so
# the S16 idle gate has fresh data. Only stamp when state is in
# vals AND it's actually changing (avoid no-op writes spamming
# the timestamp).
if 'state' in vals and 'last_activity_at' not in vals:
new_state = vals['state']
if any(step.state != new_state for step in self):
vals = dict(vals, last_activity_at=fields.Datetime.now())
result = super().write(vals)
Users = self.env['res.users']
for step, old_uid, new_uid in post_for:
@@ -542,29 +683,179 @@ class FpJobStep(models.Model):
return candidates[:1] or self.env['fp.job.step']
def _fp_has_uncaptured_step_inputs(self):
"""True when the recipe step defines step_input prompts AND
the user hasn't already saved values for this step's current
run via the Record Inputs wizard.
"""True when the recipe step has REQUIRED step_input prompts
whose values haven't been recorded yet.
Previously this checked "any move with input values exists since
date_started" — too coarse. Operator clicked Save on the dialog
after filling ONE prompt and the helper went quiet, letting
action_finish_and_advance bypass the dialog re-open even when
4 of 5 required prompts were still empty (WO-30051 / Riya 2026-05-23).
Now we count actual coverage per required input across every
move recorded against this step.
"""
self.ensure_one()
return bool(self._fp_missing_required_step_inputs())
def _fp_missing_required_step_inputs(self):
"""Return the recordset of REQUIRED step_input prompts on this
step's recipe node that have NO value recorded across any move
from this step. Centralised helper — used by both
_fp_has_uncaptured_step_inputs (re-open dialog) and
_fp_check_step_inputs_complete (raise UserError on finish).
"""
self.ensure_one()
node = self.recipe_node_id
Prompt = self.env['fusion.plating.process.node.input']
if not node:
return False
return Prompt
prompts = node.input_ids
if 'kind' in prompts._fields:
prompts = prompts.filtered(lambda i: i.kind == 'step_input')
if not prompts:
return False
# Has the operator already recorded values during this run?
# Heuristic: any in-place fp.job.step.move (transfer_type='step')
# for this step since date_started.
Move = self.env['fp.job.step.move']
already = Move.search_count([
('from_step_id', '=', self.id),
('transfer_type', '=', 'step'),
('move_datetime', '>=', self.date_started or fields.Datetime.now()),
])
return already == 0
if 'collect' in prompts._fields:
prompts = prompts.filtered(lambda i: i.collect)
required_prompts = prompts.filtered(lambda i: i.required)
if not required_prompts:
return Prompt
Value = self.env['fp.job.step.move.input.value']
recorded_input_ids = set(Value.search([
('move_id.from_step_id', '=', self.id),
('node_input_id', 'in', required_prompts.ids),
]).mapped('node_input_id.id'))
return required_prompts.filtered(
lambda p: p.id not in recorded_input_ids
)
def _fp_autosign_if_required(self):
"""Auto-set signoff_user_id to the current user when the step has
requires_signoff=True and no signoff has been recorded yet.
Called from button_finish just before the signoff gate. Captures
WHO finished the step as the signer-of-record. For shops that
need separate operator+supervisor sign-off, call action_signoff()
explicitly from a supervisor session BEFORE the operator clicks
Finish — that pre-sets signoff_user_id and this helper becomes a
no-op.
Idempotent — never overwrites an existing signoff_user_id, so a
manager pre-signing via action_signoff is preserved through the
operator's Finish click.
"""
for step in self:
if not step.requires_signoff:
continue
if step.signoff_user_id:
continue # pre-signed (likely by a supervisor)
# Use sudo because signoff_user_id is readonly=True at field
# level; we still capture env.user.id (not SUPERUSER_ID) so
# the audit trail shows who actually clicked.
step.sudo().write({'signoff_user_id': self.env.user.id})
def _fp_check_signoff_complete(self):
"""Raise UserError if the step has requires_signoff=True and
signoff_user_id IS NULL. Aerospace / Nadcap need a named signer
on every sign-off-required step; an unset signer breaks the
audit chain.
Normally _fp_autosign_if_required (called from button_finish
immediately before this gate) populates signoff_user_id with the
finisher's id, so this gate only fires when:
- The step is being finished via a code path that bypasses
autosign (e.g. a migration script writing state='done').
- The user has no env.user (background cron with no uid set).
Manager bypass via context fp_skip_signoff_gate=True for
documented customer deviations. Bypasses are posted to chatter
naming the user.
"""
if self.env.context.get('fp_skip_signoff_gate'):
for step in self:
if not step.requires_signoff:
continue
step.job_id.message_post(body=Markup(_(
'Sign-off gate bypassed on step "<b>%s</b>" by %s. '
'Documented deviation — no signer recorded.'
)) % (step.name, self.env.user.name))
return
for step in self:
if not step.requires_signoff:
continue
if step.signoff_user_id:
continue
raise UserError(_(
'Step "%(step)s" cannot be finished — sign-off required '
'but no signer recorded. Click "Sign Off" on the step '
'(or have your supervisor sign before you finish). '
'Managers can override via context flag '
'fp_skip_signoff_gate=True for documented deviations.'
) % {'step': step.name})
def action_signoff(self):
"""Explicit sign-off action — sets signoff_user_id = env.user.id
for the calling user. Use case: a supervisor reviews an operator's
work and signs off BEFORE the operator clicks Finish. Once signed,
the operator's Finish click passes the signoff gate without auto-
assigning a different signer.
Idempotent — re-clicking by the same user is a no-op. A DIFFERENT
user re-signing overwrites the prior signer (and chatters the change)
so a senior supervisor can override a junior's premature sign-off
without leaving the audit trail mute.
"""
for step in self:
if not step.requires_signoff:
raise UserError(_(
'Step "%s" does not require sign-off — nothing to sign.'
) % step.name)
prior = step.signoff_user_id
if prior and prior.id == self.env.user.id:
continue # idempotent
step.sudo().write({'signoff_user_id': self.env.user.id})
if prior:
step.job_id.message_post(body=Markup(_(
'Sign-off on step "<b>%s</b>" reassigned from %s to %s.'
)) % (step.name, prior.name, self.env.user.name))
else:
step.job_id.message_post(body=Markup(_(
'Step "<b>%s</b>" signed off by %s.'
)) % (step.name, self.env.user.name))
return True
def _fp_check_step_inputs_complete(self):
"""Raise UserError if the step has REQUIRED step_input prompts
that haven't been recorded yet. AS9100 / Nadcap need a complete
per-step data trail; finishing a step with missing prompts breaks
the audit chain.
Manager bypass via context fp_skip_required_inputs_gate=True
(e.g. paper-form catch-up or documented customer deviation).
Bypasses are posted to chatter naming the user.
"""
if self.env.context.get('fp_skip_required_inputs_gate'):
for step in self:
step.job_id.message_post(body=Markup(_(
'Required-inputs gate bypassed on step "<b>%s</b>" by %s. '
'Documented deviation — review the step\'s prompts.'
)) % (step.name, self.env.user.name))
return
for step in self:
missing = step._fp_missing_required_step_inputs()
if not missing:
continue
names = ', '.join('"%s"' % (p.name or '').strip() for p in missing)
raise UserError(_(
'Step "%(step)s" cannot be finished — %(n)s required '
'input(s) not recorded yet: %(names)s. '
'Click "Record Inputs" on the step row to enter the '
'missing values, then finish. '
'Managers can override via context flag '
'fp_skip_required_inputs_gate=True for documented '
'deviations.'
) % {
'step': step.name,
'n': len(missing),
'names': names,
})
def _fp_open_input_wizard(self, advance_after=False):
"""Open the Record Inputs OWL dialog (Sub 12e v4).
@@ -593,93 +884,12 @@ class FpJobStep(models.Model):
# _fp_open_input_wizard above adds the advance_after pathway used
# only by action_finish_and_advance.
def button_finish(self):
"""Override to:
1) Auto-spawn a bake.window when a wet plating step finishes
on a recipe that requires hydrogen-embrittlement relief
(AS9100 / Nadcap compliance). Bake fields live on the
recipe root post-promote-customer-spec.
2) Post a chatter warning when duration_actual exceeds 1.5×
duration_expected — silent overruns are a red flag for
scheduling and costing.
Both actions are idempotent and never block the finish itself.
"""
result = super().button_finish()
BW = self.env['fusion.plating.bake.window']
Bath = self.env['fusion.plating.bath']
for step in self:
if step.state != 'done':
continue
# Duration-overrun chatter alert.
if step.duration_expected and step.duration_actual:
ratio = step.duration_actual / step.duration_expected
if ratio >= 1.5:
step.job_id.message_post(body=Markup(_(
'⚠️ <b>Step "%s" ran %.1fx expected</b> — '
'expected %.0f min, actual %.0f min. Investigate: '
'equipment issue, training gap, or recipe time '
'estimate too tight.'
)) % (step.name, ratio, step.duration_expected,
step.duration_actual))
recipe_root = step.job_id.recipe_id
if not recipe_root:
continue
requires = getattr(recipe_root, 'requires_bake_relief', False)
window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
if not requires or not window_hrs:
continue
# Trigger only on the actual plating-out step. We want
# exactly ONE bake.window per job (not one per step that
# happens to have "plate" in the name). Heuristic:
# - step.kind == 'wet' (clean, recipe-authored signal); OR
# - the step name contains "plating" as a word
# Explicit excludes: inspection / bake / mask / rack steps
# whose names might happen to mention plating in passing
# (e.g. "Post-plate Inspection").
name_l = (step.name or '').lower()
kind_match = step.kind == 'wet'
name_match = bool(re.search(r'\bplating\b', name_l))
excluded = any(kw in name_l for kw in (
'inspect', 'inspection', 'bake', 'mask', 'rack',
))
if (not kind_match and not name_match) or excluded:
continue
# Idempotency — only one bake.window per (job, step).
existing = BW.sudo().search([
('part_ref', '=', step.job_id.name),
('lot_ref', '=', f'step-{step.id}'),
], limit=1)
if existing:
continue
# Pick a bath: step.bath_id wins; fall back to the first
# active bath in the facility (best-effort — operator can
# correct on the bake.window record).
bath = step.bath_id or Bath.sudo().search(
[('facility_id', '=', step.facility_id.id)], limit=1,
) if step.facility_id else False
if not bath:
bath = Bath.sudo().search([], limit=1)
if not bath:
_logger.warning(
'Step %s: bake-window auto-spawn skipped — no bath '
'configured.', step.name,
)
continue
bw = BW.sudo().create({
'bath_id': bath.id,
'plate_exit_time': step.date_finished or fields.Datetime.now(),
'window_hours': window_hrs,
'part_ref': step.job_id.name,
'lot_ref': f'step-{step.id}',
'customer_ref': step.job_id.partner_id.display_name or '',
'quantity': int(step.job_id.qty or 0),
})
step.job_id.message_post(body=Markup(_(
'Bake window <b>%s</b> auto-created — %.1fh window from '
'plate exit. Required by %s.'
)) % (bw.name, window_hrs, bw.bake_required_by))
return result
# NOTE — the earlier duplicate `button_finish` definition that held
# the duration-overrun + bake.window auto-spawn logic has been merged
# into the canonical button_finish further down (line ~1130). Python
# was silently keeping only the LAST definition in this class body,
# so the bake.window auto-spawn was dead code for the entire WO-30051
# era. Don't re-introduce a second button_finish here.
# ==================================================================
# Phase 2 multi-serial — auto-promote serials on step transitions
@@ -1070,18 +1280,112 @@ class FpJobStep(models.Model):
return result
def button_finish(self):
# Policy B — block until QA-005 complete (when customer requires it).
"""Canonical button_finish — gates first, then super(), then
post-finish side effects.
Gates (raise UserError, blocking finish):
- Required step_input prompts recorded (S21 / WO-30051 fix).
Manager bypass: fp_skip_required_inputs_gate=True.
- Sign-off recorded when recipe step has requires_signoff=True
(S22 / F1 audit fix). Auto-sign captures the finisher when
no supervisor has pre-signed. Manager bypass:
fp_skip_signoff_gate=True.
- Contract Review (QA-005) complete when customer requires it.
- Receiving gate — parts physically on site for this WO.
(Racking-inspection gate removed — racking is a recipe step
now, not a separate workflow. _fp_check_racking_inspection_
complete() is kept as a helper for diagnostics.)
Post-finish (idempotent, never blocks):
- Promote attached serials from in_process -> inspected on the
terminal step of the job.
- Chatter warning when duration_actual >= 1.5x duration_expected.
- Auto-spawn a bake.window for wet plating steps on recipes
flagged requires_bake_relief.
"""
# ----- Gates ----------------------------------------------------
# Order matters: cheapest checks first. Required-inputs is a pure
# ORM query; contract review and receiving may touch related models.
self._fp_check_step_inputs_complete()
# Sign-off: auto-capture the finisher's uid first (no-op when a
# supervisor pre-signed via action_signoff), THEN gate. Gate only
# fires when both autosign and explicit sign-off skipped (e.g.
# migration scripts, background crons).
self._fp_autosign_if_required()
self._fp_check_signoff_complete()
self._fp_check_contract_review_complete()
# Receiving gate — same helper as button_start, exempts CR steps.
self._fp_check_receiving_gate()
# NOTE: racking inspection gate removed — racking is now a recipe
# step, not a separate inspection workflow. _fp_check_racking_
# inspection_complete() is kept as a helper for diagnostics but
# no longer enforced from button_finish.
result = super().button_finish()
# ----- Post-finish side effects --------------------------------
BW = self.env['fusion.plating.bake.window']
Bath = self.env['fusion.plating.bath']
for step in self:
if step.state == 'done':
step._fp_promote_serials_on_finish()
if step.state != 'done':
continue
step._fp_promote_serials_on_finish()
# Duration-overrun chatter alert.
if step.duration_expected and step.duration_actual:
ratio = step.duration_actual / step.duration_expected
if ratio >= 1.5:
step.job_id.message_post(body=Markup(_(
'⚠️ <b>Step "%s" ran %.1fx expected</b> — '
'expected %.0f min, actual %.0f min. Investigate: '
'equipment issue, training gap, or recipe time '
'estimate too tight.'
)) % (step.name, ratio, step.duration_expected,
step.duration_actual))
# Bake-window auto-spawn — wet plating step + recipe flagged
# requires_bake_relief. Heuristic identifies the actual
# plate-out step (kind=wet OR "plating" as a word in name),
# excluding inspection/bake/mask/rack steps that mention
# plating in passing (e.g. "Post-plate Inspection").
recipe_root = step.job_id.recipe_id
if not recipe_root:
continue
requires = getattr(recipe_root, 'requires_bake_relief', False)
window_hrs = getattr(recipe_root, 'bake_window_hours', 0.0)
if not requires or not window_hrs:
continue
name_l = (step.name or '').lower()
kind_match = step.kind == 'wet'
name_match = bool(re.search(r'\bplating\b', name_l))
excluded = any(kw in name_l for kw in (
'inspect', 'inspection', 'bake', 'mask', 'rack',
))
if (not kind_match and not name_match) or excluded:
continue
existing = BW.sudo().search([
('part_ref', '=', step.job_id.name),
('lot_ref', '=', f'step-{step.id}'),
], limit=1)
if existing:
continue
bath = step.bath_id or Bath.sudo().search(
[('facility_id', '=', step.facility_id.id)], limit=1,
) if step.facility_id else False
if not bath:
bath = Bath.sudo().search([], limit=1)
if not bath:
_logger.warning(
'Step %s: bake-window auto-spawn skipped — no bath '
'configured.', step.name,
)
continue
bw = BW.sudo().create({
'bath_id': bath.id,
'plate_exit_time': step.date_finished or fields.Datetime.now(),
'window_hours': window_hrs,
'part_ref': step.job_id.name,
'lot_ref': f'step-{step.id}',
'customer_ref': step.job_id.partner_id.display_name or '',
'quantity': int(step.job_id.qty or 0),
})
step.job_id.message_post(body=Markup(_(
'Bake window <b>%s</b> auto-created — %.1fh window from '
'plate exit. Required by %s.'
)) % (bw.name, window_hrs, bw.bake_required_by))
return result
# ==================================================================
@@ -1200,6 +1504,33 @@ class FpJobStep(models.Model):
# quick-look modal. The modal is bound via context= on the parent
# 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(
string='Operator Instructions',
related='recipe_node_id.description',

View File

@@ -433,6 +433,31 @@ export class FpRecordInputsDialog extends Component {
this.state.rows.splice(idx, 1);
}
// Mirrors fp.job.step.input.wizard.line._has_value() Python helper.
// Critical: the wizard SKIPS rows where _has_value() is False when
// creating fp.job.step.move.input.value records, so the server-side
// required-inputs gate considers them "not recorded". This client
// check must match that semantic exactly or the server will reject
// saves the operator thought were complete.
_fpRowHasValue(row) {
if (row.input_type === "photo") return !!row.photo_value;
if (row.input_type === "multi_point_thickness") {
return !!(row.point_1 || row.point_2 || row.point_3
|| row.point_4 || row.point_5);
}
if (row.input_type === "bath_chemistry_panel") {
return !!(row.panel_ph || row.panel_concentration
|| row.panel_temperature || row.panel_bath_id);
}
if (row.input_type === "pass_fail") return !!row._passfail_chosen;
// Boolean: value_boolean===true counts; untouched/false is
// treated as no-value to match Python `any([..., self.value_
// boolean, ...])`. Operators MUST affirmatively check the box.
return !!(row.value_text || row.value_number
|| row.value_boolean || row.value_date
|| row.value_min || row.value_max);
}
// The "current" initials value across all rows — a row counts as a
// signature/initials field when ``_fpIsInitialsField`` is true.
// Returns the most-recently-set value (last write wins) or empty.
@@ -477,6 +502,26 @@ export class FpRecordInputsDialog extends Component {
return;
}
}
// Required-prompt gate when finishing the step (advanceAfter=true).
// Mirrors fp.job.step._fp_check_step_inputs_complete server-side
// so the operator sees the missing fields instantly instead of
// getting a server roundtrip error after the save commits. Partial
// saves are still allowed when the dialog is opened from the
// per-row Record button (advanceAfter=false).
if (this.props.advanceAfter) {
const missing = this.state.rows
.filter((r) => r.required && !this._fpRowHasValue(r))
.map((r) => r.name || _t("(unnamed)"));
if (missing.length) {
this.notification.add(
_t("Cannot finish step — %n required prompt(s) missing: %list")
.replace("%n", missing.length)
.replace("%list", missing.map((n) => `"${n}"`).join(", ")),
{ type: "danger", sticky: true },
);
return;
}
}
this.state.saving = true;
const payload = this.state.rows.map((r) => {
// When the prompt expects a range entry (min + max readings),

View File

@@ -9,20 +9,26 @@
site that needs to bring legacy menus back can simply add a
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
to all users (subject to the original groups= attribute on
each menuitem in fusion_plating_shopfloor/views/fp_menu.xml). -->
<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')])]"/>
</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">
<field name="group_ids" eval="[(6, 0, [])]"/>
</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
(the bridge module is uninstalled and its menu xmlid no longer
resolves). fp.job has its own priority field on the header. -->

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.29.0.0',
'version': '19.0.32.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
@@ -45,7 +45,9 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'security/ir.model.access.csv',
'data/fp_sequence_data.xml',
'data/fp_cron_data.xml',
'data/fp_tablet_config_data.xml',
'views/fp_shopfloor_station_views.xml',
'views/res_users_views.xml',
'views/fp_bake_oven_views.xml',
'views/fp_bake_window_views.xml',
'views/fp_first_piece_gate_views.xml',
@@ -80,6 +82,27 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'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/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',
# 2026-05-24 lock-screen redesign — tokens MUST precede tablet_lock.scss
# so the $lock-* vars are visible to the consumer (project rule 8).
'fusion_plating_shopfloor/static/src/scss/_tablet_lock_tokens.scss',
'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) ----
'fusion_plating_shopfloor/static/src/scss/job_workspace.scss',
'fusion_plating_shopfloor/static/src/xml/job_workspace.xml',
@@ -88,6 +111,33 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'fusion_plating_shopfloor/static/src/scss/shopfloor_landing.scss',
'fusion_plating_shopfloor/static/src/xml/shopfloor_landing.xml',
'fusion_plating_shopfloor/static/src/js/shopfloor_landing.js',
# ---- Plant View Kanban (2026-05-23 redesign) ---------------
# Tokens MUST load first (project rule 8: SCSS @import is
# forbidden in Odoo 19 custom code; manifest order is the
# concatenation order, and tokens carry the $plant-* vars
# used by every component partial below).
'fusion_plating_shopfloor/static/src/scss/_plant_tokens.scss',
'fusion_plating_shopfloor/static/src/scss/components/_plant_card.scss',
'fusion_plating_shopfloor/static/src/scss/components/_mini_timeline.scss',
'fusion_plating_shopfloor/static/src/scss/components/_column_header.scss',
'fusion_plating_shopfloor/static/src/scss/components/_kpi_tile.scss',
'fusion_plating_shopfloor/static/src/scss/components/_filter_chip.scss',
'fusion_plating_shopfloor/static/src/scss/plant_kanban.scss',
# XML templates (must precede their JS consumers)
'fusion_plating_shopfloor/static/src/xml/components/mini_timeline.xml',
'fusion_plating_shopfloor/static/src/xml/components/plant_card.xml',
'fusion_plating_shopfloor/static/src/xml/components/column_header.xml',
'fusion_plating_shopfloor/static/src/xml/components/kpi_tile.xml',
'fusion_plating_shopfloor/static/src/xml/components/filter_chip.xml',
'fusion_plating_shopfloor/static/src/xml/plant_kanban.xml',
# JS — leaf components first, then card (imports timeline),
# then top-level orchestrator (imports all).
'fusion_plating_shopfloor/static/src/js/components/mini_timeline.js',
'fusion_plating_shopfloor/static/src/js/components/plant_card.js',
'fusion_plating_shopfloor/static/src/js/components/column_header.js',
'fusion_plating_shopfloor/static/src/js/components/kpi_tile.js',
'fusion_plating_shopfloor/static/src/js/components/filter_chip.js',
'fusion_plating_shopfloor/static/src/js/plant_kanban.js',
'fusion_plating_shopfloor/static/src/scss/qr_scanner.scss',
'fusion_plating_shopfloor/static/src/scss/fusion_plating_shopfloor.scss',
'fusion_plating_shopfloor/static/src/scss/plant_overview.scss',

View File

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

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 odoo import http
from odoo import fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.http import request
from ._tablet_audit import env_for_tablet_tech
_logger = logging.getLogger(__name__)
@@ -387,7 +389,8 @@ class FpManagerDashboardController(http.Controller):
# Assign a worker to a step
# ------------------------------------------------------------------
@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
kwarg; ``workorder_id`` is accepted as a deprecated alias for
one release so any caller we missed doesn't break.
@@ -400,7 +403,8 @@ class FpManagerDashboardController(http.Controller):
step_id = workorder_id
if not step_id:
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():
return {'ok': False, 'error': 'Step not found.'}
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
# ------------------------------------------------------------------
@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;
``workorder_id`` is accepted as a deprecated alias.
"""
@@ -427,7 +432,8 @@ class FpManagerDashboardController(http.Controller):
step_id = workorder_id
if not step_id:
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():
return {'ok': False, 'error': 'Step not found.'}
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)
# ------------------------------------------------------------------
@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;
``workorder_id`` is accepted as a deprecated alias.
"""
@@ -454,10 +461,11 @@ class FpManagerDashboardController(http.Controller):
step_id = workorder_id
if not step_id:
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():
return {'ok': False, 'error': 'Step not found.'}
user = request.env.user
user = env.user
previous = step.assigned_user_id.name or ''
step.assigned_user_id = user.id
step.message_post(

View File

@@ -203,6 +203,13 @@ class FpTabletMoveController(http.Controller):
for prompt_id, value in (prompt_values or {}).items():
self._capture_prompt_value(move, int(prompt_id), value)
# S23 — required transition-input gate. Runs AFTER value capture
# so the operator gets credit for whatever they filled in. Raises
# UserError if to_step.requires_transition_form=True and any
# required transition_input prompt has no value. Rollback unwinds
# the move + value rows. Manager bypass: fp_skip_transition_form.
move._fp_check_transition_inputs_complete()
# Advance qty_at_step counters
to_step.qty_at_step_start = (to_step.qty_at_step_start or 0) + qty
from_step.qty_at_step_finish = (from_step.qty_at_step_finish or 0) + qty
@@ -298,6 +305,42 @@ class FpTabletMoveController(http.Controller):
rack = Rack.browse(rack_id)
to_step = Step.browse(to_step_id)
# S23 — pre-check: rack moves don't capture transition prompts
# (no per-move dialog), so if to_step.requires_transition_form
# we must reject up-front and force the operator through Move
# Parts (which has the form UI). Without this check, rack moves
# silently bypass the audit gate that Move Parts enforces.
if (to_step.requires_transition_form
and not request.env.context.get('fp_skip_transition_form')):
# Use the same model helper for consistency — build a dummy
# in-memory move to compute "missing" set, then surface a
# clear message that points operators at the right tool.
recipe_node = to_step.recipe_node_id
required_prompts = recipe_node.input_ids if recipe_node else (
request.env['fusion.plating.process.node.input']
)
if 'kind' in required_prompts._fields:
required_prompts = required_prompts.filtered(
lambda i: i.kind == 'transition_input')
required_prompts = required_prompts.filtered(
lambda i: i.required)
if required_prompts:
names = ', '.join(
'"%s"' % (p.name or '').strip()
for p in required_prompts
)
raise UserError(_(
'Step "%(step)s" requires a transition form '
'(%(n)s required prompt(s): %(names)s). '
'Use Move Parts for one batch at a time so the form '
'can be filled in, or have a manager override with '
'context flag fp_skip_transition_form=True.'
) % {
'step': to_step.name,
'n': len(required_prompts),
'names': names,
})
moves = []
for batch in Step.search([('rack_id', '=', rack.id)]):
qty = (batch.qty_done or 0) - (batch.qty_scrapped or 0)

View File

@@ -0,0 +1,378 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Plant-view Shop Floor kanban endpoint.
Returns {kpis, columns, cards} in one JSONRPC payload so the OWL
FpPlantKanban component doesn't fan out per-card RPCs. One card per
fp.job; cards grouped into the 9 fixed Shop Floor columns. See spec at
docs/superpowers/specs/2026-05-23-shopfloor-plant-view-design.md.
"""
import json
import logging
from datetime import date, datetime, timedelta
from odoo import _, http
from odoo.http import request
_logger = logging.getLogger(__name__)
# Mirrors fusion_plating_jobs.models.fp_job._COLUMN_SEQUENCE exactly.
# Keep these two in sync — the column order on the board IS the sequence.
_COLUMN_LABELS = [
('receiving', _('Receiving')),
('masking', _('Masking')),
('blasting', _('Blasting')),
('racking', _('Racking')),
('plating', _('Plating')),
('baking', _('Baking')),
('de_racking', _('De-Racking')),
('inspection', _('Final inspection')),
('shipping', _('Shipping')),
]
# Sort priority within a column (overdue → bake_due → mine → ready/run
# → idle → locked → done). Lower number wins (sorted ascending).
_SORT_PRIORITY = {
'on_hold': 0,
'no_parts': 1,
'bake_due': 2,
'awaiting_signoff': 3,
'awaiting_qc': 4,
'ready_mine': 5,
'running_mine': 6,
'ready': 7,
'running': 8,
'idle_warning': 9,
'predecessor_locked': 10,
'contract_review': 11,
'done': 12,
}
class PlantKanbanController(http.Controller):
@http.route('/fp/landing/plant_kanban', type='jsonrpc', auth='user')
def plant_kanban(self, mode='station', filters=None):
"""Returns the assembled board payload. See spec §9.2."""
env = request.env
user = env.user
Job = env['fp.job']
# Resolve paired station (first row of M2M for MVP)
paired = (user.paired_work_centre_ids[:1]
if 'paired_work_centre_ids' in user._fields
else env['fp.work.centre'])
paired_area = paired.area_kind if paired else None
# Base domain — every job with active recipe steps
domain = [
('state', 'in', ('confirmed', 'in_progress', 'done')),
]
filters = filters or {}
if filters.get('overdue'):
domain.append(('date_deadline', '<', fields_today_ts()))
domain.append(('state', '!=', 'done'))
if filters.get('on_hold'):
domain.append(('card_state', '=', 'on_hold'))
if filters.get('running'):
domain.append(('card_state', 'in', ('running', 'running_mine')))
if filters.get('blocked'):
domain.append(('card_state', 'in', (
'on_hold', 'predecessor_locked', 'awaiting_signoff',
'awaiting_qc', 'no_parts',
)))
if filters.get('mine'):
domain.append(('card_state', 'in', ('ready_mine', 'running_mine')))
if filters.get('fair'):
# Match either part-catalog or partner level requires_first_article
domain.append('|')
domain.append(('customer_spec_id.x_fc_requires_first_article', '=', True))
domain.append(('part_catalog_id.certificate_requirement', 'in', ('coc', 'coc_thickness')))
jobs = Job.search(domain, limit=500)
# Bucket by area_kind of the active step (or 'receiving' when no
# active step yet — matches the contract_review / no_parts states
# that live in Receiving column per spec §3 D5).
cards = {}
cards_by_area = {area: [] for area, _label in _COLUMN_LABELS}
for job in jobs:
area = _resolve_card_area(job)
cards_by_area.setdefault(area, []).append(job.id)
cards[str(job.id)] = _render_card(job, paired)
# Sort within each column by priority then due date
for area in cards_by_area:
cards_by_area[area].sort(key=lambda jid: _sort_key(cards[str(jid)]))
columns = [
{
'area_kind': area,
'label': label,
'is_mine': (area == paired_area),
'card_ids': cards_by_area.get(area, []),
}
for area, label in _COLUMN_LABELS
]
# KPI strip
kpis = {
'active_jobs': sum(1 for j in jobs if j.state != 'done'),
'at_my_station': sum(
1 for j in jobs
if j.card_state in ('ready_mine', 'running_mine')
),
'bakes_due_soon': sum(
1 for j in jobs if j.card_state == 'bake_due'
),
'on_hold': sum(
1 for j in jobs if j.card_state == 'on_hold'
),
'overdue': sum(
1 for j in jobs
if j.date_deadline and j.date_deadline.date() < date.today()
and j.state != 'done'
),
}
return {
'ok': True,
'mode': mode,
'paired_station': ({
'id': paired.id,
'name': paired.name,
'area_kind': paired_area,
} if paired else None),
'kpis': kpis,
'columns': columns,
'cards': cards,
}
# ===== helpers ==========================================================
def fields_today_ts():
"""Return today as the start-of-day datetime string for date_deadline
comparisons (date_deadline is a Datetime in the schema)."""
return datetime.combine(date.today(), datetime.min.time())
def _resolve_card_area(job):
"""Pick the column a card lives in.
Active-step area_kind wins. When there's no active step the card
lives in Receiving (covers contract_review + no_parts edge cases).
"""
if job.active_step_id and job.active_step_id.area_kind:
return job.active_step_id.area_kind
# Fallback: receiving column
return 'receiving'
def _render_card(job, paired):
"""Build the full card payload for one fp.job."""
step = job.active_step_id
try:
timeline = json.loads(job.mini_timeline_json or '[]')
except (TypeError, ValueError):
timeline = []
# Cross-module field probes
part = job.part_catalog_id if 'part_catalog_id' in job._fields else None
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else None
so = job.sale_order_id
po_number = ''
if so and 'x_fc_po_number' in so._fields:
po_number = so.x_fc_po_number or ''
# Tag chips (Rush / FAIR / VIP / AS9100 — only render when applicable)
tags = _compute_tags(job, part, spec)
# Step + tank labels
step_name = step.name if step else _('')
step_seq = step.sequence if step else 0
step_total = len(job.step_ids)
tank_label = ''
if step and step.work_centre_id:
tank_label = step.work_centre_id.name or step.work_centre_id.code or ''
# State chip
state_chip = _state_chip(job.card_state, step)
# Operator pill (only when step has an assigned user)
operator = None
if step and step.assigned_user_id:
u = step.assigned_user_id
operator = {
'id': u.id,
'name': u.name,
'initials': _initials_for(u),
}
# Icon row
icons = _icons(job, step)
# Due label
due_label = _due_label(job.date_deadline) if job.date_deadline else ''
is_overdue = (
bool(job.date_deadline)
and job.date_deadline.date() < date.today()
and job.state != 'done'
)
return {
'job_id': job.id,
'wo_name': job.display_wo_name or job.name or '',
'is_mine': job.card_state in ('ready_mine', 'running_mine'),
'card_state': job.card_state or '',
'due_date': (job.date_deadline.strftime('%Y-%m-%d')
if job.date_deadline else None),
'due_label': due_label,
'is_overdue': is_overdue,
'customer': job.partner_id.name if job.partner_id else '',
'part_number': (part.part_number if part else '') or '',
'part_revision': (part.revision if part and 'revision' in part._fields else '') or '',
'qty': job.qty or 0,
'po_number': po_number,
'recipe_name': job.recipe_id.name if job.recipe_id else '',
'spec_code': (spec.code if spec and 'code' in spec._fields else '') or '',
'tags': tags,
'step_name': step_name,
'step_seq': step_seq,
'step_total': step_total,
'tank_label': tank_label,
'state_chip': state_chip,
'operator': operator,
'icons': icons,
'mini_timeline': timeline,
}
def _compute_tags(job, part, spec):
tags = []
partner = job.partner_id
if partner:
if 'x_fc_rush' in partner._fields and partner.x_fc_rush:
tags.append('rush')
if 'x_fc_vip' in partner._fields and partner.x_fc_vip:
tags.append('vip')
if spec and 'x_fc_requires_first_article' in spec._fields \
and spec.x_fc_requires_first_article:
tags.append('fair')
if part and 'aerospace' in (part.name or '').lower():
tags.append('as9100')
return tags
def _state_chip(card_state, step):
"""Map card_state → {label, kind} for the chip on the card."""
if card_state == 'ready':
return {'label': _('● Ready'), 'kind': 'ready'}
if card_state == 'ready_mine':
return {'label': _('● Ready to start'), 'kind': 'ready'}
if card_state == 'running':
return {'label': _('%s') % _running_elapsed(step), 'kind': 'running'}
if card_state == 'running_mine':
return {'label': _('%s') % _running_elapsed(step), 'kind': 'running'}
if card_state == 'on_hold':
return {'label': _('🔴 Quality Hold'), 'kind': 'hold'}
if card_state == 'awaiting_signoff':
return {'label': _('🔏 Awaiting QA sign-off'), 'kind': 'signoff'}
if card_state == 'awaiting_qc':
return {'label': _('🔬 QC pending'), 'kind': 'qc'}
if card_state == 'bake_due':
return {'label': _('⏰ Bake window soon'), 'kind': 'due'}
if card_state == 'predecessor_locked':
return {'label': _('🔒 Waiting on predecessor'), 'kind': 'locked'}
if card_state == 'idle_warning':
op = (step.assigned_user_id.name.split()[0]
if step and step.assigned_user_id else _('operator'))
hrs = _idle_hours(step)
return {'label': _('⏸ Idle %dh · %s') % (hrs, op), 'kind': 'idle'}
if card_state == 'no_parts':
return {'label': _('📦 Parts in transit'), 'kind': 'no_parts'}
if card_state == 'contract_review':
return {'label': _('📋 QA-005 review'), 'kind': 'paperwork'}
if card_state == 'done':
return {'label': _('✓ Ready for pickup'), 'kind': 'done'}
return {'label': '', 'kind': ''}
def _running_elapsed(step):
"""Compact 'Running 8m' / 'Running 1h:45' label."""
if not step or not step.date_started:
return _('Running')
delta = datetime.now() - step.date_started
minutes = int(delta.total_seconds() / 60)
if minutes < 60:
return _('Running %dm') % minutes
hours = minutes // 60
rem = minutes % 60
return _('Running %dh:%02d') % (hours, rem)
def _idle_hours(step):
if not step or not step.last_activity_at:
return 0
delta = datetime.now() - step.last_activity_at
return int(delta.total_seconds() / 3600)
def _due_label(deadline):
"""'Due May 16 · 3d' style label."""
if not deadline:
return ''
d = deadline.date() if hasattr(deadline, 'date') else deadline
today = date.today()
days = (d - today).days
base = d.strftime('%b %d')
if days == 0:
return _('Due %s · today') % base
if days == 1:
return _('Due %s · tomorrow') % base
if days > 1:
return _('Due %s · %dd') % (base, days)
return _('Due %s · %dd late') % (base, -days)
def _icons(job, step):
"""Compact icon row at the card footer."""
icons = []
if step:
if step.requires_signoff and not step.signoff_user_id:
icons.append('🔏')
if step.recipe_node_id \
and step.recipe_node_id.default_kind == 'bake':
icons.append('🔥')
if job.card_state == 'bake_due':
icons.append('')
if job.card_state == 'no_parts':
icons.append('🚚')
if job.card_state == 'on_hold':
icons.append('💬')
if job.card_state == 'predecessor_locked':
icons.append('🔒')
if job.card_state == 'done':
icons.append('📜')
return icons
def _initials_for(user):
if not user or not user.name:
return ''
parts = user.name.strip().split()
if len(parts) == 1:
return parts[0][:2].upper()
return (parts[0][0] + parts[-1][0]).upper()
def _sort_key(card):
"""Sort within a column: overdue first, then by state priority,
then by due date (earlier = higher priority)."""
return (
0 if card['is_overdue'] else 1,
_SORT_PRIORITY.get(card['card_state'], 99),
card['due_date'] or '9999-12-31',
)

View File

@@ -20,6 +20,8 @@ from odoo.addons.fusion_plating.models.fp_tz import (
from odoo.exceptions import UserError
from odoo.http import request
from ._tablet_audit import env_for_tablet_tech
_logger = logging.getLogger(__name__)
@@ -255,11 +257,13 @@ class FpShopfloorController(http.Controller):
# Quick chemistry log from the tablet
# ----------------------------------------------------------------------
@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."""
env = env_for_tablet_tech(request.env, tablet_tech_id)
if not bath_id:
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():
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,
}))
log = request.env['fusion.plating.bath.log'].create({
log = env['fusion.plating.bath.log'].create({
'bath_id': bath.id,
'shift': shift or False,
'notes': notes or False,
@@ -291,10 +295,11 @@ class FpShopfloorController(http.Controller):
# Bake window controls
# ----------------------------------------------------------------------
@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
# 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():
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
if oven_id:
@@ -306,12 +311,13 @@ class FpShopfloorController(http.Controller):
return {
'ok': True,
'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')
def end_bake(self, bake_window_id):
bw = request.env['fusion.plating.bake.window'].browse(int(bake_window_id))
def end_bake(self, bake_window_id, tablet_tech_id=None):
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():
return {'ok': False, 'error': f'Bake window {bake_window_id} not found'}
try:
@@ -321,7 +327,7 @@ class FpShopfloorController(http.Controller):
return {
'ok': True,
'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,
}
@@ -340,8 +346,15 @@ class FpShopfloorController(http.Controller):
step = request.env['fp.job.step'].browse(int(sid))
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')
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).
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
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:
return {'ok': False, 'error': 'Step not found'}
if not _step_can_start(step):
@@ -369,7 +383,8 @@ class FpShopfloorController(http.Controller):
}
@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=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
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:
return {'ok': False, 'error': 'Step not found'}
if finish:
@@ -409,11 +425,12 @@ class FpShopfloorController(http.Controller):
# both with a single tap. Scrap auto-spawns a hold via fp.job.write
# (S17 hook, no extra wiring needed here).
@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).
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():
return {'ok': False, 'error': 'Job not found'}
try:
@@ -433,13 +450,15 @@ class FpShopfloorController(http.Controller):
}
@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
fp.job auto-spawns a fusion.plating.quality.hold for the delta;
the operator can edit the description on that hold later.
`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():
return {'ok': False, 'error': 'Job not found'}
try:
@@ -470,20 +489,22 @@ class FpShopfloorController(http.Controller):
position_label=None, reading_number=None,
equipment_model=None, calibration_std_ref=None,
microscope_image=None,
microscope_image_filename=None):
microscope_image_filename=None,
tablet_tech_id=None):
"""Record a single Fischerscope reading against a job.
`job_id` is the canonical kwarg; `production_id` is accepted as an
alias for older clients. The reading auto-links to an existing
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:
return {'ok': False, 'error': 'Certificates module not installed'}
target_id = job_id or production_id
if not target_id:
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():
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),
'p_percent': float(p_percent or 0.0),
'position_label': position_label or '',
'operator_id': request.env.user.id,
'operator_id': env.user.id,
}
if equipment_model:
@@ -516,7 +537,7 @@ class FpShopfloorController(http.Controller):
if calibration_std_ref:
vals['calibration_std_ref'] = calibration_std_ref
if microscope_image:
att = request.env['ir.attachment'].create({
att = env['ir.attachment'].create({
'name': microscope_image_filename or f'thickness_{reading_number}.jpg',
'datas': microscope_image,
'res_model': 'fp.thickness.reading',
@@ -525,7 +546,7 @@ class FpShopfloorController(http.Controller):
vals['microscope_image_id'] = att.id
# 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 'x_fc_job_id' in Cert._fields:
cert_field = 'x_fc_job_id'
@@ -557,7 +578,8 @@ class FpShopfloorController(http.Controller):
part_ref=None, qty_on_hold=0, qty_original=0,
hold_reason='other', description=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.
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:
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 = {
'part_ref': part_ref or '',
@@ -583,7 +606,7 @@ class FpShopfloorController(http.Controller):
if work_center_id:
vals['work_center_id'] = int(work_center_id)
if portal_job_id:
pj = request.env['fusion.plating.portal.job'].browse(
pj = env['fusion.plating.portal.job'].browse(
int(portal_job_id),
)
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`.
step_target_id = step_id or workorder_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 'x_fc_step_id' in Hold._fields:
vals['x_fc_step_id'] = step.id
@@ -605,7 +628,7 @@ class FpShopfloorController(http.Controller):
# set it through the step.
if (job_id and 'x_fc_job_id' in Hold._fields
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():
vals['x_fc_job_id'] = j.id
@@ -995,8 +1018,9 @@ class FpShopfloorController(http.Controller):
# Mark a first-piece gate result from the tablet
# ----------------------------------------------------------------------
@http.route('/fp/shopfloor/mark_gate', type='jsonrpc', auth='user')
def mark_gate(self, gate_id, result):
gate = request.env['fusion.plating.first.piece.gate'].browse(int(gate_id))
def mark_gate(self, gate_id, result, tablet_tech_id=None):
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():
return {'ok': False, 'error': 'Gate not found.'}
try:
@@ -1084,21 +1108,23 @@ class FpShopfloorController(http.Controller):
@http.route('/fp/shopfloor/plant_overview/move_card',
type='jsonrpc', auth='user')
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).
`source_model` is accepted for backward compatibility but ignored —
Plant Overview now only ever serves fp.job.step cards. A target
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))
if not step.exists():
return {'ok': False, 'error': f'Step {card_id} not found.'}
wc_id = int(target_workcenter_id) if target_workcenter_id else False
if wc_id:
wc = request.env['fp.work.centre'].browse(wc_id)
wc = env['fp.work.centre'].browse(wc_id)
if not wc.exists():
return {'ok': False,
'error': f'Work centre {target_workcenter_id} not found.'}
@@ -1108,7 +1134,7 @@ class FpShopfloorController(http.Controller):
_logger.info(
'Plant Overview: moved step %s (%s) → WC %s by uid %s',
step.id, step.name, wc_id or 'unassigned',
request.env.uid,
env.uid,
)
except Exception as exc:
_logger.exception('Plant Overview move_card failed')

View File

@@ -0,0 +1,285 @@
# -*- 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')
# ===== 2026-05-24 lock-screen redesign helpers =========================
# Three small module-level helpers powering the new lock-screen visuals.
# Imported by the tests in tests/test_tablet_lock_payload.py and consumed
# directly by the /fp/tablet/tiles route below.
_AVATAR_GRADIENTS = [
'linear-gradient(135deg, #ef4444, #dc2626)', # red
'linear-gradient(135deg, #f59e0b, #d97706)', # amber
'linear-gradient(135deg, #10b981, #059669)', # emerald
'linear-gradient(135deg, #3b82f6, #2563eb)', # blue
'linear-gradient(135deg, #8b5cf6, #7c3aed)', # violet
'linear-gradient(135deg, #ec4899, #db2777)', # pink
'linear-gradient(135deg, #14b8a6, #0d9488)', # teal
'linear-gradient(135deg, #f97316, #ea580c)', # orange
]
def _initials_from(name):
"""First letter of first + last word, capped at 2 chars uppercase.
Single-word names return their first two chars. Empty / falsy
returns '?' so the letter-mark renders something visible rather
than collapsing to a 0-height block.
"""
if not name:
return '?'
words = name.strip().split()
if not words:
return '?'
if len(words) == 1:
return words[0][:2].upper()
return (words[0][0] + words[-1][0]).upper()
def _avatar_gradient_for(user_id):
"""Deterministic gradient per user id.
Modulo the gradient list — same operator gets the same color
across sessions so they learn to recognize their own tile. 8
colors are enough for a small shop (10-15 ops) with at most 2
color collisions on average.
"""
return _AVATAR_GRADIENTS[user_id % len(_AVATAR_GRADIENTS)]
def _lock_company_payload(env):
"""Returns the company info block for the lock screen.
Reuses res.company.report_header as the tagline (the same field
that drives invoice letterhead text) with a sensible fallback
when empty. No new model field required.
"""
co = env.company
return {
'id': co.id,
'name': co.name or '',
'tagline': co.report_header or 'Shop Floor Terminal',
'logo_url': f'/web/image/res.company/{co.id}/logo',
'has_logo': bool(co.logo),
'initials': _initials_from(co.name),
}
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,
'initials': _initials_from(u.name),
'avatar_url': f'/web/image/res.users/{u.id}/avatar_128',
# has_photo lets the frontend skip the avatar img when
# the user has no uploaded photo (avoids the 1×1 default
# image flash). sudo-read of image_128 — the field is
# restricted to the user themselves otherwise.
'has_photo': bool(u_sudo.image_128),
'avatar_gradient': _avatar_gradient_for(u.id),
'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,
'company': _lock_company_payload(request.env),
'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.http import request
from ._tablet_audit import env_for_tablet_tech
_logger = logging.getLogger(__name__)
@@ -190,8 +192,8 @@ class FpWorkspaceController(http.Controller):
@http.route('/fp/workspace/hold', type='jsonrpc', auth='user')
def hold(self, job_id, reason='other', qty_on_hold=1, description='',
part_ref='', step_id=None, mark_for_scrap=False,
photo_data=None, photo_filename=None):
env = request.env
photo_data=None, photo_filename=None, tablet_tech_id=None):
env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id))
if not job.exists():
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
# ======================================================================
@http.route('/fp/workspace/sign_off', type='jsonrpc', auth='user')
def sign_off(self, step_id, signature_data_uri):
env = request.env
def sign_off(self, step_id, signature_data_uri, tablet_tech_id=None):
env = env_for_tablet_tech(request.env, tablet_tech_id)
sig = (signature_data_uri or '').strip()
if not sig:
_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
# ======================================================================
@http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
def advance_milestone(self, job_id):
env = request.env
def advance_milestone(self, job_id, tablet_tech_id=None):
env = env_for_tablet_tech(request.env, tablet_tech_id)
job = env['fp.job'].browse(int(job_id))
if not job.exists():
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,5 @@ from . import fp_bake_window
from . import fp_first_piece_gate
from . import fp_operator_queue
from . import fp_tank
from . import res_users
from . import res_config_settings

View File

@@ -73,6 +73,26 @@ class FpShopfloorStation(models.Model):
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 = [
(
'fp_shopfloor_station_code_uniq',

View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
"""Feature flags for fusion_plating_shopfloor.
Currently:
- x_fc_shopfloor_layout — switches the Shop Floor client action
between the legacy per-step kanban and the v2 plant-view kanban.
Backed by ir.config_parameter so the landing-action resolver can
read it cheaply on every action open without a recordset fetch.
"""
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
x_fc_shopfloor_layout = fields.Selection(
[
('legacy', 'Legacy (per-step kanban)'),
('v2', 'Plant View (one card per job, 9 columns)'),
],
string='Shop Floor Layout',
default='v2',
config_parameter='fusion_plating_shopfloor.layout',
help='Switches the Shop Floor client action between the legacy '
'per-step kanban and the v2 plant view. Defaults to legacy '
'during the parallel rollout; flip to v2 once validated. '
'The landing-action resolver reads this from '
'ir.config_parameter (key: fusion_plating_shopfloor.layout).',
)

View File

@@ -0,0 +1,141 @@
# -*- 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.',
)
paired_work_centre_ids = fields.Many2many(
'fp.work.centre',
'res_users_fp_work_centre_paired_rel',
'user_id',
'work_centre_id',
string='Paired Work Centres',
help='Stations the operator is currently paired to via the tablet. '
'MVP holds exactly one row on day 1 (the dropdown-selected '
'station). The Phase 2 multi-station picker can populate '
'multiple. Drives the "is this card mine" check on the '
'plant-view kanban (cards whose active_step.work_centre is '
'in this M2M get the yellow ⭐ treatment).',
)
@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

@@ -0,0 +1,13 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class FpColumnHeader extends Component {
static template = "fusion_plating_shopfloor.ColumnHeader";
static props = {
column: { type: Object }, // {area_kind, label, is_mine, card_ids}
};
get cardCount() {
return this.props.column.card_ids.length;
}
}

View File

@@ -0,0 +1,15 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class FpFilterChip extends Component {
static template = "fusion_plating_shopfloor.FilterChip";
static props = {
label: { type: String },
active: { type: Boolean },
onToggle: { type: Function },
};
onClick() {
this.props.onToggle();
}
}

View File

@@ -14,7 +14,7 @@
import { Component, useState } from "@odoo/owl";
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";
// 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;
try {
const res = await rpc("/fp/workspace/hold", {
const res = await fpRpc("/fp/workspace/hold", {
job_id: this.props.jobId,
step_id: this.props.stepId || null,
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,24 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class FpKpiTile extends Component {
static template = "fusion_plating_shopfloor.KpiTile";
static props = {
value: { type: [Number, String] },
label: { type: String },
kind: { type: String, optional: true }, // urgent | warn | good | ''
active: { type: Boolean, optional: true },
onClick: { type: Function, optional: true },
};
get tileClass() {
const classes = ["o_fp_kpi_tile"];
if (this.props.kind) classes.push(this.props.kind);
if (this.props.active) classes.push("active");
return classes.join(" ");
}
onClick() {
if (this.props.onClick) this.props.onClick();
}
}

View File

@@ -0,0 +1,56 @@
/** @odoo-module **/
// =====================================================================
// FpMiniTimeline — 9-step horizontal bar showing recipe journey.
// Consumes mini_timeline JSON from /fp/landing/plant_kanban.
// Per project rule 20: no String()/Number() in templates; classFor()
// and labelFor() do all the formatting in JS.
// =====================================================================
import { Component } from "@odoo/owl";
const AREA_LABELS = {
receiving: "Rec",
masking: "Mask",
blasting: "Blast",
racking: "Rack",
plating: "Plat",
baking: "Bake",
de_racking: "D-R",
inspection: "Insp",
shipping: "Ship",
};
// Map card_state variant → CSS modifier class on the current step
const VARIANT_TO_CLASS = {
on_hold: "hold",
predecessor_locked: "locked",
bake_due: "bake",
awaiting_signoff: "signoff",
idle_warning: "idle",
awaiting_qc: "qc",
no_parts: "noparts",
done: "done",
contract_review: "paperwork",
// ready / running / *_mine → default yellow (no extra class)
};
export class FpMiniTimeline extends Component {
static template = "fusion_plating_shopfloor.MiniTimeline";
static props = {
timeline: { type: Array },
};
labelFor(area) {
return AREA_LABELS[area] || area;
}
classFor(entry) {
if (entry.state === "done") return "tl-step done";
if (entry.state === "current") {
const variant = (entry.variant || "").replace("_mine", "");
const cls = VARIANT_TO_CLASS[variant] || "";
return cls ? `tl-step current ${cls}` : "tl-step current";
}
return "tl-step";
}
}

View File

@@ -0,0 +1,77 @@
/** @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;
// Defensive: coerce to string in JS rather than the template
// because OWL templates don't expose `String` as a callable
// (Critical Rule 20 in CLAUDE.md). Callers pass strings already
// via the string array in pin_pad.xml; this is a belt-and-braces
// guard for any future caller passing a numeric digit.
this.state.pin = this.state.pin + String(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

@@ -0,0 +1,70 @@
/** @odoo-module **/
// =====================================================================
// FpPlantCard — Variant C card for the plant-view kanban.
// Renders the full job summary + 9-step mini-timeline. Tap opens the
// Job Workspace.
//
// All formatting / class composition happens in JS — per project rule
// 20, OWL templates can't call String(), Number(), etc. as functions.
// =====================================================================
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { FpMiniTimeline } from "./mini_timeline";
const TAG_LABELS = {
rush: "RUSH",
fair: "FAIR",
vip: "VIP",
as9100: "AS9100",
};
export class FpPlantCard extends Component {
static template = "fusion_plating_shopfloor.PlantCard";
static components = { FpMiniTimeline };
static props = {
card: { type: Object },
};
setup() {
this.action = useService("action");
}
get cardClass() {
const c = this.props.card;
const classes = ["o_fp_plant_card", "state-" + (c.card_state || "ready")];
if (c.is_mine) classes.push("mine");
if (c.is_overdue) classes.push("overdue");
return classes.join(" ");
}
get progressStyle() {
const c = this.props.card;
if (!c.step_total) return "width: 0%";
const pct = Math.round((c.step_seq / c.step_total) * 100);
return "width: " + pct + "%";
}
tagChipClass(tag) {
return "chip tag-" + tag;
}
tagLabel(tag) {
return TAG_LABELS[tag] || tag.toUpperCase();
}
stateChipClass(kind) {
return "chip kind-" + (kind || "ready");
}
onCardClick() {
const c = this.props.card;
if (!c.job_id) return;
this.action.doAction({
type: "ir.actions.client",
tag: "fp_job_workspace",
target: "current",
params: { job_id: c.job_id },
});
}
}

View File

@@ -17,24 +17,27 @@
// Auto-refresh: every 15s.
// =============================================================================
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { Component, markup, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { fpRpc } from "./services/fp_rpc";
import { useService } from "@web/core/utils/hooks";
import { WorkflowChip } from "./components/workflow_chip";
import { GateViz } from "./components/gate_viz";
import { FpSignaturePad } from "./components/signature_pad";
import { FpHoldComposer } from "./components/hold_composer";
import { FpTabletLock } from "./tablet_lock";
export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"];
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer };
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer, FpTabletLock };
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.dialog = useService("dialog");
this.techStore = useService("fp_shopfloor_tech_store");
this.state = useState({
data: null,
@@ -61,6 +64,19 @@ export class FpJobWorkspace extends Component {
try {
const res = await rpc("/fp/workspace/load", { job_id: this.state.jobId });
if (res && res.ok) {
// Chatter bodies arrive as plain HTML strings off the RPC.
// The template renders them via `t-out="msg.body"`, which
// HTML-ESCAPES plain JS strings unless they're tagged with
// markup() from @odoo/owl. Without this wrap the operator
// sees literal `<p>` and `<b>` tags instead of formatted
// text (caught 2026-05-23 — Notes panel showing raw HTML).
if (res.chatter && res.chatter.length) {
for (const m of res.chatter) {
if (m && typeof m.body === "string") {
m.body = markup(m.body);
}
}
}
this.state.data = res;
} else if (res && res.error) {
this.notification.add(res.error, { type: "danger" });
@@ -72,8 +88,24 @@ export class FpJobWorkspace extends Component {
// ---- Navigation --------------------------------------------------------
onBack() {
// Close workspace; return to whatever spawned the action
this.action.doAction({ type: "ir.actions.act_window_close" });
// The workspace is opened with target: "current" which REPLACES
// the current action and wipes the backstack. Navigate explicitly
// to the plant-view kanban — the 2026-05-23 redesigned Shop Floor
// surface — instead of the deprecated fp_shopfloor_landing OWL
// component. (Bug caught 2026-05-24: Back used to dump the user
// into the old per-step kanban even when they entered via the
// new plant view.) See CLAUDE.md Critical Rule 21 + the
// "Legacy-action redirect" section.
this.action.doAction({
type: "ir.actions.client",
tag: "fp_plant_kanban",
target: "current",
});
}
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
this.techStore.lock();
}
onJumpToBlocker({ model, id }) {
@@ -109,7 +141,7 @@ export class FpJobWorkspace extends Component {
// ---- Step actions ------------------------------------------------------
async onStartStep(stepId) {
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) {
this.notification.add("Step started.", { type: "success" });
await this.refresh();
@@ -128,7 +160,7 @@ export class FpJobWorkspace extends Component {
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
onSubmit: async (dataUri) => {
try {
const res = await rpc("/fp/workspace/sign_off", {
const res = await fpRpc("/fp/workspace/sign_off", {
step_id: step.id,
signature_data_uri: dataUri,
});
@@ -147,7 +179,7 @@ export class FpJobWorkspace extends Component {
}
// Plain finish — no signature required
try {
const res = await rpc("/fp/shopfloor/stop_wo", {
const res = await fpRpc("/fp/shopfloor/stop_wo", {
workorder_id: step.id, finish: true,
});
if (res && res.ok) {
@@ -194,7 +226,7 @@ export class FpJobWorkspace extends Component {
async onAdvanceMilestone() {
try {
const res = await rpc("/fp/workspace/advance_milestone", {
const res = await fpRpc("/fp/workspace/advance_milestone", {
job_id: this.state.jobId,
});
if (res && res.ok) {

View File

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

View File

@@ -0,0 +1,162 @@
/** @odoo-module **/
// =====================================================================
// FpPlantKanban — top-level OWL action for the 2026-05-23 redesigned
// Shop Floor. Mounts via the fp_plant_kanban client action; landing
// resolver dispatches between this and the legacy fp_shopfloor_landing
// based on the x_fc_shopfloor_layout config parameter.
//
// Architecture:
// - Polls /fp/landing/plant_kanban every 10s
// - Owns mode + filter + search state (filters persist in localStorage)
// - 9 fixed columns; one card per fp.job
// - Per project rule 20, no String()/Number()/etc. in templates —
// all coercion happens here in JS-land.
// =====================================================================
import { Component, useState, onMounted, onWillUnmount } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
import { useService } from "@web/core/utils/hooks";
import { FpTabletLock } from "./tablet_lock";
import { FpPlantCard } from "./components/plant_card";
import { FpColumnHeader } from "./components/column_header";
import { FpKpiTile } from "./components/kpi_tile";
import { FpFilterChip } from "./components/filter_chip";
const LOCAL_FILTER_KEY = "fp_plant_kanban_filters";
export class FpPlantKanban extends Component {
static template = "fusion_plating_shopfloor.PlantKanban";
static props = ["*"];
static components = {
FpTabletLock,
FpPlantCard,
FpColumnHeader,
FpKpiTile,
FpFilterChip,
};
setup() {
this.notification = useService("notification");
this.action = useService("action");
// techStore may not be registered until first PIN unlock; guard with try.
try {
this.techStore = useService("fp_shopfloor_tech_store");
} catch {
this.techStore = null;
}
this.state = useState({
mode: "station",
filters: this._loadFilters(),
data: null,
loading: true,
search: "",
});
onMounted(async () => {
await this.refresh();
this._poll = setInterval(() => this.refresh(), 10000);
});
onWillUnmount(() => {
if (this._poll) clearInterval(this._poll);
});
}
_loadFilters() {
try {
const raw = localStorage.getItem(LOCAL_FILTER_KEY);
return raw ? JSON.parse(raw) : { all: true };
} catch {
return { all: true };
}
}
_saveFilters() {
try {
localStorage.setItem(LOCAL_FILTER_KEY, JSON.stringify(this.state.filters));
} catch { /* localStorage may be disabled */ }
}
async refresh() {
try {
const res = await rpc("/fp/landing/plant_kanban", {
mode: this.state.mode,
filters: this.state.filters,
});
if (res && res.ok) {
this.state.data = res;
} else if (res && res.error) {
this.notification.add(res.error, { type: "danger" });
}
} catch (err) {
this.notification.add(err.message || String(err), { type: "danger" });
} finally {
this.state.loading = false;
}
}
toggleFilter(name) {
if (name === "all") {
this.state.filters = { all: true };
} else {
delete this.state.filters.all;
this.state.filters[name] = !this.state.filters[name];
const anyActive = Object.keys(this.state.filters)
.some(k => this.state.filters[k]);
if (!anyActive) {
this.state.filters = { all: true };
}
}
this._saveFilters();
this.refresh();
}
setMode(mode) {
this.state.mode = mode;
this.refresh();
}
modeClass(mode) {
return this.state.mode === mode ? "mode-btn active" : "mode-btn";
}
onSearchInput(ev) {
this.state.search = (ev.target.value || "").toLowerCase();
}
filteredCardIds(column) {
// Client-side search filter on top of the server-side filtered set.
if (!this.state.search) return column.card_ids;
const term = this.state.search;
return column.card_ids.filter(id => {
const c = this.state.data.cards[id];
if (!c) return false;
return (
(c.wo_name || "").toLowerCase().includes(term)
|| (c.customer || "").toLowerCase().includes(term)
|| (c.part_number || "").toLowerCase().includes(term)
|| (c.po_number || "").toLowerCase().includes(term)
);
});
}
onHandOff() {
if (this.techStore && this.techStore.lock) {
this.techStore.lock();
}
}
onScanQr() {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_qr_scanner",
target: "new",
}).catch(() => {
// QR scanner action may not be registered in all installs
this.notification.add("QR scanner not available", { type: "warning" });
});
}
}
registry.category("actions").add("fp_plant_kanban", FpPlantKanban);

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);

Some files were not shown because too many files have changed in this diff Show More