Compare commits

..

57 Commits

Author SHA1 Message Date
gsinghpal
27e12dd544 chore(shopfloor): register fp_rpc.js asset + bump to 19.0.30.2.0 (P6.3.6)
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
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 failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
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 failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
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
gsinghpal
1122f84007 docs(fusion_plating): document tablet redesign architecture in CLAUDE.md (P5.1)
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Replaces the stale "Plant Overview Dashboard" section with a current
"Shop Floor Architecture" section covering the Phase 1-4 deliverables:

  - 3 OWL client actions (Landing / JobWorkspace / Manager Dashboard)
  - 5 shared OWL services
  - Backend endpoints (workspace / landing / manager)
  - Auto-pause cron config knob (ir.config_parameter name)
  - Key new model fields with their purpose
  - Operator ACL lift summary
  - Deprecated-but-still-live legacy surfaces (Phase 5 cleanup pending)
  - Old patterns to avoid

Links to the spec + plan docs as the authoritative reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:23:23 -04:00
gsinghpal
2cdb2e3d0b chore(fusion_plating): bump versions for Phase 4 — Manager Desk refactor
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
fusion_plating            19.0.20.8.0  (bottleneck_score on fp.work.centre)
  fusion_plating_shopfloor  19.0.29.0.0  (3 new endpoints + 4-tab manager dashboard
                                          + 2 new KPI tiles)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:22:18 -04:00
gsinghpal
f00dda2abd feat(fusion_plating_shopfloor): Manager Desk 4-tab refactor (P4.5-P4.10)
Plan tasks P4.5 through P4.10 batched. Existing 3-column Plant Board
becomes one tab of four; adds Workflow Funnel (default), Approval
Inbox, and At-Risk siblings. Adds 2 new KPI tiles for Pending Cert +
At-Risk.

  WORKFLOW FUNNEL (default tab)
    Calls /fp/manager/funnel. Renders one row per fp.job.workflow.state
    with stage chip + count + top 5 WO cards. Tap a card → JobWorkspace.
    Bar chart bar behind each row scales with stage count.

  APPROVAL INBOX
    Calls /fp/manager/approval_inbox. Three strips: Holds to Release,
    Certs to Issue, Scrap to Review. Per-row open + Open Workspace
    buttons. Tab badge shows total pending count.

  PLANT BOARD (existing — relocated as one tab)
    The 3-column Needs Worker / In Progress / Team layout that already
    exists, wrapped in t-if="activeTab === 'plant_board'". No behaviour
    change — still uses /fp/manager/overview with 8s refresh.

  AT-RISK
    Calls /fp/manager/at_risk. 3 sub-panels: Trending Late (sorted by
    late_risk_ratio desc), Hold Reasons (read_group), Bottleneck heatmap
    (bottleneck_score from P4.1 with red/yellow/green bars).

  KPI STRIP (new conditional tiles)
    Pending Cert  — count from inbox.certs_to_issue, click to open Inbox tab.
    At-Risk       — count from at_risk.trending_late, click to open At-Risk.

Auto-refresh: 8s for /fp/manager/overview (existing); the active tab's
data also refreshes every 8s via refreshActiveTab().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:21:53 -04:00
gsinghpal
3b7b2477cf feat(fusion_plating_shopfloor): 3 new manager endpoints — funnel, inbox, at_risk (P4.2-P4.4)
Plan tasks P4.2 + P4.3 + P4.4 batched. Adds the backend data layer
for the Manager Desk's 3 new sibling tabs (Phase 4 tablet redesign).

  POST /fp/manager/funnel
      Workflow funnel: jobs grouped by fp.job.workflow.state. Returns
      stages[] with count + top 5 WO cards per stage. Drives the
      default tab on the refactored dashboard.

  POST /fp/manager/approval_inbox
      Four buckets: holds_to_release (state=on_hold|under_review),
      certs_to_issue (all_steps_terminal + draft cert), scrap_to_review
      (last 24h mark_for_scrap holds), override_requests (deferred —
      empty placeholder).

  POST /fp/manager/at_risk
      Three panels: trending_late (top 20 by late_risk_ratio desc),
      hold_reasons (read_group on hold_reason), bottleneck (top 10
      work centres by bottleneck_score from P4.1).

All endpoints respect optional facility_id scope. Cheap implementations
— no caching yet; performance can be added if entech load demands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:17:53 -04:00
gsinghpal
e762ee4b68 feat(fusion_plating): fp.work.centre bottleneck_score + avg_wait_minutes (P4.1)
Computes for the Manager At-Risk heatmap (Phase 4 tablet redesign).
Non-stored — recomputed on /fp/manager/at_risk read; that endpoint
caches its full payload for 60s so the cost is bounded.

  bottleneck_score = active_step_count * avg_wait_minutes
  avg_wait_minutes = rolling-7-day avg of (date_started - create_date)

Work centres with high score show red in the heatmap — combination
of queue length AND average wait time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:16:37 -04:00
gsinghpal
5d086c7f27 chore(fusion_plating_shopfloor): bump 19.0.28.0.0 for Phase 3 — Landing refactor
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Phase 3 ships:
- /fp/landing/kanban endpoint (P3.1)
- ShopfloorLanding OWL client action with Station/All-Plant toggle,
  KPI strip, search, kanban with DnD, QR scan, card-tap to Workspace (P3.2-P3.4)
- Menu rewire: 'Tablet Station' + 'Plant Overview' → single 'Workstation'
  entry; legacy actions retargeted to fp_shopfloor_landing for bookmark
  back-compat (P3.5)
- DEPRECATED markers on legacy /fp/shopfloor/tablet_overview, plant_overview,
  queue endpoints (P3.6 — pragmatic deviation: bodies kept intact for the
  still-registered legacy OWL components)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:12:06 -04:00
gsinghpal
3eba80bb31 docs(fusion_plating_shopfloor): deprecation markers on legacy endpoints (P3.6)
Plan task P3.6 — pragmatic deviation. The plan called for stubs that
internally route to /fp/landing/kanban + reshape; in practice the
legacy fp_shopfloor_tablet OWL component (still registered, just
unhooked from the menu) consumes a much richer payload (my_queue,
active_wo, baths, bake_windows, gates, holds, pending_qcs, stations)
than /fp/landing/kanban returns. Gutting tablet_overview to a stub
would break that legacy component.

Instead: add explicit DEPRECATED markers + INFO log lines on the three
endpoints (tablet_overview, plant_overview, queue). Bodies stay intact
so the legacy components keep working until Phase 5 cleanup retires
both endpoints AND the legacy OWL components together.

Note: /fp/shopfloor/plant_overview/move_card is NOT deprecated — the
new Landing component still uses it for drag-and-drop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:11:49 -04:00
gsinghpal
2a0d1862df feat(fusion_plating_shopfloor): rewire menus to Shop Floor Landing (P3.5)
Plan task P3.5. Single 'Workstation' menu item replaces both the
legacy 'Tablet Station' and 'Plant Overview' entries. The new
fp_shopfloor_landing component has a Station/All-Plant toggle so
one menu covers both old surfaces.

Old action records redirected for back-compat (so existing bookmarks
+ smart-button references keep working):

  action_fp_shopfloor_tablet  tag → fp_shopfloor_landing
  action_fp_plant_overview    tag → fp_shopfloor_landing
                              params → {'mode': 'all_plant'}

The legacy OWL components (fp_shopfloor_tablet, fp_plant_overview)
remain registered — no code removed, just no menu points at them.
Phase 5 cleanup will remove the OWL components after a release of
soak time on entech.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:10:36 -04:00
gsinghpal
7f70785b79 feat(fusion_plating_shopfloor): ShopfloorLanding client action (P3.2-P3.4)
Plan tasks P3.2 + P3.3 + P3.4 batched. Full ShopfloorLanding OWL
client action — replaces fp_shopfloor_tablet AND folds in
fp_plant_overview.

  Header strip          Title, station chip, station picker dropdown,
                        Station/All-Plant mode toggle, QR scan controls,
                        last-refresh indicator.
  KPI strip             4 tech-relevant tiles: Ready · Running ·
                        Bakes Due (warning) · Holds (red when > 0).
  Search                Live debounced (200ms) across WO# + customer +
                        part. ESC clears.
  Kanban board          Columns = work centres from /fp/landing/kanban.
                        Cards = FpKanbanCard (Phase 1 — P1.7).
                        Drag-and-drop reuses existing
                        /fp/shopfloor/plant_overview/move_card.
  Card tap              doAction → fp_job_workspace with
                        {job_id, focus_step_id}.
  QR scan               FP-STATION pairs, FP-JOB / FP-STEP jump to the
                        Workspace.

Mode + station_id persist in localStorage (LS_STATION_ID, LS_MODE).
Auto-refresh every 15s; suppressed during a drop and for 5s after.

Registers client action `fp_shopfloor_landing`. Menu rewire + endpoint
stubs land in P3.5 + P3.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:09:09 -04:00
gsinghpal
9dcd00d9b2 feat(fusion_plating_shopfloor): /fp/landing/kanban endpoint
Plan task P3.1. New JSON-RPC endpoint for the Shop Floor Landing
client action (Phase 3). Two modes:

  station    — paired WC + Unassigned + next 1-2 WCs in recipe flow
  all_plant  — every active WC, recipe-flow order (replaces the data
               path for the standalone fp_plant_overview action)

Returns {columns: [{work_center_id, work_center_name, cards}], kpis:
{ready, running, bakes_due, holds}, stations: [...], facility_name,
server_time}. Card payload matches the KanbanCard OWL component
(P1.7) — same shape, no client-side adapter needed.

Light implementation — no urgency scoring or batch prefetch yet.
Both can be ported from plant_overview if performance demands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:06:40 -04:00
gsinghpal
5a28c7e90f chore(fusion_plating): bump versions for Phase 2 — cron + ACL + supporting computes
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
fusion_plating              19.0.20.7.0  (long_running on process node)
  fusion_plating_jobs         19.0.10.20.0 (late_risk_ratio, active_step_id, autopause cron)
  fusion_plating_shopfloor    19.0.27.1.0  (no code change; data-version bump for Phase 2)
  fusion_plating_certificates 19.0.7.9.0   (ACL lift — bumped in P2.6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:04:26 -04:00
gsinghpal
3c2efae951 feat(fusion_plating): lift operator ACL for cert write + thickness create + override read
Plan task P2.6. Per the spec's "techs wear multiple hats" rule, lift
gates so technicians can do their work without permission walls:

  fp.certificate         operator: read → read+write
                         (flip draft→issued from tablet)
  fp.thickness.reading   operator: read → read+write+create
                         (capture Fischerscope readings from tablet)
  fp.job.node.override   operator: NEW read-only
                         (see opt-out badges on steps)

Supervisor-only operations (step Skip, hold Release, override
Re-include) remain enforced in workspace_controller, not ACL — so the
ACL stays minimal and the controller centralizes the gate logic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:04:05 -04:00
gsinghpal
c06d3d442a feat(fusion_plating_jobs): auto-pause cron for stale in-progress steps
Plan tasks P2.4 + P2.5 batched.

Adds _cron_autopause_stale_steps method on fp.job.step + 30-min cron
registration. Flips in_progress steps idle > threshold to paused with
a chatter audit ("Auto-paused after Nh idle. Resume from the tablet
when work continues.").

Threshold from ir.config_parameter:
    fp.shopfloor.autopause_threshold_hours  (default 8.0)

Recipe nodes opt out via fusion.plating.process.node.long_running
(added in P2.1) — useful for 24h bakes and multi-shift soaks.

Fixes the 411-hour ghost timer that motivated the redesign. Doesn't
replace the existing nudge crons — those still notify the supervisor;
this one actually pauses the timer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:03:20 -04:00
gsinghpal
c76eb94724 feat(fusion_plating_jobs): late_risk_ratio + active_step_id computes on fp.job
Plan tasks P2.2 + P2.3 batched (both small additive computes on fp.job;
local tests not run between them — entech verifies).

  late_risk_ratio  — stored Float, remaining_planned / minutes_to_deadline.
                     Drives the Manager At-Risk view (Phase 4).
                     Recomputes on step state, duration, deadline changes.

  active_step_id   — non-stored Many2one. Currently in_progress step
                     (lowest sequence if multiple — defensive).
                     Drives JobWorkspace landing focus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:01:58 -04:00
gsinghpal
06dc6a62b9 feat(fusion_plating): long_running flag on process node (auto-pause opt-out)
Plan task P2.1. Boolean on fusion.plating.process.node that exempts
steps generated from this node from the shop-floor auto-pause cron
(added in P2.4/P2.5). Use for 24h bakes, multi-shift soaks, and
similar long-but-legitimate operations.

Toggle visible on the process-node form for operation/step types,
grouped with parallel_start in the Behaviour section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:01:13 -04:00
gsinghpal
5463efcfc2 chore(fusion_plating_jobs): bump 19.0.10.19.0 for Phase 1 — Workspace foundation
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
2026-05-22 21:53:43 -04:00
gsinghpal
3fdbeed813 feat(fusion_plating_jobs): Open Workspace smart button on fp.job form
Plan task P1.16. Header button on the fp.job form that opens the
JobWorkspace OWL client action focused on the current WO. Primary
entry point for techs before the Landing kanban (Phase 3) ships;
remains as a back-office shortcut after.

Hidden when state == 'draft' (no steps to work yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:53:19 -04:00
gsinghpal
a18ef6c405 feat(fusion_plating_shopfloor): JobWorkspace client action (header/steps/side/rail)
Plan tasks P1.12 through P1.15 batched. Full-screen OWL component
registered as fp_job_workspace. Layout:

  STICKY HEADER       WO #, customer, part, qty/done, deadline,
                      WorkflowChip, holds badge
  STICKY WORKFLOW BAR 9-stage dots (passed/current/pending) +
                      Next-action button driving advance_milestone
  STEP LIST           All steps with state icons; active step
                      auto-expanded with recipe chips (thickness/
                      dwell/bake/sign-off) + instructions + Start/
                      Finish buttons; blocked steps show GateViz;
                      override-excluded steps faded
  SIDE PANEL          Customer spec PDF link, attachments list,
                      chatter notes
  STICKY ACTION RAIL  Create Hold (HoldComposer modal), Add Note
                      (chatter via message_post), Issue Cert (when
                      draft cert exists), Next Milestone

Auto-refresh every 15s. Sign-off steps route Finish through
SignaturePad → /fp/workspace/sign_off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:52:26 -04:00
gsinghpal
eae6a471e8 feat(fusion_plating_shopfloor): workspace_controller — 4 endpoints + tests
Plan tasks P1.8 through P1.11 batched into one commit (local tests not
run between them; entech is the verification env).

  POST /fp/workspace/load               — full payload for one fp.job
  POST /fp/workspace/hold               — quality.hold create with photo
  POST /fp/workspace/sign_off           — signature + finish step atomic
  POST /fp/workspace/advance_milestone  — fire next_milestone_action

Each endpoint logs INFO on success, EXCEPTION on failure, returns a
consistent {'ok': bool, 'error': str?} envelope. Hold endpoint isolates
photo-attach failures so they don't roll back the hold record.

Tests cover: payload shape, bad job_id, hold create with/without photo,
empty qty rejection, empty-signature rejection, sign-off finish, and
the no-milestone-action error path.

Verify on entech: -u fusion_plating_shopfloor --test-tags fp_shopfloor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:50:09 -04:00
gsinghpal
a61bd05a5c feat(fusion_plating_shopfloor): KanbanCard shared OWL service
Plan task P1.7. Final shared service — standard WO card used on Landing
kanban, Manager Plant Board, and Workflow Funnel. Embeds WorkflowChip,
shows progress bar, priority dot, blocker badge from step.blocker_kind.

Density prop ('compact' vs 'normal') swaps padding for funnel use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:48:13 -04:00
gsinghpal
8109b3ec76 feat(fusion_plating_shopfloor): HoldComposer shared OWL service
Plan task P1.6. Modal hold-creation form: reason picker, qty split,
optional photo (camera input on mobile), description, mark-for-scrap
toggle. Calls /fp/workspace/hold (added in P1.9). Reason list kept
client-side, keep in sync with fusion.plating.quality.hold.hold_reason.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:47:29 -04:00
gsinghpal
9d78bc4317 feat(fusion_plating_shopfloor): SignaturePad shared OWL service
Plan task P1.5. Modal canvas signature capture using HTML pointer events
+ Odoo Dialog service. Returns image/png dataURI via onSubmit callback;
caller decides what to do with it (e.g. /fp/workspace/sign_off attaches
to fp.job.step).

Canvas stays light even in dark mode for signature legibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:46:46 -04:00
gsinghpal
5c3c979f77 feat(fusion_plating_shopfloor): GateViz shared OWL service
Plan task P1.4. "Can't start yet — Waiting on Step N: X" block reused
across JobWorkspace step rows and Manager Plant Board cards. Icon set
maps to blocker_kind (predecessor/contract_review/parts_not_received/
racking_required/manager_input). Optional Jump button propagates to
parent via onJump callback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:46:07 -04:00
gsinghpal
b52fe01d07 feat(fusion_plating_shopfloor): WorkflowChip shared OWL service + dark-mode SCSS
Plan task P1.3. Bootstraps the tests/ dir and adds the first of 5
shared OWL services. Pill renders fp.job.workflow.state with color
mapping + optional next-action hint.

Per CLAUDE.md "Dark Mode" rule: registered once in web.assets_backend;
Odoo 19 auto-compiles into both bright and dark bundles via the
\$o-webclient-color-scheme SCSS branch.

Version bumped to 19.0.27.0.0 (Phase 1 — Workspace foundation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:45:33 -04:00
gsinghpal
81da9bf71c feat(fusion_plating_jobs): fp.job.step blocker_kind/reason/jump_target computes
Plan task P1.2. Reuses _fp_should_block_predecessors so the new compute
stays in sync with the existing can_start logic. Drives the OWL GateViz
component on the tablet — "Can't start yet — Waiting on Step N: X".

Future work: extend with explicit branches for contract_review /
parts_not_received / racking_required / manager_input as those gate
models mature.

Tests not run locally (no fusion_plating mount in odoo-modsdev).
Verify on entech: -u fusion_plating_jobs --test-tags fp_jobs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:44:15 -04:00
gsinghpal
1d04ac8cb7 feat(fusion_plating_jobs): fp.job.display_wo_name compute (WO # 00001)
Plan task P1.1. Formats fp.job.name as "WO # <last-segment>" for
tablet/dashboard surfaces. Underlying name field is unchanged so
back-office forms, reports, and emails keep WH/JOB/00001.

Tests not run locally — fusion_plating not mounted in odoo-modsdev
container. Verify on entech: -u fusion_plating_jobs --test-tags fp_jobs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:43:36 -04:00
gsinghpal
27465cfeac docs(fusion_plating_shopfloor): implementation plan for tablet redesign
5-phase TDD plan with 28+ tasks executing the spec at
docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md:

- Phase 1: Workspace foundation — 5 shared OWL services
  (WorkflowChip, GateViz, SignaturePad, HoldComposer, KanbanCard),
  JobWorkspace OWL client action, workspace_controller with 4 endpoints,
  display_wo_name + blocker_* computes, smart button on fp.job form.

- Phase 2: Auto-pause cron (fixes 411h ghost timer),
  late_risk_ratio + active_step_id computes, long_running flag on
  process node, ACL lift for operator (cert write, thickness create,
  override read).

- Phase 3: Landing refactor — fp_shopfloor_landing replaces
  fp_shopfloor_tablet + folds in fp_plant_overview. Station-scoped
  kanban with All Plant toggle.

- Phase 4: Manager dashboard refactor — 4 sibling tabs (Workflow
  Funnel, Approval Inbox, At-Risk, existing Plant Board), 3 new
  endpoints, bottleneck_score on fp.work.centre, 2 new KPI tiles.

- Phase 5: Cleanup — remove deprecation stubs, retire plant_overview
  menu, update CLAUDE.md + README.

Each phase ships independently; each task is a self-contained TDD
cycle (write test → fail → implement → pass → commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:38:01 -04:00
gsinghpal
fb5da1e3cd docs(fusion_plating_shopfloor): brainstorm spec for tablet redesign
Multi-section design covering:

- 3 OWL client actions: fp_shopfloor_landing (replaces fp_shopfloor_tablet
  + folds in fp_plant_overview), fp_job_workspace (NEW full-screen WO
  surface), fp_manager_dashboard (refactored — 4 sibling tabs incl.
  Workflow Funnel, Approval Inbox, At-Risk).

- 5 shared OWL services: WorkflowChip, GateViz, SignaturePad,
  HoldComposer, KanbanCard — reused across all three client actions to
  enforce one-widget-one-place and prevent terminology drift.

- Backend additions: 8 new RPC endpoints, blocker_kind/reason computes
  on fp.job.step, display_wo_name + late_risk_ratio + active_step_id on
  fp.job, bottleneck_score on fp.work.centre, auto-pause cron (fixes
  411h ghost timer), ACL lift for operator group per "techs wear
  multiple hats" rule.

- Terminology pass: WO # 00001 (display only, sequence rename deferred),
  Shop Floor / Up Next / Embrittlement Bakes / etc.

- 5-phase deploy sequence, each phase independently shippable.

- Out of scope (deferred to v2): cost roll-up, cycle time, per-tech
  throughput, system-wide sequence rename.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:31:15 -04:00
gsinghpal
f661724c72 changes 2026-05-22 18:01:31 -04:00
gsinghpal
d127e19b45 changes 2026-05-21 21:00:10 -04:00
gsinghpal
d022e529d9 changes 2026-05-21 09:22:50 -04:00
119 changed files with 15188 additions and 304 deletions

View File

@@ -4,7 +4,7 @@
{
'name': 'Fusion Faxes',
'version': '19.0.2.0.0',
'version': '19.0.2.1.1',
'category': 'Productivity',
'summary': 'Send and receive faxes via RingCentral API from Sale Orders, Invoices, and Contacts.',
'description': """

View File

@@ -32,5 +32,13 @@
<field name="value"></field>
</record>
<!-- UI toggle — when False, hides the "Send Fax" header button
on sale orders and invoices. Smart "Faxes" button (count
badge) is unaffected. -->
<record id="config_show_send_fax_button" model="ir.config_parameter">
<field name="key">fusion_faxes.show_send_fax_button</field>
<field name="value">True</field>
</record>
</data>
</odoo>

View File

@@ -17,12 +17,26 @@ class AccountMove(models.Model):
string='Fax Count',
compute='_compute_fax_count',
)
x_ff_show_send_fax_button = fields.Boolean(
string='Show Send Fax Button',
compute='_compute_show_send_fax_button',
help='Driven by the Settings toggle '
'(fusion_faxes.show_send_fax_button).',
)
@api.depends('x_ff_fax_ids')
def _compute_fax_count(self):
for move in self:
move.x_ff_fax_count = len(move.x_ff_fax_ids)
def _compute_show_send_fax_button(self):
param = self.env['ir.config_parameter'].sudo().get_param(
'fusion_faxes.show_send_fax_button', 'True',
)
show = str(param).lower() not in ('false', '0', '')
for move in self:
move.x_ff_show_send_fax_button = show
def action_send_fax(self):
"""Open the Send Fax wizard pre-filled with this invoice."""
self.ensure_one()

View File

@@ -15,6 +15,15 @@ class ResConfigSettings(models.TransientModel):
string='Enable RingCentral Faxing',
config_parameter='fusion_faxes.ringcentral_enabled',
)
ff_show_send_fax_button = fields.Boolean(
string='Show "Send Fax" Button on Sale Orders & Invoices',
config_parameter='fusion_faxes.show_send_fax_button',
default=True,
help='When enabled, the "Send Fax" header button appears on '
'sale order and invoice forms (for users in the Fax User '
'group). Turn off to hide the button without removing '
'fax-user access.',
)
ff_ringcentral_server_url = fields.Char(
string='RingCentral Server URL',
config_parameter='fusion_faxes.ringcentral_server_url',
@@ -103,7 +112,15 @@ class ResConfigSettings(models.TransientModel):
}
def set_values(self):
"""Protect credential fields from being blanked accidentally."""
"""Protect credential fields from being blanked accidentally
and force-persist the Send Fax Boolean.
Odoo's stock ``set_param`` removes the row when a Boolean
config_parameter is False, which makes the ``get_param``
fallback default kick in — toggling OFF then would silently
re-show the button. We bypass that by writing 'True' / 'False'
as a string after super() runs so the row always exists.
"""
protected_keys = [
'fusion_faxes.ringcentral_client_id',
'fusion_faxes.ringcentral_client_secret',
@@ -122,4 +139,9 @@ class ResConfigSettings(models.TransientModel):
existing = ICP.get_param(key, '')
if existing:
ICP.set_param(key, existing)
return super().set_values()
res = super().set_values()
ICP.set_param(
'fusion_faxes.show_send_fax_button',
'True' if self.ff_show_send_fax_button else 'False',
)
return res

View File

@@ -17,12 +17,28 @@ class SaleOrder(models.Model):
string='Fax Count',
compute='_compute_fax_count',
)
x_ff_show_send_fax_button = fields.Boolean(
string='Show Send Fax Button',
compute='_compute_show_send_fax_button',
help='Driven by the Settings toggle '
'(fusion_faxes.show_send_fax_button). Default True for '
'back-compat — the button stays visible until a manager '
'turns it off.',
)
@api.depends('x_ff_fax_ids')
def _compute_fax_count(self):
for order in self:
order.x_ff_fax_count = len(order.x_ff_fax_ids)
def _compute_show_send_fax_button(self):
param = self.env['ir.config_parameter'].sudo().get_param(
'fusion_faxes.show_send_fax_button', 'True',
)
show = str(param).lower() not in ('false', '0', '')
for order in self:
order.x_ff_show_send_fax_button = show
def action_send_fax(self):
"""Open the Send Fax wizard pre-filled with this sale order."""
self.ensure_one()

View File

@@ -20,9 +20,11 @@
<!-- Send Fax header button (fax users only) -->
<xpath expr="//header" position="inside">
<field name="x_ff_show_send_fax_button" invisible="1"/>
<button name="action_send_fax" string="Send Fax"
type="object" class="btn-secondary"
icon="fa-fax"
invisible="not x_ff_show_send_fax_button"
groups="fusion_faxes.group_fax_user"/>
</xpath>

View File

@@ -26,6 +26,20 @@
</div>
</div>
<!-- Show "Send Fax" button toggle -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="ff_show_send_fax_button"/>
</div>
<div class="o_setting_right_pane">
<label for="ff_show_send_fax_button"/>
<div class="text-muted">
Show the "Send Fax" header button on sale orders and invoices.
Turn off to hide the button without removing fax-user access.
</div>
</div>
</div>
<!-- Server URL -->
<div class="col-12 col-lg-6 o_setting_box"
invisible="not ff_ringcentral_enabled">

View File

@@ -20,9 +20,11 @@
<!-- Send Fax header button (fax users only) -->
<xpath expr="//header" position="inside">
<field name="x_ff_show_send_fax_button" invisible="1"/>
<button name="action_send_fax" string="Send Fax"
type="object" class="btn-secondary"
icon="fa-fax"
invisible="not x_ff_show_send_fax_button"
groups="fusion_faxes.group_fax_user"/>
</xpath>

View File

@@ -29,7 +29,7 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
| **Report border rendering** | After two failed attempts (px→mm conversion + dpi bump; then `border-collapse: separate` single-side-per-cell), settled on **`border-collapse: collapse` + longhand borders + `background-clip: padding-box`**. Verticals are a hair softer than horizontals on entech wkhtmltopdf — accepted as the lesser evil vs misaligned tables. See rule 14a, last paragraph. **Don't retry the single-side pattern.** | `fusion_plating_reports` |
| **Page-break-inside: avoid placement** | When a long QWeb report dumps content into multi-page PDFs via wkhtmltopdf, the company header (rendered as `--header-html`) can overlap body content if a page break lands mid-row in a table. **Apply `page-break-inside: avoid` to `<tr>` elements** (and to wrapper `<div>`s that wrap whole logical sections like signature blocks), not to `<table>`. On entech wkhtmltopdf, `<table>`-level `page-break-inside` is unreliable when the table is long enough to definitely break; per-row is honoured. Pattern: keep individual readings/rows together so the wkhtmltopdf header zone never overlaps mid-row content. Wrap the larger logical block (cert thickness section, signature + certification statement) in `<div style="page-break-inside: avoid;">` to keep it together when it fits and naturally wrap to a fresh page when it doesn't. | `fusion_plating_reports/report/report_coc.xml` |
| **`opacity` + `italic` muted text renders jagged on entech wkhtmltopdf** | The obvious pattern for a subtle footnote — `font-style: italic; opacity: 0.7;` (used by `.fp-coc .small-label`) — produces washed-out, jagged characters that look "broken" or "messed up" on the printed PDF. Visually it reads as garbled text even though the source is clean. **Use solid grey (`color: #555`) at normal weight instead** for muted secondary text. Same workaround applies to any `opacity`-driven greyed-out element bound for wkhtmltopdf. The existing `.small-label` class still exists for legacy callers but new code should prefer an explicit `color:` style. | `fusion_plating_reports` |
| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix (the CoC pattern — proven to work):** use a TINY `margin_top` (≤8mm) + `header_spacing=0` + `header_line=False`, then put a generous `padding-top: 20mm` on the body wrapper class (`.fp-coc`, `.fp-sale`, etc.). The header HTML is allowed to overflow the reserved zone INTO the body area; the body's wrapper padding is what actually clears it. This is more robust than trying to size `margin_top` to the rendered header height — any future header change (extra phone line, taller logo) immediately causes overlap with the "sized exactly" approach, whereas the overflow+padding approach has slack built in. Reference setup: `paperformat_fp_coc` and `paperformat_fp_a4_portrait` both use `margin_top=8`, `header_spacing=0`, `header_line=False`; CoC's `.fp-coc` and SO's `.fp-sale` both use `padding-top: 20mm` on the wrapper. Each report can have its own paperformat — `report_coc_en` / `report_coc_fr` use "Fusion Plating CoC" (id 13); the legacy `report_coc` uses "A4 Landscape (Fusion Plating)" (id 12); SO portrait uses "Fusion Plating A4 Portrait (Compact)". Update the right one and don't bleed changes across reports. **Corollary — don't use negative `margin-top` to "tighten" the gap** (e.g. `.my-page { margin-top: -10px; }` to pull the H1 up under the header). The body wrapper sits at the bottom edge of the reserved margin_top zone; any negative margin pushes content INTO the header band, where wkhtmltopdf clips the top of glyphs (looks like the title is half-eaten). If the gap really feels too big, shrink the title font instead, or reduce `paperformat.margin_top` so the entire header zone is shorter. **For customer-facing portrait reports** (SO confirmation, quote, invoice, packing slip, BoL) the canonical compact paperformat is `fusion_plating_reports.paperformat_fp_a4_portrait` (margin_top=22mm, header_spacing=3mm, keeps the standard header band). Bind it via `<field name="paperformat_id" ref="fusion_plating_reports.paperformat_fp_a4_portrait"/>` rather than creating yet-another-one. **Two compounding-padding traps to be aware of:** (1) Odoo's `.page` class has `padding: 1cm` baked in (Bootstrap-derived). If you wrap your body in `<div class="page">` AND add a body `padding-top: 15mm`, you get the paperformat margin_top + 10mm Odoo + 15mm yours = ~65mm of dead space above the title. To remove the .page contribution without losing its left/right padding, override only the top: `.fp-report.fp-sale .page { padding-top: 0 !important; }`. CoC sidesteps this by NOT using an inner `.page` div — it wraps directly in `<div class="fp-coc">` and puts padding on that. (2) The base `.fp-report table.bordered th, .fp-report table.bordered td` rule applies borders explicitly, BUT a separate cascade still bleeds borders onto NESTED `<table>` elements even when the inner table has no `.bordered` class — `border: 0 !important` on the cells does NOT reliably override it (some wkhtmltopdf rendering paths still draw the lines). **Don't use a `<table>` for non-bordered layouts** like a title/barcode strip; use `<div>` + `float: right` / flexbox instead. Saves an hour of CSS specificity arguments with wkhtmltopdf. (3) **CSS comments inside QWeb `<style>` blocks are XML-parsed** — writing `/* don't use a <table> here */` makes lxml see a literal `<table>` opening tag and the file fails to load with `XMLSyntaxError: Opening and ending tag mismatch`. Strip the angle brackets from any HTML-like literals in CSS comments: write `/* don't use a table here */` or quote it as `"<table>"`. | `fusion_plating_reports`, `report.paperformat` |
| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix (the CoC pattern — proven to work):** use a TINY `margin_top` (≤8mm) + `header_spacing=0` + `header_line=False`, then put a generous `padding-top: 20mm` on the body wrapper class (`.fp-coc`, `.fp-sale`, etc.). The header HTML is allowed to overflow the reserved zone INTO the body area; the body's wrapper padding is what actually clears it. This is more robust than trying to size `margin_top` to the rendered header height — any future header change (extra phone line, taller logo) immediately causes overlap with the "sized exactly" approach, whereas the overflow+padding approach has slack built in. Reference setup: `paperformat_fp_coc` and `paperformat_fp_a4_portrait` both use `margin_top=8`, `header_spacing=0`, `header_line=False`; CoC's `.fp-coc` and SO's `.fp-sale` both use `padding-top: 20mm` on the wrapper. **For landscape custom-header reports, reuse `paperformat_fp_a4_landscape_compact`** (same shape, just rotated) — invoice landscape uses this; don't create yet-another-landscape-compact. Each report can have its own paperformat — `report_coc_en` / `report_coc_fr` use "Fusion Plating CoC" (id 13); the legacy `report_coc` uses "A4 Landscape (Fusion Plating)" (id 12); SO portrait uses "Fusion Plating A4 Portrait (Compact)". Update the right one and don't bleed changes across reports. **Corollary — don't use negative `margin-top` to "tighten" the gap** (e.g. `.my-page { margin-top: -10px; }` to pull the H1 up under the header). The body wrapper sits at the bottom edge of the reserved margin_top zone; any negative margin pushes content INTO the header band, where wkhtmltopdf clips the top of glyphs (looks like the title is half-eaten). If the gap really feels too big, shrink the title font instead, or reduce `paperformat.margin_top` so the entire header zone is shorter. **For customer-facing portrait reports** (SO confirmation, quote, invoice, packing slip, BoL) the canonical compact paperformat is `fusion_plating_reports.paperformat_fp_a4_portrait` (margin_top=22mm, header_spacing=3mm, keeps the standard header band). Bind it via `<field name="paperformat_id" ref="fusion_plating_reports.paperformat_fp_a4_portrait"/>` rather than creating yet-another-one. **Two compounding-padding traps to be aware of:** (1) Odoo's `.page` class has `padding: 1cm` baked in (Bootstrap-derived). If you wrap your body in `<div class="page">` AND add a body `padding-top: 15mm`, you get the paperformat margin_top + 10mm Odoo + 15mm yours = ~65mm of dead space above the title. To remove the .page contribution without losing its left/right padding, override only the top: `.fp-report.fp-sale .page { padding-top: 0 !important; }`. CoC sidesteps this by NOT using an inner `.page` div — it wraps directly in `<div class="fp-coc">` and puts padding on that. (2) The base `.fp-report table.bordered th, .fp-report table.bordered td` rule applies borders explicitly, BUT a separate cascade still bleeds borders onto NESTED `<table>` elements even when the inner table has no `.bordered` class — `border: 0 !important` on the cells does NOT reliably override it (some wkhtmltopdf rendering paths still draw the lines). **Don't use a `<table>` for non-bordered layouts** like a title/barcode strip; use `<div>` + `float: right` / flexbox instead. Saves an hour of CSS specificity arguments with wkhtmltopdf. (3) **CSS comments inside QWeb `<style>` blocks are XML-parsed** — writing `/* don't use a <table> here */` makes lxml see a literal `<table>` opening tag and the file fails to load with `XMLSyntaxError: Opening and ending tag mismatch`. Strip the angle brackets from any HTML-like literals in CSS comments: write `/* don't use a table here */` or quote it as `"<table>"`. (4) **XML comments cannot contain `--` (double-hyphen)** per the XML spec — `<!-- needs wkhtmltopdf --footer-html -->` fails with `XMLSyntaxError: Comment must not contain '--' (double-hyphen)`. Rewrite without the double-hyphen: `<!-- needs a wkhtmltopdf footer-html arg -->`. Bites when documenting CLI flags or option names in QWeb comments. | `fusion_plating_reports`, `report.paperformat` |
| **CoC + thickness = ONE cert (page 2 merge OR inline body)** | When a customer has both `x_fc_send_coc` and `x_fc_send_thickness_report` on (or part has `certificate_requirement='coc_thickness'`), `_resolve_required_cert_types` returns **`{'coc'}` only**. Standalone `thickness_report` certs are only created when CoC is OFF and thickness is ON (rare). The earlier "two certs" behavior was a bug — don't restore it. **Two rendering paths exist for the thickness data in the CoC PDF:** (1) **Page-2 PDF merge** via `_fp_merge_thickness_into_pdf` — used when there's a real PDF source (operator uploaded a Fischerscope PDF, or QC has `thickness_report_pdf_id`). (2) **Inline readings table in the CoC body** — used when `thickness_reading_ids` is populated but there's no PDF source (e.g. RTF upload parsed to readings, manually typed readings). Lives in `report_coc.xml` between the parts table and the signature block, gated on `doc.thickness_reading_ids`. Both can coexist on a cert — PDF merges as page 2, readings render inline; usually only one path has data per cert. | `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports` |
| **Smart-button "create or view" pattern** | For a smart button that toggles between "create" and "view" states, use **one** idempotent button with `widget="statinfo"`, not two sibling buttons gated by mutually-exclusive `invisible` expressions. Custom `<div class="o_stat_info">` without `<span class="o_stat_value">` renders awkwardly in Odoo 19 (numbers + label expected); `statinfo` handles the standard structure automatically. The action method itself should branch on whether the linked record exists (create-then-open or just open). | any module with smart buttons |
| **stock.move.name removed** | Odoo 19 dropped the `name` field on `stock.move`. Passing `name` in a create dict raises `ValueError: Invalid field 'name' on model 'stock.move'`. Use `description_picking` instead (the operator-facing line label on the picking). The DB column is gone too — `name` doesn't exist as a stored field. | any code that builds stock.move records |
@@ -44,6 +44,7 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition``Adresse d'expédition`, `N° de pièce``N° de pièce`, `—``â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). | any custom-header PDF report on entech wkhtmltopdf |
| **QWeb `t-field` requires a dotted path — bare variables fail at compile** | Odoo 19 enforces `assert "." in el.get('t-field')` in `_compile_directive_field`. Writing `<div t-field="partner" t-options="{'widget': 'contact', ...}"/>` (where `partner` came from a `<t t-set="partner" t-value="..."/>` in the calling template) **fails at template-compile time** with `AssertionError: t-field must have at least a dot like 'record.field_name'`. The error message points at the line, but the broader trap is that **you can't write a generic "render-a-partner-as-contact" sub-template that takes a record via t-set** — the contact-widget pattern only works on real field traversals like `doc.partner_id` baked into the template at author time. **Workarounds:** (a) Inline the partner rendering at each call site so the `t-field` has a dotted path (`<div t-field="doc.partner_invoice_id" t-options=...`). (b) Render the address parts manually in the sub-template using `t-esc` on explicit fields (`partner.street`, `partner.city`, etc.) — verbose but works with bare variables. Pattern (b) is what `fp_packing_slip_addr_block` uses now after this trap was hit. Same applies to `t-out` with `widget` options. | any QWeb sub-template trying to render a record via `t-field` |
| **Assigning a `Date` to a `Datetime` field shifts the day in negative-UTC timezones** | When a transient/wizard `fields.Date` value is written into a target `fields.Datetime` field (e.g. wizard `customer_deadline` → SO `commitment_date`), Odoo stores midnight UTC of the picked date. Rendered back in any negative-UTC timezone (Eastern UTC-4/-5, all of CA/US), midnight UTC = 8pm the previous day — so the user picks "May 25" in the wizard and sees "May 24" on the SO header / PDF report. **Fix:** combine the date with noon before writing: `datetime.combine(self.my_date, time(12, 0))` — noon UTC stays on the same calendar date in every reasonable timezone (±12hr). Caught here on `fp.direct.order.wizard._prepare_order_vals` writing `commitment_date`. Watch for the same pattern any time a wizard/configurator with a Date field hands off to a Datetime target. The reverse (`Datetime` field read into a Date-display) is fine if `t-options="{'widget':'date'}"` is used — Odoo handles the tz-aware date extraction. | any wizard writing a Date value into a Datetime field |
| **Customer-facing reports use bilingual EN/FR labels** | Every customer-facing report label (column titles, section banners, totals, document title) renders English first and French second. **Default to inline slash format** ("English / French" on one line) — easier to scan and saves vertical space. **Use the stacked variant only for cells too narrow** for the French word to fit on the same line (QTY, UOM, narrow column headers in dense tables). CSS classes live in the `fp_sale_bilingual_styles` template in `report_fp_sale.xml`. **Inline (default):** `.fp-bl-en { font-weight:bold; }` + `.fp-bl-sep { color:#999; margin:0 3px; }` + `.fp-bl-fr { font-weight:normal; font-style:italic; color:#555; }`. Pattern: `<span class="fp-bl-en">English</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">French</span>`. **Stacked (narrow cells):** `.fp-bl-en-stk` + `.fp-bl-fr-stk` (each `display:block`). **Always render both spans even when EN and FR are the same word** (e.g. "Description / Description", "Taxes / Taxes") — visual consistency across the row matters more than the redundancy; dropping the FR span on identical-word labels leaves an obvious gap when scanning down a column of headers. When a report has a barcode block, encode `doc.name` via `ir.actions.report.barcode_data_uri('Code128', doc.name, 600, 100)` (the helper inlines a data URI — don't `/report/barcode/...` over HTTP, wkhtmltopdf network fetches fail on entech). Apply to ALL outward-facing reports (SO confirmation, quote, invoice, CoC, packing slip, BoL); internal-only reports (job traveller, WO sticker) can stay English. | `fusion_plating_reports/report/report_fp_sale.xml` (canonical), every customer-facing report |
@@ -165,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")`.
@@ -338,13 +366,57 @@ POST /fp/recipe/duplicate — deep-copy recipe
### Client Recipes Created
- `ENP-ALUM-BASIC` — Electroless Nickel Plating Aluminium Basic (9 operations, 15 steps). Data file: `fusion_plating/data/fp_recipe_enp_alum_basic.xml`
## Plant Overview Dashboard
- OWL client action: `fp_plant_overview` in `fusion_plating_shopfloor`
- Kanban columns = work centres, cards = active `mrp.workorder` records
- Drag & drop between columns (writes `workcenter_id` on the work order)
- Endpoint: `POST /fp/shopfloor/plant_overview`
- Move endpoint: `POST /fp/shopfloor/plant_overview/move_card`
- Auto-refreshes every 30s
## Shop Floor Architecture (2026-05-22 tablet redesign — Phases 1-4)
Spec: [docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md](docs/superpowers/specs/2026-05-22-shopfloor-tablet-redesign-design.md)
Plan: [docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md](docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md)
**Three OWL client actions** (registered under `registry.category("actions")`):
- `fp_shopfloor_landing` — Workstation kanban entry. Station-scoped or All-Plant mode toggle. Tap a card → JobWorkspace. Replaces the legacy `fp_shopfloor_tablet` and folds in `fp_plant_overview`.
- `fp_job_workspace` — Full-screen single-WO surface. Sticky header (WO #, customer, qty, workflow chip), sticky 9-stage workflow bar, step list with GateViz blockers, side panel (spec/attachments/chatter), sticky action rail (Hold/Note/Milestone). Opens from kanban tap, smart button, QR scan, or manager card tap.
- `fp_manager_dashboard` — Manager Desk with 4 sibling tabs: **Workflow Funnel** (default), **Approval Inbox**, **Plant Board** (existing 3-column), **At-Risk** (trending late + hold reasons + bottleneck heatmap).
**Five shared OWL services** in `fusion_plating_shopfloor/static/src/js/components/`:
- `WorkflowChip` — workflow.state pill + optional next-action hint
- `GateViz` — "Can't start because…" explainer (reads `fp.job.step.blocker_kind`/`reason`)
- `FpSignaturePad` — Dialog canvas signature capture
- `FpHoldComposer` — Dialog hold-create form with reason picker + qty + photo
- `FpKanbanCard` — standard WO/step card with embedded WorkflowChip + blocker badge
**Backend endpoints (Phase 1-4):**
- Workspace: `/fp/workspace/{load,hold,sign_off,advance_milestone}`
- Landing: `/fp/landing/kanban` (mode=station|all_plant)
- Manager: `/fp/manager/{overview,funnel,approval_inbox,at_risk}`
**Auto-pause cron — fixes 411-hour ghost timers:**
- `_cron_autopause_stale_steps()` runs every 30 min
- Threshold from `ir.config_parameter` **`fp.shopfloor.autopause_threshold_hours`** (default 8.0)
- Recipe nodes opt out via `fusion.plating.process.node.long_running=True` for 24h bakes etc.
- Flips `state=in_progress` idle > threshold to `paused` with chatter audit
**Key model fields added (Phase 1-4):**
- `fp.job.display_wo_name` — "WO # 00001" formatter for tablet/dashboard (model `name` field stays `WH/JOB/…`; system-wide sequence rename deferred)
- `fp.job.late_risk_ratio` — stored Float (remaining_planned / minutes_to_deadline) driving At-Risk view
- `fp.job.active_step_id` — computed M2o to currently in_progress step
- `fp.job.step.blocker_kind` / `blocker_reason` / `blocker_jump_target_*` — drives GateViz
- `fp.work.centre.bottleneck_score` / `avg_wait_minutes` — drives At-Risk bottleneck heatmap
**Operator ACL lift (per "techs wear multiple hats" rule):**
- `fp.certificate` — operator gained write (flip draft → issued from tablet)
- `fp.thickness.reading` — operator gained create+write (Fischerscope capture from tablet)
- `fp.job.node.override` — operator gained read (see opt-out badges on steps)
- Supervisor-only ops (step Skip, hold Release) enforced in `workspace_controller.py`, not ACL
**Deprecated but still live** (cleanup is Phase 5):
- OWL components: `fp_shopfloor_tablet`, `fp_plant_overview` — registered but no menu points at them
- Endpoints: `/fp/shopfloor/tablet_overview`, `plant_overview`, `queue` — marked DEPRECATED with INFO log lines, bodies intact for back-compat
- `/fp/shopfloor/plant_overview/move_card` is **NOT** deprecated — the new Landing component uses it for drag-and-drop
**Old patterns to avoid:**
- Don't read `fp.job.name` for display — use `display_wo_name` everywhere on tablet/dashboard
- Don't add SCSS that uses raw hex without an `@if $o-webclient-color-scheme == dark` branch (dark mode breaks otherwise; see existing `_workflow_chip.scss` as the template)
- 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`
## Deployment

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,487 @@
# Shop Floor Tablet Redesign — Design Spec
**Date:** 2026-05-22
**Status:** Brainstorm complete, awaiting user review
**Authors:** Garry Singh + Claude
**Module owners:** `fusion_plating_shopfloor`, `fusion_plating_jobs`
**Target client:** EN Technologies (Fusion Plating)
---
## 1. Context
The current Shop Floor tablet view (client action `fp_shopfloor_tablet`, OWL component `ShopfloorTablet`) was built during the initial Fusion Plating implementation. Since then the underlying models — `fp.job`, `fp.job.step`, `fp.job.workflow.state`, `fp.certificate`, `fp.thickness.reading`, `fp.job.consumption`, `fp.job.node.override`, `fp.racking.inspection` and friends — have grown substantially. Many of those new fields, actions, and workflows are not surfaced on the tablet.
Symptoms observed on a live development instance:
- Step name shows "Active: Blasting" with no WO/customer context
- Qty rendered as "Qty 17/1" — ambiguous direction
- Step position shown as "step 1.1/11" (sequence divided by 10)
- Active timer reading **411:52:16** — a stale start that never finished and was never auto-paused
- "SIGN-OFF REQUIRED" chip is informational only; no actual sign-off control
- Customer spec, drawings, recipe overrides, milestone progress, holds, and most lifecycle actions are invisible
Three roles operate the system: **Owner**, **Manager**, **Technician**. Technicians wear multiple hats (receiving, plating, QC, shipping) — the client explicitly wants minimal gating between roles.
## 2. Goals
- A technician can manage a WO end-to-end from a single full-screen workspace, without typing into search bars or jumping to the back-office.
- A manager can see at a glance: where every WO is in the workflow, what needs their decision right now, what's trending late, and where the bottlenecks are.
- All recent additions to `fp.job` / `fp.job.step` (workflow milestones, blocker reasons, recipe overrides, customer spec, etc.) are surfaced on the tablet and manager dashboard.
- Terminology matches how techs talk on the shop floor — "WO # 00001" not "WH/JOB/00001".
- The system never displays a 411-hour ghost timer.
## 3. Non-goals (v1)
- Multi-tablet pairing per technician
- Offline-first / PWA mode
- Voice input
- Performance optimization beyond ~500 active jobs (current scale: ~50)
- Webhooks to external dashboards
- Cost roll-up per job, cycle time per recipe, per-tech throughput (P2 — deferred to v2)
- System-wide rename of `fp.job` sequence (`WH/JOB/...``WO ...`) — display-only on tablet for now; back-office/reports/emails keep current sequence until a separate decision is made
## 4. Terminology decisions
All approved in brainstorm:
| Element | Was | Is now |
|---|---|---|
| Page title | "Tablet Station" | **Shop Floor** (with station chip e.g. "@ EN Plating Tank") |
| Document number | `WH/JOB/00001` | **`WO # 00001`** (display only) |
| Active step header | "Active: Blasting" | **"WO # 00001 — Blasting"** (Step 1 of 11) |
| Qty | "Qty 17/1" | **"1 / 17 done"** + scrap subtext + mini progress bar |
| Step position | "step 1.1/11" | **"Step 1 of 11"** |
| Sign-off chip | "SIGN-OFF REQUIRED" | **"Finish & Sign Off"** action button (replaces plain Finish) |
| Queue heading | "My Queue" | **"Up Next"** (at this station) |
| Bath state | "OPERATIONAL / LOG: OUT_OF_SPEC" | **"Operating"** / **"Last log out of spec"** |
| Bake panel | "Bake Windows" | **"Embrittlement Bakes"** |
| Gate panel | "First-Piece Gates" | **"First-Piece Inspections"** |
| Tile set | 6 mixed tiles | **4 tech-relevant tiles**: Ready · Running · Bakes Due · Holds (others move to manager dashboard) |
| Stale timer | "411:52:16" | Auto-pause at 8h (configurable) with chatter audit; display switches to "Started Nd ago" past 24h |
## 5. Architecture — option B (specialized components + shared services)
Three OWL client actions, five shared OWL services, a small set of backend additions. Each client action is independently deployable.
```
┌────────────────────────────────┐ ┌────────────────────────────────┐ ┌────────────────────────────────┐
│ fp_shopfloor_landing │ │ fp_job_workspace │ │ fp_manager_dashboard │
│ (replaces fp_shopfloor_tablet │ │ NEW — full-screen WO surface │ │ refactored — 4 tabs │
│ + folds in fp_plant_overview)│ │ │ │ │
│ • station-scoped kanban │ │ • sticky header + WO chips │ │ • Workflow Funnel (default) │
│ • All-Plant toggle │ │ • workflow milestone bar │ │ • Approval Inbox │
│ • QR scan, station picker │ │ • step list + side panel │ │ • Plant Board (existing) │
│ • tap card → JobWorkspace │ │ • sticky action rail │ │ • At-Risk │
└────────────────────────────────┘ └────────────────────────────────┘ └────────────────────────────────┘
│ │ │
└──────────────────────────────────┴──────────────────────────────────┘
┌────────────┴────────────┐
│ Shared OWL services │
│ WorkflowChip · GateViz │
│ SignaturePad · KanbanCard │
│ HoldComposer │
└─────────────────────────┘
```
### 5.1 Shared OWL services
| Service | Used by | Props | Depends on |
|---|---|---|---|
| **WorkflowChip** | Landing card · Workspace header · Manager funnel | `{ state: {id, name, color}, nextActionLabel? }` | `fp.job.workflow.state` records (already shipped). Reads `color` field. |
| **GateViz** | Workspace step rows · Manager "Needs Worker" cards | `{ canStart, blockerKind, blockerReason, jumpTarget? }` | New `fp.job.step.blocker_kind` + `blocker_reason` computes |
| **SignaturePad** | Workspace (Finish & Sign Off) · Cert issue | `{ title, contextLabel, onSubmit(dataUri), onCancel }` | Odoo `dialog` service; HTML canvas + pointer events |
| **HoldComposer** | Workspace (Hold button) · Manager Approval Inbox | `{ jobId, stepId?, defaultQty, partRef, onCreated(hold) }` | New endpoint `/fp/workspace/hold` (with photo attachment) |
| **KanbanCard** | Landing (station + all-plant) · Manager (Plant Board + Workflow Funnel) | `{ data, density: 'compact'\|'normal', showWorkflowChip, showWorkcenter, showAssignedTo, onTap }` | Embeds `WorkflowChip` + `GateViz` badge |
Each service is its own file under `fusion_plating_shopfloor/static/src/js/components/`. Roughly 80200 lines OWL + 3080 lines SCSS per service.
### 5.2 Landing component (`fp_shopfloor_landing`)
Replaces today's `fp_shopfloor_tablet`, folds in `fp_plant_overview`. Single entry surface for technicians.
**Layout regions** (top-to-bottom):
1. **Header strip** — "Shop Floor" title, station chip, station picker, mode toggle (`Station``All Plant`), QR scan controls (Code + Camera), refresh indicator.
2. **KPI tile row (4 tiles)** — Ready · Running · Bakes Due · Holds. Holds turns red when > 0.
3. **Kanban board** — columns = work centres; cards = `KanbanCard` (one per WO at that work centre); urgency-sorted within column (existing logic in `plant_overview.py` carries over). Drag-and-drop between columns keeps current behaviour.
4. **Optional left filter rail** (collapsed by default) — search box, priority, customer, due-by, blocker filter. Promote the existing plant_overview search bar.
5. **Footer** — auto-refresh indicator + "Last sync HH:MM:SS".
**Mode behaviour:**
| Mode | Columns shown | Default when |
|---|---|---|
| **Station** | Paired work centre + Unassigned + next 12 work centres in the recipe flow | A station is paired (via QR scan or picker) |
| **All Plant** | Every active work centre, recipe-flow order | No station paired, OR user toggles |
Toggle persists in `localStorage` per tablet (same pattern as `fp_tablet_station_id`).
**Card tap behaviour:**
```js
action.doAction({
type: 'ir.actions.client',
tag: 'fp_job_workspace',
params: { job_id: card.job_id, focus_step_id: card.current_step_id }
});
```
Browser back returns to Landing with kanban scroll/mode preserved.
**QR scan dispatch** (existing `/fp/shopfloor/scan` endpoint, unchanged):
| Scanned | Behaviour |
|---|---|
| `FP-STATION:<code>` | Pair tablet, switch to Station mode |
| `FP-JOB:<name>` | Open JobWorkspace for that WO |
| `FP-STEP:<id>` | Open JobWorkspace, focus that step |
| `FP-TANK:<code>` / `FP-BATH:<name>` | Chemistry quick-log dialog (existing endpoint) |
| `FP-OVEN:<code>` | Jump to next bake awaiting that oven |
**Auto-refresh** — every 15s.
**Files:**
```
fusion_plating_shopfloor/
controllers/landing_controller.py ← NEW (~250 lines)
static/src/js/shopfloor_landing.js ← OWL (~600 lines)
static/src/xml/shopfloor_landing.xml
static/src/scss/shopfloor_landing.scss
```
### 5.3 Job Workspace component (`fp_job_workspace`)
The heart of the redesign. Full-screen surface a tech opens by tapping a kanban card.
**Layout regions** (sticky top → scrollable middle → sticky bottom):
| Region | Sticky | Data | Behaviour |
|---|---|---|---|
| **Back** | top | — | `doAction` back to Landing, preserves kanban scroll/mode |
| **WO header** | top | `display_wo_name`, `partner_id`, `part_catalog_id` + rev, qty/qty_done/qty_scrapped, `date_deadline`, `workflow_state_id`, `quality_hold_count`, `customer_spec_id` | `+1 Done` / `1 Done` / `+1 Scrap` quick bumps inline. Holds count → opens Holds drawer. |
| **Workflow milestone bar** | top | All `fp.job.workflow.state` records ordered by sequence; current = `workflow_state_id`; `next_milestone_action` + `next_milestone_label` | Dots: passed (●), current (filled), pending (○). "Next" button on right fires `/fp/workspace/advance_milestone`. Disabled until preconditions met. |
| **Step list** (left/center, scrolls) | scrolls | `fp.job.step_ids` sorted by `sequence` | Each row uses step-row template (see below). Active step auto-scrolled and auto-expanded if `focus_step_id` param set. |
| **Side panel** (collapsible right) | scrolls | `customer_spec_id` (PDF), attachments, chatter | Three sub-cards: **Spec** (inline via `fusion_pdf_preview`), **Drawings**, **Notes** (chatter — read + quick-add). Collapses to icon strip on narrow screens. |
| **Action rail** | bottom | — | Always: Create Hold (`HoldComposer`), Add Note, Photo. Conditional: Issue Cert (when `_fp_has_draft_required_certs()`), Mark Done / Schedule Delivery / Mark Shipped (per `next_milestone_action`). |
**Step row anatomy:**
- **Collapsed** (default for done/pending/paused): one line — icon + Step N · Name + assigned tech + duration + state badge
- **Expanded active** (auto for `state == 'in_progress'`): recipe chips + instructions + primary + secondary actions
- **Expanded by tap** (any step): same shape, action buttons gated by `can_start`
**Per-step actions:**
| Button | Visible when | Calls |
|---|---|---|
| Start | `state in ('ready','paused')` AND `can_start` | `/fp/shopfloor/start_wo` |
| Finish (or Finish & Sign Off) | `state == 'in_progress'` | `/fp/shopfloor/stop_wo` (finish=true) OR `/fp/workspace/sign_off` if `requires_signoff` |
| Pause | `state == 'in_progress'` | `step.button_pause()` |
| Skip | `state in ('ready','paused')` AND user is supervisor+ | `step.button_skip()` |
| Move Parts | always | `FpMovePartsDialog` (existing) |
| Move Rack | when `kind == 'rack'` | `FpMoveRackDialog` (existing) |
| Quick QC | when `quick_look_prompt_ids` non-empty | `step.action_open_quick_look()` |
| Operator Inputs | when step has unrecorded inputs | `step.action_open_input_wizard()` (existing wizard) |
| Photo | always | inline camera → attach to step |
| Stop Timer (correction) | when duration looks wrong | `FpStopTimerDialog` (existing) |
| Open in backend | always (small icon) | `doAction` to fp.job.step form (escape hatch) |
When step is **blocked** (`can_start == False`), action button row is replaced by `GateViz` block.
When step is **opted out** (`override_ids` says excluded), row shows ✕ icon + "Skipped per recipe override" + supervisor-only "Re-include" button.
**Auto-pause integration** — if `_cron_autopause_stale_steps` flips a step to paused, the row's chatter reflects "Auto-paused after Nh idle". Tech can tap Resume.
**Auto-refresh** — every 15s.
**Files:**
```
fusion_plating_shopfloor/
controllers/workspace_controller.py ← NEW (~400 lines)
static/src/js/job_workspace.js ← OWL (~800 lines)
static/src/xml/job_workspace.xml
static/src/scss/job_workspace.scss
static/src/js/components/{workflow_chip,gate_viz,signature_pad,hold_composer,kanban_card}.js
```
### 5.4 Manager Dashboard refactor (`fp_manager_dashboard`)
Same client action, **four sibling tabs** under a shared header + KPI strip.
**KPI strip (extended):** keep existing 4 always-on (Unassigned Steps · In Progress · Ready to Ship · Awaiting Assignment) + existing conditional reds (Missed Bakes · Open Holds · Stale Steps · Predecessor Locked) + **2 new: Pending Cert · At-Risk**.
**Tabs:**
| Tab | Default? | Content |
|---|---|---|
| **Workflow Funnel** | yes | Vertical stack of `fp.job.workflow.state` records. Each row shows stage chip + count badge + first ~5 `KanbanCard`s + "+ N more" drawer. Bar chart bar behind the row scaled to count. Tap card → JobWorkspace. |
| **Approval Inbox** | no | 4 grouped strips: **Holds to Release** (`state in ('on_hold','under_review')`), **Certs to Issue** (`all_steps_terminal` + draft required cert), **Scrap to Review** (recent `qty_scrapped` bumps with operator reason), **Override Requests** (deferred — placeholder). Per-row inline action buttons + bulk-action ("Release all"). |
| **Plant Board** | no | Today's existing 3-column "Needs Worker / In Progress / Team" view — unchanged behaviour. Becomes one tab among four. |
| **At-Risk** | no | 3 sub-panels: **Trending Late** (sorted by `late_risk_ratio` desc, top 20), **Hold Reasons** (open holds grouped by `hold_reason`), **Bottleneck Heatmap** (work centres ranked by `bottleneck_score`). |
**Cross-tab features:** live 8s refresh (existing cadence), QR scan in header, "Take Over Tablet" supervisor handover.
**Permissions:** dashboard already gated to `group_fusion_plating_supervisor`+. That stays. Owner + Manager hit this; Technicians don't.
**Files:**
```
fusion_plating_shopfloor/
controllers/manager_controller.py ← add 3 endpoints (funnel, approval_inbox, at_risk)
static/src/js/manager_dashboard.js ← refactor: extract Plant Board, add 3 sibling tabs
static/src/xml/manager_dashboard.xml
static/src/scss/manager_dashboard.scss
```
## 6. Backend support
### 6.1 HTTP endpoints
All `type='jsonrpc'`, `auth='user'`.
**NEW** — added by this work:
| Endpoint | Lives in | Purpose |
|---|---|---|
| `POST /fp/landing/kanban` | `landing_controller.py` | Station OR all-plant kanban data |
| `POST /fp/workspace/load` | `workspace_controller.py` | Full Job Workspace payload |
| `POST /fp/workspace/hold` | `workspace_controller.py` | HoldComposer create (with photo) |
| `POST /fp/workspace/sign_off` | `workspace_controller.py` | Signature + finish step atomically |
| `POST /fp/workspace/advance_milestone` | `workspace_controller.py` | Fire `next_milestone_action` |
| `POST /fp/manager/funnel` | `manager_controller.py` (add) | Workflow funnel data |
| `POST /fp/manager/approval_inbox` | `manager_controller.py` (add) | Holds + draft certs + scrap to review |
| `POST /fp/manager/at_risk` | `manager_controller.py` (add) | Late-risk + hold reasons + bottlenecks |
**KEPT** — unchanged, used by new components via wrappers: `/fp/shopfloor/scan`, `start_wo`, `stop_wo`, `start_bake`, `end_bake`, `log_chemistry`, `log_thickness_reading`, `bump_qty_done`, `bump_qty_scrapped`, `mark_gate`, `pair_station`.
**DEPRECATED** — kept as stubs for 1 release, then removed:
- `/fp/shopfloor/tablet_overview` → calls `/fp/landing/kanban` internally
- `/fp/shopfloor/plant_overview` → calls `/fp/landing/kanban?mode=all_plant`
- `/fp/shopfloor/queue` → removed (no replacement)
### 6.2 Model fields / computes
On `fp.job` (`fusion_plating_jobs/models/fp_job.py`):
| Field | Type | Purpose |
|---|---|---|
| `display_wo_name` | computed Char | "WO # 00001" formatter from `name` |
| `late_risk_ratio` | computed Float, stored | `remaining_planned_minutes / minutes_to_deadline` |
| `active_step_id` | computed Many2one→fp.job.step | Current `in_progress` step (Workspace landing focus) |
On `fp.job.step` (`fusion_plating_jobs/models/fp_job_step.py`):
| Field | Type | Purpose |
|---|---|---|
| `blocker_kind` | computed Selection | `predecessor` · `contract_review` · `parts_not_received` · `racking_required` · `manager_input` · `none` |
| `blocker_reason` | computed Char | Human reason (e.g. "Waiting on Step 3: Activation") |
| `blocker_jump_target_model` | computed Char | Optional tap-to-jump target model |
| `blocker_jump_target_id` | computed Integer | Optional tap-to-jump target id |
On `fp.work.centre` (`fusion_plating/models/fp_work_centre.py`):
| Field | Type | Purpose |
|---|---|---|
| `bottleneck_score` | computed Float, non-stored | `active_step_count × avg_wait_minutes` |
| `avg_wait_minutes` | computed Float, non-stored | Rolling 7-day avg ready→start wait |
On `fusion.plating.process.node` (recipe node):
| Field | Type | Purpose |
|---|---|---|
| `long_running` | Boolean | Opt out of auto-pause (24h bakes etc.) |
### 6.3 Auto-pause cron
```xml
<!-- fusion_plating_jobs/data/fp_cron_data.xml -->
<record id="ir_cron_autopause_stale_steps" model="ir.cron">
<field name="name">FP Jobs: auto-pause stale in-progress steps</field>
<field name="model_id" ref="model_fp_job_step"/>
<field name="state">code</field>
<field name="code">model._cron_autopause_stale_steps()</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="active" eval="True"/>
</record>
```
Method (`fp_job_step.py`):
```python
@api.model
def _cron_autopause_stale_steps(self):
threshold = float(self.env['ir.config_parameter'].sudo()
.get_param('fp.shopfloor.autopause_threshold_hours', 8))
deadline = fields.Datetime.now() - timedelta(hours=threshold)
stale = self.search([
('state', '=', 'in_progress'),
('date_started', '<', deadline),
('recipe_node_id.long_running', '=', False),
])
for step in stale:
step.button_pause()
step.message_post(body=Markup(
"<b>Auto-paused</b> after %.1fh idle. "
"Resume from the tablet when work continues."
) % threshold)
_logger.info("Auto-paused step %s after %.1fh idle", step.id, threshold)
```
`ir.config_parameter` key: `fp.shopfloor.autopause_threshold_hours` (default `8`).
### 6.4 ACL changes (operator group)
Per "techs wear multiple hats" rule — minimal new gates.
| Model | Read | Write | Create | Unlink | Notes |
|---|---|---|---|---|---|
| `fp.certificate` | ✓ existing | **NEW ✓** | — | — | Flip draft → issued from tablet "Issue Cert" |
| `fp.thickness.reading` | **NEW ✓** | **NEW ✓** | **NEW ✓** | — | Capture Fischerscope readings from tablet |
| `fp.job.node.override` | **NEW ✓** | — | — | — | Read-only — tech sees opt-out badge |
Supervisor-only operations enforced in `workspace_controller.py` (not via ACL):
- Step Skip (`button_skip`)
- Hold Release (state transition `on_hold``released`)
- Override Re-include
### 6.5 Terminology — `display_wo_name`
`fp.job.display_wo_name` is a computed Char that formats `name` as `WO # 00001`. All new tablet/dashboard payloads use this field. The underlying `fp.job.name` (`WH/JOB/00001`) stays unchanged — reports, emails, back-office forms continue using `name`.
System-wide sequence rename is **out of scope** for this work. If pursued separately, it requires: (a) updating `ir.sequence` prefix to `WO `, (b) backfill script for existing records, (c) coordination with any external integrations that grep on the old prefix.
## 7. Build & deploy sequence
Each phase is independently deployable. Rollback is per-phase, not all-or-nothing.
| Phase | Ships | Independently deployable? |
|---|---|---|
| **1** | Shared OWL services + JobWorkspace + workspace_controller + fp.job.step blocker_* computes + display_wo_name. Opens from existing `fp.job` form smart button. | Yes — works before Landing refactor |
| **2** | Auto-pause cron + ACL lift + `late_risk_ratio` + `active_step_id` computes | Yes — silent infra |
| **3** | landing_controller + `fp_shopfloor_landing` component. Old `fp_shopfloor_tablet` menu redirected. PlantOverview menu hidden. | Yes — Workspace already works via smart button |
| **4** | 3 new manager endpoints + manager dashboard refactor (4 tabs) + `bottleneck_score` compute | Yes |
| **5** | Cleanup: remove deprecated endpoint stubs, retire fp_plant_overview module dir | Last |
## 8. Testing strategy
### 8.1 Python tests (`fusion_plating_shopfloor/tests/`)
| Test | Verifies |
|---|---|
| `test_display_wo_name` | Formatter handles various `name` shapes |
| `test_late_risk_ratio` | Correct ratio with deadline / no deadline / overdue / not started |
| `test_active_step_id` | Sole in_progress step; empty when none; first-by-sequence when multiple |
| `test_blocker_kind_and_reason` | Each kind returns correct enum + human string + jump target |
| `test_autopause_cron` | Stale flips; chatter posted; respects `long_running`; idempotent |
| `test_workspace_load_payload` | Full payload shape — keys, types, opted-out marked |
| `test_workspace_sign_off` | Signature captured, step finished, empty-sig rejected |
| `test_workspace_advance_milestone` | Fires only when preconditions met; friendly error otherwise |
| `test_hold_composer_create` | Hold + photo + qty split; rollback on validation error |
| `test_acl_operator_permissions` | Operator can issue cert, cannot skip step (controller gate) |
| `test_funnel_and_inbox` | Funnel grouping correct; inbox returns all 4 buckets |
### 8.2 OWL tests (light)
- `WorkflowChip` renders correct color per state
- `GateViz` renders correct copy per blocker_kind
- `SignaturePad` returns non-empty data URI after stroke
- `HoldComposer` validates qty ≤ remaining before submit
- `KanbanCard` collapses chips at compact density
### 8.3 Manual QA checklist
Lives at `docs/qa/shopfloor-redesign-qa.md`. 10-step walkthrough covering: pairing → Landing modes → tap card → Workspace → Finish & Sign Off → Create Hold → Manager Funnel → Approval Inbox release → auto-pause test → dark mode.
## 9. Observability
- `_logger.INFO` on milestone advance, hold create from tablet, sign-off, auto-pause
- `_logger.WARNING` on workspace_load with bad job_id, sign_off with empty data URI
- `_logger.EXCEPTION` on controller failures (existing pattern)
- Chatter audit on auto-pause, hold create, milestone advance, sign-off
Future metrics (flagged, no infra now): tablet refresh frequency, time-to-Start from Workspace open, auto-pause rate, hold creation rate.
## 10. Edge cases
| Case | Handling |
|---|---|
| Job has zero steps | "Recipe not generated" placeholder + back-office link |
| Job has 50+ steps | Standard scroll, no virtualization in v1 |
| All steps in_progress (defensive) | `active_step_id` picks first by sequence; logs warning |
| No workflow states defined | Bar hides; Next button disabled |
| Step state changed during 15s gap | Refresh corrects; no error toast |
| Two techs tap Start simultaneously | `button_start` idempotent; second call returns state |
| Wrong station scanned | Header "Unpair" link; localStorage cleared |
| Network drop mid-action | Toast "Saving failed — tap to retry"; UI state preserved |
| 24h-bake step | `recipe_node.long_running=True` skips auto-pause |
| Customer spec PDF missing | Side panel: "No customer spec attached" |
| Funnel with 200+ jobs in stage | Top 5 cards + "View all (N)" drawer |
| Operator with no facility | Landing prompts pick-or-scan station |
| HoldComposer fails after photo upload | Photo cleaned in `except` block — no orphans |
| Manual step (no recipe_node_id) | Chips/instructions empty — OK |
| Sign-off step finished from back-office | Workspace re-renders without re-prompting |
| Tech opens Workspace for unassigned job | Allowed — read+write per "many hats" rule |
## 11. Performance
| Surface | Load shape | Mitigation |
|---|---|---|
| Landing kanban (All Plant, ~400 cards) | Existing `plant_overview.py` batch prefetch carries over | None new |
| Workspace load (1 job × ~11 steps) | Trivial | None |
| Manager funnel (~50 jobs × 9 stages) | Trivial | None |
| Manager At-Risk (7-day step state scan) | Potentially heavy on large plants | Cache 60s per facility |
| Auto-pause cron | Filtered query, no N+1 | None |
| `late_risk_ratio` (stored) | Recomputed on step state change | `@api.depends` triggers |
## 12. Rollback strategy
| Phase | Rollback |
|---|---|
| 1 (Workspace) | Hide smart-button entry in fp.job form. Workspace becomes orphan, harmless. |
| 2 (Cron + ACL) | Disable cron via UI. ACL changes are CSV-line edits. |
| 3 (Landing) | Re-enable old `fp_shopfloor_tablet` menu. Old endpoint stub still active. |
| 4 (Manager) | Revert manager_dashboard.xml to single Plant Board tab. |
| 5 (Cleanup) | Defer if issues — leave stubs longer. |
Only stored field added is `late_risk_ratio` Float — additive `ALTER TABLE`, safe to drop.
## 13. Backwards compatibility
- All existing QR codes keep working — `/fp/shopfloor/scan` unchanged
- Existing `fp.job` form smart buttons → Workspace opens via doAction
- Existing `fp.job.step` form view stays as "Open in backend" escape hatch
- Old reports/emails keep showing `WH/JOB/00001` (sequence rename deferred)
## 14. Decisions log
| Decision | Rationale |
|---|---|
| Hybrid mental model (queue → workspace), not pure queue or pure job-first | Queue is the natural entry; full workspace solves "manage the whole job" goal |
| One tablet, no role-segmentation per persona | Client said techs wear multiple hats; minimal gating |
| Architecture B (specialized components + shared services) over mega-component | Each surface has its own lifecycle; shared services enforce consistency |
| Station-scoped kanban as default landing, All Plant as toggle | Matches physical reality (tablet at station) + provides escape hatch |
| Approval Inbox + Workflow Funnel as new manager tabs, Plant Board stays | Tactical (assignment) and strategic (where is everything) views coexist |
| Auto-pause stale timers at 8h default | Solves the 411-hour ghost timer permanently; protects cost/cycle-time math |
| WO # display only; sequence rename deferred | Lower risk; user can choose system-wide rename separately |
| ACL lift for operator group on cert / thickness reading / override read | Per "techs wear many hats" rule; supervisor-only ops enforced in controller, not ACL |
## 15. Out of scope for v1
- Multi-tablet pairing per tech
- Offline-first / PWA mode
- Voice input
- Performance optimisation beyond ~500 active jobs
- Webhooks to external dashboards
- Cost roll-up per job (P2)
- Cycle time per recipe (P2)
- Notification audit feed (P2)
- Per-tech throughput (P2)
- System-wide `fp.job` sequence rename to `WO ` prefix
---
**Next step:** user reviews this spec; once approved, transition to `superpowers:writing-plans` skill to produce the phased implementation plan.

View File

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

View File

@@ -263,6 +263,16 @@ class FpProcessNode(models.Model):
'progress (e.g. paperwork or QA review that runs alongside '
'production).',
)
long_running = fields.Boolean(
string='Long-running step',
default=False,
help='When True, steps generated from this recipe node are exempt '
'from the shop-floor auto-pause cron. Use for 24h bakes, '
'multi-shift soaks, and similar legitimately-long operations '
'that would otherwise be auto-paused after the idle threshold '
'(ir.config_parameter fp.shopfloor.autopause_threshold_hours, '
'default 8h). See plan 2026-05-22-shopfloor-tablet-redesign.',
)
opt_in_out = fields.Selection(
[
('disabled', 'Required'),

View File

@@ -65,6 +65,51 @@ class FpWorkCentre(models.Model):
# field via _inherit if/when the bake-oven coupling is needed.
active = fields.Boolean(default=True)
# Phase 4 tablet redesign — Manager At-Risk heatmap inputs.
# Non-stored (recomputed on every read by /fp/manager/at_risk; the
# endpoint caches the payload for 60s anyway so the cost is bounded).
bottleneck_score = fields.Float(
compute='_compute_bottleneck',
string='Bottleneck Score',
help='active_step_count * avg_wait_minutes (rolling 7-day). '
'Drives the Manager At-Risk heatmap — work centres with '
'high score have queue + wait pressure.',
)
avg_wait_minutes = fields.Float(
compute='_compute_bottleneck',
string='Avg Wait (min)',
help='Average minutes that steps at this work centre waited in '
'ready state before starting, over the last 7 days.',
)
def _compute_bottleneck(self):
from datetime import timedelta
Step = self.env['fp.job.step']
now = fields.Datetime.now()
seven_days_ago = now - timedelta(days=7)
for wc in self:
active_n = Step.search_count([
('work_centre_id', '=', wc.id),
('state', 'in', ('ready', 'in_progress')),
])
# Avg wait: recent steps where date_started is set; approximate
# "ready since" as create_date when no explicit ready timestamp
# is recorded. Bounded set (last 7 days) keeps the search cheap.
recent = Step.search([
('work_centre_id', '=', wc.id),
('date_started', '>=', seven_days_ago),
('date_started', '!=', False),
])
waits = []
for s in recent:
if s.create_date and s.date_started:
waits.append(
(s.date_started - s.create_date).total_seconds() / 60.0
)
avg = (sum(waits) / len(waits)) if waits else 0.0
wc.avg_wait_minutes = avg
wc.bottleneck_score = active_n * avg
_sql_constraints = [
('unique_code', 'UNIQUE(code)', 'Work centre code must be unique.'),
]

View File

@@ -96,6 +96,11 @@
<field name="parallel_start"
invisible="node_type not in ('operation', 'step')"
help="When the parent recipe is Sequential, ticking this lets the step start while earlier-sequence steps are still in progress."/>
<!-- Phase 2 tablet redesign — opt out of the
auto-pause cron for legitimately-long steps
(24h bakes, multi-shift soaks). -->
<field name="long_running"
invisible="node_type not in ('operation', 'step')"/>
<field name="requires_predecessor_done"
invisible="node_type not in ('operation', 'step')"
groups="fusion_plating.group_fusion_plating_supervisor"

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.7.8.0',
'version': '19.0.7.9.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """

View File

@@ -1,8 +1,8 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_certificate_operator,fp.certificate.operator,model_fp_certificate,fusion_plating.group_fusion_plating_operator,1,1,0,0
access_fp_certificate_supervisor,fp.certificate.supervisor,model_fp_certificate,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,1,1,0
access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_thickness_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fp_certificate_operator fp.certificate.operator model_fp_certificate fusion_plating.group_fusion_plating_operator 1 0 1 0 0
3 access_fp_certificate_supervisor fp.certificate.supervisor model_fp_certificate fusion_plating.group_fusion_plating_supervisor 1 1 1 0
4 access_fp_certificate_manager fp.certificate.manager model_fp_certificate fusion_plating.group_fusion_plating_manager 1 1 1 1
5 access_fp_thickness_reading_operator fp.thickness.reading.operator model_fp_thickness_reading fusion_plating.group_fusion_plating_operator 1 0 1 0 1 0
6 access_fp_thickness_reading_supervisor fp.thickness.reading.supervisor model_fp_thickness_reading fusion_plating.group_fusion_plating_supervisor 1 1 1 0
7 access_fp_thickness_reading_manager fp.thickness.reading.manager model_fp_thickness_reading fusion_plating.group_fusion_plating_manager 1 1 1 1
8 access_fp_cert_void_wiz_sup fp.cert.void.wiz.supervisor model_fp_cert_void_wizard fusion_plating.group_fusion_plating_supervisor 1 1 1 1

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Configurator',
'version': '19.0.21.5.5',
'version': '19.0.21.7.2',
'category': 'Manufacturing/Plating',
'summary': 'Quotation configurator with part catalog, coating configs, and formula-based pricing engine.',
'description': """
@@ -56,10 +56,12 @@ Provides:
'wizard/fp_part_catalog_import_wizard_views.xml',
'wizard/fp_serial_bulk_add_wizard_views.xml',
'views/fp_configurator_menu.xml',
'views/fp_so_job_sort_views.xml',
'data/fp_sale_description_template_data.xml',
],
'assets': {
'web.assets_backend': [
'fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss',
'fusion_plating_configurator/static/src/scss/fp_3d_viewer.scss',
'fusion_plating_configurator/static/src/xml/fp_3d_viewer.xml',
'fusion_plating_configurator/static/src/js/fp_3d_viewer.js',
@@ -72,6 +74,13 @@ Provides:
'fusion_plating_configurator/static/src/xml/fp_part_process_composer.xml',
'fusion_plating_configurator/static/src/js/fp_part_process_composer.js',
],
# Register the Job Status pill SCSS in both bundles so the
# `@if $o-webclient-color-scheme == dark` branch compiles for
# the dark variant (see CLAUDE.md "Dark Mode" — Odoo 19 has no
# runtime DOM toggle, two pre-built bundles).
'web.assets_web_dark': [
'fusion_plating_configurator/static/src/scss/fp_job_status_pill.scss',
],
},
'installable': True,
'application': False,

View File

@@ -8,6 +8,7 @@ from . import fp_part_catalog
from . import fp_pricing_complexity_surcharge
from . import fp_pricing_rule
from . import fp_sale_description_template
from . import fp_so_job_sort
from . import fp_quote_configurator
from . import fp_serial
from . import sale_order

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpSoJobSort(models.Model):
"""A user-defined grouping bucket for sale orders ("Job Sorting").
Same pattern as `fusion.plating.tank.section` — every shop slices its
SO backlog differently (by customer programme, by priority, by
fabricator group, by week, etc.). Sections are free-form, renameable,
quick-creatable from the M2O dropdown, and let users group the SO
list with fold/expand sections.
"""
_name = 'fp.so.job.sort'
_description = 'Fusion Plating — Sale Order Job Sort'
_order = 'sequence, name'
name = fields.Char(
string='Job Sorting',
required=True,
translate=True,
)
sequence = fields.Integer(string='Sequence', default=10)
color = fields.Integer(string='Color', default=0)
fold = fields.Boolean(
string='Folded by Default',
help='When set, this section appears collapsed in the grouped '
'SO list so the body rows are hidden until expanded.',
)
description = fields.Text(string='Description', translate=True)
active = fields.Boolean(default=True)
sale_order_ids = fields.One2many(
'sale.order', 'x_fc_job_sort_id', string='Sale Orders',
)
sale_order_count = fields.Integer(
compute='_compute_sale_order_count',
)
@api.depends('sale_order_ids')
def _compute_sale_order_count(self):
for rec in self:
rec.sale_order_count = len(rec.sale_order_ids)
def action_view_sale_orders(self):
self.ensure_one()
return {
'name': self.name,
'type': 'ir.actions.act_window',
'res_model': 'sale.order',
'view_mode': 'list,form',
'domain': [('x_fc_job_sort_id', '=', self.id)],
'context': {'default_x_fc_job_sort_id': self.id},
}

View File

@@ -67,6 +67,28 @@ class SaleOrder(models.Model):
'Net Terms strategies.',
)
x_fc_rush_order = fields.Boolean(string='Rush Order', tracking=True)
# Lead Time (Phase D11) — promised production window in business
# days. Operators enter a min/max range (e.g. 3-5 days or 7-10 days)
# so we render a proper expectation on the SO confirmation instead
# of the binary Standard/Rush we had before. Both fields default to
# 0 — `x_fc_lead_time_display` computes the right human-readable
# string (range / single value / Rush / Standard) for the PDF.
x_fc_lead_time_min_days = fields.Integer(
string='Lead Time Min (days)', tracking=True,
help='Lower bound of the promised production lead time, in '
'business days. Leave 0 if not committed.',
)
x_fc_lead_time_max_days = fields.Integer(
string='Lead Time Max (days)', tracking=True,
help='Upper bound of the promised production lead time, in '
'business days. Leave 0 if not committed.',
)
x_fc_lead_time_display = fields.Char(
string='Lead Time',
compute='_compute_lead_time_display',
help='Human-readable lead time string for the SO confirmation PDF.',
)
x_fc_delivery_method = fields.Selection(
[('local_delivery', 'Local Delivery'), ('shipping_partner', 'Shipping Partner'),
('customer_pickup', 'Customer Pickup')],
@@ -88,6 +110,16 @@ class SaleOrder(models.Model):
help="Customer's internal job number for cross-referencing.",
tracking=True,
)
x_fc_job_sort_id = fields.Many2one(
'fp.so.job.sort',
string='Job Sorting',
ondelete='set null',
tracking=True,
help='Free-form bucket that groups this SO in the "Sale Orders '
'by Sorting" list view. Quick-create from the dropdown — '
'each shop slices its backlog differently (customer programme, '
'priority, week, etc.).',
)
x_fc_planned_start_date = fields.Date(
string='Planned Start Date', tracking=True,
)
@@ -129,6 +161,16 @@ class SaleOrder(models.Model):
string='Deadline',
compute='_compute_deadline_countdown',
)
# Drives the colour of the Deadline column. Computed in the same pass
# as x_fc_deadline_countdown so the buckets always agree with the
# human-readable countdown string.
x_fc_deadline_urgency = fields.Selection(
[('overdue', 'Overdue'),
('urgent', 'Due within 2 days'),
('safe', 'More than 2 days')],
string='Deadline Urgency',
compute='_compute_deadline_countdown',
)
x_fc_order_completion_date = fields.Date(
string='Order Completion Date',
compute='_compute_order_completion_date',
@@ -241,6 +283,157 @@ class SaleOrder(models.Model):
compute='_compute_invoiced_amount',
currency_field='currency_id',
)
# Single "Job Status" pill rendered in the SO list. Pipeline order:
# Draft → Awaiting Parts → Parts Partial → Ready to Start →
# <Step Name> → Ready to Ship → Ship Booked → In Transit →
# Delivered → Invoiced → Paid → Cancelled.
# Rendered as an Html field so each kind can carry its own tint via
# an .fp-kind-* class — Bootstrap's 5 decoration-* slots aren't
# enough to give every phase a distinct colour. SCSS bundle at
# static/src/scss/fp_job_status_pill.scss owns the colour map.
x_fc_fp_job_status = fields.Html(
string='Job Status',
compute='_compute_fp_job_status',
sanitize=False,
help='Single at-a-glance pill that advances through the order '
'lifecycle: receiving → WO progress → shipping → invoicing.',
)
x_fc_fp_job_status_kind = fields.Selection(
[('muted', 'Draft (grey)'),
('warning', 'Awaiting / Partial (amber)'),
('primary', 'Ready / Milestone (purple)'),
('info', 'Active Work (blue)'),
('shipping', 'Shipping (cyan)'),
('delivered', 'Delivered (teal)'),
('invoiced', 'Invoiced (lime)'),
('paid', 'Paid (green bold)'),
('danger', 'Cancelled (red)')],
string='Job Status Kind',
compute='_compute_fp_job_status',
help='Colour category that backs the Job Status pill — also '
'usable for filtering / grouping in the list search panel.',
)
@api.depends(
'state',
'x_fc_receiving_status',
'x_fc_wo_completion',
'invoice_ids.state',
'invoice_ids.payment_state',
'invoice_ids.move_type',
)
def _compute_fp_job_status(self):
from markupsafe import Markup as _Markup
from markupsafe import escape as _escape
for so in self:
label, kind = self._fp_resolve_job_status(so)
so.x_fc_fp_job_status_kind = kind
so.x_fc_fp_job_status = _Markup(
'<span class="fp-job-status fp-kind-%s">%s</span>'
) % (_Markup(kind), _escape(label))
@staticmethod
def _fp_resolve_job_status(so):
# Terminal SO states first.
if so.state == 'cancel':
return ('Cancelled', 'danger')
if so.state in ('draft', 'sent'):
return ('Draft', 'muted')
# Invoice phase (terminal positive states).
posted = so.invoice_ids.filtered(
lambda m: m.state == 'posted'
and m.move_type in ('out_invoice', 'out_refund')
)
if posted and all(
m.payment_state in ('paid', 'in_payment') for m in posted
):
return ('Paid', 'paid')
# Shipping phase signals — read once.
ship_status = None
if 'x_fc_receiving_ids' in so._fields:
for r in so.x_fc_receiving_ids:
ship = (
r.x_fc_outbound_shipment_id
if 'x_fc_outbound_shipment_id' in r._fields else False
)
if not ship:
continue
# Latch the most-advanced status across all receivings.
rank = {None: 0, 'booked': 1, 'in_transit': 2, 'delivered': 3}
cur = (
'delivered' if ship.status == 'delivered'
else 'in_transit' if ship.status == 'shipped'
else 'booked' if ship.status in ('confirmed', 'draft')
else None
)
if rank[cur] > rank[ship_status]:
ship_status = cur
if posted and ship_status == 'delivered':
return ('Invoiced', 'invoiced')
if ship_status == 'delivered':
return ('Delivered', 'delivered')
if ship_status == 'in_transit':
return ('In Transit', 'shipping')
# WO phase — figure out total steps and the current step name.
tot = 0
current_step_name = None
Job = so.env.get('fp.job')
if Job is not None and so.name:
jobs = Job.sudo().search([('origin', '=', so.name)])
if jobs:
steps = jobs.mapped('step_ids').sorted(
lambda s: (s.job_id.id, s.sequence)
)
tot = len(steps)
# Priority: in_progress → paused → next ready/pending.
current = (
steps.filtered(lambda s: s.state == 'in_progress')[:1]
or steps.filtered(lambda s: s.state == 'paused')[:1]
or steps.filtered(lambda s: s.state in ('ready', 'pending'))[:1]
)
current_step_name = current.name if current else None
all_steps_done = tot > 0 and current_step_name is None
if all_steps_done:
if ship_status == 'booked':
return ('Ship Booked', 'shipping')
return ('Ready to Ship', 'primary')
if current_step_name:
return (current_step_name, 'info')
# Receiving phase (no WO yet).
recv = so.x_fc_receiving_status or 'not_received'
if recv == 'received':
return ('Ready to Start', 'primary')
if recv == 'partial':
return ('Parts Partial', 'warning')
return ('Awaiting Parts', 'warning')
@api.depends('x_fc_lead_time_min_days', 'x_fc_lead_time_max_days', 'x_fc_rush_order')
def _compute_lead_time_display(self):
"""Render the lead time as a human-readable string for reports.
Priority order:
- Real min/max range set → "X-Y days" or "X days"
- Range not set, rush_order on → "Rush"
- Otherwise → "Standard"
"""
for so in self:
mn = so.x_fc_lead_time_min_days or 0
mx = so.x_fc_lead_time_max_days or 0
if mn and mx and mn != mx:
so.x_fc_lead_time_display = '%d-%d days' % (min(mn, mx), max(mn, mx))
elif mx or mn:
so.x_fc_lead_time_display = '%d days' % (mx or mn)
elif so.x_fc_rush_order:
so.x_fc_lead_time_display = 'Rush'
else:
so.x_fc_lead_time_display = 'Standard'
@api.depends('name')
def _compute_wo_completion(self):
@@ -493,9 +686,11 @@ class SaleOrder(models.Model):
def _compute_deadline_countdown(self):
from datetime import datetime
now = fields.Datetime.now()
TWO_DAYS = 2 * 86400 # seconds threshold for "urgent"
for rec in self:
if not rec.commitment_date:
rec.x_fc_deadline_countdown = False
rec.x_fc_deadline_urgency = False
continue
target = rec.commitment_date
if isinstance(target, datetime):
@@ -506,12 +701,13 @@ class SaleOrder(models.Model):
secs = int(delta.total_seconds())
if secs == 0:
rec.x_fc_deadline_countdown = 'due now'
rec.x_fc_deadline_urgency = 'overdue'
continue
past = secs < 0
secs = abs(secs)
days = secs // 86400
hours = (secs % 86400) // 3600
mins = (secs % 3600) // 60
abs_secs = abs(secs)
days = abs_secs // 86400
hours = (abs_secs % 86400) // 3600
mins = (abs_secs % 3600) // 60
bits = []
if days:
bits.append('%dd' % days)
@@ -523,6 +719,12 @@ class SaleOrder(models.Model):
rec.x_fc_deadline_countdown = (
'overdue %s' % phrase if past else 'in %s' % phrase
)
if past:
rec.x_fc_deadline_urgency = 'overdue'
elif secs <= TWO_DAYS:
rec.x_fc_deadline_urgency = 'urgent'
else:
rec.x_fc_deadline_urgency = 'safe'
@api.depends(
'order_line.x_fc_effective_part_deadline',

View File

@@ -42,3 +42,5 @@ access_fp_part_revision_bump_manager,fp.part.revision.bump.manager,model_fp_part
access_fp_part_material_user,fp.part.material.user,model_fp_part_material,base.group_user,1,0,0,0
access_fp_part_material_estimator,fp.part.material.estimator,model_fp_part_material,fusion_plating_configurator.group_fp_estimator,1,1,1,0
access_fp_part_material_manager,fp.part.material.manager,model_fp_part_material,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_so_job_sort_user,fp.so.job.sort.user,model_fp_so_job_sort,base.group_user,1,1,1,0
access_fp_so_job_sort_manager,fp.so.job.sort.manager,model_fp_so_job_sort,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
42 access_fp_part_material_user fp.part.material.user model_fp_part_material base.group_user 1 0 0 0
43 access_fp_part_material_estimator fp.part.material.estimator model_fp_part_material fusion_plating_configurator.group_fp_estimator 1 1 1 0
44 access_fp_part_material_manager fp.part.material.manager model_fp_part_material fusion_plating.group_fusion_plating_manager 1 1 1 1
45 access_fp_so_job_sort_user fp.so.job.sort.user model_fp_so_job_sort base.group_user 1 1 1 0
46 access_fp_so_job_sort_manager fp.so.job.sort.manager model_fp_so_job_sort fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,68 @@
// =============================================================================
// Fusion Plating — Job Status pill on the SO list
// Copyright 2026 Nexa Systems Inc. · License OPL-1
//
// One pill per row, one colour per phase, vibrant + saturated so phases
// pop at a glance against both the light and dark Odoo bundles. Same
// hue map for both modes — saturated 500-level Tailwind hues with white
// text give consistent contrast against either page background.
// =============================================================================
// ----- Vibrant tints (light + dark) -----
$_fp-muted-bg : #6b7280; // slate
$_fp-warning-bg : #f59e0b; // amber
$_fp-primary-bg : #8b5cf6; // violet
$_fp-info-bg : #3b82f6; // blue
$_fp-shipping-bg : #06b6d4; // cyan
$_fp-delivered-bg : #14b8a6; // teal
$_fp-invoiced-bg : #84cc16; // lime
$_fp-paid-bg : #16a34a; // green
$_fp-danger-bg : #ef4444; // red
// Matching glow shadows — darker tone of the same hue for a subtle
// drop-shadow that gives the pill a "lifted" feel without being noisy.
$_fp-muted-glow : rgba(31, 41, 55, 0.35);
$_fp-warning-glow : rgba(180, 83, 9, 0.45);
$_fp-primary-glow : rgba(91, 33, 182, 0.45);
$_fp-info-glow : rgba(29, 78, 216, 0.45);
$_fp-shipping-glow : rgba(14, 116, 144, 0.45);
$_fp-delivered-glow : rgba(15, 118, 110, 0.45);
$_fp-invoiced-glow : rgba(101, 163, 13, 0.45);
$_fp-paid-glow : rgba(21, 128, 61, 0.5);
$_fp-danger-glow : rgba(185, 28, 28, 0.45);
// =============================================================================
// Pill base
// =============================================================================
.fp-job-status {
display: inline-block;
padding: 0.4em 0.95em;
border-radius: 999px;
font-weight: 600;
font-size: 0.82em;
line-height: 1.25;
letter-spacing: 0.015em;
white-space: nowrap;
text-align: center;
min-width: 72px;
color: #ffffff !important;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.15);
}
// =============================================================================
// Per-kind tints — same map applies to light + dark bundles. White text
// gives consistent contrast against any saturated mid-tone hue.
// =============================================================================
.fp-kind-muted { background-color: $_fp-muted-bg; box-shadow: 0 1px 3px $_fp-muted-glow; }
.fp-kind-warning { background-color: $_fp-warning-bg; box-shadow: 0 1px 3px $_fp-warning-glow; }
.fp-kind-primary { background-color: $_fp-primary-bg; box-shadow: 0 1px 3px $_fp-primary-glow; }
.fp-kind-info { background-color: $_fp-info-bg; box-shadow: 0 1px 3px $_fp-info-glow; }
.fp-kind-shipping { background-color: $_fp-shipping-bg; box-shadow: 0 1px 3px $_fp-shipping-glow; }
.fp-kind-delivered { background-color: $_fp-delivered-bg; box-shadow: 0 1px 3px $_fp-delivered-glow; }
.fp-kind-invoiced { background-color: $_fp-invoiced-bg; box-shadow: 0 1px 3px $_fp-invoiced-glow; }
.fp-kind-paid {
background-color: $_fp-paid-bg;
box-shadow: 0 1px 4px $_fp-paid-glow;
font-weight: 700;
}
.fp-kind-danger { background-color: $_fp-danger-bg; box-shadow: 0 1px 3px $_fp-danger-glow; }

View File

@@ -0,0 +1,261 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Job Sorting:
- Section model views (list/form) under Configuration → Sales.
- Alternate SO list ("Sale Orders by Sorting") grouped by job sort
with foldable sections and create-from-here support.
-->
<odoo>
<!-- ===== Section management (Configuration) ===== -->
<record id="view_fp_so_job_sort_list" model="ir.ui.view">
<field name="name">fp.so.job.sort.list</field>
<field name="model">fp.so.job.sort</field>
<field name="arch" type="xml">
<list string="Job Sorting" editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="color" widget="color_picker"/>
<field name="fold" widget="boolean_toggle"/>
<field name="sale_order_count"/>
<field name="active" widget="boolean_toggle" optional="hide"/>
</list>
</field>
</record>
<record id="view_fp_so_job_sort_form" model="ir.ui.view">
<field name="name">fp.so.job.sort.form</field>
<field name="model">fp.so.job.sort</field>
<field name="arch" type="xml">
<form string="Job Sorting">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_sale_orders" type="object"
class="oe_stat_button" icon="fa-shopping-cart">
<field name="sale_order_count" widget="statinfo"
string="Sale Orders"/>
</button>
</div>
<div class="oe_title">
<label for="name"/>
<h1><field name="name" placeholder="e.g. Rush Orders"/></h1>
</div>
<group>
<group>
<field name="sequence"/>
<field name="color" widget="color_picker"/>
<field name="fold"/>
</group>
<group>
<field name="active"/>
</group>
</group>
<field name="description"
placeholder="What kinds of orders belong in this section?"/>
</sheet>
</form>
</field>
</record>
<record id="action_fp_so_job_sort" model="ir.actions.act_window">
<field name="name">Job Sorting</field>
<field name="res_model">fp.so.job.sort</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fp_so_job_sort"
name="Job Sorting"
parent="fusion_plating.menu_fp_config_pricing_billing"
action="action_fp_so_job_sort"
sequence="25"/>
<!-- ===== Kanban grouped by Job Sorting =====
Groups SOs into foldable columns by x_fc_job_sort_id.
Drag-drop between columns rewrites the bucket; quick-create on
the column header creates a new fp.so.job.sort row. Wired into
the existing Sale Orders action below so it shows up in the
view-switcher next to the flat list. -->
<record id="view_sale_order_kanban_fp_by_sorting" model="ir.ui.view">
<field name="name">sale.order.kanban.fp.by_sorting</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<kanban default_group_by="x_fc_job_sort_id"
group_create="true"
group_edit="true"
group_delete="true"
quick_create="false"
sample="1">
<field name="name"/>
<field name="partner_id"/>
<field name="amount_total"/>
<field name="currency_id"/>
<field name="x_fc_part_numbers_summary"/>
<field name="x_fc_customer_job_number"/>
<field name="x_fc_deadline_countdown"/>
<field name="x_fc_deadline_urgency"/>
<field name="x_fc_fp_job_status"/>
<field name="state"/>
<templates>
<t t-name="card">
<div class="o_kanban_card_content p-2">
<div class="d-flex justify-content-between align-items-start mb-1">
<strong><field name="name"/></strong>
<span t-att-class="'badge ' + (
record.x_fc_deadline_urgency.raw_value == 'overdue' and 'text-bg-danger' or
record.x_fc_deadline_urgency.raw_value == 'urgent' and 'text-bg-warning' or
record.x_fc_deadline_urgency.raw_value == 'safe' and 'text-bg-success' or
'text-bg-light')"
t-if="record.x_fc_deadline_countdown.raw_value">
<field name="x_fc_deadline_countdown"/>
</span>
</div>
<div class="text-muted small mb-1">
<field name="partner_id"/>
</div>
<div class="small mb-1" t-if="record.x_fc_part_numbers_summary.raw_value">
<i class="fa fa-cube me-1"/>
<field name="x_fc_part_numbers_summary"/>
</div>
<div class="small mb-2" t-if="record.x_fc_customer_job_number.raw_value">
<i class="fa fa-hashtag me-1"/>
<field name="x_fc_customer_job_number"/>
</div>
<div class="d-flex justify-content-between align-items-center">
<field name="x_fc_fp_job_status" widget="html"/>
<strong>
<field name="amount_total" widget="monetary"
options="{'currency_field': 'currency_id'}"/>
</strong>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===== Sale Orders by Sorting (alternate SO list) ===== -->
<!-- Duplicate of view_sale_order_list_fp but renamed and intended
to be opened with group_by=x_fc_job_sort_id by default so the
user sees foldable sections per Job Sorting bucket. -->
<record id="view_sale_order_list_fp_by_sorting" model="ir.ui.view">
<field name="name">sale.order.list.fp.by_sorting</field>
<field name="model">sale.order</field>
<field name="priority">99</field>
<field name="arch" type="xml">
<list string="Sale Orders by Sorting" create="0"
decoration-info="state == 'draft'"
decoration-muted="state == 'cancel'"
decoration-danger="x_fc_is_late_forecast">
<header>
<button name="%(action_fp_direct_order_wizard)d"
type="action"
string="New Order"
class="btn-primary"
display="always"/>
</header>
<field name="name" optional="show"/>
<field name="partner_id" optional="show"/>
<field name="x_fc_po_number" optional="show"/>
<field name="x_fc_customer_job_number" optional="show"/>
<field name="x_fc_job_sort_id" optional="show"
options="{'no_create_edit': False, 'no_open': True}"/>
<field name="x_fc_internal_deadline" optional="show"/>
<field name="commitment_date" string="Customer Deadline"
optional="show"/>
<field name="x_fc_order_completion_date" string="Completion"
optional="show"/>
<field name="x_fc_is_late_forecast" optional="hide"
widget="boolean_toggle"/>
<field name="x_fc_deadline_urgency" column_invisible="1"/>
<field name="x_fc_deadline_countdown" optional="show"
decoration-danger="x_fc_deadline_urgency == 'overdue'"
decoration-warning="x_fc_deadline_urgency == 'urgent'"
decoration-success="x_fc_deadline_urgency == 'safe'"/>
<field name="x_fc_wo_completion" optional="show"/>
<field name="x_fc_planned_start_date" optional="hide"/>
<field name="x_fc_part_numbers_summary" string="Part"
optional="show"/>
<field name="amount_total" sum="Total" optional="show"/>
<field name="x_fc_invoiced_amount" sum="Invoiced"
optional="hide"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
<field name="x_fc_fp_job_status" widget="html"
string="Job Status" optional="show" readonly="1"/>
<field name="x_fc_receiving_status" widget="badge"
optional="hide"
decoration-warning="x_fc_receiving_status == 'not_received'"
decoration-info="x_fc_receiving_status == 'partial'"
decoration-success="x_fc_receiving_status == 'received'"/>
<field name="x_fc_delivery_method" optional="hide"/>
<field name="currency_id" column_invisible="1"/>
<field name="state" widget="badge" optional="show"/>
</list>
</field>
</record>
<!-- Search view for the alternate list: surface "Group by Job
Sorting" as a search-default filter. -->
<record id="view_sale_order_search_fp_by_sorting" model="ir.ui.view">
<field name="name">sale.order.search.fp.by_sorting</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<search string="Sale Orders by Sorting">
<field name="name"/>
<field name="partner_id"/>
<field name="x_fc_part_numbers_summary" string="Part"/>
<field name="x_fc_customer_job_number"/>
<field name="x_fc_po_number"/>
<field name="x_fc_job_sort_id"/>
<filter name="late_forecast" string="Late Forecast"
domain="[('x_fc_is_late_forecast','=',True)]"/>
<filter name="cancelled" string="Cancelled"
domain="[('state','=','cancel')]"/>
<separator/>
<group>
<filter name="group_by_job_sort"
string="Job Sorting"
context="{'group_by': 'x_fc_job_sort_id'}"/>
<filter name="group_by_customer"
string="Customer"
context="{'group_by': 'partner_id'}"/>
<filter name="group_by_state"
string="Status"
context="{'group_by': 'state'}"/>
</group>
</search>
</field>
</record>
<!-- Append the kanban view to the existing Sale Orders action so
users can switch from the flat list to the grouped-by-sorting
kanban (foldable columns, drag-drop bucket reassignment) via
the view-switcher icon in the top-right of the SO list. -->
<record id="action_fp_sale_orders" model="ir.actions.act_window">
<field name="view_mode">list,kanban,form</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'list', 'view_id': ref('view_sale_order_list_fp')}),
(0, 0, {'view_mode': 'kanban', 'view_id': ref('view_sale_order_kanban_fp_by_sorting')})]"/>
</record>
<record id="action_fp_sale_orders_by_sorting" model="ir.actions.act_window">
<field name="name">Sale Orders (by Sorting)</field>
<field name="res_model">sale.order</field>
<field name="view_mode">list,form</field>
<field name="view_id" ref="view_sale_order_list_fp_by_sorting"/>
<field name="search_view_id" ref="view_sale_order_search_fp_by_sorting"/>
<field name="domain">[('state', 'not in', ('draft', 'sent'))]</field>
<field name="context">{'search_default_group_by_job_sort': 1}</field>
</record>
<menuitem id="menu_fp_sale_orders_by_sorting"
name="Sale Orders (by Sorting)"
parent="fusion_plating_configurator.menu_fp_sales"
action="action_fp_sale_orders_by_sorting"
sequence="12"/>
</odoo>

View File

@@ -123,6 +123,15 @@
<field name="commitment_date" string="Delivery Date"
readonly="state in ('cancel',)"/>
</xpath>
<!-- Job Sorting sits right under Payment Terms — a free-form
bucket that groups the SO in the "Sale Orders by Sorting"
list. Quick-create from the dropdown. -->
<xpath expr="//group[@name='order_details']/field[@name='payment_term_id']" position="after">
<field name="x_fc_job_sort_id"
options="{'no_create_edit': False, 'no_open': True}"
placeholder="Type to create a new bucket..."/>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Plating" name="plating_tab">
<!-- Multi-part summary: read-only list of every order line
@@ -201,6 +210,16 @@
</div>
<field name="x_fc_is_blanket_order"/>
<field name="x_fc_block_partial_shipments"/>
<!-- Lead Time range. Both 0 = "Standard" on
the PDF; otherwise renders "X-Y days"
(or "X days" if min==max or one is 0). -->
<label for="x_fc_lead_time_min_days" string="Lead Time (days)"/>
<div class="o_row">
<field name="x_fc_lead_time_min_days" class="oe_inline" style="width: 4em;"/>
<span> to </span>
<field name="x_fc_lead_time_max_days" class="oe_inline" style="width: 4em;"/>
</div>
<field name="x_fc_lead_time_display" readonly="1"/>
</group>
</group>
@@ -358,19 +377,29 @@
class="btn-primary"
display="always"/>
</header>
<field name="name"/>
<field name="partner_id"/>
<field name="x_fc_po_number"/>
<field name="name" optional="show"/>
<field name="partner_id" optional="show"/>
<field name="x_fc_po_number" optional="show"/>
<field name="x_fc_customer_job_number" optional="show"/>
<field name="x_fc_internal_deadline" optional="show"/>
<field name="commitment_date" string="Customer Deadline" optional="show"/>
<field name="x_fc_order_completion_date" string="Completion" optional="show"/>
<field name="x_fc_is_late_forecast" optional="hide" widget="boolean_toggle"/>
<field name="x_fc_deadline_countdown" optional="show"/>
<field name="x_fc_deadline_urgency" column_invisible="1"/>
<field name="x_fc_deadline_countdown" optional="show"
decoration-danger="x_fc_deadline_urgency == 'overdue'"
decoration-warning="x_fc_deadline_urgency == 'urgent'"
decoration-success="x_fc_deadline_urgency == 'safe'"/>
<field name="x_fc_wo_completion" optional="show"/>
<field name="x_fc_planned_start_date" optional="hide"/>
<field name="x_fc_part_catalog_id" optional="hide"/>
<field name="amount_total" sum="Total"/>
<!-- "Part" column — walks order_line.x_fc_part_catalog_id
and shows a compact summary (e.g. "M1234, M5678
(+3 more)"). The header x_fc_part_catalog_id field
is rarely populated in the configurator flow; the
line carries the authoritative part link. -->
<field name="x_fc_part_numbers_summary" string="Part"
optional="show"/>
<field name="amount_total" sum="Total" optional="show"/>
<field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
@@ -380,12 +409,21 @@
<field name="x_fc_margin_percent" optional="hide"
widget="percentage"/>
<field name="x_fc_is_blanket_order" optional="hide"/>
<!-- Single Job Status pill. Renders as HTML with a
per-kind class (.fp-kind-*) so every phase carries
its own distinct tint — see
static/src/scss/fp_job_status_pill.scss. -->
<field name="x_fc_fp_job_status" widget="html"
string="Job Status" optional="show"
readonly="1"/>
<field name="x_fc_receiving_status" widget="badge"
optional="hide"
decoration-warning="x_fc_receiving_status == 'not_received'"
decoration-info="x_fc_receiving_status == 'partial'"
decoration-success="x_fc_receiving_status == 'received'"/>
<field name="x_fc_delivery_method" optional="hide"/>
<field name="currency_id" column_invisible="1"/>
<field name="state" widget="badge"/>
<field name="state" widget="badge" optional="show"/>
</list>
</field>
</record>
@@ -455,8 +493,8 @@
<field name="model">sale.order</field>
<field name="arch" type="xml">
<list string="Quotations" decoration-muted="state == 'cancel'">
<field name="name"/>
<field name="partner_id"/>
<field name="name" optional="show"/>
<field name="partner_id" optional="show"/>
<field name="x_fc_part_numbers_summary" optional="show"/>
<field name="x_fc_po_number" optional="hide"/>
<field name="x_fc_customer_job_number" optional="hide"/>
@@ -464,15 +502,16 @@
<field name="validity_date" string="Expires" optional="show"/>
<field name="x_fc_follow_up_date" optional="show"/>
<field name="x_fc_follow_up_user_id" optional="show"/>
<field name="amount_total" sum="Total"/>
<field name="amount_total" sum="Total" optional="show"/>
<field name="x_fc_is_signed" widget="boolean_toggle"
string="Signed" optional="show"/>
<field name="x_fc_email_status" widget="badge"
optional="show"
decoration-info="x_fc_email_status == 'sent'"
decoration-warning="x_fc_email_status == 'opened'"
decoration-success="x_fc_email_status == 'won'"/>
<field name="currency_id" column_invisible="1"/>
<field name="state" widget="badge"/>
<field name="state" widget="badge" optional="show"/>
</list>
</field>
</record>
@@ -572,7 +611,10 @@
</field>
</record>
<!-- ===== Window Action — Confirmed Sale Orders ===== -->
<!-- ===== Window Action — Confirmed Sale Orders =====
The kanban view_mode + kanban view_id are appended in
fp_so_job_sort_views.xml after the kanban view is defined, so
we don't hit a missing-ref at module load. -->
<record id="action_fp_sale_orders" model="ir.actions.act_window">
<field name="name">Sale Orders</field>
<field name="res_model">sale.order</field>

View File

@@ -79,6 +79,13 @@ class FpDirectOrderWizard(models.Model):
help="Customer's internal job number for cross-referencing. "
"Appears on work orders and invoices.",
)
job_sort_id = fields.Many2one(
'fp.so.job.sort',
string='Job Sorting',
help='Free-form bucket that groups the new SO in the '
'"Sale Orders by Sorting" list. Type a new label in the '
'dropdown to create a section on the fly.',
)
# ---- Scheduling ----
planned_start_date = fields.Date(
@@ -86,6 +93,11 @@ class FpDirectOrderWizard(models.Model):
)
internal_deadline = fields.Date(string='Internal Deadline')
customer_deadline = fields.Date(string='Customer Deadline', tracking=True)
# Lead Time — promised production window. Mirrors directly to
# x_fc_lead_time_min_days / x_fc_lead_time_max_days on the SO via
# _prepare_order_vals. Leaving both 0 = Standard (no commitment).
lead_time_min_days = fields.Integer(string='Lead Time Min (days)')
lead_time_max_days = fields.Integer(string='Lead Time Max (days)')
# ---- Order flags (Phase B) ----
is_blanket_order = fields.Boolean(
@@ -528,8 +540,11 @@ class FpDirectOrderWizard(models.Model):
'x_fc_po_pending': self.po_pending,
'x_fc_po_expected_date': self.po_expected_date or False,
'x_fc_customer_job_number': self.customer_job_number or False,
'x_fc_job_sort_id': self.job_sort_id.id or False,
'x_fc_planned_start_date': self.planned_start_date,
'x_fc_internal_deadline': self.internal_deadline,
'x_fc_lead_time_min_days': self.lead_time_min_days or 0,
'x_fc_lead_time_max_days': self.lead_time_max_days or 0,
# commitment_date is a Datetime; customer_deadline is a Date.
# Assigning a bare Date stores midnight UTC, which renders as
# the PREVIOUS day in any negative-UTC timezone (Eastern shifts

View File

@@ -70,6 +70,9 @@
options="{'no_create_edit': True}"
invisible="not partner_id"/>
<field name="customer_job_number"/>
<field name="job_sort_id"
options="{'no_create_edit': False, 'no_open': True}"
placeholder="Type to create a new bucket..."/>
</group>
<group string="Purchase Order">
<field name="po_number"
@@ -102,6 +105,14 @@
still `customer_deadline` (wizard) →
`commitment_date` (SO). -->
<field name="customer_deadline" string="Delivery Date"/>
<!-- Lead time range (min/max business days).
Both 0 = "Standard" on the SO confirm PDF. -->
<label for="lead_time_min_days" string="Lead Time (days)"/>
<div class="o_row">
<field name="lead_time_min_days" class="oe_inline" style="width: 4em;"/>
<span> to </span>
<field name="lead_time_max_days" class="oe_inline" style="width: 4em;"/>
</div>
<field name="is_blanket_order"/>
<field name="block_partial_shipments"/>
</group>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.10.16.9',
'version': '19.0.10.20.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
@@ -57,11 +57,11 @@ full design rationale and §6.2 of the implementation plan for task list.
# so the statusbar's m2o has its targets available at view-render time).
'data/fp_workflow_state_data.xml',
'views/fp_workflow_state_views.xml',
'views/res_config_settings_views.xml',
'views/fp_job_step_quick_look_views.xml',
'views/fp_job_form_inherit.xml',
'views/fp_job_quality_buttons.xml',
'views/sale_order_views.xml',
'views/fp_receiving_views.xml',
'views/fp_certificate_views.xml',
'views/fp_job_consumption_views.xml',
'views/fp_step_priority_views.xml',

View File

@@ -31,4 +31,19 @@
<field name="interval_type">hours</field>
<field name="active" eval="True"/>
</record>
<!-- Phase 2 tablet redesign — actual auto-pause (not just nudge).
Flips in_progress steps idle > N hours to paused with chatter
audit. Threshold configurable via ir.config_parameter
`fp.shopfloor.autopause_threshold_hours` (default 8.0). Recipe
nodes can opt out via long_running=True (e.g. 24h bakes). -->
<record id="ir_cron_autopause_stale_steps" model="ir.cron">
<field name="name">Fusion Plating: Auto-pause stale in-progress steps</field>
<field name="model_id" ref="fusion_plating.model_fp_job_step"/>
<field name="state">code</field>
<field name="code">model._cron_autopause_stale_steps()</field>
<field name="interval_number">30</field>
<field name="interval_type">minutes</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -22,6 +22,7 @@ from . import fp_certificate
from . import fp_thickness_reading
from . import fp_delivery
from . import fp_racking_inspection
from . import fp_receiving
# Phase 4 — light refactors batch B (notifications, KPI source tag).
from . import fp_notification_trigger

View File

@@ -137,10 +137,13 @@ class AccountMove(models.Model):
if not job or not job.portal_job_id:
return
portal = job.portal_job_id
if 'state' in portal._fields:
portal.state = 'complete'
if 'invoice_ref' in portal._fields:
portal.invoice_ref = self.name
# Recompute state via the central helper — it'll only land on
# 'complete' if the WO is actually done AND the shipment is
# delivered. Posting an invoice early no longer skips the floor.
if hasattr(portal, '_fp_recompute_portal_state'):
portal._fp_recompute_portal_state()
_logger.info(
'Invoice %s linked to fp.job %s portal %s',
self.name, job.name, portal.name,

View File

@@ -97,6 +97,81 @@ class FpJob(models.Model):
'idempotency. Cleared post-cutover.',
)
# Display formatter — "WO # 00001" used everywhere on tablet/dashboard.
# The underlying `name` field stays untouched (WH/JOB/00001) so reports,
# emails, and back-office forms continue using their canonical name.
# System-wide sequence rename is a separate decision (see spec
# 2026-05-22-shopfloor-tablet-redesign-design §6.5).
display_wo_name = fields.Char(
compute='_compute_display_wo_name',
string='WO #',
help='Tablet/dashboard formatter — "WO # 00001" derived from name. '
'Underlying name field is unchanged.',
)
@api.depends('name')
def _compute_display_wo_name(self):
for job in self:
raw = (job.name or '').strip()
if not raw:
job.display_wo_name = ''
continue
# Take the last "/"-separated segment as the number portion.
# WH/JOB/00001 → 00001 ; WH/JOB/2026/00042 → 00042 ; 00123 → 00123
tail = raw.rsplit('/', 1)[-1]
job.display_wo_name = f'WO # {tail}'
# Phase 2 — At-Risk view + Workspace landing focus.
late_risk_ratio = fields.Float(
compute='_compute_late_risk_ratio',
store=True,
string='Late-risk Ratio',
help='remaining_planned_minutes / minutes_to_deadline. '
'>1.0 means the job will be late if nothing changes. '
'Drives the At-Risk view on the manager dashboard.',
)
active_step_id = fields.Many2one(
'fp.job.step',
compute='_compute_active_step_id',
string='Active Step',
help='Currently in-progress step (lowest sequence if multiple — '
'defensive). Drives JobWorkspace landing focus.',
)
@api.depends(
'date_deadline',
'step_ids.state',
'step_ids.duration_expected',
)
def _compute_late_risk_ratio(self):
from datetime import datetime
for job in self:
if not job.date_deadline:
job.late_risk_ratio = 0.0
continue
open_steps = job.step_ids.filtered(
lambda s: s.state not in ('done', 'skipped', 'cancelled')
)
remaining_planned = sum(open_steps.mapped('duration_expected') or [0])
if remaining_planned <= 0:
job.late_risk_ratio = 0.0
continue
now = datetime.now()
# date_deadline is naive UTC in Odoo; compare directly
minutes_to_deadline = max(
1.0,
(job.date_deadline - now).total_seconds() / 60.0,
)
job.late_risk_ratio = remaining_planned / minutes_to_deadline
@api.depends('step_ids.state', 'step_ids.sequence')
def _compute_active_step_id(self):
for job in self:
active = job.step_ids.filtered(
lambda s: s.state == 'in_progress'
).sorted('sequence')
job.active_step_id = active[:1].id if active else False
# ------------------------------------------------------------------
# Sub 14 — Configurable workflow state (status bar milestone)
# ------------------------------------------------------------------
@@ -552,6 +627,22 @@ class FpJob(models.Model):
'context': {'default_job_id': self.id},
}
def action_open_workspace(self):
"""Open the JobWorkspace OWL client action focused on this job.
Spec: 2026-05-22-shopfloor-tablet-redesign — Phase 1 deliverable.
Used as the smart-button entry point before the Landing kanban
(Phase 3) is shipped, and stays as a back-office shortcut after.
"""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fp_job_workspace',
'name': self.display_wo_name or self.name,
'params': {'job_id': self.id},
'target': 'current',
}
def action_finish_current_step(self):
"""Steelhead-style header button: finish whatever's currently
in_progress and auto-start the next pending/ready step. If
@@ -745,16 +836,10 @@ class FpJob(models.Model):
'name': self.portal_job_id.name,
}
# fp.job.state -> fusion.plating.portal.job.state mapping. Kept tight so
# the customer doesn't see internal states. Anything not in this map
# leaves the portal_job state alone (e.g. 'on_hold' stays in_progress).
_FP_JOB_STATE_TO_PORTAL_STATE = {
'confirmed': 'received',
'in_progress': 'in_progress',
'done': 'ready_to_ship',
# 'on_hold' and 'cancelled' intentionally omitted — managers choose
# what to surface to the customer.
}
# Sub-portal state sync — see fusion_plating_portal/.../fp_portal_job.py
# `_fp_recompute_portal_state` for the rules. The mapping table that
# used to live here was replaced by the helper so shipment / invoice
# signals can't drift away from the WO state any more.
def write(self, vals):
"""Write hook: (a) when qty_scrapped INCREASES, auto-spawn a
@@ -783,13 +868,13 @@ class FpJob(models.Model):
if job.state != new_state:
state_changed_ids.add(job.id)
result = super().write(vals)
# Mirror state to portal_job for records that actually changed.
# Mirror state to portal_job via the central recompute helper, so
# the portal state always derives from the WO + shipment + invoice
# together rather than the most-recent event flag.
if state_changed_ids:
target = self._FP_JOB_STATE_TO_PORTAL_STATE.get(vals.get('state'))
if target:
for job in self.filtered(lambda j: j.id in state_changed_ids):
if job.portal_job_id and job.portal_job_id.state != target:
job.portal_job_id.sudo().write({'state': target})
for job in self.filtered(lambda j: j.id in state_changed_ids):
if job.portal_job_id:
job.portal_job_id._fp_recompute_portal_state()
if not scrap_deltas:
return result
Hold = (self.env['fusion.plating.quality.hold']

View File

@@ -85,6 +85,128 @@ class FpJobStep(models.Model):
)
step.can_start = not bool(blocking)
# 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
# with can_start as a single source of truth.
blocker_kind = fields.Selection(
[
('none', 'Not blocked'),
('predecessor', 'Waiting on predecessor'),
('contract_review', 'Contract review pending'),
('parts_not_received', 'Parts not received'),
('racking_required', 'Racking inspection required'),
('manager_input', 'Manager input required'),
('other', 'Other'),
],
compute='_compute_blocker',
string='Blocker Kind',
)
blocker_reason = fields.Char(
compute='_compute_blocker',
string='Blocker Reason',
help='Human-readable explanation surfaced in the GateViz block.',
)
blocker_jump_target_model = fields.Char(compute='_compute_blocker')
blocker_jump_target_id = fields.Integer(compute='_compute_blocker')
@api.depends(
'state', 'sequence', 'parallel_start', 'requires_predecessor_done',
'job_id.enforce_sequential',
'job_id.step_ids.state', 'job_id.step_ids.sequence',
)
def _compute_blocker(self):
for step in self:
# Terminal/in-progress states are never "blocked"
if step.state in ('done', 'skipped', 'cancelled', 'in_progress'):
step.blocker_kind = 'none'
step.blocker_reason = ''
step.blocker_jump_target_model = False
step.blocker_jump_target_id = 0
continue
# Predecessor gate — same policy as _compute_can_start
if step._fp_should_block_predecessors():
earlier_open = step.job_id.step_ids.filtered(lambda x: (
x.id != step.id
and x.sequence < step.sequence
and x.state not in ('done', 'skipped', 'cancelled')
))
if earlier_open:
first_blocker = earlier_open.sorted('sequence')[0]
step.blocker_kind = 'predecessor'
seq_disp = (first_blocker.sequence or 0) // 10
step.blocker_reason = (
f'Waiting on Step {seq_disp}: {first_blocker.name}'
)
step.blocker_jump_target_model = 'fp.job.step'
step.blocker_jump_target_id = first_blocker.id
continue
# Future: extend with explicit checks for contract_review /
# parts_not_received / racking_required / manager_input as
# those gate models mature. For now, default to 'none'.
step.blocker_kind = 'none'
step.blocker_reason = ''
step.blocker_jump_target_model = False
step.blocker_jump_target_id = 0
# ==================================================================
# Shop-Floor auto-pause cron (Phase 2 — tablet redesign)
# ==================================================================
@api.model
def _cron_autopause_stale_steps(self):
"""Flip in_progress steps idle > threshold to paused.
Threshold read from ir.config_parameter
fp.shopfloor.autopause_threshold_hours (default 8.0)
Recipes can opt out per node via
fusion.plating.process.node.long_running (Phase 2 — P2.1)
Fixes the 411-hour ghost timer that bit us on the original tablet
when an operator started a step and never tapped Finish. Posts an
audit chatter entry on the step so the operator can see what
happened when they resume.
"""
from datetime import timedelta
threshold = float(
self.env['ir.config_parameter'].sudo()
.get_param('fp.shopfloor.autopause_threshold_hours', 8)
)
deadline = fields.Datetime.now() - timedelta(hours=threshold)
domain = [
('state', '=', 'in_progress'),
('date_started', '<', deadline),
'|',
('recipe_node_id', '=', False),
('recipe_node_id.long_running', '=', False),
]
stale = self.search(domain)
paused = 0
for step in stale:
try:
step.button_pause()
step.message_post(body=Markup(
"<b>Auto-paused</b> after %.1fh idle. "
"Resume from the tablet when work continues."
) % threshold)
_logger.info(
"Auto-paused step %s (%s) after %.1fh idle",
step.id, step.name, threshold,
)
paused += 1
except Exception:
_logger.exception(
"Auto-pause failed for step %s — skipping", step.id,
)
if paused:
_logger.info(
"_cron_autopause_stale_steps: paused %d step(s) "
"(threshold %.1fh)", paused, threshold,
)
return paused
# NOTE: the actual button_start override lives further down (~line
# 876) where it merges Sub 13 predecessor gate + Policy B Contract
# Review auto-open + Sub 8 Racking auto-open + the receiving soft
@@ -1078,6 +1200,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

@@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Adds the Work Order smart button + header action to fp.receiving so
# the receiving form mirrors the SO's WO entry point. Button appears
# once the receiving is closed and stays until every linked fp.job
# reaches state='done'.
from odoo import _, fields, models
class FpReceiving(models.Model):
_inherit = 'fp.receiving'
x_fc_fp_job_count = fields.Integer(
string='Work Orders',
compute='_compute_fp_job_count',
)
x_fc_show_work_order_btn = fields.Boolean(
string='Show Work Order Button',
compute='_compute_show_work_order_btn',
help='True once this receiving is closed and at least one linked '
'work order is still open (state != done). Hidden again '
'when every job is done.',
)
def _compute_fp_job_count(self):
Job = self.env['fp.job'].sudo()
for rec in self:
if rec.sale_order_id:
rec.x_fc_fp_job_count = Job.search_count(
[('sale_order_id', '=', rec.sale_order_id.id)]
)
else:
rec.x_fc_fp_job_count = 0
def _compute_show_work_order_btn(self):
Job = self.env['fp.job'].sudo()
for rec in self:
if rec.state != 'closed' or not rec.sale_order_id:
rec.x_fc_show_work_order_btn = False
continue
jobs = Job.search([('sale_order_id', '=', rec.sale_order_id.id)])
rec.x_fc_show_work_order_btn = bool(jobs) and any(
j.state != 'done' for j in jobs
)
def action_view_fp_jobs(self):
"""Open the work order(s) linked to this receiving's sale order."""
self.ensure_one()
if not self.sale_order_id:
return False
jobs = self.env['fp.job'].search([
('sale_order_id', '=', self.sale_order_id.id),
])
action = {
'type': 'ir.actions.act_window',
'name': _('Work Orders'),
'res_model': 'fp.job',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.sale_order_id.id)],
'context': {'default_sale_order_id': self.sale_order_id.id},
}
if len(jobs) == 1:
action.update({'view_mode': 'form', 'res_id': jobs.id})
return action

View File

@@ -24,6 +24,13 @@ class SaleOrder(models.Model):
string='Work Orders',
compute='_compute_fp_job_count',
)
x_fc_show_work_order_btn = fields.Boolean(
string='Show Work Order Header Button',
compute='_compute_show_work_order_btn',
help='True once any receiving record on this SO has closed and '
'at least one work order is still open (state != done). '
'Hidden again when every WO is done.',
)
x_fc_fp_certificate_count = fields.Integer(
string='Certificates',
compute='_compute_fp_certificate_count',
@@ -114,6 +121,25 @@ class SaleOrder(models.Model):
[('sale_order_id', '=', so.id)]
)
def _compute_show_work_order_btn(self):
Job = self.env['fp.job'].sudo()
Recv = self.env.get('fp.receiving')
for so in self:
if Recv is None:
so.x_fc_show_work_order_btn = False
continue
has_closed_recv = bool(Recv.sudo().search_count([
('sale_order_id', '=', so.id),
('state', '=', 'closed'),
]))
if not has_closed_recv:
so.x_fc_show_work_order_btn = False
continue
jobs = Job.search([('sale_order_id', '=', so.id)])
so.x_fc_show_work_order_btn = bool(jobs) and any(
j.state != 'done' for j in jobs
)
def _compute_fp_certificate_count(self):
Cert = self.env['fp.certificate'].sudo()
for so in self:

View File

@@ -2,3 +2,8 @@
from . import test_fp_job_extensions
from . import test_fp_job_milestone_cascade
from . import test_qty_received_propagation
from . import test_display_wo_name
from . import test_blocker_compute
from . import test_late_risk_ratio
from . import test_active_step_id
from . import test_autopause_cron

View File

@@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
"""Plan task P2.3 — fp.job.active_step_id compute."""
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestActiveStepId(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'AS'})
self.product = self.env['product.product'].create({'name': 'AS'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/AS',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
})
def test_no_active_step(self):
self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'S1',
'sequence': 10,
'state': 'ready',
})
self.job.invalidate_recordset(['active_step_id'])
self.assertFalse(self.job.active_step_id.id)
def test_single_in_progress_step(self):
s = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'S1',
'sequence': 10,
'state': 'in_progress',
})
self.job.invalidate_recordset(['active_step_id'])
self.assertEqual(self.job.active_step_id.id, s.id)
def test_multiple_in_progress_picks_lowest_sequence(self):
s1 = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'S1',
'sequence': 10,
'state': 'in_progress',
})
self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'S2',
'sequence': 20,
'state': 'in_progress',
})
self.job.invalidate_recordset(['active_step_id'])
self.assertEqual(self.job.active_step_id.id, s1.id)

View File

@@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
"""Plan task P2.4 — _cron_autopause_stale_steps method."""
from datetime import datetime, timedelta
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestAutopauseCron(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'AP'})
self.product = self.env['product.product'].create({'name': 'AP'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/AP',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
})
def test_stale_step_flips_to_paused(self):
step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'Stale',
'sequence': 10,
'state': 'in_progress',
'date_started': datetime.now() - timedelta(hours=10),
})
paused = self.env['fp.job.step']._cron_autopause_stale_steps()
self.assertGreaterEqual(paused, 1)
step.invalidate_recordset(['state'])
self.assertEqual(step.state, 'paused')
def test_fresh_step_unchanged(self):
step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'Fresh',
'sequence': 10,
'state': 'in_progress',
'date_started': datetime.now() - timedelta(hours=2),
})
self.env['fp.job.step']._cron_autopause_stale_steps()
step.invalidate_recordset(['state'])
self.assertEqual(step.state, 'in_progress')
def test_long_running_node_exempt(self):
node = self.env['fusion.plating.process.node'].create({
'name': 'Long bake',
'long_running': True,
'node_type': 'operation',
})
step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'Long',
'sequence': 10,
'state': 'in_progress',
'date_started': datetime.now() - timedelta(hours=20),
'recipe_node_id': node.id,
})
self.env['fp.job.step']._cron_autopause_stale_steps()
step.invalidate_recordset(['state'])
self.assertEqual(step.state, 'in_progress')
def test_threshold_config_parameter_respected(self):
self.env['ir.config_parameter'].sudo().set_param(
'fp.shopfloor.autopause_threshold_hours', '24',
)
step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'Within 24h',
'sequence': 10,
'state': 'in_progress',
'date_started': datetime.now() - timedelta(hours=10),
})
self.env['fp.job.step']._cron_autopause_stale_steps()
step.invalidate_recordset(['state'])
# 10h < 24h → still in_progress
self.assertEqual(step.state, 'in_progress')

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestBlockerCompute(TransactionCase):
"""fp.job.step.blocker_kind / blocker_reason / blocker_jump_target_*
— Gate visualizer source of truth for the OWL GateViz component.
"""
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Test Cust'})
self.product = self.env['product.product'].create({'name': 'Test Prod'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/T1',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
})
def _make_step(self, name, sequence, state='ready'):
return self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': name,
'sequence': sequence,
'state': state,
})
def test_terminal_step_has_no_blocker(self):
step = self._make_step('Done step', 10, state='done')
self.assertEqual(step.blocker_kind, 'none')
self.assertEqual(step.blocker_reason, '')
def test_in_progress_step_has_no_blocker(self):
step = self._make_step('Running step', 10, state='in_progress')
self.assertEqual(step.blocker_kind, 'none')
def test_solo_ready_step_not_blocked(self):
step = self._make_step('Solo', 10, state='ready')
# No predecessor → blocker_kind = 'none'
self.assertEqual(step.blocker_kind, 'none')
self.assertEqual(step.blocker_reason, '')
def test_predecessor_open_blocks(self):
s1 = self._make_step('Earlier', 10, state='in_progress')
s2 = self._make_step('Later', 20, state='ready')
# If recipe enforces sequential OR step requires predecessor,
# blocker_kind should be 'predecessor'. Default depends on job
# config; if neither flag triggers we'd see 'none' instead.
s2.invalidate_recordset([
'blocker_kind', 'blocker_reason',
'blocker_jump_target_model', 'blocker_jump_target_id',
])
if s2._fp_should_block_predecessors():
self.assertEqual(s2.blocker_kind, 'predecessor')
self.assertIn(s1.name, s2.blocker_reason or '')
self.assertEqual(s2.blocker_jump_target_model, 'fp.job.step')
self.assertEqual(s2.blocker_jump_target_id, s1.id)
else:
self.assertEqual(s2.blocker_kind, 'none')
def test_explicit_requires_predecessor_blocks(self):
s1 = self._make_step('Earlier', 10, state='ready')
s2 = self._make_step('Later', 20, state='ready')
s2.requires_predecessor_done = True
s2.invalidate_recordset(['blocker_kind', 'blocker_reason'])
self.assertEqual(s2.blocker_kind, 'predecessor')
self.assertEqual(s2.blocker_jump_target_id, s1.id)

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestDisplayWoName(TransactionCase):
"""fp.job.display_wo_name — Tablet/dashboard formatter."""
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'Test Cust'})
self.product = self.env['product.product'].create({'name': 'Test Prod'})
def _make_job(self, name):
return self.env['fp.job'].create({
'name': name,
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
})
def test_wh_job_prefix_formatted(self):
job = self._make_job('WH/JOB/00001')
self.assertEqual(job.display_wo_name, 'WO # 00001')
def test_wh_job_with_year(self):
job = self._make_job('WH/JOB/2026/00042')
self.assertEqual(job.display_wo_name, 'WO # 00042')
def test_plain_numeric(self):
job = self._make_job('00123')
self.assertEqual(job.display_wo_name, 'WO # 00123')
def test_falsy_name(self):
# New record before save → name is False; computed returns empty
job = self.env['fp.job'].new({'name': False})
self.assertEqual(job.display_wo_name, '')

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
"""Plan task P2.2 — fp.job.late_risk_ratio compute."""
from datetime import datetime, timedelta
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fp_jobs')
class TestLateRiskRatio(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({'name': 'LR'})
self.product = self.env['product.product'].create({'name': 'LR'})
def _make_job(self, deadline=None):
return self.env['fp.job'].create({
'name': 'WH/JOB/LR',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
'date_deadline': deadline,
})
def test_no_deadline_zero(self):
job = self._make_job(deadline=False)
self.assertEqual(job.late_risk_ratio, 0.0)
def test_no_open_steps_zero(self):
job = self._make_job(deadline=datetime.now() + timedelta(hours=8))
self.assertEqual(job.late_risk_ratio, 0.0)
def test_ratio_above_one_when_overrun(self):
job = self._make_job(deadline=datetime.now() + timedelta(hours=2))
# One step planned for 240 min, only 120 min left → ratio ~ 2.0
self.env['fp.job.step'].create({
'job_id': job.id,
'name': 'Long step',
'sequence': 10,
'state': 'ready',
'duration_expected': 240,
})
job.invalidate_recordset(['late_risk_ratio'])
self.assertGreaterEqual(job.late_risk_ratio, 1.5)
def test_done_steps_dont_count_toward_remaining(self):
job = self._make_job(deadline=datetime.now() + timedelta(hours=4))
self.env['fp.job.step'].create({
'job_id': job.id,
'name': 'Done',
'sequence': 10,
'state': 'done',
'duration_expected': 999,
})
self.env['fp.job.step'].create({
'job_id': job.id,
'name': 'Tiny remaining',
'sequence': 20,
'state': 'ready',
'duration_expected': 30,
})
job.invalidate_recordset(['late_risk_ratio'])
# 30 min remaining vs 240 min to deadline → ratio ~ 0.125
self.assertLess(job.late_risk_ratio, 0.3)

View File

@@ -20,6 +20,15 @@
<field name="inherit_id" ref="fusion_plating.view_fp_job_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<!-- Phase 1 — Tablet redesign. Opens the JobWorkspace OWL
client action focused on this WO. Primary entry point
for techs before the Landing kanban (Phase 3) ships;
stays as a back-office shortcut after. -->
<button name="action_open_workspace" type="object"
string="Open Workspace"
class="btn-primary"
icon="fa-tablet"
invisible="state == 'draft'"/>
<button name="action_open_process_tree" type="object"
string="Process Tree"
class="btn-secondary"
@@ -63,15 +72,10 @@
<field name="all_steps_terminal" invisible="1"/>
<field name="next_milestone_action" invisible="1"/>
<button name="action_print_sticker" type="object"
string="Print Sticker"
class="btn-secondary"
icon="fa-tag"
invisible="state == 'draft'"/>
<button name="action_print_wo_detail" type="object"
string="Print WO Detail"
class="btn-secondary"
icon="fa-file-text-o"
invisible="state in ('draft', 'cancelled')"/>
icon="fa-qrcode"
invisible="state == 'draft'"
help="Print Sticker"/>
</xpath>
<!-- Sub 14 — Replace the generic Draft/Confirmed/In Progress/Done

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Adds the Work Order smart button + header button to fp.receiving so
the receiving form mirrors the SO's WO entry point. Header button
appears once state == 'closed' and at least one linked fp.job is
still open. Smart button is always visible when WOs exist.
-->
<odoo>
<record id="view_fp_receiving_form_fp_jobs" model="ir.ui.view">
<field name="name">fp.receiving.form.fp.jobs</field>
<field name="model">fp.receiving</field>
<field name="inherit_id" ref="fusion_plating_receiving.view_fp_receiving_form"/>
<field name="arch" type="xml">
<!-- Work Order header button — only after receiving is
closed and while at least one job is still open. -->
<xpath expr="//header" position="inside">
<field name="x_fc_show_work_order_btn" invisible="1"/>
<button name="action_view_fp_jobs"
string="Work Order" type="object"
class="btn-primary" icon="fa-cogs"
invisible="not x_fc_show_work_order_btn"
help="Open the Work Order(s) for this receiving. Hidden automatically once every linked WO is marked Done."/>
</xpath>
<!-- Work Order smart button on the button_box (mirrors the
one on the SO form). Always visible when count > 0. -->
<xpath expr="//div[@name='button_box']" position="inside">
<button name="action_view_fp_jobs" type="object"
class="oe_stat_button" icon="fa-cogs"
invisible="x_fc_fp_job_count == 0">
<field name="x_fc_fp_job_count" widget="statinfo" string="WO"/>
</button>
</xpath>
</field>
</record>
</odoo>

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

@@ -33,6 +33,19 @@
</button>
</xpath>
<!-- Work Order header action — appears once any linked
receiving is closed AND at least one WO is still open.
Reuses the existing action_view_fp_jobs smart-button
target so single-job SOs land on the form directly. -->
<xpath expr="//header" position="inside">
<field name="x_fc_show_work_order_btn" invisible="1"/>
<button name="action_view_fp_jobs"
string="Work Order" type="object"
class="btn-primary" icon="fa-cogs"
invisible="not x_fc_show_work_order_btn"
help="Open the Work Order(s) for this order. Hidden automatically once every linked WO is marked Done."/>
</xpath>
<!-- Quote ref: small grey "Originally quoted as Q202605-200"
line under the SO name (the big SO-30000 heading). Only
renders once the SO has been confirmed (quote_ref is set

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Customer Portal',
'version': '19.0.4.3.0',
'version': '19.0.4.4.0',
'category': 'Manufacturing/Plating',
'summary': 'Customer-facing portal for plating shops: online RFQ, job status, '
'CoC downloads, invoice access.',

View File

@@ -263,6 +263,83 @@ class FpPortalJob(models.Model):
walk(mo.x_fc_recipe_id, 0)
return result
# ==========================================================================
# State recompute — single source of truth derived from upstream models
# ==========================================================================
# The portal state should ALWAYS reflect the real shop-floor state of the
# linked fp.job(s), the outbound shipment(s), and the customer invoice.
# Earlier paths wrote state directly from each event hook (tracking number
# arrived → 'shipped'; invoice posted → 'complete') which drifted out of
# sync the moment any of those events fired before the job was actually
# done — e.g. a FedEx label booked early would promote portal state to
# 'shipped' even though the WO was still in 'confirmed'. The helper below
# is the new single source of truth; the hooks now delegate to it.
def _fp_recompute_portal_state(self):
"""Derive portal state from fp.job + shipment + invoice and write
it back if it differs. Safe to call from any sync hook; only
writes when the target state actually changes."""
Job = self.env.get('fp.job')
if Job is None:
return
for portal in self:
jobs = Job.sudo().search([('portal_job_id', '=', portal.id)])
if not jobs:
# No linked job — leave manual edits alone.
continue
all_done = all(j.state == 'done' for j in jobs)
any_in_progress = any(
j.state in ('in_progress', 'done') for j in jobs
)
# Walk SO → fp.receiving → fusion.shipment for shipment status.
ship_delivered = False
ship_in_transit = False
for j in jobs:
so = j.sale_order_id
if not so or 'x_fc_receiving_ids' not in so._fields:
continue
for recv in so.x_fc_receiving_ids:
ship = (
recv.x_fc_outbound_shipment_id
if 'x_fc_outbound_shipment_id' in recv._fields
else False
)
if not ship:
continue
if ship.status == 'delivered':
ship_delivered = True
elif ship.status == 'shipped':
ship_in_transit = True
# Invoice signal — any posted customer invoice on the SO.
invoiced = False
for j in jobs:
so = j.sale_order_id
if not so:
continue
if any(
m.state == 'posted' and m.move_type in ('out_invoice', 'out_refund')
for m in so.invoice_ids
):
invoiced = True
break
# Resolve target state.
if all_done and invoiced and ship_delivered:
target = 'complete'
elif all_done and (ship_delivered or ship_in_transit):
target = 'shipped'
elif all_done:
target = 'ready_to_ship'
elif any_in_progress:
target = 'in_progress'
else:
target = 'received'
if portal.state != target:
portal.sudo().write({'state': target})
# ==========================================================================
# Per-stage timestamp snapshots
# ==========================================================================

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Receiving & Inspection',
'version': '19.0.3.25.0',
'version': '19.0.3.27.0',
'category': 'Manufacturing/Plating',
'summary': 'Parts receiving, inspection, damage logging, and manufacturing gate.',
'description': """

View File

@@ -160,14 +160,14 @@ class FusionShipment(models.Model):
vals['packing_list_attachment_id'] = (
delivery.packing_list_attachment_id.id
)
# Once a tracking number exists, the parts have been picked
# by the carrier (or are about to be) — advance the portal
# state to 'shipped' so the customer sees their order is
# on its way. The 'delivered' status flips when FedEx
# tracking reports the delivery.
if self.tracking_number and portal.state in (
'received', 'in_progress', 'ready_to_ship',
):
vals['state'] = 'shipped'
if vals:
portal.sudo().write(vals)
# State is now derived centrally — see
# fusion.plating.portal.job._fp_recompute_portal_state. It
# only promotes to 'shipped' when every linked WO is done
# AND the shipment.status is 'shipped' or 'delivered'. A
# FedEx label booked early (tracking number without the
# carrier actually picking up) no longer leapfrogs the
# shop floor.
if hasattr(portal, '_fp_recompute_portal_state'):
portal.sudo()._fp_recompute_portal_state()

View File

@@ -15,12 +15,29 @@ class SaleOrder(models.Model):
x_fc_receiving_count = fields.Integer(
string='Receiving Count', compute='_compute_receiving_count',
)
x_fc_show_receive_parts_btn = fields.Boolean(
string='Show Receive Parts Button',
compute='_compute_show_receive_parts_btn',
help='True once the SO is confirmed and there is still at least '
'one receiving record that is not yet closed. Hidden again '
'when every receiving record has been closed.',
)
@api.depends('x_fc_receiving_ids')
def _compute_receiving_count(self):
for rec in self:
rec.x_fc_receiving_count = len(rec.x_fc_receiving_ids)
@api.depends('state', 'x_fc_receiving_ids.state')
def _compute_show_receive_parts_btn(self):
for rec in self:
if rec.state not in ('sale', 'done'):
rec.x_fc_show_receive_parts_btn = False
continue
rec.x_fc_show_receive_parts_btn = any(
r.state != 'closed' for r in rec.x_fc_receiving_ids
)
def action_confirm(self):
"""Override to auto-create receiving record on SO confirmation.

View File

@@ -76,3 +76,25 @@ $fp-quote-muted : var(--fp-quote-muted, $_fp-quote-muted-hex);
font-size: 13px;
}
}
// =============================================================================
// Receive Parts header button — dark yellow / goldenrod
// Applied to the SO-form header action that opens the receiving record(s).
// Uses !important to defeat the .o_form_statusbar .btn cascades in both
// the light and dark Odoo bundles.
// =============================================================================
.o_fp_receive_parts_btn,
.o_form_statusbar .o_statusbar_buttons .o_fp_receive_parts_btn,
button.o_fp_receive_parts_btn {
background-color: #b8860b !important;
border-color: #9e7700 !important;
color: #ffffff !important;
&:hover, &:focus {
background-color: #9e7700 !important;
border-color: #7a5b00 !important;
color: #ffffff !important;
}
.fa { color: #ffffff !important; }
}

View File

@@ -21,6 +21,21 @@
<field name="x_fc_receiving_count" widget="statinfo" string="Receiving"/>
</button>
</xpath>
<!-- Receive Parts header action — appears after SO confirmation
while at least one receiving record is still open, and
disappears once every receiving record is closed. Reuses
the existing action_view_receiving method so a single
receiving opens directly while multiples land on the list. -->
<xpath expr="//header" position="inside">
<field name="x_fc_show_receive_parts_btn" invisible="1"/>
<button name="action_view_receiving"
string="Receive Parts" type="object"
class="btn o_fp_receive_parts_btn"
icon="fa-archive"
invisible="not x_fc_show_receive_parts_btn"
help="Open the receiving record(s) for this order. Hidden automatically once all receiving records are closed."/>
</xpath>
</field>
</record>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Reports',
'version': '19.0.11.26.14',
'version': '19.0.11.26.30',
'category': 'Manufacturing/Plating',
'summary': 'PDF reports for Fusion Plating: quote, SO, WO, packing, BoL, CoC, invoice, receipt, quality + compliance.',
'depends': [

View File

@@ -90,7 +90,12 @@
<t t-set="_desc"
t-value="line.fp_customer_description() if _has_helper else line.name"/>
<span t-esc="_desc" style="white-space: pre-line;"/>
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
<!-- Serial line is suppressed when the calling template sets
`fp_no_serial_in_desc=True` in `line.env.context`. SO
portrait does this because it shows the serial in its own
column in the part-number cell — otherwise it'd be
duplicated. Invoice / packing slip still display it here. -->
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id and not line.env.context.get('fp_no_serial_in_desc')">
<br/>
<small>Serial: <span t-esc="line.x_fc_serial_id.name"/></small>
</t>

View File

@@ -73,6 +73,26 @@
<field name="dpi">90</field>
</record>
<!-- ============================================================= -->
<!-- Compact A4 Landscape for customer-facing landscape reports. -->
<!-- Same shape as `paperformat_fp_a4_portrait` (margin_top=8, -->
<!-- header_spacing=0) just rotated. Bind alongside the portrait -->
<!-- compact for any report that has a landscape variant. -->
<!-- ============================================================= -->
<record id="paperformat_fp_a4_landscape_compact" model="report.paperformat">
<field name="name">Fusion Plating A4 Landscape (Compact)</field>
<field name="default" eval="False"/>
<field name="format">A4</field>
<field name="orientation">Landscape</field>
<field name="margin_top">8</field>
<field name="margin_bottom">15</field>
<field name="margin_left">10</field>
<field name="margin_right">10</field>
<field name="header_line" eval="False"/>
<field name="header_spacing">0</field>
<field name="dpi">90</field>
</record>
<!-- ============================================================= -->
<!-- 1. Certificate of Conformance (Portal Job) — Landscape -->
<!-- ============================================================= -->
@@ -394,9 +414,10 @@
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_portrait</field>
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_portrait</field>
<field name="print_report_name">'Packing Slip - %s' % object.name</field>
<field name="print_report_name">'Packing Slip - %s' % (object.sale_id.name.rsplit('-', 1)[-1] if object.sale_id else object.name)</field>
<field name="binding_model_id" ref="stock.model_stock_picking"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
</record>
<record id="action_report_fp_packing_slip_landscape" model="ir.actions.report">
@@ -405,7 +426,7 @@
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_plating_reports.report_fp_packing_slip_landscape</field>
<field name="report_file">fusion_plating_reports.report_fp_packing_slip_landscape</field>
<field name="print_report_name">'Packing Slip - %s' % object.name</field>
<field name="print_report_name">'Packing Slip - %s' % (object.sale_id.name.rsplit('-', 1)[-1] if object.sale_id else object.name)</field>
<field name="binding_model_id" ref="stock.model_stock_picking"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
@@ -450,6 +471,11 @@
<field name="binding_model_id" ref="account.model_account_move"/>
<field name="binding_type">report</field>
<field name="is_invoice_report" eval="True"/>
<!-- Same compact paperformat as the SO confirmation so the
inline custom header sits at the top of the page (not 40mm
down under Odoo's default margin). See CLAUDE.md
"wkhtmltopdf header overlap — the CoC pattern". -->
<field name="paperformat_id" ref="paperformat_fp_a4_portrait"/>
</record>
<record id="action_report_fp_invoice_landscape" model="ir.actions.report">
@@ -461,7 +487,10 @@
<field name="print_report_name">'Invoice - %s' % (object.name or '')</field>
<field name="binding_model_id" ref="account.model_account_move"/>
<field name="binding_type">report</field>
<field name="paperformat_id" ref="paperformat_fp_a4_landscape"/>
<!-- Compact landscape paperformat (same shape as the portrait
compact, just rotated) so the inline header lands at the
top with no auto-margin gap. -->
<field name="paperformat_id" ref="paperformat_fp_a4_landscape_compact"/>
</record>
<!-- ============================================================= -->

View File

@@ -14,26 +14,92 @@
<template id="report_fp_invoice_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<div class="fp-report">
<div class="page">
<t t-set="form_code" t-value="'FRM-007'"/>
<t t-call="fusion_plating_reports.fp_external_layout_clean">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id or env.company"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
<h4>
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice # </span>
<span t-elif="doc.move_type == 'out_invoice' and doc.state == 'draft'">Draft Invoice # </span>
<span t-elif="doc.move_type == 'out_refund'">Credit Note # </span>
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill # </span>
<span t-field="doc.name"/>
</h4>
<!-- Compute helpers -->
<t t-set="title_en" t-value="
'Credit Note' if doc.move_type == 'out_refund'
else 'Vendor Bill' if doc.move_type == 'in_invoice'
else 'Draft Invoice' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
else 'Invoice'"/>
<t t-set="title_fr" t-value="
'Note de crédit' if doc.move_type == 'out_refund'
else 'Facture fournisseur' if doc.move_type == 'in_invoice'
else 'Facture brouillon' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
else 'Facture'"/>
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name and doc.name != '/' else False"/>
<!-- Pull FP-specific fields from the source SO when invoice_origin
names one. Falls back to an empty recordset on manual
invoices so the t-if guards in the markup stay clean. -->
<t t-set="source_so" t-value="doc.env['sale.order'].search([('name', '=', doc.invoice_origin)], limit=1) if doc.invoice_origin else doc.env['sale.order'].browse()"/>
<t t-set="logo_uri" t-value="('data:image/png;base64,%s' % company.logo.decode()) if company.logo else False"/>
<t t-set="company_fax" t-value="company.partner_id.x_ff_fax_number if 'x_ff_fax_number' in company.partner_id._fields else False"/>
<t t-set="po_number" t-value="(source_so.x_fc_po_number if source_so else False) or doc.invoice_origin or ''"/>
<t t-set="spec_label" t-value="(source_so.x_fc_customer_spec_id.display_name or source_so.x_fc_customer_spec_id.name) if source_so and source_so.x_fc_customer_spec_id else ''"/>
<t t-set="delivery_method_label" t-value="dict(source_so._fields['x_fc_delivery_method'].selection).get(source_so.x_fc_delivery_method, '') if source_so and source_so.x_fc_delivery_method else ''"/>
<div class="fp-report fp-sale">
<!-- Inline 3-column header: logo+address LEFT,
NADCAP MIDDLE, title+barcode RIGHT.
Mirrors SO confirmation exactly. -->
<div class="fp-sale-header-row">
<div class="fp-sale-header-left">
<t t-if="logo_uri">
<img t-att-src="logo_uri" class="fp-sale-logo" alt="Logo"/>
</t>
<div class="fp-sale-company-addr">
<div>
<t t-if="company.partner_id.street"><span t-esc="company.partner_id.street"/></t>
<t t-if="company.partner_id.city"> | <span t-esc="company.partner_id.city"/></t>
<t t-if="company.partner_id.state_id"> | <span t-esc="company.partner_id.state_id.code or company.partner_id.state_id.name"/></t>
<t t-if="company.partner_id.zip"> | <span t-esc="company.partner_id.zip"/></t>
</div>
<div t-if="company.phone or company_fax">
<t t-if="company.phone">Tel: <span t-esc="company.phone"/></t>
<t t-if="company.phone and company_fax">&#160;&#160;&#160;</t>
<t t-if="company_fax">Fax: <span t-esc="company_fax"/></t>
</div>
<div t-if="company.partner_id.website">
<a t-att-href="company.partner_id.website"><span t-esc="company.partner_id.website"/></a>
</div>
</div>
</div>
<div class="fp-sale-header-mid">
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo">
<img class="fp-nadcap-logo"
t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
alt="Nadcap Accredited"/>
</t>
</div>
<div class="fp-sale-header-right">
<span class="fp-sale-title-en"><t t-esc="title_en"/></span>
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
<t t-if="barcode_uri">
<div class="fp-bc-wrap" style="margin-top: 4px;">
<img t-att-src="barcode_uri" alt="Invoice Barcode"/>
<div class="fp-bc-label"><span t-field="doc.name"/></div>
</div>
</t>
</div>
</div>
<div class="page">
<!-- Billing / Shipping -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">BILLING ADDRESS</th>
<th style="width: 50%;">DELIVERY ADDRESS</th>
<th style="width: 50%;">
<span class="fp-bl-en">Billing Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de facturation</span>
</th>
<th style="width: 50%;">
<span class="fp-bl-en">Delivery Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de livraison</span>
</th>
</tr>
</thead>
<tbody>
@@ -56,53 +122,131 @@
</tbody>
</table>
<!-- Invoice info -->
<!-- Row 1: Invoice Date | Due Date | Sales Rep | Customer PO # | Payment Ref -->
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 25%;">INVOICE DATE</th>
<th class="info-header" style="width: 25%;">DUE DATE</th>
<th class="info-header" style="width: 25%;">SOURCE</th>
<th class="info-header" style="width: 25%;">SALES REP</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Invoice Date</span>
<span class="fp-bl-fr-stk">Date de facture</span>
</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Due Date</span>
<span class="fp-bl-fr-stk">Date d'échéance</span>
</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Sales Rep</span>
<span class="fp-bl-fr-stk">Vendeur</span>
</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Customer PO #</span>
<span class="fp-bl-fr-stk">N° de B/C client</span>
</th>
<th class="info-header" style="width: 20%;">
<span class="fp-bl-en-stk">Payment Ref</span>
<span class="fp-bl-fr-stk">Réf. paiement</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.invoice_date"/></td>
<td class="text-center"><span t-field="doc.invoice_date_due"/></td>
<td class="text-center"><span t-field="doc.invoice_origin"/></td>
<td class="text-center"><span t-field="doc.invoice_user_id"/></td>
<td class="text-center"><span t-esc="po_number or '—'"/></td>
<td class="text-center"><span t-esc="doc.payment_reference or '—'"/></td>
</tr>
</tbody>
</table>
<!-- Lines -->
<!-- Row 2: Customer Job # | Specification | Delivery Method (pulled from source SO; hidden on manual invoices). -->
<t t-if="source_so and (source_so.x_fc_customer_job_number or spec_label or delivery_method_label)">
<table class="bordered">
<thead>
<tr>
<th class="info-header" style="width: 34%;">
<span class="fp-bl-en">Customer Job #</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de travail client</span>
</th>
<th class="info-header" style="width: 33%;">
<span class="fp-bl-en">Specification</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Spécification</span>
</th>
<th class="info-header" style="width: 33%;">
<span class="fp-bl-en">Delivery Method</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Méthode de livraison</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-esc="source_so.x_fc_customer_job_number or '—'"/></td>
<td class="text-center"><span t-esc="spec_label or '—'"/></td>
<td class="text-center"><span t-esc="delivery_method_label or '—'"/></td>
</tr>
</tbody>
</table>
</t>
<!-- Lines (taxes column dropped; summarized in totals) -->
<table class="bordered">
<thead>
<tr>
<th class="text-start" style="width: 20%;">PART NUMBER</th>
<th class="text-start" style="width: 32%;">DESCRIPTION</th>
<th style="width: 8%;">QTY</th>
<th style="width: 8%;">UOM</th>
<th style="width: 12%;">UNIT PRICE</th>
<th style="width: 8%;">TAXES</th>
<th style="width: 12%;">AMOUNT</th>
<th class="text-start" style="width: 24%;">
<span class="fp-bl-en">Part Number</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de pièce</span>
</th>
<th class="text-start" style="width: 38%;">
<span class="fp-bl-en">Description</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Description</span>
</th>
<th style="width: 8%;">
<span class="fp-bl-en-stk">Qty</span>
<span class="fp-bl-fr-stk">Qté</span>
</th>
<th style="width: 8%;">
<span class="fp-bl-en-stk">UOM</span>
<span class="fp-bl-fr-stk">UDM</span>
</th>
<th style="width: 11%;">
<span class="fp-bl-en-stk">Unit Price</span>
<span class="fp-bl-fr-stk">Prix unitaire</span>
</th>
<th style="width: 11%;">
<span class="fp-bl-en-stk">Amount</span>
<span class="fp-bl-fr-stk">Montant</span>
</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.invoice_line_ids" t-as="line">
<t t-if="line.display_type == 'line_section'">
<tr class="section-row"><td colspan="7"><strong t-field="line.name"/></td></tr>
<tr class="section-row"><td colspan="6"><strong t-field="line.name"/></td></tr>
</t>
<t t-elif="line.display_type == 'line_note'">
<tr class="note-row"><td colspan="7"><span t-field="line.name"/></td></tr>
<tr class="note-row"><td colspan="6"><span t-field="line.name"/></td></tr>
</t>
<t t-elif="not line.display_type or line.display_type == 'product'">
<tr>
<td>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
<!-- Three stacked lines: Part #, Name, S/N -->
<div>
<strong>Part #:</strong>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
</div>
<div>
<strong>Name:</strong>
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
<span t-esc="line.x_fc_part_catalog_id.name"/>
</t>
<t t-else=""></t>
</div>
<div>
<strong>S/N:</strong>
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
<span t-esc="line.x_fc_serial_id.name"/>
</t>
<t t-else=""></t>
</div>
</td>
<td>
<!-- Suppress duplicate serial in the description column -->
<t t-set="line" t-value="line.with_context(fp_no_serial_in_desc=True)"/>
<t t-call="fusion_plating_reports.customer_line_description"/>
</td>
<td class="text-center">
@@ -112,9 +256,6 @@
<td class="text-end">
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
<td class="text-center">
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
</td>
<td class="text-end">
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
@@ -127,40 +268,47 @@
<!-- Terms + Totals -->
<div class="row" style="margin-top: 15px;">
<div class="col-6">
<t t-if="doc.invoice_payment_term_id.note">
<strong>Payment Terms:</strong><br/>
<span t-field="doc.invoice_payment_term_id.note"/>
</t>
<t t-if="doc.payment_reference">
<div style="margin-top: 10px;">
<strong>Payment Reference:</strong>
<span t-field="doc.payment_reference"/>
</div>
<t t-if="doc.invoice_payment_term_id">
<strong>Payment Terms / Modalités de paiement:</strong><br/>
<t t-if="doc.invoice_payment_term_id.note">
<span t-field="doc.invoice_payment_term_id.note"/>
</t>
<t t-else="">
<span t-field="doc.invoice_payment_term_id.name"/>
</t>
</t>
</div>
<div class="col-6" style="text-align: right;">
<table class="totals-table" style="width: auto; margin-left: auto;">
<tr>
<td style="min-width: 150px;">Subtotal</td>
<td style="min-width: 150px;">
<span class="fp-bl-en">Subtotal</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Sous-total</span>
</td>
<td class="text-end" style="min-width: 110px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Taxes</td>
<td>
<span class="fp-bl-en">Taxes</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Taxes</span>
</td>
<td class="text-end">
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr style="background-color: #c1c1c1;">
<td><strong>Grand Total</strong></td>
<td>
<span class="fp-bl-en">Grand Total</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Total général</span>
</td>
<td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
<tr>
<td><strong>Amount Due</strong></td>
<td>
<strong><span class="fp-bl-en">Amount Due</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Montant dû</span></strong>
</td>
<td class="text-end"><strong>
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
@@ -173,14 +321,14 @@
<!-- Paid stamp -->
<t t-if="doc.payment_state in ('paid', 'in_payment')">
<div style="margin-top: 15px; text-align: center;">
<span class="paid-stamp">PAID</span>
<span class="paid-stamp">PAID / PAYÉ</span>
</div>
</t>
<!-- Notes -->
<t t-if="doc.narration">
<div style="margin-top: 15px;">
<strong>Notes:</strong>
<strong>Notes / Remarques:</strong>
<div t-field="doc.narration"/>
</div>
</t>
@@ -198,26 +346,87 @@
<template id="report_fp_invoice_landscape">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<div class="fp-landscape">
<div class="page">
<t t-set="form_code" t-value="'FRM-007'"/>
<t t-call="fusion_plating_reports.fp_external_layout_clean">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id or env.company"/>
<t t-call="fusion_plating_reports.fp_landscape_styles"/>
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
<h2 style="text-align: left;">
<span t-if="doc.move_type == 'out_invoice' and doc.state == 'posted'">Invoice # </span>
<span t-elif="doc.move_type == 'out_invoice' and doc.state == 'draft'">Draft Invoice # </span>
<span t-elif="doc.move_type == 'out_refund'">Credit Note # </span>
<span t-elif="doc.move_type == 'in_invoice'">Vendor Bill # </span>
<span t-field="doc.name"/>
</h2>
<!-- Same compute helpers as portrait -->
<t t-set="title_en" t-value="
'Credit Note' if doc.move_type == 'out_refund'
else 'Vendor Bill' if doc.move_type == 'in_invoice'
else 'Draft Invoice' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
else 'Invoice'"/>
<t t-set="title_fr" t-value="
'Note de crédit' if doc.move_type == 'out_refund'
else 'Facture fournisseur' if doc.move_type == 'in_invoice'
else 'Facture brouillon' if (doc.move_type == 'out_invoice' and doc.state == 'draft')
else 'Facture'"/>
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name and doc.name != '/' else False"/>
<t t-set="source_so" t-value="doc.env['sale.order'].search([('name', '=', doc.invoice_origin)], limit=1) if doc.invoice_origin else doc.env['sale.order'].browse()"/>
<t t-set="logo_uri" t-value="('data:image/png;base64,%s' % company.logo.decode()) if company.logo else False"/>
<t t-set="company_fax" t-value="company.partner_id.x_ff_fax_number if 'x_ff_fax_number' in company.partner_id._fields else False"/>
<t t-set="po_number" t-value="(source_so.x_fc_po_number if source_so else False) or doc.invoice_origin or ''"/>
<t t-set="spec_label" t-value="(source_so.x_fc_customer_spec_id.display_name or source_so.x_fc_customer_spec_id.name) if source_so and source_so.x_fc_customer_spec_id else ''"/>
<t t-set="delivery_method_label" t-value="dict(source_so._fields['x_fc_delivery_method'].selection).get(source_so.x_fc_delivery_method, '') if source_so and source_so.x_fc_delivery_method else ''"/>
<div class="fp-landscape fp-sale">
<!-- 3-column inline header — same as portrait -->
<div class="fp-sale-header-row">
<div class="fp-sale-header-left">
<t t-if="logo_uri">
<img t-att-src="logo_uri" class="fp-sale-logo" alt="Logo"/>
</t>
<div class="fp-sale-company-addr">
<div>
<t t-if="company.partner_id.street"><span t-esc="company.partner_id.street"/></t>
<t t-if="company.partner_id.city"> | <span t-esc="company.partner_id.city"/></t>
<t t-if="company.partner_id.state_id"> | <span t-esc="company.partner_id.state_id.code or company.partner_id.state_id.name"/></t>
<t t-if="company.partner_id.zip"> | <span t-esc="company.partner_id.zip"/></t>
</div>
<div t-if="company.phone or company_fax">
<t t-if="company.phone">Tel: <span t-esc="company.phone"/></t>
<t t-if="company.phone and company_fax">&#160;&#160;&#160;</t>
<t t-if="company_fax">Fax: <span t-esc="company_fax"/></t>
</div>
<div t-if="company.partner_id.website">
<a t-att-href="company.partner_id.website"><span t-esc="company.partner_id.website"/></a>
</div>
</div>
</div>
<div class="fp-sale-header-mid">
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo">
<img class="fp-nadcap-logo"
t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
alt="Nadcap Accredited"/>
</t>
</div>
<div class="fp-sale-header-right">
<span class="fp-sale-title-en"><t t-esc="title_en"/></span>
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
<t t-if="barcode_uri">
<div class="fp-bc-wrap" style="margin-top: 4px;">
<img t-att-src="barcode_uri" alt="Invoice Barcode"/>
<div class="fp-bc-label"><span t-field="doc.name"/></div>
</div>
</t>
</div>
</div>
<div class="page">
<!-- Billing / Shipping -->
<table class="bordered">
<thead>
<tr>
<th style="width: 50%;">BILLING ADDRESS</th>
<th style="width: 50%;">DELIVERY ADDRESS</th>
<th style="width: 50%;">
<span class="fp-bl-en">Billing Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de facturation</span>
</th>
<th style="width: 50%;">
<span class="fp-bl-en">Delivery Address</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Adresse de livraison</span>
</th>
</tr>
</thead>
<tbody>
@@ -240,44 +449,106 @@
</tbody>
</table>
<!-- Invoice info (wide) -->
<!-- Invoice info row (wide, 6 cols on landscape) -->
<table class="bordered info-table">
<thead>
<tr>
<th>INVOICE DATE</th>
<th>DUE DATE</th>
<th>SOURCE</th>
<th>SALES REP</th>
<th>PAYMENT REF</th>
<th>CURRENCY</th>
<th>
<span class="fp-bl-en-stk">Invoice Date</span>
<span class="fp-bl-fr-stk">Date de facture</span>
</th>
<th>
<span class="fp-bl-en-stk">Due Date</span>
<span class="fp-bl-fr-stk">Date d'échéance</span>
</th>
<th>
<span class="fp-bl-en-stk">Sales Rep</span>
<span class="fp-bl-fr-stk">Vendeur</span>
</th>
<th>
<span class="fp-bl-en-stk">Customer PO #</span>
<span class="fp-bl-fr-stk">N° de B/C client</span>
</th>
<th>
<span class="fp-bl-en-stk">Payment Ref</span>
<span class="fp-bl-fr-stk">Réf. paiement</span>
</th>
<th>
<span class="fp-bl-en-stk">Currency</span>
<span class="fp-bl-fr-stk">Devise</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-field="doc.invoice_date"/></td>
<td class="text-center"><span t-field="doc.invoice_date_due"/></td>
<td class="text-center"><span t-field="doc.invoice_origin"/></td>
<td class="text-center"><span t-field="doc.invoice_user_id"/></td>
<td class="text-center"><span t-esc="doc.payment_reference or '-'"/></td>
<td class="text-center"><span t-esc="po_number or ''"/></td>
<td class="text-center"><span t-esc="doc.payment_reference or '—'"/></td>
<td class="text-center"><span t-field="doc.currency_id.name"/></td>
</tr>
</tbody>
</table>
<!-- Lines — hide discount column unless at least one line has a discount -->
<!-- Source SO row (wider, 3 cols, inline). Hidden on manual invoices. -->
<t t-if="source_so and (source_so.x_fc_customer_job_number or spec_label or delivery_method_label)">
<table class="bordered info-table">
<thead>
<tr>
<th>
<span class="fp-bl-en">Customer Job #</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de travail client</span>
</th>
<th>
<span class="fp-bl-en">Specification</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Spécification</span>
</th>
<th>
<span class="fp-bl-en">Delivery Method</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Méthode de livraison</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-center"><span t-esc="source_so.x_fc_customer_job_number or '—'"/></td>
<td class="text-center"><span t-esc="spec_label or '—'"/></td>
<td class="text-center"><span t-esc="delivery_method_label or '—'"/></td>
</tr>
</tbody>
</table>
</t>
<!-- Lines (taxes column dropped; discount column conditional) -->
<t t-set="has_discount" t-value="any(l.discount for l in doc.invoice_line_ids)"/>
<t t-set="col_count" t-value="8 if has_discount else 7"/>
<t t-set="col_count" t-value="7 if has_discount else 6"/>
<table class="bordered">
<thead>
<tr>
<th class="text-start" style="width: 18%;">PART NUMBER</th>
<th class="text-start" style="width: 24%;">DESCRIPTION</th>
<th style="width: 8%;">QTY</th>
<th style="width: 8%;">UOM</th>
<th style="width: 12%;">UNIT PRICE</th>
<th t-if="has_discount" style="width: 10%;">DISCOUNT</th>
<th style="width: 10%;">TAXES</th>
<th style="width: 10%;">AMOUNT</th>
<th class="text-start" style="width: 22%;">
<span class="fp-bl-en">Part Number</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">N° de pièce</span>
</th>
<th class="text-start" style="width: 30%;">
<span class="fp-bl-en">Description</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Description</span>
</th>
<th style="width: 7%;">
<span class="fp-bl-en-stk">Qty</span>
<span class="fp-bl-fr-stk">Qté</span>
</th>
<th style="width: 7%;">
<span class="fp-bl-en-stk">UOM</span>
<span class="fp-bl-fr-stk">UDM</span>
</th>
<th style="width: 12%;">
<span class="fp-bl-en-stk">Unit Price</span>
<span class="fp-bl-fr-stk">Prix unitaire</span>
</th>
<th t-if="has_discount" style="width: 10%;">
<span class="fp-bl-en-stk">Discount</span>
<span class="fp-bl-fr-stk">Remise</span>
</th>
<th style="width: 12%;">
<span class="fp-bl-en-stk">Amount</span>
<span class="fp-bl-fr-stk">Montant</span>
</th>
</tr>
</thead>
<tbody>
@@ -291,9 +562,28 @@
<t t-elif="not line.display_type or line.display_type == 'product'">
<tr>
<td>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
<!-- Three stacked lines: Part #, Name, S/N -->
<div>
<strong>Part #:</strong>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
</div>
<div>
<strong>Name:</strong>
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
<span t-esc="line.x_fc_part_catalog_id.name"/>
</t>
<t t-else=""></t>
</div>
<div>
<strong>S/N:</strong>
<t t-if="'x_fc_serial_id' in line._fields and line.x_fc_serial_id">
<span t-esc="line.x_fc_serial_id.name"/>
</t>
<t t-else=""></t>
</div>
</td>
<td>
<t t-set="line" t-value="line.with_context(fp_no_serial_in_desc=True)"/>
<t t-call="fusion_plating_reports.customer_line_description"/>
</td>
<td class="text-center">
@@ -305,10 +595,7 @@
</td>
<td t-if="has_discount" class="text-center">
<t t-if="line.discount"><span t-esc="line.discount"/>%</t>
<t t-else="">-</t>
</td>
<td class="text-center">
<t t-esc="', '.join([(tax.invoice_label or tax.name) for tax in line.tax_ids]) or '-'"/>
<t t-else=""></t>
</td>
<td class="text-end">
<span t-field="line.price_subtotal" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
@@ -322,34 +609,47 @@
<!-- Terms + Totals -->
<div class="row" style="margin-top: 15px;">
<div class="col-7">
<t t-if="doc.invoice_payment_term_id.note">
<strong>Payment Terms:</strong><br/>
<span t-field="doc.invoice_payment_term_id.note"/>
<t t-if="doc.invoice_payment_term_id">
<strong>Payment Terms / Modalités de paiement:</strong><br/>
<t t-if="doc.invoice_payment_term_id.note">
<span t-field="doc.invoice_payment_term_id.note"/>
</t>
<t t-else="">
<span t-field="doc.invoice_payment_term_id.name"/>
</t>
</t>
</div>
<div class="col-5" style="text-align: right;">
<table class="totals-table" style="width: auto; margin-left: auto;">
<tr>
<td style="min-width: 200px;">Subtotal</td>
<td style="min-width: 200px;">
<span class="fp-bl-en">Subtotal</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Sous-total</span>
</td>
<td class="text-end" style="min-width: 150px;">
<span t-field="doc.amount_untaxed" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr>
<td>Taxes</td>
<td>
<span class="fp-bl-en">Taxes</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Taxes</span>
</td>
<td class="text-end">
<span t-field="doc.amount_tax" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</td>
</tr>
<tr style="background-color: #c1c1c1;">
<td><strong>Grand Total</strong></td>
<td>
<span class="fp-bl-en">Grand Total</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Total général</span>
</td>
<td class="text-end"><strong>
<span t-field="doc.amount_total" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
</tr>
<t t-if="doc.amount_residual and doc.amount_residual != doc.amount_total">
<tr>
<td><strong>Amount Due</strong></td>
<td>
<strong><span class="fp-bl-en">Amount Due</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">Montant dû</span></strong>
</td>
<td class="text-end"><strong>
<span t-field="doc.amount_residual" t-options='{"widget": "monetary", "display_currency": doc.currency_id}'/>
</strong></td>
@@ -362,7 +662,7 @@
<!-- Paid stamp -->
<t t-if="doc.payment_state in ('paid', 'in_payment')">
<div style="margin-top: 15px; text-align: center;">
<span class="paid-stamp">PAID</span>
<span class="paid-stamp">PAID / PAYÉ</span>
</div>
</t>

View File

@@ -13,6 +13,25 @@
<!-- ============================================================= -->
<template id="fp_packing_slip_styles">
<style>
/* CoC pattern: paperformat_fp_a4_portrait reserves only
margin_top=8mm; the body padding-top clears the header
band. Same trick as .fp-coc and .fp-sale. */
.fp-report.fp-ps .page { padding-top: 20mm; }
/* Title bar: float div layout (NO table — see CLAUDE.md
wkhtmltopdf overlap §2). Stacked bilingual title with
English bold on top and French italic-grey below. */
.fp-ps-titlebar { margin: 0 0 10px 0; padding: 0; overflow: hidden; }
.fp-ps-title-en { font-size: 18pt; font-weight: bold; color: #2e2e2e; line-height: 1.1; display: block; }
.fp-ps-title-fr { font-size: 13pt; font-style: italic; color: #555; line-height: 1.1; display: block; margin-top: 2px; }
.fp-ps-title-num { font-weight: bold; margin-left: 6px; }
/* Code128 barcode block (top-right of title bar). Same
sizing/centering as the SO confirmation; encodes the
packing-slip number so the printed label matches the
visible title. */
.fp-ps-barcode { float: right; margin-left: 12px; }
.fp-ps-barcode .fp-bc-wrap { display: inline-block; text-align: center; }
.fp-ps-barcode img { height: 48px; max-width: 240px; border: 0 !important; padding: 0; display: block; }
.fp-ps-barcode .fp-bc-label { font-size: 10pt; color: #333; margin-top: 6px; letter-spacing: 0.5px; }
.fp-ps-addrtable td { vertical-align: top; padding: 8px 10px; font-size: 10pt; }
.fp-ps-addrtable .fp-ps-addr-label { font-weight: bold; font-size: 9pt; color: #333; text-transform: uppercase; margin-bottom: 4px; }
.fp-ps-info-table th { background-color: #eaeaea; }
@@ -187,16 +206,49 @@
<t t-set="tracking_ref" t-value="doc.carrier_tracking_ref if 'carrier_tracking_ref' in doc._fields and doc.carrier_tracking_ref else False"/>
<t t-set="tracking_text" t-value="tracking_ref if tracking_ref else ('Ready for pick up' if not has_carrier else '—')"/>
<t t-set="po_number" t-value="(doc.sale_id.client_order_ref if doc.sale_id and doc.sale_id.client_order_ref else '')"/>
<!-- Packing slip number derived from the SO so it
matches the order/work order number: SO-30045
→ "30045". When the SO has multiple outbound
pickings (partial shipments), the 2nd onwards
get a -NN suffix: "30045-02", "30045-03". The
first/only picking is just the bare number.
Falls back to picking name (WH/OUT/00168) for
pickings without a linked SO. -->
<t t-set="so_name_raw" t-value="doc.sale_id.name if doc.sale_id else doc.name"/>
<t t-set="ps_base_num" t-value="so_name_raw.rsplit('-', 1)[-1] if '-' in so_name_raw else so_name_raw"/>
<t t-set="outbound_pickings" t-value="doc.sale_id.picking_ids.filtered(lambda p: p.picking_type_id and p.picking_type_id.code == 'outgoing') if doc.sale_id else doc"/>
<t t-set="picking_position" t-value="(sorted(outbound_pickings.ids).index(doc.id) + 1) if (doc.sale_id and doc.id in outbound_pickings.ids) else 1"/>
<t t-set="ps_number" t-value="ps_base_num if picking_position &lt;= 1 else ('%s-%02d' % (ps_base_num, picking_position))"/>
<!-- Code128 barcode encoding the packing-slip number so
the printed label matches the visible title. Inlined
via barcode_data_uri (no /report/barcode/ HTTP fetch
— wkhtmltopdf network calls fail on entech). -->
<t t-set="ps_barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', ps_number, 600, 100) if ps_number else False"/>
<t t-set="qr_payload" t-value="doc.name or ''"/>
<t t-set="qr_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('QR', qr_payload, 220, 220) if qr_payload else False"/>
<div class="fp-report">
<div class="fp-report fp-ps">
<div class="page">
<h4>
Packing Slip #
<span t-field="doc.name"/>
</h4>
<!-- Bilingual title (EN bold on top, FR
italic-grey below) matching SO and CoC. -->
<div class="fp-ps-titlebar">
<t t-if="ps_barcode_uri">
<div class="fp-ps-barcode">
<div class="fp-bc-wrap">
<img t-att-src="ps_barcode_uri" alt="Packing Slip Barcode"/>
<div class="fp-bc-label"><t t-esc="ps_number"/></div>
</div>
</div>
</t>
<span class="fp-ps-title-en">
Packing Slip<span class="fp-ps-title-num"># <t t-esc="ps_number"/></span>
</span>
<span class="fp-ps-title-fr">Bordereau d'expédition</span>
</div>
<!-- Bill To / Ship To -->
<table class="bordered fp-ps-addrtable">
@@ -294,16 +346,40 @@
<t t-set="tracking_ref" t-value="doc.carrier_tracking_ref if 'carrier_tracking_ref' in doc._fields and doc.carrier_tracking_ref else False"/>
<t t-set="tracking_text" t-value="tracking_ref if tracking_ref else ('Ready for pick up' if not has_carrier else '—')"/>
<t t-set="po_number" t-value="(doc.sale_id.client_order_ref if doc.sale_id and doc.sale_id.client_order_ref else '')"/>
<!-- See portrait template for the numbering logic. -->
<t t-set="so_name_raw" t-value="doc.sale_id.name if doc.sale_id else doc.name"/>
<t t-set="ps_base_num" t-value="so_name_raw.rsplit('-', 1)[-1] if '-' in so_name_raw else so_name_raw"/>
<t t-set="outbound_pickings" t-value="doc.sale_id.picking_ids.filtered(lambda p: p.picking_type_id and p.picking_type_id.code == 'outgoing') if doc.sale_id else doc"/>
<t t-set="picking_position" t-value="(sorted(outbound_pickings.ids).index(doc.id) + 1) if (doc.sale_id and doc.id in outbound_pickings.ids) else 1"/>
<t t-set="ps_number" t-value="ps_base_num if picking_position &lt;= 1 else ('%s-%02d' % (ps_base_num, picking_position))"/>
<!-- Code128 barcode encoding the packing-slip number so
the printed label matches the visible title. Inlined
via barcode_data_uri (no /report/barcode/ HTTP fetch
— wkhtmltopdf network calls fail on entech). -->
<t t-set="ps_barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', ps_number, 600, 100) if ps_number else False"/>
<t t-set="qr_payload" t-value="doc.name or ''"/>
<t t-set="qr_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('QR', qr_payload, 220, 220) if qr_payload else False"/>
<div class="fp-landscape">
<div class="fp-landscape fp-ps">
<div class="page">
<h2 style="text-align: left;">
Packing Slip #
<span t-field="doc.name"/>
</h2>
<div class="fp-ps-titlebar">
<t t-if="ps_barcode_uri">
<div class="fp-ps-barcode">
<div class="fp-bc-wrap">
<img t-att-src="ps_barcode_uri" alt="Packing Slip Barcode"/>
<div class="fp-bc-label"><t t-esc="ps_number"/></div>
</div>
</div>
</t>
<span class="fp-ps-title-en">
Packing Slip<span class="fp-ps-title-num"># <t t-esc="ps_number"/></span>
</span>
<span class="fp-ps-title-fr">Bordereau d'expédition</span>
</div>
<!-- Bill To / Ship To -->
<table class="bordered fp-ps-addrtable">

View File

@@ -22,6 +22,46 @@
External_layout already places the page body at the bottom of
the reserved margin-top — don't fight that. Use a small positive
gap and shrink the title text instead. -->
<!-- Custom minimal layout — same .article wrapper that Odoo's
report pipeline expects (so UTF-8 charset handling works
correctly via the standard processing path), but with NO
auto company .header div. Includes a minimal .footer div
that ONLY carries the wkhtmltopdf page-number placeholders
(`<span class="page"/> / <span class="topage"/>`) — those
only get filled in when the .footer div is extracted into
wkhtmltopdf's footer-html stream. The .footer is otherwise
empty so no boilerplate company info shows. -->
<template id="fp_external_layout_clean">
<t t-if="not o" t-set="o" t-value="doc"/>
<t t-if="not company">
<t t-if="company_id">
<t t-set="company" t-value="company_id"/>
</t>
<t t-elif="o and 'company_id' in o and o.company_id.sudo()">
<t t-set="company" t-value="o.company_id.sudo()"/>
</t>
<t t-else="else">
<t t-set="company" t-value="res_company"/>
</t>
</t>
<div class="article o_report_layout_standard"
t-att-data-oe-model="o and o._name"
t-att-data-oe-id="o and o.id"
t-att-data-oe-lang="o and o.env.context.get('lang')">
<t t-out="0"/>
</div>
<div class="footer">
<div style="font-size: 9pt; color: #666; overflow: hidden;">
<!-- Internal form code (e.g. "FRM-006") on the left.
Each report sets `form_code` via t-set BEFORE the
t-call to this layout; reports that don't set it
leave the left side blank. -->
<span style="float: left;" t-if="form_code"><t t-esc="form_code"/></span>
<span style="float: right; white-space: nowrap; padding-left: 10px;" t-if="report_type == 'pdf'">Page <span class="page"/> / <span class="topage"/></span>
</div>
</div>
</template>
<template id="fp_sale_bilingual_styles">
<style>
/* Inline bilingual: English bold, then a faint slash, then
@@ -38,13 +78,58 @@
below in italic-grey. */
.fp-bl-en-stk { display: block; font-weight: bold; }
.fp-bl-fr-stk { display: block; font-weight: normal; font-style: italic; color: #555; font-size: 80%; margin-top: 1px; }
/* Match the CoC pattern exactly: tiny paperformat margin_top
(8mm) lets the header HTML overflow into the body area,
and this 20mm padding-top on the wrapper clears it. See
CLAUDE.md "wkhtmltopdf header overlap" — the alternative
"size margin_top to the header height" approach has zero
slack and breaks any time the header HTML grows. */
.fp-report.fp-sale { padding-top: 20mm; }
/* This template uses fp_external_layout_clean (sibling
template in this file) instead of web.external_layout.
That gives us the `.article` wrapper Odoo's report
renderer needs for proper UTF-8 dispatch, WITHOUT the
`.header` / `.footer` divs that wkhtmltopdf would
extract into page-margin streams. So the body owns the
entire visible header (logo + address LEFT, title +
barcode RIGHT) and no auto company band shows up. See
CLAUDE.md "Custom-header reports need .article wrapper". */
.fp-report.fp-sale { padding-top: 0; }
/* Custom inline header: 2-column flex-via-floats. Left has
the company logo + address + phone + URL; right has the
document title (bilingual stack) + Code128 barcode. */
.fp-sale-header-row { overflow: hidden; margin-bottom: 14px; padding-bottom: 6px; }
.fp-sale-header-left { float: left; width: 38%; }
/* Middle column: NADCAP accreditation logo (pulled from
company settings) above a small "25 Years in Business"
medallion. Conditional on company.x_fc_nadcap_active so
non-Nadcap shops only see the badge. */
.fp-sale-header-mid { float: left; width: 24%; text-align: center; padding-top: 4px; }
.fp-nadcap-logo { max-height: 45px; max-width: 115px; display: inline-block; }
/* 25-Years-in-Business medallion: tasteful gold-on-cream
bordered badge. Picks a dark muted gold (#8a6d2c) so it
reads as "anniversary keepsake" rather than gaudy. */
.fp-25-badge {
display: inline-block;
border: 1.5px solid #c8a55b;
background-color: #faf5e8;
padding: 5px 12px;
border-radius: 4px;
margin-top: 6px;
text-align: center;
line-height: 1.05;
}
.fp-25-badge .fp-25-num { font-size: 22pt; font-weight: bold; color: #8a6d2c; }
.fp-25-badge .fp-25-lbl { font-size: 7pt; font-weight: bold; color: #8a6d2c; letter-spacing: 1.2px; margin-top: 1px; }
.fp-25-badge .fp-25-sub { font-size: 6.5pt; color: #8a6d2c; font-style: italic; margin-top: 1px; }
/* Right column: title (English bold + French italic) + barcode
+ SO number all centered as a stacked block. Width reduced
to 38% to share row with the new middle NADCAP+25Years cell. */
.fp-sale-header-right { float: right; width: 38%; text-align: center; }
.fp-sale-logo { max-height: 50px; max-width: 280px; display: block; margin-bottom: 4px; }
.fp-sale-company-addr { font-size: 8.5pt; color: #222; line-height: 1.35; }
.fp-sale-company-addr div { margin: 0; }
.fp-sale-company-addr a { color: #2e6da4; text-decoration: none; }
/* Inline footer line — phone | email | website | tax id.
One-time render at the bottom of page 1 (multi-page SO
reports are rare; if we ever need it, switch to
wkhtmltopdf --footer-html). */
.fp-sale-customfooter { text-align: center; font-size: 8pt; color: #666; margin-top: 24px; padding-top: 8px; border-top: 1px solid #ccc; }
/* Title bar uses float-based div layout, NOT an HTML table —
the global ".fp-report table" rule was applying borders
to every nested table even with "border: 0 !important",
@@ -65,48 +150,83 @@
.fp-sale-barcode { float: right; margin-left: 12px; }
.fp-bc-wrap { display: inline-block; text-align: center; }
.fp-bc-wrap img { height: 48px; max-width: 240px; border: 0 !important; padding: 0; display: block; }
.fp-bc-wrap .fp-bc-label { font-size: 10pt; color: #333; margin-top: 6px; letter-spacing: 0.5px; }
.fp-bc-wrap .fp-bc-label { font-size: 16pt; font-weight: bold; color: #000; margin-top: 6px; letter-spacing: 1.5px; }
</style>
</template>
<template id="report_fp_sale_portrait">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="web.external_layout">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
<!-- Internal form code rendered on the footer left side
by fp_external_layout_clean (see that template). -->
<t t-set="form_code" t-value="'FRM-006'"/>
<t t-call="fusion_plating_reports.fp_external_layout_clean">
<t t-set="doc" t-value="doc.with_context(lang=doc.partner_id.lang)"/>
<t t-set="company" t-value="doc.company_id or env.company"/>
<t t-call="fusion_plating_reports.fp_portrait_styles"/>
<t t-call="fusion_plating_reports.fp_sale_bilingual_styles"/>
<!-- Compute helpers -->
<t t-set="is_quote" t-value="doc.state in ('draft', 'sent')"/>
<t t-set="title_en" t-value="'Quotation' if is_quote else 'Order Confirmation'"/>
<t t-set="title_fr" t-value="'Devis' if is_quote else 'Confirmation de commande'"/>
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name else False"/>
<t t-set="spec_label" t-value="(doc.x_fc_customer_spec_id.display_name or doc.x_fc_customer_spec_id.name) if doc.x_fc_customer_spec_id else ''"/>
<t t-set="delivery_method_label" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '') if 'x_fc_delivery_method' in doc._fields and doc.x_fc_delivery_method else ''"/>
<!-- Compute helpers -->
<t t-set="is_quote" t-value="doc.state in ('draft', 'sent')"/>
<t t-set="title_en" t-value="'Quotation' if is_quote else 'Order Confirmation'"/>
<t t-set="title_fr" t-value="'Devis' if is_quote else 'Confirmation de commande'"/>
<t t-set="barcode_uri" t-value="doc.env['ir.actions.report'].sudo().barcode_data_uri('Code128', doc.name, 600, 100) if doc.name else False"/>
<t t-set="spec_label" t-value="(doc.x_fc_customer_spec_id.display_name or doc.x_fc_customer_spec_id.name) if doc.x_fc_customer_spec_id else ''"/>
<t t-set="delivery_method_label" t-value="dict(doc._fields['x_fc_delivery_method'].selection).get(doc.x_fc_delivery_method, '') if 'x_fc_delivery_method' in doc._fields and doc.x_fc_delivery_method else ''"/>
<t t-set="logo_uri" t-value="('data:image/png;base64,%s' % company.logo.decode()) if company.logo else False"/>
<t t-set="company_fax" t-value="company.partner_id.x_ff_fax_number if 'x_ff_fax_number' in company.partner_id._fields else False"/>
<div class="fp-report fp-sale">
<div class="page">
<!-- Title bar: stacked English/French title
on the left, Code128 barcode floated
right. NO HTML table — see CLAUDE.md
"wkhtmltopdf header overlap" §2 for why
a table here leaks borders. -->
<div class="fp-sale-titlebar">
<t t-if="barcode_uri">
<div class="fp-sale-barcode">
<div class="fp-bc-wrap">
<img t-att-src="barcode_uri" alt="Order Barcode"/>
<div class="fp-bc-label"><span t-field="doc.name"/></div>
</div>
</div>
</t>
<span class="fp-sale-title-en">
<t t-esc="title_en"/><span class="fp-sale-title-num"># <span t-field="doc.name"/></span>
</span>
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
<div class="fp-report fp-sale">
<!-- Inline header (drops web.external_layout for this
report — see CSS comment for context). Left: logo
+ address + tel/fax + URL. Right: bilingual title
+ Code128 barcode of the order number. -->
<div class="fp-sale-header-row">
<div class="fp-sale-header-left">
<t t-if="logo_uri">
<img t-att-src="logo_uri" class="fp-sale-logo" alt="Logo"/>
</t>
<div class="fp-sale-company-addr">
<div>
<t t-if="company.partner_id.street"><span t-esc="company.partner_id.street"/></t>
<t t-if="company.partner_id.city"> | <span t-esc="company.partner_id.city"/></t>
<t t-if="company.partner_id.state_id"> | <span t-esc="company.partner_id.state_id.code or company.partner_id.state_id.name"/></t>
<t t-if="company.partner_id.zip"> | <span t-esc="company.partner_id.zip"/></t>
</div>
<div t-if="company.phone or company_fax">
<t t-if="company.phone">Tel: <span t-esc="company.phone"/></t>
<t t-if="company.phone and company_fax">&#160;&#160;&#160;</t>
<t t-if="company_fax">Fax: <span t-esc="company_fax"/></t>
</div>
<div t-if="company.partner_id.website">
<a t-att-href="company.partner_id.website"><span t-esc="company.partner_id.website"/></a>
</div>
</div>
</div>
<!-- Middle column: NADCAP logo only. Base64-inlined
from `company.x_fc_nadcap_logo` so wkhtmltopdf
doesn't have to fetch over HTTP (network calls
fail on entech). -->
<div class="fp-sale-header-mid">
<t t-if="company.x_fc_nadcap_active and company.x_fc_nadcap_logo">
<img class="fp-nadcap-logo"
t-att-src="'data:image/png;base64,%s' % company.x_fc_nadcap_logo.decode()"
alt="Nadcap Accredited"/>
</t>
</div>
<div class="fp-sale-header-right">
<span class="fp-sale-title-en"><t t-esc="title_en"/></span>
<span class="fp-sale-title-fr"><t t-esc="title_fr"/></span>
<t t-if="barcode_uri">
<div class="fp-bc-wrap" style="margin-top: 4px;">
<img t-att-src="barcode_uri" alt="Order Barcode"/>
<div class="fp-bc-label"><span t-field="doc.name"/></div>
</div>
</t>
</div>
</div>
<div class="page">
<!-- Billing / Shipping (wide cells — inline) -->
<table class="bordered">
@@ -174,10 +294,18 @@
<td class="text-center"><span t-field="doc.user_id"/></td>
<td class="text-center"><span t-esc="doc.x_fc_po_number or '—'"/></td>
<td class="text-center">
<t t-if="doc.x_fc_rush_order">
<span class="status-warning">Rush / Urgent</span>
<!-- Lead Time renders from the
computed display string on
sale.order. Rush stays
highlighted; everything
else (range / single value
/ Standard) is plain text. -->
<t t-if="doc.x_fc_lead_time_display == 'Rush'">
<span class="status-warning">Rush</span>
</t>
<t t-else="">
<span t-esc="doc.x_fc_lead_time_display"/>
</t>
<t t-else="">Standard</t>
</td>
</tr>
</tbody>
@@ -267,13 +395,34 @@
<t t-elif="not line.display_type or line.display_type == 'product'">
<tr>
<td>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
<span> - </span>
<span t-esc="line.x_fc_part_catalog_id.name"/>
</t>
<!-- Three stacked lines:
1. Part Number (catalog part_number + revision)
2. Name (catalog name, falls back to em-dash)
3. Serial Number (m2m serials joined, or em-dash) -->
<div>
<strong>Part #:</strong>
<t t-call="fusion_plating_reports.customer_line_part_number"/>
</div>
<div>
<strong>Name:</strong>
<t t-if="line.x_fc_part_catalog_id and line.x_fc_part_catalog_id.name">
<span t-esc="line.x_fc_part_catalog_id.name"/>
</t>
<t t-else=""></t>
</div>
<div>
<strong>S/N:</strong>
<t t-if="line.x_fc_serial_ids">
<span t-esc="', '.join(line.x_fc_serial_ids.mapped('name'))"/>
</t>
<t t-else=""></t>
</div>
</td>
<td>
<!-- Rebind `line` with fp_no_serial_in_desc=True so the
shared description macro skips its Serial line — we
already render S/N in the part-number cell above. -->
<t t-set="line" t-value="line.with_context(fp_no_serial_in_desc=True)"/>
<t t-call="fusion_plating_reports.customer_line_description"/>
</td>
<td class="text-center">

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.26.2.0',
'version': '19.0.30.2.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',
@@ -62,6 +64,50 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
# and variables directly (Odoo 19 forbids @import in custom SCSS,
# so tokens are resolved via bundle concatenation order).
'fusion_plating_shopfloor/static/src/scss/_fp_shopfloor_tokens.scss',
# ---- Shared OWL services (Phase 1 — tablet redesign) ----
# Registered ONCE in web.assets_backend; Odoo 19 auto-compiles
# into BOTH bright and dark bundles via $o-webclient-color-scheme.
'fusion_plating_shopfloor/static/src/scss/components/_workflow_chip.scss',
'fusion_plating_shopfloor/static/src/xml/components/workflow_chip.xml',
'fusion_plating_shopfloor/static/src/js/components/workflow_chip.js',
'fusion_plating_shopfloor/static/src/scss/components/_gate_viz.scss',
'fusion_plating_shopfloor/static/src/xml/components/gate_viz.xml',
'fusion_plating_shopfloor/static/src/js/components/gate_viz.js',
'fusion_plating_shopfloor/static/src/scss/components/_signature_pad.scss',
'fusion_plating_shopfloor/static/src/xml/components/signature_pad.xml',
'fusion_plating_shopfloor/static/src/js/components/signature_pad.js',
'fusion_plating_shopfloor/static/src/scss/components/_hold_composer.scss',
'fusion_plating_shopfloor/static/src/xml/components/hold_composer.xml',
'fusion_plating_shopfloor/static/src/js/components/hold_composer.js',
'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',
'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',
'fusion_plating_shopfloor/static/src/js/job_workspace.js',
# ---- Shop Floor Landing (Phase 3 — tablet redesign) ----
'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',
'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

@@ -6,3 +6,6 @@ from . import shopfloor_controller
from . import manager_controller
from . import tank_status
from . import move_controller
from . import workspace_controller
from . import landing_controller
from . import tablet_controller

View File

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

View File

@@ -0,0 +1,198 @@
# -*- 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 endpoint for the Shop Floor Landing kanban (Phase 3).
Replaces the data path for both fp_shopfloor_tablet (legacy) and
fp_plant_overview (legacy). Two modes:
station — paired station's work centre + Unassigned + next 1-2 WCs
in the recipe flow. The physical-station view.
all_plant — every active work centre, sorted by recipe flow.
The card payload shape matches the existing plant_overview cards so
the front-end can share the KanbanCard component. Tapping a card opens
the JobWorkspace via doAction (handled client-side).
"""
import logging
from odoo import fields, http
from odoo.addons.fusion_plating.models.fp_tz import fp_format
from odoo.http import request
_logger = logging.getLogger(__name__)
_ACTIVE_STEP_STATES = ('ready', 'in_progress', 'paused')
class FpLandingController(http.Controller):
# ======================================================================
# /fp/landing/kanban
# ======================================================================
@http.route('/fp/landing/kanban', type='jsonrpc', auth='user')
def kanban(self, mode='all_plant', station_id=None, search=None):
env = request.env
Step = env['fp.job.step']
WorkCentre = env['fp.work.centre']
# ---- Resolve station / facility scope ----------------------------
station = None
facility = None
if station_id:
stn = env['fusion.plating.shopfloor.station'].browse(int(station_id))
if stn.exists():
station = stn
facility = stn.facility_id
if not facility:
facility = env['fusion.plating.facility'].search([], limit=1)
# ---- Which work centres to render --------------------------------
wc_dom = [('active', '=', True)]
if facility:
wc_dom.append(('facility_id', '=', facility.id))
all_wcs = WorkCentre.search(wc_dom, order='sequence, code, name')
if mode == 'station' and station and station.work_center_id:
this_wc = station.work_center_id
# Show this WC + next 1-2 WCs in the recipe flow (preview)
after = all_wcs.filtered(
lambda w: w.sequence > this_wc.sequence
)[:2]
relevant_wcs = this_wc | after
else:
relevant_wcs = all_wcs
# ---- Active steps in scope ---------------------------------------
step_dom = [('state', 'in', _ACTIVE_STEP_STATES)]
if facility:
step_dom.append(('work_centre_id.facility_id', '=', facility.id))
if mode == 'station' and relevant_wcs:
# In station mode, include the relevant WCs + Unassigned only.
# The OR-of-three-leaves is what makes this filter "this WC,
# the next 1-2 WCs, or Unassigned" — three branches OR'd.
step_dom = step_dom + [
'|', '|',
('work_centre_id', 'in', relevant_wcs.ids),
('work_centre_id', '=', False),
('work_centre_id', 'in', relevant_wcs.ids),
]
steps = Step.search(step_dom, order='sequence, id')
if search:
search_l = search.strip().lower()
steps = steps.filtered(lambda s: (
search_l in (s.job_id.display_wo_name or '').lower()
or search_l in (s.job_id.partner_id.name or '').lower()
or search_l in (
s.job_id.part_catalog_id.part_number or ''
if s.job_id.part_catalog_id else ''
).lower()
))
# ---- Group into columns ------------------------------------------
cards_by_wc = {0: []} # 0 = Unassigned sentinel
for step in steps:
wc_id = step.work_centre_id.id or 0
cards_by_wc.setdefault(wc_id, []).append(self._step_to_card(step))
columns = []
for wc in relevant_wcs:
columns.append({
'work_center_id': wc.id,
'work_center_name': wc.name,
'cards': cards_by_wc.get(wc.id, []),
})
if cards_by_wc.get(0):
columns.append({
'work_center_id': 0,
'work_center_name': 'Unassigned',
'cards': cards_by_wc[0],
})
# ---- KPIs — 4 tech-relevant tiles --------------------------------
ready = sum(1 for s in steps if s.state == 'ready')
running = sum(1 for s in steps if s.state == 'in_progress')
BakeWindow = env['fusion.plating.bake.window']
bake_dom = [('state', 'in', ('awaiting_bake', 'bake_in_progress'))]
if facility:
bake_dom.append(('facility_id', '=', facility.id))
bakes_due = BakeWindow.search_count(bake_dom)
Hold = env['fusion.plating.quality.hold']
holds = Hold.search_count([('state', 'in', ('on_hold', 'under_review'))])
# ---- Station picker payload (so client can switch stations) ------
all_stations = env['fusion.plating.shopfloor.station'].search(
[], order='facility_id, name',
)
stations = [
{
'id': s.id,
'name': s.name,
'code': s.code or '',
'facility': s.facility_id.name or '',
'work_center_name': s.work_center_id.name or '',
}
for s in all_stations
]
return {
'ok': True,
'mode': mode,
'station': {
'id': station.id,
'name': station.name,
'code': station.code or '',
'work_center_name': station.work_center_id.name or '',
} if station else None,
'facility_name': facility.name if facility else '',
'columns': columns,
'kpis': {
'ready': ready,
'running': running,
'bakes_due': bakes_due,
'holds': holds,
},
'stations': stations,
'server_time': fp_format(env, fields.Datetime.now(), fmt='%H:%M:%S'),
}
def _step_to_card(self, step):
"""Build the kanban card payload for one fp.job.step.
Shape matches the KanbanCard OWL component (Phase 1 — P1.7).
"""
job = step.job_id
return {
'step_id': step.id,
'job_id': job.id,
'display_wo_name': job.display_wo_name,
'customer': job.partner_id.name or '',
'part': (
job.part_catalog_id.part_number
if 'part_catalog_id' in job._fields and job.part_catalog_id
else (job.product_id.display_name or '')
),
'qty': int(job.qty or 0),
'qty_done': int(job.qty_done or 0),
'qty_scrapped': int(job.qty_scrapped or 0),
'date_deadline': fp_format(
request.env, job.date_deadline, fmt='%b %d',
) if job.date_deadline else '',
'priority': job.priority or 'normal',
'workflow_state': {
'id': job.workflow_state_id.id,
'name': job.workflow_state_id.name,
'color': job.workflow_state_id.color or 'grey',
} if job.workflow_state_id else None,
'blocker_kind': step.blocker_kind,
'blocker_reason': step.blocker_reason or '',
'current_step_id': step.id,
'current_step_name': step.name,
'work_center': step.work_centre_id.name or '',
}

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(
@@ -466,3 +474,231 @@ class FpManagerDashboardController(http.Controller):
),
)
return {'ok': True, 'user_name': user.name}
# ======================================================================
# Phase 4 tablet redesign — 3 new tabs on the Manager Desk
# ======================================================================
@http.route('/fp/manager/funnel', type='jsonrpc', auth='user')
def funnel(self, facility_id=None):
"""Workflow funnel: jobs grouped by fp.job.workflow.state.
One row per workflow stage with its count + top 5 WO cards.
Drives the default tab on the refactored Manager Dashboard.
"""
env = request.env
Job = env['fp.job']
all_states = env['fp.job.workflow.state'].search(
[], order='sequence, id',
)
# All in-flight jobs (not done/cancelled)
job_dom = [('state', 'not in', _NEG_JOB_STATES)]
if facility_id:
job_dom.append(('facility_id', '=', int(facility_id)))
jobs = Job.search(job_dom, order='priority desc, date_deadline asc')
# Group jobs by workflow_state_id (in-memory — list is bounded by
# active job count, typically < 200)
by_stage = {ws.id: [] for ws in all_states}
for job in jobs:
if job.workflow_state_id and job.workflow_state_id.id in by_stage:
by_stage[job.workflow_state_id.id].append(job)
def _job_card_compact(job):
return {
'job_id': job.id,
'display_wo_name': job.display_wo_name,
'customer': job.partner_id.name or '',
'priority': job.priority or 'normal',
'days_in_stage': (
(fields.Datetime.now() - job.write_date).days
if job.write_date else 0
),
}
stages = []
for ws in all_states:
jobs_in_stage = by_stage[ws.id]
stages.append({
'id': ws.id,
'name': ws.name,
'color': ws.color or 'grey',
'sequence': ws.sequence or 0,
'count': len(jobs_in_stage),
'jobs': [_job_card_compact(j) for j in jobs_in_stage[:5]],
})
return {'ok': True, 'stages': stages}
@http.route('/fp/manager/approval_inbox', type='jsonrpc', auth='user')
def approval_inbox(self, facility_id=None):
"""Approval Inbox: things waiting on a manager decision.
Four buckets: holds to release, certs to issue, recent scrap to
acknowledge, override requests (deferred — empty for now).
"""
env = request.env
# ---- Holds to Release -------------------------------------------
Hold = env['fusion.plating.quality.hold']
hold_dom = [('state', 'in', ('on_hold', 'under_review'))]
holds = Hold.search(hold_dom, order='create_date desc', limit=50)
holds_to_release = [
{
'hold_id': h.id,
'name': h.name,
'job_name': (
h.x_fc_job_id.display_wo_name
if 'x_fc_job_id' in Hold._fields and h.x_fc_job_id
else (h.x_fc_job_id.name or '' if 'x_fc_job_id' in Hold._fields else '')
),
'reason': dict(Hold._fields['hold_reason'].selection).get(
h.hold_reason, h.hold_reason or '',
),
'qty': h.qty_on_hold or 0,
'requested_by': h.operator_id.name or '',
'requested_at': fp_format(env, h.create_date) if h.create_date else '',
}
for h in holds
]
# ---- Certs to Issue ---------------------------------------------
# Jobs where all_steps_terminal AND at least one required cert
# is still draft.
Job = env['fp.job']
job_dom = [
('all_steps_terminal', '=', True),
('state', 'not in', _NEG_JOB_STATES),
]
if facility_id:
job_dom.append(('facility_id', '=', int(facility_id)))
terminal_jobs = Job.search(job_dom, order='write_date desc', limit=100)
certs_to_issue = []
for job in terminal_jobs:
try:
if not job._fp_has_draft_required_certs():
continue
except Exception:
continue
certs_to_issue.append({
'job_id': job.id,
'display_wo_name': job.display_wo_name,
'customer': job.partner_id.name or '',
'cert_types': list(job._resolve_required_cert_types()),
'all_steps_done_at': fp_format(env, job.write_date) if job.write_date else '',
})
# ---- Scrap to Review --------------------------------------------
# Recent qty_scrapped bumps via S17 hook auto-spawn holds with
# hold_reason in ('scrap', 'other'). Surface the last 24h worth.
from datetime import timedelta
scrap_cutoff = fields.Datetime.now() - timedelta(hours=24)
scrap_holds = Hold.search([
('mark_for_scrap', '=', True),
('create_date', '>=', scrap_cutoff),
], order='create_date desc', limit=20)
scrap_to_review = [
{
'hold_id': h.id,
'job_name': (
h.x_fc_job_id.display_wo_name
if 'x_fc_job_id' in Hold._fields and h.x_fc_job_id else ''
),
'scrap_qty': h.qty_on_hold or 0,
'reason': h.description or '',
'operator': h.operator_id.name or '',
'at': fp_format(env, h.create_date) if h.create_date else '',
}
for h in scrap_holds
]
return {
'ok': True,
'holds_to_release': holds_to_release,
'certs_to_issue': certs_to_issue,
'scrap_to_review': scrap_to_review,
'override_requests': [], # deferred — placeholder
}
@http.route('/fp/manager/at_risk', type='jsonrpc', auth='user')
def at_risk(self, facility_id=None):
"""At-Risk view: trending-late jobs + hold reasons + bottleneck.
Sub-panels:
- trending_late: top 20 jobs by late_risk_ratio desc (> 0)
- hold_reasons: open holds grouped by hold_reason
- bottleneck: work centres sorted by bottleneck_score desc
"""
env = request.env
# ---- Trending Late ----------------------------------------------
Job = env['fp.job']
job_dom = [
('state', 'not in', _NEG_JOB_STATES),
('late_risk_ratio', '>', 0),
]
if facility_id:
job_dom.append(('facility_id', '=', int(facility_id)))
late_jobs = Job.search(
job_dom, order='late_risk_ratio desc', limit=20,
)
trending_late = [
{
'job_id': j.id,
'display_wo_name': j.display_wo_name,
'customer': j.partner_id.name or '',
'late_risk_ratio': round(j.late_risk_ratio, 2),
'deadline': fp_format(env, j.date_deadline, fmt='%Y-%m-%d') if j.date_deadline else '',
'stuck_at': (
j.active_step_id.name
if 'active_step_id' in j._fields and j.active_step_id
else ''
),
}
for j in late_jobs
]
# ---- Hold Reasons grouped --------------------------------------
Hold = env['fusion.plating.quality.hold']
reason_selection = dict(Hold._fields['hold_reason'].selection)
# read_group is the cheap way to bucket
groups = Hold.read_group(
domain=[('state', 'in', ('on_hold', 'under_review'))],
fields=['hold_reason'],
groupby=['hold_reason'],
)
hold_reasons = [
{
'reason': g.get('hold_reason') or 'unknown',
'label': reason_selection.get(g.get('hold_reason'), g.get('hold_reason') or 'unknown'),
'count': g.get('hold_reason_count', 0),
}
for g in groups
]
hold_reasons.sort(key=lambda r: r['count'], reverse=True)
# ---- Bottleneck heatmap ----------------------------------------
WC = env['fp.work.centre']
wc_dom = [('active', '=', True)]
if facility_id:
wc_dom.append(('facility_id', '=', int(facility_id)))
wcs = WC.search(wc_dom)
bottlenecks = []
for wc in wcs:
# Skip work centres with zero queue — no signal
if wc.bottleneck_score <= 0:
continue
bottlenecks.append({
'work_centre_id': wc.id,
'work_centre_name': wc.name,
'score': round(wc.bottleneck_score, 1),
'avg_wait_minutes': round(wc.avg_wait_minutes, 1),
})
bottlenecks.sort(key=lambda b: b['score'], reverse=True)
return {
'ok': True,
'trending_late': trending_late,
'hold_reasons': hold_reasons,
'bottleneck': bottlenecks[:10], # top 10
}

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
@@ -620,15 +643,26 @@ class FpShopfloorController(http.Controller):
# ----------------------------------------------------------------------
# Tablet Overview — one-shot dashboard payload
# ----------------------------------------------------------------------
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
# New Shop Floor Landing client action (fp_shopfloor_landing) uses
# /fp/landing/kanban. The Tablet Station menu now points at the new
# surface. This endpoint stays live as long as the legacy
# fp_shopfloor_tablet OWL component is still registered — it consumes
# the rich payload (my_queue, active_wo, baths, bake_windows, gates,
# holds, pending_qcs, stations). Phase 5 cleanup will retire both the
# legacy component and this endpoint together.
@http.route('/fp/shopfloor/tablet_overview', type='jsonrpc', auth='user')
def tablet_overview(self, station_id=None, facility_id=None):
"""Return a rich dashboard snapshot for the Tablet Station page.
"""[DEPRECATED] Legacy Tablet Station dashboard payload.
Data layer: fp.job + fp.job.step. Field names on the response
keep the legacy `_wo` suffix where they were referenced from the
XML so the template doesn't need to be rewritten — internally
these now point at fp.job.step rows.
New consumers should use /fp/landing/kanban via the
fp_shopfloor_landing client action (Phase 3 tablet redesign).
"""
_logger.info(
"DEPRECATED /fp/shopfloor/tablet_overview called by uid %s"
"Phase 5 cleanup will remove this endpoint.",
request.env.uid,
)
env = request.env
user = env.user
@@ -984,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:
@@ -1002,8 +1037,20 @@ class FpShopfloorController(http.Controller):
# ----------------------------------------------------------------------
# Operator queue snapshot (legacy fusion.plating.operator.queue helper)
# ----------------------------------------------------------------------
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
# The new fp_shopfloor_landing component does NOT use this endpoint;
# it uses /fp/landing/kanban which already filters per station. The
# only remaining consumer is the legacy fp_shopfloor_tablet OWL
# component (still registered, no menu pointing at it). Phase 5
# cleanup will retire both this endpoint and the legacy component
# together — no replacement, the kanban supersedes it entirely.
@http.route('/fp/shopfloor/queue', type='jsonrpc', auth='user')
def queue(self, facility_id=None):
_logger.info(
"DEPRECATED /fp/shopfloor/queue called by uid %s"
"Phase 5 cleanup will remove this endpoint.",
request.env.uid,
)
Queue = request.env.get('fusion.plating.operator.queue')
if Queue is None or not hasattr(Queue, 'build_for_user'):
# Fallback: synthesize the queue directly from fp.job.step.
@@ -1061,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.'}
@@ -1085,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')
@@ -1093,14 +1142,26 @@ class FpShopfloorController(http.Controller):
return {'ok': True}
# DEPRECATED (Phase 3 tablet redesign — 2026-05-22).
# The new fp_shopfloor_landing client action has an "All Plant" mode
# that supersedes the standalone Plant Overview surface. Old endpoint
# stays live for the move_card sibling endpoint and the legacy
# fp_plant_overview OWL component (still registered but unhooked
# from the menu). Phase 5 cleanup will retire both together.
@http.route('/fp/shopfloor/plant_overview', type='jsonrpc', auth='user')
def plant_overview(self, facility_id=None, search=None):
"""Return active fp.job.step rows grouped by fp.work.centre.
"""[DEPRECATED] Legacy Plant Overview payload.
Cards are individual fp.job.step rows in ready / in_progress /
paused state. Columns are fp.work.centre rows; an "Unassigned"
pseudo-column collects steps without a work centre.
New consumers should use /fp/landing/kanban with mode='all_plant'
via the fp_shopfloor_landing client action (Phase 3 tablet
redesign). Note: /fp/shopfloor/plant_overview/move_card is NOT
deprecated — the Landing component still uses it for drag-drop.
"""
_logger.info(
"DEPRECATED /fp/shopfloor/plant_overview called by uid %s"
"Phase 5 cleanup will remove this endpoint.",
request.env.uid,
)
env = request.env
search = (search or '').strip().lower()

View File

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

View File

@@ -0,0 +1,340 @@
# -*- 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 Job Workspace client action.
Surfaces a single fp.job + step list + workflow milestones + side-panel
data (spec PDF, attachments, chatter) + action endpoints (hold, sign-off,
milestone advance).
Endpoints:
POST /fp/workspace/load — full payload for one fp.job
POST /fp/workspace/hold — create quality.hold with photo
POST /fp/workspace/sign_off — capture signature + finish step
POST /fp/workspace/advance_milestone — fire next_milestone_action
Companion plan: docs/superpowers/plans/2026-05-22-shopfloor-tablet-redesign-plan.md
"""
import logging
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__)
class FpWorkspaceController(http.Controller):
"""JSON-RPC endpoints for the JobWorkspace OWL client action."""
# ======================================================================
# /fp/workspace/load — full workspace payload
# ======================================================================
@http.route('/fp/workspace/load', type='jsonrpc', auth='user')
def load(self, job_id):
env = request.env
job = env['fp.job'].browse(int(job_id))
if not job.exists():
_logger.warning("workspace/load: job %s not found", job_id)
return {'ok': False, 'error': f'Job {job_id} not found'}
# ---- Workflow milestones ----------------------------------------
all_states = env['fp.job.workflow.state'].search([], order='sequence, id')
current = job.workflow_state_id
passed_ids = set()
for ws in all_states:
passed_ids.add(ws.id)
if ws.id == current.id:
break
workflow_states = [{
'id': ws.id,
'name': ws.name,
'color': ws.color or 'grey',
'sequence': ws.sequence or 0,
'passed': ws.id in passed_ids,
'is_current': ws.id == current.id,
} for ws in all_states]
# ---- Steps ------------------------------------------------------
steps = []
for step in job.step_ids.sorted('sequence'):
override = job.override_ids.filtered(
lambda o, n=step.recipe_node_id: o.node_id.id == n.id
) if 'override_ids' in job._fields else env['fp.job.node.override']
steps.append({
'id': step.id,
'sequence': step.sequence,
'sequence_display': (step.sequence or 0) // 10,
'name': step.name or '',
'kind': step.kind or 'other',
'kind_label': dict(step._fields['kind'].selection).get(step.kind, ''),
'state': step.state,
'assigned_user_id': step.assigned_user_id.id or False,
'assigned_user_name': step.assigned_user_id.name or '',
'work_centre_name': step.work_centre_id.name or '',
'duration_actual': step.duration_actual or 0,
'duration_expected': step.duration_expected or 0,
'date_started_iso': fp_format(
env, step.date_started, fmt='%Y-%m-%d %H:%M:%S',
) if step.date_started else '',
'instructions': step.instructions or '',
'thickness_target': step.thickness_target or 0,
'thickness_uom': step.thickness_uom or '',
'dwell_time_minutes': step.dwell_time_minutes or 0,
'bake_setpoint_temp': step.bake_setpoint_temp or 0,
'requires_signoff': bool(getattr(step, 'requires_signoff', False)),
'can_start': bool(step.can_start) if 'can_start' in step._fields else (
step.state in ('ready', 'paused') and step.blocker_kind == 'none'
),
'blocker_kind': step.blocker_kind,
'blocker_reason': step.blocker_reason or '',
'blocker_jump_target_model': step.blocker_jump_target_model or '',
'blocker_jump_target_id': step.blocker_jump_target_id or 0,
'override_excluded': bool(override and not override.included),
'quick_look_prompt_count': len(
getattr(step, 'quick_look_prompt_ids', step.browse())
),
})
# ---- Spec + attachments + chatter -------------------------------
spec = job.customer_spec_id if 'customer_spec_id' in job._fields else False
attachments = env['ir.attachment'].search([
('res_model', '=', 'fp.job'),
('res_id', '=', job.id),
], limit=20)
chatter = job.message_ids.filtered(
lambda m: m.message_type in ('comment', 'notification')
).sorted('date', reverse=True)[:10]
# ---- Required cert state ----------------------------------------
try:
needs = list(job._resolve_required_cert_types())
except Exception:
needs = []
try:
has_draft = bool(job._fp_has_draft_required_certs())
except Exception:
has_draft = False
required_certs = {'needs': needs, 'has_draft': has_draft}
# ---- Active step (the one in_progress) --------------------------
active = (
job.active_step_id
if 'active_step_id' in job._fields and job.active_step_id
else job.step_ids.filtered(lambda s: s.state == 'in_progress')[:1]
)
return {
'ok': True,
'job': {
'id': job.id,
'name': job.name,
'display_wo_name': job.display_wo_name,
'partner_name': job.partner_id.name or '',
'product_name': job.product_id.display_name or '',
'part_number': (
job.part_catalog_id.part_number
if 'part_catalog_id' in job._fields and job.part_catalog_id
else ''
),
'qty': int(job.qty or 0),
'qty_done': int(job.qty_done or 0),
'qty_scrapped': int(job.qty_scrapped or 0),
'date_deadline': fp_format(
env, job.date_deadline, fmt='%Y-%m-%d',
) if job.date_deadline else '',
'state': job.state,
'workflow_state': {
'id': current.id,
'name': current.name,
'color': current.color or 'grey',
} if current else None,
'next_milestone_action': job.next_milestone_action or '',
'next_milestone_label': job.next_milestone_label or '',
'quality_hold_count': job.quality_hold_count or 0,
'priority': job.priority or 'normal',
},
'workflow_states': workflow_states,
'steps': steps,
'active_step_id': active.id if active else False,
'spec': {
'id': spec.id,
'name': spec.name,
} if spec else None,
'attachments': [
{
'id': a.id,
'name': a.name,
'mimetype': a.mimetype or '',
'url': f'/web/content/{a.id}',
}
for a in attachments
],
'chatter': [
{
'id': m.id,
'author': m.author_id.name or 'System',
'body': m.body or '',
'date': fp_format(env, m.date, fmt='%Y-%m-%d %H:%M') if m.date else '',
}
for m in chatter
],
'required_certs': required_certs,
}
# ======================================================================
# /fp/workspace/hold — create a quality.hold from HoldComposer
# ======================================================================
@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, 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'}
if not qty_on_hold or int(qty_on_hold) < 1:
return {'ok': False, 'error': 'qty_on_hold must be at least 1'}
Hold = env['fusion.plating.quality.hold']
hold_vals = {
'part_ref': part_ref or '',
'qty_on_hold': int(qty_on_hold),
'qty_original': int(job.qty or 0),
'hold_reason': reason or 'other',
'description': description or '',
'mark_for_scrap': bool(mark_for_scrap),
}
if 'x_fc_job_id' in Hold._fields:
hold_vals['x_fc_job_id'] = job.id
if step_id and 'x_fc_step_id' in Hold._fields:
hold_vals['x_fc_step_id'] = int(step_id)
try:
hold = Hold.create(hold_vals)
except Exception as exc:
_logger.exception("workspace/hold: create failed")
return {'ok': False, 'error': str(exc)}
# Attach photo if provided (base64 string from the camera input).
# Photo attach failure does NOT roll back the hold — log + continue.
attachment_id = False
if photo_data:
try:
att = env['ir.attachment'].create({
'name': photo_filename or f'hold_{hold.id}.png',
'datas': photo_data,
'res_model': 'fusion.plating.quality.hold',
'res_id': hold.id,
'mimetype': 'image/png',
})
attachment_id = att.id
except Exception:
_logger.exception(
"workspace/hold: photo attach failed for hold %s", hold.id,
)
_logger.info(
"Hold %s created on job %s by uid %s, reason %s, qty %s",
hold.name, job.name, env.uid, reason, qty_on_hold,
)
return {
'ok': True,
'hold_id': hold.id,
'hold_name': hold.name,
'state': hold.state,
'attachment_id': attachment_id,
}
# ======================================================================
# /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, 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)
return {
'ok': False,
'error': 'A signature is required to finish this step.',
}
step = env['fp.job.step'].browse(int(step_id))
if not step.exists():
return {'ok': False, 'error': f'Step {step_id} not found'}
# Strip "data:...;base64," prefix if present (canvas.toDataURL adds it)
if ',' in sig and sig.startswith('data:'):
sig = sig.split(',', 1)[1]
try:
env['ir.attachment'].create({
'name': f'signature_{step.id}.png',
'datas': sig,
'res_model': 'fp.job.step',
'res_id': step.id,
'mimetype': 'image/png',
})
except Exception:
_logger.exception(
"workspace/sign_off: attachment failed for step %s", step.id,
)
return {'ok': False, 'error': 'Failed to save signature.'}
try:
step.button_finish()
except Exception as exc:
_logger.exception("workspace/sign_off: button_finish failed")
return {'ok': False, 'error': str(exc)}
_logger.info("Step %s signed off by uid %s", step.id, env.uid)
return {
'ok': True,
'step_id': step.id,
'state': step.state,
}
# ======================================================================
# /fp/workspace/advance_milestone — fire next_milestone_action
# ======================================================================
@http.route('/fp/workspace/advance_milestone', type='jsonrpc', auth='user')
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'}
if not job.next_milestone_action:
return {
'ok': False,
'error': 'No milestone advance available — finish all steps first.',
}
try:
job.action_advance_next_milestone()
except Exception as exc:
_logger.exception("workspace/advance_milestone failed")
return {'ok': False, 'error': str(exc)}
_logger.info(
"Job %s milestone advanced by uid %s", job.name, env.uid,
)
job.invalidate_recordset([
'workflow_state_id',
'next_milestone_action',
'next_milestone_label',
])
return {
'ok': True,
'workflow_state': {
'id': job.workflow_state_id.id,
'name': job.workflow_state_id.name,
'color': job.workflow_state_id.color or 'grey',
} if job.workflow_state_id else None,
'next_milestone_action': job.next_milestone_action or '',
'next_milestone_label': job.next_milestone_label or '',
}

View File

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

View File

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

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

View File

@@ -14,3 +14,4 @@ access_fp_first_piece_gate_manager,fp.first.piece.gate.manager,model_fusion_plat
access_fp_operator_queue_operator,fp.operator.queue.operator,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_operator,1,1,1,1
access_fp_operator_queue_supervisor,fp.operator.queue.supervisor,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_operator_queue_manager,fp.operator.queue.manager,model_fusion_plating_operator_queue,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_job_node_override_operator,fp.job.node.override.operator,fusion_plating_jobs.model_fp_job_node_override,fusion_plating.group_fusion_plating_operator,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
14 access_fp_operator_queue_operator fp.operator.queue.operator model_fusion_plating_operator_queue fusion_plating.group_fusion_plating_operator 1 1 1 1
15 access_fp_operator_queue_supervisor fp.operator.queue.supervisor model_fusion_plating_operator_queue fusion_plating.group_fusion_plating_supervisor 1 1 1 1
16 access_fp_operator_queue_manager fp.operator.queue.manager model_fusion_plating_operator_queue fusion_plating.group_fusion_plating_manager 1 1 1 1
17 access_fp_job_node_override_operator fp.job.node.override.operator fusion_plating_jobs.model_fp_job_node_override fusion_plating.group_fusion_plating_operator 1 0 0 0

View File

@@ -0,0 +1,53 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — GateViz (shared OWL service)
//
// "Can't start because…" explainer for fp.job.step blockers. Drives off
// step.blocker_kind/reason from the backend compute. Used in:
// • JobWorkspace step rows (inline)
// • Manager Plant Board "Needs Worker" cards (badge form)
//
// Props:
// canStart : Boolean — when true, renders nothing
// blockerKind : String — predecessor/contract_review/
// parts_not_received/racking_required/
// manager_input/other
// blockerReason : String — human-readable explanation
// jumpTargetModel : String — optional model name for tap-to-jump
// jumpTargetId : Number — optional record id for tap-to-jump
// onJump : Function — called with {model, id} on Jump click
// =============================================================================
import { Component } from "@odoo/owl";
export class GateViz extends Component {
static template = "fusion_plating_shopfloor.GateViz";
static props = {
canStart: { type: Boolean, optional: false },
blockerKind: { type: String, optional: true },
blockerReason: { type: String, optional: true },
jumpTargetModel: { type: String, optional: true },
jumpTargetId: { type: Number, optional: true },
onJump: { type: Function, optional: true },
};
get iconClass() {
const map = {
predecessor: "fa-lock",
contract_review: "fa-file-text-o",
parts_not_received: "fa-truck",
racking_required: "fa-th-large",
manager_input: "fa-user-md",
};
return map[this.props.blockerKind] || "fa-pause-circle";
}
onJumpClick() {
if (this.props.onJump && this.props.jumpTargetModel && this.props.jumpTargetId) {
this.props.onJump({
model: this.props.jumpTargetModel,
id: this.props.jumpTargetId,
});
}
}
}

View File

@@ -0,0 +1,102 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — HoldComposer (shared OWL service)
//
// Modal form to create a fusion.plating.quality.hold with reason picker,
// qty split, optional photo, description, and mark-for-scrap toggle.
// Calls /fp/workspace/hold; caller passes onCreated(res) to refresh.
//
// Mounted via the dialog service:
// this.dialog.add(FpHoldComposer, {
// jobId, stepId?, defaultQty, partRef, onCreated,
// });
// =============================================================================
import { Component, useState } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
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.
// Keep in sync with the fusion.plating.quality.hold.hold_reason Selection.
const HOLD_REASONS = [
{ value: "dimensional", label: "Dimensional" },
{ value: "thickness", label: "Thickness fail" },
{ value: "plating_defect", label: "Plating defect" },
{ value: "contamination", label: "Contamination" },
{ value: "wrong_part", label: "Wrong part" },
{ value: "other", label: "Other" },
];
export class FpHoldComposer extends Component {
static template = "fusion_plating_shopfloor.HoldComposer";
static components = { Dialog };
static props = {
close: Function,
jobId: { type: Number, optional: false },
stepId: { type: [Number, Boolean], optional: true },
defaultQty: { type: Number, optional: true },
partRef: { type: String, optional: true },
onCreated: { type: Function, optional: true },
};
setup() {
this.notification = useService("notification");
this.reasons = HOLD_REASONS;
this.state = useState({
reason: "dimensional",
qty: this.props.defaultQty || 1,
description: "",
photoDataUri: null,
photoFilename: "",
markForScrap: false,
submitting: false,
});
}
onPhotoChange(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
// Strip "data:...;base64," prefix — backend expects raw base64
const dataUri = e.target.result;
const base64 = dataUri.split(",", 2)[1] || "";
this.state.photoDataUri = base64;
this.state.photoFilename = file.name;
};
reader.readAsDataURL(file);
}
async onSubmit() {
if (!this.state.qty || this.state.qty < 1) {
this.notification.add("Qty on hold must be at least 1", { type: "warning" });
return;
}
this.state.submitting = true;
try {
const res = await fpRpc("/fp/workspace/hold", {
job_id: this.props.jobId,
step_id: this.props.stepId || null,
part_ref: this.props.partRef || "",
reason: this.state.reason,
qty_on_hold: this.state.qty,
description: this.state.description || "",
mark_for_scrap: this.state.markForScrap,
photo_data: this.state.photoDataUri,
photo_filename: this.state.photoFilename,
});
if (res && res.ok) {
this.notification.add(`Hold ${res.hold_name} created.`, { type: "success" });
if (this.props.onCreated) this.props.onCreated(res);
this.props.close();
} else {
this.notification.add((res && res.error) || "Hold creation failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message || String(err), { type: "danger" });
} finally {
this.state.submitting = false;
}
}
}

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,59 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — KanbanCard (shared OWL service)
//
// Standard WO/step card used on:
// • Shop Floor Landing kanban (station + all-plant modes)
// • Manager Plant Board (Needs Worker + In Progress columns)
// • Manager Workflow Funnel (compact density per stage)
//
// Embeds WorkflowChip + a blocker badge from the backend's step.blocker_kind.
//
// Props:
// data : { job_id, display_wo_name, customer, part, qty,
// qty_done, qty_scrapped, date_deadline, priority,
// workflow_state, blocker_kind, blocker_reason,
// current_step_id, work_center }
// density : 'compact' | 'normal' (default 'normal')
// showWorkflowChip : Boolean
// showWorkcenter : Boolean
// showAssignedTo : Boolean
// onTap : Function(data) — called on card click
// =============================================================================
import { Component } from "@odoo/owl";
import { WorkflowChip } from "./workflow_chip";
export class FpKanbanCard extends Component {
static template = "fusion_plating_shopfloor.KanbanCard";
static components = { WorkflowChip };
static props = {
data: { type: Object, optional: false },
density: { type: String, optional: true },
showWorkflowChip: { type: Boolean, optional: true },
showWorkcenter: { type: Boolean, optional: true },
showAssignedTo: { type: Boolean, optional: true },
onTap: { type: Function, optional: true },
};
get isCompact() {
return this.props.density === "compact";
}
get progressPct() {
const d = this.props.data;
if (!d.qty || d.qty <= 0) return 0;
return Math.min(100, Math.round((d.qty_done || 0) * 100 / d.qty));
}
get priorityDot() {
const p = this.props.data.priority;
if (p === "rush") return "danger";
if (p === "high") return "warning";
return "muted";
}
onClick() {
if (this.props.onTap) this.props.onTap(this.props.data);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,100 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — SignaturePad (shared OWL service)
//
// Modal canvas signature capture. Returns dataURI via onSubmit; the caller
// commits it (e.g. /fp/workspace/sign_off). Mounted via the dialog service:
// this.dialog.add(FpSignaturePad, { title, contextLabel, onSubmit, onCancel })
// =============================================================================
import { Component, useRef, onMounted, onWillUnmount } from "@odoo/owl";
import { Dialog } from "@web/core/dialog/dialog";
export class FpSignaturePad extends Component {
static template = "fusion_plating_shopfloor.SignaturePad";
static components = { Dialog };
static props = {
close: Function, // dialog service injects
title: { type: String, optional: true },
contextLabel: { type: String, optional: true },
onSubmit: { type: Function, optional: false }, // (dataUri) => void
onCancel: { type: Function, optional: true },
};
setup() {
this.canvasRef = useRef("canvas");
this.isDrawing = false;
this.lastPoint = null;
this.hasInk = false;
this._onDown = (ev) => {
this.isDrawing = true;
this.lastPoint = this._localPoint(ev);
};
this._onMove = (ev) => {
if (!this.isDrawing) return;
const p = this._localPoint(ev);
const ctx = this.canvasRef.el.getContext("2d");
ctx.beginPath();
ctx.moveTo(this.lastPoint.x, this.lastPoint.y);
ctx.lineTo(p.x, p.y);
ctx.stroke();
this.lastPoint = p;
this.hasInk = true;
};
this._onUp = () => {
this.isDrawing = false;
this.lastPoint = null;
};
onMounted(() => {
const canvas = this.canvasRef.el;
// Match canvas pixel size to its CSS box so strokes don't stretch
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
const ctx = canvas.getContext("2d");
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.strokeStyle = "#000";
canvas.addEventListener("pointerdown", this._onDown);
canvas.addEventListener("pointermove", this._onMove);
canvas.addEventListener("pointerup", this._onUp);
canvas.addEventListener("pointercancel", this._onUp);
canvas.addEventListener("pointerleave", this._onUp);
});
onWillUnmount(() => {
const canvas = this.canvasRef.el;
if (!canvas) return;
canvas.removeEventListener("pointerdown", this._onDown);
canvas.removeEventListener("pointermove", this._onMove);
canvas.removeEventListener("pointerup", this._onUp);
canvas.removeEventListener("pointercancel", this._onUp);
canvas.removeEventListener("pointerleave", this._onUp);
});
}
_localPoint(ev) {
const r = this.canvasRef.el.getBoundingClientRect();
return { x: ev.clientX - r.left, y: ev.clientY - r.top };
}
onClear() {
const canvas = this.canvasRef.el;
canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height);
this.hasInk = false;
}
onSubmit() {
if (!this.hasInk) return;
const dataUri = this.canvasRef.el.toDataURL("image/png");
this.props.onSubmit(dataUri);
this.props.close();
}
onCancel() {
if (this.props.onCancel) this.props.onCancel();
this.props.close();
}
}

View File

@@ -0,0 +1,39 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — WorkflowChip (shared OWL service)
//
// Renders an fp.job.workflow.state as a colored pill + optional next-action
// hint. Used by KanbanCard, JobWorkspace header, Manager Funnel.
//
// Props:
// state : { id, name, color } — required
// nextActionLabel : string — optional
//
// Color map mirrors the fp.job.workflow.state.color Selection
// (grey/blue/cyan/yellow/orange/green/success/danger/purple).
// =============================================================================
import { Component } from "@odoo/owl";
export class WorkflowChip extends Component {
static template = "fusion_plating_shopfloor.WorkflowChip";
static props = {
state: { type: Object, optional: false },
nextActionLabel: { type: String, optional: true },
};
get toneClass() {
const map = {
grey: "muted",
blue: "info",
cyan: "info",
yellow: "warning",
orange: "warning",
green: "success",
success: "success",
danger: "danger",
purple: "info",
};
return map[this.props.state.color] || "muted";
}
}

View File

@@ -0,0 +1,220 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Job Workspace (full-screen WO surface)
// Client action: fp_job_workspace
//
// Opens from: kanban tap (Landing — Phase 3), smart button (fp.job form),
// QR scan (FP-JOB/FP-STEP), manager dashboard card tap (Phase 4).
//
// Layout (top-to-bottom):
// sticky header — WO #, customer, part, qty/done, deadline, holds
// sticky workflow bar — 9-stage milestone dots + Next-action button
// scrollable main:
// left/center — step list with active expansion + GateViz
// right side panel — spec PDF link + attachments + chatter
// sticky action rail — Hold · Note · Milestone advance · Issue Cert
//
// Auto-refresh: every 15s.
// =============================================================================
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 { 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, 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,
jobId: null,
focusStepId: null,
});
onMounted(async () => {
const params = (this.props.action && this.props.action.params) || {};
this.state.jobId = params.job_id || null;
this.state.focusStepId = params.focus_step_id || null;
await this.refresh();
this._refreshInterval = setInterval(() => this.refresh(), 15000);
});
onWillUnmount(() => {
if (this._refreshInterval) clearInterval(this._refreshInterval);
});
}
// ---- Data refresh ------------------------------------------------------
async refresh() {
if (!this.state.jobId) return;
try {
const res = await rpc("/fp/workspace/load", { job_id: this.state.jobId });
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" });
}
}
// ---- Navigation --------------------------------------------------------
onBack() {
// Close workspace; return to whatever spawned the action
this.action.doAction({ type: "ir.actions.act_window_close" });
}
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
this.techStore.lock();
}
onJumpToBlocker({ model, id }) {
// If the predecessor is in this same workspace, just scroll to it
const inThisJob = (this.state.data.steps || []).find((s) => s.id === id);
if (inThisJob) {
const el = document.querySelector(`[data-step-id="${id}"]`);
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
return;
}
this.action.doAction({
type: "ir.actions.act_window",
res_model: model,
res_id: id,
views: [[false, "form"]],
target: "current",
});
}
// ---- Step state helpers ------------------------------------------------
iconForStepState(state) {
const map = {
ready: "○", paused: "⏸", in_progress: "▶",
done: "✓", skipped: "✕", cancelled: "✕",
};
return map[state] || "○";
}
isStepActive(step) {
return step.state === "in_progress";
}
// ---- Step actions ------------------------------------------------------
async onStartStep(stepId) {
try {
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();
} else {
this.notification.add((res && res.error) || "Start failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
async onFinishStep(step) {
if (step.requires_signoff) {
this.dialog.add(FpSignaturePad, {
title: `Sign to finish ${step.name}`,
contextLabel: `${this.state.data.job.display_wo_name} · Step ${step.sequence_display}: ${step.name}`,
onSubmit: async (dataUri) => {
try {
const res = await fpRpc("/fp/workspace/sign_off", {
step_id: step.id,
signature_data_uri: dataUri,
});
if (res && res.ok) {
this.notification.add("Step signed off and finished.", { type: "success" });
await this.refresh();
} else {
this.notification.add((res && res.error) || "Sign-off failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
},
});
return;
}
// Plain finish — no signature required
try {
const res = await fpRpc("/fp/shopfloor/stop_wo", {
workorder_id: step.id, finish: true,
});
if (res && res.ok) {
this.notification.add("Step finished.", { type: "success" });
await this.refresh();
} else {
this.notification.add((res && res.error) || "Finish failed", { type: "danger" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
// ---- Action rail handlers ---------------------------------------------
onCreateHold() {
const job = this.state.data.job;
const remaining = Math.max(1, (job.qty || 0) - (job.qty_done || 0));
this.dialog.add(FpHoldComposer, {
jobId: this.state.jobId,
defaultQty: remaining,
partRef: job.part_number || "",
onCreated: () => this.refresh(),
});
}
async onAddNote() {
const text = window.prompt("Add a note to this WO:");
if (!text) return;
try {
// ORM call for message_post — keeps chatter behaviour identical
// to back-office posts (handles HTML escaping, subscribers, etc.)
await rpc("/web/dataset/call_kw", {
model: "fp.job",
method: "message_post",
args: [[this.state.jobId]],
kwargs: { body: text, message_type: "comment" },
});
this.notification.add("Note added.", { type: "success" });
await this.refresh();
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
async onAdvanceMilestone() {
try {
const res = await fpRpc("/fp/workspace/advance_milestone", {
job_id: this.state.jobId,
});
if (res && res.ok) {
this.notification.add("Milestone advanced.", { type: "success" });
await this.refresh();
} else {
this.notification.add((res && res.error) || "Advance failed", { type: "warning" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
}
}
}
registry.category("actions").add("fp_job_workspace", FpJobWorkspace);

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,
@@ -43,15 +46,27 @@ export class ManagerDashboard extends Component {
// Defaults to false because lead-hand coverage often needs
// off-roster names.
hideOffShift: false,
// Phase 4 tablet redesign — 4 sibling tabs.
// funnel | inbox | plant_board | at_risk
activeTab: "funnel",
funnel: null, // /fp/manager/funnel payload
inbox: null, // /fp/manager/approval_inbox payload
atRisk: null, // /fp/manager/at_risk payload
});
this._lastHash = null; // sent to server to skip unchanged polls
onMounted(async () => {
await this.refresh();
// Load the default tab's data (Workflow Funnel) on first paint
await this.loadFunnel();
// 8s cadence: fast enough for production pace, light on the
// network since unchanged payloads short-circuit server-side.
this._interval = setInterval(() => this.refresh(), 8000);
// The active tab's data also refreshes on each tick.
this._interval = setInterval(() => {
this.refresh();
this.refreshActiveTab();
}, 8000);
});
onWillUnmount(() => {
@@ -136,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;
}
@@ -189,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) {
@@ -207,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) {
@@ -224,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) {
@@ -283,6 +303,85 @@ export class ManagerDashboard extends Component {
target: "current",
});
}
// ==================================================================
// Phase 4 tablet redesign — 4 sibling tabs
// ==================================================================
async setActiveTab(tab) {
if (this.state.activeTab === tab) return;
this.state.activeTab = tab;
// Load the tab's data on first switch — subsequent ticks refresh
// via the auto-poll.
await this.refreshActiveTab();
}
async refreshActiveTab() {
if (this.state.activeTab === "funnel") return this.loadFunnel();
if (this.state.activeTab === "inbox") return this.loadInbox();
if (this.state.activeTab === "at_risk") return this.loadAtRisk();
// plant_board uses /fp/manager/overview via refresh()
}
async loadFunnel() {
try {
const res = await rpc("/fp/manager/funnel", {});
if (res && res.ok) this.state.funnel = res;
} catch (err) {
this.setMessage(`Funnel: ${err.message}`, "danger");
}
}
async loadInbox() {
try {
const res = await rpc("/fp/manager/approval_inbox", {});
if (res && res.ok) this.state.inbox = res;
} catch (err) {
this.setMessage(`Inbox: ${err.message}`, "danger");
}
}
async loadAtRisk() {
try {
const res = await rpc("/fp/manager/at_risk", {});
if (res && res.ok) this.state.atRisk = res;
} catch (err) {
this.setMessage(`At-Risk: ${err.message}`, "danger");
}
}
// Tap a WO card on any tab → open the JobWorkspace (Phase 1)
openJobWorkspace(jobId) {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_job_workspace",
params: { job_id: jobId },
target: "current",
});
}
// Pill colour from workflow_state.color (mirrors WorkflowChip toneClass)
funnelStageTone(color) {
const map = {
grey: "muted", blue: "info", cyan: "info",
yellow: "warning", orange: "warning",
green: "success", success: "success",
danger: "danger", purple: "info",
};
return map[color] || "muted";
}
// Bottleneck severity tone for the heatmap bar colour
bottleneckTone(score) {
if (score >= 200) return "danger";
if (score >= 60) return "warning";
return "success";
}
bottleneckPct(score) {
// Normalize to 0-100 for the bar width; cap at 100
return Math.min(100, Math.round(score / 5));
}
}
registry.category("actions").add("fp_manager_dashboard", ManagerDashboard);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,277 @@
/** @odoo-module **/
// =============================================================================
// Fusion Plating — Shop Floor Landing (OWL client action)
// Client action: fp_shopfloor_landing
//
// Replaces fp_shopfloor_tablet AND folds in fp_plant_overview. Single
// kanban entry surface for technicians. Two modes:
//
// station — paired station's work centre + Unassigned + next 1-2
// WCs in recipe flow. Default when a station is paired.
// all_plant — every active work centre. Default with no station.
//
// Tap a card → JobWorkspace. QR scan: stations pair, jobs jump.
// Drag-and-drop between columns reassigns step.work_centre_id (existing
// /fp/shopfloor/plant_overview/move_card endpoint).
//
// Auto-refresh: 15s. Mode + station_id persist in localStorage.
// =============================================================================
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 { FpKanbanCard } from "./components/kanban_card";
import { FpTabletLock } from "./tablet_lock";
const LS_STATION_ID = "fp_landing_station_id";
const LS_MODE = "fp_landing_mode";
const REFRESH_MS = 15000;
export class FpShopfloorLanding extends Component {
static template = "fusion_plating_shopfloor.ShopfloorLanding";
static props = ["*"];
static components = { QrScanner, FpKanbanCard, FpTabletLock };
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.techStore = useService("fp_shopfloor_tech_store");
this.state = useState({
mode: localStorage.getItem(LS_MODE) || "all_plant",
stationId: parseInt(localStorage.getItem(LS_STATION_ID) || "0") || null,
data: null,
search: "",
scanInput: "",
showScan: false,
lastRefresh: "",
});
this._draggedCard = null;
this._movesInFlight = 0;
this._lastDropAt = 0;
this._searchTimer = null;
onMounted(async () => {
await this.refresh();
this._refreshInterval = setInterval(() => {
if (this._movesInFlight > 0) return;
if (Date.now() - this._lastDropAt < 5000) return;
this.refresh();
}, REFRESH_MS);
});
onWillUnmount(() => {
if (this._refreshInterval) clearInterval(this._refreshInterval);
if (this._searchTimer) clearTimeout(this._searchTimer);
});
}
// ---- Data load ---------------------------------------------------------
async refresh() {
try {
const res = await rpc("/fp/landing/kanban", {
mode: this.state.mode,
station_id: this.state.stationId,
search: this.state.search || null,
});
if (res && res.ok) {
this.state.data = res;
this.state.lastRefresh = res.server_time || new Date().toLocaleTimeString();
// If station resolved (e.g. via QR scan), persist its id
if (res.station && res.station.id) {
this.state.stationId = res.station.id;
localStorage.setItem(LS_STATION_ID, String(res.station.id));
}
}
} catch (err) {
this.notification.add(err.message || String(err), { type: "danger" });
}
}
// ---- Mode toggle -------------------------------------------------------
setMode(mode) {
if (this.state.mode === mode) return;
this.state.mode = mode;
localStorage.setItem(LS_MODE, mode);
this.refresh();
}
// ---- Station picker ----------------------------------------------------
onPickStation(ev) {
const id = parseInt(ev.target.value) || null;
this.state.stationId = id;
if (id) {
localStorage.setItem(LS_STATION_ID, String(id));
// Picking a station naturally switches to station mode
this.state.mode = "station";
localStorage.setItem(LS_MODE, "station");
} else {
localStorage.removeItem(LS_STATION_ID);
}
this.refresh();
}
onUnpairStation() {
this.state.stationId = null;
this.state.mode = "all_plant";
localStorage.removeItem(LS_STATION_ID);
localStorage.setItem(LS_MODE, "all_plant");
this.refresh();
}
// ---- Hand-Off (Phase 6.2) ---------------------------------------------
handOff() {
// Tech walking away: lock the tablet so the next operator must PIN in
this.techStore.lock();
}
// ---- Search ------------------------------------------------------------
onSearchInput(ev) {
this.state.search = ev.target.value;
if (this._searchTimer) clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => this.refresh(), 200);
}
onSearchKey(ev) {
if (ev.key === "Enter") {
if (this._searchTimer) clearTimeout(this._searchTimer);
this.refresh();
} else if (ev.key === "Escape") {
this.state.search = "";
this.refresh();
}
}
// ---- Tap card → JobWorkspace ------------------------------------------
onCardTap(cardData) {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_job_workspace",
params: {
job_id: cardData.job_id,
focus_step_id: cardData.current_step_id,
},
target: "current",
});
}
// ---- QR scan -----------------------------------------------------------
toggleScan() {
this.state.showScan = !this.state.showScan;
}
async onScanSubmit() {
const code = (this.state.scanInput || "").trim();
if (!code) return;
try {
const res = await rpc("/fp/shopfloor/scan", { qr_code: code });
if (!res || !res.ok) {
this.notification.add((res && res.error) || "Unrecognised QR", { type: "danger" });
return;
}
if (res.model === "fusion.plating.shopfloor.station") {
this.state.stationId = res.id;
this.state.mode = "station";
localStorage.setItem(LS_STATION_ID, String(res.id));
localStorage.setItem(LS_MODE, "station");
this.notification.add(`Paired to ${res.name}`, { type: "success" });
} else if (res.model === "fp.job") {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_job_workspace",
params: { job_id: res.id },
target: "current",
});
return;
} else if (res.model === "fp.job.step") {
this.action.doAction({
type: "ir.actions.client",
tag: "fp_job_workspace",
params: { job_id: res.job_id || 0, focus_step_id: res.id },
target: "current",
});
return;
} else {
this.notification.add(`Scanned ${res.model}`, { type: "info" });
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
} finally {
this.state.scanInput = "";
await this.refresh();
}
}
onScanKey(ev) {
if (ev.key === "Enter") this.onScanSubmit();
}
// ---- Drag-and-drop -----------------------------------------------------
// Reuses the existing /fp/shopfloor/plant_overview/move_card endpoint,
// which still works for re-assigning step.work_centre_id.
onCardDragStart(card, col, ev) {
this._draggedCard = {
id: card.step_id,
source_wc_id: col.work_center_id,
};
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", String(card.step_id));
}
onColDragOver(col, ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
}
async onColDrop(col, ev) {
ev.preventDefault();
const dragged = this._draggedCard;
this._draggedCard = null;
if (!dragged) return;
if (dragged.source_wc_id === col.work_center_id) return;
// Optimistic move: pop from source, push to target
const srcIdx = this.state.data.columns.findIndex(c => c.work_center_id === dragged.source_wc_id);
const tgtIdx = this.state.data.columns.findIndex(c => c.work_center_id === col.work_center_id);
let movedCard = null;
if (srcIdx >= 0 && tgtIdx >= 0) {
const src = this.state.data.columns[srcIdx].cards;
const idx = src.findIndex(c => c.step_id === dragged.id);
if (idx >= 0) {
movedCard = src[idx];
this.state.data.columns[srcIdx].cards = [
...src.slice(0, idx), ...src.slice(idx + 1),
];
this.state.data.columns[tgtIdx].cards = [
movedCard, ...this.state.data.columns[tgtIdx].cards,
];
}
}
this._movesInFlight += 1;
this._lastDropAt = Date.now();
try {
const res = await fpRpc("/fp/shopfloor/plant_overview/move_card", {
card_id: dragged.id,
target_workcenter_id: col.work_center_id,
});
if (res && res.ok) {
this.notification.add(`Moved to ${col.work_center_name}`, { type: "success" });
} else {
this.notification.add((res && res.error) || "Move failed", { type: "warning" });
await this.refresh(); // server is the source of truth on conflict
}
} catch (err) {
this.notification.add(err.message, { type: "danger" });
await this.refresh();
} finally {
this._movesInFlight -= 1;
}
}
}
registry.category("actions").add("fp_shopfloor_landing", FpShopfloorLanding);

View File

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

View File

@@ -0,0 +1,30 @@
// =============================================================================
// GateViz — "this step can't start because..." explainer
// Dark-mode aware via $o-webclient-color-scheme branch.
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_gate-bg-hex: rgba(255, 159, 10, 0.10);
$_gate-border-hex: #ff9f0a;
$_gate-text-hex: #b06600;
@if $o-webclient-color-scheme == dark {
$_gate-text-hex: #ffb84d !global;
}
.o_fp_gate {
background: $_gate-bg-hex;
border-left: 3px solid $_gate-border-hex;
padding: 0.5rem 0.75rem;
border-radius: 0 6px 6px 0;
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.o_fp_gate_icon { color: $_gate-border-hex; margin-top: 0.15rem; }
.o_fp_gate_body { flex: 1; }
.o_fp_gate_title { font-weight: 600; color: $_gate-text-hex; font-size: 0.85rem; }
.o_fp_gate_reason { color: var(--text-secondary, #666); font-size: 0.78rem; margin-top: 0.1rem; }
.o_fp_gate_jump { flex-shrink: 0; }

View File

@@ -0,0 +1,21 @@
// =============================================================================
// HoldComposer — modal hold-create form
// =============================================================================
.o_fp_hc {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.o_fp_hc_row {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.o_fp_hc_row label {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary, #666);
}

View File

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

View File

@@ -0,0 +1,83 @@
// =============================================================================
// KanbanCard — standard WO/step card for Landing + Manager surfaces
// Dark-mode aware via $o-webclient-color-scheme branch.
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_kc-bg-hex: #ffffff;
$_kc-border-hex: #d8dadd;
$_kc-hover-hex: #f5f5f7;
@if $o-webclient-color-scheme == dark {
$_kc-bg-hex: #22262d !global;
$_kc-border-hex: #424245 !global;
$_kc-hover-hex: #2d3138 !global;
}
.o_fp_kcard {
background: $_kc-bg-hex;
border: 1px solid $_kc-border-hex;
border-radius: 6px;
padding: 0.55rem 0.7rem;
cursor: pointer;
transition: background 0.1s ease;
&:hover { background: $_kc-hover-hex; }
}
.o_fp_kcard_compact { padding: 0.4rem 0.55rem; }
.o_fp_kcard_h1 {
display: flex; justify-content: space-between; align-items: center;
font-weight: 700; font-size: 0.85rem;
}
.o_fp_kcard_h2 {
color: var(--text-secondary, #666);
font-size: 0.75rem;
margin-top: 0.15rem;
}
.o_fp_kcard_qty {
display: flex; justify-content: space-between;
font-size: 0.7rem; color: var(--text-secondary, #777);
margin-top: 0.3rem;
}
.o_fp_kcard_due { color: var(--text-secondary, #999); }
.o_fp_kcard_bar {
height: 4px; background: rgba(0,0,0,0.08);
border-radius: 2px; overflow: hidden;
margin-top: 0.3rem;
> div { height: 100%; background: #34c759; }
}
.o_fp_kcard_chips {
display: flex; gap: 0.35rem; flex-wrap: wrap;
margin-top: 0.4rem;
}
.o_fp_kcard_pri { width: 8px; height: 8px; border-radius: 50%; }
.o_fp_kcard_pri_danger { background: #ff3b30; }
.o_fp_kcard_pri_warning { background: #ff9f0a; }
.o_fp_kcard_pri_muted { background: transparent; }
.o_fp_kcard_blocked {
background: rgba(255,159,10,0.15);
color: #b06600;
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.7rem;
}
.o_fp_kcard_wc {
color: var(--text-secondary, #999);
font-size: 0.7rem;
}
@if $o-webclient-color-scheme == dark {
.o_fp_kcard_blocked { color: #ffb84d; }
}

View File

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

View File

@@ -0,0 +1,41 @@
// =============================================================================
// SignaturePad — modal canvas signature capture
// Canvas stays light even in dark mode (signature legibility).
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_sig-canvas-bg-hex: #ffffff;
$_sig-canvas-border-hex: #d8dadd;
@if $o-webclient-color-scheme == dark {
$_sig-canvas-bg-hex: #f5f5f5 !global;
$_sig-canvas-border-hex: #5a5a5e !global;
}
.o_fp_sig_pad {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.o_fp_sig_ctx {
font-size: 0.85rem;
color: var(--text-secondary, #666);
}
.o_fp_sig_canvas {
width: 100%;
height: 200px;
background: $_sig-canvas-bg-hex;
border: 2px solid $_sig-canvas-border-hex;
border-radius: 6px;
cursor: crosshair;
touch-action: none;
}
.o_fp_sig_hint {
font-size: 0.75rem;
color: var(--text-secondary, #999);
text-align: center;
}

View File

@@ -0,0 +1,57 @@
// =============================================================================
// WorkflowChip — colored milestone pill
// Dark-mode aware via $o-webclient-color-scheme branch (registered in BOTH
// web.assets_backend AND web.assets_web_dark — see manifest).
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_wf-bg-muted-hex: #f0f0f2;
$_wf-bg-info-hex: rgba(0, 113, 227, 0.15);
$_wf-bg-success-hex: rgba(52, 199, 89, 0.15);
$_wf-bg-warning-hex: rgba(255, 159, 10, 0.15);
$_wf-bg-danger-hex: rgba(255, 59, 48, 0.15);
@if $o-webclient-color-scheme == dark {
$_wf-bg-muted-hex: #2d2d2f !global;
}
.o_fp_wf_chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.65rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
}
.o_fp_wf_dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
opacity: 0.85;
}
.o_fp_wf_next {
font-weight: 400;
opacity: 0.75;
margin-left: 0.15rem;
}
.o_fp_wf_chip_muted { background: $_wf-bg-muted-hex; color: #666; }
.o_fp_wf_chip_info { background: $_wf-bg-info-hex; color: #0050a0; }
.o_fp_wf_chip_success { background: $_wf-bg-success-hex; color: #1d6e2f; }
.o_fp_wf_chip_warning { background: $_wf-bg-warning-hex; color: #b06600; }
.o_fp_wf_chip_danger { background: $_wf-bg-danger-hex; color: #b00018; }
@if $o-webclient-color-scheme == dark {
.o_fp_wf_chip_muted { color: #a8a8b0; }
.o_fp_wf_chip_info { color: #6cb6ff; }
.o_fp_wf_chip_success { color: #6be398; }
.o_fp_wf_chip_warning { color: #ffb84d; }
.o_fp_wf_chip_danger { color: #ff7a72; }
}

View File

@@ -0,0 +1,319 @@
// =============================================================================
// JobWorkspace — full-screen WO surface
// Dark-mode aware via $o-webclient-color-scheme branch.
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_ws-page-hex: #f3f4f6;
$_ws-card-hex: #ffffff;
$_ws-border-hex: #d8dadd;
$_ws-text-hex: #1d1d1f;
@if $o-webclient-color-scheme == dark {
$_ws-page-hex: #1a1d21 !global;
$_ws-card-hex: #22262d !global;
$_ws-border-hex: #424245 !global;
$_ws-text-hex: #f5f5f7 !global;
}
.o_fp_ws {
display: flex;
flex-direction: column;
height: 100%;
background: $_ws-page-hex;
color: $_ws-text-hex;
overflow: hidden;
}
.o_fp_ws_loading {
margin: auto;
text-align: center;
color: var(--text-secondary, #666);
> div { margin-top: 0.6rem; }
}
// ---- HEADER ------------------------------------------------------------
.o_fp_ws_head {
background: $_ws-card-hex;
border-bottom: 1px solid $_ws-border-hex;
padding: 0.6rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.o_fp_ws_head_l, .o_fp_ws_head_r {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.o_fp_ws_back { padding: 0.25rem 0.5rem; }
.o_fp_ws_wo { font-weight: 700; font-size: 1.1rem; }
.o_fp_ws_dot { color: var(--text-secondary, #999); }
.o_fp_ws_cust, .o_fp_ws_part { color: var(--text-secondary, #555); }
.o_fp_ws_pill {
background: $_ws-page-hex;
border: 1px solid $_ws-border-hex;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.78rem;
color: var(--text-secondary, #555);
}
.o_fp_ws_holds_ok {
background: rgba(52, 199, 89, 0.12);
color: #1d6e2f;
border-color: rgba(52, 199, 89, 0.3);
}
.o_fp_ws_holds_red {
background: rgba(255, 59, 48, 0.12);
color: #b00018;
border-color: rgba(255, 59, 48, 0.3);
}
// ---- WORKFLOW BAR ------------------------------------------------------
.o_fp_ws_bar {
background: $_ws-page-hex;
border-bottom: 1px solid $_ws-border-hex;
padding: 0.55rem 1rem;
display: flex;
align-items: center;
gap: 1rem;
}
.o_fp_ws_bar_line {
flex: 1;
display: flex;
align-items: center;
gap: 0.3rem;
}
.o_fp_ws_dot_wrap {
display: flex;
flex-direction: column;
align-items: center;
min-width: 60px;
.o_fp_ws_bar_dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: $_ws-border-hex;
border: 2px solid $_ws-border-hex;
}
.o_fp_ws_bar_label {
font-size: 0.65rem;
color: var(--text-secondary, #888);
margin-top: 0.2rem;
text-align: center;
}
&.done .o_fp_ws_bar_dot {
background: #34c759;
border-color: #34c759;
}
&.current .o_fp_ws_bar_dot {
background: #0071e3;
border-color: #0071e3;
box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.25);
}
&.current .o_fp_ws_bar_label {
color: #0071e3;
font-weight: 600;
}
}
.o_fp_ws_link {
flex: 0 0 8px;
height: 2px;
background: $_ws-border-hex;
margin-top: -16px;
&.done { background: #34c759; }
}
.o_fp_ws_next { white-space: nowrap; }
// ---- MAIN (step list + side panel) -------------------------------------
.o_fp_ws_main {
flex: 1;
display: grid;
grid-template-columns: 1.7fr 1fr;
overflow: hidden;
@media (max-width: 900px) { grid-template-columns: 1fr; }
}
.o_fp_ws_steps {
padding: 0.7rem 1rem;
overflow-y: auto;
border-right: 1px solid $_ws-border-hex;
}
.o_fp_ws_side {
padding: 0.7rem 1rem;
overflow-y: auto;
background: $_ws-page-hex;
}
.o_fp_ws_empty {
text-align: center;
padding: 2rem 1rem;
color: var(--text-secondary, #999);
> div { margin-top: 0.5rem; }
}
// ---- STEP ROW ---------------------------------------------------------
.o_fp_ws_step {
background: $_ws-card-hex;
border: 1px solid $_ws-border-hex;
border-radius: 6px;
padding: 0.5rem 0.7rem;
margin-bottom: 0.4rem;
&.done { opacity: 0.7; }
&.active {
border: 2px solid #0071e3;
padding: 0.6rem 0.75rem;
box-shadow: 0 0 0 1px #0071e3;
}
&.blocked {
background: rgba(255, 159, 10, 0.06);
border-color: #ff9f0a;
}
&.excluded { opacity: 0.5; }
}
.o_fp_ws_step_l1 {
display: flex;
align-items: center;
gap: 0.45rem;
}
.o_fp_ws_step_icon { width: 18px; text-align: center; font-weight: 700; }
.o_fp_ws_step_num { color: var(--text-secondary, #999); font-size: 0.78rem; min-width: 50px; }
.o_fp_ws_step_name { font-weight: 600; }
.o_fp_ws_step_meta { color: var(--text-secondary, #999); font-size: 0.78rem; margin-left: auto; }
.o_fp_ws_step_badge {
background: #0071e3;
color: white;
padding: 0.1rem 0.4rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.04em;
margin-left: 0.4rem;
}
.o_fp_ws_step_detail {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed $_ws-border-hex;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.o_fp_ws_step_chips { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.o_fp_ws_step_instr { font-size: 0.78rem; color: var(--text-secondary, #555); font-style: italic; }
.o_fp_ws_step_actions { display: flex; gap: 0.35rem; flex-wrap: wrap; }
.o_fp_ws_step_excluded {
font-size: 0.78rem;
color: var(--text-secondary, #888);
font-style: italic;
}
// ---- Chips (inline mini-chips for recipe targets) ---------------------
.o_fp_chip {
padding: 0.15rem 0.45rem;
border-radius: 999px;
font-size: 0.72rem;
}
.o_fp_chip_info { background: rgba(0, 113, 227, 0.12); color: #0050a0; }
.o_fp_chip_warning { background: rgba(255, 159, 10, 0.15); color: #b06600; }
@if $o-webclient-color-scheme == dark {
.o_fp_chip_info { color: #6cb6ff; }
.o_fp_chip_warning { color: #ffb84d; }
}
// ---- SIDE PANEL CARDS -------------------------------------------------
.o_fp_ws_side_card {
background: $_ws-card-hex;
border: 1px solid $_ws-border-hex;
border-radius: 6px;
padding: 0.55rem 0.7rem;
margin-bottom: 0.45rem;
h4 {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary, #777);
margin-bottom: 0.35rem;
}
}
.o_fp_ws_spec_link {
display: flex;
gap: 0.4rem;
align-items: center;
font-size: 0.82rem;
}
.o_fp_ws_attach {
display: flex;
justify-content: space-between;
padding: 0.2rem 0;
font-size: 0.78rem;
border-bottom: 1px dashed $_ws-border-hex;
&:last-child { border-bottom: none; }
}
.o_fp_ws_note {
font-size: 0.78rem;
padding: 0.3rem 0;
}
.o_fp_ws_note_h {
display: flex;
gap: 0.4rem;
font-size: 0.72rem;
color: var(--text-secondary, #777);
}
.o_fp_ws_note .author { font-weight: 600; }
.o_fp_ws_note .body { color: var(--text-secondary, #555); margin-top: 0.15rem; }
.o_fp_ws_empty_small {
color: var(--text-secondary, #999);
font-size: 0.75rem;
font-style: italic;
}
// ---- ACTION RAIL ------------------------------------------------------
.o_fp_ws_rail {
background: $_ws-card-hex;
border-top: 1px solid $_ws-border-hex;
padding: 0.55rem 1rem;
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}

View File

@@ -646,3 +646,230 @@
display: flex; gap: $fp-space-1; margin-top: 4px;
}
}
// =============================================================================
// Phase 4 tablet redesign — Manager dashboard sibling tabs
// =============================================================================
.o_fp_mgr_tabs {
display: flex;
gap: 4px;
padding: 8px 16px 0;
border-bottom: 1px solid $fp-border;
background: $fp-card;
.o_fp_mgr_tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 8px 14px;
font-size: 0.9rem;
color: $fp-ink-soft;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: color 0.15s ease, border-color 0.15s ease;
&:hover { color: $fp-ink; }
&.active {
color: $fp-accent;
border-bottom-color: $fp-accent;
font-weight: 600;
}
.o_fp_mgr_tab_badge {
background: $fp-accent;
color: white;
border-radius: 999px;
padding: 1px 8px;
font-size: 0.65rem;
font-weight: 700;
margin-left: 4px;
}
}
}
// ---- Workflow Funnel tab -------------------------------------------------
.o_fp_mgr_funnel {
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
.o_fp_funnel_row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid $fp-border;
}
.o_fp_funnel_stage {
display: flex;
align-items: center;
gap: 8px;
min-width: 200px;
}
.o_fp_funnel_count {
font-weight: 700;
font-size: 1.1rem;
min-width: 28px;
text-align: right;
}
.o_fp_funnel_cards {
display: flex;
gap: 6px;
flex: 1;
overflow-x: auto;
align-items: center;
}
.o_fp_funnel_card {
background: $fp-card;
border: 1px solid $fp-border;
border-radius: 6px;
padding: 6px 10px;
font-size: 0.78rem;
min-width: 130px;
cursor: pointer;
transition: background 0.1s ease, border-color 0.1s ease;
&:hover {
background: color-mix(in srgb, #{$fp-accent} 5%, #{$fp-card});
border-color: color-mix(in srgb, #{$fp-accent} 30%, #{$fp-border});
}
.o_fp_funnel_card_wo { font-weight: 600; }
.o_fp_funnel_card_meta { color: $fp-ink-soft; font-size: 0.7rem; }
}
.o_fp_funnel_more, .o_fp_funnel_empty {
color: $fp-ink-soft;
font-size: 0.78rem;
padding: 0 6px;
}
}
// ---- Approval Inbox tab --------------------------------------------------
.o_fp_mgr_inbox {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
.o_fp_inbox_strip {
background: $fp-card;
border: 1px solid $fp-border;
border-radius: 8px;
padding: 12px 16px;
h4 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $fp-ink-soft;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
}
.o_fp_inbox_row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
font-size: 0.85rem;
border-bottom: 1px dashed $fp-border;
&:last-child { border-bottom: none; }
.ms-auto { margin-left: auto; }
}
.o_fp_empty_small {
color: $fp-ink-soft;
font-size: 0.8rem;
font-style: italic;
padding: 4px 0;
}
}
// ---- At-Risk tab ---------------------------------------------------------
.o_fp_mgr_atrisk {
padding: 16px;
.o_fp_atrisk_grid {
display: grid;
grid-template-columns: 1.4fr 1fr 1.2fr;
gap: 12px;
@media (max-width: 1100px) {
grid-template-columns: 1fr;
}
}
.o_fp_atrisk_card {
background: $fp-card;
border: 1px solid $fp-border;
border-radius: 8px;
padding: 12px 16px;
h4 {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $fp-ink-soft;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
}
.o_fp_atrisk_row {
display: flex;
gap: 6px;
padding: 6px 0;
font-size: 0.82rem;
border-bottom: 1px dashed $fp-border;
align-items: center;
cursor: default;
&[t-on-click], &:hover { cursor: pointer; }
&:last-child { border-bottom: none; }
.ms-auto { margin-left: auto; }
}
.o_fp_atrisk_bar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 0.78rem;
.o_fp_atrisk_bar_name { min-width: 100px; }
.o_fp_atrisk_bar_track {
flex: 1;
height: 10px;
background: color-mix(in srgb, #{$fp-ink-soft} 15%, transparent);
border-radius: 5px;
overflow: hidden;
}
.o_fp_atrisk_bar_fill { height: 100%; display: block; }
.o_fp_atrisk_bar_danger { background: #ff3b30; }
.o_fp_atrisk_bar_warning { background: #ff9f0a; }
.o_fp_atrisk_bar_success { background: #34c759; }
.o_fp_atrisk_bar_score { font-weight: 600; min-width: 32px; text-align: right; }
}
.o_fp_empty_small {
color: $fp-ink-soft;
font-size: 0.8rem;
font-style: italic;
padding: 4px 0;
}
}

View File

@@ -0,0 +1,246 @@
// =============================================================================
// Shop Floor Landing — kanban entry surface (Phase 3 tablet redesign)
// Replaces fp_shopfloor_tablet + fp_plant_overview.
// Dark-mode aware via $o-webclient-color-scheme branch.
// =============================================================================
$o-webclient-color-scheme: bright !default;
$_lan-page-hex: #f3f4f6;
$_lan-card-hex: #ffffff;
$_lan-border-hex: #d8dadd;
$_lan-text-hex: #1d1d1f;
@if $o-webclient-color-scheme == dark {
$_lan-page-hex: #1a1d21 !global;
$_lan-card-hex: #22262d !global;
$_lan-border-hex: #424245 !global;
$_lan-text-hex: #f5f5f7 !global;
}
.o_fp_landing {
display: flex;
flex-direction: column;
height: 100%;
background: $_lan-page-hex;
color: $_lan-text-hex;
overflow: hidden;
}
.o_fp_landing_loading {
margin: auto;
text-align: center;
color: var(--text-secondary, #666);
> div { margin-top: 0.6rem; }
}
// ---- HEADER ------------------------------------------------------------
.o_fp_landing_head {
background: $_lan-card-hex;
border-bottom: 1px solid $_lan-border-hex;
padding: 0.55rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.o_fp_landing_title_block {
display: flex;
align-items: center;
gap: 0.6rem;
}
.o_fp_landing_title {
font-size: 1.05rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
gap: 0.4rem;
}
.o_fp_landing_station_chip {
background: rgba(0, 113, 227, 0.12);
color: #0050a0;
padding: 0.2rem 0.55rem;
border-radius: 4px;
font-size: 0.78rem;
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
@if $o-webclient-color-scheme == dark {
.o_fp_landing_station_chip { color: #6cb6ff; }
}
.o_fp_landing_unpair { padding: 0 0.2rem; color: inherit; opacity: 0.6;
&:hover { opacity: 1; }
}
.o_fp_landing_head_actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.o_fp_landing_station_picker { min-width: 180px; }
.o_fp_landing_refresh {
font-size: 0.7rem;
margin-left: 0.5rem;
color: var(--text-secondary, #999);
}
// ---- Scan drawer -------------------------------------------------------
.o_fp_landing_scan_drawer {
background: $_lan-card-hex;
border-bottom: 1px solid $_lan-border-hex;
padding: 0.5rem 1rem;
display: flex;
gap: 0.5rem;
}
// ---- KPI strip ---------------------------------------------------------
.o_fp_landing_kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
padding: 0.55rem 1rem;
background: $_lan-page-hex;
}
.o_fp_landing_kpi {
background: $_lan-card-hex;
border: 1px solid $_lan-border-hex;
border-radius: 6px;
padding: 0.5rem 0.7rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
position: relative;
> i {
position: absolute;
top: 0.45rem;
right: 0.55rem;
opacity: 0.4;
font-size: 0.85rem;
}
.o_fp_landing_kpi_v {
font-size: 1.6rem;
font-weight: 700;
line-height: 1.1;
}
.o_fp_landing_kpi_l {
font-size: 0.72rem;
color: var(--text-secondary, #777);
text-transform: uppercase;
letter-spacing: 0.04em;
}
&.o_fp_landing_kpi_success { border-color: rgba(52, 199, 89, 0.3); }
&.o_fp_landing_kpi_warning {
border-color: rgba(255, 159, 10, 0.4);
.o_fp_landing_kpi_v { color: #b06600; }
}
&.o_fp_landing_kpi_danger {
border-color: rgba(255, 59, 48, 0.4);
background: rgba(255, 59, 48, 0.06);
.o_fp_landing_kpi_v { color: #b00018; }
}
}
@if $o-webclient-color-scheme == dark {
.o_fp_landing_kpi_warning .o_fp_landing_kpi_v { color: #ffb84d; }
.o_fp_landing_kpi_danger .o_fp_landing_kpi_v { color: #ff7a72; }
}
// ---- Search bar --------------------------------------------------------
.o_fp_landing_search {
background: $_lan-page-hex;
padding: 0.3rem 1rem;
display: flex;
align-items: center;
gap: 0.4rem;
> i { color: var(--text-secondary, #999); font-size: 0.85rem; }
> input { max-width: 320px; }
}
// ---- Kanban board ------------------------------------------------------
.o_fp_landing_board {
flex: 1;
display: flex;
gap: 0.6rem;
padding: 0.6rem 1rem 1rem;
overflow-x: auto;
align-items: stretch;
}
.o_fp_landing_empty {
margin: auto;
text-align: center;
color: var(--text-secondary, #999);
> div { margin-top: 0.6rem; max-width: 280px; }
}
.o_fp_landing_col {
flex: 0 0 240px;
background: $_lan-card-hex;
border: 1px solid $_lan-border-hex;
border-radius: 6px;
display: flex;
flex-direction: column;
max-height: 100%;
&.o_fp_drop_target {
outline: 2px dashed #0071e3;
outline-offset: -2px;
}
}
.o_fp_landing_col_head {
padding: 0.4rem 0.7rem;
border-bottom: 1px solid $_lan-border-hex;
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 0.78rem;
}
.o_fp_landing_col_name { flex: 1; }
.o_fp_landing_col_count {
background: $_lan-page-hex;
border-radius: 999px;
padding: 0.1rem 0.5rem;
font-size: 0.7rem;
color: var(--text-secondary, #777);
}
.o_fp_landing_col_body {
flex: 1;
overflow-y: auto;
padding: 0.4rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
min-height: 60px;
}
.o_fp_landing_col_empty {
color: var(--text-tertiary, #aaa);
text-align: center;
font-size: 0.78rem;
padding: 1rem 0;
}

View File

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

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.GateViz">
<div class="o_fp_gate" t-if="!props.canStart">
<i t-att-class="'fa o_fp_gate_icon ' + iconClass"/>
<div class="o_fp_gate_body">
<div class="o_fp_gate_title">Can't start yet</div>
<div class="o_fp_gate_reason">
<t t-esc="props.blockerReason or 'Reason unknown — open the step in the back-office.'"/>
</div>
</div>
<button t-if="props.jumpTargetModel and props.jumpTargetId and props.onJump"
class="btn btn-sm btn-outline-warning o_fp_gate_jump"
t-on-click="onJumpClick">
Jump <i class="fa fa-arrow-right"/>
</button>
</div>
</t>
</templates>

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