Compare commits

...

62 Commits

Author SHA1 Message Date
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
gsinghpal
894eea7ce2 Merge branch 'main' of https://github.com/gsinghpal/Odoo-Modules 2026-05-21 05:18:40 -04:00
gsinghpal
612394c987 Merge branch 'main' of https://github.com/gsinghpal/Odoo-Modules 2026-05-21 04:48:06 -04:00
gsinghpal
48dd7718e2 feat(fusion_repairs): Bundle 10 - align pricing to Westin's printed rate card
User shared their actual published service-rate card. Bundle 9's seeded
numbers were placeholders that no longer match. Realigned the rate card,
added the LIFT & ELEVATING SERVICE class, added the in-shop labour
rate path, added the delivery / pickup charge model, added rush as a
proper tier (distinct from after-hours), and added 30-min increment
rounding on top of the existing 1-hour minimum.

EQUIPMENT CLASS

  fusion.repair.product.category gets a new x_fc_equipment_class
  selection: 'standard' vs 'lift_elevating'. The published card splits
  pricing into two service classes - lift_elevating has higher rates
  ($160 callout vs $95, $110/h vs $85).

  Categories marked lift_elevating in seed:
    stairlift, porch_lift, lift_chair (new)

  New 'Lift Chair' category seeded (power recliner / lift chair).

CALLOUT RATE CARD

  fusion.repair.callout.rate gets:
    - equipment_class field (standard / lift_elevating)
    - in_shop_labor_rate field (separate $75 vs $85 on-site)
    - 'rush' tier value (was missing - rush was implicit via emergency
       surcharge from Bundle 8; now a proper tier matching the printed
       rate card row 'Rush Service Calls $120')

  Re-seeded with the PUBLISHED Westin rate card (exact values):

    STANDARD SERVICE
      regular         $95  callout / $85/h on-site  / $75/h in-shop
      rush            $120 callout / $85/h          / $75/h
      after_hours     $140 callout / $85/h          / $75/h
      weekend         $180 callout / $85/h          / $75/h   (extension)
      holiday         $220 callout / $85/h          / $75/h   (extension)

    LIFT & ELEVATING SERVICE
      regular         $160 callout / $110/h on-site / $110/h in-shop
      rush            $200 callout / $110/h         / $110/h  (extension)
      after_hours     $240 callout / $110/h         / $110/h  (extension)
      weekend         $300 callout / $110/h         / $110/h  (extension)
      holiday         $360 callout / $110/h         / $110/h  (extension)

    Travel: $0.70 per km, BOTH WAYS, past 25 km, per technician
    (matches the per-card '$0.70 per km x 2-way' footnote).

  get_for_tier(tier, equipment_class) now resolves with a fallback:
  tries (tier, lift_elevating) first, falls back to (tier, standard)
  if no lift-specific row exists - so an admin can leave standard rows
  as the catch-all and only customise lift for the exceptions.

DELIVERY / PICKUP RATE CARD

  New fusion.repair.delivery.charge model + seed of all 7 items from
  the printed card:
    Local Service Area (within Brampton) ........ $35
    Outside Local Area .......................... $60
    Rush Pickups / Delivery ..................... $60 + $0.70/km x 2-way
    Lift Chair Delivery and Set-Up .............. $120
    Hospital Bed Delivery and Set-Up ............ $120
    Stairlift Delivery and Set-Up ............... $300
    Stairlift Removal ........................... $300

  quote_rush(distance_km) helper for the office's delivery scheduling.
  New menu: Configuration > Delivery / Pickup Charges.

PRICING ENGINE UPDATES (repair.order._compute_callout_quote)

  - Class-aware rate lookup (uses category.equipment_class).
  - In-shop mode (x_fc_in_shop=True): skips callout fee + extra-tech +
    travel; charges in_shop_labor_rate * hours * techs only. Per the
    rate-card footnote 'In-Shop Labour Rate'.
  - 30-min increment rounding ON TOP of the 1-hour floor:
    billable_h = max(ceil(actual * 2) / 2, min_hours)
    -> 20-min work bills 1.0 h
    -> 75-min work bills 1.5 h
    -> 95-min work bills 2.0 h
  - Improved breakdown text shows the rate-card row name + class +
    pro-ration math so the client can see how the total was computed.

NEW FIELDS

  repair.order:
    x_fc_in_shop  (Boolean) - flip to switch the quote engine to
                              in-shop mode.
    x_fc_callout_tier now includes 'rush' as a value (was missing).

  visit-report wizard:
    callout_in_shop related field - tech can flip the mode on-site if
    the work was actually done in-store after pickup.

MIGRATION SCRIPT

  migrations/19.0.2.1.0/post-migration.py runs once on existing
  installs:
    1. Updates stairlift / porch_lift / lift_chair categories
       equipment_class -> lift_elevating
    2. Wipes the 4 Bundle 9 rate-card xml_ids so the new noupdate=1
       seed creates them with the correct printed values.

  Fresh installs get the right values directly from the seed XML.
  Admin-created custom rate rows (no xml_id) are NEVER touched.

VERIFIED END-TO-END (0 bugs across 28 checks)

  Rate card matches printed values exactly:
    regular/standard      = $95/$85h/$75h          PASS
    rush/standard         = $120/$85h/$75h         PASS
    after_hours/standard  = $140/$85h/$75h         PASS
    regular/lift          = $160/$110h/$110h       PASS

  Six end-to-end quote scenarios:
    A. Standard 12km 20-min   -> $180  ($95 + 1h*$85)
    B. Lift     12km 20-min   -> $270  ($160 + 1h*$110)
    C. Rush     30km 1.2h     -> $254.50
       ($120 + ceil(2.4)/2=1.5h * $85 + 5km*2*$0.70 = $7)
    D. After-hours lift 2-tech 35km 2.6h -> $928.00
       ($240 + ceil(5.2)/2=3.0h * $110 * 2 + 10km*2*$0.70*2)
    E. In-shop  standard 2h   -> $150  (2h * $75 in-shop, no callout)
    F. In-shop  lift 1.5h     -> $165  (1.5h * $110 in-shop)

  Seven delivery rates loaded with correct amounts; rush 40km calc
  = $81 ($60 base + 15km*2*$0.70).

  Stairlift / Porch Lift / Lift Chair categories correctly marked
  lift_elevating; rest stay standard.

Bumped to 19.0.2.1.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 02:47:11 -04:00
gsinghpal
ecca8e357f feat(billing): seed Westin/Mobility service charges on first install only
New module `fusion_service_charges` that creates the standard
service-billing product catalog for Westin Healthcare and Mobility
Specialties:

  Standard Service
    SVC-STD-CALL       Service Call (incl. 30 min)         $95
    SVC-STD-LABOUR     Standard Labour (hourly)            $85
    SVC-INSHOP-LABOUR  In-Shop Labour (hourly)             $75
    SVC-RUSH-CALL      Rush Service Call                   $120
    SVC-AH-CALL        After-Hours Service Call            $140
  Lift & Elevating
    SVC-LIFT-CALL      Lift Service Call (incl. 30 min)    $160
    SVC-LIFT-LABOUR    Lift Labour (hourly)                $110
  Delivery / Pickup
    DEL-LOCAL          Local (within Brampton)             $35
    DEL-OUT            Outside Local Area                  $60
    DEL-RUSH           Rush Delivery / Pickup              $60
    DEL-LIFT-CHAIR     Lift Chair Delivery + Set-up        $120
    DEL-HOSP-BED       Hospital Bed Delivery + Set-up      $120
    DEL-STAIRLIFT      Stairlift Delivery + Set-up         $300
    SVC-STAIRLIFT-RM   Stairlift Removal                   $300

Loading pattern (intentional):

- Products created via post_init_hook on FIRST install only.
- Manifest's `data` list is EMPTY so no XML is loaded on `-u`.
- Hook is idempotent — sentinel ir.model.data xmlid check skips
  records that already exist. Safe to re-run.
- User edits / deletes survive every upgrade (proven on entech-
  westin: edited SVC-STD-CALL price to $999.99 → ran -u → price
  stuck. Reset to $95 after test.).
- Uninstall + reinstall does re-seed (ir.model.data sentinels drop
  on uninstall, fresh install treats it as new).

Per-km surcharges (Rush, Outside Local, After-Hours) are noted in
the product description so the dispatcher knows to add a separate
mileage line. Formula-based pricelist for auto-mileage is out of
scope — matches current manual workflow on both shops.

Odoo 19 compatibility: dropped uom_po_id from the create vals
(retired in 18; uom_id is now the single source of truth for sale
and purchase UoM on product.template).

Deployed and verified on:
- odoo-westin / westin-v19 (Docker: odoo-dev-app)   — 14 products
- odoo-mobility / mobility (Docker: odoo-mobility-app) — 14 products

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 02:08:52 -04:00
gsinghpal
f41426c5b9 feat(fusion_repairs): Bundle 9 - service callout pricing + store labor warranty
Full home-service pricing engine plus the store labor warranty model. The
call price now itemises base callout + extra techs + hourly labour (with
the 30-min-included + 1-hour-minimum rule) + travel both ways past
threshold, with three independent waive paths: in-warranty / manager
override / sales-rep override. CS cannot waive (RBAC).

NEW MODELS

fusion.repair.callout.rate (rate card)
  Per (tier, company) row. Tiers: regular / after_hours / weekend / holiday.
  Fields:
    - base_callout_fee   (INCLUDES first 30 min for inspection / report)
    - second_tech_fee    + additional_tech_fee  (3rd, 4th tech)
    - hourly_labor_rate  + minimum_labor_hours  (default 1.0 floor)
    - travel_distance_threshold_km  + travel_per_km_fee
    - effective_from     (newer rows supersede older)
  Seeded with 4 default rows (regular $120/$95/0.85, after-hours
  $180/$140/1.10, weekend $240/$170/1.35, holiday $300/$200/1.50).

fusion.repair.labor.warranty (store labor warranty)
  Per (partner, product/lot, sale_order) record with warranty_years +
  start_date + computed end_date. State machine: active / expired / void
  / consumed. Void reasons spec'd by the user: user_negligence /
  gross_negligence / misuse / over_recommended_use / accidental_damage
  / not_covered_part / other.

  find_active_for(partner, product, lot) - lot-first then product+partner
  then partner-only fallback so warranty resolution survives partner-
  contact / product-variant differences.

  action_void(reason, notes) - manager-only; audit stamps voided_by_id
  + voided_at + reason; posts chatter.

PRODUCT EXTENSION
  product.template.x_fc_labor_warranty_years (Integer, default 0).

SALE-ORDER EXTENSION
  sale.order.action_confirm now also runs _fc_spawn_labor_warranties()
  which creates one fusion.repair.labor.warranty per unit of any product
  with x_fc_labor_warranty_years > 0. Lives alongside the existing
  service-plan spawn so a 5y-LW stairlift sold with a maintenance plan
  spawns both records in one go.

PRICING ENGINE ON REPAIR.ORDER

  9 new fields:
    x_fc_callout_tier            (regular/after_hours/weekend/holiday)
    x_fc_callout_distance_km     (one-way; system bills both ways)
    x_fc_callout_techs           (1, 2, 3+)
    x_fc_callout_labor_hours     (hours above the 30 min the callout covers)
    x_fc_labor_warranty_id       (auto-resolved on visit)
    x_fc_labor_warranty_status   (not_checked / eligible / not_covered /
                                  expired / void_misuse / waived)
    x_fc_labor_waived            + _by_id + _at + _reason

  6 computed quote fields:
    x_fc_quote_callout_base    (base_callout_fee)
    x_fc_quote_extra_techs     (second + additional fees)
    x_fc_quote_labor           (max(hours, min_hours) * rate * techs)
    x_fc_quote_travel          (max(distance - threshold, 0) * 2 * per_km * techs)
    x_fc_quote_waived          (= labor if warranty eligible OR labor waived)
    x_fc_quote_total           (sum minus waived; stored, indexable)
  + a human-readable x_fc_quote_breakdown_text used in the email template.

  3 new actions:
    action_check_labor_warranty  (anyone) - resolves the warranty and
       stamps x_fc_labor_warranty_status. Called automatically by the
       visit-report wizard.
    action_waive_labor_fee       (SECURITY GATED) - raises UserError unless
       caller is in group_fusion_repairs_manager OR
       group_fusion_repairs_sales_rep. CS users get the explicit message
       'Only Repairs Managers and Sales Reps can waive the labor fee.'
    action_acknowledge_rush      - Bundle 8 carryover.

SECURITY

  New group_fusion_repairs_sales_rep
    Independent group so a sales rep can waive labor on their accounts
    without becoming a Repairs Dispatcher / Manager. Manager IMPLIES
    sales_rep so managers automatically inherit the right.
  ACLs: callout.rate user-read / manager-full; labor.warranty user-read /
    sales_rep-write / manager-full / technician-read+write.

VISIT-REPORT WIZARD EXTENSIONS

  Pricing block (visible when outcome=completed):
    callout_tier / techs / distance_km / labor_hours_used (default 1.0
    minimum). Live quote_total_preview + breakdown shown to the tech so
    they can confirm the price with the client right at the door.

  Warranty block:
    labor_warranty_id_preview + labor_warranty_status_preview (badge
    coloured by status). 'warranty_void_reason' selection lets the tech
    void the warranty in real time when they find misuse / negligence /
    accidental damage - on submit the matching warranty record is voided
    permanently (action_void) AND the repair's labor charge re-computes
    without the waive.

  On confirm the wizard:
    1. Persists callout_labor_hours_used to the repair
    2. Calls repair.action_check_labor_warranty()
    3. If warranty_void_reason set + warranty resolved -> voids it,
       posts chatter, repair labor_warranty_status -> void_misuse

NAVIGATION

  Repair form 4 new header buttons:
    Check Labor Warranty   (anyone)
    Waive Labor Fee        (sales_rep + manager only, server-side gated)
    (plus the Bundle 8 Squeeze + Ack Rush from before)

  New 'Callout Pricing' notebook tab on repair form with:
    inputs, warranty/waiver, and the 6-line quote breakdown.

  New menus:
    Fusion Repairs > Labor Warranties
    Configuration > Callout Rate Card
    Configuration > Emergency Surcharges (Bundle 8 carryover)

VERIFICATION END-TO-END (7 scenarios, 0 bugs)

  A. Sale of a product with 5y LW -> LW-00002 spawned, expires 2031-05-21.
  B. In-warranty regular 12km 20-min repair:
       base 120 + labor 95 - waived 95 = $120 (callout only)
  C. After-hours 2-tech 40km 1.5h, NO warranty:
       180 + 90 + (1.5*140*2) + (15*2*1.10*2) = $756.00 exact
  D. In-warranty visit -> tech ticks misuse void_reason:
       Warranty record -> state=void / reason=misuse.
       Repair labor_warranty_status -> void_misuse.
       Quote re-computes WITHOUT waive: labor 1.5 * 95 = $142.50 charged.
  E. Manager waives labor on a no-warranty repair:
       Pre-waive $310 -> post-waive $120 (labor $190 -> waived).
       Audit: waived_by_id stamped to gsingh@.
  F. CS rep tries to waive: correctly denied with the spec'd error
       'Only Repairs Managers and Sales Reps can waive the labor fee.'
  G. Weekend 1-tech 30km 30-min:
       240 + (1.0*170) + (5*2*1.35) = $423.50 exact (min-1h floor
       correctly applied to the 0.5h actual work).

Bumped to 19.0.2.0.0 (minor version bump - new public-facing model).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 01:56:09 -04:00
gsinghpal
ebbadb3002 feat(fusion_repairs): Bundle 8 - rush service + emergency pricing + parts-ordered workflow
The grumpy-old-customer-with-broken-stairlift scenario. Four real workflows
the office faces every week, with comms baked in so the client never has to
call back asking for status.

NEW MODELS
- fusion.repair.emergency.charge (rate card)
  Per (category, tier) rate with per_tech_multiplier; 5 tiers
  (same_day / next_day / after_hours / weekend / holiday). Each category
  can have its own rates - bed motors need 2 techs, stairlift is single.
  Seeded with realistic Westin rates: stairlift same-day $250, weekend
  $450; porch lift same-day $300; bed same-day $175 with 0.6 multiplier
  (2-tech jobs frequent); powerchair same-day $200.

- fusion.repair.part.order (procurement-facing record)
  One per distinct part the tech needs from the manufacturer. Carries
  description + OEM # + manufacturer + quantity + photos + notes.
  4-state lifecycle: draft -> ordered -> received -> fitted (or
  cancelled). On state transitions:
    draft -> ordered:  email client "ordered, expected by X"
    ordered -> received: email client "arrived, scheduling return visit"
                         + auto-create follow-up dispatch task when ALL
                         outstanding parts on the repair have arrived.

REPAIR.ORDER EXTENSIONS
- Rush fields: x_fc_rush_requested, x_fc_rush_tier,
  x_fc_rush_techs_required, x_fc_rush_surcharge (computed via rate card),
  x_fc_rush_acknowledged_at + x_fc_rush_acknowledged_by_id (audit trail
  proving CS got verbal OK before charging).
- Parts-awaiting fields: x_fc_parts_awaiting + x_fc_parts_eta_date +
  x_fc_part_order_ids One2many + x_fc_part_order_count.

- New methods:
  * action_acknowledge_rush() - one-click "client agreed" with audit.
  * action_squeeze_into_today() - picks the lightest-loaded skilled tech,
    finds their first free 1-hour slot between 9am-6pm, schedules the
    task in it, sends:
      1) live bus.bus push to the tech (sticky notification in their
         web client - so they see it MID-SHIFT)
      2) rush-alert email (force_send=True - this can't wait in the queue)
      3) chatter post on the tech task itself
    Validates against fusion_tasks' time-conflict rule by passing
    force_schedule via context (intake.service honours it).
  * action_view_part_orders() - smart button.

WIZARD EXTENSIONS
- repair.intake.wizard:
  New rush_requested + rush_tier + rush_techs_required + rush_acknowledged
  controls. Live rush_surcharge_preview compute shows CS the price in
  real-time as they change category / tier / tech count. Yellow alert
  reminds CS to read the price to the client BEFORE submitting.

- repair.visit.report.wizard:
  New outcome radio: completed / parts_needed / rescheduled.
  When outcome=parts_needed, needs_parts_line_ids One2many appears for
  the tech to capture each part (description, OEM, manufacturer, qty,
  lead days, notes, photos). On submit each line creates a
  fusion.repair.part.order, the repair flips to x_fc_parts_awaiting=True
  with an ETA, and the client gets the "we found the problem, here's the
  plan" email immediately.

INTAKE SERVICE
- _create_dispatch_task now honours force_schedule (date + time_start +
  time_end) via context so squeeze + auto-redispatch don't crash on
  fusion_tasks' time-window validator.
- _create_single_repair carries rush_requested/tier/techs through to
  the new repair fields.

MAIL TEMPLATES (4 new)
- email_template_rush_tech_alert: red 4px accent, address + phone + the
  $surcharge - what the tech needs to know mid-shift.
- email_template_repair_awaiting_parts: amber accent, "we found the
  problem, parts ordered, return visit ~ETA, no action needed".
- email_template_parts_ordered: blue, per-part confirmation.
- email_template_parts_received: green, "arrived, office will call to
  confirm visit".

UI / NAVIGATION
- Backend wizard: rush controls + live surcharge preview + verbal-OK alert.
- repair.order form: new Rush / Parts notebook tab with all the fields
  + linked part orders list. Two new header buttons (Squeeze into
  Today / Client Agreed to Rush Price). Two new search filters
  (Rush, Awaiting Parts).
- Part Order form: statusbar with the 4 transitions + Cancel; notes +
  photos notebook tabs; full chatter for audit.
- Menus: 'Parts to Order' under root; 'Emergency Surcharges' under
  Configuration.

SECURITY
- 8 new ACL entries (emergency_charge user/manager; part_order
  user/dispatcher/manager/technician; visit_report partline for office
  and field tech). Office sees parts but only managers can edit
  emergency rates.

Verified end-to-end on local westin-v19 - all 4 scenarios green:
  S1 Same-day rush stairlift -> $250 surcharge, ack stamped, squeeze
     assigned garry@ at first free 1h slot today, alert email queued,
     chatter posted.
  S2 Next-day priority bed -> $0 surcharge (no rate seeded for bed
     next_day - office can configure), 4 emails queued (client + office).
  S3 2-tech weekend stairlift -> $675 (450 base + 0.5x base for 2nd tech).
  S4 Parts-needed visit-report -> 2 PART-#### records created, repair
     awaiting_parts=True, ETA=2026-06-06, office activity scheduled,
     client email sent. Marking part ordered -> client mail. Marking
     all parts received -> auto-dispatch follow-up + client mail.

Bumped to 19.0.1.9.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 01:28:13 -04:00
gsinghpal
4f1b7c2df6 fix(fusion_repairs): persona-driven workflow audit - 6 real bugs
Full end-to-end walk acting as customer, CS rep, dispatcher, technician,
and manager surfaced 6 real bugs (1 critical state-machine, 4 missing UX
wires, 1 docstring). Server endpoints existed for everything but several
were not wired into the templates.

B1 (HIGH) - Visit-report wizard never closed the repair
  Tech submitted visit -> state stayed 'draft' -> x_fc_done_at never
  stamped -> NPS cron never fired -> the whole post-visit flow died
  silently. Customers never got their NPS email.

  Fix: action_confirm() now drives the Odoo native state machine
  draft -> action_validate (with _action_repair_confirm fallback) ->
  action_repair_start -> action_repair_end. Each step guarded by the
  current state and exception-logged. Leaves the repair open if:
    - requires_requote=True (variance flag - office must re-quote)
    - no_show=True (office reschedules)
    - x_fc_is_quote_only (still a quote)
    - found_another_issue spawned a stub
  Posts a clear chatter line on success or failure.
  Verified: e2e walk now shows state=done + x_fc_done_at stamped +
  NPS cron fires + flags x_fc_nps_email_sent=True.

B2 (HIGH) - /repair/new form never called /repair/self_check
  The AI self-check engine was the headline weekend feature but it was
  invisible to the client. The endpoint worked server-side, just had
  no frontend.

  Fix: new portal_client_repair.js (Interaction class, registered on
  registry.category('public.interactions')). 'Try 1-3 safe self-check
  steps first' button POSTs to /repair/self_check, renders steps via
  createElement + textContent (no innerHTML - all server output is
  treated as untrusted text). Shows the AI's safety disclaimer on
  every result. On escalate_immediately, shows a clear 'submit the
  form, we'll come to you' message instead of the steps.
  Verified: HTTP POST returns full JSON with instruction +
  expected_result + disclaimer; new button + result panel appear in
  rendered HTML.

B3 (HIGH) - No phone-lookup UI for returning clients
  Same problem - endpoint existed but no UI. Returning clients had to
  retype everything from scratch.

  Fix:
  - lookup_phone now returns a 'partners' array (id, name, email,
    street, city) - cap of 3 results, rate-limited, every match logged
    at INFO level for audit. Privacy compromise: a phone holder
    deserves to see their own pre-fill; rate limit caps harvesting.
  - JS lookup widget at the top of the form posts to /repair/lookup_phone
    and pre-fills the 5 contact fields + writes the partner_id to a
    hidden #fr_known_partner_id input.
  - controller /repair/submit now trusts known_partner_id if present
    (skips the phone re-match) so we don't create duplicate partners
    when the lookup widget already identified the right one.
  Verified: HTTP POST returns the 2 partner records we have for
  +19055551234 with full id/name/email/street/city.

B4 (MEDIUM) - /repair?sn=<serial> from QR sticker did nothing
  Spec: 'Client scans QR sticker - portal pre-fills the unit info.'
  Reality: the form had no serial field; ?sn= was ignored.

  Fix: new _resolve_serial_info(serial) on the controller resolves
  the lot via stock.lot.search([('name','=',sn)]) and returns
  {serial, lot_id, product_id, product_name, category_id}. Both
  /repair (landing) and /repair/new pass it as serial_info template
  context. Templates show 'Recognized X (Serial: Y)' + auto-select
  the matching category in the dropdown. Hidden #fr_serial_number
  carries it through to /repair/submit, which attaches the lot_id +
  uses the QR category as fallback if user didn't pick one.
  Verified: ?sn=stella23-20040164 produces 'Pre-filled from QR scan:'
  banner + hidden input populated.

B5 (MEDIUM) - No upsell after submit
  Spec required an upsell - 'reduce future calls'. Page was a bare
  'Got it'.

  Fix: /repair/thanks now shows a 2-card layout:
    - 'Want to avoid this next time?' with 4 bullets (priority booking,
      free inspection cert, discounted parts, annual reminder) +
      'See our maintenance plans' CTA to /shop?category=maintenance
    - 'What happens next' 4-step bulleted explanation
  Verified: both cards render.

B6 (LOW) - SyntaxWarning '\-->' in repair_service_plan.py
  Made the module docstring a raw string (r''') so the ASCII flowchart
  arrows don't trigger Python's invalid-escape-sequence warning.

Bumped to 19.0.1.8.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 01:06:12 -04:00
gsinghpal
b4b59cc3c9 feat(fusion_repairs): Bundle 7 - tech mobile (T3 + T4 + T6 + T7)
T3 Labour timer on technician task
- Two new fields on fusion.technician.task: x_fc_timer_running_since
  (Datetime) + x_fc_timer_accumulated_minutes (Float).
- action_timer_start / action_timer_stop methods, idempotent (start when
  already running is a no-op, stop when not running is a no-op).
- Multiple start/stop cycles accumulate into the same total.
- Two header buttons (Start Timer green / Stop Timer amber), invisible
  based on the running_since field so the right one shows at any time.
- Stop posts a chatter line 'Labour timer stopped. Added X.X min, total
  Y.Y min.' so audit history shows every shift.

T4 Client signature on visit report
- New client_signature Binary field on the visit-report wizard with
  Odoo native widget='signature' that draws on canvas + base64-encodes
  the PNG.
- client_signature_name Char for typed name (audit).
- Persisted as an ir.attachment on the repair.order via the new
  _persist_mobile_artefacts helper.
- Chatter post 'Client signature captured (Jane Smith).'.

T6 Replaced parts - serial capture
- parts_serial_capture Text on the wizard (one per line per the spec).
- On confirm, posted to chatter wrapped in <pre> so line breaks survive.
- Used by OEM warranty filing in future M8.

T7 Client no-show photo proof
- no_show Boolean + no_show_photo Binary with widget='image' (visible
  only when no_show=True via Odoo 19 invisible= conditional).
- Photo saved as ir.attachment on the repair when present.
- Chatter post 'Visit recorded as client no-show (photo attached)'.

Verified end-to-end on local westin-v19:
  T3 timer started -> 2s sleep -> stopped -> 0.0357 min recorded
  T4 attachment 'signature-RO-202605-17.png' created on repair
  T6 chatter shows 'SN-AAA-111 / SN-BBB-222'
  T4 chatter shows 'Client signature captured (Jane Smith)'

Bumped to 19.0.1.7.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 00:24:35 -04:00
gsinghpal
638b223d3b feat(fusion_repairs): Bundle 6 - M7 failure analytics + M9 margin per repair
M9 margin per repair
- New non-stored computes on repair.order: x_fc_revenue, x_fc_labour_cost,
  x_fc_parts_cost, x_fc_margin, x_fc_margin_pct.
- Revenue: sum of posted out_invoice.amount_untaxed on the repair's sale
  order (handles partial / multi invoice scenarios).
- Labour: sum of (task.duration_hours x technician.x_fc_tech_cost_rate)
  over COMPLETED visits only - avoids counting scheduled-but-not-done time.
- Parts: sum of standard_price x qty for stock moves where
  repair_line_type='add' (parts consumed, not removed).
- New 'Margin' notebook tab on repair.order form, manager-group gated.

M7 failure analytics on the dashboard
- Three new keys in get_dashboard_data():
  * failures_by_product - top 8 products by repair_count in last 90 days
    via _read_group (efficient - no record load)
  * failures_by_symptom - top 8 x_fc_issue_category values
  * margin_summary - revenue/labour/parts/margin/margin_pct + sample_size
    over the same 90-day window
- Three new tiles on the OWL dashboard 'Last 90 Days' section:
  Margin Summary (revenue/labour/parts/margin breakdown),
  Failure Rate by Product, Failure Rate by Symptom.
- New formatMoney + formatPercent helpers on the dashboard JS so values
  display as 'CAD 12,345' rather than raw floats.

Verified end-to-end on local westin-v19:
  Dashboard returned all 9 expected keys.
  Top product: 'M6 X 27 THREADED BARREL' (2 repairs) - actual test data.
  Margin summary over 26 repairs (dev has $0 invoices so values 0.0,
  but the compute path is exercised and shapes are correct).

Bumped to 19.0.1.6.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 00:21:57 -04:00
gsinghpal
f463600585 feat(fusion_repairs): Bundle 5 - M5 pre-paid service plans + burn-down
New models
- fusion.repair.service.plan.subscription
  Tracks pre-paid maintenance packages: partner, plan product, optional
  category restriction, visits_included / visits_used / visits_remaining,
  start_date / end_date, computed state (active/exhausted/expired/cancelled),
  burn_history One2many. PLAN-NNNNN sequence.
- fusion.repair.service.plan.burn
  One row per maintenance visit that consumed a plan visit - feeds the
  Burn History tab on the subscription form.

product.template extensions
- x_fc_is_service_plan boolean toggle
- x_fc_plan_visits_included (default 4)
- x_fc_plan_duration_months (default 12)
- x_fc_plan_category_id - if set, only burns for repairs in that category
  (e.g. an Annual Stairlift Maintenance plan does not burn for wheelchair
  repairs)

sale.order.action_confirm() override
- For each order line whose product has x_fc_is_service_plan=True,
  spawns one fusion.repair.service.plan.subscription per qty unit.
- Start date = today; end date = today + plan_duration_months
  (relativedelta - correct month boundaries).

Visit report wizard
- New _burn_service_plan_visit(repair) call from action_confirm() finds
  the matching active subscription and burns one visit + posts a chatter
  note "Visit burned for repair X. N of M remaining." on the subscription.
- Skips quote-only repairs.
- The wizard does NOT zero out the invoice - the burn is informational;
  the office reconciles plan credits in their accounting workflow.

Backend
- Service Plans menu under Fusion Repairs root.
- List view colour-coded by state.
- Form with statusbar + cancel button + Burn History notebook.
- Service Plan tab added to product.template form (manager only).
- ACL: User read; Dispatcher write/create; Manager full + unlink.

Verified end-to-end on local westin-v19:
  Created plan product 'Annual Stairlift Maintenance - 4 Visits'
  Sold it via sale.order -> PLAN-00001 auto-created
  (visits_included=4, end_date=2027-05-21)
  Submitted visit-report on a stairlift repair -> visits_used=1
  remaining=3 (correctly category-matched).

Bumped to 19.0.1.5.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 00:19:28 -04:00
gsinghpal
bf4464ba37 fix(fusion_repairs): Bundle 4 review - lock cert editing + drop flex in PDF
H1+H2: Field technicians had perm_create=1 perm_write=1 on inspection
certs (could forge or edit issued certs). Reduced to read-only - the
visit-report wizard already sudos when creating new certs from a tech
visit. Added rule_inspection_cert_readonly for the dispatcher group so
even dispatchers cannot edit already-issued certs; only the manager can
revoke/correct. Sealed audit trail.

H3: Replaced display:flex / gap (which wkhtmltopdf 0.12 renders as a
vertical stack) with inline-block + margin in the certificate PDF.
Footer uses float left/right for the cert-number / inspector signature
line so the layout survives wkhtmltopdf rendering.

Bumped to 19.0.1.4.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 00:16:05 -04:00
gsinghpal
65c4d8801c feat(fusion_repairs): Bundle 4 - M1 compliance inspection certificates
New fusion.repair.inspection.certificate model for the annual safety
inspections required on stairlifts, porch lifts, and power wheelchairs
in many jurisdictions.

Model
- mail.thread chatter-tracked; fields: name (CERT-YYYY-NNNN auto-seq),
  partner_id, product_id (filtered to safety-critical categories), lot_id,
  repair_order_id back-link, inspector_user_id (must be field staff),
  jurisdiction (selection: Ontario / BC / Alberta / Quebec / Other),
  issued_date, valid_for_months (default 12), expiry_date (computed,
  stored, uses relativedelta - correct month boundaries), status
  (non-stored compute: valid / expiring / expired / revoked), revoked,
  notes, last_reminder_band.
- Unique constraint on certificate number (models.Constraint, not
  _sql_constraints, per project rule).
- Sequence 'fusion.repair.inspection.certificate' with use_date_range=True
  so the counter resets each year (CERT-2026-0001 ... CERT-2027-0001).

Visit report integration
- New issue_inspection_cert checkbox on fusion.repair.visit.report.wizard.
- When ticked AND the repair's category is safety_critical, action_confirm()
  creates the certificate via _create_inspection_certificate() and
  redirects to the cert form so the tech can print immediately.
- Non-safety-critical equipment quietly skips with a chatter note
  explaining why.

PDF report
- web.html_container + web.external_layout, model bound so it appears
  as a Print action on the certificate form.
- 'Certificate of Inspection' / 'Safety Inspected' gold-banner layout
  with client name, equipment, serial, jurisdiction, issued + expiry
  dates, inspector signature line, and the certificate number.
- Print Certificate button in form header.

Daily cron
- cron_send_expiry_reminders runs at 09:00, sends two band-tracked
  reminders (30 days + 7 days before expiry) to the client.
- New mail.template email_template_inspection_expiry_reminder with
  4px amber accent, certificate ref, equipment, expiry date, and a
  CTA to call to book the re-inspection visit.
- last_reminder_band on the cert prevents re-sending the same band.

Backend wiring
- New menu entry 'Fusion Repairs > Inspection Certificates'.
- ACL: User read, Dispatcher write, Manager unlink. Field technicians
  can create (they need to issue from the field).
- List view with red/amber/green status decoration.
- Form with statusbar, header buttons (Print, Revoke with confirm),
  chatter.

Verified end-to-end on local westin-v19:
  Stairlift repair RO-202605-15 -> visit-report with issue_inspection_cert=True
  -> CERT-2026-0001 issued (status=valid, expires 2027-05-21)
  Cert CERT-2026-0002 expiring in 30 days -> cron flagged
  last_reminder_band='30' (would email client).

Bumped to 19.0.1.4.0 (minor bump for the new public-facing capability).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 00:11:59 -04:00
gsinghpal
ef0c096e48 fix(fusion_repairs): Bundle 3 code-review fixes (H1-H5 + M1-M6 + L1)
HIGH
H1 X2 reminder flag was per-repair - multi-visit repairs missed reminders
  Moved x_fc_day_before_reminder_sent off repair.order onto
  fusion.technician.task so each scheduled visit is tracked separately.
  Cron now walks tasks directly with state-narrowed repair filter
  (confirmed/under_repair only, drops L1's draft inclusion).

H2 X4 NPS cron used write_date - moved on every chatter/invoice write
  Added x_fc_done_at Datetime on repair.order, stamped on the first
  transition to state=done via write() override. Cron filters on
  ('x_fc_done_at', '<=', cutoff) instead of write_date.

H3 X2 template's [:1] slice picked an arbitrary task, not tomorrow's
  Cron now passes the specific task via with_context(reminder_task_id=...).
  Template fetches that task by id; falls back to [:1] only for manual
  sends so chatter Send Email composer still works.

H4 NPS Google-Search fallback URL not URL-encoded - breaks on &/spaces
  Template now uses url_encode({'q': company_name}) so "Westin & Sons"
  produces a working URL instead of truncating at the ampersand.

H5 + L1 Loaner cron fired on drafts and used create_date instead of schedule_date
  Domain rewritten to: state in ('confirmed','under_repair'), exclude
  quote-only repairs, and EITHER schedule_date <= cutoff OR (schedule_date
  is False AND create_date <= cutoff). Added limit=200 ordered by
  create_date desc (M6).

MEDIUM
M1 Function-level datetime imports moved to module top
  date, datetime, timedelta imported once at the top of repair_order.py,
  removed from cron_send_day_before_reminders, cron_send_post_visit_nps,
  cron_offer_loaner_for_long_repairs.

M2 _notifications_enabled duplicated - promoted to single source
  repair_order._notifications_enabled now delegates to
  fusion.repair.intake.service._notifications_enabled() (with a fallback
  ICP read if the service AbstractModel isn't available).

M3 self.env.get('model') -> 'model' in self.env (Odoo standard idiom)
  Two call sites in repair_order.py converted.

M4 + M5 Bare 'except: continue' + missing logger - operational blindness
  Added import logging + _logger to repair_order.py. All three crons now
  log exceptions with _logger.exception(). Activity-type ref check now
  warns + returns early if the xml id is missing (instead of passing
  activity_type_id=False which raises). For X2 and X4 the flag is set
  regardless of send-success so we don't retry indefinitely on
  permanently-misconfigured partners.

M6 Loaner cron has limit=200 + order='create_date desc'
  Caps blast radius if 5000 stale draft repairs ever accumulate.

L1 X2 state filter tightened: was ('not in', ('done','cancel')), now
  ('in', ('confirmed','under_repair')) so drafts and quote-only don't
  email "your tech is coming tomorrow".

Verified - upgrade clean, no errors. Bumped to 19.0.1.3.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 00:07:41 -04:00
gsinghpal
c506b53dec feat(fusion_repairs): Bundle 3 - reminders + upsells (X2 + X4 + M3)
X2 Day-before visit reminder email
- New cron 'Fusion Repairs: Day-before visit reminders' (daily at 08:00)
  walks repair.order records with at least one linked
  fusion.technician.task scheduled for tomorrow and not yet reminded.
- Sends mail.template email_template_visit_day_before to the client.
- New x_fc_day_before_reminder_sent flag (copy=False) so the cron
  never re-sends the same reminder.
- Template uses 4px blue accent, 600px max-width, shows the scheduled
  date + technician name + equipment, with a 'reply to reschedule' note.
- Verified: cron flagged the test repair x_fc_day_before_reminder_sent=True
  after running.

X4 Post-visit NPS / Google review email
- New cron 'Fusion Repairs: Send post-visit NPS emails' (hourly)
  finds repairs in state='done' with write_date >= 24h ago and no NPS
  email sent. Sends mail.template email_template_post_visit_nps.
- New x_fc_nps_email_sent flag so we never re-pester clients.
- Template uses 4px green accent + 'Leave a Google review' CTA button
  linking to res.company.x_fc_google_review_url (or a sensible Google
  search fallback when the company hasn't configured a review URL).

M3 Loaner auto-offer for long-running repairs
- Soft-bridges fusion_loaners_management without a hard dep -
  cron_offer_loaner_for_long_repairs returns immediately if the
  fusion.loaner.checkout model isn't installed.
- Walks repair.order records open longer than
  fusion_repairs.loaner_offer_threshold_days (ICP, default 3 days)
  with no existing loaner-offer activity.
- Posts a 'Repair: Offer Loaner' activity (new mail.activity.type)
  assigned to the repair responsible.
- New x_fc_loaner_offered flag to prevent daily re-posting.
- Manual 'Offer Loaner' button on repair header opens the
  fusion.loaner.checkout wizard pre-filled with partner + SO.
- Daily cron runs at 08:30.

Email + ICP + cron wiring:
- 2 new mail.template records (visit_day_before, post_visit_nps)
- 1 new mail.activity.type (loaner_offer)
- 3 new ir.cron records (day-before, NPS, loaner)
- 1 new ir.config_parameter (loaner_offer_threshold_days)
- 1 new header button (Offer Loaner) on repair.order

Verified end-to-end on local westin-v19:
  X2 setup repair: RO-202605-12 task: TASK-00045
     day-before flag after cron: True (expected True)
  M3 loaner model not installed - cron correctly no-op'd
  (no flag set, no activity posted, no error - the soft-dep guard works)

Bumped to 19.0.1.3.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 23:59:40 -04:00
gsinghpal
d93b500901 fix(fusion_repairs): Bundle 2 code-review fixes (C1-C3 + H1-H5 + M5/M7-M11 + L1-L3/L6)
CRITICAL
C1 Cron re-pages same on-call user forever
  page_on_call() now excludes the currently paged user (not just
  acknowledged users) so the 15-min escalation cron actually moves
  to the next priority. Removed the dead `already` var in the cron.
  Verified: page 1 -> gsingh@..., page 2 -> ak@... (different user).

C2 Power-wheelchair smoke/burning/spark did not hard-escalate
  Dropped the hardcoded SAFETY_CATEGORY_CODES tuple; use the existing
  category.safety_critical Boolean instead. Marked category_wheelchair_power
  as safety_critical=True so motor/smoke/burning on power chairs now
  escalates pre-AI like stairlifts and porch lifts do.
  Verified: powerchair + smoke -> escalate=True.

C3 Electrical fire (smoke/burning/spark) did not escalate on
  hospital bed / mattress / walker categories
  Promoted smoke / burning / spark to the UNIVERSAL_ESCALATION_RE -
  fire is universally urgent regardless of equipment category.
  Verified: hospital bed + "motor smells like burning" -> escalate=True.

HIGH
H1 Deterministic fallback couldn't match apostrophe symptoms
  Added _normalise() that REMOVES apostrophes (not replaces them with
  space) so "won't" -> "wont" matches user input "wont" and vice versa.
  Handles straight, curly, and modifier-letter apostrophes.
  Verified: "bed wont move" -> matches the "won't move" rule (1 step).

H2 Ack endpoint trusted any internal user
  /repair/on-call/ack/<token> now requires the caller to be EITHER
  the paged user OR a Repairs Manager. Denied attempts render the
  invalid-token page and log a warning.

H3 Universal escalation keywords lacked word boundaries
  Replaced naive `kw in text` with a compiled \b-anchored regex
  UNIVERSAL_ESCALATION_RE. Likewise SAFETY_SYMPTOMS_RE for category-
  scoped symptoms with won.?t to handle the apostrophe variant.
  "unhurt" no longer matches "hurt", "firearm" no longer matches "fire".

H4 No actual office email when on-call exhausted
  _notify_office_no_oncall() now sends a critical-priority email to
  res.company.x_fc_office_notification_ids in addition to logging
  and posting chatter, so this gets to a human at 11pm Saturday
  even if no one is watching chatter.

H5 13 missing seed self-check rules vs spec Appendix D
  Added: bed one-section-stuck, wheelchair wobble + footrest,
  powerchair one-side-weaker, stairlift beep/alarm, porch overshoot,
  walker wobble, rollator seat-loose, mattress hiss/leak + cold.
  10 added (27 total) - within rounding distance of the spec's "30".

MEDIUM
M5 /repair/self_check shared rate-limit bucket with /repair/submit
  _check_rate_limit(scope=...) - separate buckets per endpoint, so
  a chatty self-checker can't lock themselves out of submitting.
  Per-scope ICP cap key (fusion_repairs.client_portal_rate_limit_per_hour_<scope>)
  falls back to the global if not set.

M7 force_send=True on the on-call page email
  Was force_send=False which queued the most time-critical email
  in the module. Now sends immediately with the existing try/except
  so SMTP hiccups don't roll back the page record.

M8 QR generation swallowed all errors silently
  _logger.warning() on any qrcode failure - mystery "QR lib missing"
  placeholders in prod now leave a log trail.

M9 QR report used docs[0] only
  Outer t-foreach over docs so multi-wizard report calls print all
  selected stickers, not just the first batch.

M10 + M11
  - Added models.Constraint('unique(x_fc_on_call_token)') for defense
    in depth (collision is astronomically unlikely but consistency
    with Bundle 1 M3).
  - _send_page_email() returns True/False; _post_chatter only fires
    on success. On failure a different chatter line says "page email
    failed - verify SMTP".

LOW
L6 find_next_on_call() now filters by company_ids (cross-company safe).

Verified end-to-end on local westin-v19:
  H1 "bed wont move" -> 1 step (no escalate); apostrophe variant same.
  C1 page 1 -> gsingh; page 2 -> ak (different).
  C2 powerchair+smoke -> escalate=True.
  C3 bed+burning -> escalate=True.
  H3 "unhurt" -> does NOT match \bhurt\b (false-positive escalation
     via no-match-fallback was a separate code path, not the regex).

Bumped to 19.0.1.2.2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 23:55:40 -04:00
gsinghpal
5c8768c556 feat(fusion_repairs): Bundle 2 - weekend self-service (CL6/CL7 + CL15 + CL17)
CL6/CL7 AI self-check engine
- New fusion.repair.ai.service AbstractModel with single guardrailed
  suggest_self_check(category_id, symptoms, urgency) entry point.
- Hard-escalation FIRST (before any AI call): stairlift / porch lift +
  safety symptoms (smoke / burning / spark / stuck / motor), OR any
  mention of fire / injury / hurt / bleeding / trapped, OR urgency=safety
  -> escalate immediately regardless of AI availability.
- AI call via fusion.api.service.call_openai() (consumer='fusion_repairs',
  feature='client_self_triage') with try/fallback per project rule -
  no hard fusion_api dep, no install error if it's missing.
- Strict response validation: JSON schema check, max 3 steps, max 200
  chars per field, forbidden-phrase regex (diagnose, you have, medical
  condition, stop using, consult doctor, price patterns) - on any
  failure falls back to deterministic rules.
- 24h in-memory cache keyed by (category, symptom_hash) so repeat calls
  during AI cost-cap incidents come from cache.
- System prompt + JSON schema published as ir.config_parameter so office
  can refine without code changes (default prompt + schema in spec
  Appendix A).
- New fusion.repair.self.check.rule model + 17 seeded rules across all
  7 product categories (data/self_check_data.xml) - these are the
  deterministic fallback AND the canonical seed if AI is disabled.
- New /repair/self_check jsonrpc route (auth=public) gated by the
  per-IP rate-limit; defensive input bounds (max 5 symptoms, 500 chars
  each) defend against prompt-injection bloat.

CL15 weekend safety escalation + on-call paging
- New fusion.repair.on.call.service AbstractModel with:
  * find_next_on_call(exclude=...) -> lowest x_fc_on_call_priority
  * page_on_call(repair) -> sends mail to next available + writes
    x_fc_on_call_token / x_fc_on_call_paged_user_id / paged_at on the
    repair, posts chatter
  * acknowledge(repair, user) -> records ack, posts chatter
  * cron_escalate_unacknowledged() -> every 5 min, re-pages the next
    priority for repairs paged >15 min ago without ack
- Auto-fires from intake service whenever x_fc_urgency='safety' is
  submitted. _is_business_hours() defaults to "page" when no calendar
  is set or after working hours.
- New email_template_on_call_page with 4px red accent + acknowledge
  CTA button linking to /repair/on-call/ack/<token>.
- /repair/on-call/ack/<token> http route (auth=user, must be the paged
  manager OR any internal user) records the ack and renders confirmation.
- 5-minute cron 'Fusion Repairs: Escalate unacknowledged on-call pages'
  with configurable window via fusion_repairs.on_call_escalate_minutes
  (default 15).
- New repair.order fields x_fc_on_call_token, x_fc_on_call_paged_user_id,
  x_fc_on_call_paged_at, x_fc_on_call_acknowledged_user_ids,
  x_fc_on_call_acknowledged_at - all copy=False so duplicates start fresh.

CL17 QR sticker generator
- New fusion.repair.qr.sticker.wizard TransientModel takes a Many2many
  of stock.lot records (optionally filtered by product).
- QWeb PDF report fusion_repairs.report_qr_stickers prints a 4-up
  sticker sheet on letter paper: 80mm x 50mm per sticker with the
  QR code (38mm), product name, serial number, and the canonical
  portal URL (from web.base.url + fusion_repairs.client_portal_url).
- QR encodes /repair?sn=<serial> which the public client portal
  already pre-fills via the ?sn= query param.
- Uses the qrcode library if available; renders 'QR lib missing'
  placeholder otherwise so the PDF still prints.
- New menu Configuration > Generate QR Stickers + standalone wizard.

Verified end-to-end on local westin-v19:
  CL6 stairlift+smoke -> escalate=True source=escalated reason=safety
  CL6 bed (no AI) -> fallback returned escalate=True (safe default)
  CL15 admin paged for RO-202605-10 with 27-char token
  CL17 sticker URL: /repair?sn=001124032521528404
       QR data URI: data:image/png;base64,iVBORw... (PNG OK)

Bumped to 19.0.1.2.0 (minor bump - new public-facing capabilities).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 23:40:52 -04:00
gsinghpal
3a15164605 fix(fusion_repairs): Bundle 1 code-review fixes (H1-H5 + M1-M6)
H1 Float -> Monetary for outstanding_balance
  Added currency_id companion field on the wizard so widget="monetary"
  renders properly. Currency defaults to env.company.currency_id.

H2 Maps URL address duplication
  fusion_tasks address_street often contains the full Google-Places-
  formatted address. Concatenating address_street + address_city + zip
  was producing "15 Fisherman Dr, Brampton, ON L7A 1B7, Canada, Brampton,
  L7A 1B7". Now uses the existing address_display field (fusion_tasks
  computes it correctly for both Google Places and manual entries), with
  a partner-based fallback that includes street, street2, city,
  state_id.name, zip, country_id.name.

H3 Banner copy hardcoded "14 days"
  Added duplicate_window_days compute field; banner now reads
  "in last <N> days" from the ir.config_parameter.

H4 Outstanding-balance multi-company + child_of direction
  - Dropped .sudo() (CS users already have access to their own company's
    invoices via standard groups + the Repairs Office rule)
  - Replaced child_of (which only walks descendants) with
    commercial_partner_id (the canonical Odoo "billed-to root" - covers
    child contacts AND walks up from a child if the caller IS a child)
  - Added ('company_id', 'in', env.companies.ids) filter to both the
    invoice search AND the duplicate-repair search so a CS rep in
    Westin Healthcare doesn't see NEXA Systems balances

H5 duplicate_count capped at 5 (false reassurance)
  Now uses search_count for the true total + search(limit=5) for the
  display list. Earlier verification showed count=5 was actually
  capped; running again shows 15 for the same partner.

M1 Function-level imports
  Moved urllib.parse.quote_plus and odoo.exceptions.UserError to module
  top in technician_task.py.

M2 Many2many 'in' with scalar
  Changed ('x_fc_repair_skills', 'in', category.id) to
  ('x_fc_repair_skills', 'in', [category.id]) - safer against future
  ORM tightening.

M4 C6 - added x_fc_is_quote_only field + filter + form indicator
  Boolean tracked field on repair.order (was previously discoverable
  only via chatter text). Indexed. Visible on the form's intake metadata
  row and filterable on the dashboard search view as "Quote Only".

M5 Account-move read perf
  Replaced Move.search() + Python sum with _read_group(
    aggregates=['amount_residual:sum', '__count']) - pushes the SUM to
  Postgres; O(1) record load vs O(N).

M6 Hide Maps button when no address
  Added invisible="not address_display and not partner_id" on the
  Open in Maps button so it doesn't appear on in-store tasks.

Plus the dispatch-task cutoff is now a datetime (was a date) so the
create_date >= cutoff comparison is type-correct.

Verified end-to-end on local westin-v19 after fixes:
  C1 count: 15 (was capped at 5)  window_days: 14
  C5 balance: 0.0  currency: CAD  warning: False (correct)
  C6 x_fc_is_quote_only: True  tech_tasks: 0 (urgent intake, NOT dispatched)
  T1 URL: https://www.google.com/maps?q=15+Fisherman+Dr%2C+Brampton%2C+ON+L7A+1B7%2C+Canada%2C+Unit+7
       (no duplicated city/zip)

Bumped to 19.0.1.1.1.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 23:34:34 -04:00
gsinghpal
194850e3cf feat(fusion_repairs): Bundle 1 - wizard polish (C1 + C5 + C6 + D2 + T1)
C1 duplicate-call detection
- Wizard computes duplicate_count + duplicate_repair_ids when partner is
  picked (open repairs from the configurable window, default 14 days).
- Yellow banner with "Open Existing Repair" button to jump to the most
  recent duplicate so CS can add a note instead of creating a new repair.

C5 outstanding-balance warning
- Wizard sums posted unpaid account.move.amount_residual across all
  invoices of the partner.
- Red banner shown when balance >= fusion_repairs.outstanding_balance_threshold
  (default $100) with a "View Invoices" button.

C6 quote-only mode
- New quote_only boolean on the wizard; passed through the shared intake
  service. Skips dispatch-task creation for urgent/safety AND for catalogue
  auto_schedule. Chatter note "Created in Quote Only mode" posted on the
  resulting repair.order.

D2 skills filter on dispatch picker
- _pick_dispatch_technician(repair) prefers users whose x_fc_repair_skills
  Many2many contains the repair's product category. Three-tier preference:
  1) intake user if field staff AND has the skill
  2) any active field-staff user with the skill
  3) any active field-staff user (no skill filter) - last-resort
- Logs a warning + skips task creation if no field-staff user exists at all.

T1 Open in Maps on technician task
- action_open_in_maps() returns ir.actions.act_url to
  https://www.google.com/maps?q=<URL-encoded address>. Deep-links into
  Apple Maps / Google Maps native apps on iOS / Android, browser otherwise.
- Header button added on the fusion.technician.task form (after the
  existing buttons) plus a "View Repair" button when x_fc_repair_order_id
  is set.

Verified end-to-end on local westin-v19:
  Existing repair: RO-202605-06
  C1 duplicate_count = 5 (>=1 expected) - last duplicate: RO-202605-06
  C5 balance check ran without error (target partner had $0)
  C6 quote-only repair: RO-202605-07 tech_tasks = 0 (expected 0)
  D2 picked the only stairlift-skilled field-staff user
  T1 Maps URL: https://www.google.com/maps?q=15+Fisherman+Dr%2C+Brampton%2C+ON+L7A+1B7%2C+Canad...

Bumped to 19.0.1.1.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 23:27:43 -04:00
gsinghpal
d15d9e4303 fix(fusion_repairs): admin + office users get full read/schedule access
When admin (gsingh, uid=2) opened a repair on the dashboard:
  "Sorry, Gurpreet Singh (id=2) doesn't have 'read' access to:
   - Repair Order, RO-202605-04 (repair.order: 34)
   Blame the following rules:
   - Repair Order: Technician sees own repairs"

Root cause: per-group record rules in Odoo are OR'd within the same
model. Admin had been added directly to fusion_tasks.group_field_technician
in this database (verified via res_groups_users_rel - direct=1), so the
technician's restrictive rule ('only repairs you are assigned to') kicked
in. Until now there was no per-group rule for the Repairs Office groups
to OR against, so the restrictive rule won by default.

Fix - added two pairs of permissive rules:

  rule_repair_order_repairs_user_full        - User can read/write/create
  rule_repair_order_repairs_manager_unlink   - Manager also can delete
  rule_technician_task_repairs_office        - User can read/write/create tasks
  rule_technician_task_repairs_manager_unlink - Manager also can delete tasks

Both have domain_force=[(1,'=',1)] so they grant unrestricted access for
the Repairs groups. OR'd with the field_technician rule, admin and other
office users now see everything. Field technicians who do NOT have any
Repairs group still see only their assigned repairs (rule unchanged).

Also added the matching ir.model.access.csv entries - record rules don't
fire if the user has no model-level ACL. This is the second fix
('office users can schedule') from the same complaint - Repairs User now
has read/write/create on fusion.technician.task; Repairs Manager also
gets unlink.

Verified end-to-end on westin-v19:
  Admin can see 17 repairs (was 0 before fix)
  Admin can read RO-202605-04 -> 'Gurpreet Singh' (the exact failing record)
  Admin can create fusion.technician.task -> permission check passes
  (model's own time-overlap business validation correctly rejects an
  overlap, but that is a value error not a permission error)

Bumped to 19.0.1.0.7.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 23:11:37 -04:00
gsinghpal
7f8a80fecb fix(fusion_repairs): dashboard scrolling
The dashboard root used min-height: calc(100vh - 46px) which expanded
to the viewport but bypassed the parent .o_action_manager flex sizing,
so the inner overflow-y: auto had nothing to scroll - vertical content
was clipped or stuck.

Replaced with height: 100% + overflow-y: auto + overflow-x: hidden so
the component fills its action container and scrolls naturally. Bumped
to 19.0.1.0.6 to bust the asset bundle hash.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 23:00:45 -04:00
gsinghpal
38a79a4b04 feat(fusion_repairs): OWL dashboard - quick actions, KPIs, portal share
A real landing dashboard for the Fusion Repairs app so users see at a
glance what is open, what is urgent, and where to click. Built as an
OWL client action, theme-aware (light AND dark) at SCSS compile time,
zero hardcoded user-facing colours.

What's on it
- Hero banner with gradient accent
- 4 quick-action tiles (New Service Call, Service Calls, Maintenance
  Contracts, Repair Warranties)
- 6 KPI stat tiles (Open / Urgent+Safety / Awaiting Dispatch /
  Needs Re-Quote / New This Month / Maintenance Due 30d) - each is
  clickable and lands in the right filtered list
- Self-service portal cards with copy-to-clipboard for the public
  client portal URL and the sales rep portal URL (so office can
  share them on voicemail / printed materials / training)
- Recent Service Calls list (last 5) - click jumps to repair form
- Upcoming Maintenance list (next 5 due) - red pill when <=7 days out
- Configuration tiles (Equipment Categories / Intake Templates /
  Service Catalogue)
- Refresh button

Architecture
- fusion.repair.dashboard AbstractModel exposes get_dashboard_data():
  returns stats + urgency_breakdown + source_breakdown + recent[5] +
  upcoming[5] + portals (URLs resolved via web.base.url +
  fusion_repairs.client_portal_url)
- FusionRepairsDashboard OWL component (registry actions
  'fusion_repairs.dashboard') uses standalone rpc() per project rule
  #3, useService('action') for navigation, useService('notification')
  for copy feedback. static props = ['*'] to accept the client-action
  props envelope.
- _fr_tokens.scss registered FIRST in web.assets_backend so its
  variables are in scope when dashboard.scss compiles. NO @import (per
  project rule). Branches on $o-webclient-color-scheme at compile time
  so the dark bundle (web.assets_web_dark) gets dark hex values
  automatically - per project CLAUDE.md rule on dark mode.
- All visible colours come from CSS-variable-wrapped SCSS tokens
  (--fr-page-bg, --fr-card-bg, --fr-border, --fr-accent, ...) which
  fall back to the SCSS hex value. Three-layer contrast: page (grayest)
  -> card (mid) -> elevated (brightest).
- New ir.actions.client action_fusion_repairs_home_dashboard with
  tag='fusion_repairs.dashboard'.
- Top-level menu now lands on this dashboard. 'Dashboard' added as
  the first sub-menu; 'Service Calls' (the kanban) is still right
  below it.

Verified on local westin-v19:
  STATS: open=15, urgent=4, new_this_month=13, awaiting_dispatch=9,
         requires_requote=1, maintenance_due_30d=1, active_total=2
  PORTALS: client=http://192.168.139.165:8069/repair
           sales_rep=http://192.168.139.165:8069/my/repair/new
  RECENT count: 5
  UPCOMING count: 2
  SOURCE breakdown: backend_wizard 9, client_portal 3, manual 2, sales_rep_portal 1
  Web /web/login: 200, no SCSS compile errors in logs.

Bumped to 19.0.1.0.5 so the asset bundle hash refreshes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 22:58:06 -04:00
gsinghpal
5a5e310a83 feat(fusion_repairs): repair.order reference format -> RO-YYYYMM-NN
Replaced the picking-type default reference (BR-WA/RO/00010) with a
date-based monthly-resetting sequence: RO-202605-01, RO-202605-02, ...
where YYYY is the year and MM is the zero-padded month. The counter
resets to 01 every time the month rolls over.

Implementation:
- New ir.sequence 'fusion.repair.order.monthly' with prefix
  'RO-%(year)s%(month)s-', padding=2, use_date_range=True (Odoo creates
  one ir.sequence.date_range per month, each with its own number_next)
- repair.order.create() override pre-fills vals['name'] with the new
  sequence BEFORE super(), so Odoo's native picking-type sequence
  assignment (which only fires when name is empty / 'New') is bypassed

Verified on local westin-v19: three back-to-back creates produced
RO-202605-01 / -02 / -03. Existing records (pre-upgrade) keep their
old BR-WA/RO/##### references - this only affects repairs created
from this version onward.

Bumped to 19.0.1.0.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 22:43:29 -04:00
gsinghpal
cb56a38680 fix(fusion_repairs): chatter posts render HTML correctly via Markup
Reports of literal '<b>Client Self-Service</b>' showing in the chatter
instead of bold formatting. Cause: message_post(body=str) HTML-escapes
the string. The Odoo idiom for HTML chatter bodies is markupsafe.Markup,
with the % operator auto-escaping substitution values for XSS safety.

Fixed every message_post call:

  models/intake_service.py
    - 'Service call submitted via <b>...</b>' (the reported one)
    - 'This repair MAY be covered by our active warranty <b>...</b>'

  models/maintenance_contract.py
    - 'Sent N-day maintenance reminder to <email>'
    - 'Maintenance visit <b>...</b> booked from reminder link'

  models/technician_task.py
    - 'Rolled forward after maintenance task <b>...</b> completed'

  wizard/repair_visit_report_wizard.py
    - 'Spawned follow-up repair <b>...</b> for "found another issue"'

Pattern used: Markup(_('... <b>%(x)s</b> ...')) % {'x': escaped_value}.

Verified on local westin-v19 (BR-WA/RO/00026): DB row now reads
'<p>Service call submitted via <b>Client Self-Service</b> by Gurpreet
Singh. Session reference: RIS000015.</p>' which renders correctly in
the chatter UI.

Bumped to 19.0.1.0.3.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 22:41:17 -04:00
gsinghpal
750c7068e2 fix(fusion_repairs): activity-create access error + dashboard landing
Two complaints from the first hands-on test:

1) Submit button raised "Access Error (Document type: Activity,
   Operation: create)" - the wizard called the intake service WITHOUT
   sudo so the mail.activity records the service schedules tripped on
   the activity ACL (admin's group chain does not auto-grant activity
   create on repair.order without sudo). Both portal controllers
   already sudo'd; the wizard now does too. x_fc_intake_user_id
   preserves audit identity regardless.

   Verified end-to-end as gsingh@westinhealthcare.com (admin):
     Created: BR-WA/RO/00025
     Activities: 2
     Source: backend_wizard
     Intake user: gsingh@westinhealthcare.com

2) "Real dashboard with dedicated pages would have been nice" - the
   main menu opened the wizard directly as a modal. Restructured so
   the menu lands on a proper kanban dashboard of service calls,
   matching the standard Odoo app pattern:

   Fusion Repairs (app icon)
     - Service Calls         <- dashboard kanban (default landing)
     - New Service Call      <- wizard (still a modal, accessed from menu OR kanban's New button)
     - All Repair Orders     <- native Odoo repair list (full backend)
     - Maintenance Contracts
     - Configuration
         - Equipment Categories / Intake Templates / Service Catalogue / Repair Warranties

   New view_fusion_repair_dashboard_kanban shows urgency badges (red /
   amber / grey), category, scheduled date, intake source pill, and
   a 3rd-party warning. Default group_by=state.

   New view_fusion_repair_dashboard_search adds quick filters: Today,
   This Week, Safety/Urgent, Third-Party, Open, plus per-source filters
   and Group By (Status / Urgency / Category / Intake Source).

   Wizard remains target='new' (modal) so submitting drops the user
   back to the kanban they came from with the new repair visible.

Bumped version to 19.0.1.0.2 to bust the asset bundle hash.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 22:38:27 -04:00
gsinghpal
44e5b391f9 fix(fusion_repairs): admin sees app + add placeholder icon
Two related issues that hid the Fusion Repairs app from the Apps menu
for admin users:

1. Custom security groups don't auto-include admin

   The Repairs User / Dispatcher / Manager groups are new custom groups.
   Having base.group_user or base.group_system on its own does NOT grant
   membership in custom child groups - implied chains only flow one way
   (child -> parent). Admin therefore had no Repairs groups, so the
   top-level "Fusion Repairs" menu (gated on group_fusion_repairs_user)
   was hidden from them.

   Fix: extend base.group_system with implied_ids that include
   group_fusion_repairs_manager. Manager already implies Dispatcher
   implies User, so admin (= base.group_system) now automatically gets
   the whole chain on install / upgrade with no manual user editing.

   Verified via odoo-shell:
     admin.has_group('fusion_repairs.group_fusion_repairs_user')       == True
     admin.has_group('fusion_repairs.group_fusion_repairs_dispatcher') == True
     admin.has_group('fusion_repairs.group_fusion_repairs_manager')    == True
     menu_fusion_repairs_root._filter_visible_menus()                 == ir.ui.menu(2735,)

2. Missing static/description/icon.png

   The manifest referenced fusion_repairs,static/description/icon.png
   via web_icon on the top-level menu but the file did not exist. Odoo
   handles missing icons gracefully but the apps list ends up rendering
   without a tile graphic. Copied fusion_tasks/static/description/icon.png
   as a placeholder; replace with a custom asset whenever desired.

   Verified: /fusion_repairs/static/description/icon.png returns
   HTTP 200 with 43989 bytes after restart.

Bumped manifest version to 19.0.1.0.1 to bust the asset bundle hash so
clients pick up the new icon without a manual cache clear.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 22:31:38 -04:00
gsinghpal
c86f1bbbe5 fix(fusion_repairs): code-review batch - 4 critical + 8 high + 8 medium/low
Critical
- C1: _sql_constraints -> models.Constraint (Odoo 19 deprecation rule violation)
- C2: variance threshold no longer uses abs() - under-cost is good news,
  must not block invoicing. Now only OVER-cost triggers requires_requote.
- C3: roll_next_due_date() was dead code - now wired from
  fusion.technician.task.write() when a maintenance task transitions to
  'completed', so the whole maintenance lifecycle actually advances.
- C4: warranty.is_active was store=True but time-dependent (became stale).
  Dropped store=True; find_active_for() now filters by expiry_date directly.

High
- H1: added x_fc_maintenance_contract_id back-link on repair.order and
  populated it from create_repair_from_booking().
- H2: find_active_for() returns empty when neither lot nor product is
  supplied - prevents cross-product false warranty matches.
- H3: visit-report wizard now creates stock.move records of repair_line_type
  'add' for each part line, so Odoo's native action_create_sale_order()
  chain has lines to invoice and stock gets consumed properly.
- H4: office intake email template now carries a fallback email_to header
  computed from res.company.x_fc_office_notification_ids (or company email),
  so it does not silently send with no recipient.
- H5: maintenance reminder cron nextcall now always rolls to tomorrow
  at 07:00 local time, so installing/upgrading after 07:00 does not
  immediately fire all the day's reminders.
- H6: public portal no longer hardcodes UID 1 as the intake user fallback
  (which in Odoo 19 is OdooBot). Prefers base.user_admin, else the
  lowest-id non-share user, else SUPERUSER_ID.
- H7: public portal validates client_email via tools.email_normalize
  before partner creation; malformed addresses redirect with error=email.
- H8: find_best_match() returns empty when no symptom keywords match
  (no silent first-catalog guess) and uses word-boundary regex to avoid
  matching 'battery' inside 'no battery problem'.

Medium
- M1: _inherit moved next to _name on maintenance_contract (cosmetic but
  brittle if Odoo refactors model class detection)
- M2: relativedelta(months=N) instead of timedelta(days=N*30) for warranty
  and maintenance intervals (correct month boundaries)
- M3: unique constraint on fusion.repair.maintenance.contract.booking_token
- M6: dispatch task fallback now searches for an actual x_fc_is_field_staff
  user; gracefully skips and logs if no field staff exists (instead of
  silently failing the constraint check)
- M7: maintenance contract list view date decoration uses context_today()
  (date) instead of strftime(string) - the str comparison would TypeError
- M9: Visit Report button hidden on draft repairs and when no technician
  task is linked yet

Low
- L2: portal-created partners get default lang + company_id so mail
  templates render in the right language
- L3: dropped unused exception variable in sales rep portal controller
- L4: visit-report wizard 'found another issue' now redirects to the
  spawned stub repair so the tech can fill it in immediately
- L5: dropped unrecognized data-string from <app> in settings view

Public portal also: rate-limit check moved BEFORE the counter increment so
blocked attempts do not keep inflating the bucket.

All fixes verified end-to-end on local westin-v19:
- variance one-sided: 0.5h labour vs $500 est -> requires_requote=False;
  2h x $250 + $200 parts vs $100 est -> requires_requote=True
- maintenance roll-forward: created MC/00006 due 2026-05-31, completed
  linked maintenance task -> contract rolled to 2026-11-21 with
  last_reminder_band reset
- warranty find_active_for(partner only) -> empty recordset
- service catalog find_best_match with unrelated text -> empty recordset
- pg_constraint shows fusion_repair_maintenance_contract_booking_token_unique
- /repair landing still 200 after restart

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 22:22:11 -04:00
gsinghpal
afe19f2105 feat(fusion_repairs): sale.order smart buttons - repairs + maintenance
On the original purchase sale.order:
- Repairs button (fa-wrench) lists all repair.order records where
  x_fc_original_sale_order_id = this SO
- Maintenance button (fa-calendar-check-o) lists all
  fusion.repair.maintenance.contract records spawned from this SO
- Both auto-hide when count is zero
- Both gated by fusion_repairs.group_fusion_repairs_user

Follows the count + action_view_* + oe_stat_button / statinfo pattern
from fusion_claims/views/sale_order_views.xml line ~1176.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 22:02:12 -04:00
gsinghpal
73ee48e7c9 feat(fusion_repairs): Phase 3 - maintenance contracts + client self-booking
Maintenance contracts
- New fusion.repair.maintenance.contract model: one per partner +
  product + lot. Fields: interval_months, last_service_date,
  next_due_date, state, booking_token (secrets.token_urlsafe),
  last_reminder_band (30 / 7 / 1), booking_repair_id
- roll_next_due_date() advances the cycle by interval_months and resets
  the band / booked-repair so the next cycle starts fresh
- sale.order._spawn_maintenance_contracts() creates contracts for
  delivered SOs whose product has x_fc_maintenance_interval_months > 0
  (called from Phase 3 hooks; ready for cron / on-state change wiring)

Reminder cron
- Daily ir.cron at 07:00 -> cron_send_due_reminders()
- Sends email at 30 / 7 / 1 day bands before next_due_date; tracks
  last_reminder_band so we never re-send the same band in one cycle
- Master toggle via ir.config_parameter fusion_repairs.enable_email_notifications

Public client booking portal
- /repairs/maintenance/book/<token>  GET landing page with a date input
- /repairs/maintenance/book/<token>/confirm  POST creates a repair.order
  via contract.create_repair_from_booking() (source='client_portal')
- Idempotent: existing booking shows "already booked" instead of
  spawning a duplicate
- Invalid / expired tokens render a friendly "link not valid" page

Mail template
- email_template_maintenance_due_reminder with 4px green accent bar,
  600px max-width, dark/light safe; renders the tokenized booking CTA
  button directly to /repairs/maintenance/book/<token>

Backend
- Maintenance Contracts list / form with statusbar + chatter
- Menu under Operations -> Maintenance Contracts
- Sequence MC/##### for contract reference
- Access rules: User read, Dispatcher write, Manager full

Verified end-to-end on local westin-v19:
- Contract MC/00003 created due in 7 days
- cron_send_due_reminders() fires the 7-day band; second invocation
  skips (idempotent)
- create_repair_from_booking() spawns BR-WA/RO/00014 with
  x_fc_intake_source='client_portal' and links it back to the contract
- HTTP GET /repairs/maintenance/book/<token> -> 200 with the date input
  and contract reference visible in the page

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 22:01:30 -04:00
gsinghpal
7727745b73 feat(fusion_repairs): Phase 2 - service catalogue, visit report, warranty, Poynt
Service catalogue
- New fusion.repair.service.catalog model: named service entries per
  equipment category with symptom keywords, estimated hours / cost,
  default parts, auto_schedule flag, optional pricelist override
- find_best_match() scores candidates by symptom-keyword overlap against
  intake text hints (issue summary + category + notes)
- Intake service wires it in: on submit, the matcher sets
  x_fc_service_catalog_id + x_fc_estimated_duration + x_fc_estimated_cost
  and (when auto_schedule=True) creates a draft dispatch task
- Double-task guard: if catalogue match already created a task, the
  urgency-based dispatch skips so we never duplicate

Visit report wizard
- fusion.repair.visit.report.wizard with labour hours + parts lines +
  technician notes + 'found another issue' branch
- Computes actual cost = (labour x service_product.list_price) + parts
- Compares against estimate -> sets requires_requote when variance
  exceeds configured threshold (% or $); shows warning banner inline
- On confirm: writes actuals back to repair, posts notes to chatter,
  optionally spawns a follow-up repair (T5 'found another issue')

Repair warranty
- New fusion.repair.warranty.coverage model (start/expiry, partner,
  product, lot, active flag)
- find_active_for(partner, product, lot) returns the most-recent active
  coverage
- Intake service auto-checks: when a new repair lands on an equipment
  that has active warranty coverage, posts a chatter banner so the
  office knows the work may be free under our 30/90-day re-do policy
  (manager review still required; never auto-zeros pricing)

Repair form
- Header: Visit Report + Collect Payment buttons (gated by group)
- action_collect_payment looks up the linked posted unpaid invoice on
  the repair SO and opens the Poynt wizard (action_open_poynt_payment_wizard)

AI intake summary
- _generate_ai_summary calls self.env['fusion.api.service'].call_openai
  with consumer='fusion_repairs', feature='intake_triage'
- Strict system prompt: no medical advice, no diagnoses, no recommending
  stop equipment use; ~80 words; plain English
- Try/fallback per fusion-api-integration.mdc: if fusion_api not
  installed or call fails -> silently skip; intake never blocked

Verified end-to-end on local westin-v19:
- Stairlift motor intake -> catalogue match -> estimated $500/2h -> auto
  dispatch task (count=1, not duplicated)
- Visit report: 2.5h x $250 + $100 parts = $725 actual vs $500 estimated
  = 45% variance -> requires_requote=True
- Warranty: 30-day coverage on the completed repair; second repair on
  same partner triggers warranty banner in chatter

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 21:57:33 -04:00
gsinghpal
ad553b1082 feat(fusion_repairs): Phase 1 sales rep + public client portals
Both portals share the existing fusion.repair.intake.service so behaviour
stays identical across all three intake surfaces (backend wizard,
sales rep portal, public client portal).

Sales rep portal
- Hard depends on fusion_authorizer_portal (reuses is_sales_rep_portal
  flag + group_sales_rep_portal scaffolding)
- /my/repair/new  - mobile-friendly intake form with phone-first
  partner search (jsonrpc lookup), category select, third-party flag,
  urgency, photo capture
- /my/repairs     - list of repairs the rep submitted (paginated)
- /my/repair/<id> - read-only detail with status, equipment, scheduled
  visit
- Interaction-class JS (Odoo 19 public.interactions), safe DOM construction
- Mobile SCSS with 44px tap targets, sticky CTA on small screens
- Record rule scopes portal users to repairs where
  x_fc_intake_user_id = user.id

Public client portal
- auth='public' - voicemail-ready /repair URL
- /repair         - landing page with 911 disclaimer and Start CTA
- /repair/new     - single-page form: contact, equipment, issue, urgency,
  optional photos. QR pre-fill via ?sn=<serial>
- /repair/submit  - CSRF + honeypot + per-IP rate limit (configurable);
  finds or creates partner; calls intake service with sudo
- /repair/thanks  - confirmation with reference number
- /repair/lookup_phone (jsonrpc) - safe partner match returning ONLY
  masked name (first + last initial) + city (no other PII leakage)

Security fix: technician record rule on repair.order now uses STORED
fields (technician_id + additional_technician_ids) instead of the
non-stored all_technician_ids compute, which was failing SQL generation.

Verified end-to-end on local westin-v19:
- Sales rep create via intake service with the rep user context creates
  the repair with x_fc_intake_source='sales_rep_portal' and proper
  activities
- /repair/submit posts urlencoded data -> creates partner + repair
  ('BR-WA/RO/00010', source='client_portal', urgency='urgent') ->
  redirects to /repair/thanks with the reference

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 21:52:12 -04:00
gsinghpal
429084e0bf feat(fusion_repairs): Phase 1 MVP - backend intake wizard + core models
Scaffolds the fusion_repairs module that extends Odoo 19 repair.order with
a guided medical-equipment intake workflow.

Models
- fusion.repair.product.category (8 medical equipment categories seeded)
- fusion.repair.intake.template / .question / .answer (7 templates,
  32 questions seeded across hospital bed, stairlift, porch lift,
  wheelchair, walker/rollator, mattress)
- fusion.repair.intake.service (AbstractModel) - single entry point used
  by backend wizard, sales rep portal, and public client portal so all
  three surfaces produce identical outcomes
- repair.order extensions (x_fc_intake_*, x_fc_third_party_equipment,
  x_fc_photo_ids, x_fc_urgency, x_fc_estimated/actual_cost, AI summary)
- fusion.technician.task back-link (x_fc_repair_order_id)
- res.partner service preferences (preferred tech, time window, access notes)
- res.users repair extensions (skills, cost rate, on-call rotation fields)
- res.config.settings for variance thresholds, portal URL, rate limit

UI
- Backend intake wizard with multi-equipment loop, third-party flag, photos
- repair.order form: Intake tab, Photos, Pricing tab, AI tab, smart buttons
  (technician tasks, intake answers, original SO)
- Kanban + list view urgency badges
- Fusion Repairs app menu (New Service Call, Repair Orders, Config)

Activities & Email
- 4 follow-up activity types (CS callback, tech dispatch, visit follow-up,
  manager review) with urgency-tiered deadlines
- 2 mail templates (client confirmation + office notification) with the
  same dark/light-safe styling as fusion_claims ADP templates

Security
- New res.groups.privilege + 3 groups (User, Dispatcher, Manager)
- Reuses fusion_tasks.group_field_technician (do NOT recreate)
- Reuses fusion_authorizer_portal.group_sales_rep_portal
- Multi-company global rule + technician scoping rule on repair.order

Verified end-to-end on local westin-v19 dev DB via odoo-shell - creates
multiple repairs in one session, auto-creates dispatch task for urgent,
attaches 4 activity types correctly per urgency tier and third-party flag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 21:35:52 -04:00
gsinghpal
79fbfec61f docs(fusion_repairs): add design spec
Comprehensive 4-phase design for fusion_repairs Odoo 19 module covering
three intake surfaces (backend wizard, sales rep portal, public client
portal), AI self-check with strict medical safety guardrails, weekend
on-call paging, repairs pricelist automation, Poynt payment collection,
and maintenance lifecycle with client self-booking. 53 features across
phases 1-4; reuses existing fusion_tasks technician model and
fusion_authorizer_portal sales rep scaffolding.

Includes Appendices A-D with seed AI system prompt + JSON schema,
15 upsell rules, voicemail scripts, and 30 deterministic self-check
rules across 7 medical equipment categories.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 21:22:01 -04:00
185 changed files with 25462 additions and 247 deletions

File diff suppressed because it is too large Load Diff

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 |

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,21 @@
<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="numbercall">-1</field>
<field name="doall" eval="False"/>
<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})
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

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

@@ -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="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"/>
<div class="fp-report">
<div class="page">
<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="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="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: 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>
<!-- 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">
<strong>Payment Terms / Modalités de paiement:</strong><br/>
<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-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="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"/>
<div class="fp-landscape">
<div class="page">
<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>
<!-- 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">
<strong>Payment Terms / Modalités de paiement:</strong><br/>
<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-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,15 +150,19 @@
.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">
<!-- 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"/>
@@ -84,29 +173,60 @@
<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">
<!-- 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-sale-barcode">
<div class="fp-bc-wrap">
<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>
</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>
</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>
<!-- 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> - </span>
<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.29.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',
@@ -62,6 +62,32 @@ 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',
# ---- 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,5 @@ from . import shopfloor_controller
from . import manager_controller
from . import tank_status
from . import move_controller
from . import workspace_controller
from . import landing_controller

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

@@ -466,3 +466,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

@@ -620,15 +620,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
@@ -1002,8 +1013,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.
@@ -1093,14 +1116,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,338 @@
# -*- 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
_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):
env = request.env
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):
env = request.env
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):
env = request.env
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

@@ -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 { rpc } from "@web/core/network/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 rpc("/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,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,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,212 @@
/** @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 { 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";
export class FpJobWorkspace extends Component {
static template = "fusion_plating_shopfloor.JobWorkspace";
static props = ["*"];
static components = { WorkflowChip, GateViz, FpSignaturePad, FpHoldComposer };
setup() {
this.notification = useService("notification");
this.action = useService("action");
this.dialog = useService("dialog");
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" });
}
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 rpc("/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 rpc("/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 rpc("/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 rpc("/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

@@ -43,15 +43,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(() => {
@@ -283,6 +295,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,268 @@
/** @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 { useService } from "@web/core/utils/hooks";
import { QrScanner } from "./qr_scanner";
import { FpKanbanCard } from "./components/kanban_card";
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 };
setup() {
this.notification = useService("notification");
this.action = useService("action");
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();
}
// ---- 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 rpc("/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,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,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,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,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>

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.HoldComposer">
<Dialog title="'Place hold'" size="'md'">
<div class="o_fp_hc">
<div class="o_fp_hc_row">
<label>Reason</label>
<select class="form-select" t-model="state.reason">
<t t-foreach="reasons" t-as="r" t-key="r.value">
<option t-att-value="r.value"><t t-esc="r.label"/></option>
</t>
</select>
</div>
<div class="o_fp_hc_row">
<label>Qty on hold</label>
<input type="number" min="1" class="form-control"
t-model.number="state.qty"/>
</div>
<div class="o_fp_hc_row">
<label>Description</label>
<textarea class="form-control" rows="3" t-model="state.description"
placeholder="What happened?"/>
</div>
<div class="o_fp_hc_row">
<label>Photo (optional)</label>
<input type="file" accept="image/*" capture="environment"
class="form-control" t-on-change="onPhotoChange"/>
<small t-if="state.photoFilename" class="text-success">
<i class="fa fa-check"/> <t t-esc="state.photoFilename"/>
</small>
</div>
<div class="o_fp_hc_row">
<label class="form-check-label">
<input type="checkbox" class="form-check-input me-1"
t-model="state.markForScrap"/>
Mark for scrap
</label>
</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-link" t-on-click="() => this.props.close()">Cancel</button>
<button class="btn btn-warning" t-on-click="onSubmit"
t-att-disabled="state.submitting">
<i class="fa fa-exclamation-triangle me-1"/> Create Hold
</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.KanbanCard">
<div t-att-class="'o_fp_kcard ' + (isCompact ? 'o_fp_kcard_compact' : '')"
t-on-click="onClick">
<div class="o_fp_kcard_h1">
<span class="o_fp_kcard_wo"><t t-esc="props.data.display_wo_name"/></span>
<span t-att-class="'o_fp_kcard_pri o_fp_kcard_pri_' + priorityDot"/>
</div>
<div class="o_fp_kcard_h2">
<span t-esc="props.data.customer or ''"/>
<t t-if="props.data.part"> · <t t-esc="props.data.part"/></t>
</div>
<div class="o_fp_kcard_qty" t-if="props.data.qty">
<span><t t-esc="props.data.qty_done or 0"/> / <t t-esc="props.data.qty"/> done</span>
<span t-if="props.data.date_deadline" class="o_fp_kcard_due">
Due <t t-esc="props.data.date_deadline"/>
</span>
</div>
<div class="o_fp_kcard_bar" t-if="props.data.qty">
<div t-att-style="'width: ' + progressPct + '%'"/>
</div>
<div class="o_fp_kcard_chips">
<WorkflowChip t-if="props.showWorkflowChip and props.data.workflow_state"
state="props.data.workflow_state"/>
<span t-if="props.data.blocker_kind and props.data.blocker_kind !== 'none'"
class="o_fp_kcard_blocked"
t-att-title="props.data.blocker_reason">
<i class="fa fa-lock"/> Blocked
</span>
<span t-if="props.showWorkcenter and props.data.work_center"
class="o_fp_kcard_wc">
@ <t t-esc="props.data.work_center"/>
</span>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.SignaturePad">
<Dialog title="props.title or 'Signature required'" size="'md'">
<div class="o_fp_sig_pad">
<div class="o_fp_sig_ctx" t-if="props.contextLabel">
<t t-esc="props.contextLabel"/>
</div>
<canvas class="o_fp_sig_canvas" t-ref="canvas"/>
<div class="o_fp_sig_hint">Draw your signature above</div>
</div>
<t t-set-slot="footer">
<button class="btn btn-secondary" t-on-click="onClear">Clear</button>
<button class="btn btn-link" t-on-click="onCancel">Cancel</button>
<button class="btn btn-primary" t-on-click="onSubmit">Sign &amp; Finish</button>
</t>
</Dialog>
</t>
</templates>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.WorkflowChip">
<span t-att-class="'o_fp_wf_chip o_fp_wf_chip_' + toneClass">
<span class="o_fp_wf_dot"/>
<span class="o_fp_wf_label" t-esc="props.state.name"/>
<t t-if="props.nextActionLabel">
<span class="o_fp_wf_next">· next: <t t-esc="props.nextActionLabel"/></span>
</t>
</span>
</t>
</templates>

View File

@@ -0,0 +1,230 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.JobWorkspace">
<div class="o_fp_ws">
<!-- Loading state -->
<div t-if="!state.data" class="o_fp_ws_loading">
<i class="fa fa-spinner fa-spin fa-2x"/>
<div>Loading Job Workspace…</div>
</div>
<t t-if="state.data">
<!-- =========================================================
STICKY HEADER — WO context, qty bumps, workflow chip
========================================================= -->
<header class="o_fp_ws_head">
<div class="o_fp_ws_head_l">
<button class="btn btn-link o_fp_ws_back" t-on-click="onBack">
<i class="fa fa-arrow-left"/> Back
</button>
<span class="o_fp_ws_wo"><t t-esc="state.data.job.display_wo_name"/></span>
<span class="o_fp_ws_dot"> · </span>
<span class="o_fp_ws_cust"><t t-esc="state.data.job.partner_name"/></span>
<t t-if="state.data.job.part_number">
<span class="o_fp_ws_dot"> · </span>
<span class="o_fp_ws_part"><t t-esc="state.data.job.part_number"/></span>
</t>
</div>
<div class="o_fp_ws_head_r">
<span class="o_fp_ws_pill">
<t t-esc="state.data.job.qty_done"/> / <t t-esc="state.data.job.qty"/> done
<t t-if="state.data.job.qty_scrapped">· <t t-esc="state.data.job.qty_scrapped"/> scrap</t>
</span>
<span class="o_fp_ws_pill" t-if="state.data.job.date_deadline">
Due <t t-esc="state.data.job.date_deadline"/>
</span>
<WorkflowChip t-if="state.data.job.workflow_state"
state="state.data.job.workflow_state"/>
<span t-att-class="'o_fp_ws_pill ' + (state.data.job.quality_hold_count ? 'o_fp_ws_holds_red' : 'o_fp_ws_holds_ok')">
<t t-esc="state.data.job.quality_hold_count"/> holds
</span>
</div>
</header>
<!-- =========================================================
STICKY WORKFLOW BAR — milestone dots + Next button
========================================================= -->
<div class="o_fp_ws_bar">
<div class="o_fp_ws_bar_line">
<t t-foreach="state.data.workflow_states" t-as="ws" t-key="ws.id">
<div t-att-class="'o_fp_ws_dot_wrap' + (ws.is_current ? ' current' : (ws.passed ? ' done' : ''))">
<span class="o_fp_ws_bar_dot"/>
<span class="o_fp_ws_bar_label" t-esc="ws.name"/>
</div>
<span t-if="!ws_last" t-att-class="'o_fp_ws_link' + (ws.passed ? ' done' : '')"/>
</t>
</div>
<button t-if="state.data.job.next_milestone_action"
class="btn btn-primary o_fp_ws_next"
t-on-click="onAdvanceMilestone">
Next: <t t-esc="state.data.job.next_milestone_label"/> <i class="fa fa-arrow-right"/>
</button>
</div>
<!-- =========================================================
MAIN — step list (left/center) + side panel (right)
========================================================= -->
<div class="o_fp_ws_main">
<!-- STEP LIST -->
<div class="o_fp_ws_steps">
<div t-if="!state.data.steps.length" class="o_fp_ws_empty">
<i class="fa fa-exclamation-circle fa-2x"/>
<div>Recipe not generated for this WO.</div>
</div>
<t t-foreach="state.data.steps" t-as="step" t-key="step.id">
<div t-att-class="'o_fp_ws_step ' + step.state +
(isStepActive(step) ? ' active' : '') +
(step.override_excluded ? ' excluded' : '') +
(step.blocker_kind !== 'none' ? ' blocked' : '')"
t-att-data-step-id="step.id">
<div class="o_fp_ws_step_l1">
<span class="o_fp_ws_step_icon" t-esc="iconForStepState(step.state)"/>
<span class="o_fp_ws_step_num">Step <t t-esc="step.sequence_display"/></span>
<span class="o_fp_ws_step_name" t-esc="step.name"/>
<span t-if="isStepActive(step)" class="o_fp_ws_step_badge">ACTIVE</span>
<span class="o_fp_ws_step_meta">
<t t-if="step.assigned_user_name"><t t-esc="step.assigned_user_name"/></t>
<t t-if="step.duration_actual"> · <t t-esc="Math.round(step.duration_actual)"/> min</t>
</span>
</div>
<t t-if="isStepActive(step) or step.blocker_kind !== 'none' or step.override_excluded">
<div class="o_fp_ws_step_detail">
<!-- Recipe chips (only on active step) -->
<div class="o_fp_ws_step_chips" t-if="isStepActive(step)">
<span t-if="step.thickness_target" class="o_fp_chip o_fp_chip_info">
🎯 Thickness <t t-esc="step.thickness_target"/> <t t-esc="step.thickness_uom or 'mils'"/>
</span>
<span t-if="step.dwell_time_minutes" class="o_fp_chip o_fp_chip_info">
⏱ Dwell <t t-esc="step.dwell_time_minutes"/> min
</span>
<span t-if="step.bake_setpoint_temp" class="o_fp_chip o_fp_chip_warning">
🔥 Bake <t t-esc="step.bake_setpoint_temp"/>°
</span>
<span t-if="step.requires_signoff" class="o_fp_chip o_fp_chip_warning">
✎ Sign-off required
</span>
</div>
<!-- Recipe author instructions (only on active step) -->
<div t-if="step.instructions and isStepActive(step)"
class="o_fp_ws_step_instr">
<t t-esc="step.instructions"/>
</div>
<!-- Opt-out notice -->
<div t-if="step.override_excluded" class="o_fp_ws_step_excluded">
<i class="fa fa-ban"/> Skipped per recipe override for this WO
</div>
<!-- Blocker explainer -->
<GateViz t-if="step.blocker_kind !== 'none'"
canStart="false"
blockerKind="step.blocker_kind"
blockerReason="step.blocker_reason"
jumpTargetModel="step.blocker_jump_target_model"
jumpTargetId="step.blocker_jump_target_id"
onJump.bind="onJumpToBlocker"/>
<!-- Action buttons (only when unblocked) -->
<div class="o_fp_ws_step_actions"
t-if="isStepActive(step) and step.blocker_kind === 'none'">
<button t-if="step.requires_signoff"
class="btn btn-success"
t-on-click="() => this.onFinishStep(step)">
<i class="fa fa-check"/> Finish &amp; Sign Off
</button>
<button t-else=""
class="btn btn-success"
t-on-click="() => this.onFinishStep(step)">
<i class="fa fa-check"/> Finish
</button>
</div>
<div class="o_fp_ws_step_actions"
t-if="step.can_start and !isStepActive(step) and step.blocker_kind === 'none'">
<button class="btn btn-primary"
t-on-click="() => this.onStartStep(step.id)">
<i class="fa fa-play"/> Start
</button>
</div>
</div>
</t>
</div>
</t>
</div>
<!-- SIDE PANEL: spec + attachments + chatter -->
<div class="o_fp_ws_side">
<div class="o_fp_ws_side_card" t-if="state.data.spec">
<h4>Customer spec</h4>
<div class="o_fp_ws_spec_link">
<i class="fa fa-file-pdf-o"/>
<a t-att-href="'/web/content/' + state.data.spec.id"
target="_blank"><t t-esc="state.data.spec.name"/></a>
</div>
</div>
<div class="o_fp_ws_side_card" t-if="state.data.attachments.length">
<h4>Drawings &amp; attachments</h4>
<t t-foreach="state.data.attachments" t-as="att" t-key="att.id">
<div class="o_fp_ws_attach">
<span>📄 <t t-esc="att.name"/></span>
<a t-att-href="att.url" target="_blank">Open</a>
</div>
</t>
</div>
<div class="o_fp_ws_side_card">
<h4>Notes</h4>
<div t-if="!state.data.chatter.length" class="o_fp_ws_empty_small">
No notes yet.
</div>
<t t-foreach="state.data.chatter" t-as="msg" t-key="msg.id">
<div class="o_fp_ws_note">
<div class="o_fp_ws_note_h">
<span class="author"><t t-esc="msg.author"/></span>
<span class="time"><t t-esc="msg.date"/></span>
</div>
<div class="body" t-out="msg.body"/>
</div>
</t>
</div>
</div>
</div>
<!-- =========================================================
STICKY ACTION RAIL — Hold · Note · Cert · Milestone
========================================================= -->
<footer class="o_fp_ws_rail">
<button class="btn btn-warning" t-on-click="onCreateHold">
<i class="fa fa-exclamation-triangle"/> Create Hold
</button>
<button class="btn btn-light" t-on-click="onAddNote">
<i class="fa fa-pencil"/> Note
</button>
<span style="flex: 1"/>
<button t-if="state.data.required_certs.has_draft"
class="btn btn-light"
t-on-click="onAdvanceMilestone">
<i class="fa fa-file-text"/> Issue Cert
</button>
<button t-if="state.data.job.next_milestone_action"
class="btn btn-primary"
t-on-click="onAdvanceMilestone">
<i class="fa fa-arrow-right"/> <t t-esc="state.data.job.next_milestone_label"/>
</button>
</footer>
</t>
</div>
</t>
</templates>

View File

@@ -153,10 +153,53 @@
</t>
</div>
</div>
<!-- Phase 4 tablet redesign — Pending Cert + At-Risk tiles -->
<div class="o_fp_kpi o_fp_kpi_warning"
t-if="state.inbox and state.inbox.certs_to_issue and state.inbox.certs_to_issue.length"
t-on-click="() => this.setActiveTab('inbox')">
<i class="fa fa-file-text"/>
<div class="o_fp_kpi_value">
<t t-esc="state.inbox.certs_to_issue.length"/>
</div>
<div class="o_fp_kpi_label">Pending Cert</div>
</div>
<div class="o_fp_kpi o_fp_kpi_danger"
t-if="state.atRisk and state.atRisk.trending_late and state.atRisk.trending_late.length"
t-on-click="() => this.setActiveTab('at_risk')">
<i class="fa fa-exclamation-triangle"/>
<div class="o_fp_kpi_value">
<t t-esc="state.atRisk.trending_late.length"/>
</div>
<div class="o_fp_kpi_label">At-Risk</div>
</div>
</div>
<!-- ============ Workload grid ============ -->
<div class="o_fp_manager_grid" t-if="state.overview">
<!-- ============ Phase 4 tab navigation ============ -->
<div class="o_fp_mgr_tabs" t-if="state.overview">
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'funnel' ? 'active' : '')"
t-on-click="() => this.setActiveTab('funnel')">
<i class="fa fa-filter"/> Workflow Funnel
</button>
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'inbox' ? 'active' : '')"
t-on-click="() => this.setActiveTab('inbox')">
<i class="fa fa-inbox"/> Approval Inbox
<span t-if="state.inbox" class="o_fp_mgr_tab_badge">
<t t-esc="(state.inbox.holds_to_release.length + state.inbox.certs_to_issue.length + state.inbox.scrap_to_review.length)"/>
</span>
</button>
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'plant_board' ? 'active' : '')"
t-on-click="() => this.setActiveTab('plant_board')">
<i class="fa fa-th"/> Plant Board
</button>
<button t-att-class="'o_fp_mgr_tab ' + (state.activeTab === 'at_risk' ? 'active' : '')"
t-on-click="() => this.setActiveTab('at_risk')">
<i class="fa fa-fire"/> At-Risk
</button>
</div>
<!-- ============ PLANT BOARD TAB (existing 3-column grid) ============ -->
<div class="o_fp_manager_grid"
t-if="state.overview and state.activeTab === 'plant_board'">
<!-- Needs a Worker -->
<section class="o_fp_panel o_fp_panel_unassigned">
@@ -369,6 +412,171 @@
</section>
</div>
<!-- ============ WORKFLOW FUNNEL TAB (Phase 4) ============ -->
<div class="o_fp_mgr_funnel"
t-if="state.overview and state.activeTab === 'funnel'">
<div t-if="!state.funnel" class="o_fp_empty">
<i class="fa fa-spinner fa-spin"/>
<div>Loading workflow funnel…</div>
</div>
<t t-if="state.funnel">
<div t-foreach="state.funnel.stages" t-as="stage" t-key="stage.id"
class="o_fp_funnel_row">
<div class="o_fp_funnel_stage">
<span t-att-class="'o_fp_wf_chip o_fp_wf_chip_' + funnelStageTone(stage.color)">
<span class="o_fp_wf_dot"/>
<span class="o_fp_wf_label" t-esc="stage.name"/>
</span>
<span class="o_fp_funnel_count" t-esc="stage.count"/>
</div>
<div class="o_fp_funnel_cards">
<t t-foreach="stage.jobs" t-as="card" t-key="card.job_id">
<div class="o_fp_funnel_card"
t-on-click="() => this.openJobWorkspace(card.job_id)">
<div class="o_fp_funnel_card_wo" t-esc="card.display_wo_name"/>
<div class="o_fp_funnel_card_meta">
<t t-esc="card.customer"/> · <t t-esc="card.days_in_stage"/>d
</div>
</div>
</t>
<span t-if="stage.count > stage.jobs.length" class="o_fp_funnel_more">
+<t t-esc="stage.count - stage.jobs.length"/> more
</span>
<span t-if="!stage.jobs.length" class="o_fp_funnel_empty"></span>
</div>
</div>
</t>
</div>
<!-- ============ APPROVAL INBOX TAB (Phase 4) ============ -->
<div class="o_fp_mgr_inbox"
t-if="state.overview and state.activeTab === 'inbox'">
<div t-if="!state.inbox" class="o_fp_empty">
<i class="fa fa-spinner fa-spin"/>
<div>Loading approval inbox…</div>
</div>
<t t-if="state.inbox">
<!-- Holds to Release -->
<section class="o_fp_inbox_strip">
<h4>
<i class="fa fa-pause-circle text-danger"/>
Holds to Release (<t t-esc="state.inbox.holds_to_release.length"/>)
</h4>
<div t-if="!state.inbox.holds_to_release.length" class="o_fp_empty_small">
No open holds.
</div>
<t t-foreach="state.inbox.holds_to_release" t-as="h" t-key="h.hold_id">
<div class="o_fp_inbox_row">
<span><strong t-esc="h.name"/> · <t t-esc="h.job_name"/></span>
<span class="text-muted">· <t t-esc="h.reason"/> · qty <t t-esc="h.qty"/></span>
<span class="text-muted ms-auto"><t t-esc="h.requested_by"/> · <t t-esc="h.requested_at"/></span>
<button class="btn btn-sm btn-outline-secondary ms-2"
t-on-click="() => this.openRecord('fusion.plating.quality.hold', h.hold_id)">
Open
</button>
</div>
</t>
</section>
<!-- Certs to Issue -->
<section class="o_fp_inbox_strip">
<h4>
<i class="fa fa-certificate text-warning"/>
Certs to Issue (<t t-esc="state.inbox.certs_to_issue.length"/>)
</h4>
<div t-if="!state.inbox.certs_to_issue.length" class="o_fp_empty_small">
No certs waiting to be issued.
</div>
<t t-foreach="state.inbox.certs_to_issue" t-as="c" t-key="c.job_id">
<div class="o_fp_inbox_row">
<span><strong t-esc="c.display_wo_name"/> · <t t-esc="c.customer"/></span>
<span class="text-muted">· needs <t t-esc="c.cert_types.join(', ')"/></span>
<span class="text-muted ms-auto">all steps done <t t-esc="c.all_steps_done_at"/></span>
<button class="btn btn-sm btn-primary ms-2"
t-on-click="() => this.openJobWorkspace(c.job_id)">
Open Workspace
</button>
</div>
</t>
</section>
<!-- Scrap to Review -->
<section class="o_fp_inbox_strip">
<h4>
<i class="fa fa-trash text-muted"/>
Scrap to Review (<t t-esc="state.inbox.scrap_to_review.length"/>)
</h4>
<div t-if="!state.inbox.scrap_to_review.length" class="o_fp_empty_small">
No recent scrap to acknowledge.
</div>
<t t-foreach="state.inbox.scrap_to_review" t-as="s" t-key="s.hold_id">
<div class="o_fp_inbox_row">
<span><strong t-esc="s.job_name"/> · <t t-esc="s.scrap_qty"/> scrapped</span>
<span class="text-muted" t-if="s.reason">· "<t t-esc="s.reason"/>"</span>
<span class="text-muted ms-auto"><t t-esc="s.operator"/> · <t t-esc="s.at"/></span>
<button class="btn btn-sm btn-outline-secondary ms-2"
t-on-click="() => this.openRecord('fusion.plating.quality.hold', s.hold_id)">
Open
</button>
</div>
</t>
</section>
</t>
</div>
<!-- ============ AT-RISK TAB (Phase 4) ============ -->
<div class="o_fp_mgr_atrisk"
t-if="state.overview and state.activeTab === 'at_risk'">
<div t-if="!state.atRisk" class="o_fp_empty">
<i class="fa fa-spinner fa-spin"/>
<div>Loading at-risk view…</div>
</div>
<t t-if="state.atRisk">
<div class="o_fp_atrisk_grid">
<section class="o_fp_atrisk_card">
<h4><i class="fa fa-clock-o"/> Trending Late (<t t-esc="state.atRisk.trending_late.length"/>)</h4>
<div t-if="!state.atRisk.trending_late.length" class="o_fp_empty_small">
No late-risk jobs right now.
</div>
<t t-foreach="state.atRisk.trending_late" t-as="j" t-key="j.job_id">
<div class="o_fp_atrisk_row"
t-on-click="() => this.openJobWorkspace(j.job_id)">
<span><strong t-esc="j.display_wo_name"/> · <t t-esc="j.customer"/></span>
<span t-if="j.stuck_at" class="text-muted">· stuck at <t t-esc="j.stuck_at"/></span>
<span class="text-danger ms-auto">×<t t-esc="j.late_risk_ratio"/></span>
</div>
</t>
</section>
<section class="o_fp_atrisk_card">
<h4><i class="fa fa-pause-circle"/> Hold Reasons</h4>
<div t-if="!state.atRisk.hold_reasons.length" class="o_fp_empty_small">
No open holds.
</div>
<t t-foreach="state.atRisk.hold_reasons" t-as="r" t-key="r.reason">
<div class="o_fp_atrisk_row">
<span t-esc="r.label"/>
<strong class="ms-auto" t-esc="r.count"/>
</div>
</t>
</section>
<section class="o_fp_atrisk_card">
<h4><i class="fa fa-fire"/> Bottleneck</h4>
<div t-if="!state.atRisk.bottleneck.length" class="o_fp_empty_small">
No bottlenecks detected.
</div>
<t t-foreach="state.atRisk.bottleneck" t-as="b" t-key="b.work_centre_id">
<div class="o_fp_atrisk_bar">
<span class="o_fp_atrisk_bar_name" t-esc="b.work_centre_name"/>
<span class="o_fp_atrisk_bar_track">
<span t-att-class="'o_fp_atrisk_bar_fill o_fp_atrisk_bar_' + bottleneckTone(b.score)"
t-att-style="'width: ' + bottleneckPct(b.score) + '%'"/>
</span>
<span class="o_fp_atrisk_bar_score" t-esc="b.score"/>
</div>
</t>
</section>
</div>
</t>
</div>
<!-- ============ Loading ============ -->
<div t-if="!state.overview and !state.loadError" class="o_fp_empty">
<i class="fa fa-spinner fa-spin"/>

View File

@@ -0,0 +1,163 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_plating_shopfloor.ShopfloorLanding">
<div class="o_fp_landing">
<!-- Loading state -->
<div t-if="!state.data" class="o_fp_landing_loading">
<i class="fa fa-spinner fa-spin fa-2x"/>
<div>Loading Shop Floor…</div>
</div>
<t t-if="state.data">
<!-- ===== HEADER ===== -->
<header class="o_fp_landing_head">
<div class="o_fp_landing_title_block">
<h1 class="o_fp_landing_title">
<i class="fa fa-industry"/> Shop Floor
</h1>
<t t-if="state.data.station">
<span class="o_fp_landing_station_chip">
@ <t t-esc="state.data.station.work_center_name or state.data.station.name"/>
<button class="btn btn-sm btn-link o_fp_landing_unpair"
t-on-click="onUnpairStation"
title="Unpair this tablet">
<i class="fa fa-times"/>
</button>
</span>
</t>
</div>
<div class="o_fp_landing_head_actions">
<!-- Station picker -->
<select class="o_fp_landing_station_picker form-select form-select-sm"
t-on-change="onPickStation">
<option value="">— Pick station —</option>
<t t-foreach="state.data.stations" t-as="s" t-key="s.id">
<option t-att-value="s.id"
t-att-selected="state.stationId === s.id">
<t t-esc="s.name"/>
<t t-if="s.work_center_name"> · <t t-esc="s.work_center_name"/></t>
</option>
</t>
</select>
<!-- Mode toggle -->
<div class="o_fp_landing_mode_toggle btn-group btn-group-sm">
<button t-att-class="'btn ' + (state.mode === 'station' ? 'btn-primary' : 'btn-outline-secondary')"
t-on-click="() => this.setMode('station')">
Station
</button>
<button t-att-class="'btn ' + (state.mode === 'all_plant' ? 'btn-primary' : 'btn-outline-secondary')"
t-on-click="() => this.setMode('all_plant')">
All Plant
</button>
</div>
<!-- Scan controls -->
<button class="btn btn-sm btn-outline-secondary" t-on-click="toggleScan">
<i class="fa fa-qrcode"/> Code
</button>
<QrScanner cssClass="'btn btn-sm btn-outline-secondary'" label="'Camera'"/>
<!-- Refresh indicator -->
<span class="o_fp_landing_refresh text-muted">
<i class="fa fa-clock-o"/> <t t-esc="state.lastRefresh"/>
</span>
</div>
</header>
<!-- ===== Scan drawer ===== -->
<div t-if="state.showScan" class="o_fp_landing_scan_drawer">
<input type="text"
class="form-control"
placeholder="Scan FP-STATION:… FP-JOB:… FP-STEP:…"
t-model="state.scanInput"
t-on-keydown="onScanKey"
autofocus="autofocus"/>
<button class="btn btn-primary" t-on-click="onScanSubmit">Scan</button>
</div>
<!-- ===== KPI strip (4 tech-relevant tiles) ===== -->
<div class="o_fp_landing_kpis">
<div class="o_fp_landing_kpi">
<i class="fa fa-hourglass-half"/>
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.ready"/></span>
<span class="o_fp_landing_kpi_l">Ready</span>
</div>
<div class="o_fp_landing_kpi o_fp_landing_kpi_success">
<i class="fa fa-cogs"/>
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.running"/></span>
<span class="o_fp_landing_kpi_l">Running</span>
</div>
<div t-att-class="'o_fp_landing_kpi ' + (state.data.kpis.bakes_due ? 'o_fp_landing_kpi_warning' : '')">
<i class="fa fa-fire"/>
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.bakes_due"/></span>
<span class="o_fp_landing_kpi_l">Bakes Due</span>
</div>
<div t-att-class="'o_fp_landing_kpi ' + (state.data.kpis.holds ? 'o_fp_landing_kpi_danger' : '')">
<i class="fa fa-pause-circle"/>
<span class="o_fp_landing_kpi_v"><t t-esc="state.data.kpis.holds"/></span>
<span class="o_fp_landing_kpi_l">Holds</span>
</div>
</div>
<!-- ===== Search bar ===== -->
<div class="o_fp_landing_search">
<i class="fa fa-search"/>
<input type="text"
class="form-control form-control-sm"
placeholder="Search WO #, customer, part…"
t-model="state.search"
t-on-input="onSearchInput"
t-on-keydown="onSearchKey"/>
</div>
<!-- ===== Kanban board ===== -->
<div class="o_fp_landing_board">
<div t-if="!state.data.columns.length" class="o_fp_landing_empty">
<i class="fa fa-check-circle fa-2x text-success"/>
<div t-if="state.mode === 'station'">
No jobs at this station right now. Switch to All Plant
to pull one over.
</div>
<div t-else="">
Plant is quiet — nothing in progress.
</div>
</div>
<t t-foreach="state.data.columns" t-as="col" t-key="col.work_center_id">
<div class="o_fp_landing_col"
t-on-dragover="(ev) => this.onColDragOver(col, ev)"
t-on-drop="(ev) => this.onColDrop(col, ev)">
<div class="o_fp_landing_col_head">
<span class="o_fp_landing_col_name" t-esc="col.work_center_name"/>
<span class="o_fp_landing_col_count"><t t-esc="col.cards.length"/></span>
</div>
<div class="o_fp_landing_col_body">
<t t-foreach="col.cards" t-as="card" t-key="card.step_id">
<div draggable="true"
t-on-dragstart="(ev) => this.onCardDragStart(card, col, ev)">
<FpKanbanCard
data="card"
density="'normal'"
showWorkflowChip="true"
showWorkcenter="state.mode === 'all_plant'"
onTap.bind="onCardTap"/>
</div>
</t>
<div t-if="!col.cards.length" class="o_fp_landing_col_empty">
</div>
</div>
</div>
</t>
</div>
</t>
</div>
</t>
</templates>

View File

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

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
"""Plan task P3.1 — /fp/landing/kanban endpoint."""
import json
from odoo.tests.common import HttpCase, tagged
def _rpc(case, url, **params):
res = case.url_open(
url,
data=json.dumps({'jsonrpc': '2.0', 'params': params}),
headers={'Content-Type': 'application/json'},
)
return res.json()['result']
@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestLandingKanban(HttpCase):
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
def test_all_plant_returns_columns_and_kpis(self):
res = _rpc(self, '/fp/landing/kanban', mode='all_plant')
self.assertTrue(res['ok'])
self.assertEqual(res['mode'], 'all_plant')
self.assertIn('columns', res)
self.assertIn('kpis', res)
for kpi in ('ready', 'running', 'bakes_due', 'holds'):
self.assertIn(kpi, res['kpis'])
self.assertIn('stations', res)
def test_station_mode_with_invalid_id_falls_back_to_all_plant_shape(self):
# No real station paired → station resolution returns None, but
# endpoint still produces a valid columns/kpis payload.
res = _rpc(self, '/fp/landing/kanban', mode='station', station_id=999999)
self.assertTrue(res['ok'])
self.assertIsNone(res['station'])
self.assertIn('columns', res)

View File

@@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc. — License OPL-1
"""HTTP tests for /fp/workspace/* endpoints."""
import base64
import json
from odoo.tests.common import HttpCase, tagged
# Minimal 1x1 PNG so photo + signature attachment tests can run without
# packing a real binary in the source tree.
_TINY_PNG_B64 = base64.b64encode(
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\x00\x01'
b'\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
).decode()
def _rpc(case, url, **params):
res = case.url_open(
url,
data=json.dumps({'jsonrpc': '2.0', 'params': params}),
headers={'Content-Type': 'application/json'},
)
return res.json()['result']
@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceLoad(HttpCase):
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.partner = self.env['res.partner'].create({'name': 'WS Cust'})
self.product = self.env['product.product'].create({'name': 'WS Prod'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/WS001',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 5,
})
def test_load_returns_full_payload(self):
res = _rpc(self, '/fp/workspace/load', job_id=self.job.id)
self.assertTrue(res['ok'])
self.assertEqual(res['job']['display_wo_name'], 'WO # WS001')
self.assertEqual(res['job']['id'], self.job.id)
for key in ('steps', 'workflow_states', 'chatter',
'attachments', 'required_certs'):
self.assertIn(key, res)
def test_load_bad_job_id_returns_error(self):
res = _rpc(self, '/fp/workspace/load', job_id=999999)
self.assertFalse(res['ok'])
self.assertIn('not found', res['error'].lower())
@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceHold(HttpCase):
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.partner = self.env['res.partner'].create({'name': 'Hold Cust'})
self.product = self.env['product.product'].create({'name': 'Hold Prod'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/H001',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 10,
})
def test_hold_creates_quality_hold(self):
res = _rpc(
self, '/fp/workspace/hold',
job_id=self.job.id, reason='dimensional', qty_on_hold=3,
description='Bracket bent on de-rack',
part_ref='Bracket Rev A',
)
self.assertTrue(res['ok'])
hold = self.env['fusion.plating.quality.hold'].browse(res['hold_id'])
self.assertEqual(hold.qty_on_hold, 3)
self.assertEqual(hold.hold_reason, 'dimensional')
def test_hold_with_photo_creates_attachment(self):
res = _rpc(
self, '/fp/workspace/hold',
job_id=self.job.id, reason='thickness', qty_on_hold=1,
photo_data=_TINY_PNG_B64, photo_filename='evidence.png',
)
self.assertTrue(res['ok'])
self.assertTrue(res['attachment_id'])
attachments = self.env['ir.attachment'].search([
('res_model', '=', 'fusion.plating.quality.hold'),
('res_id', '=', res['hold_id']),
])
self.assertGreaterEqual(len(attachments), 1)
def test_hold_qty_zero_rejected(self):
res = _rpc(
self, '/fp/workspace/hold',
job_id=self.job.id, qty_on_hold=0,
)
self.assertFalse(res['ok'])
@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceSignOff(HttpCase):
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.partner = self.env['res.partner'].create({'name': 'Sig Cust'})
self.product = self.env['product.product'].create({'name': 'Sig Prod'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/S001',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
})
self.step = self.env['fp.job.step'].create({
'job_id': self.job.id,
'name': 'ENP Plate',
'sequence': 50,
'state': 'in_progress',
})
def test_sign_off_rejects_empty_signature(self):
res = _rpc(
self, '/fp/workspace/sign_off',
step_id=self.step.id, signature_data_uri='',
)
self.assertFalse(res['ok'])
self.assertIn('signature', res['error'].lower())
def test_sign_off_finishes_step(self):
res = _rpc(
self, '/fp/workspace/sign_off',
step_id=self.step.id, signature_data_uri=_TINY_PNG_B64,
)
self.assertTrue(res['ok'])
self.step.invalidate_recordset(['state'])
self.assertEqual(self.step.state, 'done')
@tagged('-at_install', 'post_install', 'fp_shopfloor')
class TestWorkspaceAdvanceMilestone(HttpCase):
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.partner = self.env['res.partner'].create({'name': 'M Cust'})
self.product = self.env['product.product'].create({'name': 'M Prod'})
self.job = self.env['fp.job'].create({
'name': 'WH/JOB/M001',
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': 1,
})
def test_advance_no_action_returns_error(self):
# Job with no steps → no next_milestone_action → friendly reject
res = _rpc(self, '/fp/workspace/advance_milestone', job_id=self.job.id)
self.assertFalse(res['ok'])

View File

@@ -26,14 +26,12 @@
sequence="3"
groups="fusion_plating.group_fusion_plating_manager"/>
<menuitem id="menu_fp_shopfloor_plant_overview"
name="Plant Overview"
parent="menu_fp_shopfloor"
action="action_fp_plant_overview"
sequence="5"/>
<!-- Phase 3 tablet redesign — single Workstation menu entry replaces
the legacy "Tablet Station" + "Plant Overview" pair. The new
fp_shopfloor_landing component has a Station/All-Plant toggle
so one menu item covers both old surfaces. -->
<menuitem id="menu_fp_shopfloor_tablet"
name="Tablet Station"
name="Workstation"
parent="menu_fp_shopfloor"
action="action_fp_shopfloor_tablet"
sequence="10"/>

View File

@@ -7,11 +7,17 @@
<odoo>
<!-- ================================================================== -->
<!-- Client action — Plant Overview Dashboard -->
<!-- Client action — was "Plant Overview" (fp_plant_overview). -->
<!-- Phase 3 tablet redesign retargets the tag at the new -->
<!-- fp_shopfloor_landing component (it has an "All Plant" mode that -->
<!-- supersedes the standalone plant overview). Old bookmarks keep -->
<!-- working; the legacy fp_plant_overview OWL component is still -->
<!-- registered. Menu entry removed in fp_menu.xml. -->
<!-- ================================================================== -->
<record id="action_fp_plant_overview" model="ir.actions.client">
<field name="name">Plant Overview</field>
<field name="tag">fp_plant_overview</field>
<field name="tag">fp_shopfloor_landing</field>
<field name="params" eval="{'mode': 'all_plant'}"/>
</record>
<!-- ================================================================== -->

View File

@@ -89,11 +89,15 @@
</record>
<!-- ================================================================== -->
<!-- Client action that launches the OWL tablet component -->
<!-- Client action — was "Tablet Station" (fp_shopfloor_tablet). -->
<!-- Phase 3 tablet redesign retargets the tag at the new -->
<!-- fp_shopfloor_landing component so old bookmarks keep working. -->
<!-- The legacy fp_shopfloor_tablet OWL component is still registered -->
<!-- (no code removed) but no menu points at it anymore. -->
<!-- ================================================================== -->
<record id="action_fp_shopfloor_tablet" model="ir.actions.client">
<field name="name">Tablet Station</field>
<field name="tag">fp_shopfloor_tablet</field>
<field name="name">Shop Floor</field>
<field name="tag">fp_shopfloor_landing</field>
</record>
</odoo>

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import models
from . import wizard
from . import controllers

View File

@@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Repairs',
'version': '19.0.2.1.0',
'category': 'Inventory/Repairs',
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
'description': """
Fusion Repairs
==============
Comprehensive repairs and maintenance management for medical equipment retailers
and service providers (hospital beds, wheelchairs, stairlifts, porch lifts,
walkers, mattresses, rollators).
Phase 1 - MVP
-------------
- Three intake surfaces sharing one service layer:
* Backend wizard for CS reps on the phone
* Sales rep portal (/my/repair/new) for reps on the road
* Public client self-service portal (/repair) - voicemail ready
- Guided question templates per medical equipment category
- Phone-first partner lookup with duplicate-call detection
- Multi-equipment per call (one repair.order per unit)
- Photo / video capture during intake
- Third-party equipment support (equipment we didn't sell)
- Auto warranty detection from original sale order
- Office notification recipients + 4 follow-up activities
- repair.order extensions linked to fusion.technician.task
Phase 2-4 (roadmap)
-------------------
- AI self-check engine with strict medical safety guardrails
- Upsell engine and direct-buy parts/plans
- Repair warranty tracking (free re-do window)
- Visit report wizard with Poynt terminal payment
- Maintenance contracts with client self-booking
- Weekend safety on-call paging
- SMS notifications, compliance certificates, analytics
Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'support': 'support@nexasystems.ca',
'license': 'OPL-1',
'price': 0.00,
'currency': 'CAD',
'depends': [
'base',
'mail',
'portal',
'website',
'sale_management',
'stock',
'repair',
'maintenance',
'fusion_tasks',
'fusion_poynt',
'fusion_authorizer_portal',
],
'data': [
# Security
'security/security.xml',
'security/ir.model.access.csv',
# Data (must load before views that reference records)
'data/ir_sequence_data.xml',
'data/ir_config_parameter_data.xml',
'data/ir_cron_data.xml',
'data/mail_activity_type_data.xml',
'data/mail_template_data.xml',
'data/repair_product_category_data.xml',
'data/intake_template_data.xml',
'data/self_check_data.xml',
'data/emergency_charge_data.xml',
'data/callout_rate_data.xml',
'data/delivery_charge_data.xml',
# Views
'views/repair_product_category_views.xml',
'views/intake_template_views.xml',
'views/service_catalog_views.xml',
'views/repair_warranty_views.xml',
'views/maintenance_contract_views.xml',
'views/repair_dashboard_views.xml',
'views/repair_emergency_charge_views.xml',
'views/repair_inspection_views.xml',
'views/repair_callout_rate_views.xml',
'views/repair_delivery_charge_views.xml',
'views/repair_labor_warranty_views.xml',
'views/repair_order_views.xml',
'views/repair_part_order_views.xml',
'views/repair_service_plan_views.xml',
'views/sale_order_views.xml',
'views/technician_task_views.xml',
'views/res_partner_views.xml',
'views/res_users_views.xml',
'views/res_config_settings_views.xml',
# Portal templates
'views/portal_sales_rep_templates.xml',
'views/portal_client_repair_templates.xml',
'views/portal_maintenance_templates.xml',
# Wizards
'wizard/repair_intake_wizard_views.xml',
'wizard/repair_visit_report_wizard_views.xml',
'wizard/qr_sticker_wizard_views.xml',
# Reports
'report/qr_sticker_report.xml',
'report/inspection_certificate_report.xml',
# Menus (last, after all referenced actions exist)
'views/menus.xml',
],
'assets': {
'web.assets_backend': [
# Tokens MUST load first - dashboard.scss references its variables.
'fusion_repairs/static/src/scss/_fr_tokens.scss',
'fusion_repairs/static/src/scss/dashboard.scss',
'fusion_repairs/static/src/components/dashboard/dashboard.js',
'fusion_repairs/static/src/components/dashboard/dashboard.xml',
],
'web.assets_frontend': [
'fusion_repairs/static/src/scss/portal_repair_mobile.scss',
'fusion_repairs/static/src/scss/portal_client_repair.scss',
'fusion_repairs/static/src/js/portal_repair_intake.js',
'fusion_repairs/static/src/js/portal_client_repair.js',
],
},
'images': ['static/description/icon.png'],
'installable': True,
'application': True,
'auto_install': False,
}

View File

@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import portal_sales_rep_repair
from . import portal_client_repair
from . import portal_maintenance_booking

View File

@@ -0,0 +1,372 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Public client self-service portal at /repair.
Phase 1 scope (no AI yet):
- /repair Landing page with "Start" CTA
- /repair/new Multi-step form
- /repair/submit POST -> creates repair.order via shared intake service
- /repair/thanks Confirmation with reference
- /repair/lookup_phone jsonrpc safe partner match (masked PII)
Security:
- Public auth (no login) - the voicemail prompts mention this URL
- Per-IP rate limit on submit (configurable)
- Honeypot + CSRF
- Phone lookup returns ONLY masked name + address slice (never other PII)
- Records created via sudo in the controller; record rules don't apply
because anonymous users don't have a session
Phase 2+ will add: AI self-check, upsell engine, smart SMS verify,
safety on-call paging, reCAPTCHA v3.
"""
import base64
import hashlib
import logging
import re
import time
from odoo import SUPERUSER_ID, http, fields
from odoo.http import request
from odoo.tools import email_normalize
_logger = logging.getLogger(__name__)
# In-memory rate-limit window per worker. Good enough for Phase 1
# and matches the project's "no extra infra" goal. Resets on restart.
_RATE_LIMIT_BUCKET = {}
def _now_hour_bucket():
return int(time.time() // 3600)
def _mask_partner_for_lookup(partner):
"""Return ONLY safe summary fields - never the full partner record."""
name = partner.name or ""
# First name + last initial; never reveal full surname.
if " " in name:
first, last = name.split(" ", 1)
safe_name = f"{first} {(last or ' ')[:1]}."
else:
safe_name = name
return {
"matched": True,
"name": safe_name,
"city": partner.city or "",
}
def _e164_clean(phone):
if not phone:
return ""
return re.sub(r"[^\d+]", "", phone)[-12:]
class ClientRepairPortal(http.Controller):
# ------------------------------------------------------------------
# RATE LIMIT (scoped per endpoint so /repair/self_check and
# /repair/submit and /repair/lookup_phone don't share one bucket).
# ------------------------------------------------------------------
def _check_rate_limit(self, scope="submit"):
ICP = request.env["ir.config_parameter"].sudo()
# Scope-specific cap if configured, falls back to the global.
try:
limit = int(ICP.get_param(
f"fusion_repairs.client_portal_rate_limit_per_hour_{scope}",
ICP.get_param("fusion_repairs.client_portal_rate_limit_per_hour", "10"),
))
except (ValueError, TypeError):
limit = 10
ip = (
request.httprequest.headers.get("X-Forwarded-For")
or request.httprequest.remote_addr
or "unknown"
)
ip = ip.split(",")[0].strip()
bucket = _now_hour_bucket()
key = f"{scope}:{ip}:{bucket}"
# Prune old buckets across all scopes (cheap - dict is small).
suffix = f":{bucket}"
for k in list(_RATE_LIMIT_BUCKET.keys()):
if not k.endswith(suffix):
_RATE_LIMIT_BUCKET.pop(k, None)
if _RATE_LIMIT_BUCKET.get(key, 0) >= limit:
return True # blocked
_RATE_LIMIT_BUCKET[key] = _RATE_LIMIT_BUCKET.get(key, 0) + 1
return False
# ------------------------------------------------------------------
# LANDING
# ------------------------------------------------------------------
@http.route("/repair", type="http", auth="public", website=True, sitemap=True)
def repair_landing(self, sn=None, **kw):
serial_info = self._resolve_serial_info((sn or "").strip())
# Preserve the ?sn= in the CTA so the form gets it too.
form_url = "/repair/new" + (f"?sn={sn}" if sn else "")
return request.render("fusion_repairs.portal_client_repair_landing", {
"page_name": "client_repair_landing",
"serial_info": serial_info,
"form_url": form_url,
})
@http.route("/repair/new", type="http", auth="public", website=True,
sitemap=False)
def repair_new(self, sn=None, **kw):
categories = request.env["fusion.repair.product.category"].sudo().search([
("active", "=", True),
], order="sequence, name")
serial_info = self._resolve_serial_info((sn or "").strip())
return request.render("fusion_repairs.portal_client_repair_form", {
"page_name": "client_repair_new",
"categories": categories,
"serial_info": serial_info,
"error": kw.get("error"),
})
# ------------------------------------------------------------------
# B4: resolve ?sn=<serial> from a QR sticker scan
# ------------------------------------------------------------------
def _resolve_serial_info(self, serial):
if not serial:
return None
Lot = request.env["stock.lot"].sudo()
lot = Lot.search([("name", "=", serial)], limit=1)
if not lot:
return None
product = lot.product_id
category = product.product_tmpl_id.x_fc_repair_category_id
return {
"serial": lot.name,
"lot_id": lot.id,
"product_id": product.id,
"product_name": product.display_name,
"category_id": category.id if category else False,
}
# ------------------------------------------------------------------
# PARTNER LOOKUP (rate-limited, audited)
# The client is identifying themselves with a phone they own. We return
# enough info to pre-fill the form (name, email, street, city) plus the
# partner_id so submit can re-use the existing record instead of creating
# a duplicate. Privacy guard: rate-limited to 10/hr per IP; every match
# is logged at INFO level so abuse leaves a trail.
# ------------------------------------------------------------------
@http.route("/repair/lookup_phone", type="jsonrpc", auth="public",
website=True)
def repair_lookup_phone(self, phone=None, **kw):
if self._check_rate_limit(scope="lookup"):
return {"error": "rate_limited"}
cleaned = _e164_clean(phone)
if len(cleaned) < 7:
return {"matched": False, "partners": []}
matches = request.env["res.partner"].sudo().search([
"|",
("phone", "ilike", cleaned[-7:]),
("phone_sanitized", "ilike", cleaned[-7:]),
], limit=3) # cap at 3 - real households rarely have more
if not matches:
return {"matched": False, "partners": []}
_logger.info(
"Portal phone lookup matched %d partner(s) for last7=%s from IP=%s",
len(matches), cleaned[-7:], request.httprequest.remote_addr,
)
return {
"matched": True,
"partners": [{
"id": p.id,
"name": p.name or "",
"email": p.email or "",
"street": p.street or "",
"city": p.city or "",
} for p in matches],
}
# ------------------------------------------------------------------
# SUBMIT
# ------------------------------------------------------------------
@http.route("/repair/submit", type="http", auth="public", methods=["POST"],
csrf=True, website=True)
def repair_submit(self, **post):
# Honeypot - bots tend to fill every visible field.
if (post.get("hp_company") or "").strip():
_logger.info("Client portal submit blocked by honeypot from IP=%s",
request.httprequest.remote_addr)
return request.redirect("/repair/new?error=spam")
if self._check_rate_limit(scope="submit"):
return request.redirect("/repair/new?error=rate_limited")
# Required fields.
partner_name = (post.get("client_name") or "").strip()
phone = (post.get("client_phone") or "").strip()
issue_summary = (post.get("issue_summary") or "").strip()
category_id = int(post.get("category_id") or 0)
if not (partner_name and phone and issue_summary and category_id):
return request.redirect("/repair/new?error=missing")
# Validate email if provided. Empty is allowed; malformed redirects back.
raw_email = (post.get("client_email") or "").strip()
clean_email = email_normalize(raw_email) if raw_email else False
if raw_email and not clean_email:
return request.redirect("/repair/new?error=email")
# B3: trust the explicit known_partner_id from the lookup widget when
# present (client identified themselves via the lookup widget on this
# very page). Otherwise re-match by phone, otherwise create.
partner = False
try:
known_id = int(post.get("known_partner_id") or 0)
except (ValueError, TypeError):
known_id = 0
if known_id:
partner = request.env["res.partner"].sudo().browse(known_id).exists()
cleaned_phone = _e164_clean(phone)
if not partner and len(cleaned_phone) >= 7:
partner = request.env["res.partner"].sudo().search([
"|",
("phone", "ilike", cleaned_phone[-7:]),
("phone_sanitized", "ilike", cleaned_phone[-7:]),
], limit=1)
partner_vals = None
if not partner:
partner_vals = {
"name": partner_name,
"phone": phone,
"email": clean_email or False,
"street": (post.get("client_street") or "").strip(),
"city": (post.get("client_city") or "").strip(),
}
# Stage uploaded photos.
files = request.httprequest.files.getlist("photos")
attachment_ids = []
for f in files or []:
if not getattr(f, "filename", None):
continue
data = f.read()
if not data:
continue
attachment_ids.append(request.env["ir.attachment"].sudo().create({
"name": f.filename,
"datas": base64.b64encode(data),
"res_model": "fusion.repair.intake.session",
"res_id": 0,
}).id)
# B4: resolve ?sn= QR scan -> attach the lot to the repair
serial_info = self._resolve_serial_info((post.get("serial_number") or "").strip())
equipment = {
"repair_category_id": category_id,
"third_party": post.get("third_party") in ("on", "true", "1"),
"urgency": post.get("urgency") or "normal",
"issue_summary": issue_summary,
"internal_notes": (post.get("internal_notes") or "").strip(),
"photo_attachment_ids": attachment_ids,
}
if serial_info:
equipment["lot_id"] = serial_info["lot_id"]
# If client didn't override category, use what the QR identified.
if not category_id and serial_info.get("category_id"):
equipment["repair_category_id"] = serial_info["category_id"]
# Pick a real human owner for the repair so emails go from a person:
# admin if present, else the lowest-id non-share user, else SUPERUSER_ID.
admin = request.env.ref("base.user_admin", raise_if_not_found=False)
if admin:
intake_uid = admin.id
else:
internal = request.env["res.users"].sudo().search(
[("share", "=", False)], order="id asc", limit=1,
)
intake_uid = internal.id if internal else SUPERUSER_ID
payload = {
"partner_id": partner.id if partner else None,
"partner_vals": partner_vals,
"intake_user_id": intake_uid,
"equipment_items": [equipment],
}
try:
repairs = request.env["fusion.repair.intake.service"].sudo() \
.create_repair_orders(payload, source="client_portal")
except Exception:
_logger.exception("Client portal repair submit failed")
return request.redirect("/repair/new?error=server")
token = hashlib.sha256(
f"{repairs[0].id}:{repairs[0].create_date}".encode()
).hexdigest()[:16]
return request.redirect(f"/repair/thanks?ref={repairs[0].name}&t={token}")
@http.route("/repair/thanks", type="http", auth="public", website=True,
sitemap=False)
def repair_thanks(self, ref=None, t=None, **kw):
return request.render("fusion_repairs.portal_client_repair_thanks", {
"page_name": "client_repair_thanks",
"ref": ref or "",
})
# ------------------------------------------------------------------
# CL6 / CL7: AI self-check JSONRPC endpoint
# ------------------------------------------------------------------
@http.route("/repair/self_check", type="jsonrpc", auth="public",
website=True)
def repair_self_check(self, category_id=None, symptoms=None,
urgency=None, **kw):
if self._check_rate_limit(scope="self_check"):
return {"error": "rate_limited"}
if not symptoms:
symptoms = []
if isinstance(symptoms, str):
symptoms = [symptoms]
# Defensive: cap input size to defend against prompt-injection bloat
symptoms = [str(s)[:500] for s in symptoms[:5]]
Service = request.env["fusion.repair.ai.service"].sudo()
return Service.suggest_self_check(
product_category_id=int(category_id or 0) or None,
symptoms=symptoms,
urgency=urgency or None,
)
# ------------------------------------------------------------------
# CL15: on-call acknowledgement endpoint
# Only the paged user OR a Repairs Manager can ack - prevents arbitrary
# internal users (or someone with a forwarded mail) from acknowledging
# a page they were never paged for.
# ------------------------------------------------------------------
@http.route("/repair/on-call/ack/<string:token>", type="http",
auth="user", website=True, sitemap=False)
def repair_on_call_ack(self, token, **kw):
Repair = request.env["repair.order"].sudo()
repair = Repair.search([("x_fc_on_call_token", "=", token)], limit=1)
if not repair:
return request.render(
"fusion_repairs.portal_on_call_ack_invalid", {},
)
user = request.env.user
is_paged_user = user == repair.x_fc_on_call_paged_user_id
is_manager = user.has_group("fusion_repairs.group_fusion_repairs_manager")
if not (is_paged_user or is_manager):
_logger.warning(
"On-call ack denied for repair %s - user %s is not the paged "
"user (%s) and not a Repairs Manager.",
repair.name, user.login,
repair.x_fc_on_call_paged_user_id.login or "(none)",
)
return request.render(
"fusion_repairs.portal_on_call_ack_invalid", {},
)
Service = request.env["fusion.repair.on.call.service"].sudo()
Service.acknowledge(repair, user)
return request.render("fusion_repairs.portal_on_call_ack_ok", {
"repair_name": repair.name,
})

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Client maintenance booking portal.
The maintenance reminder email contains a tokenized URL:
/repairs/maintenance/book/<token>
Clicking it lands the client on a single-page form where they can confirm
a preferred date. On submit, a repair.order is spawned via the same
intake service (source='client_portal') and the contract's next reminder
band is locked so we don't keep nagging them.
"""
import logging
from odoo import _, fields, http
from odoo.http import request
_logger = logging.getLogger(__name__)
class MaintenanceBookingPortal(http.Controller):
def _resolve_contract(self, token):
if not token:
return None
Contract = request.env['fusion.repair.maintenance.contract'].sudo()
contract = Contract.search([('booking_token', '=', token)], limit=1)
if not contract or contract.state != 'active':
return None
return contract
@http.route('/repairs/maintenance/book/<string:token>', type='http',
auth='public', website=True, sitemap=False)
def maintenance_book_get(self, token, **kw):
contract = self._resolve_contract(token)
if not contract:
return request.render('fusion_repairs.portal_maintenance_invalid_token', {})
already = bool(contract.booking_repair_id)
return request.render('fusion_repairs.portal_maintenance_book', {
'contract': contract,
'already_booked': already,
'default_date': fields.Date.context_today(request.env.user).isoformat(),
})
@http.route('/repairs/maintenance/book/<string:token>/confirm', type='http',
auth='public', methods=['POST'], csrf=True, website=True)
def maintenance_book_post(self, token, **post):
contract = self._resolve_contract(token)
if not contract:
return request.render('fusion_repairs.portal_maintenance_invalid_token', {})
if contract.booking_repair_id:
return request.redirect(f'/repairs/maintenance/book/{token}?ok=already')
preferred_date = (post.get('preferred_date') or '').strip()
scheduled = False
if preferred_date:
try:
scheduled = fields.Date.from_string(preferred_date)
except ValueError:
scheduled = False
repair = contract.create_repair_from_booking(scheduled_date=scheduled)
return request.render('fusion_repairs.portal_maintenance_thanks', {
'contract': contract,
'repair': repair,
})

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