Compare commits

..

294 Commits

Author SHA1 Message Date
gsinghpal
5a28c7e90f chore(fusion_plating): bump versions for Phase 2 — cron + ACL + supporting computes
Some checks are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
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 are pending
fusion_accounting CI / test (fusion_accounting_ai) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_core) (push) Waiting to run
fusion_accounting CI / test (fusion_accounting_migration) (push) Waiting to run
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
b395600a1c changes 2026-05-21 05:18:32 -04:00
gsinghpal
612394c987 Merge branch 'main' of https://github.com/gsinghpal/Odoo-Modules 2026-05-21 04:48:06 -04:00
gsinghpal
d6d6249857 changes 2026-05-21 04:47:45 -04:00
gsinghpal
3440e4b7c6 feat(fusion_claims): force full-width sheet + 3-col responsive layout at xl
Aggressive sheet override: flex-basis 100%%, !important on width and
max-width to beat parent flex/media-query constraints. Also overrides
the o_form_sheet_bg wrapper.

Layout at xl (>=1200px) now splits into 3 columns:
- Col 1 (3/12): Your Activities + Bottlenecks
- Col 2 (5/12): ADP Pre + ADP Post + MOD
- Col 3 (4/12): Aging + Other Funders + Recent ADP Exports

Falls back to 5/7 on lg (Col 3 wraps below as full row) and stacked
single column on md and below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:38:39 -04:00
gsinghpal
5295aefd8f fix(fusion_claims): force full-width dashboard sheet with dedicated class
The .o_fc_dashboard .o_form_sheet override wasn't winning specificity
against Odoo's default form-sheet constraints. Added a dedicated class
o_fc_dashboard_sheet directly on the <sheet> element + !important
overrides on max-width, width, and flex to stretch the sheet to the
full container width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:30:04 -04:00
gsinghpal
4025789ba0 feat(fusion_claims): expand dashboard with this-month, pipeline, aging, recent exports + full-width
Adds 4 new sections:
- This Month rollup: submitted/approved/delivered/billed counts MTD
- Pipeline $ by stage: pre-submit / submitted / approved / ready-to-bill amounts
- Aging buckets: 30-59d, 60-89d, 90+ days
- Recent ADP Exports: last 5 with totals

Also overrides Odoo's form-sheet max-width on .o_fc_dashboard so the
dashboard uses the full browser width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:26:25 -04:00
gsinghpal
5b6e53c863 fix(fusion_claims): add Dashboard menu item under ADP Claims root
The dashboard action existed but no menuitem ever pointed to it (latent
bug in the original module). Adding menu_fusion_claims_dashboard as the
first child of menu_adp_claims_root so the dashboard becomes the default
landing for the Fusion Claims app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:04:20 -04:00
gsinghpal
b70fff01e1 feat(fusion_claims): bump version to 19.0.9.0.0 for dashboard rewrite
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:53:25 -04:00
gsinghpal
07f9bcf79b feat(fusion_claims): add OWL countdown widget for posting deadline
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:53:18 -04:00
gsinghpal
1420a5c445 feat(fusion_claims): add dashboard SCSS with dual-bundle theming
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:52:57 -04:00
gsinghpal
2bfb1015ea feat(fusion_claims): rewrite dashboard form view with action-oriented layout
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:51:59 -04:00
gsinghpal
ace82de88c feat(fusion_claims): add dashboard create-SO hotlinks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:50:58 -04:00
gsinghpal
1b1e9fdb9e feat(fusion_claims): add dashboard open-list action methods
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:50:32 -04:00
gsinghpal
95e0e2d9bd feat(fusion_claims): add dashboard ADP + MOD workflow tile counts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:49:48 -04:00
gsinghpal
cdc9f864b2 feat(fusion_claims): add dashboard other-funder counts
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:49:10 -04:00
gsinghpal
a00c891277 feat(fusion_claims): add dashboard activities and bottlenecks
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:48:41 -04:00
gsinghpal
f45883233c feat(fusion_claims): add dashboard KPI tiles (ready/claimed/AR)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:48:08 -04:00
gsinghpal
d5e79cdc10 feat(fusion_claims): add dashboard banner fields
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:47:24 -04:00
gsinghpal
1a8a96d94e feat(fusion_claims): scaffold dashboard model with role filter
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:46:17 -04:00
gsinghpal
53fd6114e7 changes 2026-05-21 03:42:46 -04:00
gsinghpal
1314f4581d changes 2026-05-21 03:37:25 -04:00
gsinghpal
b2f483d67c docs(fusion_claims): add dashboard redesign spec
Action-oriented dashboard replacing the existing 4-panel HTML overview:
posting-week banner with live countdown, 3 KPI tiles, 8 funder hotlinks,
ADP + MOD workflow flag tiles, role-aware filtering, dark-mode aware SCSS.

Spec captures all design decisions from the brainstorm session; ready to
hand off to writing-plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:29:23 -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
f1cea2fb35 fix(fusion_schedule): stop archiving valid events on @removed=changed
Microsoft Graph's delta API returns @removed={reason:'changed'} when an
event drifts outside the original delta-query window — the event still
exists upstream. The old code treated any truthy @removed the same as a
real delete and archived the local calendar.event. Combined with
_find_existing_event filtering by active=True, every subsequent sync
recreated a duplicate (then archived it on the next pass), accumulating
5x duplicates and emptying the user's calendar.

- _process_microsoft_event: only archive on isCancelled or
  @removed.reason='deleted'; skip on @removed.reason='changed'
- _process_microsoft_event link path: reactivate when MS Graph confirms
  a previously-archived event still exists
- _process_microsoft_event iCalUId path: same reactivation
- _find_existing_event: include archived records so wrongly-archived
  duplicates are reused instead of piling up
- callers reactivate the matched archived record

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 23:21:15 -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
8ef57a4bb1 fix(task_sync): defend against silent sync_id integrity violations
The cross-instance sync silently drops tasks when x_fc_tech_sync_id is
missing on the technician, and silently collapses duplicates via dict
comprehension. Both make sync break in ways that are invisible until
someone notices a missing task on the other instance.

- _get_remote_tech_map / _get_local_syncid_to_uid: warn on duplicates
- _push_tasks_to_remote: info-log when a task is skipped because the
  tech has no sync_id or no remote counterpart
- res.users onchange: warn in the form when entering a sync_id that
  is already used by another active field staff

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 22:29:48 -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
gsinghpal
d4fb1eebbf changes 2026-05-20 21:01:58 -04:00
gsinghpal
2e4d957a47 fix(certs): auto-edit first row in Issue Certs wizard so upload is visible
Previous attempt (e5928b96) used CSS to force the binary widget's
"Upload your file" button visible in display mode. Problem: it
rendered a non-clickable stub in every row, then DUPLICATED when
the operator clicked into edit mode (two upload links stacked).

Drop the SCSS hack entirely. Replace with a custom form-view
controller that auto-edits the first incomplete row on mount.
When the wizard opens, the JS:

  1. Scopes itself via the form's o_fp_cert_issue_wizard_form class
     (no-ops on every other form view in the system).
  2. Finds rows where the is_ready toggle is False.
  3. Clicks the fischer_file cell of the first such row.
  4. The row enters edit mode → Odoo's native binary widget renders
     its upload button → operator drops the file → onchange fires
     → readings parse.

Wired via js_class="fp_cert_issue_wizard_form" on the form root.
Banner copy updated to "Click a row, then click Upload your file in
the Fischerscope column" so even if the auto-edit fails for some
DOM reason, the operator knows the click path.

Module: fusion_plating_jobs 19.0.10.16.1 → 19.0.10.16.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:15:27 -04:00
gsinghpal
e5928b965f fix(certs): always-visible upload button in Issue Certs wizard list
Reported 2026-05-20: the Fischerscope file column shows "↑ Upload
your file" only when the operator clicks the cell. Until then, the
cell looks empty and operators don't know they can upload there.

Root cause: Odoo's default `widget="binary"` only renders the
upload button in EDIT mode. In editable lists, non-selected rows
stay in display mode, which hides the button. Stock theme CSS
hides .o_select_file_button on inactive rows.

Fix: scoped SCSS that overrides the default theme rule for the
Issue Certs wizard ONLY. `.o_select_file_button` becomes
`display: inline-flex !important` so it shows on every row from
the moment the wizard opens. Added a fa-upload icon glyph + dotted
underline so the button reads as clickable-action, not text.

Scoped to `.o_field_one2many[name="line_ids"]` inside the form view
so binary fields elsewhere in the system are unaffected. Registered
in both web.assets_backend and web.assets_web_dark per CLAUDE.md
two-bundle rule.

Module: fusion_plating_jobs 19.0.10.16.0 → 19.0.10.16.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:51:55 -04:00
gsinghpal
0600b87a29 fix(certs): surface Fischerscope upload inline in Issue Certs wizard
Reported 2026-05-20: clicking "Issue Cert" on a job opened the
wizard with a banner saying "Fischerscope file or readings needed
— fill it in below before confirming", but the list view only
showed status toggles (Needs Thickness / Is Ready). No upload
affordance was visible. Operators had to know they could click a
list row to expand into a hidden detail form where the upload
field lived.

The wizard model already had the file field, the .docx parser
(_fp_parse_fischerscope_docx), and the @onchange that prefills
readings — only the view was hiding it.

Fix: promote the file upload into the list as its own editable
binary column, alongside the existing Needs Thickness toggle.
Operator now sees:

  Reference │ Type │ Customer │ Needs Thickness │
  Fischerscope File (PDF or .docx) │ Parsed │ Ready

Drop the file → onchange fires → readings + parsed summary
populate in-row. Click "Confirm & Issue" to commit.

The per-line expanded form is preserved (still accessible via
row click) as a "details" panel for editing individual readings
after upload — but the primary upload action is now in the list
row where the operator's eyes are.

Module: fusion_plating_jobs 19.0.10.15.0 → 19.0.10.16.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:46:06 -04:00
gsinghpal
3d1b6e7ec5 fix(receiving): drop dead staged state — Option B (draft→counted→closed)
Reported 2026-05-20: the receiving state machine had four states
(draft → counted → staged → closed) where the middle pair was pure
ceremony. Real-usage data on entech:

  state distribution: 14 draft, 4 closed (zero `staged` records)
  median dwell counted → staged: 11 seconds
  median dwell staged  → closed: 4 minutes

`staged` captured no fields, fired no gates, mapped to the same SO
`x_fc_receiving_status='partial'` as `counted`. Pure click-through.

Cleanup:
- State Selection retains `staged` as `Staged (legacy)` so historical
  records remain readable; new transitions never write it.
- statusbar_visible drops it from the chevron header.
- action_mark_staged becomes a thin shim that advances counted →
  closed directly (any old button binding still works).
- action_close now accepts `counted` as a valid source state (was
  previously only `staged` / legacy `accepted` / `resolved`).
- View: "Stage for Racking" button removed. "Close" button renamed
  to "Close — Racking Confirmed" so the racking-crew confirmation
  meaning stays obvious.
- _update_so_receiving_status mapping unchanged for legacy `staged`
  (still maps to partial) — only the comment block updated to
  describe the new canonical flow.

Migration 19.0.3.20.0 advances any `staged` records to `closed`
and syncs the linked SO's x_fc_receiving_status to `received` so
downstream gates (job step start, mark_done qty check, cert
creation) don't see a stale "partial" status.

Module: fusion_plating_receiving 19.0.3.19.0 → 19.0.3.20.0.

Tests: TestQtyReceivedPropagation updated — 5 tests dropped the
action_mark_staged() call, walk draft → counted → closed directly.
All 11 tests green (carrier 6 + propagation 5).

Verified on entech: existing 14 draft + 4 closed records untouched.
Direct draft → counted → closed transition works end-to-end on
RCV-30041 (was the test target).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:40:43 -04:00
gsinghpal
d7bee9e854 fix(configurator): widen Template dropdown in Add Variant strip
Reported 2026-05-20: the Template dropdown in the Part > Process
Composer's 'Add Variant from Template' row truncated long recipe
names to 4 characters ("Cher" instead of "Chemical Conversion …").
The hard-coded max-width: 280px was set before the curated template
catalog grew names like "Chemical Conversion — Iridite Type II Cl 3"
and "ENP-STEEL-BASIC — Standard Heavy Phos".

Fix: replace the rigid max-width with a flex sizing that gives the
dropdown room to grow:
  - min-width: 360px (full common recipe name fits)
  - flex: 1 1 360px  (grows to fill available space)
  - max-width: 560px (cap so it doesn't push the buttons off-screen)

Same flex pattern applied to the Variant label input (slightly
narrower min/max).

Also: pulled the entech-side version of fp_part_process_composer.xml
back into the local repo — local was stale (one 'Add Variant' button;
entech had the dual 'Add — Tree' / 'Add — Simple' buttons that
landed in an out-of-band edit).

Module: fusion_plating_configurator 19.0.21.5.0 → 19.0.21.5.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:27:04 -04:00
gsinghpal
6343386488 fix(simple-editor): sticky Step Library panel for long recipes
Reported 2026-05-20: on a 40+ step recipe (e.g. ENP-STEEL-BASIC),
scrolling down into the Selected steps pane scrolled the Step
Library off the top of the screen. Authors had to scroll back up
to grab a step, then scroll down to drop it.

Fix: position: sticky on .o_fp_library_panel, pinned to top: 1rem
(matches the editor's padding) inside the .o_fp_simple_editor
overflow container. align-items: start on the grid so the library
column doesn't stretch to match the recipe column's height
(prerequisite for sticky to behave).

The library itself can have 30+ entries (curated step kinds +
shop-defined library templates). max-height: calc(100vh - 8rem)
+ overflow-y: auto keeps it from blowing past the viewport — it
grows its own internal scrollbar instead.

Mobile (≤900px) reverts to static positioning so the stacked
layout stays sensible.

Module: fusion_plating 19.0.20.6.1 → 19.0.20.6.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:20:59 -04:00
gsinghpal
afe0fd1206 fix(simple-editor): preserve scroll position across loadAll() re-renders
Regression of an earlier fix. Operators reported the editor jumping
to the top of the page on every step save / insert / remove / promote.

Root cause: .o_fp_simple_editor is the overflow:auto scroll
container. loadAll() replaces state.steps with a fresh JSONRPC
payload — OWL tears down the t-foreach and rebuilds every row, which
snaps scrollTop back to 0. Every author action (Save Step, Add
Step, Remove, Promote, Demote, Reorder, Import Template) routes
through loadAll, so the symptom hit everywhere.

Fix: capture scrollTop before the RPC, restore in a double-rAF
after the response settles. rAF (microtask runs before paint in
OWL 2; we need the rebuilt DOM to exist). One choke point fix —
every caller benefits without per-handler changes.

Cheap: a single DOM lookup + an integer save/restore. No XML or
state-shape changes.

Module: fusion_plating 19.0.20.6.0 → 19.0.20.6.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:13:55 -04:00
gsinghpal
ac1db177e1 feat(step-kinds): curate to 11 + mandatory + admin-only creation
Operator-reported foot-gun: Step Kind dropdown had 24 options, most
of which were visual-only (cleaning, electroclean, etch, rinse,
strike, dry, wbf_test, hardness_test, adhesion_test, salt_spray,
packaging, etc.) and didn't drive any gate or milestone. Picking the
wrong one meant nothing happened; picking Generic (left default)
meant nothing happened. Authors couldn't tell which choice mattered.

Curation: 24 → 11 active kinds. Each remaining kind has a concrete
downstream behaviour (gate, portal milestone, hardware tie-in, or
"explicitly no behaviour" for Other):

  other            Other (catch-all, default — no special behaviour)
  receiving        Received portal milestone
  contract_review  QA-005 form gate + button_finish lock
  racking          Rack-assignment dialog + button_finish lock
  mask             Visual mask kind (covers Masking + De-Masking)
  wet_process      Visual wet kind (NEW, covers cleaning, rinse,
                   etch, strike, dry, electroclean, wbf_test)
  plate            Plated portal milestone (last plate step closes)
  bake             Bake-window state machine + Baked milestone
  inspect          Intermediate inspection milestone
  final_inspect    Inspected (terminal) portal milestone
  ship             Shipped milestone (back-compat; delivery-state
                   driven is preferred)

Retired kinds (active=False, hidden from dropdown): cleaning,
electroclean, etch, rinse, strike, dry, wbf_test, demask, derack,
replenishment, hardness_test, adhesion_test, salt_spray, packaging,
gating. Kept in DB for audit / history but not selectable.

Mandatory enforcement:
- fp.step.kind_id on fusion.plating.process.node and fp.step.template
  is now required=True with ondelete='restrict' and a default that
  resolves to the 'other' kind. Existing NULL rows are backfilled by
  the pre-migrate before the NOT NULL constraint hits the schema.
- Dropdown no longer offers a blank / "Generic" option. New steps
  land on 'other' instead of NULL.

Admin-only catalog:
- /fp/simple_recipe/kinds/create endpoint now refuses requests from
  non-managers (group_fusion_plating_manager). Returns a clear
  message explaining why ("each kind drives gates / milestones /
  routing — pick Other if none fits, or ask a manager to wire up a
  new kind").
- "+ Add a new kind…" sentinel option in the library form is hidden
  unless state.recipe.user_is_manager. Backend gate is the authority;
  the UI hide is just to stop showing a button that will error.
- The Step Type dropdown in the inline step-edit panel switched from
  a 24-line hard-coded XML option list to a t-foreach over
  state.kindOptions (the same kinds/list endpoint payload). One
  source of truth — retire / add a kind in the catalog and every
  picker reflects the change.

Migration impact (entech): 5 templates + 579 nodes backfilled via
name-match heuristic. 15 kinds flipped to active=False. Distribution
of the 579 backfilled nodes:
  racking 105, other 97, bake 91, wet_process 90, mask 74,
  inspect 44, plate 32, final_inspect 25, receiving 10,
  contract_review 9, ship 2.

Drive-by:
- Migration uses _ensure_kind() that also registers ir.model.data
  for the new xmlids so the subsequent data XML load doesn't create
  duplicate kind records.
- Stored related default_kind on fusion.plating.process.node /
  fp.step.template is written alongside kind_id in every SQL UPDATE
  so legacy `node.default_kind == 'foo'` comparisons stay accurate
  (the ORM doesn't recompute stored related fields after direct
  SQL writes).

Module: fusion_plating 19.0.20.5.0 → 19.0.20.6.0.
15 existing tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:08:31 -04:00
gsinghpal
7c31269691 fix(simple-editor): stop seed resurrection + add promote/demote + drag substeps
Three bugs reported on 2026-05-20:

1. RESURRECTION. User deletes a substep in the Simple Editor (e.g.
   Soak Clean (S-3) under Cleaner), then on the next -u fusion_plating
   the substep comes back. Root cause: the recipe XML lived in the
   manifest's `data` list with `noupdate="1"`. Odoo's noupdate=1 only
   blocks UPDATE of existing records — when a record's ir.model.data
   row is missing, the loader treats it as "not yet created" and
   re-creates from XML. Every upgrade resurrected every user-deleted
   seed node.

   Fix: pull the recipe XML files out of `data` and load them once
   via post_init_hook → _seed_starter_recipes_once. Sentinel checks
   ir.model.data for each recipe's root xmlid; if present, skip
   loading entirely. Result: deletions are permanent across all
   future upgrades. Existing entech recipes untouched.

   Files affected: fp_recipe_enp_alum_basic, fp_recipe_enp_steel_basic,
   fp_recipe_enp_sp, fp_recipe_general_processing, fp_recipe_anodize,
   fp_recipe_chem_conversion.

2. PROMOTE / DEMOTE. Simple Editor had no way to turn a substep into
   a top-level operation, or to tuck an operation under another as a
   substep. Authors had to delete + re-create. New endpoints:

   * /fp/simple_recipe/step/promote → flips node_type 'step' →
     'operation', re-parents to the recipe (or sub-process) root,
     places right after the old parent operation.
   * /fp/simple_recipe/step/demote → flips 'operation' → 'step',
     re-parents under the preceding operation (or a caller-supplied
     target_op_id). Blocks demoting an operation that has its own
     children, with a helpful message.

   UI: each row in the editor now carries an up-arrow (promote, only
   shown on substeps) and a down-arrow (demote, only shown on
   operations). Confirmation dialog explains what's about to happen.

3. DRAG SUBSTEPS. Last commit (2142a66b) disabled drag on substep
   rows. Operators couldn't reorder substeps within an operation.
   Re-enabled drag on substeps. The step_reorder endpoint now groups
   incoming node_ids by parent_id and renumbers within each parent
   (10, 20, 30…). Cross-parent drag still no-ops on parent change —
   Promote/Demote buttons are the way to move between parents.

Drive-by:
- Added `from odoo import _` to the controller (missing import the
  new endpoints surfaced).
- Edit-panel field wiring audited: all fields visible in the screen
  (Step name, Default instructions, Step Type, Triggers Workflow,
  Parallel Start, QA Sign-off, Collect measurements, Instruction
  Images, custom prompts) persist correctly through step_write or
  dedicated endpoints. No broken wires.

Tests: 15 total in TestSimpleRecipeFlatten (was 10). 5 new cover
promote happy-path, promote reject (non-substep), demote happy-path,
demote block on has_children, and reorder parent-scoping.

Module: fusion_plating 19.0.20.4.0 → 19.0.20.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:53:09 -04:00
gsinghpal
2142a66bc0 fix(simple-editor): also surface step children of operations
Follow-up to 821e768b. The previous fix flattened sub_process nodes
so all 16 operations of ENP-STEEL-BASIC became visible — but the
Tree Editor also shows the 26 `step` nodes that live under each
operation ("Ready For Blast / Blast", "Soak Clean / Electroclean /
Primary Rinse", etc.). The Simple Editor still hid those, so author
+ Tree Editor still disagreed by 26 rows.

New `_flatten_recipe_nodes(recipe)` helper walks DFS and surfaces
BOTH operations and their step children. Each operation is followed
immediately by its step children in sequence order so the editor
renders them as a contiguous block:

  10. Ready For Steel Line
  11. Cleaner                            [Steel Line]
     ↳ Soak Clean (S-3)                  [Steel Line › Cleaner]
     ↳ Electroclean (S-3)                [Steel Line › Cleaner]
     ↳ Primary Rinse (S-4)               [Steel Line › Cleaner]
  15. Acid Dip (S-5)                     [Steel Line]
     ↳ Primary Rinse (S-6)               [Steel Line › Acid Dip (S-5)]
     ...

Payload additions on each step:
- `node_type`: 'operation' | 'step'
- `is_substep`: True for steps (renders indented)
- `nested_under`: chained path (sub-process › operation for substeps,
  sub-process for nested operations, '' for top-level operations)

UI: substep rows are indented 2.5rem, smaller font, no drag handle,
no numeric position. The "↳" indent glyph and a "[parent operation]"
chip make the parent-child relationship obvious. Substeps are not
draggable to keep the existing reorder semantics simple — Tree Editor
remains the home for structural changes.

Legacy `_flatten_recipe_operations` helper retained for back-compat
(it now delegates by filtering `node.node_type == 'operation'` from
the full walk).

ENP-STEEL-BASIC on entech: Simple Editor now shows 42 rows (was 10
before 821e768b, was 16 after 821e768b) — matches what the Tree
Editor displays exactly.

Tests: 10 total (was 7), 3 new cover the substep surfacing, path
chaining, and is_substep / node_type flags on the payload.

Module: fusion_plating 19.0.20.3.0 → 19.0.20.4.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:30:00 -04:00
gsinghpal
821e768b7e fix(simple-editor): surface operations nested inside sub_process nodes
Bug on ENP-STEEL-BASIC (2026-05-20): authoring used the Tree Editor
to build a recipe with a "Steel Line" sub_process holding 7 nested
operations (Cleaner, Acid Dip, Nickel Strike, E-Nickel Plate, etc.).
The Simple Editor's /fp/simple_recipe/load endpoint only walked
`recipe.child_ids`, so it returned 10 steps. The work order generator
(fp.job._generate_steps) walked the same tree depth-first and emitted
16 steps. Author and operator disagreed about what was in the recipe.

Fix: new `_flatten_recipe_operations(recipe)` helper walks the tree
depth-first, recurses into `recipe` and `sub_process`, emits each
`operation` exactly once, skips `step` children (they're sub-
instructions of operations). Mirrors the WO walker.

Step payload now carries a `nested_under` string — the chained sub-
process name(s) the operation lives inside (empty for top-level).
The Simple Editor XML renders that as a small "↳ Steel Line" badge
next to the step name so the author can see where each row came from
in the tree. Deep nesting chains with ' › ' (e.g. "Outer › Inner").

`step` children of `recipe` itself remain invisible — they were
silently skipped by the WO generator pre-19.0.18.8.0 anyway (only
operation nodes spawn fp.job.step rows). Restoring them here would
contradict that long-standing contract.

Edit/insert/reorder/remove endpoints unchanged: editing a nested
operation's name / description / tanks works (no parent change).
Drag-reorder within sub-process siblings still works. Drag across
sub-process boundaries isn't supported — opens the door for a Tree
Editor follow-up if needed, but the immediate "I can't see my
steps" complaint is resolved.

ENP-STEEL-BASIC on entech now shows all 16 operations in the Simple
Editor (was 10), with the 7 inside Steel Line tagged accordingly.

Tests: 7 new (TestSimpleRecipeFlatten) — flat recipes still work,
nested operations surface with correct path label, sub_process
nodes never appear as editor rows, step children of operations
stay hidden, deep-nested sub_processes chain path labels.

Module: fusion_plating 19.0.20.2.0 → 19.0.20.3.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:22:54 -04:00
gsinghpal
2645db40a2 fix(receiving): propagate qty_received to fp.job + drop duplicate carrier field
Bug surfaced on WO-30043 (2026-05-20): operator walked every step
including a fully closed receiving record, then hit
"Quantity Received is blank — close the receiving record for
SO SO-30043 before completing this job." Receiving WAS closed.

Root cause: the 2026-05-18 cert-creation gate
(fp.job.button_mark_done) blocks on job.qty_received but nothing
populated it. fp.receiving carried the qty on its line records,
fp.job stayed at 0 indefinitely. Two disconnected records on the
same SO.

Fix: when fp.receiving._update_so_receiving_status runs (i.e. on
every state transition — counted / staged / closed / accepted /
resolved), also mirror each line's received_qty onto the matching
fp.job by (sale_order_id + part_catalog_id). Single-part SOs map
1-to-1; multi-part SOs spawn one job per line so the same join
still works.

Two defensive guards in the hook:
- Skip silently when fusion_plating_jobs not installed
  (Job = env.get('fp.job') returns None).
- Skip silently when fp.job doesn't yet carry part_catalog_id /
  qty_received (test scope, unusual install topology).

Drive-by during cleanup:
- fp_parent_numbered_mixin._fp_assign_parent_name: guard
  so.x_fc_parent_number access with field-existence check. The
  column lives in fusion_plating_jobs; downstream modules that
  inherit the mixin (receiving) but don't depend on jobs were
  hitting AttributeError on every fp.receiving.create at test
  time. Falls through to the legacy sequence when the column
  isn't there.

- fp_receiving_views.xml: legacy carrier_name Char field rendered
  as a second carrier row labeled "Legacy Carrier" alongside the
  proper x_fc_carrier_id M2O — operators saw two carrier fields
  and got confused. Hide the legacy display (data stays in DB for
  audit; migration 19.0.3.10.0 already matched it to a real
  delivery.carrier).

Migration 19.0.3.19.0/post-migrate.py backfills qty_received from
closed receiving lines for any job stuck at 0 — fixes WO-30043
and two sibling jobs on entech.

Modules: fusion_plating 19.0.20.2.0, fusion_plating_receiving
19.0.3.19.0, fusion_plating_jobs 19.0.10.15.0.

All 19 tests green (TestCarrierFields 6, TestQtyReceivedPropagation 5
new, TestReceivingGate 8). Direct verification on entech: WO-30043
qty_received = 1, mark_done succeeds, delivery + cert auto-created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:15:46 -04:00
gsinghpal
60eb2adef3 fix(claims): intake_mode title above radio, not on the left
Switched the section title from group string= (which Odoo was rendering
as a left-side column label) to a real <separator/>, so the heading
sits above the radio and the options use the full form width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:48:37 -04:00
gsinghpal
e3bec557b6 fix(claims): restore long intake_mode labels, give group full width
Reverts the label shortening and instead sets col=1 on the radio group
so the group's inner layout is a single column. With the full wizard
width available, the full labels fit on one line each.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:41:51 -04:00
gsinghpal
6a1640ff6d fix(claims): shorten intake_mode labels — single line in radio
The group title already says "How were pages 11 & 12 provided?", so the
radio labels don't need to repeat "Pages 11 & 12". Shortened to:
"Inside the original application" / "Separate file" / "Sign remotely".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:37:31 -04:00
gsinghpal
10f5d44965 chore(claims): bump version to 19.0.8.0.7
Bumps fusion_claims version to bust the asset bundle cache after the
Application Received wizard refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:01:32 -04:00
gsinghpal
a4d615d74e feat(claims): wizard view — intake-mode radio + conditional groups
Three-mode radio at the top of the Application Received wizard. The
Signed Pages 11 & 12 group is only shown in Separate mode; the remote
sign banner/button is only shown in Remote mode. Adds a read-only
'Detected pages' indicator next to the uploaded original PDF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:01:08 -04:00
gsinghpal
f5ac8d07d7 feat(claims): three-mode Application Received wizard
Adds intake_mode (bundled / separate / remote) so staff can mark
applications received with a single bundled PDF, the existing
separate-pages-file flow, or a pending remote signature. Folds in
content-based PDF validation, a friendlier status-gate message,
and a page-count helper for the original application.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 18:00:41 -04:00
gsinghpal
50539741ce feat(claims): case-close audit accepts bundled pages flag
The signed-pages verification step on case close now treats the bundled
flag as 'pages present', matching the ready-for-submission gate and the
audit trail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:20:56 -04:00
gsinghpal
7a891c5aaa feat(claims): ready-for-submission gate accepts bundled pages flag
Both the has_documents indicator and the action_confirm missing-items
gate now read x_fc_has_signed_pages_11_12, so orders with pages 11 & 12
bundled inside the original PDF can move to Ready for Submission without
a separate signed-pages file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:20:35 -04:00
gsinghpal
3bef640979 feat(claims): audit trail honours bundled pages flag
x_fc_trail_has_signed_pages now reads x_fc_has_signed_pages_11_12, so
the trail correctly shows complete when pages 11 & 12 are bundled inside
the original application.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:15:52 -04:00
gsinghpal
1f20eb3d2a feat(claims): add x_fc_pages_11_12_in_original + computed gate
New boolean on sale.order tracks whether pages 11 & 12 are bundled
inside the original application PDF. Computed helper
x_fc_has_signed_pages_11_12 ORs bundled flag with separate-file and
remote-signing presence so downstream gates can read one field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 15:15:50 -04:00
gsinghpal
df53ab956f docs(plan): ADP application received — bundled pages 11 & 12
Seven-task TDD implementation plan for the design at
2026-05-19-adp-application-received-bundled-pages-design.md. Adds the
bundled-flag + computed gate to sale.order, updates downstream gates
(ready-for-submission, case-close, audit trail), rewrites the
Application Received wizard with a three-mode radio, and bumps the
module version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:26:10 -04:00
gsinghpal
5ff271a7b1 docs(spec): ADP application received — bundled pages 11 & 12 design
Design for refining the Application Received wizard so staff can mark
applications received with a single PDF when pages 11 & 12 are inside
the original application — without losing the existing separate-file
and remote-signing paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 14:19:32 -04:00
gsinghpal
8831176ec4 feat(certificates): Fischerscope thickness-report upload wizard
Operators now drop a .docx or .pdf Fischerscope XDAL 600 export
on the cert form's Thickness Report tab. The wizard parses the
readings, calibration std, operator + date metadata, and the
embedded microscope image, then shows them for review before
recording on fp.certificate.

  Operator         Wizard               Certificate
  ─────────────────────────────────────────────────────────────
  Click "Upload    Parse .docx /        - thickness_reading_ids
   Thickness         .pdf →               written (3 rows)
   Report"         Show 3 readings      - x_fc_local_thickness
  Pick file        + metadata             _pdf attached (original
  Click Parse      Click Save             file)
                                        - microscope image as
                                          ir.attachment on cert
                                        - chatter post
  ─────────────────────────────────────────────────────────────

When parse can't find readings (unrecognised format), wizard falls
through to manual state — operator can still save, file lands on
the cert as-is for the existing CoC page-2 merge logic.

Closes the gap in the S19 enforcement: x_fc_send_thickness_report
customers blocked at action_issue until the file is on file. Now
they have a parseable upload UX, not just a bare Binary field.

Architecture
- fischerscope_parser.py: pure-Python lib, branches on extension,
  python-docx + PyPDF2 already on entech (no new deps). Regex
  extraction returns {readings, metadata, image, errors}.
- fp.thickness.upload.wizard: TransientModel with upload/review/
  manual states. Lazy-imports parser at action_parse time to dodge
  Python 3.11 partial-init relative-import error.
- 27 tests (TestFischerscopeParser 9 + TestThicknessUploadWizard 8
  + the rehoused TestActionIssueGates 10) — all green on entech.

Same metadata copies onto every reading row, microscope image
attaches once at cert level (decisions 2026-05-19).

Drive-by fixes uncovered while running tests on entech:
- fp.certificate.action_issue: guard rec.company_id access with
  field-existence check. Lazy-fill-signer branch crashed when
  certified_by_id was unset on certs that don't carry a company_id
  field. Pre-existing bug that never fired in production because
  jobs auto-fill certified_by_id before reaching this branch.
- test_action_issue_gates: set x_fc_send_thickness_report=False on
  the test partner. Field defaults to True so every cert in this
  class hit the thickness gate; tests were never able to verify
  the other gates in isolation.
- Tests directory missing test_action_issue_gates.py on entech.
  Synced; turns out the 2026-05-18 "changes" commit added the file
  locally but the deploy script never copied tests/.

Module: fusion_plating_certificates 19.0.6.4.0 → 19.0.7.0.0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:05:16 -04:00
gsinghpal
d77cc252bb fix(tests): TestReceivingGate — drop nonexistent step_kind_id, use step name
The helper set step_kind_id on fp.job.step when fp.step.kind model
exists, but step_kind_id field doesn't actually exist on fp.job.step
in deployed shape — both test_start_skips_contract_review and
test_finish_skips_contract_review erred with
  ValueError: Invalid field 'step_kind_id' in 'fp.job.step'

Per CLAUDE.md rule 18, _fp_is_contract_review_step() matches step
name case-insensitive against 'contract review' or 'qa-005'. The
test only needs to trigger that detection — set name='Contract
Review' on the CR branch and let the receiving gate's existing
exemption fire.

All 8 TestReceivingGate tests now pass on entech.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:21:53 -04:00
gsinghpal
091f98e1f9 changes 2026-05-18 22:33:23 -04:00
gsinghpal
25f568f225 fix(portal): correct terminology — Sales Orders everywhere (revert Purchase Orders rename)
The customer's Purchase Order is the doc they send US — a separate
artifact, often a PDF attachment on the quote. What lives in our
system is the Sales Order we create in response. Labeling the SO
list as "Purchase Orders" in the customer portal was a wrong-side
mapping.

Reverts and renames in this commit:

- Sidebar item label: "Purchase Orders" → "Sales Orders" (key stays
  odoo_orders; URL still /my/orders). _FP_SIDEBAR_LAYOUT.

- Dashboard KPI tile: "Active POs" → "Active Sales Orders". Link
  hint: "View POs →" → "View orders →". Link target updated to the
  current /my/orders (the legacy /my/purchase_orders still redirects
  but we point at the canonical URL now).

- Dashboard panel: "Recent Purchase Orders" → "Recent Sales Orders".
  Empty state: "No purchase orders yet." → "No sales orders yet."
  View-all link target updated to /my/orders.

- Dashboard docs entries strip: "Purchase Orders" docs entry title
  → "Sales Orders"; URL → /my/orders.

- Removed the three Odoo template rename inherits from
  fp_sale_order_portal.xml (sale.portal_my_home_menu_sale,
  sale.portal_my_orders, sale.sale_order_portal_content). With those
  gone the stock templates emit Odoo's native "Sales Order(s)" and
  "Your Orders" wording on the list page header, breadcrumb, and
  detail page <h2> — which is now the correct terminology.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:30:55 -04:00
gsinghpal
4e54ecc32f fix(portal): sidebar values + Purchase Order naming on /my/orders detail
1. Odoo's portal_order_page route calls _get_page_view_values which
   doesn't touch _prepare_portal_layout_values, so our sidebar
   context (fp_sidebar_items, fp_partner_display_name) was missing
   on every Odoo detail page (SO, invoice, delivery, quote). Override
   _get_page_view_values to setdefault our two keys into the values
   dict — non-clobbering, covers every detail route.

2. Rename "Sales Order(s)" / "Your Orders" to "Purchase Order(s)" on
   the customer portal so the wording matches the sidebar item and
   the customer's perspective (they purchase from us). Inherits in
   fp_sale_order_portal.xml replace the relevant text nodes in
   sale.portal_my_home_menu_sale / sale.portal_my_orders /
   sale.sale_order_portal_content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:22:36 -04:00
gsinghpal
ab7ff3eea5 fix(portal): /my/orders 500 — QWeb t-value is Python not Jinja, |length is bitwise OR
orders|length in t-value parses as orders | length, not as a Jinja
length filter. orders is a sale.order recordset; the `length`
identifier resolves to None; Python evaluates
recordset | None and raises TypeError. Use len(orders) instead.

Also documents the gotcha in CLAUDE.md (rule 19) so future templates
don't reach for Jinja-style filters in t-value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:13:33 -04:00
gsinghpal
f8fc6be370 feat(portal): inject filter+search strip into Odoo /my/orders + docs
- views/fp_sale_order_portal.xml: new template inherit
  portal_my_orders_fp_search on sale.portal_my_orders. Injects the
  fp_portal_list_controls strip before the "no orders" alert. Filter
  pills + sort dropdown are disabled here (we don't own the route,
  Odoo's sortby is preserved separately). The search input is wired
  to .o_portal_my_doc_table tbody (the table class Odoo's
  portal.portal_table emits) so real-time keyword filtering works
  without needing to monkey-patch the stock route or template.

- CLAUDE.md: documents two conventions surfaced by the recent portal
  work:
    Rule 17 — test scaffolding for account.move creation must use
      with_context(fp_from_so_invoice=True) and pass
      invoice_payment_term_id, to satisfy custom gates in
      fusion_plating_jobs and fusion_plating_invoicing.
    Rule 18 — FP portal list pages don't paginate. They load up to
      500 records and rely on fp_portal_list_search.js to filter
      client-side. Hidden <td class="d-none"> cells per row carry
      extra searchable text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:06:26 -04:00
gsinghpal
b27f68b8d5 feat(portal): real-time search + filter pills on 4 FP list pages
Replaces the tab nav / portal.portal_searchbar on the 4 FP list
pages with the new fp_portal_list_controls macro (filter pills +
search input + sort dropdown) and drops portal_pager in favour of
client-side filtering of up to 500 records:

- Quote Requests (/my/quote_requests):
    filters: All / Active / Converted / Declined
    sorts:   Newest / Reference / Status
    extra search fields: contact_name, contact_email, line.part_number,
                         line.description, line.product_id.default_code

- Work Orders (/my/jobs, cards layout):
    filters: All / Active / Ready to Ship / Complete
    sorts:   Newest / Reference / Status
    extra search fields per card: part_catalog.part_number, part_catalog.name,
                                  sale_order.name, sale_order.client_order_ref,
                                  job.notes

- Certifications (/my/certifications):
    no filters (all rows are terminal CoC jobs)
    sorts:   Newest / Reference
    extra search fields: part name, processes (already in card text)

- Packing Slips / Deliveries (/my/deliveries):
    no filters (all rows are state=done)
    sorts:   Newest / Reference
    adds a visible Origin column (sale order ref) so customers can
    locate a slip by the SO it came from

Each route accepts ?filter_state=... and ?sortby=... query params,
returns up to 500 records, and passes result_total + clipped to the
template so the macro can render a "showing latest 500 of N" notice
when the cap is hit.

Hidden <td class="d-none"> cells inside each row carry extra terms
that aren't displayed but are matched by the JS textContent scan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:06:18 -04:00
gsinghpal
d9bdbd8e18 feat(portal): reusable list-search JS + fp_portal_list_controls macro
Adds the shared infrastructure for real-time multi-keyword search on
portal list pages:

- static/src/js/fp_portal_list_search.js — vanilla-JS IIFE that wires
  every input.o_fp_list_search to the container at the selector in
  its data-fp-target. On every keystroke, walks the container's
  direct children and toggles display: none based on whether each
  row's textContent contains all whitespace-tokenised keywords. Also
  wires .o_fp_sort_select dropdowns on every page EXCEPT Account
  Summary (scoped by .o_fp_account_summary closest-ancestor check) so
  the existing fp_portal_account_summary.js handler isn't doubled up.

- views/fp_portal_macros.xml — new t-call macro
  fusion_plating_portal.fp_portal_list_controls that renders the
  filter pills + search input + sort dropdown strip in one block.
  Callers pass filters, sorts, active_filter, active_sort, search,
  url, extra_qs, target, result_total, clipped via t-set.

- __manifest__.py — registers the new JS in web.assets_frontend
  (after fp_portal_account_summary.js). Version bumps 19.0.4.0.0 ->
  19.0.4.1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:06:02 -04:00
gsinghpal
281941c7ee fix(portal): column-top fix needs !important to beat Bootstrap utilities
Previous attempts (e50631c, 6f2bea9) zeroed .container's pt-3 and the
first child's mt-3, but the right column was still sitting ~32px lower
than the sidebar. Reason: Bootstrap 5 ships .pt-3 and .mt-3 as
margin-top: 1rem !important / padding-top: 1rem !important. My
overrides without !important lost the cascade and never took effect.
Match Bootstrap's specificity by adding !important on both rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:40:52 -04:00
gsinghpal
7eb9dd02a7 fix(portal): force outer breadcrumb container on every /my/* page
Odoo stock routes (/my/orders, /my/invoices, etc.) call
portal.portal_searchbar with breadcrumbs_searchbar=True, which made
portal.portal_layout suppress its outer breadcrumb container — the
breadcrumb then rendered inside the searchbar nav, which lives inside
our shell's <main> and showed up in the right column. We can't edit
the stock route handlers, so override portal.portal_layout in
fp_portal_shell to ignore breadcrumbs_searchbar (still respect
no_breadcrumbs and my_details). CSS-hide the now-duplicate inline
breadcrumb inside .o_portal_navbar so we don't show two trails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:36:19 -04:00
gsinghpal
3a520564a7 fix(portal): account summary 500 — open_balance can't use t-field
t-field requires a record.field_name access pattern. open_balance is a
Python float (returned by _fp_account_summary_open_balance), not a
recordset attribute, so QWeb threw AssertionError at render time and
the page 500'd. Format the value in the controller via tools.formatLang
and render it as a plain string with t-out instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:36:10 -04:00
gsinghpal
6f2bea9773 fix(portal): zero first-child top margin so right column aligns flush
Many FP templates slap mt-3/mt-4 onto their root content div (dashboard,
configurator wizard steps, etc.) which still pushed the right column's
content ~16px below the sidebar's top edge even after pt-3 was zeroed
in e50631c. Scope a margin-top: 0 to .o_fp_portal_main #wrap > .container's
first child — strips whichever utility class the template happens to use
without touching siblings or styles below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:28:07 -04:00
gsinghpal
e50631c46a fix(portal): align right column top with sidebar top
Odoo's portal_layout wraps page content in <div class="container pt-3 pb-5">.
The pt-3 (1rem) was pushing the right column's first visible content ~16px
below the sidebar card's top edge, so the two column corners looked
misaligned. Zero out the top padding on that inner container, scoped via
.o_fp_portal_main #wrap > .container so it only applies inside our shell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:24:42 -04:00
gsinghpal
76c68e0311 fix(portal): consistent breadcrumb position + history + column height parity
Three coordinated portal-chrome fixes:

1. Drop `breadcrumbs_searchbar=True` from the four list templates
   (quote_requests, jobs, deliveries, certifications). They were
   suppressing Odoo's outer breadcrumb container, so the breadcrumb
   rendered inside portal.portal_searchbar in the right column on
   those pages. With the flag off, the outer container fires on
   every /my/* page (consistent with the dashboard, configurator,
   and detail pages). The portal_searchbar's else-branch now renders
   the page title in a Bootstrap navbar — the title still shows,
   just no longer doubled up as breadcrumb chrome.

2. Breadcrumb history pass in fp_portal_breadcrumbs.xml:
   - fp_jobs / fp_portal_job: rename label from "Parts Portal" to
     "Work Orders" so the breadcrumb matches the sidebar item.
   - fp_purchase_orders / fp_invoices: drop the dead stanzas. Both
     page_names are unreachable since Task 7 turned those routes
     into redirects.
   - fp_account_summary: add the missing entry so the new page has
     a trail.

3. Drop `align-items: start` on .o_fp_portal_shell and add
   min-height: 100% + min-width: 0 on .o_fp_portal_main. The right
   column now stretches to match the sidebar's height on short
   pages, so layouts look uniform. min-width: 0 lets wide table
   children scroll horizontally instead of forcing the grid track
   to grow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:50:51 -04:00
gsinghpal
04862e8a28 fix(portal): inject sidebar layout values into every FP portal render
Every FP portal route built `values = {...}` from scratch and called
`request.render(...)` directly, bypassing `_prepare_portal_layout_values`.
Our new `fp_sidebar_items` and `fp_partner_display_name` keys live in
that hook, so the sidebar template's `t-foreach` was a no-op on every
custom page (`/my/home`, `/my/jobs`, `/my/account_summary`, etc.) — the
sidebar rendered with the "My Account" fallback header and only the
Sign Out footer link visible.

Fix: each FP render now does
    values = self._prepare_portal_layout_values()
    values.update({...route-specific values...})
This puts the layout values in first (so `fp_sidebar_items` and
`fp_partner_display_name` always present), and the route's own
update wins on `page_name` and other collisions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:39:53 -04:00
gsinghpal
cdc47554ed fix(portal): account summary sort dropdown — drop inline JS for CSP safety
The inline 'onchange=\"window.location.href = this.value\"' attribute on
the sort <select> is the only inline-JS handler in the project's QWeb
templates. Under a strict Content-Security-Policy (script-src 'self')
the handler silently fails, leaving the sort dropdown dead. Replace
with a tiny vanilla-JS file (fp_portal_account_summary.js) that attaches
the listener via class selector .o_fp_sort_select inside the Account
Summary page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:23:01 -04:00
gsinghpal
77b84ac11b feat(portal): Account Summary template (3 tabs, filter, search, sort, pager)
Tabs: Invoices / Credit Memos / Statements (V1 placeholder).
Page header carries the Open Balance pill. Per-tab filter pills
(Open/Closed/All), search box (name OR ref), sort dropdown
(newest/oldest/largest/smallest), 10-per-page pager.

Empty states: 'No results for X' for failed searches, 'No records
in this tab' for empty result sets, and the dedicated Statements
'coming soon' card. Statements tab hides the filter/search/sort
strip — nothing to filter yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:19:33 -04:00
gsinghpal
b92a396934 feat(portal): account_summary controller + 3 unit tests
New /my/account_summary route. Splits posted account.move into
Invoices (out_invoice) / Credit Memos (out_refund) / Statements
(V1 placeholder). Open Balance helper sums amount_residual across
open invoices for the partner's commercial tree.

Search filters name OR ref (customer PO). Sort options: date desc/asc,
amount desc/asc. Filter pills: open / closed / all.

Tests cover the tab partitioning, the open-balance sum, and the
search behaviour. Helpers use commercial_partner.env so they work
in both HTTP context and unit tests without requiring request.env.
Test scaffolding uses fp_from_so_invoice=True context flag and
invoice_payment_term_id to satisfy the fusion_plating_jobs and
fusion_plating_invoicing create/post gates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:13:48 -04:00
gsinghpal
8225061dfa feat(portal): redirect 3 legacy URLs to consolidated homes (Sub-A IA)
- /my/fp_invoices       -> /my/account_summary
- /my/purchase_orders   -> /my/orders (Odoo default)
- /my/quote_requests/new (GET) -> /my/configurator/new
  (POST handler preserved for back-compat with the existing RFQ form
  button; will be removed after the form is fully retired)

Thin templates deleted: portal_my_fp_invoices, portal_my_purchase_orders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:01:32 -04:00
gsinghpal
fe4cceeffa chore(portal): bump 19.0.4.0.0 + register sidebar SCSS + JS
fp_portal_shell.xml was already registered in Task 3 commit
(d17cada). This commit adds the two missing asset entries:
fp_portal_sidebar.scss in web.assets_frontend, after
fp_portal_dashboard.scss; fp_portal_sidebar.js after fp_rfq_form.js.
Version bumps 19.0.3.7.0 -> 19.0.4.0.0 (sidebar is a chrome change,
minor bump).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:50:30 -04:00
gsinghpal
a99f9aa5ee feat(portal): _fp_sidebar_items helper + layout-values inject
Drives the sidebar from a single Python data structure
(_FP_SIDEBAR_LAYOUT). Active state resolved by page_name lookup OR
URL-prefix match (so Odoo default pages like /my/orders and
/my/account light up correctly). _prepare_portal_layout_values
extends super() so existing counter injection (fp_quote_request_count
etc.) keeps firing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:46:23 -04:00
gsinghpal
ca60500c07 fix(portal): guard sidebar item dict access with .get() fallbacks
Direct entry['url'] / entry['label'] would 500 the portal page if a
future helper emits an item dict missing a key. Use .get('url', '#')
and .get('label', '') so a malformed entry degrades silently instead
of taking the page down. Helper data is currently trusted (defined
in _FP_SIDEBAR_LAYOUT class constant) but defensive iteration is
cheap and prevents regression bugs from cascading.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:44:41 -04:00
gsinghpal
d17cadabf0 feat(portal): sidebar shell template + portal.portal_layout inherit
fp_portal_shell wraps every /my/* page (FP custom + Odoo default)
in a sticky-sidebar shell with no per-template edits. Sidebar markup
is a separate fp_portal_sidebar template that reads fp_sidebar_items
+ fp_partner_display_name from the page context.

Approach D ($0 re-emit) used instead of plan's unbalanced-xpath approach:
position="replace" on //div[@id='wrap'] with $0 inside <main> causes
Odoo's Python inheritance engine to re-emit the original #wrap node
(verified in tools/template_inheritance.py lines 162-169). Every
xpath block is well-formed XML.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:39:17 -04:00
gsinghpal
df74d702af fix(portal): close sidebar drawer on resize past desktop breakpoint
Backdrop display:block isn't media-scoped in fp_portal_sidebar.scss
(intentional — JS owns the drawer lifecycle). Without a resize
listener, opening the drawer at <=768px and resizing the browser
to >768px leaves the semi-opaque backdrop visible on desktop while
the sidebar visually snaps back to its sticky rail. Resize handler
calls toggleOpen(false) when crossing the breakpoint with .o_fp_open
still set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:32:40 -04:00
gsinghpal
ada22a583f feat(portal): mobile sidebar hamburger toggle (vanilla JS)
20 lines, no framework. Toggles .o_fp_open on sidebar + backdrop.
Backdrop click closes drawer; navigating a sidebar link on mobile
auto-closes. No-ops gracefully when sidebar isn't on the page
(logged-out, 500 pages, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:30:10 -04:00
gsinghpal
009562913c feat(portal): sidebar shell SCSS — sticky 240px rail + mobile drawer
Grouped sections via .o_fp_sidebar_section_label, active item gets
mint gradient fill + brand-teal left bar. Below 768px the sidebar
collapses to a fixed slide-in drawer (.o_fp_open class), with
.o_fp_portal_hamburger button + .o_fp_portal_backdrop as siblings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:25:44 -04:00
gsinghpal
0593b70354 docs(portal): session handoff + sub-A IA spec + plan
Captures everything the next Claude session needs to pick up cold:
  - Live module versions on entech (portal 19.0.3.7.0, jobs/reports
    versions, all 5 tests green)
  - What shipped this session (24+ commits, summarised by area)
  - Sub-A (IA + sidebar) brainstorm decisions locked, spec written,
    plan ready to execute (11 tasks, 4 phases)
  - What's deferred (sub-B multi-user, sub-C search, drafts, real
    statements, RMA portal, top-recurring-parts) and WHY — so next
    session doesn't re-litigate
  - Gotchas hit + fixed this session that aren't obvious from code
  - Deploy recipe (file copy + module upgrade + cache bust) used 20+
    times this session

CLAUDE.md's Recent Session Handoff section now points to the new
handoff doc; the previous handoff is kept as 'superseded but kept
for context' below it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:21:21 -04:00
gsinghpal
26fe41e7d4 fix(portal): sudo portal job queries so template traversal works for customers
Portal users have read access to fp.portal.job but NOT to fp.job.
The new job-card macro traverses job.x_fc_job_id -> fp.job to surface
part info, sale_order, ship-to address — that raised AccessError for
real customers (admins were fine due to inherited groups).

Adding .sudo() to the three Job queries in home(), portal_my_jobs(),
and the certifications panel mirror lookup. Domain still filters to
the customer's commercial partner tree, so sudo doesn't widen
visibility — it just lets the template walk past the portal-job
boundary to the privileged backend models.

Same pattern is already used in the same file for sale.order,
account.move, and stock.picking queries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:39:26 -04:00
gsinghpal
2802fcf738 feat(portal): fix configurator 500, hide manual measurements, upgrade job card
1. Configurator step 2/3 500 fix: fp.coating.config was retired
   (Sub-11) but the controller still queried it -> KeyError. Swapped
   to fusion.plating.process.type (the real coating taxonomy on entech:
   Hard Chrome, EN Low Phos, Type I Anodize, etc). Step 2 template
   dropped dead refs (coat.process_type_id / spec_reference / thickness_*
   / certification_level), now shows code + process_family + description.
   Pricing helper relaxed: filters out rules keyed to the dead model
   and silently returns {'available': False} -> template shows 'Quote
   will be priced by EN Plating' instead of fake numbers.

2. Configurator step 1: manual measurements hidden per customer
   feedback. Length/Width/Height/Surface Area are kept as hidden 0s so
   the rest of the flow doesn't error; backend trimesh still auto-calcs
   surface area silently when STL is uploaded. Single file input split
   into two: separate Drawing (PDF) + 3D Model (STL/STP/STEP/IGES)
   uploads so customer can send both. Multi-upload session shape:
   attachment_ids list. Submit handler re-keys ALL uploads onto the
   new quote_request.

3. Job card upgraded: new fp_portal_job_card macro shared by dashboard
   + jobs list. Renders wrap div containing main anchor (whole card
   clickable -> detail page) + sibling actions footer (4 doc download
   quick-buttons: SO / WO / CoC / Packing + Repeat Order form).
   Forms-inside-anchor is invalid HTML so the footer lives as a
   sibling, not a child. Card now shows part name+number and ship-to
   address pulled inline from job.x_fc_job_id.sale_order_id chain.
   Same data also added to detail-page hero for consistency.

Version bump: 19.0.3.6.0 -> 19.0.3.7.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:34:06 -04:00
gsinghpal
153b980e2b fix(portal): correct group indices after adding work_order to docs panel
Regression I introduced when adding the WO Detail group: the
groups.insert(2, wo_group) ran BEFORE the SPECIFICATIONS / QUALITY /
SHIPPING appends, so groups[2] shifted from 'quality' to 'work_order'
mid-helper. Result: the CoC got appended to the work_order group's
docs and shipping doc went into quality. Test caught it.

Restructured to declare the 5-group list up front in display order
and use stable indices throughout (0=from_you, 1=specs, 2=work_order,
3=quality, 4=shipping). Added a code comment warning future editors
that reordering means updating every groups[N] reference.

Test updated to expect 5 groups, asserting both 'work_order' and
'quality' keys are present + pending state in each.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:12:20 -04:00
gsinghpal
6cad69cb86 feat(portal): customer PO/uploads + WO Detail PDF + hover-underline fix
1. From-You group now surfaces ANY ir.attachment attached to the
   linked sale.order (sudo'd) so customer-uploaded PO + drawings
   appear automatically. Each shows file name + upload date + size,
   downloads via /web/content/<id>?download=true. Falls through to
   the Sales Order Confirmation entry as before.

2. New 'Work Order' document group between Specifications and Quality,
   surfacing the EN Plating WO Detail PDF via new route
   /my/jobs/<id>/wo_detail. Sudo'd render of report_fp_job_wo_detail_
   template so the template can read backend fp.job + recipe nodes.
   Placeholder rendered when there's no linked backend job yet.

3. Hover underline gone: Bootstrap Reboot puts
   text-decoration: underline on a:hover for every anchor, which read
   as buggy on our flat chips / pill buttons / dashboard cards. Added
   a catch-all selector list in fp_portal_buttons.scss that pins
   text-decoration: none across hover/focus/active for every brand
   element. Hover signal lives in color + shadow only.

Version bump: 19.0.3.5.0 -> 19.0.3.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:06:41 -04:00
gsinghpal
27badff570 fix(portal): align stepper labels with circles via per-unit absolute positioning
Original macro put the 5 labels in a separate flex container below the
stepper with flex:1 each. That distributes them at 10%/30%/50%/70%/90%
(centred in 1/5 slots) while the circles distribute at 0%/25%/50%/75%/
100% (edges via space-between + line-flex). Result: labels visibly off
from their circles, getting worse the wider the row.

Restructured the macro so each circle + its label live inside a single
.o_fp_step_unit. The label is absolute-positioned at top:100% / left:50%
with translateX(-50%), so its horizontal centre always pins to the
circle's centre regardless of text width. Wider labels ('Inspected')
overflow equally to both sides instead of pushing the column.

Bumped stepper margin-bottom to 2.4rem so the absolutely-positioned
labels have clearance below. Dropped the now-unused .o_fp_step_labels
container rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 04:10:28 -04:00
gsinghpal
a63fbe1558 fix(portal): restore .o_fp_step_line nesting inside .o_fp_stepper
Regression from the pulse-animation commit: the @media (prefers-
reduced-motion) block had crept up and swallowed the .o_fp_step_line
rule, so the connector lines only got flex:1 when the user had
reduce-motion enabled. Everywhere else they had zero width and the
circles clustered on the left of the row with no visible gaps.

Moved .o_fp_step_line back inside the parent .o_fp_stepper { } where
it belongs. Added a comment so the next person doesn't make the same
mistake when editing the surrounding rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 04:00:47 -04:00
gsinghpal
49013c64fb feat(portal): pulse animation, repeat-order button, 5-panel dashboard
1. Pulse animation on the active step indicator:
   - New @keyframes fp-pulse-teal / fp-pulse-amber in stepper.scss
   - Applied to .o_fp_step_active / _warn and .o_fp_timeline_active
     .o_fp_timeline_dot so dashboard stepper + detail-page timeline
     breathe in sync. 1.8s ease-in-out, ring grows 4px -> 9px and
     fades 20% -> 6% opacity. Two color variants so QC (warn) keeps
     its amber meaning.
   - prefers-reduced-motion: reduce kills the animation for users
     who opted out.

2. Repeat Order button on /my/jobs/<id> detail page:
   - New POST /my/jobs/<id>/repeat route that creates a draft
     fusion.plating.quote.request seeded with the user's contact +
     the job's quantity, posts a chatter link back to the original
     job, redirects to the new RFQ for review/submit.
   - Button placed in the detail footer next to 'Back to all jobs',
     CSRF-protected via the form's csrf_token hidden field.

3. Dashboard expanded from 3 secondary panels to 5 (Recent Quote
   Requests + Recent Purchase Orders added) so every previously-
   designed customer page is reachable from /my/home.
   - Auto-fit grid: 3+2 / 2+2+1 / single column depending on width.
   - Every panel header gets a 'View all ->' link to its list page
     (Quote Requests / POs / Certs / Deliveries / Invoices).
   - Empty-state for Quote Requests gets an inline 'Get a quote ->'
     CTA so first-time customers know where to start.

Version bump: 19.0.3.4.0 -> 19.0.3.5.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 03:56:53 -04:00
gsinghpal
ba6f39375a fix(portal): full timestamp format + interpolated middle stages
Two changes to _fp_get_stage_timeline:

1. Format: 'May 16, 2026 \xb7 9:14 AM' (full year + space + uppercase
   AM/PM) instead of 'may 16 \xb7 9:14a'. Matches the mockup the
   user approved. Date-only render kicks in when the timestamp has
   no time component (backfilled/interpolated midnight values), so
   we don't show fake '12:00 AM' next to a date we only know to the
   day.

2. Linear interpolation: records that pre-date Task 16's per-stage
   Datetime hook had empty middle-stage timestamps. The new fallback
   spreads done stages evenly between received_at (or received_date)
   and now() so old records show a plausible progression instead of
   gap-toothed empty rows. Records created post-hook hit the real
   captured values and never reach the interpolation branch.

Helper imports datetime + time at module level since we need
datetime.combine for Date->Datetime conversion in the fallback chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 03:49:54 -04:00
gsinghpal
cbed74e5eb fix(portal): fallback to existing Date fields when stage Datetime is null
Records created before Task 16 (per-stage Datetime fields + write
snapshot hook) have NULL for received_at/shipped_at/etc. SQL backfill
copies received_date -> received_at; this commit adds a runtime
fallback so if any record slips through (manual edits, future
imports) the timeline still surfaces what's available.

Also render date-only ('May 16, 2026') when the timestamp has no
time component, so backfilled-from-Date records don't show the
misleading 'may 16 · 12:00a' fake time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 03:43:59 -04:00
gsinghpal
2730c455f5 fix(reports): remove Customer Acceptance/Authorized Representative signature block from FP sale report
The signature footer ('Customer Acceptance (Signature / Date)' +
'Authorized Representative') is not part of EN Plating's intended
customer-facing quote/SO PDF flow. Removed from both portrait and
landscape variants of report_fp_sale_portrait/landscape.

Invoice report (report_fp_invoice.xml) had no such block - nothing
to remove there. Verified by grep across fusion_plating_reports.

Version bump: fusion_plating_reports 19.0.11.14.0 -> 19.0.11.15.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 03:36:54 -04:00
gsinghpal
669ba0fd8a fix(portal): dedicated /my/jobs/<id>/so_confirmation route with sudo render
The FP sale report template (report_fp_sale_portrait) walks into
fp.part.catalog records, which portal users don't have ACL on -
they'd hit 'You are not allowed to access Fusion Plating - Part
Catalog' when rendering. Standard /report/pdf/ route runs as the
authed user, so the template traversal fails.

Mirror the portal_download_coc pattern: gate on _document_check_access
for the portal job (customer can only ever reach their own data),
then render the report via ir.actions.report.sudo()._render_qweb_pdf
so the QWeb template traversal bypasses ACL. Return the PDF as an
attachment with a friendly filename.

Updates _fp_group_documents to point the From-You SO Confirmation
link at this new route instead of /report/pdf/ directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 03:31:25 -04:00
gsinghpal
8e172132e7 fix(portal): use FP custom sale report for SO Confirmation download
Standard sale.report_saleorder hit the sale_pdf_quote_builder
header/footer merge bug (CLAUDE.md MEMORY.md gotcha) and produced
garbled PDFs on FP-customised sale orders. Switching to
fusion_plating_reports.report_fp_sale_portrait which is the
customer-facing FP template and bypasses the merge gate. Added
?download=true so the browser saves the PDF instead of trying to
embed it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 03:24:56 -04:00
gsinghpal
d3c5c25865 changes
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-17 03:20:33 -04:00
gsinghpal
f8586611c9 fix(portal): derive portal_job initial state from fp.job.state on create
_fp_create_portal_job hardcoded state='in_progress'. Now uses the
same _FP_JOB_STATE_TO_PORTAL_STATE map as write(), so a portal job
created for an already-confirmed (but not yet started) fp.job lands
in 'received' instead of jumping to 'in_progress'. Falls back to
'received' for unmapped states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 03:16:22 -04:00
gsinghpal
28220f0732 fix(portal): 5 hotfixes - /my route, button sizing, clickable cards, state sync, SO doc
1. /my now serves the FP dashboard (stock Odoo home was leaking
   through because parent route declared ['/my', '/my/home'] but my
   override only listed /my/home).
2. Button padding bumped to .5rem 1rem + font 1rem so o_fp_btn matches
   Odoo's standard Bootstrap button rhythm. Ghost button drops its
   custom padding override.
3. .o_fp_job_card on /my/home + /my/jobs is now an <a> wrapping the
   whole card area — full row is the click target, not just the WO
   number. Inner <a> on job.name dropped to avoid nested anchors;
   focus-visible outline added for keyboard nav.
4. fp.job.write() now mirrors state -> fp.portal.job.state via new
   _FP_JOB_STATE_TO_PORTAL_STATE map (confirmed->received,
   in_progress->in_progress, done->ready_to_ship). Fixes the bug where
   completed backend jobs left the portal stuck on 'in_progress'.
   'on_hold' and 'cancelled' intentionally not mirrored — manager
   choice what to surface.
5. Sales Order Confirmation now surfaces in the 'From You' group on
   the job detail page, pulled via job.x_fc_job_id.sale_order_id ->
   /report/pdf/sale.report_saleorder/<id>. Falls back to the upload
   placeholder when no SO is linked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 03:13:00 -04:00
gsinghpal
edcc325483 chore(portal): bump 19.0.3.3.0 - Phase 4 cosmetic sweep
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:57:40 -04:00
gsinghpal
37f1f7e8a3 refactor(portal): trim legacy catch-all SCSS, deduplicate vs new partials
Removed (now superseded by Phase 1-3 partials):
- .o_fp_dashboard .o_fp_dashboard_card (-> .o_fp_job_card + .o_fp_panel)
- .o_fp_seg_progress (-> .o_fp_stepper)
- .o_fp_portal_status_dot (-> .o_fp_badge_dot)
- .o_fp_portal_progress (-> .o_fp_timeline)
- .o_fp_jobs_list (dashboard wraps in .o_fp_dashboard instead)
- fp-portal-tint mixin (unused after refactor)

Kept (still referenced by untouched templates):
- .o_fp_portal_card (empty-state cards + configurator coating cards)
- .o_fp_part_row + .o_fp_file_drop_zone (RFQ wizard JS-driven elements)
- .o_fp_portal_form (configurator forms)
- .nav-tabs (quote-request filter tabs)

File goes from 304 to 124 lines (-59%).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:57:29 -04:00
gsinghpal
0f10c490cd style(portal): tokenise configurator buttons with new system
All btn-primary -> o_fp_btn_primary, btn-outline-secondary ->
o_fp_btn_secondary, large CTAs get o_fp_btn_lg modifier. Status
badges (text-bg-secondary/warning/info) left untouched - they're
auto-calculated chips not workflow states.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:56:18 -04:00
gsinghpal
e166fae57b style(portal): tokenise quote/RFQ/delivery/cert templates with new system
Swap legacy Bootstrap classes for the new o_fp_* token system:
- Quote-list 'New Quote Request' CTA: btn-primary -> o_fp_btn_primary
- Quote list+detail state badges: complex conditional -> macro call
- RFQ form Cancel/Submit: btn-link/primary -> o_fp_btn_ghost/primary
- RFQ 'Add Part' button: btn-outline-secondary -> o_fp_btn_secondary
- Process-type chips (cert+detail): badge text-bg-light -> o_fp_doc_chip
- 'Delivered' badge in deliveries list: o_fp_badge o_fp_badge_shipped
- CoC download button on certs: btn-outline-success -> o_fp_btn_secondary

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:55:25 -04:00
gsinghpal
488243cd75 chore(portal): bump 19.0.3.2.0 + register timeline SCSS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:51:49 -04:00
gsinghpal
6cf826268b feat(portal): rewrite /my/jobs/<id> detail page with timeline + doc panel
Two-column grid: vertical timeline (5 stages with per-stage timestamps)
on the left, grouped document panel (4 categories) on the right. Hero
header carries WO ref + part / qty / ETA / tracking facts.

Controller adds stage_timeline, doc_groups, and timeline_spine_pct
to the render context. Spine fill = done + half-credit for the
active stage (so the spine visually leads the eye to where the work
is happening).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:51:33 -04:00
gsinghpal
c8deef1482 feat(portal): rewrite /my/jobs list with V2 stepper cards
Drops the old 3-segment progress bar in favour of the dashboard's
5-step circle-and-line stepper for consistency. Uses the same
state_to_idx mapping so all 6 fp.portal.job states (including
'complete') render correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:50:30 -04:00
gsinghpal
55ac05667c feat(portal): vertical timeline + detail-page wrapper SCSS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:49:50 -04:00
gsinghpal
4da123c2d3 feat(portal): _fp_group_documents helper for detail-page doc panel
V1 surfaces only the fields directly on fp.portal.job (CoC + packing
list). Other 2 groups (From You, Specifications) render placeholder
rows. V2 will wire in sale.order linking for full doc surfacing.

Also adds _fp_size_label helper for friendly file-size strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:49:18 -04:00
gsinghpal
8c6718e352 feat(portal): _fp_get_stage_timeline helper for detail-page timeline
Builds a 5-entry list (label, status, started_at, time_label, notes)
ordered by stage. Labels match the dashboard stepper exactly
(Received/Inspected/Plating/QC/Shipped) so the two surfaces tell
the same story. Inspected and Plating share in_progress_started_at
since state in_progress means both transitions happened.

Time labels use lowercase am/pm matching the mockup typography.
'complete' state correctly shows all 5 stages as done (caught by
new test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:48:42 -04:00
gsinghpal
9d58f5f61e feat(portal): per-stage timestamps on fp.portal.job
Adds received_at, in_progress_started_at, qc_started_at,
ready_to_ship_at, shipped_at - snapshotted on state change via
write() override using super().write() to avoid recursion. Required
for the vertical-timeline rendering on the job detail page (Phase 3).

Idempotent: re-transitioning to a state already-stamped does not
overwrite the original timestamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:47:08 -04:00
gsinghpal
06df9745a0 chore(portal): bump 19.0.3.1.0 + register Phase 2 SCSS/data
Adds 4 Phase 2 SCSS partials (badges/cards/stepper/dashboard) plus
the macros XML data file. Macros load before any template that
t-calls them per Odoo's strict-sequential XML loader.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:43:46 -04:00
gsinghpal
3aa11eaffc feat(portal): rewrite /my/home as jobs-forward dashboard
Welcome strip -> 4-tile KPI row (In-Flight Jobs is the hero) ->
Active Work Orders section with 3 most-recent V2 cards ->
3-panel secondary strip (Certs / Packing Slips / Invoices).
Uses the new badge/stepper/doc-chip macros.

Also fixes a stepper state->step mapping bug that would have
shown Inspected as active when state=in_progress (should be
Plating active). New state_to_idx dict handles all 6 fp.portal.job
states correctly, including 'complete' (all 5 stages done).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:43:24 -04:00
gsinghpal
c2590a99ff feat(portal): welcome-line summary counts on /my/home + tests
Adds active_job_count, awaiting_review_count, ready_to_ship_count
to the dashboard context. Tests verify partition is correct across
the fp.portal.job and fp.quote.request state machines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:41:17 -04:00
gsinghpal
215e393bdb feat(portal): shared QWeb macros (badge, stepper, doc chip, doc group)
Macros take dict args so callers never reach into the underlying
records — keeps templates testable + makes the stepper reusable
on dashboard cards AND detail-page if needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:40:31 -04:00
gsinghpal
1780b383b9 feat(portal): jobs-forward dashboard layout SCSS
Welcome strip + 4-tile KPI row + jobs hero + secondary 3-panel strip.
Responsive at 768px (KPI grid -> 2x2, secondary -> stacked).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:40:04 -04:00
gsinghpal
a6ff3054bc feat(portal): numbered horizontal stepper with state classes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:40:04 -04:00
gsinghpal
b3a86cd4b9 feat(portal): card shells, KPI tiles, doc chips + rows
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:40:03 -04:00
gsinghpal
23ac3284cb feat(portal): status badge pills with dot + glow halo
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:40:03 -04:00
gsinghpal
83c2b42aad chore(portal): bump 19.0.3.0.0 + register Phase 1 SCSS
Tokens partial loaded first; buttons SCSS loaded next; legacy
catch-all stays last. Per CLAUDE.md rule 8 every SCSS file is a
separate entry (no @import allowed in Odoo 19 custom SCSS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:37:25 -04:00
gsinghpal
22e217a16c feat(portal): gradient button system (primary/secondary/ghost/danger/mint)
Five button variants under .o_fp_btn_* classes that don't fight
Bootstrap. Primary uses the brand teal gradient with mint-tinted
shadow; danger uses the red gradient. Focus/hover/active states
included.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:37:02 -04:00
gsinghpal
3310b12754 feat(portal): add brand design tokens partial
EN Plating teal palette + gradient/shadow/radius/spacing/typography
tokens. Single source of truth for the customer portal redesign.
Tokens load first in web.assets_frontend so downstream SCSS sees them.

Refs spec: docs/superpowers/specs/2026-05-17-portal-dashboard-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:36:42 -04:00
gsinghpal
eac337c058 docs(portal): add dashboard redesign spec + implementation plan
Spec covers the brainstormed design: jobs-forward layout, V2 stepper
with timestamps, EN Plating teal/gradient palette, 4 doc categories.
Plan decomposes implementation into 4 independently-deployable phases
(tokens+buttons -> dashboard -> jobs detail -> cosmetic sweep) with
27 tasks total.

Also adds .gitignore so .superpowers/ brainstorm artifacts stay
untracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:36:02 -04:00
gsinghpal
655b767127 fix(portal): override stock /my/home with FP rich dashboard
The custom dashboard at fusion_plating_portal was rendering a 6-card
view at /my/home, but a method-name mismatch left the parent
portal.CustomerPortal.home() route active instead. Rename the
override to home() so Python MRO does the override naturally, and
add CLAUDE.md Critical Rule 16 documenting the gotcha so future
controller-override work doesn't trip on it.

Version bump: 19.0.2.2.0 -> 19.0.2.3.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:35:52 -04:00
gsinghpal
9ebf89bde2 changes 2026-05-16 13:18:52 -04:00
gsinghpal
191a9c82be changes 2026-05-16 13:07:50 -04:00
gsinghpal
00981a502a feat(acr-wedge+kiosk): SSE bridge for ACR122U / PC-SC readers
macOS keystroke injection from a CLI-launched Python hits multiple
TCC permission walls (Accessibility AND Automation, both attaching
to identities macOS often can't resolve cleanly). After bouncing
through Quartz, AppleScript, and pyautogui fallbacks, none of them
worked reliably in our test environment.

Switch to a proper IPC channel instead of pretending to be a
keyboard.

Daemon (wedge.py):
  - Adds a ThreadingHTTPServer on 127.0.0.1:8765 exposing /events
  - SSE stream pushes each detected UID as one event
  - 30s keep-alive comments to keep idle connections open
  - CORS: Access-Control-Allow-Origin: * (kiosk page may be on any
    client-domain HTTPS origin; SSE source is always localhost)
  - Keystroke injection kept as best-effort fallback for non-SSE
    clients

Kiosk JS (fusion_clock_nfc_kiosk.js):
  - Adds startWedgeSseListener() that opens EventSource to
    http://localhost:8765/events on setup
  - On message: same handleTap()/_onEnrollTap() flow as Web NFC + HID
  - EventSource auto-reconnects; first error is logged then silenced
  - http://localhost is a "potentially trustworthy origin" so this
    works from https:// pages without mixed-content blocking

Result: ACR122U + wedge.py daemon now drives the kiosk with zero
macOS permission prompts and no focused-window dependency. Same
input plumbing as Web NFC and HID — penalty/photo/activity log
fire identically.

Bump fusion_clock to 19.0.3.3.0.
2026-05-15 20:10:40 -04:00
gsinghpal
d75198be9f fix(acr-wedge): use AppleScript on macOS for keystroke injection
pyautogui's Quartz-based keystroke path often fails on newer macOS
because the Python CLI binary doesn't auto-surface in System Settings
> Accessibility. User reported the daemon detected taps fine but
keystrokes never landed in any window.

Switch to AppleScript / System Events on macOS. Permission attaches
to whatever terminal/app launched the Python process (Terminal.app,
iTerm, etc.) — a familiar named app the user can grant Accessibility
to in one click. Combined keystroke + Return in a single osascript
call to keep latency ~100ms per tap.

Fall back to pyautogui if osascript fails (handles edge cases) and
on non-macOS platforms.
2026-05-15 19:56:49 -04:00
gsinghpal
d009a1ef50 feat(acr-wedge): ACR122U PC/SC -> keyboard wedge daemon
ACR122U is a 13.56 MHz PC/SC (CCID) reader, not HID. Browsers can't
talk to PC/SC devices directly, so the kiosk JS can't see ACR122U
taps the way it sees a USB-HID reader.

This daemon bridges the gap:
  - Polls the ACR122U via pyscard
  - Reads UID via the standard ACS GET_UID APDU (FF CA 00 00 00)
  - Types UID + Enter into the focused window using pyautogui
  - Debounces re-reads of the same card (2s window)

Output format matches FusionClockNfcKiosk._normalize_uid() expectations:
colon-separated uppercase hex (04:10:5B:CA:FD:22:90 + Enter).

The kiosk JS already has a keyboard-wedge listener (v19.0.3.2.0+),
so no server-side or kiosk-side changes needed — wedge.py's
keystrokes route through the same handleTap() path as a USB-HID
reader, preserving photo verification + penalty + activity log.

Setup docs include macOS, Windows, Linux instructions plus
launchd/Task Scheduler/systemd snippets for running as a service.

Strategic value: with this, ACR122U deployments support UA-Pockets
(13.56 MHz DESFire EV3) for single-card door+clock setups in the
premium tier of the standard product kit. The 125 kHz EM4100 USB-C
HID reader remains the default tier.
2026-05-15 19:45:53 -04:00
gsinghpal
9001b6fc51 feat(fusion_clock): USB HID reader support + desktop-tolerant kiosk setup
The NFC kiosk previously required Web NFC, which is Android-Chrome-only.
This blocked desktop testing and locked us to a single hardware path.

Add a keyboard-wedge listener that captures keystrokes from USB HID NFC
readers (the standard Sycreader/Yanzeo class). The listener buffers hex
chars + separators, flushes on Enter (or 600ms idle as fallback for
readers without a terminator), and routes the UID through the same
handleTap()/_onEnrollTap() codepath as Web NFC. Photo verification,
penalty calc, and activity logging all fire identically.

Make the setup button tolerant: try Web NFC, but treat its absence as
non-fatal. USB HID always activates. Only hard-fail when photoRequired
is True AND the camera is unavailable.

Result: same kiosk page now works on Android Chrome (Web NFC), desktop
Chrome with a USB reader, or both at once.

Bump manifest to 19.0.3.2.0.
2026-05-15 19:30:51 -04:00
gsinghpal
a24ef15a02 fix(fusion_clock): add ir.model.access for NFC enrollment wizard
Wizard was deployed without an entry in security/ir.model.access.csv,
so ANY user (including managers) got a permission error when opening
the menu. The model is registered but has no group access rules,
so Odoo's ORM blocks read/create on it.

Grant full CRUD on fusion.clock.nfc.enrollment.wizard to
group_fusion_clock_manager (the same group the menu is gated to).

Bump manifest to 19.0.3.1.1.
2026-05-15 19:15:56 -04:00
gsinghpal
7fdab094fc fix(fusion_clock): load wizard XML before clock_menus.xml
The Enroll NFC Card menu item references action_fusion_clock_nfc_enrollment_wizard,
which is defined in wizard/clock_nfc_enrollment_views.xml. With the wizard file
listed AFTER clock_menus.xml in the manifest, the menu load failed with
"External ID not found in the system" on first upgrade.

Move the wizard view above clock_menus.xml so the action XMLID exists by the
time the menu references it.

Verified on odoo-entech: fusion_clock upgraded cleanly to 19.0.3.1.0, all
wizard XMLIDs registered.
2026-05-15 19:09:26 -04:00
gsinghpal
c2646f59c4 feat(fusion_clock): NFC card enrollment wizard + employee form field
Adds a tap-driven enrollment workflow so managers can pair NFC/RFID
cards to employees using a USB HID reader at their desk:

- New wizard model fusion.clock.nfc.enrollment.wizard with auto-focused
  Card UID field, employee picker, and reassignment warning if the
  card is already held by someone else.
- Two actions: 'Enroll Card' (single) and 'Enroll & Next' (bulk).
- Menu entry under Fusion Clock root, manager-gated.
- Exposes x_fclk_nfc_card_uid on the Employee form Clock Settings
  section (next to Kiosk PIN) so it can be inspected/edited directly.
- Bumps manifest to 19.0.3.1.0 for asset cache bust.

Wizard reuses FusionClockNfcKiosk._normalize_uid so stored format
matches what the kiosk /tap endpoint looks up later. Reassignment
clears the UID from the previous holder and logs both events to the
activity log under 'card_enrollment'.
2026-05-15 18:55:42 -04:00
gsinghpal
152ed86c3a feat(thickness): single Char range field — drop fp.recipe.thickness picker
Per client direction: every order is a thickness RANGE (e.g.
"0.0005-0.0008 mils" or "5-10 mils"), never a single value. The
old picker model (fp.recipe.thickness with a single 'value' Float)
was modelling the wrong concept and overcrowding the order entry
UI. Replaced with one free-text Char field that auto-fills from
last-used or part default.

DELETED entirely:
- fp.recipe.thickness model (file + view + ACL + manifest entry)
- recipe.thickness_option_ids One2many (the picker source)
- "Thickness Options" inline list on the recipe form
- sale.order.line.x_fc_thickness_id (M2O picker)
- account.move.line.x_fc_thickness_id
- fp.delivery.x_fc_thickness_id
- fp.direct.order.line.thickness_id

ADDED:
- sale.order.line.x_fc_thickness_range (Char) — operator types range
- account.move.line.x_fc_thickness_range — for invoice rendering
- fp.delivery.x_fc_thickness_range — for packing slip
- fp.direct.order.line.thickness_range — for the wizard
- fp.part.catalog.x_fc_default_thickness_range — part default

AUTO-FILL CHAIN (sale.order.line + wizard line):
1. Operator already typed → keep
2. Most recent SO line for (this part, this customer) with a
   non-empty thickness_range → copy that
3. part.x_fc_default_thickness_range → copy
4. Blank — operator types

Implemented as both an @api.onchange (interactive) AND a
create() override (programmatic — wizard, sale_mrp bridge,
imports). Same logic in both paths.

WIZARD push-to-defaults: when "Save as Default" toggle is ticked
on a wizard line, persist the line's thickness_range to
part.x_fc_default_thickness_range so future first-customer orders
get a sensible starting point.

REPORTS: customer_line_header.xml + report_fp_wo_sticker.xml now
print the Char range as-typed (no display_name lookup needed).

KEPT (admin documentation only — doesn't affect order entry):
- recipe.thickness_min, thickness_max, thickness_uom on the recipe
  root: documents the recipe's CAPABILITY range. No UI gate; just
  for spec authors to record what the chemistry can produce.

JOB GROUPING: fp.job auto-create groups SO lines by (recipe, part,
spec, thickness, serial). Updated to key on the thickness_range
Char (stripped) instead of the deleted thickness_id integer.

DB cleanup: --update=base ran on the upgrade, dropping the
fp_recipe_thickness table + the four x_fc_thickness_id columns.
Existing data was already nulled in earlier dev work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:54:40 -04:00
gsinghpal
21754c1660 fix(specs): @api.depends on _compute_display_name — fixes 'Unnamed' dropdown
The _compute_display_name method on fusion.plating.customer.spec was
missing its @api.depends decorator. Without it, Odoo doesn't know
when to fire the compute, so display_name stayed NULL on:
- All seeded specs (created via XML data import)
- Any spec created later (the field was never recomputed)

Symptom: Specification dropdown on the SO line showed "Unnamed" for
every option, making spec selection useless.

Fix:
- @api.depends('code', 'revision', 'name') on _compute_display_name
- Imported `api` (was only `fields, models`)

Companion entech-side action: forced recompute on the 15 existing
specs via `env.add_to_compute(specs._fields['display_name'], specs)`
so the stored column was backfilled. New specs created via UI will
trigger the compute automatically going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:36:00 -04:00
gsinghpal
145b424760 fix(seeds): noupdate=1 on remaining 3 user-editable seed files
Audit of all 86 data XML files in the fusion_plating module set
turned up 3 more files that lacked noupdate=1 protection — every
module upgrade would re-import them and silently overwrite user
customisations. Following the ENP-ALUM-BASIC recovery (a68bf2e),
locked these too:

1. fusion_tasks/data/ir_cron_data.xml — 4 ir.cron records
   (technician travel times, push notifications, late-arrival
   checks, location cleanup). Users may disable / re-schedule.

2. fusion_plating_shopfloor/data/fp_cron_data.xml — 1 ir.cron
   (Bake Window state updater). Same reasoning.

3. fusion_plating_bridge_maintenance/data/fp_maintenance_stage_data.xml
   — 3 maintenance.stage records (kanban columns: New / Active /
   Completed). Admin may rename, reorder, or add new stages.

Companion entech-side action (executed via SQL during the fix
session): 11 ir.model.data rows for these records were updated to
noupdate=true so the next module upgrade respects the new flag.

Files left explicitly noupdate=0 — verified safe:
- fusion_plating/data/fp_landing_data.xml — 1 ir.actions.server
  (system action, code-defined; re-import is harmless)
- fusion_plating_reports/data/fp_hide_default_reports.xml —
  re-asserts deletion of default Odoo report bindings; intentional
  to re-run on every upgrade

Final audit confirmed 0 user-editable noupdate=false records remain.
ir.model.inherit + report.paperformat rows still noupdate=false but
those are system metadata (Odoo manages) and Odoo's standard
paperformat pattern, both safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:32:30 -04:00
gsinghpal
a68bf2eae7 fix(recipes): noupdate=1 on 5 seeded recipes — STOP wiping user edits
CRITICAL BUG: 5 of 6 seeded recipe files had <data noupdate="0">
which caused EVERY module upgrade to re-import the recipe and
overwrite any user customisations to the base recipe (renamed
steps, added child nodes, custom prompts on seeded steps).

Files fixed (now noupdate="1"):
- fp_recipe_enp_alum_basic.xml
- fp_recipe_enp_steel_basic.xml
- fp_recipe_enp_sp.xml
- fp_recipe_anodize.xml
- fp_recipe_chem_conversion.xml

(fp_recipe_general_processing.xml was already correctly noupdate=1.)

Companion entech-side action (not in this commit, executed via SQL
during the fix session): 200 ir.model.data rows for the affected
process_node + process_node_input records were updated to
noupdate=true so the next module upgrade will skip them entirely
and respect the user's current state.

Recovery for users whose base recipe edits were already lost:
the variants (part-cloned recipes that share the recipe name)
were untouched because they have no XML xmlid match. The
customisations are preserved in the variants and can be lifted
back to the base recipe via the simple/tree editor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:20:04 -04:00
gsinghpal
bc7c771f20 chore(menu): promote Specifications + clarify misleading menu names
Specifications menu (urgent — workflow blocker for estimators):
- Moved from Configuration → Quality & Documents (manager-only) up
  to Plating → Quality (sequence 70). Now visible to estimator,
  supervisor, and manager.
- Renamed "Customer Specs" → "Specifications" — the seeded library
  includes industry standards (AMS, MIL, ASTM, BAC) not just
  customer-private specs.
- Action display name updated: "Customer Specifications" → "Specifications".
- Added action.help HTML so the empty-state placeholder explains
  the Specifications library purpose to first-time users.
- Old xmlid (menu_fp_config_customer_spec) preserved so existing
  links / breadcrumbs / search references continue to resolve.

Other clarifying renames:
- Safety: "JHSC" / "JHSC Meetings" → "H&S Committee (JHSC)" /
  "H&S Committee Meetings" — acronym was opaque to non-Canadian
  H&S folks.
- Operations: "Move Log" → "Parts & Rack Move Log" — generic name
  could be confused with chatter messages or stock moves.
- Configuration → Recipes & Steps: "Workflow States" →
  "Job Workflow Stages" — generic name; clarifies these are job
  state milestones (passed-stage tracking), not generic workflow.
- Compliance → General: child folder "Configuration" → "Reference
  Data" — three levels of "Configuration" nesting (Plating>Config
  vs Plating>Compliance>General>Config) was confusing.

No model / data changes. Pure menu metadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:05:19 -04:00
gsinghpal
1ed414c6fb chore(menu): retire Configurator top-level — fold survivors into Configuration hub
After Phase E removed Coating Config + Treatments + Customer Price List
+ Coating Thickness from the Configurator submenu, only 3 admin items
remained — not enough to justify a top-level menu just for an
estimator.

Re-homed:
- Pricing Rules                → Configuration → Pricing & Billing
                                  (sequence 40, joins Invoice Strategy
                                   Defaults + Account Holds)
- Materials                    → Configuration → Materials & Tanks
                                  (sequence 40, joins Bath Parameters,
                                   Replenishment Rules, Chemicals,
                                   Rack Tags, Calibration Equipment)
- Line Description Templates   → Configuration → Quality & Documents
                                  (sequence 90, joins Notification
                                   Templates — same "templates" pattern)

All three keep estimator visibility (group_fp_estimator) plus manager
access. Top-level menu count under "Plating" drops from 9 visible to 8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:52:53 -04:00
gsinghpal
7d27db69c6 fix(promote-customer-spec): leftover has_cost_data ref in _compute_margin
Phase E removed the coating-rollup loop but left a stale `has_cost_data`
reference in the percent computation. NameError on every SO list /
form load.

Margin is "not available" until recipe-level cost data exists
(backlog item). Set all three margin fields to 0 / False explicitly
so no stale references remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:11:41 -04:00
gsinghpal
d891002c84 feat(promote-customer-spec): Phase E — final removal of coating + treatment
DELETED entirely (model + view + ACL + data file + menu):
- fp.coating.config (configurator)
- fp.treatment (configurator + seeded data)
- fp.coating.thickness (configurator) — replaced by fp.recipe.thickness in Phase A
- fp.customer.price.list (configurator) — coating-keyed, no replacement

Field deletions:
- sale.order.x_fc_coating_config_id
- sale.order.line.x_fc_coating_config_id + x_fc_treatment_ids
- account.move.line.x_fc_coating_config_id
- fp.part.catalog.x_fc_default_coating_config_id + x_fc_default_treatment_ids
- fp.job.coating_config_id
- fp.pricing.rule.coating_config_id
- fp.quality.point.coating_config_ids
- fp.direct.order.line.coating_config_id + treatment_ids
- fp.sale.description.template.coating_config_id

Refactored:
- fp.quote.configurator.coating_config_id → recipe_id (now points at
  fusion.plating.process.node, the actual recipe). All compute, onchange,
  and matcher logic updated to use recipe directly. Quality inherit
  extends matcher with spec-tier scoring.
- fp.job._fp_create_certificates now reads spec from job.customer_spec_id
  and formats spec_reference as "code Rev rev". Same for thickness
  source — bake fields read from recipe_root (Phase A).
- fp.job.step.button_finish bake-window auto-spawn reads bake settings
  from recipe_root instead of coating.
- fp.certificate auto-fill spec_min_mils/max_mils from recipe (Phase A
  thickness fields) instead of coating.
- jobs/sale_order.py: job creation reads x_fc_customer_spec_id from
  line, drops coating refs and the legacy header-coating fallback.
- Wizards drop coating + treatment fields and refs.
- Configurator views drop x_fc_coating_config_id + x_fc_treatment_ids
  fields entirely. Quality inherits re-anchor on stable fields
  (x_fc_part_catalog_id, x_fc_internal_description, default_process_id,
  process_variant_id, substrate_material) so they keep working.
- Reports drop coating fallback elifs; print recipe / spec.
- Tablet payload drops coating_config_id from job.read fields.

Skipped (deferred to backlog):
- fusion_plating_bridge_mrp — module is uninstalled per Sub 11; source
  files retain coating refs but no runtime impact.
- fusion_plating_portal — circular dep (portal → quality → certs →
  portal). Customer-facing portal coating picker stays for now;
  promote-spec polish is a separate sub-project.

Verification: grep for "coating_config_id|fp.coating.config|
fp.treatment|fp.coating.thickness" in live (non-bridge_mrp,
non-portal, non-script, non-test) Python/XML/CSV returns 3 hits,
all in module / class docstrings explaining Phase E history.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 02:00:41 -04:00
gsinghpal
e0eacc2530 feat(promote-customer-spec): Phase D — reports + tablet payload include spec
Reports updated to print Specification (with revision via display_name):
- report_fp_sale.xml — header sections show "SPECIFICATION" instead
  of "COATING CONFIG", reads doc.x_fc_customer_spec_id (added on
  sale.order via quality inherit, computed from line.customer_spec_id)
- report_fp_wo_sticker.xml — propagates _spec alongside _coating
- fusion_plating_reports/report_fp_job_traveller.xml — header row
  now shows Specification (falls back to coating)
- fusion_plating_jobs/report_fp_job_traveller.xml — same fall-back
- fusion_plating_jobs/report_fp_job_sticker.xml — _spec added

sale.order.x_fc_customer_spec_id added as a stored compute on
sale.order (in quality) so reports can render order-level spec.
Mirrors the line's first spec; updates on line edit.

Tablet payload (shopfloor_controller.py):
- spec_label added to the job payload dict
- defensive 'customer_spec_id' in job._fields check (shopfloor doesn't
  depend on quality — circular if added)

Portal: deferred (same circular-dep issue, more substantial UI rewrite
needed; Phase E backlog item).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:30:05 -04:00
gsinghpal
c637f82ae2 feat(promote-customer-spec): Phase C — pricing, quality, job, cert re-keyed
Pricing:
- Quality inherit on fp.pricing.rule adds customer_spec_id + recipe_id
- Quality inherit on fp.quote.configurator adds customer_spec_id field
  + extends _find_matching_rule with priority chain:
    spec (+8) > recipe (+6) > coating (+4) > material (+2) > cert (+1)
- View inherit surfaces both new pickers on the rule form

Quality points:
- fp.quality.point now has customer_spec_ids + recipe_ids M2M filters
- Matcher (_matches + _find_matching) accepts new args
- Hook overrides on SO confirm + job confirm/done + step finish
  pass spec/recipe context through to the matcher
- View surfaces both new M2M widgets

Job:
- jobs/sale_order.py wires x_fc_customer_spec_id from SO line to
  fp.job.customer_spec_id on action_confirm

Cert:
- Quality inherit on fp.certificate adds customer_spec_id field +
  create() override auto-fills spec_reference from spec.code+revision
  Resolution priority: explicit spec_reference > cert.customer_spec_id
  > SO line spec (with print_on_cert) > legacy coating fallback

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:23:06 -04:00
gsinghpal
7cafab1b9f feat(promote-customer-spec): Phase B — two-picker SO line UX
Spec-side picker (x_fc_customer_spec_id / customer_spec_id) added on:
- sale.order.line (via quality inherit — onchange autofill, create()
  fallback to part default, _prepare_invoice_line carry)
- account.move.line (via quality inherit — invoice rendering)
- fp.part.catalog (via quality inherit — x_fc_default_customer_spec_id)
- fp.direct.order.line (via quality inherit — wizard picker + autofill)
- fp.direct.order.wizard (action_create_order post-creates spec on SO line)

Thickness picker switched to fp.recipe.thickness (replaces coating-scoped):
- sale.order.line.x_fc_thickness_id comodel + domain rewired to recipe
- account.move.line + fp.delivery same
- fp.direct.order.line.thickness_id same

View inherits in quality add Specification picker next to legacy
Primary Treatment column on:
- SO form line tree
- part catalog Default Treatments block
- direct-order wizard line tree + drawer

Wizard files (fp.contract.review.client.email.wizard) pulled from
entech into the repo — they were ahead of the repo. Quality __init__
now imports wizards/.

Legacy x_fc_coating_config_id + treatment_ids remain visible during
transition; Phase E removes them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:16:25 -04:00
gsinghpal
c96f27b96c feat(promote-customer-spec): NADCAP recipe lock (Phase A+)
Per client review: NADCAP-qualified recipes need manager-only edit
permission. Word-doc external approval workflow stays outside ERP;
this is the in-app enforcement.

- New field fp.process.node.is_locked (recipe root)
- write() override blocks non-manager edits when recipe root is_locked
  Lock checks via recipe_root_id so child ops/steps are also protected
  Manager bypass via group + env.su (sudo) bypass for system jobs
- Amber "LOCKED — Manager Edit Only" ribbon at top of recipe form
- Toggle on Specification & Bake page under "Change Control (NADCAP)"
- Spec doc updated with Decision 6.5 + backlog from client review:
  approvals list, doc control auto-sync, oven recorder sync, SOP
  word-doc workflow, final-inspection signoff on cert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:55:07 -04:00
gsinghpal
406cac1362 feat(promote-customer-spec): Phase A — recipe + spec foundation
- Add fp.recipe.thickness model (replaces fp.coating.thickness, scoped to recipe root)
- Add spec metadata + bake-relief fields to fusion.plating.process.node (recipe root):
  phosphorus_level, thickness_min/max/uom, thickness_option_ids,
  requires_bake_relief + bake_window_hours/temperature/duration
- Add recipe_ids M2M + print_on_cert to fusion.plating.customer.spec
- Add applicable_spec_ids reverse M2M as inherit in fusion_plating_quality
  (avoids circular dep — core can't reference customer.spec which lives in quality)
- Surface new fields on recipe form ("Specification & Bake" notebook page)
- Surface recipe linkage on customer spec form

Pure additive. Foundation for Phases B-E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:50:17 -04:00
gsinghpal
13fd0712d9 docs(fusion_plating): add Promote Customer Spec design + implementation plan
- Spec: retire fp.coating.config + fp.treatment, promote fusion.plating.customer.spec
- Two-picker SO line UX (Specification + Recipe), aerospace-correct audit posture
- Plan: 5 phases (foundation, SO line, pricing/quality/job/cert, reports/tablet/portal, removal)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:23:22 -04:00
gsinghpal
1414ef2c1c fix(fusion_clock): NFC kiosk header — center logo at top, stack clock+date below 2026-05-14 08:42:26 -04:00
gsinghpal
42e8fe3d21 fix(fusion_clock): NFC kiosk — subtle logo glass, centered clock, AM/PM 2026-05-14 08:38:25 -04:00
gsinghpal
bad73fcea8 fix(fusion_clock): NFC kiosk visual polish — bigger chip, uncut waves, logo glass pill, no clock collision 2026-05-14 08:29:33 -04:00
gsinghpal
94249ba67d feat(fusion_clock): premium glass NFC kiosk + scope CSS to kiosk page
Visual rewrite of the NFC kiosk page:
- Animated mesh gradient background (drifts on a 28s loop)
- Glass-panel state cards with backdrop-filter blur
- Animated SVG NFC icon (concentric waves emanate from a chip)
- Company logo pulled from res.company.logo, displayed in header
- Dominant-hue extraction from logo sets --nfc-h CSS var; entire
  palette interpolates from that one HSL hue
- Success burst (green glow + scale), error shake, smooth state fades
- Reduced-motion fallback respects prefers-reduced-motion
- Glass numpad + employee picker in Enroll Mode

CRITICAL FIX: scoped all kiosk styles under :has(#nfc_kiosk_root) so
they no longer leak into other frontend pages. Previous version applied
html/body overflow:hidden + display:none on header/footer globally,
breaking website scrolling and chrome on every frontend page.
2026-05-14 08:22:47 -04:00
gsinghpal
2abd859a29 feat(fusion_clock): NFC kiosk Wake Lock to keep screen on while active 2026-05-14 08:09:27 -04:00
gsinghpal
98cb42d2e5 feat(fusion_clock): NFC kiosk on-screen debug overlay + clearer settings label 2026-05-14 08:03:47 -04:00
gsinghpal
878d05685c fix(fusion_clock): split min(80vw,700px) into width+max-width to avoid Sass unit error 2026-05-14 07:23:49 -04:00
gsinghpal
bd2c037a97 Update 2026-05-13-nfc-clock-kiosk-plan.md 2026-05-14 07:07:45 -04:00
gsinghpal
44636e47fb chore(fusion_clock): bump version to 19.0.3.0.0 for NFC kiosk feature 2026-05-14 01:32:07 -04:00
gsinghpal
06c49ecec6 feat(fusion_clock): NFC kiosk mock-tap debug shortcut
Add Ctrl+Shift+T keyboard shortcut (guarded by debugEnabled / nfc_kiosk_debug
setting) that prompts for a UID and fires _onEnrollTap or handleTap depending
on currentState (ENROLL vs IDLE). Persists last-used UID in localStorage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:30:35 -04:00
gsinghpal
37deaedf0d feat(fusion_clock): NFC kiosk Enroll Mode UI
Replaces the Task 18 stub renderEnroll with the full four-phase
implementation (password numpad → employee picker → tap-to-enroll →
result), adds _onEnrollTap wired to the NFC reading event, and exposes
it via window.__nfcKiosk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:28:36 -04:00
gsinghpal
30f7f18472 feat(fusion_clock): camera capture on NFC kiosk
Replace camera stub with real getUserMedia + canvas capture. Setup button
now starts NFC reader and camera together; camera failure is non-fatal when
photo is not required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:25:12 -04:00
gsinghpal
66e9749853 feat(fusion_clock): Web NFC integration on kiosk page
Adds NDEFReader scan loop, onNfcReading tap dispatcher, handleTap
state machine, postJson helper, capturePhoto stub (Task 17), and
setup wizard activation with error display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:22:55 -04:00
gsinghpal
c9be68a575 feat(fusion_clock): NFC kiosk JS scaffold + state machine + clock display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:20:31 -04:00
gsinghpal
19d692afe7 feat(fusion_clock): NFC kiosk QWeb template with static chrome + setup wizard
Replace placeholder template with full version: static chrome (company,
clock, date, location, settings button), one-time setup wizard state,
hidden video/canvas for camera, and data-* attrs for JS feature flags.
Update test assertion from h1 text to nfc_kiosk_root id to match new markup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:18:04 -04:00
gsinghpal
0351dcd497 feat(fusion_clock): NFC kiosk SCSS (always-dark, high-contrast)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:14:14 -04:00
gsinghpal
03fd3d7c1c feat(fusion_clock): NFC kiosk employee search endpoint
Add /fusion_clock/kiosk/nfc/employee_search that delegates to the
existing kiosk_search method, avoiding logic duplication. Adds
TestEmployeeSearch HttpCase (33 tests total, all passing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:11:28 -04:00
gsinghpal
f4c9ed3d24 feat(fusion_clock): NFC tap photo capture + photo-required gate
- Add _strip_data_url_prefix() helper to clean data-URL prefix from base64 photo payloads
- Gate nfc_tap on fusion_clock.nfc_photo_required ICP param (default True): rejects with error='photo_required' when photo absent
- Write x_fclk_check_in_photo / x_fclk_check_out_photo on clock-in/out attendance records
- Add TestTapPhotoHandling (3 tests): photo saved, required-rejects-missing, optional-succeeds-without

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:09:27 -04:00
gsinghpal
ef885c66dc feat(fusion_clock): NFC tap endpoint debounce + 6 error-case tests
Adds module-level 5s debounce (_is_debounced) with thread-safe dict +
GC. Inserts debounce guard in nfc_tap immediately after uid validation.
Adds TestTapEndpointErrors (6 tests): unknown_card, clock_disabled,
no_location_configured, kiosk_disabled, invalid_uid, debounce.
Adds setUp() to both tap test classes to clear _recent_taps between
tests, preventing cross-test debounce bleed. 29/29 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 01:06:30 -04:00
gsinghpal
148aa5cba8 feat(fusion_clock): NFC tap endpoint (happy path)
Add /fusion_clock/kiosk/nfc/tap JSON-RPC endpoint that toggles attendance
via _attendance_action_change, writing x_fclk_clock_source='nfc_kiosk' and
location on clock-in, applying break deduction/penalty checks on clock-out.
Add 2 HttpCase tests (clock-in + clock-out with 6s debounce sleep).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 01:02:49 -04:00
gsinghpal
661c8ae227 feat(fusion_clock): NFC card enrollment endpoint
Adds /fusion_clock/kiosk/nfc/enroll (jsonrpc, auth=user) that validates
the enroll password, normalises the card UID, checks for duplicate
assignments, writes x_fclk_nfc_card_uid, and creates a card_enrollment
activity log entry. 4 new tests; 21 total passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:58:25 -04:00
gsinghpal
a24a1ddf1a feat(fusion_clock): NFC card UID normalization helper
Add _normalize_uid static method to FusionClockNfcKiosk that strips
whitespace, uppercases, removes separators, validates hex-only content,
and reformats to canonical colon-separated pairs; returns None for
empty/invalid input. Covered by 7 new TransactionCase unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:56:02 -04:00
gsinghpal
f05cacec22 feat(fusion_clock): NFC kiosk page render route
Controller scaffold with GET /fusion_clock/kiosk/nfc, placeholder QWeb
template, and HttpCase tests (10 pass, 0 failures). Fixed Odoo 19
res.users create API: groups_id -> group_ids.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:53:03 -04:00
gsinghpal
9239ee2822 feat(fusion_clock): add NFC Clock Kiosk settings block
Extends res.config.settings with 5 NFC kiosk fields (enable toggle,
photo required, enroll password, debug mode, kiosk location via
related company field) and adds the corresponding settings view block
with conditional sub-fields hidden until the kiosk is enabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:45:41 -04:00
gsinghpal
4733885211 feat(fusion_clock): add NFC kiosk ir.config_parameter defaults
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:43:07 -04:00
gsinghpal
8e708bf2c4 fix(fusion_clock): NFC kiosk location domain + test isolation
Add domain filter on x_fclk_nfc_kiosk_location_id so the dropdown
only shows locations belonging to the current company in multi-company
setups. Replace shared-company mutation in test with a fresh company
to prevent cross-test state leakage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:40:28 -04:00
gsinghpal
caf240daec feat(fusion_clock): add NFC kiosk location to res.company
Adds x_fclk_nfc_kiosk_location_id (Many2one → fusion.clock.location) to
res.company so each company can designate which NFC kiosk location it uses.
Two tests cover field assignment and default-false behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:36:56 -04:00
gsinghpal
4bed8ab2c5 fix(fusion_clock): reorder NFC kiosk source before System per plan
Move ('nfc_kiosk', 'NFC Kiosk') to sit between kiosk and system in the
source Selection field, matching the spec's semantic grouping of
interactive sources before the automated system source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:34:36 -04:00
gsinghpal
50c209b8d3 feat(fusion_clock): NFC kiosk attendance fields + activity-log selections
- Add 'nfc_kiosk' to x_fclk_clock_source selection on hr.attendance
- Add x_fclk_check_in_photo and x_fclk_check_out_photo Binary fields (attachment=True)
- Add 'card_enrollment' and 'unknown_card_tap' to activity log log_type selection
- Add 'nfc_kiosk' to activity log source selection
- Add TestNfcAttendanceFields test class (3 tests); all 6 fusion_clock tests pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:24:35 -04:00
gsinghpal
65a1c4b17e fix(fusion_clock): remove unused ValidationError import in NFC tests 2026-05-14 00:19:20 -04:00
gsinghpal
91d3a3f9d1 docs(fusion_clock): use actual docker env names (odoo-modsdev-app/modsdev) in NFC plan 2026-05-14 00:14:51 -04:00
gsinghpal
70f855d91b feat(fusion_clock): add x_fclk_nfc_card_uid to hr.employee
Adds the NFC card UID field (Char, unique, manager-only) that the kiosk
will use to identify employees by card tap. Includes the tests package
with three post-install tests covering write, uniqueness, and nullable
multi-row behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 00:13:26 -04:00
gsinghpal
85eddba546 docs(fusion_clock): NFC clock kiosk implementation plan
20-task TDD plan for the NFC clock kiosk feature spec'd in
2026-05-13-nfc-clock-kiosk-design.md. Bite-sized steps with full code
in each, ordered: data model -> config -> backend endpoints ->
SCSS+template -> JS state machine -> NFC + camera -> Enroll Mode ->
debug shortcut -> version bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:05:23 -04:00
gsinghpal
48d3e48e61 docs(fusion_clock): NFC clock kiosk design
Design for tap-to-clock NFC kiosk in fusion_clock. Pilot scope: 1
station per company, Samsung Galaxy Tab Active 5 Pro running Web NFC
in Chrome kiosk mode. Reuses Ubiquiti-issued cards. Silent photo
verification via front camera. Backend reuses FusionClockAPI helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 23:50:00 -04:00
gsinghpal
f07e1bcce1 fix(chatter): wrap HTML message_post bodies in Markup() — 4 sites
Four message_post calls were passing strings with HTML tags as
plain `body=_(...)` instead of `body=Markup(_(...))`. Odoo escapes
non-Markup strings, so the chatter rendered "<b>QA Review failed</b>"
as literal text instead of bolding it.

Original bug surfaced via the Contract Review (QA-005) flow:
  body: "&lt;b&gt;QA Review failed&lt;/b&gt; by Garry Singh. Awaiting
  client information.&lt;br/&gt;&lt;b&gt;Reason:&lt;/b&gt;&lt;br/&gt;
  &lt;div data-oe-version=\"2.0\"&gt;Need to get updated
  drawing...&lt;/div&gt;"

Audit scan turned up three more identical patterns:

  fusion_plating/models/fp_parent_numbered_mixin.py:118
     "Issued <strong>%s</strong> to ..."
  fusion_plating_jobs/models/sale_order.py:282
     "Confirmed quote <strong>%s</strong> as <strong>%s</strong>."
  fusion_plating_quality/models/fp_contract_review.py:430
     "<b>QA Review failed</b> by ... <b>Reason:</b><br/>%(reason)s"
  fusion_plating_quality/models/fp_contract_review.py:524
     "<b>QA Review completed</b> by ... <b>Special Instructions
      captured:</b><br/>%(notes)s"

Fixes:
- Wrapped each body=_(...) with Markup(_(...)) using the
  Markup(template) % values pattern (auto-escapes the substituted
  values; user-supplied free text stays safe).
- For Html-field substitutions (qa_failure_reason,
  special_instructions), explicitly wrapped the value in Markup()
  so already-formatted HTML editor content (with data-oe-version="2.0"
  wrapper divs) flows through without being re-escaped.
- Added `from markupsafe import Markup` to the two files that
  didn't already import it (mixin + contract_review).

Drift cleanup: pulled the 180-line newer fp_contract_review.py
from entech to the local repo (added action_qa_review_failed,
action_open_client_email_wizard, action_view_client_emails,
action_complete_after_info, awaiting_info state, qa_failure_reason
+ special_instructions Html fields, etc. that had been edited on
entech without being committed).

Tested by re-posting via odoo shell on review 10: body now stores
"<b>QA Review failed</b>..." with literal HTML tags instead of
the double-escaped "&lt;b&gt;..." entities. Old chatter records
with the bad escape stay as-is in the audit trail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:41:39 -04:00
gsinghpal
e7c6960de9 feat(sticker): restore customer-name secrecy cover (ABC-MANU)
Body Customer row now prints a 3-and-4 short code instead of the
full company name. Operators see "ABC-MANU" on the floor; visiting
customers / unauthorised passers-by can't immediately tell whose
parts are on which rack.

Rule (per user's reference design):
  - First 3 chars of first word + "-" + first 4 chars of second word
  - Single-word names → just first 3 chars
  - All uppercase
  - Strips non-alphanumeric per word so "St. John's Mfg." doesn't
    leak punctuation into the slice

Logic lives in the shared inner template, so all 4 variants pick
it up automatically:
  sale.order     External + Internal Sticker
  fp.job         External + Internal Job Sticker

Verified on fp.job 2635: Customer row now reads "ABC-MANU" (was
"ABC Manufactoring").

Doesn't use the orphaned x_fc_short_code field on res.partner
(that field has no column or compute — broken Studio remnant).
A future spec can replace this inline computation with a proper
stored+inverse field if customers want per-partner overrides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:24:07 -04:00
gsinghpal
ad64b0b4c9 changes 2026-05-13 08:17:35 -04:00
gsinghpal
cd763fa1d7 chore(sticker): rename External action labels for the variant split
Print menu now shows External + Internal as paired entries:

  sale.order:  External Sticker      / Internal Sticker
  fp.job:      External Job Sticker  / Internal Job Sticker

XML IDs unchanged (action_report_fp_so_sticker /
action_report_fp_job_sticker) so existing bookmarks and
binding_model_id records keep working. print_report_name strings
also updated so the downloaded filename matches the new label.

DB verification:
  fp.job      | External Job Sticker
  fp.job      | Internal Job Sticker
  sale.order  | External Sticker
  sale.order  | Internal Sticker

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:07:50 -04:00
gsinghpal
f40f44aafd feat(sticker): add Internal Job Sticker variant on fp.job Print menu
Mirror of the SO Internal variant for fp.job. Same body fields,
same per-box loop; Notes column reads x_fc_internal_description
from the first linked SO line (job.sale_order_line_ids[:1]).
Operator on the shop floor sees ops-internal notes without those
ever appearing on the customer-facing External sticker.

Verified on fp.job 2635 with seeded internal_description: Notes
column reads "INTERNAL JOB: handle with care, no rework on this
batch" — confirms the Job Internal variant's override path mirrors
the SO Internal variant's.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:06:12 -04:00
gsinghpal
63bf271725 feat(sticker): add Internal Sticker variant on sale.order Print menu
Same 3-cell + body layout as External; Notes column reads
x_fc_internal_description (Sub 2 internal-description field on the
SO line) instead of line.name. Shop floor gets ops-facing notes
without leaking them to the customer-facing variant.

New action record action_report_fp_so_sticker_internal — binds to
sale.order, appears in the Print menu next to the existing External
sticker. New template report_fp_so_sticker_internal that pre-sets
_notes_content before t-calling the shared inner.

Verified on SO-30019 with a seeded internal_description: Notes
column reads "INTERNAL: rework if any dings on flange. Buff per
WI-104." — confirms the override path is wired through the
defaults-block initialiser, the inner's fallback chain, and the
new outer template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:04:29 -04:00
gsinghpal
974b8a5152 feat(sticker): wire _qty_total in SO + Job External outers
Activates the per-box loop landed in the prior commit. SO External
reads line.product_uom_qty; Job External reads job.qty. Inner
template now renders one sticker per physical box, marking each
with "X / N" in the Qty row.

Verified on fp.job 2635 (qty temporarily set to 3): 3-page PDF
with Qty rows "1 / 3", "2 / 3", "3 / 3" — each page identical
otherwise (same WO#, same QR, same body fields).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:02:32 -04:00
gsinghpal
0a32ed2da7 feat(sticker): per-box render loop + Notes override hook
Inner sticker template gains two parameters that outer templates
pre-set:

  _qty_total — total qty for the line/job. Inner wraps the body
    in t-foreach="range(int(_qty_total or 1))" so a qty=5 line
    produces 5 consecutive single-box stickers. Qty row in the
    body switches from "5" to "1 / 5", "2 / 5", ... "5 / 5".
    When _qty_total is missing/0/1, the Qty row keeps showing
    the plain integer (regression-free).

  _notes_content — Notes column source. Existing inner code
    hard-read _line.name; new code accepts an outer override
    and falls back to _line.name. External outers don't set it
    (unchanged behaviour); the new Internal outers (Task 4+5)
    pre-set it to x_fc_internal_description.

Defaults template initialises both new vars to False so the
inner's "outer-supplied OR fallback" pattern doesn't NameError
when called from existing outers that haven't been updated yet.

Verified regression-free: fp.job 2635 (qty=1) renders identically
to its pre-Task baseline — Qty row shows plain "1", Notes from
line.name as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:00:22 -04:00
gsinghpal
e4681a58c6 fix(jobs): split fp.jobs by thickness + serial on SO confirm
The _fp_auto_create_job grouping key was (recipe, part, coating).
Lines that shared all three but differed in thickness (or serial)
silently collapsed into one fp.job — the second line's thickness/SN
was lost, and any downstream cert printed the first line's values
across both batches. Silent mis-attestation = compliance hole.

Extended the key tuple to (recipe, part, coating, thickness, serial).
Single-line SOs and same-(thickness, SN) multi-line SOs collapse
identically to before. Only lines that previously merged when they
shouldn't have now split into their own fp.jobs.

TDD via test_so_confirm_splits_by_thickness:
  - seeds the part with default_process_id so both lines hit the
    `if recipe:` branch (where the bug lived — the no_recipe branch
    already split correctly per line)
  - confirms 2 jobs after action_confirm with each carrying its
    own thickness via the linked SO line

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:57:56 -04:00
gsinghpal
135cbd3a5c docs: implementation plan — sticker multi-part / per-box / Internal+External
7 tasks, bite-sized steps with exact code + commands. TDD on the
backend grouping change (new test_so_confirm_splits_by_thickness);
deploy-and-render-PDF on the QWeb template changes. Each task
self-contained, pushes to entech LXC 111 via the standard pct
exec + cat-pipe path, bumps the module version, and commits.

Task 7 is verification-only — creates a multi-line test SO with
two different thicknesses, renders External + Internal stickers
on both the SO and each spawned fp.job, confirms the box loop
and the Notes variant pattern both work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:47:40 -04:00
gsinghpal
3182ca3c39 docs: design spec — sticker multi-part / per-box / Internal+External
Three problems on the box-sticker stack rolled into one spec:

1. Backend: _create_fp_jobs grouping key collapses lines with
   different thicknesses or SNs into one job. Silent compliance
   hole. Fix: add thickness_id + serial_id to the key tuple.
2. No per-box stickers: a line with qty=5 prints 1 page showing
   "Qty: 5". Want 5 pages with "1 / 5", "2 / 5", ... "5 / 5".
3. No Internal variant: sticker always reads line.name (customer
   facing). Want a parallel variant that reads
   x_fc_internal_description (Sub 2 internal description field).

Renaming: existing actions keep their XML IDs (bookmarks /
binding_model_id records survive). Labels become:
  sale.order:  External Sticker      + Internal Sticker      (new)
  fp.job:      External Job Sticker  + Internal Job Sticker  (new)

All three changes share the same inner template, same files —
ship together. No data migration required; existing fp.jobs are
protected by the idempotency guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:41:53 -04:00
gsinghpal
677e460438 fix(sticker): wire SN # + Thickness to the correct Sub 5 fields
The values were structurally blank because the variable
resolution was reading the wrong field names:

  Was:  _line.x_fc_serial_number    (doesn't exist)
        _line.x_fc_thickness        (doesn't exist)
  Now:  _line.x_fc_serial_id.name           (M2O fp.serial)
        _line.x_fc_thickness_id.display_name (M2O fp.coating.thickness)

Sub 5 shipped these as Many2one registries (fp.serial,
fp.coating.thickness) — the sticker was guessing at flat
Char-field equivalents that were never created.

Verified on SO-30019: SN # now prints "65767", Thickness now
prints "0.3-0.5 mils" (the en-dash in display_name mojibakes
to "â€"" through wkhtmltopdf's font path on entech, so we
replace en-dash + em-dash with ASCII hyphen-minus before
render — ASCII-only is what label printers want anyway).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:25:42 -04:00
gsinghpal
c7b794f604 fix(sticker): drop SO-line sequence suffix + bump Notes type
SO sticker (report_fp_so_sticker):
  Was: "SO-30019 / 10"  (the "/ 10" was line.sequence — Odoo's
       default increment-by-10 — meaningless to the operator)
  Now: "SO-30019"
Multi-line SOs are disambiguated by the body fields (Part #,
Customer, etc.) which already differ per sticker, so the
suffix wasn't earning its keep.

Notes column size bumps:
- Label 44pt -> 48pt
- Content 30pt -> 36pt (+20%) — easier to read from across
  the line. Line-height tightened 1.15 -> 1.1 to keep the
  multi-paragraph wrap inside the body band.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:21:09 -04:00
gsinghpal
64c61dcca8 feat(sticker): much bigger text + QR +30%
wkhtmltopdf renders CSS font-size at a smaller physical scale
than the em-square math predicts (a "30pt" cell text was only
~4mm tall visually). Pushing all type up significantly so it
actually reads at scan/print distance:

Text bumps:
- Body field text 30pt -> 50pt (+67%, label + value)
- WO# 56pt -> 72pt (+29%)
- Notes label 30pt -> 44pt
- Notes content 22pt -> 30pt (+36%)
- Muted rev tag 22pt -> 30pt
- Body cell padding 0 10px -> 0 8px (a touch more horizontal
  room for long values now that the font is bigger)

QR + 30% as asked:
- Wrapper 280 -> 365px (+30.4%). Image 368 -> 480px, offset
  -44 -> -58px (recomputed for the new quiet-zone crop).

Header re-balanced for the bigger content:
- Height 25% -> 32% (fits the +30% QR + bigger WO# + bigger
  logo at 135px)
- Body band: 75% -> 68% (rows now ~9.6mm tall; line-height
  1.0 keeps the 50pt body text snug inside)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:48:40 -04:00
gsinghpal
649b75d4a1 feat(sticker): bigger field labels + values + notes text
Trimmed the header from 30% to 25% of page height to free up
vertical room for the body band's 7 rows. Each row is now
~10.45mm tall (was 9.88mm), so the field font fits comfortably
at the bigger size.

Size bumps:
- Body field text 26pt -> 30pt (label + value, +15%)
- Muted rev tag 18pt -> 22pt
- Notes label 26pt -> 30pt
- Notes content 19pt -> 22pt (+16%, wraps cleanly to 2 lines
  when the customer description runs long)

Header re-fit (smaller cells, same content):
- Header height 30% -> 25%
- WO# font 62pt -> 56pt
- Logo max-height 135 -> 105px
- QR wrapper 340 -> 280px (image 447 -> 368px, offset -53 ->
  -44px to keep the quiet-zone crop math right)
- High-def 600x600 QR source unchanged — still prints crisp
  at the smaller wrapper size

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:41:17 -04:00
gsinghpal
8aa817b1a0 feat(sticker): bigger text, bigger high-def QR, drop "WO #" prefix
WO# cell now just renders the number (e.g. "WO-30019") since the
"WO" is already baked into the doc index format — the redundant
prefix was eating cell width without adding information.

Size bumps:
- WO# 44pt -> 62pt (text is shorter so the cell can carry the
  extra weight)
- Body field text 22pt -> 26pt, line-height 1.1 -> 1.0 so the
  bigger font still fits 7 rows in the body band
- Notes label 22pt -> 26pt, content 16pt -> 19pt
- Logo max-height 120 -> 135px
- Muted rev tag 16pt -> 18pt

QR upgrades (both "bigger" and "high def" as asked):
- Source resolution 300x300 -> 600x600. At 300dpi print across
  a 28.8mm wrapper, effective output is ~515ppi vs the prior
  ~256ppi. Scanners on the floor will read it cleanly even at
  steeper angles / scuffed labels.
- Wrapper 290 -> 340px (+17%). Image 390 -> 447px, offset -50
  -> -53px (recomputed quiet-zone crop: 600 * 0.12 = 72px
  margin -> 456px effective QR data -> 340 * 600/456 = 447
  scaled image -> (447-340)/2 = 53px offset).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:38:05 -04:00
gsinghpal
80d1cc5639 feat(sticker): 3-cell header + right-side Notes column + new field list
Restores the original ENTECH sticker layout from the operator's
screenshot reference:

Header (3 horizontal cells, divided by vertical rules):
  [Logo]  |  WO #WO-30019  |  [QR]

Body (left side = field table, right side = Notes column):
  PO #:        587854         | Notes:
  SN #:        -              | <customer-facing description>
  Customer:    ABC Manufact.  |
  Part #:      9876... Rev A  |
  Due Date:    May 17, 2026   |
  Thickness:   -              |
  Qty:         1              |

Changes from previous (stacked-left) layout:
- Header: 1-row 3-cell (Logo 28% | WO# 44% | QR 28%) replaces
  the 2-cell w/ logo+WO# stacked on left.
- Body: 2-region (66% / 34%) replaces single 7-row table.
  Notes column now spans full body height on the right.
- Fields: SN # and Thickness added; Process row removed.
- Labels: "PO (RO)" -> "PO #", "Part Number" -> "Part #".
- Notes content: switched from SO.x_fc_internal_note to the SO
  line's `name` (= customer-facing description per Sub 2 Q6).
- SN # reads _line.x_fc_serial_number (Sub 5 field).
- Thickness reads _line.x_fc_thickness with coating.thickness
  fallback (Sub 5 field, defensive 'in _fields' check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:33:18 -04:00
gsinghpal
2db789d7dd feat(sticker): bigger QR + double-height Notes row
Both changes the operator asked for, applied to the original
ENTECH stacked-left layout (no other structural changes):

- QR wrapper 380px → 460px (image 510px → 620px, offset -65 → -80
  to keep the white quiet-zone cropped). Roughly +21% surface area.
- Notes row height 14.28% → 24% (~2x). Other 6 rows shrink
  proportionally from 14.28% to 12.67% each so the band still
  totals 100%. Notes value also gets white-space: normal +
  vertical-align: top so the operator's handwriting room sits at
  the top of the cell and a long internal note can wrap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:25:37 -04:00
gsinghpal
7a02382623 fix(reports): WO Margin model name must match report_name + '_template' suffix
The previous fix swapped t-field -> t-esc so the QWeb error stopped,
but the report still printed blank. Root cause: Odoo looks up the
report data model via env['report.<report_name>'], but our model was
named 'report.fusion_plating_jobs.report_fp_job_margin' while the
action's report_name is 'fusion_plating_jobs.report_fp_job_margin_template'.
The model lookup missed, _get_report_values never fired, and the
template rendered with no 'rows' in scope — empty foreach -> empty
page.

Renamed the model to report.fusion_plating_jobs.report_fp_job_margin_template.

Verified: PDF size jumped from 1229 bytes (blank) to 125880 bytes
(fully populated). HTML now contains 'Job Margin', 'Step Breakdown',
and the actual WO name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:08:38 -04:00
gsinghpal
169e97af02 feat(nexa_coa_setup): analytic plans + seed accounts
- 'Customer Project' plan (renamed from 'Project' to avoid duplicate with
  project module's auto-created plan) — mandatory
- 'Department' plan (mandatory) — seeded with DEPT-DEV, DEPT-SALES,
  DEPT-ADMIN, DEPT-HOSTING
- 'SR&ED Tag' plan (optional) — seeded with 7 tag values:
  SRED-T4-DEV-SALARY, SRED-SPECIFIED-EMPLOYEE,
  SRED-CONTRACTOR-CA-ARM-LENGTH, SRED-CONTRACTOR-CA-NON-ARM-LENGTH,
  SRED-MATERIALS-CONSUMED, SRED-OVERHEAD-PROXY-BASIS, NOT-ELIGIBLE

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:53:21 -04:00
gsinghpal
3c959771ae feat(nexa_coa_setup): pre_init_hook to clear l10n_ca code collisions
Bakes the staging-side one-off collision clearing into the module install
itself so production install will execute the same sweep automatically.

For each of the 29 l10n_ca codes that conflict with Nexa's planned chart:
- If the account has zero postings: suffix code with '.OLD', mark inactive,
  rename to '(l10n_ca LEGACY) <original>'
- If the account has postings (currently 115100 AR control with 240 lines
  and 511100 Inside Purchases with 1 line): leave alone (Nexa renumbered
  to 119100 / 511105 in the XML)

Idempotent — pre_init_hook re-running has no effect (already-suffixed
codes are skipped).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:51:25 -04:00
gsinghpal
449f29fc7f fix(reports): WO Margin PDF — t-field requires dot-notation on Odoo 19
The template used 't-field="step['rate']"' for monetary values pulled
from dict rows. Odoo 19's QWeb asserts t-field has at least one dot
(it's strictly for record.field_name lookups). Replaced six bare-dict
t-field usages with t-esc; the existing t-options widget=monetary +
display_currency still applies for currency formatting.

Verified by rendering report for WO-30019 — 1229-byte valid PDF, no
QWeb error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:51:17 -04:00
gsinghpal
3c2fb22346 feat(nexa_coa_setup): chart of accounts — 128 accounts across 1-6xxxxx
Renumbered to avoid collisions with pre-loaded l10n_ca codes:
- Due From Shareholder/Associated: 115xxx → 119xxx range (115100/115110 already
  held l10n_ca AR control accounts with 240 postings)
- Cloud Infrastructure: 511100 → 511105 (511100 was l10n_ca 'Inside Purchases'
  with 1 historical posting)

All other 28 colliding l10n_ca codes (118xxx, 213xxx, 214xxx, 221xxx, 311xxx,
411xxx, 413xxx, 511110-511210, 512100-512200, 611100-300, 612xxx) had zero
postings and were cleared in-place by suffixing existing codes with '.OLD'
via a one-off odoo-shell script on staging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:50:00 -04:00
gsinghpal
3a41370189 fix(nexa_coa_setup): tolerant fiscal-year lock hook
The post_init_hook attempt to set fiscalyear_lock_date=2025-12-31 fails
with RedirectWarning when unreconciled bank statement lines exist in
the period. Catch RedirectWarning/UserError/ValidationError, log a
clear instruction to set the lock manually after reconciliation, and
let install continue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:41:15 -04:00
gsinghpal
d6513ff7ab feat(nexa_coa_setup): module skeleton with hooks stub
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:39:24 -04:00
gsinghpal
457d9b7dbf fix(numbering): post-review fixes — credit notes, SO unlink, multi-part grouping, SQL whitelist
- B1: Add Credit Note wizard path was blocked because invoice_origin
  has copy=False and the wizard doesn't set fp_from_so_invoice. Now
  the validator allows reversals when reversed_entry_id points at a
  customer-facing move that itself went through the validator at
  original creation time. account.move._fp_parent_sale_order also
  walks self.reversed_entry_id._fp_parent_sale_order so the credit
  note inherits the parent number (CN-<parent>).

- Bug 1: sale.order.unlink() now blocks deletion when x_fc_parent_number
  is set (matches spec §6.2). Draft quotes remain freely deletable
  per Odoo standard. Applies to all users including admins.

- Bug 2: out_receipt added to CUSTOMER_TYPES so POS-style receipts
  hit the same SO-flow gate as out_invoice / out_refund.

- C1: WO grouping key changed from recipe.id to (recipe.id, part.id,
  coating.id). Bundling lines with different parts under one WO put
  first_line's part_number on the CoC header — silent compliance
  mis-attestation. Now distinct parts always get distinct WOs even
  when they share a recipe.

- C3: SQL whitelist (_FP_COUNTER_FIELD_RE) on _fp_assign_parent_name's
  interpolated counter field name. No user input today; defence in
  depth for future subclasses that might read the name from context.

Verified on entech: parent=30017, credit note = CN-30017,
multi-part SO produces 2 WOs (one per part), confirmed-SO unlink
blocked, out_receipt blocked, whitelist regex enforced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:19:08 -04:00
gsinghpal
c85a9bbf82 docs: nexa_coa_setup implementation plan
Bite-sized task plan to implement the CoA design against odoo-nexa
nexamain database. 12 phases:
0. Safety backup + staging clone
1. Module skeleton (nexa_coa_setup)
2. Chart of accounts (~110 new accounts across 1-6xxxxx)
3. Analytic plans (Project, Department, SR&ED Tag)
4. Hooks for archive-unused / rename-legacy
5. Tax cleanup
6. 8 fiscal positions with auto-detect
7. Service product categories
8. Westin/Divine partner records (RP-Associated tag)
9. 8 bank reconciliation rules
10. End-to-end test invoices (ON, US, intercompany)
11. Apply to production (with explicit GO/NO-GO gate)
12. Operating runbook

Each task has a verify-before / change / verify-after / commit cycle.
Staging clone (nexamain_staging) used for every phase before prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:05:33 -04:00
gsinghpal
5b399fbdda fix(configurator): copy operator-input prompts when cloning recipe to part
_clone_subtree() in fp_part_composer_controller built node vals
manually and never copied source.input_ids — so 'Load Template'
copied the recipe tree structure but dropped every custom prompt,
leaving operators with empty data-capture screens. The fix iterates
input_ids and calls .copy({'node_id': new_node.id}) so kind,
target_min/max/unit, compliance_tag, hint, selection_options,
sequence — every field on the input model — flows through.

Verified on entech: ENP-ALUM-BASIC clone now shows all 105 prompts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:58:11 -04:00
gsinghpal
b5416d242c test(numbering): E2E walkthrough — quote -> SO -> WO -> IN -> CoC -> DLV -> RCV -> Hold -> RMA
Verified pass on entech (parent=30015): all linked docs share the
parent number, immutability + unlink-block + direct-invoice-block
all enforced. NCR/CAPA fall back to legacy sequences as designed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:35:29 -04:00
gsinghpal
fdbbd2852a fix(numbering): WO Detail report strips WO- prefix for compact display
short_wo now handles both naming schemes: new WO-NNNNN[-NN] (strips
WO-) and legacy WH/JOB/NNNNN (last slash segment). Customer-facing
Work Order column shows '30000-02' instead of 'WO-30000-02'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:34:09 -04:00
gsinghpal
be109c9c79 feat(numbering): surface quote ref under SO name on the form
A small grey 'Originally quoted as Q202605-200' line appears below
the SO heading once the order is confirmed. Uses invisible= on the
wrapper div (Odoo 19 forbids t-if in standard form views).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:32:22 -04:00
gsinghpal
78d633f63f feat(numbering): immutable name/doc_index + unlink block on issued docs
write() override raises UserError if name or x_fc_doc_index is in
vals and differs from the stored value (bypass: context flag
fp_allow_name_rename=True for the SO-confirm rename + bulk WO
creation paths). unlink() override raises UserError for records
that have been issued a name; applies to all users including
admins — cancellation must go through the state machine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:39:03 -04:00
gsinghpal
95cb73d91a feat(numbering): wire NCR, CAPA, Hold, RMA into parent-numbered mixin
Hold derives parent via job_id.sale_order_id; RMA via sale_order_id
directly — both get HOLD-<parent> / RMA-<parent> names. NCR and CAPA
have no SO link in core, so they fall back to their legacy sequences
(NCR/YYYY/NNN, CAPA/YYYY/NNN); future modules can override the
_fp_parent_sale_order hook to enable parent naming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:36:29 -04:00
gsinghpal
0d85063b5e feat(numbering): wire CoC/RCV/DLV/PU into parent-numbered mixin + rename counters
Per-model counter fields on sale.order renamed to x_fc_pn_*_count
to avoid collision with pre-existing compute fields of the same
short name in bridge_mrp / receiving / configurator (silent
compute-override was suppressing the storage). 4 child models
(fp.certificate, fp.receiving, fusion.plating.delivery,
fusion.plating.pickup.request) now derive names as PFX-<parent>
with -NN suffix from the 2nd onward.

fusion.plating.pickup.request gains a sale_order_id field
(optional) so pickups created against an SO get parent-derived
names, while standalone pickups (pre-SO) fall back to PU/YYYY/NNNN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:30:37 -04:00
gsinghpal
765a0a4c82 feat(numbering): block direct invoice creation + wire account.move into mixin
Customer invoices (out_invoice / out_refund) can only be created via
sale.order._create_invoices() or with an invoice_origin matching an
existing SO. Applies to ALL users including admins. Once created,
the move's name is derived from the SO's parent number: IN-30000,
IN-30000-02, CN-30000, ... Pre-existing portal-job link on
action_post() preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:21:09 -04:00
gsinghpal
daf1235e20 docs(nexa-coa): annual HST + T2 filing cadence; HST# normalization
Captures user-confirmed CRA registration & filing setup:
- Annual GST/HST filer (return Mar 31, instalments if prior net tax ≥ \$3k)
- Annual T2 filer (return Jun 30, balance due Mar 31 for CCPC)
- HST# 741224877 currently stored as 9-digit BN root only; normalize to
  full 15-char '741224877 RT0001' for tax-report validation
- Quick Method opportunity downgraded — \$400k threshold applies to
  associated-group totals; Nexa+Westin+Divine combined likely exceeds it
- Add HST cadence escalation flag (quarterly auto-trigger at \$1.5M)
- Acceptance criteria expanded with HST# format, filer config, and
  intercompany invoice test case

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:20:16 -04:00
gsinghpal
3d4f003aba docs(nexa-coa): treat Westin & Divine as associated corps
Restructure Section 9 to handle Westin Healthcare Inc and Divine Mobility
Inc as Gurpreet's associated corporations (ITA s.256):
- Future intercompany flows go through normal AR/AP with partner records
  tagged 'RP-Associated', not slush 'Due to/from' GL buckets
- 'Due to/from Associated Corporations' now reserved only for true
  intercompany loans (no invoice)
- Surface SBD $500k sharing and SR&ED $3M sharing rules; Schedule 23
  allocation drives major annual tax decisions
- Manpreet account archived (employee of another corp, not Nexa-related)
- Add transfer-pricing risk flag (ITA s.247, 10% penalty)
- Add multi-company Odoo as future sub-project

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:18:52 -04:00
gsinghpal
6c6fb8d2a4 feat(numbering): WO grouping by recipe + parent-derived bulk naming
Replaces x_fc_wo_group_tag grouping with resolved-recipe grouping.
Bare WO-<parent> when 1 recipe, WO-<parent>-NN zero-padded for N>1
ordered by min line sequence. fp.job inherits parent-numbered mixin
for the manual-add path; bulk SO-confirm sets names explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:18:10 -04:00
gsinghpal
1b1bebdcd8 feat(numbering): assign parent_number + rename to SO-<n> on confirm
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:14:47 -04:00
gsinghpal
e0d1998811 feat(numbering): draw quote name from fp.quote.number on SO create
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:12:45 -04:00
gsinghpal
bc3f584851 feat(numbering): add parent_number + counters to sale.order
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:10:55 -04:00
gsinghpal
105909470f feat(numbering): add fp.parent.numbered.mixin abstract model
Atomic counter via SELECT FOR UPDATE on the parent SO row. Composes
child names as PREFIX-PARENT (bare for first) or PREFIX-PARENT-NN
(zero-padded 2-digit, then unpadded past 99). Subclasses implement
three hooks: _fp_parent_sale_order, _fp_name_prefix, _fp_parent_counter_field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:09:17 -04:00
gsinghpal
6e67fc5ce3 docs: nexa systems CoA + accounting setup design spec
Comprehensive chart-of-accounts redesign for odoo-nexa nexamain DB:
hybrid approach over l10n_ca, three analytic plans (Project/Department/SR&ED
Tag), fiscal positions for auto tax handling, cleanup plan for the
~370 unused accounts and 49 messy taxes, automation hooks via product
categories and bank reconciliation rules.

Goals: CRA compliance, SR&ED claim infrastructure, zero-rated export
handling, one-click invoicing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:08:40 -04:00
gsinghpal
fd9d4e775b feat(numbering): add fp.parent.number + fp.quote.number sequences
Parent sequence starts at 30000. Quote sequence is Q + YYYYMM + non-resetting
counter starting at 200. Phase 1 Task 1 of the parent-number hierarchy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 13:07:16 -04:00
gsinghpal
2de5491693 plan(numbering): step-by-step implementation plan
15 tasks across 8 phases — foundation (sequences + mixin + SO fields),
quote/SO rename, WO grouping rewrite, invoice block + naming, child
model wiring (CoC/RCV/DLV/PU/NCR/CAPA/Hold/RMA), immutability + unlink
block, view + report fixes, end-to-end walkthrough.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:38:08 -04:00
gsinghpal
671820427a spec(numbering): parent-number hierarchy design
Quote→SO→WO→IN→CoC→DLV→RCV→… all share a single parent number drawn
from the sale order. New abstract mixin centralises naming with atomic
counter increment, compliance-grade immutability, and a hard block on
direct invoice creation outside the SO workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:28:52 -04:00
gsinghpal
b07f771d98 changes 2026-05-12 09:08:34 -04:00
gsinghpal
01a46e33e2 fix(process-tree): breadcrumb pile-up + dead "No MO selected" banner
Two issues on the Process Tree client action:

1. Back to Work Order kept growing breadcrumbs (WO -> Tree -> WO ->
   Tree -> ...) because onBack used action.doAction() which PUSHES
   a new act_window onto the stack instead of popping. Fixed by
   trying action.restore() first (pops the Tree off the stack and
   returns to the parent WO/Step controller). Falls through to
   explicit doAction only when there's no parent in the stack
   (direct URL access).

2. The empty-state banner referenced productionId, a dead variable
   from the bridge_mrp era when the tree was tied to mrp.production.
   Since the component now uses jobId (fp.job context key), the
   "No manufacturing order selected" message ALWAYS fired regardless
   of whether a job was loaded. Fixed by using jobId and updating
   the message to "No work order selected".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:05:27 -04:00
gsinghpal
2d9779047b fix(jobs): registry load failure after Tier 2/3 persistence patches
Two compounding issues introduced by the persistence audit work:

1. fields.Text mismatch: sale.order.x_fc_internal_note and
   x_fc_external_note are actually fields.Html. I declared the
   sale.order.line related mirrors and the fp.job stored copies as
   fields.Text, so setup_related raised:
     TypeError: Type of related field
     sale.order.line.x_fc_internal_note is inconsistent with
     sale.order.x_fc_internal_note
   Fixed by switching both Note fields on fp.job and sale.order.line
   to fields.Html.

2. Module-load-order: Tier 3 fields (x_fc_delivery_method,
   x_fc_ship_via, x_fc_invoice_strategy) are defined in
   fusion_plating_jobs (related to sale.order via _inherit), but I
   referenced them in fusion_plating core's fp_job_views.xml — which
   loads BEFORE fusion_plating_jobs registers the fields. View
   validator raised "Field x_fc_delivery_method does not exist".
   Fixed by removing those 3 fields from the core view group and
   adding them via xpath in fusion_plating_jobs's fp_job_form_inherit
   (which loads after the fields are registered).

Both fixes deployed and verified — registry loads in 2s, all field
types match, related path resolves correctly. No data loss; the
fp.job rows that already had stored Text content for internal_note /
external_note will carry over into the Html field intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:45:59 -04:00
gsinghpal
cba9a6da6b feat(jobs): mirror delivery_method/ship_via/invoice_strategy on fp.job
Tier 3 of the SO->fp.job persistence audit. Three logistics/billing
fields surface on fp.job as related read-only (not stored) mirrors:

- x_fc_delivery_method - Local Delivery / Shipping Partner / Customer
  Pickup. Cargo classification used by logistics planning.
- x_fc_ship_via - Carrier name (UPS, FedEx, customer pickup, etc.).
- x_fc_invoice_strategy - Deposit / Progress / Net Terms / COD-Prepay.
  Read by the invoicing module's hooks; mirroring on the WO is for
  manager visibility only.

These were intentionally chosen as related (not stored persisted)
because the SO is the authoritative source - the existing downstream
code (delivery + invoicing modules) already reads them off SO directly.
A stored copy would risk drift. Related auto-follows SO updates.

Same three fields also mirrored on sale.order.line as stored related
for per-line list visibility.

Closes the SO->fp.job persistence audit. All 10 operational fields
identified now flow through to the WO (7 stored + populated at confirm,
3 related read-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:39:12 -04:00
gsinghpal
15eac309ee feat(jobs): persist deadlines + planned start + notes on fp.job
Tier 2 of the SO->fp.job persistence audit. Four operational metadata
fields mirrored from sale.order:

- x_fc_internal_deadline (Date) - shop's internal target finish date,
  ahead of the customer-facing deadline. Kept separate from
  date_deadline (which scheduling code may adjust).
- x_fc_planned_start_date (Date) - customer-quoted planned start date.
  Kept separate from date_planned_start (Datetime, capacity-adjusted).
- x_fc_internal_note (Text) - shop-internal notes from the order.
- x_fc_external_note (Text) - customer-facing notes, printed on
  traveller / BoL / cert.

All four populate at SO confirm via _fp_auto_create_job, and surface
on sale.order.line as stored related fields for per-line visibility.
fp.job form view gets a Notes group alongside the Customer References
group from Tier 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:37:19 -04:00
gsinghpal
7d37f5713c feat(jobs): persist Customer Job # + PO # + Rush Order on fp.job
Tier 1 of the SO->fp.job persistence audit. Three customer-reference
fields entered on sale.order's Plating tab were not flowing through
to fp.job (or SO lines), so the shop floor and printed paperwork
(traveller, BoL, cert) had to round-trip via sale_order_id every time.

Changes:
- fp.job: new x_fc_customer_job_number (Char, tracking), x_fc_po_number
  (Char, tracking), x_fc_rush_order (Boolean, tracking). All three
  populated by _fp_auto_create_job at SO confirm time.
- sale.order.line: x_fc_customer_job_number / x_fc_po_number added as
  stored related fields off order_id so per-line list views show the
  customer's references without navigating to the order header
  (x_fc_rush_order was already on lines).
- fp.job form view: small Customer References group under the title
  surfaces the three fields where the user expects them.

Verified end-to-end: SO -> SO line related fields -> fp.job direct
fields all carry the same value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:34:56 -04:00
gsinghpal
cd2584d6ee ui(rename): "Plating Job" -> "Work Order" / display "WO # 01368"
Standardise user-facing terminology across 5 modules (27 files):
  - display_name compute: 'Work Order # 01368' -> 'WO # 01368'
  - _description on 5 models: Plating Job{," Step"," Step Time Log"," Margin Report"," Recipe Node Override"} -> Work Order equivalents
  - field labels (string=...) on 13 Many2one / One2many fields
    across fp.batch, fp.thickness_reading, fp.quality.hold,
    fp.job_consumption, fp.portal.job, fp.certificate, fp.delivery,
    fp.quality.check, fp.racking.inspection, res.partner, sale.order
  - XML view labels: action names, list/form/search strings,
    portal template names, dashboard tile titles

What's deliberately preserved:
  - DB model name 'fp.job' (technical identifier — used by
    sale_order.x_fc_plating_job_ids and all comodel refs)
  - Module name 'fusion_plating_jobs' (directory / import path)
  - Settings -> Apps display label 'Fusion Plating Jobs' (module
    identity for Odoo's app picker)
  - 'Use Native Plating Jobs' migration toggle (internal mechanism
    flag, not user-facing terminology)

Verified on entech: WH/JOB/01368 now displays as 'WO # 01368'
everywhere humans look (form header, breadcrumbs, M2O dropdowns,
error messages, smart-button titles).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:22:09 -04:00
gsinghpal
dcbe8305d0 ui(process-tree): back to Work Order + pulsing green for done steps
Two improvements to the Process Tree visualization opened from the
Work Order's Process Tree header button:

  1. Back button returns to the Work Order (job form) instead of
     Plant Overview. fp.job.action_open_process_tree now passes
     back_job_id in the client-action context; process_tree.js
     reads it via a new backJobId getter, updates the button label
     to "Back to Work Order", and routes onBack to fp.job form.
     The Plant Overview fallback stays for callers that don't pass
     either back_step_id or back_job_id.

  2. Completed operation/step cards now have a green fill (#1e8449)
     and a subtle pulsing glow (box-shadow animation, 2.6s alternate)
     so finished work pops against still-pending dark cards. Hover
     pauses the animation so the click target is steady. Reuses the
     same green the workflow-state slice already used.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:13:46 -04:00
gsinghpal
798458c834 ui(jobs): Finish & Next becomes pulsing vivid-green icon
User wanted Finish & Next to drop its text label like the other row
buttons, but stand out visually as the primary action. Solution:
icon-only with a vivid green color and a subtle pulse animation.

- New SCSS: fp_finish_btn.scss with branch-on-$o-webclient-color-
  scheme so the dark bundle uses green-400 (pops on dark bg) and
  light bundle uses green-600. Pulse animation 1.8s ease-in-out
  infinite, scale 1.0 ↔ 1.18. Pauses on hover/focus so the click
  target is steady.
- Registered in both web.assets_backend and web.assets_web_dark
  per the project's dark-mode rule (CLAUDE.md).
- View: string="Finish & Next" → title="Finish & Next",
  class drops "text-primary", gains "o_fp_finish_btn".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:05:28 -04:00
gsinghpal
30a1141997 ui+fix(jobs): compact row buttons + remove racking inspection gate
UI: secondary row buttons in the embedded step list are now icon-only
with tooltips (Pause / Complete 1 → Next / Record / Skip / Move…).
Saves ~70% horizontal space. "Finish & Next" stays text+icon as the
primary action.

Fix: removed the racking-inspection gate from button_finish. Racking
is now a recipe step (not a separate inspection workflow), so the
"Racking inspection for ... is Inspecting — must be Done" error no
longer fires. _fp_check_racking_inspection_complete() helper is
preserved for diagnostics but no longer called from the finish path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 01:01:50 -04:00
gsinghpal
a0644a7e5c fix(jobs): Finish & Next bulk-moves all parts, no more "click N times"
User feedback: operators with small parts (e.g. valve bodies) batch
them through the whole recipe. The previous behavior — Finish & Next
raising "use Complete 1 → Next or Move..." when qty>1 — forced N
clicks for a workflow that's naturally one click.

Change: _fp_record_one_piece_auto_move now ALWAYS bulk-moves
qty_at_step parts to the next step in one move record, regardless of
whether the qty is seed-only (first / paperwork step) or real (parked
from an upstream move). Audit trail is preserved (one move row per
finish), operator gets one click.

Three buttons now map cleanly to the three workflows:
  - Finish & Next: bulk all parts forward, finish, auto-start next
  - Complete 1 -> Next: streaming flow, move 1 part, stay open
  - Move...: explicit qty + destination wizard for partial batches

Verified end-to-end on entech: seed qty=6 + real-incoming qty=6 both
move forward in a single click each.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:55:03 -04:00
gsinghpal
8b5472bf4e fix(jobs): qty gate false-positive on paperwork / first steps
The qty gate I added refused Finish on steps where qty_at_step > 0,
to force operators to move parts forward first. But the first-step
seed in _compute_qty_at_step gives the earliest non-terminal step
a notional qty = job.qty — a UI hint, not actual parked parts.
Paperwork steps (Contract Review, Inspection-by-paperwork, etc.)
sit on that seed, and the gate was blocking Finish with a misleading
error.

Fixes:
- button_finish gate now checks for REAL incoming moves before
  refusing. Seed-only qty (no incoming_move_ids filtered to non-
  self-loop) is exempt.
- _fp_record_one_piece_auto_move detects seed-only qty and bulk-
  moves ALL parts in one shot to the downstream step. Correct for
  paperwork / first steps where parts don't physically wait
  per-piece — one click finishes the paperwork and pushes the whole
  batch forward.

For steps with REAL incoming moves (parts actually moved here via
a Move record), the original gate semantics still apply: qty == 1
auto-moves one part; qty > 1 raises with the "use Complete 1 → Next
or Move…" message.

Verified on entech: Contract Review with seed qty=6 now finishes
cleanly, bulk-moving all 6 parts to the next step in one move.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:44:29 -04:00
gsinghpal
d6bda9740f fix(jobs): Received workflow milestone keys off pre-recipe receiving
The Received milestone was tied to recipe steps tagged
default_kind='receiving'. But receiving in this system is a pre-
recipe inbound logistics flow (fp.receiving model in
fusion_plating_receiving). When parts physically arrive, the flow
sets sale_order.x_fc_receiving_status to partial or received.

Changes:
  - New trigger_on_parts_received Boolean on fp.job.workflow.state.
  - _fp_is_passed_for_job branch: passes when sale_order's
    x_fc_receiving_status is in (partial, received).
  - _compute_workflow_state_id depends extended with
    sale_order_id.x_fc_receiving_status so the bar recomputes
    automatically when the receiving flow updates the SO.
  - DB seed update: Received state drops trigger_default_kinds=
    'receiving' and gains trigger_on_parts_received=True.

Verified end-to-end on entech: bar moves Confirmed → Received on
status change, regresses on rollback, accepts both 'partial' and
'received' as satisfying the milestone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:34:19 -04:00
gsinghpal
8d082cd9cc fix(jobs): make "In Progress" workflow milestone fire reliably
Two coupled fixes so the workflow bar shows "In Progress" when work
is actually underway, even on recipes without kind tagging:

  B. Auto-promote fp.job.state on first step start.
     fp.job.step.write hook detects step transitions to in_progress
     and promotes parent job state from 'confirmed' → 'in_progress'.
     Without this, fp.job.state never reached 'in_progress' anywhere
     in the codebase, so the trigger_on_job_state='in_progress'
     path was dead code.

  C. Smarter trigger_first_step_started for untagged recipes.
     For tagged recipes (any step has kind in wet/bake/mask/rack),
     keep the strict kind-based check. For untagged recipes (all
     steps kind='other' or similar), fall back to "any step in
     in_progress/paused/done" so the milestone fires regardless of
     recipe authoring quality.

Verified end-to-end on entech with untagged steps:
  - confirmed → in_progress when first step starts
  - workflow bar tracks at in_progress through the work
  - workflow bar advances to done when all steps done/skipped

Recipe authoring still encouraged for full Received / Inspected
intermediate states (those keep their default_kind triggers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:27:05 -04:00
gsinghpal
89dd77aff2 fix(jobs): workflow bar stuck at Draft for confirmed/done jobs
Root cause: two compounding bugs in fp.job.workflow_state_id.
  - The "Confirmed" state seed has no trigger fields set, so it
    never passes _fp_is_passed_for_job.
  - The _compute_workflow_state_id loop breaks on the first
    non-passed state — so when Confirmed fails, every later
    state stays unevaluated and the bar is stuck at Draft.

Fixes:
  - Add trigger_on_job_state Selection field on fp.job.workflow.state
    with values confirmed/in_progress/done. Passes when fp.job.state
    >= the chosen value ("at least" semantics with explicit ordering
    that treats on_hold==in_progress and cancelled outside the
    progression). Lets workflow states key off the job's own state
    when recipe default_kind tagging isn't present.
  - Extend _fp_is_passed_for_job with the new branch.
  - Change _compute_workflow_state_id from first-non-pass-breaks to
    highest-passed-wins. Untagged/not-applicable states no longer
    block the cascade — the bar shows the furthest milestone the
    job has actually reached.
  - Seed update (DB-side, since data is noupdate=1): Confirmed now
    has trigger_on_job_state='confirmed'.

Result: Work Order # 00011 (state=confirmed, all 11 steps done/
skipped) now correctly shows the bar at "Done" instead of "Draft"
(via the existing trigger_all_steps_done on Done). Mid-flight
confirmed jobs without recipe tagging will show at least
"Confirmed" now.

Recipe authoring note (out of scope here): for accurate Received /
In Progress / Inspected intermediate states, recipe nodes still need
default_kind tagging (receiving / wet|bake|mask|rack / final_inspect).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:05:05 -04:00
gsinghpal
1c68fd0555 fix(jobs): auto-resync step.duration_actual on timelog edits
When a supervisor edits a timelog's date_started/date_finished (or
deletes a stale timelog), the parent step's "Actual Min" column
was showing stale data — duration_actual is a regular Float set
once by button_finish.

Adds:
- fp.job.step._fp_resum_duration_actual: quiet helper that re-sums
  duration_actual from time_log_ids.duration_minutes. Skip no-op
  updates so write traffic is minimised.
- fp.job.step.timelog.create/write/unlink hooks: call the helper
  on the affected parent step(s) so duration_actual stays
  consistent. Write hook only fires when date_started/date_finished/
  step_id changed (notes edits skip resync). step_id reassignment
  resyncs both old and new parent.
- Existing action_recompute_duration_from_timelogs (manual button)
  still posts a chatter entry for audit-trail use cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:53:45 -04:00
gsinghpal
b0070afc1b feat(jobs): step qty gate + partial-qty + display rename
Three coupled shop-floor corrections:
- fp.job._compute_display_name: renders "Work Order # 00011" in
  form header, breadcrumbs, M2O dropdowns, and error messages.
  DB name stays as WH/JOB/00011 - existing chatter/cert/delivery
  references unchanged.
- fp.job.step.button_finish: refuses if qty_at_step > 0 AND a
  downstream pending/ready step exists. Last runnable step is
  exempt (parts complete in place). Manager bypass via
  fp_skip_qty_gate=True context key.
- fp.job.step.action_complete_one_to_next: new per-row button
  "Complete 1 -> Next" for streaming flow (large parts going
  one-by-one). Records move(qty=1) to next step; if drain takes
  qty_at_step to 0, auto-finishes source + auto-starts destination
  via existing action_finish_and_advance.
- fp.job.step._fp_record_one_piece_auto_move: auto-move shim
  wired into action_finish_and_advance. qty=1 + downstream =>
  silently record move(1). qty>1 + downstream => raise pointing
  at Complete 1 -> Next. Last step always allowed.
- 16 new TestQtyGate tests covering gate / shim / auto-finish /
  last-step exemption / display rename / Move wizard zero-qty.

Spec: docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md
Plan: docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:31:56 -04:00
gsinghpal
9e39e41b0d docs: step qty gate + display rename implementation plan
7-task plan: display rename (compute + view), qty gate on
button_finish with last-step exemption, action_complete_one_to_next
row button, auto-move shim on Finish & Next, view additions,
end-to-end smoke test, and repo sync-back.

14 unit tests in the existing TestQtyGate class covering all five
state-machine branches plus display-name format and Move wizard
zero-qty regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:10:58 -04:00
gsinghpal
f4c41de91c docs: step qty gate + partial-qty + display rename design spec
Three coupled shop-floor corrections:
1. Job display rename: WH/JOB/00011 -> Work Order # 00011
   via display_name compute (name stays stable for DB refs)
2. Quantity gate on button_finish: refuses if qty_at_step > 0
   AND there is a downstream pending/ready step (last step exempt)
3. Partial-qty UX: new action_complete_one_to_next per-row button
   for streaming flow; auto-move shim on Finish for 1-of-1; Move
   wizard unchanged (already has zero-qty + over-qty guards)

Spec covers architecture, state transitions, test plan,
files-touched matrix, and explicit Out of Scope (qty_done auto-tick,
per-step scrap, cert PDF display).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:07:24 -04:00
gsinghpal
913311653f feat(jobs+certs): milestone-cascade Phase 1 + session patch catch-up
Implements the milestone-cascade design (Phase 1) and catches the
fusion_plating_jobs / fusion_plating_certificates source up to entech.

Milestone cascade (this PR's core):
- fp.job: new computes all_steps_terminal, next_milestone_action,
  next_milestone_label; dispatcher action_advance_next_milestone with
  3 helpers (_action_open_draft_certs, _action_open_draft_delivery,
  _action_mark_active_delivery_delivered); _resolve_required_cert_types
  resolver; _fp_create_certificates rewritten to honour
  part.certificate_requirement + partner flags + loop over resolved
  cert types
- fp.job.workflow.state: new trigger_on_delivery_state Boolean;
  _fp_is_passed_for_job extended with delivery-state branch;
  Shipped state seed reroutes from default_kind=ship to the new trigger
- View: hide Finish & Next when all_steps_terminal; add 4 mutually-
  exclusive milestone buttons (Mark Job Done / Issue Certs / Schedule
  Delivery / Mark Shipped) bound to one dispatcher
- Cert gate (fusion_plating_certificates/models/fp_delivery.py):
  action_mark_delivered hard-blocks on draft certs; manager bypass
  via fp_skip_cert_gate=True context key
- 24 unit tests in test_fp_job_milestone_cascade.py covering computes,
  resolver, dispatcher, cert gate
- Spec: docs/superpowers/specs/2026-05-12-job-milestone-cascade-design.md
- Plan: docs/superpowers/plans/2026-05-12-job-milestone-cascade.md

Other entech changes caught up in this sync (from earlier session
patches not previously committed):
- fp.job version bump series 18.x → 19.0
- res_users_views.xml addition (signature widget in user prefs)
- racking inspection smart button removal
- various view/manifest touch-ups

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:40:25 -04:00
gsinghpal
1c1f517847 docs: job milestone cascade implementation plan (Phase 1)
10-task plan implementing the milestone cascade design — bite-sized
steps with exact code, deployment commands, and verification. Covers
compute fields, dispatcher, cert resolver + auto-create rewrite,
workflow trigger reroute, view swap, cert gate, e2e smoke test, and
repo sync-back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:07:16 -04:00
gsinghpal
b2592d70f8 docs: job milestone cascade design spec (Phase 1)
Replaces per-step Finish & Next with a context-aware milestone-advance
button cycling Mark Job Done → Issue Certs → Schedule Delivery →
Mark Shipped. Architecture, cascade, gates, files-touched, and the
cert-gate hard-block decision are all captured for implementation
planning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:01:10 -04:00
gsinghpal
03f14c2c40 changes 2026-05-11 17:57:04 -04:00
2887 changed files with 298646 additions and 5674 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -83,6 +83,24 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
- Local URL: http://localhost:8069
- Test before deploying. Edit existing files — don't create unnecessary new ones.
## PDF Preview — Prefer fusion_pdf_preview Over Downloads/New-Tab
When a Python action opens an attachment, route it through `fusion_pdf_preview` instead of returning `ir.actions.act_url` with `download=true` or `target=new`. The preview dialog gives operators preview + print + download in one place and writes an audit log; non-PDF attachments fall back to the legacy download path automatically.
The drop-in replacement is the new helper on `ir.attachment`:
```python
return att.action_fusion_preview(title='My Doc')
# vs. the old pattern:
# return {'type': 'ir.actions.act_url',
# 'url': '/web/content/%s?download=true' % att.id,
# 'target': 'new'}
```
The helper auto-detects mimetype: PDFs go to the dialog, everything else (ZPL, CSV, XML, images) stays on download. So a callsite that today serves CSV today and a PDF tomorrow doesn't need a code change — same call, different routing.
If you need to invoke the client action directly (rare — only when you don't have a recordset handy), the tag is `fusion_pdf_preview.open_attachment` and the params are `{attachment_id, title, model_name, record_ids, report_name}`. See `fusion_pdf_preview/static/src/js/open_attachment_action.js`.
Existing reports (`ir.actions.report` of type `qweb-pdf`) are intercepted automatically by `fusion_pdf_preview/static/src/js/pdf_preview.js`; the helper above is for the *other* pattern — attachments opened by custom buttons.
## Supabase Knowledge Base
Before starting unfamiliar work, check Supabase for context:
```bash

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,552 @@
# Nexa Systems Inc — Chart of Accounts & Accounting Setup Design
**Date**: 2026-05-12
**Target**: odoo-nexa production instance, database `nexamain`
**Status**: Design — pending implementation plan
## 1. Context
Nexa Systems Inc is a Canadian CCPC providing IT services: custom software development, custom ERP, business apps, hosting, custom websites, and custom web apps. Operations are Canada-wide with planned global expansion. Workforce: solo founder today (Gurpreet, Canadian), hiring plan favours Canadian T4/T4A with occasional India contractors for burst capacity. Nexa will pursue SR&ED tax credits.
**Current state (as of 2026-05-12)**:
- Odoo 19 Enterprise, l10n_ca localization loaded
- 426 GL accounts (most unused — generic Canadian template bloat)
- 49 active taxes with duplicates
- 14 journals incl. 7 bank accounts (overprovisioned)
- 776 journal entries, 125 invoices, data 2020-01-01 to 2026-05-04
- **Historical Odoo data is NOT authoritative** — accountant has filed externally on Excel-based records. Past will be reconciled later.
- All prior years filed with CRA. Fiscal year-end Dec 31.
**CRA registration & filing cadence**:
- **Business Number / HST account**: `741224877` (currently stored as 9-digit BN root only on company record; needs to be updated to full 15-char format `741224877 RT0001` for Odoo's Canadian tax reports to validate cleanly).
- **GST/HST filing**: annual. Return due **3 months after fiscal year-end** (March 31).
- **T2 corporate income tax filing**: annual. Return due **6 months after fiscal year-end** (June 30). Balance owing due 3 months after year-end (March 31) for CCPCs eligible for SBD; 2 months otherwise.
- **HST instalments**: annual filers must remit quarterly instalments if their net tax for the prior year was ≥ $3,000. Track via account 118200 GST/HST Instalments Paid.
- **T2 instalments**: monthly or quarterly instalments required if Part I tax owing in prior year ≥ $3,000.
**Goals**:
1. **CRA compliance** — clean tax handling, T2 Schedule 125 alignment, audit-ready
2. **Tax savings** — SR&ED claim infrastructure from day 1, zero-rated export handling, CCA structure
3. **Automation** — fiscal positions, default accounts, bank feeds, subscription billing
4. **Ease of use** — invoicing is one-click after customer/product selection
**Scope**: Chart of accounts structure + tax/fiscal-position setup + analytic plans + automation hooks. **Out of scope**: bank feed onboarding (separate sub-project), CCA custom module (defer until volume warrants), historical data reconciliation (separate sub-project when accountant records arrive).
## 2. Approach
**Approach #2 — Hybrid**: keep l10n_ca's 6-digit code scheme (Canadian accountants recognize it), aggressively curate (~370 unused accounts archived, ~20 renamed, ~70 added), supplement with three analytic plans for finer reporting without GL proliferation.
**Rejected alternatives**:
- *Surgical* — keep all 426 accounts unchanged. Rejected: bookkeeping burden, no IT-services shape.
- *Clean slate (custom 4-digit)* — toss l10n_ca. Rejected: accountants would have to learn it; loses pre-mapped CRA tax structure.
## 3. Code Skeleton
```
1xxxxx ASSETS
111xxx Cash & cash equivalents
112xxx Accounts receivable
113xxx Prepaid expenses
114xxx Other current assets
115xxx Due from shareholder / related parties
118xxx Tax assets (HST ITC, instalments)
151xxx Capital assets — cost
154xxx Accumulated depreciation (contra)
2xxxxx LIABILITIES
211xxx Accounts payable
213xxx HST/GST/QST collected
214xxx Net tax payable
215xxx Source deductions payable
216xxx Corporate income tax payable
221xxx Due to shareholder
222xxx Due to related parties
251xxx Long-term debt
3xxxxx EQUITY
311xxx Share capital + contributed surplus
321xxx Retained earnings + dividends
4xxxxx REVENUE (by service line — jurisdiction handled by tax codes, not by account)
411xxx Recurring revenue (SaaS, hosting, support)
412xxx Project revenue (custom dev, web app, website, ERP)
413xxx Services (consulting, training, support hourly)
414xxx Reseller revenue (third-party software/hardware)
419xxx Sales adjustments (discounts, returns, bad debt recovery)
5xxxxx DIRECT COSTS (COGS)
511xxx Infrastructure & hosting costs
512xxx Project direct costs (subcontractors, project software, project travel)
513xxx Cost of resold goods
519xxx COGS adjustments
6xxxxx OPERATING EXPENSES
611xxx Personnel — internal staff (T4)
612xxx Personnel — contract (T4A non-project)
621xxx Office & facilities
631xxx Technology — operating (internal SaaS subs)
641xxx Marketing & sales
651xxx Professional fees
661xxx Insurance
671xxx Travel & entertainment
681xxx Training & development
691xxx Banking & finance charges
699xxx Other (bad debt, donations, fines, FX losses, depreciation)
7xxxxx Other income (interest, FX gains)
8xxxxx Other expenses (rare; mostly absorbed in 691/699)
```
**Three analytic plans** (orthogonal tagging, applied per journal line):
| Plan | Required On | Purpose |
|---|---|---|
| **Project** | revenue, COGS, project costs | Project P&L, customer profitability, WIP, billable-hour realization |
| **Department** | payroll, OpEx | Departmental P&L, overhead allocation |
| **SR&ED Tag** | labour, contractors, materials (R&D) | T661 SR&ED claim — eligibility classification |
## 4. Revenue Accounts (4xxxxx)
```
Recurring Revenue
411100 SaaS Subscription Revenue
411200 Hosting & Infrastructure Revenue
411300 Support & Maintenance Contracts
411400 Domain/SSL/Renewal Pass-through Revenue
411500 Setup / Onboarding Fees
Project Revenue (one-time, milestone-billed)
412100 Custom Software Development
412200 Custom Web Application Development
412300 Custom Website Development
412400 ERP Implementation & Customization
412500 Mobile App Development ← reserved for future
412600 Business App / Integration Work
Services (hourly, retainer)
413100 Consulting & Advisory
413200 Training & Workshops
413300 Technical Support — Per-incident / Hourly
Reseller / Pass-through
414100 Third-party Software Resale (M365, Adobe)
414200 Hardware Resale
Adjustments (contra-revenue)
419100 Sales Discounts
419200 Sales Returns & Refunds
419300 Bad Debt Recovery
```
**Design rule**: one revenue account per service line. Jurisdiction (ON/Atlantic/QC/export/etc.) tracked entirely through tax codes and fiscal positions, NOT duplicate accounts.
## 5. Direct Costs / COGS (5xxxxx)
```
Infrastructure & Hosting
511100 Cloud Infrastructure (AWS, Hetzner, OVH, DigitalOcean, Linode)
511110 CDN & Edge Services (Cloudflare, Fastly)
511120 Backup & Storage Services
511130 Database & Backend Services (Supabase, hosted Postgres, Redis)
511140 Monitoring & Observability (customer-facing only)
511150 SSL Certificates & Domains (wholesale for resale)
511160 DNS & Email Hosting (wholesale)
Third-party APIs & Per-transaction Costs
511200 Third-party API Costs (Twilio, SendGrid, OpenAI)
511210 Per-customer Licensing & Royalties
Note: 511100511160 are shared between SaaS revenue (411100) and Hosting revenue (411200).
Allocation to specific revenue line happens via the Project analytic plan, not separate accounts.
Project Direct Costs
512100 Subcontracted Labour — Canadian (T4A) ← SR&ED-eligible
512110 Subcontracted Labour — Foreign ← NOT SR&ED-eligible
512200 Project-specific Software & Licenses
512300 Project Travel & Onsite (rebilled)
512400 Project Hardware (passed through)
Resold Goods & Services
513100 Cost of Software Resold
513200 Cost of Hardware Resold
Adjustments
519100 COGS Adjustments / Write-offs
```
**Design choices**:
- **Salaries in OpEx, not COGS** — keeps SR&ED tracking clean; allocation to projects via Project analytic plan.
- **Stripe/merchant fees in OpEx (691200)** — re-class to COGS later if SaaS revenue dominates.
- **Canadian vs Foreign subcontractor split** — critical for SR&ED (80% × 35% = 28% credit on CA arm's length; 0% on foreign).
## 6. Operating Expenses (6xxxxx)
```
Personnel — Internal Staff (T4)
611100 Salaries & Wages — Development ← SR&ED-eligible base
611200 Salaries & Wages — Sales & Marketing
611300 Salaries & Wages — Admin & Operations
611400 Salary — Shareholder/Officer (Gurpreet) ← 75% SR&ED cap (specified employee)
611500 Employer CPP / QPP Contributions
611600 Employer EI Premiums
611700 Employer Health Tax (EHT/QHST)
611800 WCB / WSIB Premiums
611900 Employee Benefits (health, dental, group)
611950 Bonuses & Incentives
611960 Vacation Pay Accrual
Personnel — Contract (non-project)
612100 Contract Labour — Canadian (admin/marketing/freelance)
612200 Contract Labour — Foreign
Office & Facilities
621100 Rent — Commercial Office
621200 Home Office — Business Portion ← own account; allocated %
621300 Utilities — Commercial
621400 Internet & Phone — Business
621500 Office Supplies & Consumables
621600 Cleaning & Maintenance
621700 Office Snacks & Refreshments
Technology — Operating
631100 Software — Productivity (M365, Slack, Notion, Linear, GitHub)
631200 Software — Development Tools (Cursor, Figma, IDEs)
631300 Software — Internal Infrastructure
631400 Software — Security & IT
631500 Software — Sales & Marketing
Marketing & Sales
641100 Advertising — Digital Ads
641200 Advertising — Content / SEO
641300 Trade Shows & Conferences
641400 Promotional Items / Branded Swag
641500 Website — Own (nexasystems.ca)
Professional Fees
651100 Legal Fees — General
651200 Accounting & Bookkeeping
651300 Tax Preparation (T2, T1, GST/HST)
651400 Business Consulting
Insurance
661100 Commercial General Liability
661200 Professional Liability / E&O
661300 Cyber Liability
661400 Property Insurance
661500 Directors & Officers Insurance
Travel & Entertainment
671100 Travel — Flights, Hotels, Ground Transport
671200 Meals & Entertainment — 50% Deductible ← own account; 50% adjustment at year-end
671300 Vehicle — Operating
671400 Mileage Reimbursement — Personal Vehicle
Training & Development
681100 Conferences & Seminars
681200 Courses & Certifications
681300 Books & Publications
681400 Professional Memberships & Dues
Banking & Finance
691100 Bank Service Charges
691200 Merchant Processing Fees (Stripe, PayPal, Square)
691300 Wire Transfer & FX Fees
691400 Interest Expense — Bank Loans / LOC
691500 Interest Expense — Credit Cards
691600 Late Payment Penalties — Non-deductible
Other
699100 Bad Debt Expense
699200 Donations & Sponsorships
699300 Penalties & Fines — Non-deductible
699400 Realized FX Losses
699500 Depreciation / CCA Expense
```
**Notable design decisions**:
- Salaries split by function (dev/sales/admin) — so SR&ED proxy applies cleanly to dev only.
- Owner/Shareholder salary isolated (611400) — for T2 Schedule 11 (Compensation of Officers) and CRA reasonableness defence.
- Non-deductible items isolated (691600, 699300) — prevents accidental deduction.
- Meals & Entertainment own account (671200) — accountant applies the 50% adjustment cleanly.
- Home office own account (621200) — business-use % applied to the whole account.
## 7. Capital Assets & CCA (1xxxxx + asset module)
```
Capital Assets — Cost
151100 Computer Hardware & Equipment (CCA Class 50, 55% DB)
151200 Office Furniture & Equipment (CCA Class 8, 20% DB)
151300 Vehicles (CCA Class 10 / 10.1)
151400 Leasehold Improvements (CCA Class 13, SL)
151500 Acquired Software/Intangibles (CCA Class 14.1, 5% DB)
151600 Tools & Small Equipment <$500 (CCA Class 12, 100% Y1)
Accumulated Depreciation (contra)
154100 Acc. Dep — Computer Hardware
154200 Acc. Dep — Office Furniture
154300 Acc. Dep — Vehicles
154400 Acc. Dep — Leasehold Improvements
154500 Acc. Dep — Acquired Software
```
**Asset model approach**: book straight-line depreciation in Odoo for financial reporting (clean monthly journal); maintain CCA schedule separately for T2 filing. CCA rates: Class 50 effective 82.5% Y1 (with AccII through 2027); Class 14.1 software 100% Y1; Class 12 small tools 100% Y1.
## 8. Tax Accounts (1xxxxx + 2xxxxx)
```
Tax Assets
118100 HST/GST Input Tax Credit (ITC) Receivable
118200 HST/GST Instalments Paid
118300 QST Input Tax Refund Receivable
Tax Liabilities
213100 HST/GST Collected on Sales ← single bucket; tax report breaks down by code
213500 QST Collected
214100 Net HST/GST Payable
215100 Source Deductions Payable — Federal Tax
215200 Source Deductions Payable — CPP
215300 Source Deductions Payable — EI
216100 Corporate Income Tax — Federal Payable
216200 Corporate Income Tax — Provincial Payable
216300 Corporate Tax Instalments Paid (contra)
```
## 9. Shareholder, Associated Corporations & Equity
**Associated corporations** (Gurpreet >25% owner of each → ITA s.256 associated group):
- Nexa Systems Inc (this company)
- Westin Healthcare Inc
- Divine Mobility Inc
**Treatment**: Westin and Divine are **regular Customers and Vendors of Nexa**, NOT slush accounts. Their transactions flow through normal AR/AP. They get partner records tagged `Related Party — Associated Corporation` for disclosure tracking. The "Due To/From Related Party" GL buckets exist only for true intercompany loans (cash moved between the corps' bank accounts without an invoice).
```
Due From — Assets
115100 Due From Shareholder — Gurpreet
115900 Due From Associated Corporations (intercompany loans only — NOT customer AR)
Due To — Liabilities
221100 Due To Shareholder — Gurpreet (short-term, <1 year)
221200 Shareholder Loan — Gurpreet (long-term, with commercial terms)
222900 Due To Associated Corporations (intercompany loans only — NOT vendor AP)
Equity
311100 Share Capital — Common Shares
311200 Share Capital — Preferred Shares (placeholder)
311300 Contributed Surplus
321100 Retained Earnings — Current Year
321200 Retained Earnings — Prior Years
321900 Dividends Declared (contra)
```
**Partner setup** (under Contacts, not GL accounts):
- `Westin Healthcare Inc` → partner with both Customer and Vendor flags; tagged `RP-Associated`
- `Divine Mobility Inc` → partner with both Customer and Vendor flags; tagged `RP-Associated`
- Nexa invoices Westin/Divine like any client → AR in 112xxx, revenue in 4xxxxx, HST 13% (Ontario)
- Westin/Divine bill Nexa → AP in 211xxx, expense in 6xxxxx / COGS in 5xxxxx
**Intercompany compliance flags (CRITICAL — drives major tax decisions)**:
1. **Small Business Deduction (SBD) sharing — ITA s.125(5.1)**: The $500k federal SBD limit is **shared across all associated corporations**. If Nexa, Westin, and Divine are each profitable, they collectively get **one** $500k pool, not three. The corps must file Schedule 23 (T2) allocating the limit. Strategy: allocate the limit to whichever corp has the highest taxable income each year.
2. **SR&ED expenditure limit shared — ITA s.127(10.2)**: The $3M expenditure limit for the 35% refundable ITC is also shared across the associated group. Same Schedule 23 mechanism. Nexa being the dev shop probably consumes most/all of it.
3. **Transfer pricing — ITA s.247**: Services between related corps must be priced at fair market value. Nexa invoicing Westin at $50/hr while billing arm's-length clients $150/hr will be scrutinized. Document the rate methodology. Penalty for non-compliance is 10% of the adjustment.
4. **Subsection 15(2) shareholder loans**: outstanding >1 year past FY end → taxable to Gurpreet personally.
5. **T2 Schedule 9** (Related and Associated Corporations) must be filed by Nexa listing Westin and Divine.
6. **GAAR risk**: aggressive intercompany pricing or loan arrangements designed primarily for tax benefit can be challenged under general anti-avoidance rules.
## 10. Analytic Plans
### 10.1 Project Plan
- One analytic account per customer engagement
- Naming: `PRJ-{YYYY}-{CUST}-{SHORTNAME}` (e.g., `PRJ-2026-WESTIN-ERP`)
- Required on revenue, COGS, project costs
- Linked to Odoo Project module for time tracking → automatic GL posting
### 10.2 Department Plan
- `DEPT-DEV` — Development
- `DEPT-SALES` — Sales & Marketing
- `DEPT-ADMIN` — Admin & Operations
- `DEPT-HOSTING` — Hosting Operations (optional future split)
- Required on payroll, OpEx
### 10.3 SR&ED Tag Plan
- `SRED-T4-DEV-SALARY` — T4 dev employees on R&D (full proxy 55%)
- `SRED-SPECIFIED-EMPLOYEE` — Gurpreet/officers (75% basic salary cap)
- `SRED-CONTRACTOR-CA-ARM-LENGTH` — Canadian arm's length (80% eligible)
- `SRED-CONTRACTOR-CA-NON-ARM-LENGTH` — affiliated CA contractors
- `SRED-MATERIALS-CONSUMED` — R&D materials
- `SRED-OVERHEAD-PROXY-BASIS` — direct labour basis
- `NOT-ELIGIBLE` — default
**T661 generation at year-end**: filter analytic report on SR&ED tag → eligible salaries + 55% proxy + 80% contractor + materials = total qualified expenditures × 35% refundable ITC.
## 11. Tax Setup & Fiscal Positions
**Consolidated active taxes** (~14, down from 49):
| Tax | Rate | Sale / Purchase | Applies |
|---|---|---|---|
| HST 13% Ontario | 13% | Both | ON |
| HST 15% Atlantic | 15% | Both | NB, NS, PE, NL |
| GST 5% | 5% | Both | AB, MB, SK, BC, YT, NT, NU |
| GST 5% + PST 7% BC | 12% group | Both | BC (goods, rare for services) |
| GST 5% + PST 7% MB | 12% group | Both | MB |
| GST 5% + PST 6% SK | 11% group | Both | SK |
| GST 5% + QST 9.975% QC | 14.975% group | Both | QC |
| Zero-rated Export | 0% | Sale | US, EU, ROW |
| Tax Exempt | 0% | Sale | Cert-holders |
**Fiscal Positions** (auto-applied based on customer billing address):
| Position | Customer Location | Auto-Substitute Default Tax |
|---|---|---|
| CA — Ontario (default) | ON | HST 13% |
| CA — Atlantic | NB/NS/PE/NL | HST 15% |
| CA — Quebec | QC | GST 5% + QST 9.975% |
| CA — BC | BC | GST 5% (PST per-product) |
| CA — Prairies / Territories | AB/MB/SK/YT/NT/NU | GST 5% |
| Export — US | United States | 0% Zero-rated |
| Export — International | Outside CA/US | 0% Zero-rated |
| Tax Exempt | Tagged customers | 0% |
**Invoice flow**: customer → fiscal position auto-applies → product picks default tax → fiscal position substitutes → no manual tax decisions.
**Export advantage**: zero-rated sales charge no HST but retain ITC claims on all related inputs. For a small shop with 30% US revenue, this is ~$515k/year in recovered HST.
## 12. Cleanup Plan
### Phase 1 — Archive (~370 accounts)
- Every l10n_ca account NOT in the keep-list (built from Sections 49).
- Constraint: Odoo blocks archiving accounts with postings. Archive zero-history only.
- Accounts with history we no longer want: stop posting; they go to $0 going forward.
### Phase 2 — Rename (~20 accounts)
| Old | New |
|---|---|
| 1400 Transferred to Gurpreet | 221100 Due To Shareholder — Gurpreet |
| 1505 Sent to India | 612200 Contract Labour — Foreign |
| 1580 Transferred to Westin | ARCHIVE — Westin is an associated corp, future transactions go through normal AR/AP via partner record `Westin Healthcare Inc` |
| 1590 Transferred to Divine | ARCHIVE — Divine is an associated corp, future transactions go through normal AR/AP via partner record `Divine Mobility Inc` |
| 1600 Transferred to Manpreet | ARCHIVE — Manpreet is an employee of another company, not a related party of Nexa; historical transactions to be re-classified by accountant during reconciliation |
| 1500 Food & Entertainment | 671200 Meals & Entertainment — 50% Deductible |
| 1501 Office Expenses | 621500 Office Supplies & Consumables |
| 411000 Inside Sales | ARCHIVE (replaced by 412xxx) |
| 412000 Harmonized Provinces Sales | ARCHIVE (jurisdiction = tax codes) |
| 413000 Non-Harmonized Provinces Sales | ARCHIVE |
| 414000 International Sales | ARCHIVE |
| 12000 Abdul & Future Mobility | ARCHIVE (use partner subledger) |
| 12001 MSI Account | ARCHIVE |
| 110010 Bank Fee | 691100 Bank Service Charges |
| 511100 Inside Purchases | ARCHIVE |
### Phase 3 — Add (~70 new accounts)
All per Sections 49.
### Phase 4 — Bank Consolidation
Current 8 bank journals (BMO, RBC, RBC VISA, Scotia ×3, Bank, Cash). Audit; archive inactive. Target: ≤5 active (primary operating, USD for future global, LOC, 12 credit cards).
### Phase 5 — Lock Prior Periods
Set `fiscalyear_lock_date = 2025-12-31`. Blocks postings to closed periods. Forces all 2026 work into new structure.
## 13. Automation Hooks
### Product Categories with Default Accounts
| Product Category | Default Income | Default COGS | Default Tax |
|---|---|---|---|
| Services / SaaS Subscription | 411100 | — | per fiscal position |
| Services / Hosting | 411200 | — | per fiscal position |
| Services / Support Contract | 411300 | — | per fiscal position |
| Services / Custom Software Dev | 412100 | — | per fiscal position |
| Services / Web App Dev | 412200 | — | per fiscal position |
| Services / Website Dev | 412300 | — | per fiscal position |
| Services / ERP Implementation | 412400 | — | per fiscal position |
| Services / Consulting | 413100 | — | per fiscal position |
| Services / Training | 413200 | — | per fiscal position |
| Services / Setup Fee | 411500 | — | per fiscal position |
| Resale / Software | 414100 | 513100 | per fiscal position |
| Resale / Hardware | 414200 | 513200 | per fiscal position |
### Bank Reconciliation Rules
| Pattern (description contains) | Auto-categorize To | Tax |
|---|---|---|
| `AMAZON WEB SERVICES`, `AWS` | 511100 Cloud Infrastructure | HST 13% ITC |
| `HETZNER`, `OVH`, `DIGITALOCEAN`, `LINODE` | 511100 | 0% foreign |
| `CLOUDFLARE`, `FASTLY` | 511110 CDN | mixed |
| `GITHUB`, `JETBRAINS`, `CURSOR` | 631200 Software — Dev Tools | HST 13% ITC |
| `MICROSOFT`, `SLACK`, `NOTION`, `LINEAR` | 631100 Software — Productivity | HST 13% ITC |
| `STRIPE PAYOUT` | AR receipts journal | — |
| `STRIPE FEE` | 691200 Merchant Processing | exempt |
| `GOOGLE ADS`, `LINKEDIN ADS` | 641100 Advertising | HST 13% ITC |
### Bank Feeds (Plaid via Odoo Enterprise)
Daily auto-import → bank reconciliation rules → ~70% of transactions auto-categorized.
### Subscription Module
Already installed. Use for SaaS/Hosting/Support contracts: recurring invoices, Stripe auto-charge, MRR/ARR/churn dashboards.
### Default Journals
- Customer Invoices → `INV`
- Vendor Bills → `BILL`
- Bank feeds → respective bank journals
- HR Expenses → `EXP` (add if missing)
- Misc → `MISC`
- Exchange Difference → `EXCH`
## 14. Out-of-Scope (Future Sub-Projects)
- **Historical reconciliation** — load accountant's Excel records into new structure (requires accountant docs).
- **Custom CCA module** — only if asset count grows; until then, accountant maintains CCA schedule separately.
- **Multi-currency setup** — add USD bank + currency-rate-live config when first US client signs.
- **Payroll system** — when first T4 employee is hired; integrate with Wagepoint/Payworks/ADP or Odoo Payroll.
- **Approval workflows** — purchase approval, expense approval limits.
- **Inventory** — N/A unless reselling hardware regularly.
## 15. Tax-Saving Opportunities Enabled
| Opportunity | Mechanism | Estimated Annual Value | Notes |
|---|---|---|---|
| SR&ED ITC | Analytic SR&ED tag + T661 filing | $30k$100k (refundable) | **$3M expenditure limit SHARED across Nexa/Westin/Divine — allocate to Nexa via S23** |
| Zero-rated exports | Fiscal position for US/international | $515k recovered HST on inputs | Per-company |
| Small Business Deduction (SBD) | Federal 9% on first $500k taxable income | ~$30k/yr if hitting threshold | **$500k limit SHARED across associated group — allocate to highest-income corp via S23** |
| CCA Class 50 + AccII | 82.5% Y1 deduction on computers/servers | Time-value, front-loads deductions | Per-company |
| Quick Method GST/HST | If <$400k sales, simpler method | $5002k/yr cash if eligible | **LIKELY UNAVAILABLE — Quick Method $400k threshold applies to associated-group totals; Nexa + Westin + Divine combined revenue probably exceeds limit. Re-verify with accountant.** |
| OIDMTC (Ontario Interactive Digital Media) | If building interactive media products | 3540% of eligible labour | Strict eligibility test; need to verify product fits |
| Apprenticeship Job Creation TC | 10% of eligible apprentice wages, max $2k/yr per apprentice | Per apprentice hired | Activates when first apprentice T4 employee hired |
| Intercompany cost recovery | Bill associated corps for shared services (back-office, hosting, IT) | Allocates expenses to highest-tax-rate corp | Requires arm's-length pricing documentation |
## 16. Risks & Open Questions
1. **Associated corporation tax planning** — Westin Healthcare Inc, Divine Mobility Inc, and Nexa Systems Inc share the $500k SBD limit and the $3M SR&ED expenditure limit. Yearly Schedule 23 allocation decision needs accountant input. Recommendation: allocate SR&ED limit primarily to Nexa (dev shop); allocate SBD to whichever corp has highest taxable income each year.
2. **Transfer pricing on intercompany services** — Nexa billing Westin/Divine must be at fair market value. Document hourly rate methodology and apply consistently across all clients. Penalty: 10% of any adjustment.
3. **Past data backposting** — once accountant records arrive, mapping old transactions into new structure requires care to avoid breaking the post-2025-12-31 lock.
4. **BC PST on software services** — BC PST exempts custom software developed for a specific customer; off-the-shelf software and certain SaaS subscriptions ARE taxable. For Nexa's mix (most work is custom dev = exempt; SaaS sold off-the-shelf to BC customers = taxable at 7%), each BC customer/product combo needs review. Default to "GST only" for custom dev; flag SaaS-to-BC for review at first sale.
5. **Quebec QST registration** — required if Nexa has QC customers and revenue >$30k. Confirm registration status. If not yet registered and you start taking QC clients, registration with Revenu Québec is separate from CRA.
8. **HST filing cadence review** — currently annual. Once revenue clears $1.5M (combined Nexa-only, not associated group), CRA may auto-move you to **quarterly** filing. Monitor and update filing cadence in tax report config when it happens.
6. **Specified employee SR&ED math** — Gurpreet's salary cap is 75%, no bonus inclusion. Accountant must apply at T661 time.
7. **Multi-company Odoo (future sub-project)** — Westin and Divine currently run on separate Odoo databases (odoo-westin, odoo-mobility). Future option: migrate all three into one multi-company nexamain database to enable auto-mirrored intercompany invoices (Nexa invoices Westin → auto-creates Bill in Westin's books). Major data-migration effort; only worth it once intercompany volume justifies the effort.
## 17. Acceptance Criteria
- [ ] All 11 sections of CoA approved and present in odoo-nexa nexamain DB
- [ ] ≥370 unused accounts archived
- [ ] 14 active taxes (down from 49)
- [ ] 8 fiscal positions configured with auto-detection
- [ ] 3 analytic plans created (Project, Department, SR&ED Tag) with seed analytic accounts
- [ ] Product categories created with default accounts
- [ ] Bank reconciliation rules created
- [ ] Fiscal year locked at 2025-12-31
- [ ] Company HST/BN number stored in full 15-char form (`741224877 RT0001`)
- [ ] HST report config set to **annual filer**, fiscal-year-end Dec 31, deadline March 31
- [ ] Westin Healthcare Inc and Divine Mobility Inc partner records created with Customer + Vendor flags, tagged `RP-Associated`
- [ ] Test invoice flows through correctly for: ON customer (HST 13%), US customer (Zero-rated), QC customer (GST+QST)
- [ ] Test vendor bill creates correct ITC for: Canadian vendor (HST ITC), foreign vendor (no ITC)
- [ ] Test intercompany invoice: Nexa → Westin generates proper AR + 13% HST collected (Westin is Ontario-based)
- [ ] Bank consolidation complete; ≤5 active bank journals

View File

@@ -0,0 +1,300 @@
# NFC Clock Kiosk — Design
**Date:** 2026-05-13
**Module:** `fusion_clock`
**Status:** Approved design — pending implementation plan
**Pilot scope:** 1 station per company
## Problem
`fusion_clock` already supports shared-device clock-in/out via a PIN kiosk at `/fusion_clock/kiosk`. Shop-floor employees find name search + PIN entry slow, and shared PINs make buddy-punching trivial. The company is rolling out Ubiquiti UniFi Access NFC readers for door entry, so every employee already carries an NFC card. We want a "tap-and-go" kiosk that:
- Takes ~2 seconds (vs ~10 seconds for name search + PIN)
- Reuses the same physical Ubiquiti-issued card the employee uses for doors
- Works with gloves, dirty hands, or wet hands (touchscreens fail here)
- Captures a silent photo at every tap so managers can spot-check buddy-punching attempts
## Goals
1. **Tap-to-clock**: NFC card tap on a wall-mounted Android tablet → attendance state toggles in Odoo within ~1 second of the tap
2. **Single-credential**: same card the employee uses for door access also clocks them in
3. **Silent photo verification**: front camera snaps a frame on every tap; manager dashboard shows photos for spot-check
4. **Self-contained kiosk**: lockable into a single-purpose device, no escape, auto-restart on crash, no Odoo navbar visible
5. **Reuses existing fusion_clock backend**: geofencing, penalty rules, activity log, attendance lifecycle — all unchanged
6. **One-time setup**: enroll once, then employees never touch a setup flow again
## Non-goals
- Multi-station / multi-zone clocking (future — pilot is 1 station per company)
- Per-station geolocation (one location per company; tablet is implicitly at the company location)
- Offline mode (v1 fails loudly on network loss; offline replay is future work)
- Phone-as-credential support (NFC HCE on Android is fragile; iPhone NFC is closed)
- QR code alternate credential (deferred to v1.1 if iPhone-only employees push back)
- Native Android kiosk app (overkill for a 1-2 station pilot; Web NFC is sufficient)
## Architecture decision
**Option B: Separate kiosk page, shared backend.**
A new route `/fusion_clock/kiosk/nfc` and a new lean template optimized for tap-and-go. The new controller (`controllers/clock_nfc_kiosk.py`) calls into the existing `FusionClockAPI` helpers (`_verify_location`, `_attendance_action_change`, `_log_activity`, `_check_and_create_penalty`, `_apply_break_deduction`) so all geofencing/penalty/activity logic is shared with the PIN kiosk. The existing `/fusion_clock/kiosk` route is untouched.
**Why not extend the existing kiosk (Option A):** existing PIN kiosk page would get tap-mode JS interleaved with PIN-mode JS, increasing the regression surface for both modes.
**Why not native Android app (Option C):** maintaining a Kotlin app + Play Console signing/distribution doubles the dev effort for marginal UX gain. Web NFC + Chrome kiosk is production-proven (gyms, warehouses, healthcare check-in).
## Hardware decision
**Per company:** 1× Samsung Galaxy Tab Active 5 Pro (10.1") on an official Samsung Pogo charging dock, wall-mounted. Reasoning:
- Built-in NFC antenna on the back, dead-center
- IP68, MIL-STD-810H, drop-resistant (shop-floor durable)
- Replaceable battery (avoids battery-swelling failure mode in 24/7-tethered devices)
- Knox enables true kiosk lockdown
- Pogo dock = magnetic constant power, no cable to yank
- 10.1" screen visible from a few feet away (vs 8" on regular Active 5)
Cards: same Ubiquiti-issued NFC cards employees already carry. Web NFC reads the card's UID via `NDEFReader`'s `serialNumber` field, which works on raw MIFARE access cards even though they have no NDEF data.
## Data model
### `hr.employee` — new field
- `x_fclk_nfc_card_uid``Char`, indexed, unique constraint when not null
- Stores card UID as canonical hex (uppercase, colon-separated, MSB first), e.g., `04:A2:B5:62:C1:80`
- Editable by HR managers; visible on the employee form in the existing "Clock Settings" section near the existing PIN field
### `res.company` — new field
- `x_fclk_nfc_kiosk_location_id``Many2one` to `fusion.clock.location`
- Designates which fusion.clock.location is bound to the NFC kiosk for this company
- Required when `fusion_clock.enable_nfc_kiosk = True`; the tap endpoint returns `no_location_configured` if it's empty
- Editable in the NFC Clock Kiosk settings section (per-company since this is multi-company-aware)
### `hr.attendance` — new fields
- `x_fclk_check_in_photo``Binary`, `attachment=True`. Frame captured at clock-in.
- `x_fclk_check_out_photo``Binary`, `attachment=True`. Frame captured at clock-out.
- `x_fclk_clock_source` — extend existing `Selection` field to include `'nfc_kiosk'`.
### `ir.config_parameter` — new entries
- `fusion_clock.enable_nfc_kiosk` — Boolean, default `False`. Master switch.
- `fusion_clock.nfc_photo_required` — Boolean, default `True`. If False, photo is best-effort and tap still succeeds without one.
- `fusion_clock.nfc_enroll_password` — Char, default empty. Short password the manager types to enter Enroll Mode on the kiosk. If empty, falls back to manager-group membership of the kiosk service user.
- `fusion_clock.nfc_kiosk_debug` — Boolean, default `False`. Enables a hidden mock-tap keyboard shortcut for development.
### `res.config.settings` — new view section
"NFC Clock Kiosk" section in the Clock settings page exposing the four `ir.config_parameter` toggles above.
**No new models.** All data piggybacks on existing `hr.employee`, `hr.attendance`, `fusion.clock.activity.log`.
## Backend — controller and endpoints
**New file:** `controllers/clock_nfc_kiosk.py`
All endpoints under `/fusion_clock/kiosk/nfc/...`. All require `fusion_clock.group_fusion_clock_manager` on the logged-in kiosk service user. All gated on `fusion_clock.enable_nfc_kiosk == 'True'`.
**Kiosk service user:** an Odoo `res.users` record created per-company specifically for the tablet to log in as. Member of `fusion_clock.group_fusion_clock_manager`. Long random password stored in the tablet's saved-credentials. Distinct from any human user so its session can be revoked independently if the tablet is stolen. Setup is documented in the provisioning script below; no new code creates this user (it's a manual one-time creation in HR Settings).
### `GET /fusion_clock/kiosk/nfc` — page render
- Renders the NFC kiosk QWeb template
- Resolves the kiosk's location from `request.env.company.x_fclk_nfc_kiosk_location_id` and passes its name to the template for display ("Clock at: Westin Plant 1")
- Returns redirect to `/my` if the kiosk is disabled or the user lacks the manager group
### `POST /fusion_clock/kiosk/nfc/tap` — clock toggle
- `type='jsonrpc'`, `auth='user'`
- Input: `{ card_uid: "04:A2:B5:62:C1:80", photo_b64: "data:image/jpeg;base64,..." (optional) }`
- Logic:
1. Normalize UID (uppercase, colon-separated, reject malformed input)
2. Lookup `hr.employee` by `x_fclk_nfc_card_uid` (sudo). Not found → `{error: "card_unknown", message: "Card not enrolled"}`. Log to `fusion.clock.activity.log` with the unknown UID.
3. If `x_fclk_enable_clock` is False → `{error: "clock_disabled"}`
4. Resolve location from `request.env.company.x_fclk_nfc_kiosk_location_id`. If empty → `{error: "no_location_configured"}`
5. Server-side debounce: if same UID was tapped within the last 5 seconds, return `{error: "debounce"}` silently
6. Call `FusionClockAPI._attendance_action_change(geo_info)` with `geo_info = { browser: 'nfc_kiosk', ip_address: <remote_addr>, latitude: 0, longitude: 0 }` to toggle attendance state
7. Write `x_fclk_clock_source = 'nfc_kiosk'`, `x_fclk_location_id = <resolved>`, distance fields = 0
8. If `photo_b64` present, decode and save to `x_fclk_check_in_photo` (clock-in) or `x_fclk_check_out_photo` (clock-out)
9. If `nfc_photo_required = True` and photo is missing/decode-failed → reject the tap with `{error: "photo_required"}`
10. Reuse `_check_and_create_penalty`, `_apply_break_deduction`, `_log_activity` calls (same as PIN kiosk)
11. Return `{ success: true, action: 'clock_in' | 'clock_out', employee_name, employee_avatar_url, message, net_hours_today }`
### `POST /fusion_clock/kiosk/nfc/enroll` — card enrollment
- `type='jsonrpc'`, `auth='user'`
- Input: `{ employee_id: 42, card_uid: "04:A2:B5:62:C1:80", enroll_password: "1234" }`
- Logic:
1. Verify `enroll_password` matches `fusion_clock.nfc_enroll_password` (or accept if config is empty AND caller is in manager group)
2. Normalize UID
3. Check no other employee has this UID → `{error: "card_already_assigned", existing_employee: "<name>"}`
4. Write `x_fclk_nfc_card_uid` on the target employee
5. Log to `fusion.clock.activity.log` ("Manager X enrolled card UID Y to employee Z")
6. Return `{ success: true, employee_name, card_uid }`
### `POST /fusion_clock/kiosk/nfc/employee_search` — pick employee for enroll
- Reuses the existing `/fusion_clock/kiosk/search` controller method by importing it; does not duplicate logic.
## Frontend — kiosk page UX
**Files:**
- `views/kiosk_nfc_templates.xml` — QWeb template for the page
- `static/src/js/fusion_clock_nfc_kiosk.js` — Web NFC + camera + state machine
- `static/src/css/nfc_kiosk.css` — high-contrast shop-floor styling (always dark)
**Visual:** always-dark, high-contrast, no Odoo navbar. Shop-floor lighting washes out light backgrounds.
### State machine
```
┌─── (3s timeout) ─────────────────────────┐
▼ │
┌─────────────────────────┐ tap detected ┌────────────────────┐
│ IDLE │ ────────────────► │ PROCESSING │
│ "Tap card to clock │ │ spinner, "Reading"│
│ in or out" │ └────────────────────┘
│ big clock, date, │ │
│ company name │ success / error
└─────────────────────────┘ ▼
▲ ┌─────────────────────────┐
│ │ RESULT │
│ │ green: "Welcome John, │
└─── (3s) ──────────────────│ CLOCKED IN, 8:02 AM" │
│ red: "Card not │
│ enrolled" │
└─────────────────────────┘
```
### IDLE state
- Top: company name + current time (HH:MM, updates every second) + date
- Center: large NFC icon + "Tap your card to clock in or out", subtle pulse animation
- Bottom-right corner: tiny "⚙" icon (gateway to Enroll Mode)
### PROCESSING state
- Brief spinner + "Reading card…"
- Mostly imperceptible at typical network latency
### RESULT state — success
- Green panel
- Large employee avatar on the left
- "John Smith" — name in big text
- "CLOCKED IN at 8:02 AM" or "CLOCKED OUT — 8.1h today"
- Auto-return to IDLE after 3s
### RESULT state — error
- Red panel
- `card_unknown` → "Card not recognized. See your manager."
- `network_error` → "No connection. Please try again."
- `debounce` → silent (no UI change to avoid double-tap confusion)
- `photo_required` → "Camera unavailable. Ask IT to check the kiosk."
- Auto-return to IDLE after 4s
### Web NFC implementation
- One-time activation button on first page load: "Tap here to enable NFC reader" (Web NFC requires a user gesture before `scan()` is permitted)
- After activation, `NDEFReader.scan()` runs continuously
- `reading` event fires for any tap; we extract `event.serialNumber` (works for raw MIFARE access cards even with no NDEF data)
- UID format: hex bytes joined by colons, uppercased
- If `scan()` throws, restart with a 1-second backoff
### Camera implementation
- `getUserMedia({ video: { facingMode: 'user' } })` activated alongside NFC
- Hidden `<video>` element streams continuously
- On tap, grab one frame to a `<canvas>`, encode as JPEG quality 0.7 (~3060 KB), POST as base64 in the same JSON payload as the UID
- If `nfc_photo_required = True` and camera is unavailable → tap is rejected ("Camera unavailable") rather than silently degrading
### Enroll Mode
- Tap the bottom-right "⚙" → on-screen numpad password entry → match against `fusion_clock.nfc_enroll_password` → enter Enroll Mode
- Enroll Mode UI:
1. Search input → employee list (uses `/fusion_clock/kiosk/nfc/employee_search`)
2. Manager picks employee → "Now tap John Smith's card on the back of the tablet"
3. Tap detected → POST to `/enroll` → "✓ Card 04:A2:B5:62:C1:80 enrolled to John Smith. Enroll another?"
4. "Done" button → exit Enroll Mode → back to IDLE
- 60-second inactivity timeout in Enroll Mode → auto-exit to IDLE (so an unattended kiosk doesn't stay open in admin mode)
### One-time setup flow (first load on a new tablet)
1. "Welcome to Fusion Clock NFC Kiosk." — large tap-to-continue button (this gesture activates Web NFC)
2. Browser permission prompts: NFC, then Camera. Page text guides the manager through each.
3. Test prompt: "Tap any card to verify reader is working" → shows the UID detected → "Reader OK ✓"
4. "Setup complete." → enters IDLE
- After setup, page auto-resumes IDLE on every reload (Web NFC permission is sticky per origin, so no re-prompts)
### Mock-tap debug mode
- Gated by `fusion_clock.nfc_kiosk_debug = True`
- When enabled, hidden keyboard shortcut `Ctrl+Shift+T` fires a mock tap with a configurable UID stored in localStorage
- Off in production; useful for dev iteration on the UI state machine without hardware, and for support troubleshooting
## Edge cases & failure modes
| Scenario | Behavior |
|---|---|
| Card not enrolled | Red screen "Card not recognized. See your manager." Activity logged with the unknown UID. No attendance change. |
| Employee disabled (`x_fclk_enable_clock=False`) | "Clock disabled for this account." Activity logged. |
| Card lost/damaged | Manager opens employee form, clears `x_fclk_nfc_card_uid`, issues new card, re-enrolls via kiosk Enroll Mode. |
| Card already assigned during enroll | "This card is already assigned to Jane Doe. Unenroll first." No silent overwrite. |
| Tablet offline / WiFi drops | Fail loudly: "No connection. Use the portal on your phone." No local cache in v1. |
| Same card tapped twice within 5s | Server-side debounce. Second tap silently ignored. |
| MIFARE clone attack | UIDs can be cloned with cheap hardware. Mitigation = the photo. Manager dashboard surfaces photos for spot-check. Cards alone are not treated as secure. |
| Tablet stolen | Knox remote wipe + revoke kiosk service user credentials in Odoo (instantly invalidates that tablet's session). |
| Power outage | Tab Active battery covers brief outages. Full reboot → Chrome+Fully Kiosk auto-launch the kiosk URL. Setup is sticky → goes straight to IDLE. |
| Tablet clock drift | Irrelevant. All timestamps come from `fields.Datetime.now()` server-side. Tablet clock is for display only. |
| UID format mismatch (Ubiquiti vs Web NFC byte order) | Normalize on the server: uppercase, colon-separated, MSB first. Reject malformed UIDs at the endpoint. |
| Camera unavailable while `nfc_photo_required=True` | Tap rejected with "Camera unavailable" — forces a real fix instead of silent degradation. |
## Hardware checklist (per company)
- Samsung Galaxy Tab Active 5 Pro (10.1") — ~$700 USD
- Samsung official Pogo charging dock — ~$100
- Wall mount bracket compatible with Tab Active 5 Pro (The Joy Factory, Maclocks, or Heckler) — ~$80
- USB-C 30W PSU + cable — ~$25
- Fully Kiosk Browser commercial license (~€10 one-time) OR Samsung Knox Configure (~$30/year/device)
- "TAP HERE" decal for the back of the tablet — DIY/printed sticker
**Total**: ~$915 per company, one-time.
## Provisioning script (one-time per tablet)
**Prerequisite — Odoo side (one-time per company):**
- Create a `res.users` named e.g. `kiosk-westin@<domain>`, member of `fusion_clock.group_fusion_clock_manager`
- Generate a long random password; store it in a password manager
- Set `res.company.x_fclk_nfc_kiosk_location_id` for that company to the desired `fusion.clock.location`
- Toggle `fusion_clock.enable_nfc_kiosk = True` and `fusion_clock.nfc_photo_required` per policy
- Set `fusion_clock.nfc_enroll_password` to a 4-digit Enroll Mode password
**Tablet side:**
1. Factory reset
2. Sign in with company Google account
3. Install Fully Kiosk Browser from Play Store
4. In Fully Kiosk: set kiosk URL → `https://<odoo-domain>/fusion_clock/kiosk/nfc`, enable "hide bars", "auto-restart on crash", "keep screen on while charging", "auto-reload daily at 3am"
5. Open kiosk URL once in normal Chrome → log in as the kiosk service user (saved credentials) → walk through the one-time setup flow (activate NFC, allow camera, test-tap a card)
6. Lock tablet into kiosk mode via Fully Kiosk's "Start Kiosk" button
7. Mount on dock
## Testing plan
### Python unit tests (`tests/test_clock_nfc_kiosk.py`)
- Tap with valid UID → attendance toggled, photo saved, activity logged
- Tap with unknown UID → `card_unknown` error, no attendance row
- Tap when `x_fclk_enable_clock=False``clock_disabled` error
- Double-tap same UID within 5s → second is debounced
- Enroll with conflicting UID → `card_already_assigned`, no overwrite
- Enroll with wrong password → 403
- Tap with no `fusion.clock.location` configured for company → `no_location_configured`
- UID normalization: lowercase input → stored uppercase
### Manual smoke tests (real tablet or Android phone for dev)
- Cold boot → IDLE within 5s
- Tap → RESULT within 1s
- Photo attached to attendance record (verify in backend)
- Enroll Mode password gate works; 60s timeout exits cleanly
- WiFi disconnect → tap shows "No connection"; reconnect → tap works again
- Tap own card 5x in fast succession → only one state change (debounce holds)
### Dev shortcut
- Test the entire flow on any Android phone with NFC + Chrome before touching tablet hardware
- For pre-card testing: use any contactless credit/debit card or transit pass (Web NFC reads only the UID, not card data — safe)
- Mock-tap debug mode (`Ctrl+Shift+T`) lets the UI state machine be tested without any hardware
### Soak test (before declaring pilot ready)
- 24h continuous on the dock
- Periodic taps every few hours
- Verify Chrome memory stable (DevTools), NFC reader still active, no zombie permissions prompts
## Future considerations
- **Offline mode** — local IndexedDB cache + replay queue when network returns. Adds complexity (conflict resolution, clock-skew handling) for marginal benefit at 1 station. Defer until pilot proves it's a real problem.
- **Multi-station** — if a single station becomes a bottleneck at shift change, add a second tablet at the same company. No code changes needed; just provision another tablet pointing at the same URL.
- **QR-code-on-portal alternate credential** — for iPhone-only employees who don't want to carry a card. Adds `BarcodeDetector` to the kiosk page alongside `NDEFReader`, plus a "My Clock Code" page in the portal that shows a rotating short-lived QR. Defer to v1.1.
- **Ubiquiti webhook integration** — subscribe to UniFi Access tap events on a designated "clock door" reader so an entry tap doubles as clock-in. Saves the tablet purchase but loses the photo verification and the screen feedback. Probably not worth it but easy to add later.
- **Native Android kiosk app** — only if the pilot scales to 50+ stations and Web NFC's quirks become operationally painful. Today, not worth it.

View File

@@ -0,0 +1,284 @@
# ADP Application Received — Bundled Pages 11 & 12 (Design)
**Date:** 2026-05-19
**Module:** `fusion_claims`
**Owner:** Gurpreet
**Status:** Approved (ready for implementation plan)
## Problem
When marking an ADP application as Received, the `Application Received` wizard requires two separate PDF uploads:
1. **Original ADP Application** (`x_fc_original_application`)
2. **Signed Pages 11 & 12** (`x_fc_signed_pages_11_12`)
In day-to-day operations the office or the client often scans (or emails) the **entire** ADP application as a single PDF — already including signed pages 11 & 12. Today, staff have to manually split pages 11 & 12 out of the bundled PDF and upload them again as a separate file, even though the same signatures are already present in the original PDF.
The wizard must continue to support the existing flows (separate signed-pages file, remote signing via Page 11 signing request), but it should also accept the bundled case without manual splitting.
## Goals
- Allow staff to mark Application Received with **one** PDF when pages 11 & 12 are inside it.
- Preserve the two existing modes (separate file, remote signing).
- Keep downstream audit/case-close checks correct without rewriting every consumer.
- Make the wizard easier to use and slightly safer (real PDF detection, friendlier messages).
## Non-Goals
- PDF page extraction or splitting (explicitly rejected by user — "no split").
- Capturing Page 11 signer identity in the bundled / separate-file modes (existing gap; out of scope).
- Re-architecting the document-attachment model to de-duplicate identical binaries (out of scope).
- Changes to the remote signing wizard or `fusion.page11.sign.request` model.
## High-Level Approach
Add a **single boolean flag** on `sale.order` that records whether pages 11 & 12 are inside the original application PDF. Introduce a **computed helper field** that downstream consumers read instead of `x_fc_signed_pages_11_12` directly. Add a **three-mode radio** at the top of the Application Received wizard.
Minimal blast radius:
- One new boolean, one new computed field on `sale.order`.
- Wizard view + Python rewritten to drive logic off the radio mode.
- Four downstream call sites change which field they read (no logic change).
- Three small complementary fixes folded in (status-gate text, PDF magic-bytes check, page-count indicator).
## Data Model
### `sale.order` — new fields
```python
x_fc_pages_11_12_in_original = fields.Boolean(
string='Pages 11 & 12 in Original Application',
default=False,
tracking=True,
help='True when the original application PDF already contains the signed pages 11 & 12.',
)
x_fc_has_signed_pages_11_12 = fields.Boolean(
string='Has Signed Pages 11 & 12',
compute='_compute_has_signed_pages_11_12',
store=True,
help='True if pages 11 & 12 are satisfied — either bundled, uploaded separately, '
'or signed via remote signing request.',
)
@api.depends(
'x_fc_signed_pages_11_12',
'x_fc_pages_11_12_in_original',
'page11_sign_request_ids.state',
)
def _compute_has_signed_pages_11_12(self):
for order in self:
order.x_fc_has_signed_pages_11_12 = bool(
order.x_fc_pages_11_12_in_original
or order.x_fc_signed_pages_11_12
or order.page11_sign_request_ids.filtered(lambda r: r.state == 'signed')
)
```
### Existing fields — unchanged meaning
- `x_fc_original_application` — original (or bundled) PDF.
- `x_fc_signed_pages_11_12` — separate signed-pages file when one exists. Stays optional.
- `page11_sign_request_ids` — remote signing requests. Unchanged.
### Audit trail field
`x_fc_trail_has_signed_pages` already exists at [models/sale_order.py:3248](../../fusion_claims/models/sale_order.py:3248). Its compute body changes from `bool(order.x_fc_signed_pages_11_12)` to `order.x_fc_has_signed_pages_11_12`.
### Migration
None. Existing records get `x_fc_pages_11_12_in_original = False` by default; their existing `x_fc_signed_pages_11_12` binary continues to satisfy the new computed gate. Stored compute will populate `x_fc_has_signed_pages_11_12` for legacy rows on first read or recompute.
## Wizard Changes — `fusion_claims.application.received.wizard`
### New fields
```python
intake_mode = fields.Selection(
[
('bundled', 'Pages 11 & 12 are INCLUDED in the original application'),
('separate', 'Pages 11 & 12 are a SEPARATE file'),
('remote', 'Pages 11 & 12 will be SIGNED REMOTELY'),
],
string='Intake Mode',
required=True,
default='bundled',
)
original_page_count = fields.Integer(
string='Original PDF Page Count',
compute='_compute_original_page_count',
)
```
`signed_pages_11_12` and `signed_pages_filename` keep their current definitions — they're only required in `separate` mode now.
The existing computed fields `has_pending_page11_request` and `has_signed_page11` ([wizard/application_received_wizard.py:44-49](../../fusion_claims/wizard/application_received_wizard.py:44)) **stay** — they drive the "request pending" / "remote signature complete" banners now only shown when `intake_mode == 'remote'`.
### `default_get` — pick an initial mode from existing state
```python
# When re-opening the wizard on an order that already has some data:
if order.x_fc_pages_11_12_in_original:
res['intake_mode'] = 'bundled'
elif order.x_fc_signed_pages_11_12:
res['intake_mode'] = 'separate'
elif order.page11_sign_request_ids.filtered(lambda r: r.state in ('sent', 'signed')):
res['intake_mode'] = 'remote'
else:
res['intake_mode'] = 'bundled' # new default for fresh records
```
### View behaviour (declarative `invisible` on group containers)
| Mode | Original upload | Signed Pages 11 & 12 upload | Remote-sign banner / button |
|---|---|---|---|
| `bundled` | shown, required | hidden | hidden |
| `separate` | shown, required | shown, required | hidden |
| `remote` | shown, required | hidden | shown (existing `action_request_page11_signature` button) |
Page count is displayed read-only next to the original-application filename once a PDF is loaded. If `pdfrw` fails to parse, show *"(could not read PDF)"* — does not block confirmation.
### `action_confirm` (new shape)
```python
def action_confirm(self):
self.ensure_one()
order = self.sale_order_id
if order.x_fc_adp_application_status not in ('assessment_completed', 'waiting_for_application'):
raise UserError(
"Can only mark application received from 'Assessment Completed' "
"or 'Waiting for Application' status."
)
if not self.original_application:
raise UserError("Please upload the Original ADP Application.")
self._validate_pdf_bytes(self.original_application, 'Original ADP Application')
vals = {
'x_fc_adp_application_status': 'application_received',
'x_fc_original_application': self.original_application,
'x_fc_original_application_filename': self.original_application_filename,
'x_fc_pages_11_12_in_original': (self.intake_mode == 'bundled'),
}
if self.intake_mode == 'separate':
if not (self.signed_pages_11_12 or order.x_fc_signed_pages_11_12):
raise UserError("Pages 11 & 12 file is required for Separate-file mode.")
if self.signed_pages_11_12:
self._validate_pdf_bytes(self.signed_pages_11_12, 'Signed Pages 11 & 12')
vals['x_fc_signed_pages_11_12'] = self.signed_pages_11_12
vals['x_fc_signed_pages_filename'] = self.signed_pages_filename
elif self.intake_mode == 'remote':
has_request = order.page11_sign_request_ids.filtered(
lambda r: r.state in ('sent', 'signed')
)
if not has_request:
raise UserError(
"Remote-signing request not found. Click 'Request Remote Signature' "
"first, or pick a different mode."
)
# bundled flag stays False — signature lives in the request's signed_pdf
order.with_context(skip_status_validation=True).write(vals)
self._post_chatter(order)
return {'type': 'ir.actions.act_window_close'}
```
When `intake_mode == 'bundled'`, any pre-existing `x_fc_signed_pages_11_12` from a prior wizard run is left alone (we don't clear it). The bundled flag plus the existing separate file together are harmless — the computed gate is `OR`.
### PDF magic-bytes check
```python
def _validate_pdf_bytes(self, b64_data, label):
import base64
if not b64_data:
return
try:
head = base64.b64decode(b64_data)[:5]
except Exception:
raise UserError(f"{label}: could not decode uploaded file.")
if head != b'%PDF-':
raise UserError(f"{label} must be a PDF file (content check failed).")
```
The existing filename `.pdf` check stays in place as a defence-in-depth `@api.constrains`.
### Chatter message — mode-aware
| Mode | Headline | Detail line |
|---|---|---|
| `bundled` | *Application Received — bundled* | "Pages 11 & 12 included in original PDF" |
| `separate` | *Application Received — separate files* | "Original + separate signed pages uploaded" |
| `remote` | *Application Received — remote signature pending* | "Page 11 sent for remote signature (`N` request(s) outstanding)" where `N` is the count of `page11_sign_request_ids` in state `sent` or `signed`. |
Notes from the wizard, if any, are appended below as today.
## Downstream Consumer Changes
These are mechanical: change which field they read. **No logic changes.**
| File | Line | Old | New |
|---|---|---|---|
| [wizard/ready_for_submission_wizard.py:95](../../fusion_claims/wizard/ready_for_submission_wizard.py:95) | `_compute_field_status` | `bool(order.x_fc_original_application and order.x_fc_signed_pages_11_12)` | `bool(order.x_fc_original_application and order.x_fc_has_signed_pages_11_12)` |
| [wizard/ready_for_submission_wizard.py:148](../../fusion_claims/wizard/ready_for_submission_wizard.py:148) | gate check | `if not order.x_fc_signed_pages_11_12` | `if not order.x_fc_has_signed_pages_11_12` |
| [wizard/case_close_verification_wizard.py](../../fusion_claims/wizard/case_close_verification_wizard.py) | wherever pages-11-12 gate is checked | `x_fc_signed_pages_11_12` | `x_fc_has_signed_pages_11_12` |
| [models/sale_order.py:3248](../../fusion_claims/models/sale_order.py:3248) | `x_fc_trail_has_signed_pages` compute | `bool(order.x_fc_signed_pages_11_12)` | `order.x_fc_has_signed_pages_11_12` |
The `x_fc_signed_pages_11_12` field stays in the data model. Any download / preview / "open document" button that points at the literal binary stays as-is — bundled-mode orders simply won't have this field populated, and the UI should hide the "Open signed pages" button when the field is empty (it already does — Odoo hides empty binary widgets by default).
## Error / Edge Cases
| Scenario | Behaviour |
|---|---|
| User toggles from `separate` to `bundled` after uploading a separate file | Wizard does not clear the upload field. On confirm, only the original application is written; bundled flag goes to True. The separate-file binary in the wizard is discarded (it was never written). |
| User picks `remote` but has no sent/signed request | Block with the message above; user must click *Request Remote Signature* first. |
| User picks `bundled` but the PDF is short (e.g. 4 pages) | Page-count indicator shows *"(4 pages)"* as a visual hint, but **does not block**. The 14-page ADP form is the norm but the system can't reliably enforce it across form versions. |
| Legacy record without `x_fc_pages_11_12_in_original` set | Defaults to False. As long as `x_fc_signed_pages_11_12` is present, `x_fc_has_signed_pages_11_12` is True — gate still passes. |
| Stored compute not populated for legacy rows | Triggered on first read or via a one-line `_recompute` on module load is **not** required — Odoo computes on first access. If users hit issues, a one-off psql `UPDATE` can be run manually. |
| Remote signing completes after `bundled` mode was used | `_compute_has_signed_pages_11_12` already ORs in `page11_sign_request_ids.state == 'signed'` — harmless overlap; trail stays correct. |
| Uploaded file is not really a PDF (wrong content) | Magic-byte check raises a UserError; record is not changed. |
## Testing
### Unit tests — wizard (`tests/test_application_received_wizard.py`, new)
- `test_bundled_mode_marks_received_with_only_original`
- `test_separate_mode_requires_signed_pages`
- `test_remote_mode_requires_sent_or_signed_request`
- `test_invalid_pdf_bytes_rejected`
- `test_chatter_message_mentions_intake_mode`
### Unit tests — downstream gates
- `test_ready_for_submission_passes_with_bundled_flag` (no `x_fc_signed_pages_11_12` set)
- `test_case_close_audit_accepts_bundled_flag`
- `test_trail_has_signed_pages_true_when_bundled`
### Manual smoke test on local dev DB
```bash
docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims --stop-after-init
```
Then in the UI:
1. Take an order in *Waiting for Application*.
2. Click *Mark Application Received* → pick **Bundled** → upload a single PDF → confirm.
3. Confirm chatter shows the bundled message and `x_fc_pages_11_12_in_original = True`.
4. Click *Mark Ready for Submission* — the document gate should pass.
5. Repeat on another order with **Separate** mode to confirm the old flow still works.
6. Repeat on a third order with **Remote** mode after triggering a signing request.
## Rollout
- Bump `version` in [fusion_claims/__manifest__.py](../../fusion_claims/__manifest__.py).
- `docker exec odoo-dev-app odoo -d fusion-dev -u fusion_claims --stop-after-init`.
- Reload browser with cache clear (per CLAUDE.md asset-bundle-cache rule).
- No production deploy steps unique to this change.
## Open Questions (none blocking implementation)
- Should bundled-mode capture Page 11 signer identity (signer name, relationship) the way the remote flow does? Currently neither bundled nor separate-file modes do — existing gap, deferred.
- Should the bundled-mode chatter automatically attach a one-line note like *"Operator confirms pages 11 & 12 are within the original application"* with the user's name? The default chatter post already records the user. Leaving as-is.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,969 @@
# Step Qty Gate, Partial-Qty Handling, and Job Display Rename — Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a quantity gate on `fp.job.step.button_finish` (with last-step exemption), introduce a per-row `Complete 1 → Next` action for streaming flow, add an auto-move shim on Finish & Next for the 1-of-1 case, and override `fp.job.display_name` so jobs render as `Work Order # 00011` instead of `WH/JOB/00011`.
**Architecture:** Five small Python changes (one compute + one gate + one action + one helper + manager-bypass keys) on `fp.job` and `fp.job.step`, plus two view edits (form `<h1>` and embedded step list row button). Move wizard's existing zero-qty + over-qty guards stay; one regression test added for them. All changes deploy on entech, sync back to the local repo as the final task.
**Tech Stack:** Odoo 19, PostgreSQL. No new dependencies.
**Spec:** [`docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md`](../specs/2026-05-12-step-qty-gate-and-display-rename-design.md)
---
## Deployment conventions
Same pattern as the milestone-cascade plan that just shipped:
- File paths are **entech container paths** (`/mnt/extra-addons/custom/...`).
- Edits go via base64-encoded Python patch scripts:
```bash
B64=$(base64 -w0 path/to/_patch.py)
ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/_patch.py && python3 /tmp/_patch.py\""
```
- After each Python change: manifest version bump, then upgrade module:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && \
su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u <module> --stop-after-init' 2>&1 | tail -5 && \
systemctl start odoo && systemctl is-active odoo\""
```
- Tests via:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && \
su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*(FAIL|ERROR|Starting)' | head -30 && \
systemctl start odoo\""
```
- Backups: `cp <file> /tmp/<basename>.bak` before the first patch of any file.
- No git commits during tasks. Final task (Task 7) syncs touched files back to `K:/Github/Odoo-Modules/` and commits there.
---
## File structure
| File | Type | Responsibility |
|---|---|---|
| `fusion_plating_jobs/models/fp_job.py` | modify | Add `_compute_display_name` override (renames `WH/JOB/00011` → `Work Order # 00011`). |
| `fusion_plating/models/fp_job_step.py` | modify | Quantity gate in `button_finish`; new `action_complete_one_to_next`; new helper `_fp_record_one_piece_auto_move`; wire the helper into `action_finish_and_advance`. |
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | modify | `<h1>` binds `display_name`; per-row "Complete 1 → Next" button. |
| `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` | modify | Append `TestQtyGate` class with 14 tests. |
| `fusion_plating/__manifest__.py` | modify | Version bump. |
| `fusion_plating_jobs/__manifest__.py` | modify | Version bump. |
---
## Task 1: Display rename — `Work Order # 00011`
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml`
- [ ] **Step 1: Backup files**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py /tmp/fp_job_t1.py.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml /tmp/fp_job_form_inherit_t1.xml.bak'"
```
- [ ] **Step 2: Add `_compute_display_name` to `fp.job`**
Locate the existing class declaration in `fp_job.py` (around the first `class FpJob(models.Model)` line, then the `_inherit = 'fp.job'` block). Find the existing `name` field declaration (around line 62 — `name = fields.Char(...)`). Add the new compute method immediately after the existing field declarations on the class (any spot inside the class body before existing `@api.depends` methods is fine; convention is to put it near the field it depends on).
Insert:
```python
@api.depends('name')
def _compute_display_name(self):
"""Reformat 'WH/JOB/00011' → 'Work Order # 00011' for every
human-facing surface (form header, breadcrumbs, M2O dropdowns,
smart-button titles, error messages). The DB `name` is unchanged
so existing certs / deliveries / chatter references don't break.
"""
for job in self:
if job.name and '/' in job.name:
suffix = job.name.rsplit('/', 1)[-1]
job.display_name = _('Work Order # %s') % suffix
else:
job.display_name = job.name or ''
```
Use a patch script with anchor-based string replacement. The anchor should be unique enough to find exactly one insertion site — pick a stable nearby field declaration (e.g. the `state` field's closing `)` if it's unique).
- [ ] **Step 3: Bind `display_name` in the form header**
In `fp_job_form_inherit.xml`, find the `<h1>` block in the sheet header that currently binds `name`:
Search anchor:
```xml
<h1><field name="name"/></h1>
```
Replace with:
```xml
<h1><field name="display_name"/></h1>
```
If the file uses a slightly different markup (e.g. with extra attributes like `class=...` or `readonly=...`), keep those attributes and just change `name="name"` to `name="display_name"`.
- [ ] **Step 4: Bump fusion_plating_jobs manifest version**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"CUR=\\\$(grep \\\"'version':\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py | head -1 | grep -oP '\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+\\\\.\\\\d+') && echo \\\"current: \\\$CUR\\\"\""
```
Bump the last component (`19.0.8.19.6` → `19.0.8.19.7`):
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.6'/'version': '19.0.8.19.7'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\""
```
(If the current version is different from `19.0.8.19.6` because Phase 1 work iterated more, substitute the actual current version.)
- [ ] **Step 5: Validate Python + XML syntax**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py\\\").read()); print(\\\"py OK\\\")' && python3 -c 'import xml.etree.ElementTree as ET; ET.parse(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml\\\"); print(\\\"xml OK\\\")'\""
```
Expected: `py OK` and `xml OK`.
- [ ] **Step 6: Upgrade fusion_plating_jobs**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --stop-after-init' 2>&1 | tail -5 && systemctl start odoo && systemctl is-active odoo\""
```
Expected: `Modules loaded`, `Registry loaded`, then `active`.
- [ ] **Step 7: Verify display_name renders correctly via odoo shell**
```bash
SCRIPT='job = env["fp.job"].search([("name", "like", "WH/JOB/")], limit=1)
print(">>> name=", job.name)
print(">>> display_name=", job.display_name)'
B64=$(echo -n "$SCRIPT" | base64 -w0)
ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/check.py && su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < /tmp/check.py' 2>&1 | grep '>>>'\""
```
Expected:
```
>>> name= WH/JOB/00011
>>> display_name= Work Order # 00011
```
---
## Task 2: Quantity gate on `button_finish`
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- [ ] **Step 1: Backup**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'cp /mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py /tmp/fp_job_step_t2.py.bak && cp /mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py /tmp/test_fp_job_milestone_cascade_t2.py.bak'"
```
- [ ] **Step 2: Add quantity gate to `button_finish`**
Find the existing method in `fp_job_step.py` (around line 385). The current opening looks like:
```python
def button_finish(self):
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can finish."
) % (step.name, step.state))
now = fields.Datetime.now()
# Close the open timelog (the one with no date_finished)
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
```
Use a patch script to inject the quantity gate immediately after the existing `state != 'in_progress'` check. New text:
```python
def button_finish(self):
skip_qty_gate = self.env.context.get('fp_skip_qty_gate')
for step in self:
if step.state != 'in_progress':
raise UserError(_(
"Step '%s' is in state '%s' — only in-progress steps can finish."
) % (step.name, step.state))
# Quantity gate: refuses if parts still parked AND there's a
# downstream step to move them to. Last runnable step is
# exempt — parts finishing there complete in place.
if not skip_qty_gate and step.qty_at_step > 0:
has_downstream = step.job_id.step_ids.filtered(
lambda s: s.sequence > step.sequence
and s.state in ('pending', 'ready')
)
if has_downstream:
raise UserError(_(
"Step '%(name)s' still has %(n)d part(s) parked "
"— move them to the next step before finishing. "
"Use the row's 'Complete 1 → Next' or 'Move…' "
"button."
) % {'name': step.name, 'n': step.qty_at_step})
now = fields.Datetime.now()
# Close the open timelog (the one with no date_finished)
open_log = step.time_log_ids.filtered(lambda l: not l.date_finished)
```
Patch script uses the existing method-opening anchor (`def button_finish(self):\n for step in self:\n if step.state != 'in_progress':`) and replaces with the new opening.
- [ ] **Step 3: Add `TestQtyGate` test class skeleton + 3 gate tests**
Append to `test_fp_job_milestone_cascade.py`:
```python
class TestQtyGate(TransactionCase):
"""Step-level quantity gate + partial-qty handling.
Covers:
- button_finish blocks when qty_at_step > 0 AND downstream
steps exist (mid-recipe)
- manager bypass via fp_skip_qty_gate=True
- last-runnable-step exemption (qty_at_step > 0 allowed)
- action_complete_one_to_next (Task 3)
- auto-move shim on action_finish_and_advance (Task 4)
- display_name rename (Task 1)
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'QtyCust'})
cls.product = cls.env['product.product'].create({
'name': 'QtyWidget',
})
def _make_job(self, qty=3, **kw):
vals = {
'partner_id': self.partner.id,
'product_id': self.product.id,
'qty': qty,
}
vals.update(kw)
return self.env['fp.job'].create(vals)
def _make_step(self, job, name='Step', sequence=10, state='pending'):
return self.env['fp.job.step'].create({
'job_id': job.id,
'name': name,
'sequence': sequence,
'state': state,
})
def _make_two_step_chain(self, qty=3):
"""Create a job with two steps; the first is in_progress
with `qty` parts parked, the second is ready. Returns
(job, step1, step2)."""
job = self._make_job(qty=qty)
step1 = self._make_step(
job, name='Plate', sequence=10, state='in_progress',
)
step2 = self._make_step(
job, name='Bake', sequence=20, state='ready',
)
# date_started required by button_finish's timelog close
step1.date_started = fields.Datetime.now()
return job, step1, step2
# ---------------- button_finish gate ----------------------------
def test_button_finish_blocks_when_qty_at_step(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=3)
# First-step seed gives step1 qty_at_step = job.qty = 3
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 3)
with self.assertRaises(UserError) as exc:
step1.button_finish()
self.assertIn('parts parked', str(exc.exception))
def test_button_finish_bypass(self):
job, step1, step2 = self._make_two_step_chain(qty=3)
step1.invalidate_recordset(['qty_at_step'])
step1.with_context(fp_skip_qty_gate=True).button_finish()
self.assertEqual(step1.state, 'done')
def test_button_finish_allows_last_step_with_qty(self):
"""Last runnable step is exempt — parts complete in place."""
job = self._make_job(qty=5)
last = self._make_step(
job, name='FinalInspect', sequence=10, state='in_progress',
)
last.date_started = fields.Datetime.now()
last.invalidate_recordset(['qty_at_step'])
self.assertEqual(last.qty_at_step, 5) # first-step seed
# No downstream step → gate exempt
last.button_finish()
self.assertEqual(last.state, 'done')
def test_button_finish_passes_when_qty_zero(self):
"""qty_at_step==0 (already moved out manually) → no gate fires."""
job, step1, step2 = self._make_two_step_chain(qty=2)
# Move all parts out so step1.qty_at_step = 0
self.env['fp.job.step.move'].create({
'job_id': job.id,
'from_step_id': step1.id,
'to_step_id': step2.id,
'transfer_type': 'step',
'qty_moved': 2,
'moved_by_user_id': self.env.user.id,
})
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 0)
step1.button_finish()
self.assertEqual(step1.state, 'done')
```
- [ ] **Step 4: Bump fusion_plating manifest version**
Find current version, bump the last component:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"grep \\\"'version':\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py | head -1\""
```
Then bump (assuming current is `19.0.18.14.12`):
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.12'/'version': '19.0.18.14.13'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
```
- [ ] **Step 5: Validate Python**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")'\""
```
Expected: `OK`.
- [ ] **Step 6: Upgrade fusion_plating + fusion_plating_jobs with tests**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*(FAIL|ERROR|Starting)' | head -30 && systemctl start odoo\""
```
Expected: 4 `Starting TestQtyGate.test_button_finish_*` lines, no FAIL or ERROR lines for TestQtyGate.
---
## Task 3: `action_complete_one_to_next`
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- [ ] **Step 1: Add `action_complete_one_to_next` method**
Append the new method to `fp_job_step.py` at the end of the `FpJobStep` class (after `button_manager_reset_to_ready` from the milestone-cascade Phase 1 work, since both are recent additions and group together). Patch via append-or-anchor-replace.
Code:
```python
def action_complete_one_to_next(self):
"""One-piece flow shortcut: records move(qty=1) from this step
to the next pending/ready step, drains qty_at_step by 1. If
the drain takes qty_at_step to 0, auto-finishes the source
and starts the destination step (delegates to
action_finish_and_advance)."""
self.ensure_one()
if self.state != 'in_progress':
raise UserError(_(
"Step '%s' must be in progress to complete a part."
) % self.name)
if self.qty_at_step < 1:
raise UserError(_(
"No parts parked at step '%s' — nothing to complete."
) % self.name)
next_step = self.job_id.step_ids.filtered(
lambda s: s.sequence > self.sequence
and s.state in ('pending', 'ready')
).sorted('sequence')[:1]
if not next_step:
raise UserError(_(
"Step '%s' is the last runnable step on the job — "
"no downstream step to move into. Finish the step "
"instead (it will close out the job)."
) % self.name)
self.env['fp.job.step.move'].create({
'job_id': self.job_id.id,
'from_step_id': self.id,
'to_step_id': next_step.id,
'transfer_type': 'step',
'qty_moved': 1,
'moved_by_user_id': self.env.user.id,
})
# qty_at_step is computed from moves; force re-read before
# checking whether this was the last part. Without invalidate
# the cache still says "still 1 parked" and auto-finish never
# fires.
self.invalidate_recordset(['qty_at_step'])
if self.qty_at_step == 0:
return self.action_finish_and_advance()
return True
```
- [ ] **Step 2: Add 4 tests for `action_complete_one_to_next`**
Append to `TestQtyGate` class:
```python
# ---------------- action_complete_one_to_next -------------------
def test_complete_one_to_next_records_move(self):
job, step1, step2 = self._make_two_step_chain(qty=3)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 3)
step1.action_complete_one_to_next()
# One move(qty=1) created
moves = self.env['fp.job.step.move'].search([
('from_step_id', '=', step1.id),
])
self.assertEqual(len(moves), 1)
self.assertEqual(moves.qty_moved, 1)
# step1 still in progress, 2 parts left
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.state, 'in_progress')
self.assertEqual(step1.qty_at_step, 2)
def test_complete_one_to_next_auto_finishes_on_last(self):
job, step1, step2 = self._make_two_step_chain(qty=1)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 1)
step1.action_complete_one_to_next()
# Source step done; next step started
self.assertEqual(step1.state, 'done')
self.assertEqual(step2.state, 'in_progress')
def test_complete_one_to_next_blocks_when_empty(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=2)
# Move all out first → qty_at_step = 0
self.env['fp.job.step.move'].create({
'job_id': job.id,
'from_step_id': step1.id,
'to_step_id': step2.id,
'transfer_type': 'step',
'qty_moved': 2,
'moved_by_user_id': self.env.user.id,
})
step1.invalidate_recordset(['qty_at_step'])
with self.assertRaises(UserError) as exc:
step1.action_complete_one_to_next()
self.assertIn('nothing to complete', str(exc.exception))
def test_complete_one_to_next_blocks_when_no_next_step(self):
from odoo.exceptions import UserError
job = self._make_job(qty=3)
last = self._make_step(
job, name='Inspect', sequence=10, state='in_progress',
)
last.date_started = fields.Datetime.now()
last.invalidate_recordset(['qty_at_step'])
with self.assertRaises(UserError) as exc:
last.action_complete_one_to_next()
self.assertIn('last runnable step', str(exc.exception))
def test_complete_one_to_next_blocks_when_not_in_progress(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=3)
step1.state = 'pending' # not in_progress
with self.assertRaises(UserError) as exc:
step1.action_complete_one_to_next()
self.assertIn('must be in progress', str(exc.exception))
```
- [ ] **Step 3: Bump fusion_plating manifest version**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.13'/'version': '19.0.18.14.14'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
```
- [ ] **Step 4: Validate + run tests**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*test_complete_one_to_next.*(FAIL|ERROR|Starting)' | head -15 && systemctl start odoo\""
```
Expected: 5 `Starting` lines (the test from Step 2 plus 4 here), zero FAIL/ERROR.
---
## Task 4: Auto-move shim on Finish & Next
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- [ ] **Step 1: Add `_fp_record_one_piece_auto_move` helper**
Find the existing `action_finish_and_advance` method on `fp.job.step` (search for `def action_finish_and_advance`). It probably looks like:
```python
def action_finish_and_advance(self):
"""Finish this step and auto-start the next pending/ready
step (Steelhead-style per-row button)."""
self.ensure_one()
if self.state == 'in_progress':
self.button_finish()
# ...rest: pick next step + button_start
```
Add the helper as a sibling method, then wire it in. New code:
```python
def _fp_record_one_piece_auto_move(self):
"""Decide whether to silently record a move(qty=1) before
the step finishes. Five cases:
- qty_at_step == 0: nothing to do (parts already moved).
- last runnable step: parts complete in place; no move.
- qty_at_step == 1 + downstream: record move(1).
- qty_at_step > 1 + downstream: raise.
- qty_at_step > 1 + last step: allow (parts complete in
place; qty_done auto-tick is Phase 2).
Called from action_finish_and_advance just before
button_finish.
"""
self.ensure_one()
qty = self.qty_at_step
if qty <= 0:
return False
next_step = self.job_id.step_ids.filtered(
lambda s: s.sequence > self.sequence
and s.state in ('pending', 'ready')
).sorted('sequence')[:1]
if not next_step:
# Last runnable step: parts complete in place.
return False
if qty > 1:
raise UserError(_(
"Step '%s' still has %d parts here — use the row's "
"'Complete 1 → Next' button (for one-by-one flow) "
"or the 'Move…' wizard (for batched flow) to drain "
"the step before finishing."
) % (self.name, qty))
# qty == 1 + next_step exists → record move silently.
self.env['fp.job.step.move'].create({
'job_id': self.job_id.id,
'from_step_id': self.id,
'to_step_id': next_step.id,
'transfer_type': 'step',
'qty_moved': 1,
'moved_by_user_id': self.env.user.id,
})
return True
```
- [ ] **Step 2: Wire the helper into `action_finish_and_advance`**
Find `action_finish_and_advance`. The current code likely starts:
```python
def action_finish_and_advance(self):
self.ensure_one()
if self.state == 'in_progress':
self.button_finish()
```
Insert the helper call before `button_finish`:
```python
def action_finish_and_advance(self):
self.ensure_one()
if self.state == 'in_progress':
# Auto-move shim: for qty_at_step==1 + downstream, record a
# move(qty=1) so the qty gate in button_finish passes. Raises
# for qty>1 with a friendly pointer to Complete 1 → Next.
self._fp_record_one_piece_auto_move()
self.button_finish()
```
The patch script uses the existing method's `self.ensure_one()\n if self.state == 'in_progress':\n self.button_finish()` as the anchor.
- [ ] **Step 3: Add 4 auto-move shim tests**
Append to `TestQtyGate`:
```python
# ---------------- auto-move shim on Finish & Next ---------------
def test_finish_and_advance_auto_move_for_qty_1(self):
job, step1, step2 = self._make_two_step_chain(qty=1)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 1)
step1.action_finish_and_advance()
# Move(qty=1) recorded silently
moves = self.env['fp.job.step.move'].search([
('from_step_id', '=', step1.id),
])
self.assertEqual(len(moves), 1)
self.assertEqual(moves.qty_moved, 1)
self.assertEqual(step1.state, 'done')
self.assertEqual(step2.state, 'in_progress')
def test_finish_and_advance_blocks_for_qty_gt_1(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=3)
step1.invalidate_recordset(['qty_at_step'])
self.assertEqual(step1.qty_at_step, 3)
with self.assertRaises(UserError) as exc:
step1.action_finish_and_advance()
self.assertIn("Complete 1", str(exc.exception))
# State unchanged
self.assertEqual(step1.state, 'in_progress')
def test_finish_and_advance_passes_for_qty_0(self):
job, step1, step2 = self._make_two_step_chain(qty=2)
# Move all out first
self.env['fp.job.step.move'].create({
'job_id': job.id,
'from_step_id': step1.id,
'to_step_id': step2.id,
'transfer_type': 'step',
'qty_moved': 2,
'moved_by_user_id': self.env.user.id,
})
step1.invalidate_recordset(['qty_at_step'])
before = self.env['fp.job.step.move'].search_count([
('from_step_id', '=', step1.id),
])
step1.action_finish_and_advance()
after = self.env['fp.job.step.move'].search_count([
('from_step_id', '=', step1.id),
])
self.assertEqual(after, before) # no extra move
self.assertEqual(step1.state, 'done')
def test_finish_and_advance_allows_last_step_with_qty_gt_1(self):
"""Last runnable step: parts complete in place; no auto-move,
no UserError, no qty gate."""
job = self._make_job(qty=5)
last = self._make_step(
job, name='FinalInspect', sequence=10, state='in_progress',
)
last.date_started = fields.Datetime.now()
last.invalidate_recordset(['qty_at_step'])
self.assertEqual(last.qty_at_step, 5)
before = self.env['fp.job.step.move'].search_count([])
last.action_finish_and_advance()
after = self.env['fp.job.step.move'].search_count([])
self.assertEqual(after, before) # no move recorded
self.assertEqual(last.state, 'done')
```
- [ ] **Step 4: Bump fusion_plating manifest version**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.18.14.14'/'version': '19.0.18.14.15'/\\\" /mnt/extra-addons/custom/fusion_plating/__manifest__.py\""
```
- [ ] **Step 5: Validate + run tests**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py\\\").read()); ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"OK\\\")' && systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating,fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*test_finish_and_advance.*(FAIL|ERROR|Starting)' | head -10 && systemctl start odoo\""
```
Expected: 4 `Starting` lines for `test_finish_and_advance_*`, zero FAIL/ERROR.
---
## Task 5: Per-row "Complete 1 → Next" button + display_name tests
**Files:**
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml`
- Modify: `/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- [ ] **Step 1: Add the per-row button**
In `fp_job_form_inherit.xml`, find the embedded step list's button block. The existing per-row buttons include `button_pause`, `action_open_input_wizard`, `button_skip`, `action_open_move_wizard`. We're adding "Complete 1 → Next" after `button_pause` and before `action_open_input_wizard` (so it sits with the primary-action buttons).
Anchor — the existing Pause button:
```xml
<button name="button_pause" type="object"
string="Pause" icon="fa-pause"
class="btn-link text-warning"
invisible="state != 'in_progress'"/>
```
Insert immediately after Pause's closing `/>`:
```xml
<!-- Streaming flow: complete 1 part at a time, move to next
step. Hidden when there's nothing parked or the step isn't
actively running. Auto-finishes the step when qty_at_step
drains to 0. -->
<button name="action_complete_one_to_next" type="object"
string="Complete 1 → Next" icon="fa-forward"
class="btn-link text-success"
invisible="state != 'in_progress' or qty_at_step &lt; 1"/>
```
- [ ] **Step 2: Add display_name + Move wizard regression tests**
Append to `TestQtyGate`:
```python
# ---------------- display_name rename ----------------------------
def test_display_name_format(self):
job = self._make_job(qty=1)
# The default ir.sequence creates name='WH/JOB/NNNNN'.
self.assertTrue(job.name.startswith('WH/JOB/'))
self.assertTrue(job.display_name.startswith('Work Order # '))
# Suffix matches.
suffix = job.name.rsplit('/', 1)[-1]
self.assertEqual(job.display_name, 'Work Order # %s' % suffix)
def test_display_name_no_slash_passthrough(self):
"""Manually-named jobs without the sequence prefix display
as-is (no rewrite)."""
job = self._make_job(qty=1)
# Override name to something without a slash
job.name = 'SmokeJob42'
job.invalidate_recordset(['display_name'])
self.assertEqual(job.display_name, 'SmokeJob42')
# ---------------- Move wizard zero-qty regression ----------------
def test_move_wizard_blocks_zero_qty(self):
from odoo.exceptions import UserError
job, step1, step2 = self._make_two_step_chain(qty=2)
wiz = self.env['fp.job.step.move.wizard'].create({
'job_id': job.id,
'from_step_id': step1.id,
'to_step_id': step2.id,
'qty_moved': 0,
'transfer_type':'step',
})
with self.assertRaises(UserError) as exc:
wiz.action_commit()
self.assertIn('at least 1', str(exc.exception))
```
- [ ] **Step 3: Bump fusion_plating_jobs manifest version**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"sed -i \\\"s/'version': '19.0.8.19.7'/'version': '19.0.8.19.8'/\\\" /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py\""
```
- [ ] **Step 4: Validate XML + Python**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"python3 -c 'import xml.etree.ElementTree as ET; ET.parse(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml\\\"); print(\\\"xml OK\\\")' && python3 -c 'import ast; ast.parse(open(\\\"/mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py\\\").read()); print(\\\"py OK\\\")'\""
```
Expected: `xml OK`, `py OK`.
- [ ] **Step 5: Upgrade fusion_plating_jobs + run tests**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c \"systemctl stop odoo && su - odoo -s /bin/bash -c '/usr/bin/odoo -c /etc/odoo/odoo.conf -d admin -u fusion_plating_jobs --test-enable --test-tags /fusion_plating_jobs --stop-after-init' 2>&1 | grep -E 'TestQtyGate.*(FAIL|ERROR)' | head -10 && systemctl start odoo\""
```
Expected: 0 lines (no failures in `TestQtyGate`).
---
## Task 6: End-to-end smoke test on entech
**Files:** none (verification via odoo shell + browser).
- [ ] **Step 1: Create a 3-step recipe job with qty=2**
```bash
SCRIPT='partner = env["res.partner"].create({"name": "QtyGate Smoke"})
prod = env["product.product"].create({"name": "QtyGateProd"})
job = env["fp.job"].create({"partner_id": partner.id, "product_id": prod.id, "qty": 2})
step1 = env["fp.job.step"].create({"job_id": job.id, "name": "S1-Plate", "sequence": 10, "state": "in_progress"})
step1.date_started = fields.Datetime.now()
step2 = env["fp.job.step"].create({"job_id": job.id, "name": "S2-Bake", "sequence": 20, "state": "ready"})
step3 = env["fp.job.step"].create({"job_id": job.id, "name": "S3-Inspect", "sequence": 30, "state": "ready"})
job.invalidate_recordset()
print(">>> JOB_ID=", job.id)
print(">>> JOB_NAME=", job.name)
print(">>> DISPLAY_NAME=", job.display_name)
print(">>> step1.qty_at_step=", step1.qty_at_step)
env.cr.commit()'
B64=$(echo -n "$SCRIPT" | base64 -w0)
ssh pve-worker5 "pct exec 111 -- bash -c \"echo $B64 | base64 -d > /tmp/smoke_qty.py && su - odoo -s /bin/bash -c '/usr/bin/odoo shell -c /etc/odoo/odoo.conf -d admin --no-http < /tmp/smoke_qty.py' 2>&1 | grep '>>>'\""
```
Expected:
```
>>> JOB_ID= <some id>
>>> JOB_NAME= WH/JOB/00xxx
>>> DISPLAY_NAME= Work Order # 00xxx
>>> step1.qty_at_step= 2
```
Note JOB_ID for later steps.
- [ ] **Step 2: Try to finish step1 — must be blocked**
```bash
SCRIPT='from odoo.exceptions import UserError
step1 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S1-Plate")
try:
step1.button_finish()
print(">>> RESULT: no error (unexpected)")
except UserError as e:
print(">>> RESULT: blocked,", str(e)[:120])'
```
Run the script (substituting JOB_ID). Expected:
```
>>> RESULT: blocked, Step 'S1-Plate' still has 2 part(s) parked — move them to the next step before finishing...
```
- [ ] **Step 3: Use action_complete_one_to_next to drain step1**
```bash
SCRIPT='step1 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S1-Plate")
step1.action_complete_one_to_next()
step1.invalidate_recordset(["qty_at_step"])
print(">>> step1.state=", step1.state, "qty_at_step=", step1.qty_at_step)
step2 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S2-Bake")
step2.invalidate_recordset(["qty_at_step"])
print(">>> step2.state=", step2.state, "qty_at_step=", step2.qty_at_step)
env.cr.commit()'
```
Expected after first call:
```
>>> step1.state= in_progress qty_at_step= 1
>>> step2.state= ready qty_at_step= 0
```
(Step2 stays `ready` because step1 still has 1 part — step1 isn't done yet.)
- [ ] **Step 4: Complete the second part — auto-finish**
```bash
SCRIPT='step1 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S1-Plate")
step1.action_complete_one_to_next()
step1.invalidate_recordset()
step2 = env["fp.job"].browse(<JOB_ID>).step_ids.filtered(lambda s: s.name == "S2-Bake")
step2.invalidate_recordset()
print(">>> step1.state=", step1.state)
print(">>> step2.state=", step2.state, "qty_at_step=", step2.qty_at_step)
env.cr.commit()'
```
Expected:
```
>>> step1.state= done
>>> step2.state= in_progress qty_at_step= 2
```
(step2 now has both parts; auto-finish + auto-start fired on the last `Complete 1 → Next` call.)
- [ ] **Step 5: Open the job in browser, verify the header label**
Navigate to `https://enplating.com/odoo` → open the smoke job. Verify:
- Form header reads **"Work Order # 00xxx"** (not WH/JOB/00xxx).
- Step1 row no longer shows the "Complete 1 → Next" button (state=done).
- Step2 row DOES show "Complete 1 → Next" (state=in_progress, qty_at_step > 0).
- [ ] **Step 6: Clean up smoke data**
```bash
SCRIPT='job = env["fp.job"].browse(<JOB_ID>)
if job.exists():
env["fp.job.step.move"].search([("job_id", "=", job.id)]).sudo().unlink()
job.step_ids.sudo().unlink()
job.sudo().unlink()
env["res.partner"].search([("name", "=", "QtyGate Smoke")]).sudo().unlink()
env["product.product"].search([("name", "=", "QtyGateProd")]).sudo().unlink()
env.cr.commit()
print(">>> cleanup done")'
```
---
## Task 7: Sync touched files back to local repo + commit
**Files:**
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/models/fp_job.py`
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml`
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/__manifest__.py`
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_job_step.py`
- `K:/Github/Odoo-Modules/fusion_plating/fusion_plating/__manifest__.py`
- [ ] **Step 1: Pull each touched file from entech to local repo**
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/models/fp_job.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/models/fp_job.py
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/views/fp_job_form_inherit.xml'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/views/fp_job_form_inherit.xml
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating_jobs/__manifest__.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating_jobs/__manifest__.py
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating/models/fp_job_step.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating/models/fp_job_step.py
ssh pve-worker5 "pct exec 111 -- bash -c 'cat /mnt/extra-addons/custom/fusion_plating/__manifest__.py'" > K:/Github/Odoo-Modules/fusion_plating/fusion_plating/__manifest__.py
```
- [ ] **Step 2: Review diff**
```bash
cd K:/Github/Odoo-Modules && git diff --stat fusion_plating/fusion_plating_jobs/ fusion_plating/fusion_plating/
```
Expected: ~6 files changed, additions concentrated in `fp_job_step.py` (button_finish gate + action_complete_one_to_next + _fp_record_one_piece_auto_move + wiring), `fp_job.py` (_compute_display_name), and `test_fp_job_milestone_cascade.py` (14 new tests).
- [ ] **Step 3: Stage + commit**
```bash
cd K:/Github/Odoo-Modules && git add fusion_plating/fusion_plating_jobs/ fusion_plating/fusion_plating/ && git commit -m "$(cat <<'EOF'
feat(jobs): step qty gate + partial-qty + display rename
Three coupled shop-floor corrections:
- fp.job.step.button_finish: refuses if qty_at_step > 0 AND a
downstream pending/ready step exists. Last runnable step is
exempt (parts complete in place). Manager bypass via
fp_skip_qty_gate=True context key.
- fp.job.step.action_complete_one_to_next: per-row "Complete
1 -> Next" button. Records move(qty=1) to next step; if that
drains qty_at_step to 0, auto-finishes source + auto-starts
destination via existing action_finish_and_advance.
- fp.job.step._fp_record_one_piece_auto_move: auto-move shim
wired into action_finish_and_advance. qty=1 + downstream =>
silently record move(1). qty>1 + downstream => raise pointing
at Complete 1 -> Next. Last step always allowed.
- fp.job._compute_display_name: renders "Work Order # 00011"
in form header, breadcrumbs, M2O dropdowns, error messages.
DB name stays as WH/JOB/00011 - existing refs unchanged.
- 14 new TestQtyGate tests covering gate / shim / auto-finish /
last-step exemption / display rename / Move wizard zero-qty.
Spec: docs/superpowers/specs/2026-05-12-step-qty-gate-and-display-rename-design.md
Plan: docs/superpowers/plans/2026-05-12-step-qty-gate-and-display-rename.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
- [ ] **Step 4: Push (optional)**
```bash
cd K:/Github/Odoo-Modules && git push origin main
```
---
## Self-review notes
- **Spec coverage:** Architecture sections 15 map to Tasks 1, 2, 3, 4, 5. State diagram entries are each covered by a dedicated test. Out-of-scope items (qty_done auto-tick, per-step scrap, cert PDF audit) are explicitly NOT in any task.
- **Placeholder scan:** Two `<JOB_ID>` placeholders in Task 6 are cross-step substitutions (the engineer reads the value from Step 1's output). All code blocks are complete; no "TBD" or "...similar to..." references.
- **Type consistency:** `action_complete_one_to_next` / `_fp_record_one_piece_auto_move` / `button_finish` all reference the same field names (`qty_at_step`, `state`, `sequence`, `job_id`, `step_ids`). The auto-move-shim's call site in `action_finish_and_advance` matches the helper's signature (no arguments, returns bool that the caller ignores). Test `TestQtyGate.setUpClass` matches the test method's `self.partner`, `self.product` references.
- **Field invalidation:** Every test that creates a Move and then checks `qty_at_step` calls `invalidate_recordset(['qty_at_step'])` first. Inside `action_complete_one_to_next` itself, the same invalidate is performed before the auto-finish check. The spec's "implementation notes" callout matches the tests.

View File

@@ -0,0 +1,310 @@
# Job Milestone Cascade — Design Spec
**Date:** 2026-05-12
**Status:** Approved for implementation (Phase 1)
**Scope:** `fusion_plating`, `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_logistics` (on entech)
## Goal
Replace the per-step "Finish & Next" button on the `fp.job` form header with a single context-aware milestone-advance button. When all steps are done, the button cycles the manager through the remaining post-step lifecycle:
```
Mark Job Done → Issue Certs → Schedule Delivery → Mark Shipped → (closed)
```
Each click runs the existing downstream method (no new business logic invented). The button is **one place** the manager looks; the system always tells them what's next.
## Motivation (workflow gap audit)
End-to-end audit found:
- **G1.** `fp.job.state` and `fp.job.workflow_state_id` are two parallel state machines that drift.
- **G2.** No auto-fire of `button_mark_done` when all steps complete. The cascade (delivery / cert / notification) hangs off a manual click that has no UI surface after Finish & Next becomes a no-op.
- **G3.** Delivery + cert creation only happen via `button_mark_done`.
- **G4.** Invoice timing is strategy-dependent; no `on_job_done` strategy.
- **G5.** Certificate auto-creation is best-effort and only spawns CoC. Thickness Report cert is never auto-created even when the part / partner requires it.
- **G6.** No "next action" surface on the job header.
Phase 1 closes **G2 and G6 directly**, makes meaningful progress on **G5**, and lays groundwork for G3/G4. G1 is explicitly deferred.
## Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Ship in recipe vs separate | **Separate (Option C — Hybrid)** | Recipes = manufacturing; deliveries = logistics. Surface "next" on the job header so manager doesn't have to navigate. Supports split shipments naturally. |
| Cert gate strictness on Mark Shipped | **Hard block** (with manager bypass via context key) | AS9100 / Nadcap compliance — no shipping without paperwork. |
| Per-cert vs bulk issuance | **Per-cert** | Each cert (CoC vs Thickness Report) needs its own compliance review. |
| No-cert-required jobs | Skip Issue Certs, go straight to Schedule Delivery | Commercial customers don't need to click a button that has nothing to do. |
| Migration of existing data | **None — dev stage** | No production jobs to preserve. Just rewrite the `Shipped` state seed XML; `-u` reloads it. |
## Architecture
### New compute fields on `fp.job`
```python
all_steps_terminal = fields.Boolean(
compute='_compute_all_steps_terminal', store=True,
help='True ⇔ at least one step exists AND every step is in '
'done/skipped/cancelled.',
)
next_milestone_action = fields.Selection([
('mark_done', 'Mark Job Done'),
('issue_certs', 'Issue Certs'),
('schedule_delivery', 'Schedule Delivery'),
('mark_shipped', 'Mark Shipped'),
('closed', 'Closed'),
], compute='_compute_next_milestone_action')
next_milestone_label = fields.Char(
compute='_compute_next_milestone_action',
help='Human label for the next-action button — read by the view.',
)
```
`_compute_next_milestone_action` resolution order (top wins):
```
1. NOT all_steps_terminal → None (the existing Finish & Next stays)
2. state != 'done' → mark_done
3. ANY required cert in state='draft' → issue_certs
4. NO delivery, OR delivery in state='draft' → schedule_delivery
5. delivery.state in scheduled/in_transit → mark_shipped
6. otherwise → closed
```
### Dispatcher action
```python
def action_advance_next_milestone(self):
"""Single entry point — branches on next_milestone_action and
delegates to the existing method. Never invents new business logic."""
self.ensure_one()
handlers = {
'mark_done': self.button_mark_done,
'issue_certs': self._action_open_draft_certs,
'schedule_delivery': self._action_open_draft_delivery,
'mark_shipped': self._action_mark_active_delivery_delivered,
}
fn = handlers.get(self.next_milestone_action)
if fn:
return fn()
return True
```
**Helper methods** (each returns an Odoo action dict or calls the existing
business-logic method):
- `_action_open_draft_certs` → returns an `ir.actions.act_window` opening
the `fp.certificate` list view with domain
`[('x_fc_job_id', '=', self.id), ('state', '=', 'draft')]` and
`target='current'` so the manager works on the cert list, then uses the
breadcrumb to return.
- `_action_open_draft_delivery` → finds the first delivery in
`state='draft'` for this job and returns an `ir.actions.act_window`
opening that record's form in `target='current'`. Falls back to the
delivery list view filtered to this job if no draft delivery exists.
- `_action_mark_active_delivery_delivered` → finds the first delivery in
`state in ('scheduled', 'in_transit')`, calls `action_mark_delivered`
on it directly (no UI navigation — the cascade just *does* the thing).
Posts to job chatter on success.
`target='current'` is chosen everywhere because the manager is working
on the cascade as a multi-step process; a popup would lose breadcrumb
context. The existing job-form breadcrumb survives, so they can navigate
back when done.
### New trigger on `fp.job.workflow.state`
```python
trigger_on_delivery_state = fields.Boolean(
string='Trigger on Delivery Delivered',
help='When True, this state passes once at least one '
'fusion.plating.delivery linked to the job reaches '
'state="delivered". Use for the Shipped milestone in '
'lieu of recipe-side default_kind="ship" tagging.',
)
```
`fp.job.workflow.state._fp_is_passed_for_job(job)` gains:
```python
if self.trigger_on_delivery_state:
return any(d.state == 'delivered' for d in job.delivery_ids)
```
`fp.job._compute_workflow_state_id`'s `@api.depends` extends to include `delivery_ids.state`.
### Cert auto-create hardening
Add to `fp.job`:
```python
def _resolve_required_cert_types(self):
"""Return the set of cert types this job must produce.
Reads the part's certificate_requirement; falls back to the
customer's send_coc / send_thickness_report flags when the part
is set to 'inherit'."""
req = (self.part_catalog_id and
self.part_catalog_id.certificate_requirement) or 'inherit'
if req == 'inherit':
types = set()
if self.partner_id.x_fc_send_coc:
types.add('coc')
if self.partner_id.x_fc_send_thickness_report:
types.add('thickness_report')
return types
return {
'none': set(),
'coc': {'coc'},
'coc_thickness': {'coc', 'thickness_report'},
}.get(req, {'coc'})
```
`_fp_create_certificates` is rewritten to loop over the resolved set and create one draft `fp.certificate` per type, idempotent per type (checks `x_fc_job_id` + `certificate_type` before creating).
### Cert gate on Mark Shipped
`fusion.plating.delivery.action_mark_delivered` gains a gate:
```python
def action_mark_delivered(self):
skip_cert = self.env.context.get('fp_skip_cert_gate')
for delivery in self:
if not skip_cert and delivery.job_ref:
job = self.env['fp.job'].search(
[('name', '=', delivery.job_ref)], limit=1)
if job:
draft_certs = self.env['fp.certificate'].search([
('x_fc_job_id', '=', job.id),
('state', '=', 'draft'),
])
if draft_certs:
raise UserError(_(
'Cannot mark delivery %(d)s shipped — '
'job %(j)s still has %(n)d draft certificate(s). '
'Issue them first, or override via '
'fp_skip_cert_gate=True context key.'
) % {
'd': delivery.name,
'j': job.name,
'n': len(draft_certs),
})
return super().action_mark_delivered()
```
Lives in `fusion_plating_certificates/models/fp_delivery.py` (so the gate ships with the certs module — no coupling to logistics).
### View changes
In `fusion_plating_jobs/views/fp_job_form_inherit.xml`:
1. **Hide existing Finish & Next** when `all_steps_terminal`:
```xml
<button name="action_finish_current_step" type="object"
string="Finish &amp; Next" class="btn-primary" icon="fa-arrow-right"
invisible="state not in ('confirmed', 'in_progress') or all_steps_terminal"/>
```
2. **Add four mutually-exclusive milestone buttons.** Each binds to `action_advance_next_milestone` but with a hardcoded label so users don't see a generic button. Visibility is gated on `next_milestone_action`:
```xml
<button name="action_advance_next_milestone" type="object"
string="Mark Job Done" class="btn-success" icon="fa-check-circle"
invisible="next_milestone_action != 'mark_done'"/>
<button name="action_advance_next_milestone" type="object"
string="Issue Certs" class="btn-primary" icon="fa-certificate"
invisible="next_milestone_action != 'issue_certs'"/>
<button name="action_advance_next_milestone" type="object"
string="Schedule Delivery" class="btn-primary" icon="fa-truck"
invisible="next_milestone_action != 'schedule_delivery'"/>
<button name="action_advance_next_milestone" type="object"
string="Mark Shipped" class="btn-success" icon="fa-paper-plane"
invisible="next_milestone_action != 'mark_shipped'"/>
```
`next_milestone_action == 'closed'` shows nothing (terminal).
3. **Hide invisible field** — register `<field name="next_milestone_action" invisible="1"/>` and `<field name="all_steps_terminal" invisible="1"/>` so the view can reference them in `invisible=` expressions.
### Data change — Shipped workflow state seed
In `fusion_plating_jobs/data/fp_workflow_state_data.xml`, replace the `Shipped` state record:
```xml
<record id="workflow_state_shipped" model="fp.job.workflow.state">
<field name="name">Shipped</field>
<field name="code">shipped</field>
<field name="sequence">60</field>
<field name="color">success</field>
<field name="trigger_on_delivery_state" eval="True"/>
<field name="description">Shipment confirmed (delivery marked delivered). Customer can be notified.</field>
</record>
```
Keep `noupdate="1"` on the wrapping `<data>` block since shops may further customise. In dev, `-u fusion_plating_jobs` re-applies it on fresh DBs.
## State transition cascade (visual)
```
┌──────────────────────┐
│ Steps still running │ ← Finish & Next visible
└──────────┬───────────┘
▼ last step done
┌──────────────────────┐
│ Mark Job Done │ ← button cascade starts
└──────────┬───────────┘
▼ button_mark_done (gates + create delivery + cert)
┌────────────────────────────┴─────────────────────────────┐
│ │
any draft cert? no required certs
│ │
▼ ▼
┌────────────┐ (skip to next)
│ Issue Certs│
└─────┬──────┘
▼ all certs issued
┌─────────────────┐
│ Schedule Deliv. │
└─────┬───────────┘
▼ delivery scheduled
┌─────────────┐
│ Mark Shipped │ ← gates on issued certs (cert module)
└─────┬────────┘
▼ delivery.action_mark_delivered
(workflow_state → Shipped via the new trigger;
invoice fires if strategy='on_delivery')
Closed
```
## Files touched
| File | Change |
|---|---|
| `fusion_plating_jobs/models/fp_job.py` | Add `all_steps_terminal`, `next_milestone_action`, `next_milestone_label` compute fields. Add `action_advance_next_milestone` dispatcher + 3 helper methods. Add `_resolve_required_cert_types`. Rewrite `_fp_create_certificates` to loop over resolved types. Extend `@api.depends` on `_compute_workflow_state_id` to include `delivery_ids.state`. |
| `fusion_plating_jobs/models/fp_job_workflow_state.py` | Add `trigger_on_delivery_state` Boolean. Extend `_fp_is_passed_for_job` with delivery-state branch. |
| `fusion_plating_jobs/data/fp_workflow_state_data.xml` | Rewrite `Shipped` state seed: drop `trigger_default_kinds='ship'`, add `trigger_on_delivery_state=True`. |
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | Hide `Finish & Next` when `all_steps_terminal`. Add 4 milestone buttons. Add invisible field declarations. |
| `fusion_plating_certificates/models/fp_delivery.py` | Inherit `fusion.plating.delivery`; override `action_mark_delivered` to gate on draft certs. Manager bypass via `fp_skip_cert_gate=True`. |
| `fusion_plating_certificates/__init__.py` / `models/__init__.py` | Register the new `fp_delivery.py` if needed. |
Manifest versions to bump:
- `fusion_plating_jobs`
- `fusion_plating_certificates`
## Out of scope (Phase 2+)
- **Send Certs to Customer button** — wrap `action_send_to_customer` per cert into the cascade after Mark Shipped. Existing `fp_notification_trigger` hooks already handle ship-time customer email; needs integration design.
- **`on_job_done` invoice strategy** — currently invoices fire at SO confirm or delivery delivered. A "fire at job done" option is desirable for cash-up-front shops; needs strategy-pattern extension in `fusion_plating_invoicing/models/sale_order.py`.
- **`fp.job.state` ↔ `workflow_state_id` reconciliation (G1)** — pick one source of truth, drop or compute the other. Larger refactor; defer until Phase 1 lands and we see how the cascade affects state-machine readability.
## Implementation notes / gotchas
- `next_milestone_action` is **not stored** — recompute on every access. Cheap (4 boolean checks). Avoids dependency-tracking complexity when delivery state changes.
- The cascade reads `delivery_ids` on `fp.job`. Confirm this field exists (related/computed) before relying on it. Fallback: search `fusion.plating.delivery` by `job_ref == self.name`.
- The cert gate in `action_mark_delivered` lives in the certs module so logistics doesn't depend on certs (currently logistics is upstream of certs in the dependency graph — verify).
- View buttons share the same `name="action_advance_next_milestone"` but Odoo distinguishes them by their `string=` attribute in the rendered DOM — this is the standard Odoo pattern for context-aware buttons (see `sale.order` action buttons).
- All four buttons are inside the header; users won't see more than one at a time thanks to the `invisible=` filters.

View File

@@ -0,0 +1,294 @@
# Step Quantity Gate, Partial-Qty Handling, and Job Display Rename
**Date:** 2026-05-12
**Status:** Approved for implementation
**Scope:** `fusion_plating`, `fusion_plating_jobs` (on entech)
## Goal
Three coupled shop-floor corrections on `fp.job` / `fp.job.step`:
1. **Display rename:** show `Work Order # 00011` everywhere a job appears to humans, while keeping `name = "WH/JOB/00011"` as the stable DB identifier.
2. **Quantity gate on `button_finish`:** prevent a step from being marked Done while parts are still parked at it. The current implementation has no quantity check, which is how an operator can produce the "all steps Done, qty_done=0" state visible in production.
3. **Partial-quantity flow:** add a per-row "Complete 1 → Next" action so streaming (large parts moving one-by-one through the same step) is a single click per part. Keep the Move wizard for batched (sub-batch) flow. Keep "Finish & Next" working for the 1-of-1 case via a transparent auto-move shim.
## Motivation
The current state observed in production (job `WH/JOB/00011`, `qty=1`, `qty_done=0`, 11 steps all `Done`) shows the data integrity problem: `fp.job.step.button_finish()` checks only `state == 'in_progress'`. No quantity validation. The user can click Finish on every step regardless of whether parts physically moved through. The job-level `button_mark_done` catches the qty discrepancy at the very end, but by then the per-step audit trail is already a fiction.
Real shop floors run three flows on the same job model:
| Flow | Example | Operator UX needed |
|---|---|---|
| **1-of-1** | One large valve body, qty=1 | One click: Finish & Next (auto-moves the 1 part) |
| **Streaming** | 10 large parts going one-by-one through the same plating tank | One click per part: Complete 1 → Next |
| **Batched** | 50 small parts going through in groups of 10 | Move wizard for each chunk, then Finish |
The data model (`fp.job.step.move` records, `qty_at_step` compute) already supports all three. What's missing is the gate plus a first-class shortcut for streaming.
## Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Job rename mechanism | Override `display_name` via compute; leave `name` untouched | DB identifier stable; old references in chatter/certs/deliveries don't break; rollback is one line |
| Quantity gate scope | `qty_at_step > 0` blocks `button_finish` | Catches the bug at the right layer; manager bypass via context |
| Partial qty UX | Move-driven (Option A from brainstorming) | Maps cleanly to all three flows with one click per natural unit of work |
| Streaming shortcut | New `action_complete_one_to_next` row button | First-class action for the one-by-one case; no wizard ceremony |
| 1-of-1 shortcut | Auto-move shim on existing `action_finish_current_step` + `action_finish_and_advance` | Keeps the single-click UX; transparently records the move |
| Move wizard zero-qty | Already guarded (`qty_moved <= 0` raises) | Verify with a test; no code change needed |
| Manager force-complete | Stays bypass-by-design (already skips `button_finish`) | Manager use-case is "this step was done outside ERP" — no qty in ERP to validate |
## Architecture
### 1. `fp.job.display_name` compute
Single override on `fp.job`. No model change beyond adding a computed method.
```python
@api.depends('name')
def _compute_display_name(self):
"""Reformat 'WH/JOB/00011''Work Order # 00011' for every
human-facing surface (form header, breadcrumbs, M2O dropdowns,
smart-button titles, error messages). The DB `name` is unchanged
so existing certs / deliveries / chatter references don't break.
"""
for job in self:
if job.name and '/' in job.name:
suffix = job.name.rsplit('/', 1)[-1]
job.display_name = _('Work Order # %s') % suffix
else:
job.display_name = job.name or ''
```
View change: the form `<h1>` binds `display_name` instead of `name`. Everywhere else Odoo uses `display_name` automatically — M2O widgets, kanban titles, list views, breadcrumbs.
### 2. Quantity gate on `fp.job.step.button_finish`
The gate only fires when there's a *downstream* step parts could move into. The **last runnable step** of a recipe is allowed to finish with parts here — they complete the recipe in place. (`qty_done` reconciliation at job close is unchanged for Phase 1; see Out of Scope.)
```python
def button_finish(self):
"""[existing docstring extended]
Quantity gate (new): refuses if qty_at_step > 0 AND there is at
least one downstream pending/ready step. The last runnable step
is exempt — parts finishing in place are valid. Manager bypass
via context key fp_skip_qty_gate=True.
"""
skip_qty_gate = self.env.context.get('fp_skip_qty_gate')
for step in self:
if step.state != 'in_progress':
raise UserError(...) # existing
if not skip_qty_gate and step.qty_at_step > 0:
has_downstream = step.job_id.step_ids.filtered(
lambda s: s.sequence > step.sequence
and s.state in ('pending', 'ready')
)
if has_downstream:
raise UserError(_(
"Step '%(name)s' still has %(n)d part(s) parked "
"— move them to the next step before finishing. "
"Use the row's 'Complete 1 → Next' or 'Move…' "
"button."
) % {'name': step.name, 'n': step.qty_at_step})
# No downstream step: this is the last runnable step.
# Parts finishing here become "done" with the recipe.
# ...remainder unchanged
```
### 3. New `fp.job.step.action_complete_one_to_next`
```python
def action_complete_one_to_next(self):
"""One-piece flow shortcut: records move(qty=1) from this step
to the next pending/ready step. Drains qty_at_step by 1. If the
drain takes qty_at_step to 0, auto-finishes the source step and
starts the destination step (delegates to action_finish_and_advance,
which already handles auto-start)."""
self.ensure_one()
if self.state != 'in_progress':
raise UserError(_(
"Step '%s' must be in progress to complete a part."
) % self.name)
if self.qty_at_step < 1:
raise UserError(_(
"No parts parked at step '%s' — nothing to complete."
) % self.name)
next_step = self.job_id.step_ids.filtered(
lambda s: s.sequence > self.sequence
and s.state in ('pending', 'ready')
).sorted('sequence')[:1]
if not next_step:
raise UserError(_(
"Step '%s' is the last runnable step on the job — "
"no downstream step to move into. Finish the step "
"instead (it will close out the job)."
) % self.name)
self.env['fp.job.step.move'].create({
'job_id': self.job_id.id,
'from_step_id': self.id,
'to_step_id': next_step.id,
'transfer_type': 'step',
'qty_moved': 1,
'moved_by_user_id': self.env.user.id,
})
# qty_at_step is computed from moves; force re-read before deciding
# whether this was the last part. Without invalidate the cache says
# "still 1 parked" and the auto-finish never fires.
self.invalidate_recordset(['qty_at_step'])
if self.qty_at_step == 0:
return self.action_finish_and_advance()
return True
```
### 4. Auto-move shim on `action_finish_current_step` + `action_finish_and_advance`
Both methods finish "the current step" and (for the former) "auto-start the next". The shim adds:
- **Before finishing:** if `qty_at_step == 1` AND there's a next pending/ready step → record a `move(qty=1)` to the next step, then proceed.
- **If `qty_at_step > 1`:** raise with a friendly message pointing at "Complete 1 → Next" or "Move…".
- **If `qty_at_step == 0`:** proceed as today (the parts already moved via Move wizard or Complete 1 → Next).
The shim lives in `action_finish_and_advance` (on `fp.job.step`); `action_finish_current_step` (on `fp.job`) calls it, so it inherits the shim. Single point of behaviour.
```python
def _fp_record_one_piece_auto_move(self):
"""Helper called from action_finish_and_advance. Decides whether
to silently record a move(qty=1) before the step finishes. Three
cases:
- qty_at_step == 0: nothing to do (parts already moved manually).
- qty_at_step == 1 + downstream step exists: record move(1).
- qty_at_step == 1 + no downstream (last step): no move; parts
complete in place.
- qty_at_step > 1 + downstream exists: raise (operator must use
Complete 1 → Next or Move… to drain the step).
- qty_at_step > 1 + no downstream (last step): allow; parts
all complete in place. (qty_done auto-tick is Phase 2.)
"""
self.ensure_one()
qty = self.qty_at_step
if qty <= 0:
return False
next_step = self.job_id.step_ids.filtered(
lambda s: s.sequence > self.sequence
and s.state in ('pending', 'ready')
).sorted('sequence')[:1]
if not next_step:
# Last runnable step — parts here complete in place. The
# button_finish gate already permits this case; just allow.
return False
if qty > 1:
raise UserError(_(
"Step '%s' still has %d parts here — use the row's "
"'Complete 1 → Next' button (for one-by-one flow) or "
"the 'Move…' wizard (for batched flow) to drain the "
"step before finishing."
) % (self.name, qty))
# qty == 1 and next_step exists → record the move silently.
self.env['fp.job.step.move'].create({
'job_id': self.job_id.id,
'from_step_id': self.id,
'to_step_id': next_step.id,
'transfer_type': 'step',
'qty_moved': 1,
'moved_by_user_id': self.env.user.id,
})
return True
```
Wired into `action_finish_and_advance` immediately before the existing finish logic:
```python
def action_finish_and_advance(self):
self.ensure_one()
if self.state == 'in_progress':
self._fp_record_one_piece_auto_move() # may raise on qty>1
# ...rest unchanged (button_finish + auto-start next)
```
### 5. View additions
In `fp_job_form_inherit.xml` (embedded step list):
```xml
<!-- Complete 1 part and advance — streaming flow (large parts
going one-by-one through the same step). Hidden when there's
nothing parked or the step isn't actively running. -->
<button name="action_complete_one_to_next" type="object"
string="Complete 1 → Next" icon="fa-forward"
class="btn-link text-success"
invisible="state != 'in_progress' or qty_at_step &lt; 1"/>
```
Placed in the row's button column, after "Pause" and before "Move…". The header `Finish & Next` button is unchanged in markup — the auto-move/qty-gate logic is entirely behind the existing button.
In the form header `<sheet>` block, change the `<h1>` to bind `display_name`:
```xml
<h1><field name="display_name"/></h1>
```
`qty_at_step` is already a list column on the embedded step list (visible as "Qty Here"). No change needed for visibility — the existing field declaration is sufficient for the `invisible=` expression.
## State transition diagram
```
Before this work:
in_progress ──button_finish──> done (no qty check)
After:
any step, qty_at_step==0 ──button_finish──> done
mid-recipe step, qty_at_step==1 ──Finish & Next──> [auto-move(1)] ──> done
mid-recipe step, qty_at_step==1 ──Complete 1→Next──> [move(1)] ──> done + start_next
mid-recipe step, qty_at_step>1 ──Complete 1→Next──> [move(1)] (stays in_progress)
mid-recipe step, qty_at_step>1 ──Finish & Next──> ❌ UserError (use shortcuts)
LAST recipe step, qty_at_step>0 ──Finish & Next──> done (no move; parts complete in place)
```
"Mid-recipe step" = at least one downstream step is pending/ready. "LAST recipe step" = no downstream step in pending/ready state (either truly last, or all later steps are skipped/cancelled).
## Test plan
New class `TestQtyGate` in `tests/test_fp_job_milestone_cascade.py`:
| Test | Scenario | Expected |
|---|---|---|
| `test_button_finish_blocks_when_qty_at_step` | qty_at_step=3, click Finish | `UserError("still 3 parts parked")` |
| `test_button_finish_bypass` | `fp_skip_qty_gate=True` context | state→done |
| `test_complete_one_to_next_records_move` | qty=3 → click | move(qty=1) created, qty_at_step=2, state still in_progress |
| `test_complete_one_to_next_auto_finishes_on_last` | qty=1 → click | move(qty=1), source state→done, next step started |
| `test_complete_one_to_next_blocks_when_empty` | qty=0 | `UserError("nothing to complete")` |
| `test_complete_one_to_next_blocks_when_no_next_step` | last step | `UserError("last runnable step")` |
| `test_complete_one_to_next_blocks_when_not_in_progress` | state=pending | `UserError("must be in progress")` |
| `test_finish_and_advance_auto_move_for_qty_1` | running step, qty_at_step=1 | move(qty=1) recorded, then finish + auto-start next |
| `test_finish_and_advance_blocks_for_qty_gt_1` | running step, qty_at_step=3 | `UserError("use Complete 1 → Next or Move")` |
| `test_finish_and_advance_passes_for_qty_0` | qty=0 (already moved) | finish proceeds, no extra move |
| `test_button_finish_allows_last_step_with_qty` | last runnable step, qty_at_step=3, click Finish | state→done; no UserError; no move recorded |
| `test_finish_and_advance_allows_last_step_with_qty_gt_1` | last runnable step, qty_at_step=5 | state→done; no auto-move; no UserError |
| `test_display_name_format` | name=`WH/JOB/00099` | display_name=`Work Order # 00099` |
| `test_display_name_no_slash_passthrough` | name=`SmokeJob` | display_name=`SmokeJob` |
| `test_move_wizard_blocks_zero_qty` | wizard.qty_moved=0 → commit | `UserError("at least 1")` |
## Files touched
| File | Change |
|---|---|
| `fusion_plating_jobs/models/fp_job.py` | Add `_compute_display_name` override. |
| `fusion_plating/models/fp_job_step.py` | Quantity gate in `button_finish`; new `action_complete_one_to_next`; new helper `_fp_record_one_piece_auto_move` invoked from `action_finish_and_advance`. |
| `fusion_plating_jobs/views/fp_job_form_inherit.xml` | Header `<h1>``display_name`; per-row "Complete 1 → Next" button. |
| `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` | New `TestQtyGate` class with the 13 tests above. |
| `fusion_plating_jobs/__manifest__.py` | Version bump. |
| `fusion_plating/__manifest__.py` | Version bump (touches `fp_job_step.py`). |
## Out of scope
- **Auto-tick `job.qty_done` when last step finishes.** Currently `qty_done` is operator-entered before the job-level "Mark Job Done" button. A future improvement: when the last runnable step finishes with `qty_at_step > 0`, automatically bump `job.qty_done` by that count. Skipped from Phase 1 because (a) the existing job-level qty-reconciliation gate already catches mismatches and (b) it requires capturing pre-finish `qty_at_step` into the existing-but-unused `qty_at_step_finish` field, which expands scope.
- **Per-step scrap tracking** — currently scrap is captured at the *job* level (`qty_scrapped`). Per-step scrap (which step did each scrap event happen at?) is a real shop-floor desire but a bigger data-model change; future spec.
- **Auto-finish on Move wizard's last move** — when the Move wizard records a move that drops `qty_at_step` to 0, it could optionally auto-finish the source step. Skipped because the Move wizard is already explicit (operator chose a qty); an extra confirmation step adds value. Can reconsider if the manual Finish click after a manual Move becomes a friction complaint.
- **Display name in CoC / cert PDFs** — `display_name` automatically threads through Odoo's M2O rendering, but the CoC PDF template may hardcode `name` in places. Audit pass in a follow-up if/when shop reports the new label needs to land on customer-facing paperwork.
## Implementation notes / gotchas
- `qty_at_step` is `compute=False, store=False`. After creating a Move in `action_complete_one_to_next`, the in-memory cache still holds the pre-move value. Always call `invalidate_recordset(['qty_at_step'])` before reading it to decide auto-finish.
- The Move wizard's existing zero-qty guard lives in `action_commit` (raises `UserError`). The new `action_complete_one_to_next` doesn't go through the wizard, so it has its own `qty_at_step < 1` check (gates differently — refuses when nothing to move, vs. refusing when qty entered is 0). Both surfaces are now protected.
- `display_name` is a magic field in Odoo — overriding its compute is the supported pattern. Odoo's M2O widget, breadcrumb, and `name_get` API all route through it. No additional wiring needed.

BIN
fusion_accounting/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

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