Compare commits

..

136 Commits

Author SHA1 Message Date
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
319 changed files with 48313 additions and 1969 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

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

View File

View File

@@ -252,10 +252,23 @@ class FusionCpShipment(models.Model):
}
def _action_open_attachment(self, attachment):
"""Open an attachment PDF in the browser viewer (new tab)."""
"""Open an attachment for the operator.
Delegates to ir.attachment.action_fusion_preview when
fusion_pdf_preview is installed — PDFs render in the preview
dialog, anything else downloads. Falls back to the legacy
new-tab URL when the helper isn't available. See CLAUDE.md
"PDF Preview" for the contract.
"""
self.ensure_one()
if not attachment:
return False
if hasattr(attachment, 'action_fusion_preview'):
return attachment.action_fusion_preview(
title=attachment.name or 'Shipping Label',
model_name=self._name,
record_ids=self.id,
)
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=false' % attachment.id,

1
fusion_claims/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
.superpowers/

3106
fusion_claims/CLAUDE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.8.0.6',
'version': '19.0.9.2.0',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -175,6 +175,18 @@
'fusion_claims/static/src/js/attachment_image_compress.js',
'fusion_claims/static/src/js/debug_required_fields.js',
'fusion_claims/static/src/xml/document_preview.xml',
# Dashboard: tokens MUST load before dashboard layout
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
# Dashboard OWL countdown widget
'fusion_claims/static/src/js/fc_posting_countdown.js',
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
],
'web.assets_web_dark': [
# Dark bundle recompiles the same SCSS with the dark
# $o-webclient-color-scheme default so tokens branch correctly.
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
],
},
'images': ['static/description/icon.png'],

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,432 @@
# Fusion Claims Dashboard — Design Spec
**Date:** 2026-05-21
**Module:** `fusion_claims`
**Status:** Design approved, ready for implementation plan
**Replaces:** the existing 4-panel HTML-field dashboard at `models/dashboard.py` + `views/dashboard_views.xml`
---
## 1. Purpose
Surface workflow flags, posting-week context, and per-funder hotlinks on a single dashboard so claims processors, sales reps, and managers can see at a glance what needs action today and how much money is in motion for the current ADP posting cycle.
The existing dashboard is a case-count overview. The new dashboard is action-oriented: "what's stuck, what's due this week, what should I be doing."
## 2. Audience and role behaviour
Single dashboard used by three personas, with auto-applied role filter:
- **Managers** (in `fusion_claims.group_fusion_claims_manager` or `sales_team.group_sale_manager`) — see all cases.
- **Office staff** — same as managers (they are typically in the manager group already, per the module's security model).
- **Sales reps** (only in `group_fusion_claims_user`) — see only SOs where `user_id = self.env.uid`.
A small "Showing your cases" hint appears above the workflow tiles when the role filter is active (driven by computed `is_manager`).
## 3. Scope
**In scope:**
- Posting-period banner with live countdown to submission cutoff
- 3 KPI tiles: Ready to Claim, Claimed This Period, Total AR (ADP-portion)
- 8 quick-action hotlinks: + ADP, + MOD, + ODSP, + WSIB, + Insurance, + MDC, + Hardship, + Private
- "Your Activities" list (top 10 of current user's `mail.activity`)
- Two bottleneck callouts: Approved without POD, Submitted with no ADP response > 14 days
- ADP Pre-Approval workflow tiles (4): Waiting App, App Received, Ready Submission, Needs Correction
- ADP Post-Approval workflow tiles (4): Approved, Ready Delivery, Ready Billing, On Hold
- MOD workflow tiles (5): Awaiting Funding, Funding Approved, PCA Received, Project Complete, POD Submitted
- Other-funder count cards (6): ODSP, WSIB, Insurance, MDC, Hardship, ACSD
- Light + dark theme support via compile-time SCSS branching
**Out of scope:**
- Charts / time-series graphs
- The existing 4 configurable HTML panels (removed)
- A "Recent Cases" power-user view (deferred — separate spec if needed)
- Auto-refresh on window focus (manual reload only)
- Per-user personalisation beyond the role filter (no saved layouts/filters)
- Push notifications, email digests (out of scope, handled elsewhere)
## 4. Architecture
### 4.1 Implementation pattern
**Hybrid: form-view shell + computed fields + small OWL widget for the live countdown.**
Server-rendered Bootstrap-grid form view sits on top of a TransientModel with ~36 computed fields. One OWL field-widget handles the live deadline countdown (ticks every 60 seconds, swaps colour as deadline approaches).
The TransientModel name `fusion.claims.dashboard` is **preserved** — existing menu/action records continue to resolve. The model's internals are rewritten; old fields are dropped.
### 4.2 Files
| File | Action | Purpose |
|---|---|---|
| `models/dashboard.py` | **Rewrite** | TransientModel with ~36 computed fields + role-filter helper + ~24 action methods |
| `views/dashboard_views.xml` | **Rewrite** | Form view: banner → KPIs → quick-actions → 2-column grid |
| `static/src/scss/_fc_dashboard_tokens.scss` | **New** | Colour palette tokens, compile-time `@if $o-webclient-color-scheme == dark` branch |
| `static/src/scss/fc_dashboard.scss` | **New** | Layout + section styles, references tokens |
| `static/src/js/fc_posting_countdown.js` | **New** | OWL field widget for live countdown (~60 lines) |
| `static/src/xml/fc_posting_countdown.xml` | **New** | OWL template (~10 lines) |
| `__manifest__.py` | **Edit** | Bump version (asset cache-bust), add SCSS to **both** `web.assets_backend` AND `web.assets_web_dark`, add JS+XML to backend |
### 4.3 Layout
```
┌──────────────────────────────────────────────────────────────┐
│ BANNER: Posting Period: Mar 5 19 · [OWL: 3d to cutoff] │
├──────────────────────────────────────────────────────────────┤
│ KPI TILES (3-up): Ready | Claimed | Total AR │
├──────────────────────────────────────────────────────────────┤
│ QUICK ACTIONS: + ADP + MOD + ODSP + WSIB + Ins + ... │
├────────────────────────┬─────────────────────────────────────┤
│ LEFT COLUMN │ RIGHT COLUMN │
│ Your Activities │ ADP Pre-Approval (4 tiles) │
│ Bottlenecks │ ADP Post-Approval (4 tiles) │
│ Other Funders (6) │ MOD (5 tiles) │
└────────────────────────┴─────────────────────────────────────┘
```
### 4.4 Data flow
1. User clicks Dashboard menu.
2. Existing `action_fusion_claims_dashboard` creates a fresh TransientModel record.
3. Compute methods run (5 clusters — see §6).
4. Form renders.
5. OWL countdown widget tickets every 60 s, reading `submission_deadline_dt` from the rendered field, formatting it client-side.
6. User clicks a tile → returns `ir.actions.act_window` opening a filtered `sale.order` list.
7. User clicks a quick-action pill → returns `ir.actions.act_window` opening a fresh `sale.order` form with `default_x_fc_sale_type` in context.
8. User clicks Refresh (form header button) → reloads the action.
## 5. Role filter
Central helper on `fusion.claims.dashboard`:
```python
def _role_filter_domain(self):
user = self.env.user
if (user.has_group('fusion_claims.group_fusion_claims_manager')
or user.has_group('sales_team.group_sale_manager')):
return []
return [('user_id', '=', user.id)]
```
Every count/sum compute method prepends `_role_filter_domain()` to its domain. For `account.move` based counts (KPIs), the filter is applied through `x_fc_source_sale_order_id.user_id` (the linked SO's salesperson) because invoices don't have their own `user_id` to filter on in this module.
`is_manager` (Boolean computed) exposed for the view to optionally show a "Showing your cases" hint.
## 6. Field inventory (≈36 fields)
### 6.1 Header / banner
| Field | Type | Description |
|---|---|---|
| `posting_period_label` | Char | e.g. `"Mar 5 Mar 19"` |
| `posting_period_start` | Date | Start of current posting cycle |
| `posting_period_end` | Date | Start of next cycle (exclusive) |
| `submission_deadline_dt` | Datetime | Wed 18:00 of posting week, Toronto TZ |
| `is_manager` | Boolean | Drives role-hint visibility |
| `is_pre_first_posting` | Boolean | True if today < `adp_posting_base_date` |
Derived from helpers already on `adp.posting.schedule.mixin`. Dashboard `_inherit = ['adp.posting.schedule.mixin']`.
### 6.2 KPI tiles
| Field | Type | Source |
|---|---|---|
| `kpi_ready_amount` | Monetary | Sum of `account.move.amount_total` where `x_fc_adp_billing_status='waiting'` AND `adp_exported=False`, role-filtered via linked SO |
| `kpi_ready_count` | Integer | Same filter, count |
| `kpi_claimed_amount` | Monetary | Sum where `x_fc_adp_billing_status in ('submitted','resubmitted')` AND `adp_export_date >= posting_period_start` |
| `kpi_claimed_count` | Integer | Same filter, count |
| `kpi_ar_amount` | Monetary | Sum where `move_type='out_invoice'`, `state='posted'`, `payment_state in ('not_paid','partial')`, `x_fc_invoice_type='adp'` |
| `kpi_ar_count` | Integer | Same filter, count |
| `currency_id` | Many2one | Defaults to `company_id.currency_id` |
### 6.3 Activities (left column)
| Field | Type | Description |
|---|---|---|
| `my_activities_count` | Integer | `mail.activity` where `user_id=current_user` AND `res_model in ('sale.order','account.move','fusion.technician.task')` |
| `my_activities_html` | Html | Top 10 ordered by `date_deadline asc`, links via `/odoo/<model>/<id>`, overdue rows tinted |
### 6.4 Bottlenecks (left column)
| Field | Type | Domain |
|---|---|---|
| `bottleneck_no_pod_count` | Integer | ADP cases `x_fc_adp_application_status in ('approved','approved_deduction')` AND `x_fc_proof_of_delivery=False` |
| `bottleneck_no_response_count` | Integer | ADP cases `x_fc_adp_application_status in ('submitted','resubmitted')` AND `x_fc_claim_submission_date < today - 14 days` |
### 6.5 Other funders (left column)
Each is an Integer count of active (non-terminal) cases:
| Field | Domain |
|---|---|
| `count_odsp` | `x_fc_sale_type in ('odsp','adp_odsp')` excluding division-specific terminal states |
| `count_wsib` | `x_fc_sale_type='wsib'` excluding `case_closed`, `cancelled`, `denied` |
| `count_insurance` | `x_fc_sale_type='insurance'` excluding terminal states |
| `count_mdc` | `x_fc_sale_type='muscular_dystrophy'` excluding terminal states |
| `count_hardship` | `x_fc_sale_type='hardship'` excluding terminal states |
| `count_acsd` | `x_fc_client_type='ACS'` excluding terminal states |
### 6.6 ADP Pre-Approval (right column, 4 tiles)
| Field | Status filter |
|---|---|
| `adp_waiting_app_count` | `x_fc_adp_application_status in ('waiting_for_application','assessment_completed')` |
| `adp_app_received_count` | `x_fc_adp_application_status='application_received'` |
| `adp_ready_submit_count` | `x_fc_adp_application_status='ready_submission'` |
| `adp_needs_correction_count` | `x_fc_adp_application_status='needs_correction'` (rendered as urgent tile) |
`adp_waiting_app_count` and `adp_needs_correction_count` are styled `--urgent` (red tint).
### 6.7 ADP Post-Approval (right column, 4 tiles)
| Field | Status filter |
|---|---|
| `adp_approved_count` | `x_fc_adp_application_status in ('approved','approved_deduction')` |
| `adp_ready_delivery_count` | `x_fc_adp_application_status='ready_delivery'` |
| `adp_ready_bill_count` | `x_fc_adp_application_status='ready_bill'` |
| `adp_on_hold_count` | `x_fc_adp_application_status='on_hold'` (rendered as urgent tile) |
### 6.8 MOD (right column, 5 tiles)
| Field | Status filter |
|---|---|
| `mod_awaiting_funding_count` | `x_fc_mod_status='awaiting_funding'` |
| `mod_funding_approved_count` | `x_fc_mod_status='funding_approved'` |
| `mod_pca_received_count` | `x_fc_mod_status='contract_received'` |
| `mod_project_complete_count` | `x_fc_mod_status='project_complete'` |
| `mod_pod_submitted_count` | `x_fc_mod_status='pod_submitted'` |
## 7. Compute method clustering
Five compute methods, each owning a logical section so an expensive query in one cluster doesn't recompute the rest:
| Method | Fields populated |
|---|---|
| `_compute_banner` | 6 banner fields |
| `_compute_kpis` | 6 KPI fields + `currency_id` |
| `_compute_activities` | 2 activity fields |
| `_compute_workflow_counts` | 13 stage-tile fields (ADP + MOD) |
| `_compute_secondary_counts` | 8 fields (bottlenecks + other funders) |
All compute methods are bound to non-stored `compute='_compute_*'` fields (no `@api.depends` since TransientModel records are throwaway — every dashboard open is a fresh record). Counts use `search_count()` not `search()` to avoid loading recordsets.
## 8. Action methods (~24)
### 8.1 `action_open_<bucket>` (~16)
Thin wrappers returning `ir.actions.act_window`. Where the module already has per-stage actions (e.g. `adp_claims_views.xml` defines `act_window_adp_ready_for_billing`), reuse them via `self.env.ref(...).read()[0]`. Otherwise build the action inline.
Examples:
- `action_open_adp_waiting_app` — opens SO list filtered to `('x_fc_adp_application_status', 'in', ['waiting_for_application', 'assessment_completed'])`
- `action_open_bottleneck_no_pod` — opens SO list filtered to approved-without-POD
- `action_open_my_activities` — opens activity list filtered to current user
### 8.2 `action_create_<funder>_so` (8)
One per funder hotlink. Each opens a fresh `sale.order` form with `default_x_fc_sale_type` in context:
| Method | Context |
|---|---|
| `action_create_adp_so` | `{'default_x_fc_sale_type': 'adp'}` |
| `action_create_mod_so` | `{'default_x_fc_sale_type': 'march_of_dimes'}` |
| `action_create_odsp_so` | `{'default_x_fc_sale_type': 'odsp', 'default_x_fc_odsp_division': 'standard'}` |
| `action_create_wsib_so` | `{'default_x_fc_sale_type': 'wsib'}` |
| `action_create_insurance_so` | `{'default_x_fc_sale_type': 'insurance'}` |
| `action_create_mdc_so` | `{'default_x_fc_sale_type': 'muscular_dystrophy'}` |
| `action_create_hardship_so` | `{'default_x_fc_sale_type': 'hardship'}` |
| `action_create_private_so` | `{'default_x_fc_sale_type': 'direct_private'}` |
User picks ODSP division on the SO form (we default to `standard`, they can change to `sa_mobility` or `ontario_works`).
## 9. Theming (SCSS structure)
### 9.1 File order
Tokens load **first** in each bundle. SCSS variables defined in `_fc_dashboard_tokens.scss` must be in scope when `fc_dashboard.scss` is compiled. Odoo concatenates SCSS within a bundle in registration order, so the manifest registration sequence is load-bearing — see §11.
### 9.2 `_fc_dashboard_tokens.scss`
Single source of truth. Define light values at top level, override with `!global` inside `@if $o-webclient-color-scheme == dark`. Token names use the `$_fc-*` convention (underscore prefix for "private" partials).
Light palette (22 tokens):
```
page-bg: #f7f7f8 card-bg: #ffffff card-border: #d8dadd
text: #2b2b2b text-muted: #6c7480
banner: linear-gradient(#eef2ff → #fce7f3) border: #c7d2fe text: #3730a3
deadline-text: #b91c1c
kpi-bg: #f0f4ff kpi-border: #c7d2fe kpi-num: #1e3a8a
action-bg: #ecfdf5 action-border: #6ee7b7 action-text: #047857
tile-bg: #f3f4f6 tile-border: #e5e7eb tile-num: #111827
urgent-bg: #fee2e2 urgent-border: #fca5a5 urgent-num: #991b1b urgent-text: #7f1d1d
activity-bg: #fefce8 activity-border: #fde047
bottleneck-bg: #fef2f2 bottleneck-border: #fecaca
```
Dark palette overrides (cool blue monochrome banner per Round 3 selection):
```
page-bg: #1a1d21 card-bg: #22262d card-border: #3a3f47
text: #e5e7eb text-muted: #9ca3af
banner: linear-gradient(#1e293b → #1e3a5f) border: #3b82f6 text: #93c5fd
deadline-text: #fca5a5
kpi-bg: #1e293b kpi-border: #334155 kpi-num: #93c5fd
action-bg: #064e3b action-border: #047857 action-text: #6ee7b7
tile-bg: #2d3138 tile-border: #3a3f47 tile-num: #f3f4f6
urgent-bg: #4a1414 urgent-border: #7f1d1d urgent-num: #fca5a5 urgent-text: #fecaca
activity-bg: #3a2e0a activity-border: #854d0e
bottleneck-bg: #3a1414 bottleneck-border: #7f1d1d
```
### 9.3 `fc_dashboard.scss`
Layout file. Re-exports each token as a CSS custom property scoped under `.o_fc_dashboard` so dev-tools can inspect/tweak live, then uses both the SCSS variable (for compile-time work like `darken()`) and the CSS variable (for runtime). Section classes:
- `.o_fc_banner` — gradient + border, flex-row with deadline countdown on the right
- `.o_fc_kpi` (with `.o_fc_kpi__num`) — 3-up KPI tiles
- `.o_fc_pill` — quick-action button pills
- `.o_fc_activities`, `.o_fc_bottleneck` — left-column section backgrounds
- `.o_fc_tile`, `.o_fc_tile--urgent` (with `.o_fc_tile__num`) — workflow stage tiles
- `.o_fc_countdown--info` / `.o_fc_countdown--warning` / `.o_fc_countdown--danger` / `.o_fc_countdown--muted` — countdown widget colour levels (driven by OWL state)
### 9.4 Verification
After deploy, in `odoo-shell`:
```python
env['ir.qweb']._get_asset_bundle('web.assets_backend').css() # light bundle URL
env['ir.qweb']._get_asset_bundle('web.assets_web_dark').css() # dark bundle URL
```
The two URLs must differ. If they're identical, the dark bundle didn't recompile — fix by deleting `ir.attachment` rows under `/web/assets/%` and restarting Odoo.
## 10. OWL countdown widget
### 10.1 Why a widget
The rest of the dashboard is fine being recomputed on page open — case counts move slowly. The countdown ("3 days 4 hours to cutoff") needs to tick without a page refresh, and its colour needs to shift as the deadline approaches (info → warning → danger).
### 10.2 Behaviour
- Registered as a field widget under the name `fc_posting_countdown`.
- Reads `submission_deadline_dt` from `props.record.data`.
- Ticks every 60 seconds via `setInterval`. Cleared on `onWillDestroy`.
- Four levels with auto-shift:
- `> 3 days remaining`**info** (banner text colour)
- `13 days`**warning** (amber)
- `< 24 hours`**danger** (urgent-num colour)
- `past deadline`**muted** (text-muted colour), text reads "Cutoff passed"
- Uses Luxon for date math (already loaded by Odoo).
### 10.3 Template
```xml
<templates xml:space="preserve">
<t t-name="fusion_claims.PostingCountdown" owl="1">
<span t-att-class="'o_fc_countdown o_fc_countdown--' + state.level"
t-esc="state.text"/>
</t>
</templates>
```
### 10.4 Use in form view
```xml
<field name="submission_deadline_dt"
widget="fc_posting_countdown"
nolabel="1"
readonly="1"/>
```
## 11. Manifest changes
```python
'version': '<bump minor>', # e.g. 19.0.8.0.7 → 19.0.9.0.0 for asset cache-bust per CLAUDE.md §Asset Cache Busting
'data': [
# ...existing entries (data files load order unchanged)...
'views/dashboard_views.xml', # rewritten
],
'assets': {
'web.assets_backend': [
# ...existing entries...
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss', # tokens FIRST
'fusion_claims/static/src/scss/fc_dashboard.scss',
'fusion_claims/static/src/js/fc_posting_countdown.js',
'fusion_claims/static/src/xml/fc_posting_countdown.xml',
],
'web.assets_web_dark': [
'fusion_claims/static/src/scss/_fc_dashboard_tokens.scss',
'fusion_claims/static/src/scss/fc_dashboard.scss',
# No JS in dark bundle — Odoo loads JS once from backend.
],
},
```
Token file is registered **before** layout file in **both** bundles. JS+XML only in backend.
## 12. Edge cases
### 12.1 Pre-first-posting
If today < `fusion_claims.adp_posting_base_date` (default 2026-01-23), `_get_current_posting_date()` returns the base date itself. Treatment:
- `posting_period_label` reads `"Posting starts Jan 23"`.
- `submission_deadline_dt` set to first Wednesday at 18:00.
- KPI tiles all show `$0 / 0` (no posting period to bill against yet).
- `is_pre_first_posting=True` is exposed; view shows a one-line info note above the KPIs.
### 12.2 No invoices / empty system
All counts compute to 0. KPI tiles render `$0.00`. Activities section renders an empty-state message ("No activities assigned"). Bottleneck section hides itself when both counts are zero.
### 12.3 Sales rep with no assigned SOs
`_role_filter_domain()` returns `[('user_id', '=', user.id)]`. All counts → 0. The form still renders; "Showing your cases" hint plus an empty-state message ("You have no assigned cases").
### 12.4 Portal user accidentally clicks dashboard menu
The dashboard menu is already gated by `groups_id` on the existing menu item to `fusion_claims.group_fusion_claims_user` (internal users only). Confirm this is preserved in the rewritten `dashboard_views.xml`.
### 12.5 Currency mix
KPI sums assume a single company currency. `currency_id` defaults to `company_id.currency_id`. If invoices in another currency exist, they are summed in their own currency by Odoo's standard behaviour — out of scope to handle multi-currency for this dashboard. Document this limitation in the design note.
## 13. Decisions explicitly excluded
- **Auto-refresh on window focus** — considered, dropped to keep scope tight. Manual refresh via form header button is sufficient.
- **The 4 configurable HTML panels from the existing dashboard** — removed entirely. If a "Recent Cases" view is needed later, that's a separate spec.
- **Per-funder workflow tiles for ODSP / WSIB / Insurance / MDC / Hardship** — those funders get a count card only, not a row of stage tiles. Decision: keep the dashboard focused on the two highest-volume funders (ADP, MOD).
- **Toggle between "My Cases" and "All Cases"** — group-based auto-filter only. Sales reps see their cases, managers see everything, no switch.
## 14. Acceptance criteria
1. Dashboard menu opens to a single page; old 4-panel UI gone.
2. Banner shows current posting period and a live (ticking) countdown to Wed 6 PM cutoff.
3. 3 KPI tiles render with correct dollar amounts for Ready / Claimed This Period / Total AR.
4. 8 quick-action pills open a fresh SO form with the correct `x_fc_sale_type` pre-applied.
5. All 17 workflow tiles show non-stale counts (verified by clicking a tile → resulting SO list count matches the tile number).
6. Both bottleneck callouts compute and render; clicking opens the matching filtered SO list.
7. Sales reps see only their own cases; managers see all.
8. Light and dark themes render the dashboard without any invisible / low-contrast elements. Verified by:
- Opening in light mode → no `display:none`-like artifacts, all text readable.
- Switching to dark mode (user profile → Color Scheme → Dark → reload) → all colours shift to the dark palette, banner gradient is the cool blue monochrome.
9. Asset bundles compile to distinct URLs in both themes (verified with the §9.4 snippet).
10. No regression on existing dashboard menu item / action references — module loads cleanly, no XML resolution errors.
## 15. Open questions / non-decisions
None. All design choices are locked in. Implementation plan can proceed.

View File

@@ -4,159 +4,763 @@
from odoo import api, fields, models
CASE_TYPE_SELECTION = [
('adp', 'ADP'),
('odsp', 'ODSP'),
('march_of_dimes', 'March of Dimes'),
('hardship', 'Hardship Funding'),
('acsd', 'ACSD'),
('muscular_dystrophy', 'Muscular Dystrophy'),
('insurance', 'Insurance'),
('wsib', 'WSIB'),
]
TYPE_DOMAINS = {
'adp': [('x_fc_sale_type', 'in', ['adp', 'adp_odsp'])],
'odsp': [('x_fc_sale_type', 'in', ['odsp', 'adp_odsp'])],
'march_of_dimes': [('x_fc_sale_type', '=', 'march_of_dimes')],
'hardship': [('x_fc_sale_type', '=', 'hardship')],
'acsd': [('x_fc_client_type', '=', 'ACS')],
'muscular_dystrophy': [('x_fc_sale_type', '=', 'muscular_dystrophy')],
'insurance': [('x_fc_sale_type', '=', 'insurance')],
'wsib': [('x_fc_sale_type', '=', 'wsib')],
}
TYPE_LABELS = dict(CASE_TYPE_SELECTION)
class FusionClaimsDashboard(models.TransientModel):
_name = 'fusion.claims.dashboard'
_inherit = 'fusion_claims.adp.posting.schedule.mixin'
_description = 'Fusion Claims Dashboard'
_rec_name = 'name'
name = fields.Char(default='Dashboard', readonly=True)
# Case counts by funding type
adp_count = fields.Integer(compute='_compute_stats')
odsp_count = fields.Integer(compute='_compute_stats')
march_of_dimes_count = fields.Integer(compute='_compute_stats')
hardship_count = fields.Integer(compute='_compute_stats')
acsd_count = fields.Integer(compute='_compute_stats')
muscular_dystrophy_count = fields.Integer(compute='_compute_stats')
insurance_count = fields.Integer(compute='_compute_stats')
wsib_count = fields.Integer(compute='_compute_stats')
total_profiles = fields.Integer(compute='_compute_stats')
# =========================================================================
# Role-aware filter
# =========================================================================
is_manager = fields.Boolean(compute='_compute_is_manager')
# Panel selectors (4 panels)
panel1_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 1', default='adp')
panel2_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 2', default='odsp')
panel3_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 3', default='march_of_dimes')
panel4_type = fields.Selection(CASE_TYPE_SELECTION, string='Window 4', default='hardship')
# Panel HTML
panel1_html = fields.Html(compute='_compute_panels', sanitize=False)
panel2_html = fields.Html(compute='_compute_panels', sanitize=False)
panel3_html = fields.Html(compute='_compute_panels', sanitize=False)
panel4_html = fields.Html(compute='_compute_panels', sanitize=False)
panel1_title = fields.Char(compute='_compute_panels')
panel2_title = fields.Char(compute='_compute_panels')
panel3_title = fields.Char(compute='_compute_panels')
panel4_title = fields.Char(compute='_compute_panels')
def _compute_stats(self):
SO = self.env['sale.order'].sudo()
Profile = self.env['fusion.client.profile'].sudo()
def _compute_is_manager(self):
manager_group = self.env.ref('fusion_claims.group_fusion_claims_manager',
raise_if_not_found=False)
sale_mgr_group = self.env.ref('sales_team.group_sale_manager',
raise_if_not_found=False)
for rec in self:
rec.adp_count = SO.search_count(TYPE_DOMAINS['adp'])
rec.odsp_count = SO.search_count(TYPE_DOMAINS['odsp'])
rec.march_of_dimes_count = SO.search_count(TYPE_DOMAINS['march_of_dimes'])
rec.hardship_count = SO.search_count(TYPE_DOMAINS['hardship'])
rec.acsd_count = SO.search_count(TYPE_DOMAINS['acsd'])
rec.muscular_dystrophy_count = SO.search_count(TYPE_DOMAINS['muscular_dystrophy'])
rec.insurance_count = SO.search_count(TYPE_DOMAINS['insurance'])
rec.wsib_count = SO.search_count(TYPE_DOMAINS['wsib'])
rec.total_profiles = Profile.search_count([])
@api.depends('panel1_type', 'panel2_type', 'panel3_type', 'panel4_type')
def _compute_panels(self):
SO = self.env['sale.order'].sudo()
for rec in self:
for i in range(1, 5):
ptype = getattr(rec, f'panel{i}_type') or 'adp'
domain = TYPE_DOMAINS.get(ptype, [])
orders = SO.search(domain, order='create_date desc', limit=50)
count = SO.search_count(domain)
title = f'Window {i} - {TYPE_LABELS.get(ptype, ptype)} ({count} cases)'
html = rec._build_top_list(orders)
setattr(rec, f'panel{i}_title', title)
setattr(rec, f'panel{i}_html', html)
def _build_top_list(self, orders):
if not orders:
return '<p class="text-muted text-center py-4">No cases found</p>'
rows = []
for o in orders:
status = o.x_fc_adp_application_status or ''
status_label = dict(o._fields['x_fc_adp_application_status'].selection).get(status, status)
rows.append(
f'<tr>'
f'<td><a href="/odoo/sales/{o.id}">{o.name}</a></td>'
f'<td>{o.partner_id.name or ""}</td>'
f'<td>{status_label}</td>'
f'<td class="text-end">${o.amount_total:,.2f}</td>'
f'</tr>'
user = rec.env.user
rec.is_manager = bool(
(manager_group and user.has_group('fusion_claims.group_fusion_claims_manager'))
or (sale_mgr_group and user.has_group('sales_team.group_sale_manager'))
)
return (
'<table class="table table-sm table-hover mb-0">'
'<thead><tr><th>Order</th><th>Client</th><th>Status</th><th class="text-end">Total</th></tr></thead>'
'<tbody>' + ''.join(rows) + '</tbody></table>'
)
def action_open_order(self, order_id):
"""Open a specific sale order with breadcrumbs."""
def _role_filter_domain(self):
"""Common domain prefix for SO-based counts.
Managers (fusion_claims.group_fusion_claims_manager or
sales_team.group_sale_manager) see everything.
Other users see only SOs where they are the salesperson.
"""
self.ensure_one()
if self.is_manager:
return []
return [('user_id', '=', self.env.user.id)]
def _month_start(self):
from datetime import date
return date.today().replace(day=1)
# =========================================================================
# Header banner
# =========================================================================
posting_period_label = fields.Char(compute='_compute_banner')
posting_period_start = fields.Date(compute='_compute_banner')
posting_period_end = fields.Date(compute='_compute_banner')
submission_deadline_dt = fields.Datetime(compute='_compute_banner')
is_pre_first_posting = fields.Boolean(compute='_compute_banner')
def _compute_banner(self):
from datetime import date, datetime, time, timedelta
import pytz
today = date.today()
for rec in self:
base_date = rec._get_adp_posting_base_date()
rec.is_pre_first_posting = today < base_date
current = rec._get_current_posting_date(today)
nxt = rec._get_next_posting_date(today)
# If we're sitting on a posting date, current == next; treat
# the period as the one starting today.
if current == nxt:
period_start = current
period_end = current + timedelta(days=rec._get_adp_posting_frequency())
else:
period_start = current
period_end = nxt
rec.posting_period_start = period_start
rec.posting_period_end = period_end
if rec.is_pre_first_posting:
rec.posting_period_label = f"Posting starts {base_date.strftime('%b %d')}"
else:
rec.posting_period_label = (
f"{period_start.strftime('%b %d')} "
f"{period_end.strftime('%b %d')}"
)
wednesday = rec._get_posting_week_wednesday(nxt)
naive_deadline = datetime.combine(wednesday, time(18, 0, 0))
# Store as UTC; users see it in their TZ; OWL widget computes in local TZ.
tz = pytz.timezone(rec.env.user.tz or 'America/Toronto')
local_deadline = tz.localize(naive_deadline)
rec.submission_deadline_dt = local_deadline.astimezone(pytz.UTC).replace(tzinfo=None)
# =========================================================================
# KPI tiles (3-up)
# =========================================================================
currency_id = fields.Many2one('res.currency', compute='_compute_kpis')
kpi_ready_amount = fields.Monetary(compute='_compute_kpis',
currency_field='currency_id')
kpi_ready_count = fields.Integer(compute='_compute_kpis')
kpi_claimed_amount = fields.Monetary(compute='_compute_kpis',
currency_field='currency_id')
kpi_claimed_count = fields.Integer(compute='_compute_kpis')
kpi_ar_amount = fields.Monetary(compute='_compute_kpis',
currency_field='currency_id')
kpi_ar_count = fields.Integer(compute='_compute_kpis')
def _invoice_role_filter(self):
"""Role filter for invoices — applied through linked SO's user_id."""
self.ensure_one()
if self.is_manager:
return []
return [('x_fc_source_sale_order_id.user_id', '=', self.env.user.id)]
def _compute_kpis(self):
Move = self.env['account.move'].sudo()
for rec in self:
rec.currency_id = rec.env.company.currency_id
inv_filter = rec._invoice_role_filter()
# KPI 1: Ready to Claim
ready_domain = inv_filter + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', '=', 'waiting'),
('adp_exported', '=', False),
]
ready_invoices = Move.search(ready_domain)
rec.kpi_ready_count = len(ready_invoices)
rec.kpi_ready_amount = sum(ready_invoices.mapped('amount_total'))
# KPI 2: Claimed This Period
claimed_domain = inv_filter + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', 'in', ['submitted', 'resubmitted']),
('adp_export_date', '>=', rec.posting_period_start),
]
claimed_invoices = Move.search(claimed_domain)
rec.kpi_claimed_count = len(claimed_invoices)
rec.kpi_claimed_amount = sum(claimed_invoices.mapped('amount_total'))
# KPI 3: Total AR (ADP-portion invoices, unpaid)
ar_domain = inv_filter + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_invoice_type', '=', 'adp'),
('payment_state', 'in', ['not_paid', 'partial']),
]
ar_invoices = Move.search(ar_domain)
rec.kpi_ar_count = len(ar_invoices)
rec.kpi_ar_amount = sum(ar_invoices.mapped('amount_total'))
# =========================================================================
# Activities (left column)
# =========================================================================
my_activities_count = fields.Integer(compute='_compute_activities')
my_activities_html = fields.Html(compute='_compute_activities', sanitize=False)
def _compute_activities(self):
Activity = self.env['mail.activity'].sudo()
domain = [
('user_id', '=', self.env.user.id),
('res_model', 'in', ['sale.order', 'account.move', 'fusion.technician.task']),
]
for rec in self:
activities = Activity.search(domain, order='date_deadline asc', limit=10)
rec.my_activities_count = Activity.search_count(domain)
if not activities:
rec.my_activities_html = (
'<p class="o_fc_empty">No activities assigned.</p>'
)
continue
from datetime import date
today = date.today()
rows = []
for act in activities:
overdue = act.date_deadline and act.date_deadline < today
row_class = 'o_fc_activity_row o_fc_activity_overdue' if overdue else 'o_fc_activity_row'
deadline_text = act.date_deadline.strftime('%b %d') if act.date_deadline else ''
url = f'/odoo/{act.res_model.replace(".", "_")}/{act.res_id}'
rows.append(
f'<div class="{row_class}">'
f'<a href="{url}"><b>{act.summary or act.activity_type_id.name or "Activity"}</b></a>'
f'<span class="o_fc_activity_deadline">{deadline_text}</span>'
f'</div>'
)
rec.my_activities_html = '\n'.join(rows)
# =========================================================================
# Bottlenecks (left column) + Other funder counts
# =========================================================================
bottleneck_no_pod_count = fields.Integer(compute='_compute_secondary_counts')
bottleneck_no_response_count = fields.Integer(compute='_compute_secondary_counts')
count_odsp = fields.Integer(compute='_compute_secondary_counts')
count_wsib = fields.Integer(compute='_compute_secondary_counts')
count_insurance = fields.Integer(compute='_compute_secondary_counts')
count_mdc = fields.Integer(compute='_compute_secondary_counts')
count_hardship = fields.Integer(compute='_compute_secondary_counts')
count_acsd = fields.Integer(compute='_compute_secondary_counts')
def _compute_secondary_counts(self):
from datetime import date, timedelta
SO = self.env['sale.order'].sudo()
cutoff_14d_ago = date.today() - timedelta(days=14)
for rec in self:
base = rec._role_filter_domain()
active = base + [('state', '!=', 'cancel')]
rec.bottleneck_no_pod_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
('x_fc_proof_of_delivery', '=', False),
])
rec.bottleneck_no_response_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
('x_fc_claim_submission_date', '<', cutoff_14d_ago),
])
rec.count_odsp = SO.search_count(active + [
('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']),
])
rec.count_wsib = SO.search_count(active + [('x_fc_sale_type', '=', 'wsib')])
rec.count_insurance = SO.search_count(active + [('x_fc_sale_type', '=', 'insurance')])
rec.count_mdc = SO.search_count(active + [('x_fc_sale_type', '=', 'muscular_dystrophy')])
rec.count_hardship = SO.search_count(active + [('x_fc_sale_type', '=', 'hardship')])
rec.count_acsd = SO.search_count(active + [('x_fc_client_type', '=', 'ACS')])
# =========================================================================
# ADP Pre-Approval (right column, 4 tiles)
# =========================================================================
adp_waiting_app_count = fields.Integer(compute='_compute_workflow_counts')
adp_app_received_count = fields.Integer(compute='_compute_workflow_counts')
adp_ready_submit_count = fields.Integer(compute='_compute_workflow_counts')
adp_needs_correction_count = fields.Integer(compute='_compute_workflow_counts')
# =========================================================================
# ADP Post-Approval (right column, 4 tiles)
# =========================================================================
adp_approved_count = fields.Integer(compute='_compute_workflow_counts')
adp_ready_delivery_count = fields.Integer(compute='_compute_workflow_counts')
adp_ready_bill_count = fields.Integer(compute='_compute_workflow_counts')
adp_on_hold_count = fields.Integer(compute='_compute_workflow_counts')
# =========================================================================
# MOD (right column, 5 tiles)
# =========================================================================
mod_awaiting_funding_count = fields.Integer(compute='_compute_workflow_counts')
mod_funding_approved_count = fields.Integer(compute='_compute_workflow_counts')
mod_pca_received_count = fields.Integer(compute='_compute_workflow_counts')
mod_project_complete_count = fields.Integer(compute='_compute_workflow_counts')
mod_pod_submitted_count = fields.Integer(compute='_compute_workflow_counts')
def _compute_workflow_counts(self):
SO = self.env['sale.order'].sudo()
for rec in self:
base = rec._role_filter_domain()
# ADP Pre-Approval
rec.adp_waiting_app_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed']),
])
rec.adp_app_received_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'application_received'),
])
rec.adp_ready_submit_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'ready_submission'),
])
rec.adp_needs_correction_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'needs_correction'),
])
# ADP Post-Approval
rec.adp_approved_count = SO.search_count(base + [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
])
rec.adp_ready_delivery_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'ready_delivery'),
])
rec.adp_ready_bill_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'ready_bill'),
])
rec.adp_on_hold_count = SO.search_count(base + [
('x_fc_adp_application_status', '=', 'on_hold'),
])
# MOD
rec.mod_awaiting_funding_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'awaiting_funding'),
])
rec.mod_funding_approved_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'funding_approved'),
])
rec.mod_pca_received_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'contract_received'),
])
rec.mod_project_complete_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'project_complete'),
])
rec.mod_pod_submitted_count = SO.search_count(base + [
('x_fc_mod_status', '=', 'pod_submitted'),
])
# =========================================================================
# This Month rollup (4-up secondary KPI strip)
# =========================================================================
count_month_submitted = fields.Integer(compute='_compute_this_month')
count_month_approved = fields.Integer(compute='_compute_this_month')
count_month_delivered = fields.Integer(compute='_compute_this_month')
count_month_billed = fields.Integer(compute='_compute_this_month')
def _compute_this_month(self):
SO = self.env['sale.order'].sudo()
for rec in self:
base = rec._role_filter_domain()
ms = rec._month_start()
rec.count_month_submitted = SO.search_count(base + [
('x_fc_claim_submission_date', '>=', ms),
])
rec.count_month_approved = SO.search_count(base + [
('x_fc_claim_approval_date', '>=', ms),
])
rec.count_month_delivered = SO.search_count(base + [
('x_fc_adp_delivery_date', '>=', ms),
])
rec.count_month_billed = SO.search_count(base + [
('x_fc_billing_date', '>=', ms),
])
# =========================================================================
# Pipeline $ by stage (4-up money-in-motion strip)
# =========================================================================
pipeline_pre_amount = fields.Monetary(compute='_compute_pipeline',
currency_field='currency_id')
pipeline_submitted_amount = fields.Monetary(compute='_compute_pipeline',
currency_field='currency_id')
pipeline_approved_amount = fields.Monetary(compute='_compute_pipeline',
currency_field='currency_id')
pipeline_ready_bill_amount = fields.Monetary(compute='_compute_pipeline',
currency_field='currency_id')
def _compute_pipeline(self):
SO = self.env['sale.order'].sudo()
for rec in self:
base = rec._role_filter_domain()
pre = SO.search(base + [
('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed',
'application_received', 'ready_submission']),
])
sub = SO.search(base + [
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
])
app = SO.search(base + [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
])
bill = SO.search(base + [
('x_fc_adp_application_status', '=', 'ready_bill'),
])
rec.pipeline_pre_amount = sum(pre.mapped('amount_total'))
rec.pipeline_submitted_amount = sum(sub.mapped('amount_total'))
rec.pipeline_approved_amount = sum(app.mapped('amount_total'))
rec.pipeline_ready_bill_amount = sum(bill.mapped('amount_total'))
# =========================================================================
# Aging buckets (disjoint: 30-59d, 60-89d, 90+d)
# =========================================================================
aging_30_count = fields.Integer(compute='_compute_aging')
aging_60_count = fields.Integer(compute='_compute_aging')
aging_90_count = fields.Integer(compute='_compute_aging')
def _compute_aging(self):
from datetime import date, timedelta
SO = self.env['sale.order'].sudo()
today = date.today()
cut_30 = today - timedelta(days=30)
cut_60 = today - timedelta(days=60)
cut_90 = today - timedelta(days=90)
# "Active" = SO not cancelled at order level, AND if it has an ADP
# status, it's not in a terminal ADP state.
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
for rec in self:
base = rec._role_filter_domain() + [
('state', '!=', 'cancel'),
'|',
('x_fc_adp_application_status', '=', False),
('x_fc_adp_application_status', 'not in', terminal_adp),
]
rec.aging_30_count = SO.search_count(base + [
('create_date', '<', cut_30),
('create_date', '>=', cut_60),
])
rec.aging_60_count = SO.search_count(base + [
('create_date', '<', cut_60),
('create_date', '>=', cut_90),
])
rec.aging_90_count = SO.search_count(base + [
('create_date', '<', cut_90),
])
# =========================================================================
# Recent ADP Exports (last 5)
# =========================================================================
recent_exports_html = fields.Html(compute='_compute_recent_exports',
sanitize=False)
recent_exports_count = fields.Integer(compute='_compute_recent_exports')
def _compute_recent_exports(self):
Exp = self.env['fusion_claims.adp.export.record'].sudo()
for rec in self:
records = Exp.search([], order='export_date desc', limit=5)
rec.recent_exports_count = Exp.search_count([])
if not records:
rec.recent_exports_html = (
'<p class="o_fc_empty">No exports yet.</p>'
)
continue
rows = []
for r in records:
total = sum(r.invoice_ids.mapped('amount_total'))
date_str = (r.export_date.strftime('%b %d, %Y')
if r.export_date else '')
label = r.posting_period_label or r.name or 'Export'
inv_count = r.invoice_count or 0
rows.append(
f'<div class="o_fc_export_row" '
f'data-export-id="{r.id}">'
f'<div class="o_fc_export_label">'
f'<b>{label}</b>'
f'<br/><small>{date_str} · {inv_count} inv</small>'
f'</div>'
f'<div class="o_fc_export_amount">${total:,.0f}</div>'
f'</div>'
)
rec.recent_exports_html = '\n'.join(rows)
# =========================================================================
# Open-list action methods
# =========================================================================
def _so_list_action(self, name, domain):
return {
'type': 'ir.actions.act_window',
'name': 'Sale Order',
'name': name,
'res_model': 'sale.order',
'view_mode': 'form',
'res_id': order_id,
'view_mode': 'list,form',
'domain': self._role_filter_domain() + domain,
'target': 'current',
}
def action_open_adp(self):
return self._open_type_action('adp')
# ----- ADP Pre-Approval -----
def action_open_adp_waiting_app(self):
return self._so_list_action('ADP — Waiting for Application', [
('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed']),
])
def action_open_odsp(self):
return self._open_type_action('odsp')
def action_open_adp_app_received(self):
return self._so_list_action('ADP — Application Received', [
('x_fc_adp_application_status', '=', 'application_received'),
])
def action_open_march(self):
return self._open_type_action('march_of_dimes')
def action_open_adp_ready_submit(self):
return self._so_list_action('ADP — Ready for Submission', [
('x_fc_adp_application_status', '=', 'ready_submission'),
])
def action_open_hardship(self):
return self._open_type_action('hardship')
def action_open_adp_needs_correction(self):
return self._so_list_action('ADP — Needs Correction', [
('x_fc_adp_application_status', '=', 'needs_correction'),
])
def action_open_acsd(self):
return self._open_type_action('acsd')
# ----- ADP Post-Approval -----
def action_open_adp_approved(self):
return self._so_list_action('ADP — Approved', [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
])
def action_open_muscular(self):
return self._open_type_action('muscular_dystrophy')
def action_open_adp_ready_delivery(self):
return self._so_list_action('ADP — Ready for Delivery', [
('x_fc_adp_application_status', '=', 'ready_delivery'),
])
def action_open_insurance(self):
return self._open_type_action('insurance')
def action_open_adp_ready_bill(self):
return self._so_list_action('ADP — Ready to Bill', [
('x_fc_adp_application_status', '=', 'ready_bill'),
])
def action_open_wsib(self):
return self._open_type_action('wsib')
def action_open_adp_on_hold(self):
return self._so_list_action('ADP — On Hold', [
('x_fc_adp_application_status', '=', 'on_hold'),
])
def action_open_profiles(self):
return {
'type': 'ir.actions.act_window', 'name': 'Client Profiles',
'res_model': 'fusion.client.profile', 'view_mode': 'list,form',
}
# ----- MOD -----
def action_open_mod_awaiting_funding(self):
return self._so_list_action('MOD — Awaiting Funding', [
('x_fc_mod_status', '=', 'awaiting_funding'),
])
def _open_type_action(self, type_key):
def action_open_mod_funding_approved(self):
return self._so_list_action('MOD — Funding Approved', [
('x_fc_mod_status', '=', 'funding_approved'),
])
def action_open_mod_pca_received(self):
return self._so_list_action('MOD — PCA Received', [
('x_fc_mod_status', '=', 'contract_received'),
])
def action_open_mod_project_complete(self):
return self._so_list_action('MOD — Project Complete', [
('x_fc_mod_status', '=', 'project_complete'),
])
def action_open_mod_pod_submitted(self):
return self._so_list_action('MOD — POD Submitted', [
('x_fc_mod_status', '=', 'pod_submitted'),
])
# ----- Other funders -----
def action_open_odsp_cases(self):
return self._so_list_action('ODSP Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', 'in', ['odsp', 'adp_odsp']),
])
def action_open_wsib_cases(self):
return self._so_list_action('WSIB Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', '=', 'wsib'),
])
def action_open_insurance_cases(self):
return self._so_list_action('Insurance Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', '=', 'insurance'),
])
def action_open_mdc_cases(self):
return self._so_list_action('Muscular Dystrophy Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', '=', 'muscular_dystrophy'),
])
def action_open_hardship_cases(self):
return self._so_list_action('Hardship Cases', [
('state', '!=', 'cancel'),
('x_fc_sale_type', '=', 'hardship'),
])
def action_open_acsd_cases(self):
return self._so_list_action('ACSD Cases', [
('state', '!=', 'cancel'),
('x_fc_client_type', '=', 'ACS'),
])
# ----- Bottlenecks -----
def action_open_bottleneck_no_pod(self):
return self._so_list_action('Bottleneck — Approved without POD', [
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
('x_fc_proof_of_delivery', '=', False),
])
def action_open_bottleneck_no_response(self):
from datetime import date, timedelta
cutoff = date.today() - timedelta(days=14)
return self._so_list_action('Bottleneck — Submitted, no response', [
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
('x_fc_claim_submission_date', '<', cutoff),
])
# ----- Activities -----
def action_open_my_activities(self):
return {
'type': 'ir.actions.act_window',
'name': f'{TYPE_LABELS.get(type_key, type_key)} Cases',
'res_model': 'sale.order', 'view_mode': 'list,form',
'domain': TYPE_DOMAINS.get(type_key, []),
'name': 'My Activities',
'res_model': 'mail.activity',
'view_mode': 'list,form',
'domain': [
('user_id', '=', self.env.user.id),
('res_model', 'in', ['sale.order', 'account.move',
'fusion.technician.task']),
],
'target': 'current',
}
# ----- KPI drill-downs -----
def action_open_kpi_ready(self):
return {
'type': 'ir.actions.act_window',
'name': 'Ready to Claim (ADP)',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': self._invoice_role_filter() + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', '=', 'waiting'),
('adp_exported', '=', False),
],
'target': 'current',
}
def action_open_kpi_claimed(self):
return {
'type': 'ir.actions.act_window',
'name': 'Claimed This Period',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': self._invoice_role_filter() + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_adp_billing_status', 'in', ['submitted', 'resubmitted']),
('adp_export_date', '>=', self.posting_period_start),
],
'target': 'current',
}
def action_open_kpi_ar(self):
return {
'type': 'ir.actions.act_window',
'name': 'Total AR (ADP)',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': self._invoice_role_filter() + [
('move_type', '=', 'out_invoice'),
('state', '=', 'posted'),
('x_fc_invoice_type', '=', 'adp'),
('payment_state', 'in', ['not_paid', 'partial']),
],
'target': 'current',
}
# =========================================================================
# Create-SO hotlinks
# =========================================================================
def _create_so_action(self, name, ctx_extra):
context = dict(self.env.context)
context.update(ctx_extra)
return {
'type': 'ir.actions.act_window',
'name': name,
'res_model': 'sale.order',
'view_mode': 'form',
'view_id': False,
'context': context,
'target': 'current',
}
def action_create_adp_so(self):
return self._create_so_action('New ADP Order',
{'default_x_fc_sale_type': 'adp'})
def action_create_mod_so(self):
return self._create_so_action('New MOD Order',
{'default_x_fc_sale_type': 'march_of_dimes'})
def action_create_odsp_so(self):
return self._create_so_action('New ODSP Order', {
'default_x_fc_sale_type': 'odsp',
'default_x_fc_odsp_division': 'standard',
})
def action_create_wsib_so(self):
return self._create_so_action('New WSIB Order',
{'default_x_fc_sale_type': 'wsib'})
def action_create_insurance_so(self):
return self._create_so_action('New Insurance Order',
{'default_x_fc_sale_type': 'insurance'})
def action_create_mdc_so(self):
return self._create_so_action('New MDC Order',
{'default_x_fc_sale_type': 'muscular_dystrophy'})
def action_create_hardship_so(self):
return self._create_so_action('New Hardship Order',
{'default_x_fc_sale_type': 'hardship'})
def action_create_private_so(self):
return self._create_so_action('New Private Order',
{'default_x_fc_sale_type': 'direct_private'})
# =========================================================================
# Additional drill-downs (This Month, Pipeline, Aging, Exports)
# =========================================================================
def action_open_month_submitted(self):
return self._so_list_action('Submitted This Month', [
('x_fc_claim_submission_date', '>=', self._month_start()),
])
def action_open_month_approved(self):
return self._so_list_action('Approved This Month', [
('x_fc_claim_approval_date', '>=', self._month_start()),
])
def action_open_month_delivered(self):
return self._so_list_action('Delivered This Month', [
('x_fc_adp_delivery_date', '>=', self._month_start()),
])
def action_open_month_billed(self):
return self._so_list_action('Billed This Month', [
('x_fc_billing_date', '>=', self._month_start()),
])
def action_open_pipeline_pre(self):
return self._so_list_action('Pipeline — Pre-Submission', [
('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed',
'application_received', 'ready_submission']),
])
def action_open_pipeline_submitted(self):
return self._so_list_action('Pipeline — Submitted to ADP', [
('x_fc_adp_application_status', 'in', ['submitted', 'resubmitted']),
])
def action_open_aging_30(self):
from datetime import date, timedelta
today = date.today()
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
return self._so_list_action('Aging — 30 to 59 Days', [
('state', '!=', 'cancel'),
'|',
('x_fc_adp_application_status', '=', False),
('x_fc_adp_application_status', 'not in', terminal_adp),
('create_date', '<', today - timedelta(days=30)),
('create_date', '>=', today - timedelta(days=60)),
])
def action_open_aging_60(self):
from datetime import date, timedelta
today = date.today()
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
return self._so_list_action('Aging — 60 to 89 Days', [
('state', '!=', 'cancel'),
'|',
('x_fc_adp_application_status', '=', False),
('x_fc_adp_application_status', 'not in', terminal_adp),
('create_date', '<', today - timedelta(days=60)),
('create_date', '>=', today - timedelta(days=90)),
])
def action_open_aging_90(self):
from datetime import date, timedelta
today = date.today()
terminal_adp = ['case_closed', 'cancelled', 'expired', 'withdrawn']
return self._so_list_action('Aging — 90+ Days', [
('state', '!=', 'cancel'),
'|',
('x_fc_adp_application_status', '=', False),
('x_fc_adp_application_status', 'not in', terminal_adp),
('create_date', '<', today - timedelta(days=90)),
])
def action_open_recent_exports(self):
return {
'type': 'ir.actions.act_window',
'name': 'ADP Export History',
'res_model': 'fusion_claims.adp.export.record',
'view_mode': 'list,form',
'target': 'current',
}

View File

@@ -2909,7 +2909,38 @@ class SaleOrder(models.Model):
x_fc_signed_pages_filename = fields.Char(
string='Signed Pages Filename',
)
x_fc_pages_11_12_in_original = fields.Boolean(
string='Pages 11 & 12 in Original Application',
default=False,
tracking=True,
copy=False,
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 in the original '
'application, uploaded as a separate file, or signed via remote signing.'
),
)
@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')
)
# ==========================================================================
# PAGE 11 SIGNATURE TRACKING (Client/Agent Signature)
# Page 11 must be signed by: Client, Spouse, Parent, Legal Guardian, POA, or Public Trustee
@@ -3234,7 +3265,7 @@ class SaleOrder(models.Model):
@api.depends(
'x_fc_assessment_start_date', 'x_fc_assessment_end_date',
'x_fc_claim_authorization_date', 'x_fc_original_application',
'x_fc_signed_pages_11_12', 'x_fc_final_submitted_application',
'x_fc_has_signed_pages_11_12', 'x_fc_final_submitted_application',
'x_fc_xml_file', 'x_fc_approval_letter', 'x_fc_proof_of_delivery',
'x_fc_vendor_bill_ids', 'invoice_ids', 'invoice_ids.state'
)
@@ -3245,7 +3276,7 @@ class SaleOrder(models.Model):
)
order.x_fc_trail_has_authorization = bool(order.x_fc_claim_authorization_date)
order.x_fc_trail_has_original_app = bool(order.x_fc_original_application)
order.x_fc_trail_has_signed_pages = bool(order.x_fc_signed_pages_11_12)
order.x_fc_trail_has_signed_pages = order.x_fc_has_signed_pages_11_12
order.x_fc_trail_has_final_app = bool(order.x_fc_final_submitted_application)
order.x_fc_trail_has_xml = bool(order.x_fc_xml_file)
order.x_fc_trail_has_approval_letter = bool(order.x_fc_approval_letter)

View File

@@ -0,0 +1,63 @@
/** @odoo-module **/
// Fusion Claims — Posting Period Countdown
// Reads the submission_deadline_dt field, computes "Nd Xh to cutoff" client-side,
// re-renders every 60 seconds, swaps colour class as the deadline approaches.
// Copyright 2026 Nexa Systems Inc.
// License OPL-1
import { Component, useState, onWillDestroy } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { standardFieldProps } from "@web/views/fields/standard_field_props";
class FcPostingCountdown extends Component {
static template = "fusion_claims.PostingCountdown";
static props = { ...standardFieldProps };
setup() {
this.state = useState({ text: "", level: "info" });
this._render();
this._timer = setInterval(() => this._render(), 60_000);
onWillDestroy(() => {
if (this._timer) {
clearInterval(this._timer);
this._timer = null;
}
});
}
_render() {
const deadline = this.props.record.data[this.props.name];
if (!deadline) {
this.state.text = "";
this.state.level = "muted";
return;
}
// Odoo provides a luxon DateTime for Datetime fields
const now = luxon.DateTime.now();
const diff = deadline.diff(now, ["days", "hours", "minutes"]).toObject();
if (diff.days < 0 || (diff.days === 0 && diff.hours < 0)) {
this.state.text = "Cutoff passed";
this.state.level = "muted";
return;
}
const days = Math.floor(diff.days);
const hours = Math.floor(diff.hours);
if (days < 1) {
this.state.text = `${hours}h to cutoff`;
this.state.level = "danger";
} else if (days < 3) {
this.state.text = `${days}d ${hours}h to cutoff`;
this.state.level = "warning";
} else {
this.state.text = `${days} days to cutoff`;
this.state.level = "info";
}
}
}
registry.category("fields").add("fc_posting_countdown", {
component: FcPostingCountdown,
});

View File

@@ -0,0 +1,81 @@
// =============================================================================
// Fusion Claims Dashboard — Palette Tokens
// Compile-time branch on $o-webclient-color-scheme so the same SCSS file
// produces different palettes in web.assets_backend (light) and
// web.assets_web_dark (dark). Tokens load FIRST in each bundle.
// =============================================================================
$o-webclient-color-scheme: bright !default;
// ---------- LIGHT (defaults) ----------
$_fc-page-bg: #f7f7f8 !default;
$_fc-card-bg: #ffffff !default;
$_fc-card-border: #d8dadd !default;
$_fc-text: #2b2b2b !default;
$_fc-text-muted: #6c7480 !default;
$_fc-banner-from: #eef2ff !default;
$_fc-banner-to: #fce7f3 !default;
$_fc-banner-border: #c7d2fe !default;
$_fc-banner-text: #3730a3 !default;
$_fc-deadline-text: #b91c1c !default;
$_fc-kpi-bg: #f0f4ff !default;
$_fc-kpi-border: #c7d2fe !default;
$_fc-kpi-num: #1e3a8a !default;
$_fc-action-bg: #ecfdf5 !default;
$_fc-action-border: #6ee7b7 !default;
$_fc-action-text: #047857 !default;
$_fc-tile-bg: #f3f4f6 !default;
$_fc-tile-border: #e5e7eb !default;
$_fc-tile-num: #111827 !default;
$_fc-urgent-bg: #fee2e2 !default;
$_fc-urgent-border: #fca5a5 !default;
$_fc-urgent-num: #991b1b !default;
$_fc-urgent-text: #7f1d1d !default;
$_fc-activity-bg: #fefce8 !default;
$_fc-activity-border: #fde047 !default;
$_fc-bottleneck-bg: #fef2f2 !default;
$_fc-bottleneck-border: #fecaca !default;
// ---------- DARK overrides ----------
@if $o-webclient-color-scheme == dark {
$_fc-page-bg: #1a1d21 !global;
$_fc-card-bg: #22262d !global;
$_fc-card-border: #3a3f47 !global;
$_fc-text: #e5e7eb !global;
$_fc-text-muted: #9ca3af !global;
// Cool blue monochrome banner (selected option A from brainstorm)
$_fc-banner-from: #1e293b !global;
$_fc-banner-to: #1e3a5f !global;
$_fc-banner-border: #3b82f6 !global;
$_fc-banner-text: #93c5fd !global;
$_fc-deadline-text: #fca5a5 !global;
$_fc-kpi-bg: #1e293b !global;
$_fc-kpi-border: #334155 !global;
$_fc-kpi-num: #93c5fd !global;
$_fc-action-bg: #064e3b !global;
$_fc-action-border: #047857 !global;
$_fc-action-text: #6ee7b7 !global;
$_fc-tile-bg: #2d3138 !global;
$_fc-tile-border: #3a3f47 !global;
$_fc-tile-num: #f3f4f6 !global;
$_fc-urgent-bg: #4a1414 !global;
$_fc-urgent-border: #7f1d1d !global;
$_fc-urgent-num: #fca5a5 !global;
$_fc-urgent-text: #fecaca !global;
$_fc-activity-bg: #3a2e0a !global;
$_fc-activity-border: #854d0e !global;
$_fc-bottleneck-bg: #3a1414 !global;
$_fc-bottleneck-border: #7f1d1d !global;
}

View File

@@ -0,0 +1,282 @@
// =============================================================================
// Fusion Claims Dashboard — Layout & Section Styles
// Consumes tokens from _fc_dashboard_tokens.scss (must load FIRST in bundle).
// =============================================================================
// =============================================================================
// Force full-width sheet on the dashboard. The sheet defaults to ~1100-1300px
// max-width via `flex: 1 1 <fixed>` plus a CSS max-width. We override both
// at every possible nesting level + use !important to beat media-query rules.
// =============================================================================
// 1. The sheet itself
.o_fc_dashboard_sheet,
.o_form_sheet.o_fc_dashboard_sheet,
.o_form_view .o_fc_dashboard_sheet,
.o_form_renderer .o_fc_dashboard_sheet {
max-width: 100% !important;
width: 100% !important;
min-width: 100% !important;
flex: 1 1 100% !important;
flex-basis: 100% !important;
margin: 0 !important;
}
// 2. The sheet-bg wrapper around the sheet
.o_form_view:has(.o_fc_dashboard_sheet) .o_form_sheet_bg,
.o_form_renderer:has(.o_fc_dashboard_sheet) .o_form_sheet_bg,
.o_form_sheet_bg:has(> .o_fc_dashboard_sheet) {
max-width: 100% !important;
width: 100% !important;
flex: 1 1 100% !important;
}
// 3. The form view itself
.o_form_view.o_fc_dashboard,
.o_form_view:has(.o_fc_dashboard_sheet) {
max-width: 100% !important;
width: 100% !important;
}
// 4. Legacy fallback (older Odoo selector pattern)
.o_fc_dashboard .o_form_sheet {
max-width: 100% !important;
width: 100% !important;
flex: 1 1 100% !important;
}
.o_fc_dashboard {
// Re-export tokens as CSS custom properties for devtools inspection
--fc-page-bg: #{$_fc-page-bg};
--fc-card-bg: #{$_fc-card-bg};
--fc-card-border: #{$_fc-card-border};
--fc-text: #{$_fc-text};
--fc-text-muted: #{$_fc-text-muted};
--fc-banner-from: #{$_fc-banner-from};
--fc-banner-to: #{$_fc-banner-to};
--fc-banner-border: #{$_fc-banner-border};
--fc-banner-text: #{$_fc-banner-text};
--fc-deadline-text: #{$_fc-deadline-text};
--fc-kpi-bg: #{$_fc-kpi-bg};
--fc-kpi-border: #{$_fc-kpi-border};
--fc-kpi-num: #{$_fc-kpi-num};
--fc-action-bg: #{$_fc-action-bg};
--fc-action-border: #{$_fc-action-border};
--fc-action-text: #{$_fc-action-text};
--fc-tile-bg: #{$_fc-tile-bg};
--fc-tile-border: #{$_fc-tile-border};
--fc-tile-num: #{$_fc-tile-num};
--fc-urgent-bg: #{$_fc-urgent-bg};
--fc-urgent-border: #{$_fc-urgent-border};
--fc-urgent-num: #{$_fc-urgent-num};
--fc-urgent-text: #{$_fc-urgent-text};
--fc-activity-bg: #{$_fc-activity-bg};
--fc-activity-border: #{$_fc-activity-border};
--fc-bottleneck-bg: #{$_fc-bottleneck-bg};
--fc-bottleneck-border: #{$_fc-bottleneck-border};
background: var(--fc-page-bg);
color: $_fc-text;
.o_fc_banner {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(90deg, var(--fc-banner-from), var(--fc-banner-to));
border: 1px solid var(--fc-banner-border);
border-radius: 8px;
padding: 10px 14px;
font-weight: 600;
color: var(--fc-banner-text);
}
.o_fc_banner__deadline { font-weight: 700; }
.o_fc_kpi {
background: var(--fc-kpi-bg);
border: 1px solid var(--fc-kpi-border);
border-radius: 8px;
padding: 14px 10px;
text-align: center;
transition: transform 0.15s ease;
&:hover { transform: translateY(-2px); }
}
.o_fc_kpi__num {
display: block;
font-size: 1.6rem;
font-weight: 700;
color: var(--fc-kpi-num);
}
.o_fc_kpi__lbl {
display: block;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--fc-text-muted);
margin-top: 2px;
}
// Secondary KPI variant — smaller, denser. Used for "This Month" and
// "Pipeline by stage" tile strips.
.o_fc_kpi--secondary {
padding: 10px 6px;
.o_fc_kpi__num { font-size: 1.15rem; }
.o_fc_kpi__lbl { font-size: 0.68rem; }
}
.o_fc_actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.o_fc_pill {
background: var(--fc-action-bg);
border: 1px solid var(--fc-action-border);
color: var(--fc-action-text);
border-radius: 16px;
padding: 5px 12px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
&:hover { background: var(--fc-action-border); }
}
.o_fc_section {
background: var(--fc-card-bg);
border: 1px solid var(--fc-card-border);
border-radius: 8px;
padding: 10px 12px;
}
.o_fc_h6 {
display: flex;
align-items: center;
font-size: 0.9rem;
font-weight: 700;
margin-bottom: 8px;
color: var(--fc-text);
}
.o_fc_tag {
display: inline-block;
font-size: 0.65rem;
padding: 2px 7px;
border-radius: 4px;
background: var(--fc-banner-border);
color: var(--fc-banner-text);
margin-left: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.o_fc_tile {
background: var(--fc-tile-bg);
border: 1px solid var(--fc-tile-border);
border-radius: 6px;
padding: 8px 6px;
text-align: center;
font-size: 0.75rem;
line-height: 1.3;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
}
.o_fc_tile__num {
display: block;
font-size: 1.3rem;
font-weight: 700;
color: var(--fc-tile-num);
margin-bottom: 2px;
}
.o_fc_tile--urgent {
background: var(--fc-urgent-bg);
border-color: var(--fc-urgent-border);
color: var(--fc-urgent-text);
.o_fc_tile__num { color: var(--fc-urgent-num); }
}
.o_fc_activities {
background: var(--fc-activity-bg);
border: 1px solid var(--fc-activity-border);
border-radius: 8px;
padding: 10px 12px;
}
.o_fc_activity_row {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px dashed var(--fc-card-border);
font-size: 0.85rem;
&:last-child { border-bottom: none; }
}
.o_fc_activity_overdue {
color: var(--fc-urgent-text);
font-weight: 600;
}
.o_fc_activity_deadline { color: var(--fc-text-muted); }
.o_fc_empty {
color: var(--fc-text-muted);
font-style: italic;
text-align: center;
padding: 12px;
margin: 0;
}
.o_fc_bottleneck {
background: var(--fc-bottleneck-bg);
border: 1px solid var(--fc-bottleneck-border);
border-radius: 8px;
padding: 10px 12px;
}
.o_fc_bottleneck_row {
display: block;
width: 100%;
text-align: left;
padding: 4px 0;
color: var(--fc-text);
text-decoration: none;
&:hover { color: var(--fc-urgent-num); text-decoration: underline; }
}
// Recent ADP Exports list rows
.o_fc_export_row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px dashed var(--fc-card-border);
font-size: 0.85rem;
&:last-child { border-bottom: none; }
}
.o_fc_export_label small {
color: var(--fc-text-muted);
font-size: 0.72rem;
}
.o_fc_export_amount {
font-weight: 700;
color: var(--fc-kpi-num);
font-variant-numeric: tabular-nums;
}
// Countdown widget colour levels (driven by OWL state)
.o_fc_countdown {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-weight: 700;
font-size: 0.85rem;
}
.o_fc_countdown--info { color: var(--fc-banner-text); }
.o_fc_countdown--warning { color: #d97706; } // amber (intentional fixed hex)
.o_fc_countdown--danger { color: var(--fc-urgent-num); }
.o_fc_countdown--muted { color: var(--fc-text-muted); font-style: italic; }
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_claims.PostingCountdown">
<span t-attf-class="o_fc_countdown o_fc_countdown--{{state.level}}"
t-esc="state.text"/>
</t>
</templates>

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import test_signed_pages_gate
from . import test_application_received_wizard
from . import test_dashboard

View File

@@ -0,0 +1,191 @@
# -*- coding: utf-8 -*-
import base64
from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase, tagged
PDF_BYTES = b'%PDF-1.4\n%fake pdf for tests'
NOT_PDF_BYTES = b'this is not a pdf'
def _b64(data):
return base64.b64encode(data)
@tagged('-at_install', 'post_install', 'fusion_claims')
class TestApplicationReceivedWizard(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'ARW Test Client'})
def _make_order(self):
return self.env['sale.order'].create({
'partner_id': self.partner.id,
'x_fc_adp_application_status': 'waiting_for_application',
})
def _open_wizard(self, order, vals=None):
wizard = self.env['fusion_claims.application.received.wizard'].with_context(
active_id=order.id, active_model='sale.order',
).create({
'sale_order_id': order.id,
**(vals or {}),
})
return wizard
# ---- bundled mode ----
def test_bundled_mode_marks_received_with_only_original(self):
order = self._make_order()
wizard = self._open_wizard(order, {
'intake_mode': 'bundled',
'original_application': _b64(PDF_BYTES),
'original_application_filename': 'app.pdf',
})
wizard.action_confirm()
self.assertEqual(order.x_fc_adp_application_status, 'application_received')
self.assertTrue(order.x_fc_pages_11_12_in_original)
self.assertFalse(order.x_fc_signed_pages_11_12)
self.assertTrue(order.x_fc_has_signed_pages_11_12)
# ---- separate mode ----
def test_separate_mode_requires_signed_pages(self):
order = self._make_order()
wizard = self._open_wizard(order, {
'intake_mode': 'separate',
'original_application': _b64(PDF_BYTES),
'original_application_filename': 'app.pdf',
})
with self.assertRaises(UserError):
wizard.action_confirm()
def test_separate_mode_writes_both_files(self):
order = self._make_order()
wizard = self._open_wizard(order, {
'intake_mode': 'separate',
'original_application': _b64(PDF_BYTES),
'original_application_filename': 'app.pdf',
'signed_pages_11_12': _b64(PDF_BYTES),
'signed_pages_filename': 'p11_12.pdf',
})
wizard.action_confirm()
self.assertEqual(order.x_fc_adp_application_status, 'application_received')
self.assertFalse(order.x_fc_pages_11_12_in_original)
self.assertTrue(order.x_fc_signed_pages_11_12)
# ---- remote mode ----
def test_remote_mode_requires_sent_or_signed_request(self):
order = self._make_order()
wizard = self._open_wizard(order, {
'intake_mode': 'remote',
'original_application': _b64(PDF_BYTES),
'original_application_filename': 'app.pdf',
})
with self.assertRaises(UserError):
wizard.action_confirm()
def test_remote_mode_passes_when_request_sent(self):
order = self._make_order()
self.env['fusion.page11.sign.request'].create({
'sale_order_id': order.id,
'signer_email': 'sign@example.com',
'signer_type': 'client',
'state': 'sent',
})
wizard = self._open_wizard(order, {
'intake_mode': 'remote',
'original_application': _b64(PDF_BYTES),
'original_application_filename': 'app.pdf',
})
wizard.action_confirm()
self.assertEqual(order.x_fc_adp_application_status, 'application_received')
self.assertFalse(order.x_fc_pages_11_12_in_original)
# ---- PDF magic-byte check ----
def test_non_pdf_original_is_rejected(self):
order = self._make_order()
wizard = self._open_wizard(order, {
'intake_mode': 'bundled',
'original_application': _b64(NOT_PDF_BYTES),
'original_application_filename': 'fake.pdf',
})
with self.assertRaises(UserError):
wizard.action_confirm()
def test_non_pdf_signed_pages_is_rejected(self):
order = self._make_order()
wizard = self._open_wizard(order, {
'intake_mode': 'separate',
'original_application': _b64(PDF_BYTES),
'original_application_filename': 'app.pdf',
'signed_pages_11_12': _b64(NOT_PDF_BYTES),
'signed_pages_filename': 'p11_12.pdf',
})
with self.assertRaises(UserError):
wizard.action_confirm()
# ---- status gate ----
def test_blocks_from_wrong_status(self):
order = self._make_order()
order.x_fc_adp_application_status = 'submitted'
wizard = self._open_wizard(order, {
'intake_mode': 'bundled',
'original_application': _b64(PDF_BYTES),
'original_application_filename': 'app.pdf',
})
with self.assertRaises(UserError):
wizard.action_confirm()
# ---- default_get picks initial mode ----
def _get_defaults(self, order, fields_list=('intake_mode',)):
return self.env['fusion_claims.application.received.wizard'].with_context(
active_id=order.id, active_model='sale.order',
).default_get(list(fields_list))
def test_default_intake_mode_bundled_on_fresh_order(self):
order = self._make_order()
defaults = self._get_defaults(order)
self.assertEqual(defaults.get('intake_mode'), 'bundled')
def test_default_intake_mode_bundled_when_flag_set(self):
order = self._make_order()
order.x_fc_pages_11_12_in_original = True
defaults = self._get_defaults(order)
self.assertEqual(defaults.get('intake_mode'), 'bundled')
def test_default_intake_mode_separate_when_file_present(self):
order = self._make_order()
order.x_fc_signed_pages_11_12 = _b64(PDF_BYTES)
order.x_fc_signed_pages_filename = 'p.pdf'
defaults = self._get_defaults(order)
self.assertEqual(defaults.get('intake_mode'), 'separate')
def test_default_intake_mode_remote_when_request_pending(self):
order = self._make_order()
self.env['fusion.page11.sign.request'].create({
'sale_order_id': order.id,
'signer_email': 'a@b.com',
'signer_type': 'client',
'state': 'sent',
})
defaults = self._get_defaults(order)
self.assertEqual(defaults.get('intake_mode'), 'remote')
# ---- chatter ----
def test_chatter_message_mentions_bundled(self):
order = self._make_order()
wizard = self._open_wizard(order, {
'intake_mode': 'bundled',
'original_application': _b64(PDF_BYTES),
'original_application_filename': 'app.pdf',
})
wizard.action_confirm()
messages = order.message_ids.mapped('body')
self.assertTrue(
any('bundled' in (m or '').lower() or 'included in original' in (m or '').lower()
for m in messages),
f"Expected bundled-mode chatter; got: {messages}",
)

View File

@@ -0,0 +1,367 @@
# -*- coding: utf-8 -*-
from odoo.tests.common import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'fusion_claims')
class TestFusionClaimsDashboard(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Dashboard = cls.env['fusion.claims.dashboard']
cls.User = cls.env['res.users']
cls.Partner = cls.env['res.partner']
# Manager user (sees everything)
cls.manager = cls.User.create({
'name': 'Test Dashboard Manager',
'login': 'test_dash_mgr',
'group_ids': [
(4, cls.env.ref('fusion_claims.group_fusion_claims_manager').id),
(4, cls.env.ref('sales_team.group_sale_salesman').id),
],
})
# Sales rep (sees only own cases)
cls.salesrep = cls.User.create({
'name': 'Test Dashboard Salesrep',
'login': 'test_dash_rep',
'group_ids': [
(4, cls.env.ref('fusion_claims.group_fusion_claims_user').id),
(4, cls.env.ref('sales_team.group_sale_salesman').id),
],
})
cls.partner = cls.Partner.create({'name': 'Test Client'})
@classmethod
def _make_invoice(cls, user, billing_status, amount=1000.0,
exported=False, export_date=None,
invoice_type='adp', payment_state='not_paid'):
"""Helper: create a posted ADP invoice linked to an SO owned by `user`."""
so = cls.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': cls.partner.id,
'user_id': user.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'approved',
})
invoice = cls.env['account.move'].with_context(skip_sync=True).create({
'move_type': 'out_invoice',
'partner_id': cls.partner.id,
'x_fc_source_sale_order_id': so.id,
'x_fc_invoice_type': invoice_type,
'x_fc_adp_billing_status': billing_status,
'adp_exported': exported,
'adp_export_date': export_date,
'invoice_line_ids': [(0, 0, {
'name': 'Test line',
'quantity': 1.0,
'price_unit': amount,
'tax_ids': [(5, 0)], # clear taxes so amount_total == price_unit
})],
})
invoice.action_post()
invoice.with_context(skip_sync=True).write({'payment_state': payment_state})
return invoice
def test_dashboard_record_creates(self):
dashboard = self.Dashboard.create({})
self.assertTrue(dashboard.id, "Dashboard record should be creatable")
self.assertEqual(dashboard.name, 'Dashboard')
def test_role_filter_empty_for_manager(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard._role_filter_domain(), [],
"Manager should see all cases (empty domain)")
def test_role_filter_restricts_for_salesrep(self):
dashboard = self.Dashboard.with_user(self.salesrep).create({})
domain = dashboard._role_filter_domain()
self.assertEqual(domain, [('user_id', '=', self.salesrep.id)],
"Sales rep should see only their own SOs")
def test_is_manager_true_for_manager(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertTrue(dashboard.is_manager)
def test_is_manager_false_for_salesrep(self):
dashboard = self.Dashboard.with_user(self.salesrep).create({})
self.assertFalse(dashboard.is_manager)
# -------------------------------------------------------------------------
# Task 2 — Banner
# -------------------------------------------------------------------------
def test_banner_posting_period_label_format(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
label = dashboard.posting_period_label
self.assertTrue(any(month in label
for month in ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']),
"Label should contain a month abbreviation")
def test_banner_posting_period_start_and_end_are_dates(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertTrue(dashboard.posting_period_start)
self.assertTrue(dashboard.posting_period_end)
delta = (dashboard.posting_period_end - dashboard.posting_period_start).days
self.assertEqual(delta, 14)
def test_banner_submission_deadline_is_wednesday_6pm(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
deadline = dashboard.submission_deadline_dt
self.assertTrue(deadline, "Deadline should be set")
# Stored in UTC; convert to user's TZ to assert the wall-clock weekday/hour
import pytz
tz = pytz.timezone(self.manager.tz or 'America/Toronto')
local = pytz.UTC.localize(deadline).astimezone(tz)
self.assertEqual(local.weekday(), 2, "Deadline should be Wednesday")
self.assertEqual(local.hour, 18, "Deadline should be 18:00 (6 PM)")
def test_is_pre_first_posting_false_when_today_is_past_base_date(self):
# Test runs after 2026-01-23 by default.
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertFalse(dashboard.is_pre_first_posting)
# -------------------------------------------------------------------------
# Task 3 — KPI tiles
# -------------------------------------------------------------------------
def test_kpi_ready_counts_waiting_invoices_not_exported(self):
self._make_invoice(self.manager, 'waiting', amount=500.0, exported=False)
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.kpi_ready_count, 1)
self.assertAlmostEqual(dashboard.kpi_ready_amount, 500.0, places=2)
def test_kpi_ready_excludes_already_exported(self):
from datetime import date
self._make_invoice(self.manager, 'waiting', amount=500.0,
exported=True, export_date=date.today())
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.kpi_ready_count, 0)
self.assertAlmostEqual(dashboard.kpi_ready_amount, 0.0, places=2)
def test_kpi_claimed_counts_exported_in_current_period(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
in_period_date = dashboard.posting_period_start
self._make_invoice(self.manager, 'submitted', amount=700.0,
exported=True, export_date=in_period_date)
dashboard2 = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard2.kpi_claimed_count, 1)
self.assertAlmostEqual(dashboard2.kpi_claimed_amount, 700.0, places=2)
def test_kpi_ar_counts_posted_unpaid_adp_invoices(self):
self._make_invoice(self.manager, 'submitted', amount=2000.0,
exported=True, payment_state='not_paid')
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.kpi_ar_count, 1)
self.assertAlmostEqual(dashboard.kpi_ar_amount, 2000.0, places=2)
def test_kpi_ready_respects_role_filter(self):
self._make_invoice(self.manager, 'waiting', amount=500.0)
dashboard_rep = self.Dashboard.with_user(self.salesrep).create({})
self.assertEqual(dashboard_rep.kpi_ready_count, 0,
"Salesrep must not see manager's invoice")
# -------------------------------------------------------------------------
# Task 4 — Activities + bottlenecks
# -------------------------------------------------------------------------
def test_my_activities_count_zero_when_none(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.my_activities_count, 0)
def test_my_activities_count_picks_up_user_activity(self):
so = self.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': self.partner.id,
'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
})
self.env['mail.activity'].create({
'res_model_id': self.env['ir.model']._get('sale.order').id,
'res_id': so.id,
'res_model': 'sale.order',
'user_id': self.manager.id,
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'summary': 'Test activity',
})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.my_activities_count, 1)
self.assertIn('Test activity', dashboard.my_activities_html or '')
def test_bottleneck_no_pod_count(self):
self.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': self.partner.id,
'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'approved',
})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.bottleneck_no_pod_count, 1)
def test_bottleneck_no_response_count(self):
from datetime import date, timedelta
old_date = date.today() - timedelta(days=20)
self.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': self.partner.id,
'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'submitted',
'x_fc_claim_submission_date': old_date,
})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.bottleneck_no_response_count, 1)
# -------------------------------------------------------------------------
# Task 5 — Other funder counts
# -------------------------------------------------------------------------
def test_other_funder_counts_segregate_by_sale_type(self):
SO = self.env['sale.order'].with_context(skip_status_validation=True)
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'odsp'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'wsib'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'insurance'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'muscular_dystrophy'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'hardship'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp', 'x_fc_client_type': 'ACS'})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.count_odsp, 1)
self.assertEqual(dashboard.count_wsib, 1)
self.assertEqual(dashboard.count_insurance, 1)
self.assertEqual(dashboard.count_mdc, 1)
self.assertEqual(dashboard.count_hardship, 1)
self.assertEqual(dashboard.count_acsd, 1)
def test_other_funder_counts_exclude_cancelled(self):
so = self.env['sale.order'].with_context(skip_status_validation=True).create({
'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'wsib',
})
so.with_context(skip_status_validation=True).write({'state': 'cancel'})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.count_wsib, 0)
# -------------------------------------------------------------------------
# Task 6 — ADP + MOD workflow counts
# -------------------------------------------------------------------------
def test_adp_pre_approval_tile_counts(self):
SO = self.env['sale.order'].with_context(skip_status_validation=True)
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'waiting_for_application'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'application_received'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'ready_submission'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'needs_correction'})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.adp_waiting_app_count, 1)
self.assertEqual(dashboard.adp_app_received_count, 1)
self.assertEqual(dashboard.adp_ready_submit_count, 1)
self.assertEqual(dashboard.adp_needs_correction_count, 1)
def test_adp_post_approval_tile_counts(self):
SO = self.env['sale.order'].with_context(skip_status_validation=True)
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'approved'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'ready_delivery'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'ready_bill'})
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'adp',
'x_fc_adp_application_status': 'on_hold'})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.adp_approved_count, 1)
self.assertEqual(dashboard.adp_ready_delivery_count, 1)
self.assertEqual(dashboard.adp_ready_bill_count, 1)
self.assertEqual(dashboard.adp_on_hold_count, 1)
def test_mod_tile_counts(self):
SO = self.env['sale.order'].with_context(skip_status_validation=True)
for status in ('awaiting_funding', 'funding_approved', 'contract_received',
'project_complete', 'pod_submitted'):
SO.create({'partner_id': self.partner.id, 'user_id': self.manager.id,
'x_fc_sale_type': 'march_of_dimes',
'x_fc_mod_status': status})
dashboard = self.Dashboard.with_user(self.manager).create({})
self.assertEqual(dashboard.mod_awaiting_funding_count, 1)
self.assertEqual(dashboard.mod_funding_approved_count, 1)
self.assertEqual(dashboard.mod_pca_received_count, 1)
self.assertEqual(dashboard.mod_project_complete_count, 1)
self.assertEqual(dashboard.mod_pod_submitted_count, 1)
# -------------------------------------------------------------------------
# Task 7 — Open-list action methods
# -------------------------------------------------------------------------
def test_action_open_adp_waiting_app_returns_correct_domain(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_open_adp_waiting_app()
self.assertEqual(action['res_model'], 'sale.order')
self.assertIn(('x_fc_adp_application_status', 'in',
['waiting_for_application', 'assessment_completed']),
action['domain'])
def test_action_open_bottleneck_no_pod_returns_correct_domain(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_open_bottleneck_no_pod()
self.assertEqual(action['res_model'], 'sale.order')
self.assertIn(('x_fc_proof_of_delivery', '=', False), action['domain'])
def test_action_open_mod_awaiting_funding_returns_correct_domain(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_open_mod_awaiting_funding()
self.assertEqual(action['res_model'], 'sale.order')
self.assertIn(('x_fc_mod_status', '=', 'awaiting_funding'), action['domain'])
def test_action_open_my_activities_returns_activity_model(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_open_my_activities()
self.assertEqual(action['res_model'], 'mail.activity')
# -------------------------------------------------------------------------
# Task 8 — Create-SO hotlinks
# -------------------------------------------------------------------------
def test_action_create_adp_so_has_default_sale_type(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_create_adp_so()
self.assertEqual(action['res_model'], 'sale.order')
self.assertEqual(action['view_mode'], 'form')
self.assertEqual(action['context']['default_x_fc_sale_type'], 'adp')
def test_action_create_mod_so_has_default_sale_type(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_create_mod_so()
self.assertEqual(action['context']['default_x_fc_sale_type'], 'march_of_dimes')
def test_action_create_odsp_so_has_division_default(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
action = dashboard.action_create_odsp_so()
self.assertEqual(action['context']['default_x_fc_sale_type'], 'odsp')
self.assertEqual(action['context']['default_x_fc_odsp_division'], 'standard')
def test_all_create_so_actions_exist(self):
dashboard = self.Dashboard.with_user(self.manager).create({})
for method_name, expected_type in [
('action_create_adp_so', 'adp'),
('action_create_mod_so', 'march_of_dimes'),
('action_create_odsp_so', 'odsp'),
('action_create_wsib_so', 'wsib'),
('action_create_insurance_so', 'insurance'),
('action_create_mdc_so', 'muscular_dystrophy'),
('action_create_hardship_so', 'hardship'),
('action_create_private_so', 'direct_private'),
]:
action = getattr(dashboard, method_name)()
self.assertEqual(action['res_model'], 'sale.order')
self.assertEqual(action['context']['default_x_fc_sale_type'], expected_type,
f"{method_name} returned wrong default sale type")

View File

@@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
import base64
from odoo.tests.common import TransactionCase, tagged
PDF_MAGIC = b'%PDF-1.4\n%fake pdf for tests'
def _b64_pdf():
return base64.b64encode(PDF_MAGIC)
@tagged('-at_install', 'post_install', 'fusion_claims')
class TestSignedPagesGate(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'Test Client'})
cls.order = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
'x_fc_adp_application_status': 'waiting_for_application',
})
def test_pages_11_12_in_original_defaults_false(self):
self.assertFalse(self.order.x_fc_pages_11_12_in_original)
def test_has_signed_pages_false_when_nothing_set(self):
self.assertFalse(self.order.x_fc_has_signed_pages_11_12)
def test_has_signed_pages_true_when_bundled_flag_set(self):
self.order.x_fc_pages_11_12_in_original = True
self.order.flush_recordset()
self.assertTrue(self.order.x_fc_has_signed_pages_11_12)
def test_has_signed_pages_true_when_separate_file_uploaded(self):
self.order.x_fc_signed_pages_11_12 = _b64_pdf()
self.order.flush_recordset()
self.assertTrue(self.order.x_fc_has_signed_pages_11_12)
def test_has_signed_pages_true_when_remote_request_signed(self):
self.env['fusion.page11.sign.request'].create({
'sale_order_id': self.order.id,
'signer_email': 'test@example.com',
'signer_type': 'client',
'state': 'signed',
})
self.order.invalidate_recordset()
self.assertTrue(self.order.x_fc_has_signed_pages_11_12)
def test_has_signed_pages_false_when_remote_request_only_sent(self):
self.env['fusion.page11.sign.request'].create({
'sale_order_id': self.order.id,
'signer_email': 'test@example.com',
'signer_type': 'client',
'state': 'sent',
})
self.order.invalidate_recordset()
self.assertFalse(self.order.x_fc_has_signed_pages_11_12)
def test_trail_has_signed_pages_true_when_bundled(self):
self.order.x_fc_pages_11_12_in_original = True
self.order.flush_recordset()
self.assertTrue(self.order.x_fc_trail_has_signed_pages)
def test_trail_has_signed_pages_false_when_nothing(self):
self.assertFalse(self.order.x_fc_trail_has_signed_pages)
def test_trail_has_signed_pages_true_when_separate_file(self):
self.order.x_fc_signed_pages_11_12 = _b64_pdf()
self.order.flush_recordset()
self.assertTrue(self.order.x_fc_trail_has_signed_pages)
def test_ready_for_submission_passes_with_bundled_flag_only(self):
"""Ready-for-submission gate passes when bundled flag is True even
without a separate signed-pages file."""
self.order.write({
'x_fc_adp_application_status': 'application_received',
'x_fc_original_application': _b64_pdf(),
'x_fc_original_application_filename': 'app.pdf',
'x_fc_pages_11_12_in_original': True,
'x_fc_client_ref_1': 'JODO',
'x_fc_client_ref_2': '1234',
'x_fc_reason_for_application': 'first_access',
})
self.order.flush_recordset()
wizard = self.env['fusion_claims.ready.for.submission.wizard'].with_context(
active_id=self.order.id, active_model='sale.order',
).create({
'sale_order_id': self.order.id,
'claim_authorization_date': '2026-05-01',
})
wizard.action_confirm()
self.assertEqual(self.order.x_fc_adp_application_status, 'ready_submission')
def test_case_close_audit_accepts_bundled_flag(self):
"""Case-close audit treats bundled flag as 'signed pages present'."""
self.order.x_fc_pages_11_12_in_original = True
self.order.flush_recordset()
wizard = self.env['fusion_claims.case.close.verification.wizard'].with_context(
active_id=self.order.id, active_model='sale.order',
).create({
'sale_order_id': self.order.id,
})
self.assertTrue(wizard.has_signed_pages)

View File

@@ -4,151 +4,536 @@
<field name="name">fusion.claims.dashboard.form</field>
<field name="model">fusion.claims.dashboard</field>
<field name="arch" type="xml">
<form string="Dashboard" create="0" delete="0">
<sheet>
<!-- ===== FUNDING CARDS (one line, bigger) ===== -->
<div class="d-flex flex-nowrap gap-2 mb-4 overflow-auto">
<div invisible="adp_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_adp" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="adp_count"/></div>
<div style="font-size: 0.85rem;">ADP</div>
</div>
</button>
<form string="Dashboard" create="0" delete="0" edit="0"
class="o_fc_dashboard">
<sheet class="o_fc_dashboard_sheet">
<!-- Hidden invariants used by buttons + widgets -->
<field name="currency_id" invisible="1"/>
<field name="posting_period_start" invisible="1"/>
<field name="is_manager" invisible="1"/>
<field name="is_pre_first_posting" invisible="1"/>
<!-- BANNER -->
<div class="o_fc_banner mb-3">
<div class="o_fc_banner__label">
<i class="fa fa-calendar me-2"/>
<span>Posting Period: </span>
<field name="posting_period_label" nolabel="1"
class="fw-bold"/>
</div>
<div invisible="odsp_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_odsp" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="odsp_count"/></div>
<div style="font-size: 0.85rem;">ODSP</div>
</div>
</button>
</div>
<div invisible="march_of_dimes_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_march" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="march_of_dimes_count"/></div>
<div style="font-size: 0.85rem;">March of Dimes</div>
</div>
</button>
</div>
<div invisible="hardship_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_hardship" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="hardship_count"/></div>
<div style="font-size: 0.85rem;">Hardship</div>
</div>
</button>
</div>
<div invisible="acsd_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_acsd" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="acsd_count"/></div>
<div style="font-size: 0.85rem;">ACSD</div>
</div>
</button>
</div>
<div invisible="muscular_dystrophy_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_muscular" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="muscular_dystrophy_count"/></div>
<div style="font-size: 0.85rem;">Muscular Dystrophy</div>
</div>
</button>
</div>
<div invisible="insurance_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_insurance" type="object" class="btn p-0 w-100 border-0">
<div class="text-dark text-center py-3 px-2" style="background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="insurance_count"/></div>
<div style="font-size: 0.85rem;">Insurance</div>
</div>
</button>
</div>
<div invisible="wsib_count == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_wsib" type="object" class="btn p-0 w-100 border-0">
<div class="text-dark text-center py-3 px-2" style="background: linear-gradient(135deg, #ff9a9e 0%, #fad0c4 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="wsib_count"/></div>
<div style="font-size: 0.85rem;">WSIB</div>
</div>
</button>
</div>
<div invisible="total_profiles == 0" style="flex: 1 1 0; min-width: 120px;">
<button name="action_open_profiles" type="object" class="btn p-0 w-100 border-0">
<div class="text-white text-center py-3 px-2" style="background: linear-gradient(135deg, #30cfd0 0%, #330867 100%); border-radius: 14px;">
<div class="fw-bold" style="font-size: 1.8rem;"><field name="total_profiles"/></div>
<div style="font-size: 0.85rem;">Profiles</div>
</div>
</button>
<div class="o_fc_banner__deadline">
<field name="submission_deadline_dt"
widget="fc_posting_countdown"
nolabel="1" readonly="1"/>
</div>
</div>
<!-- ===== PANEL SELECTORS (4 dropdowns) ===== -->
<!-- "Showing your cases" hint when role-filtered -->
<div class="alert alert-info py-2 mb-2"
invisible="is_manager">
Showing your assigned cases only.
</div>
<!-- KPI TILES (3-up) -->
<div class="row g-2 mb-3">
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 1</div>
<field name="panel1_type" nolabel="1"/>
<div class="col-12 col-md-4">
<button name="action_open_kpi_ready" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi">
<span class="o_fc_kpi__num">
<field name="kpi_ready_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Ready to Claim
(<field name="kpi_ready_count" nolabel="1"/>)
</span>
</div>
</button>
</div>
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 2</div>
<field name="panel2_type" nolabel="1"/>
<div class="col-12 col-md-4">
<button name="action_open_kpi_claimed" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi">
<span class="o_fc_kpi__num">
<field name="kpi_claimed_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Claimed This Period
(<field name="kpi_claimed_count" nolabel="1"/>)
</span>
</div>
</button>
</div>
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 3</div>
<field name="panel3_type" nolabel="1"/>
</div>
<div class="col-3">
<div class="fw-bold mb-1" style="font-size: 0.8rem;">Window 4</div>
<field name="panel4_type" nolabel="1"/>
<div class="col-12 col-md-4">
<button name="action_open_kpi_ar" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi">
<span class="o_fc_kpi__num">
<field name="kpi_ar_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Total AR
(<field name="kpi_ar_count" nolabel="1"/>)
</span>
</div>
</button>
</div>
</div>
<!-- ===== TOP PANELS ROW 1 ===== -->
<div class="row g-3 mb-3">
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<field name="panel1_title" nolabel="1"/>
<!-- THIS MONTH ROLLUP (4 count tiles) -->
<div class="row g-2 mb-3">
<div class="col-6 col-md-3">
<button name="action_open_month_submitted" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num"><field name="count_month_submitted" nolabel="1"/></span>
<span class="o_fc_kpi__lbl">Submitted MTD</span>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel1_html" class="w-100" nolabel="1"/>
</div>
</div>
</button>
</div>
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<field name="panel2_title" nolabel="1"/>
<div class="col-6 col-md-3">
<button name="action_open_month_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num"><field name="count_month_approved" nolabel="1"/></span>
<span class="o_fc_kpi__lbl">Approved MTD</span>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel2_html" class="w-100" nolabel="1"/>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_month_delivered" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num"><field name="count_month_delivered" nolabel="1"/></span>
<span class="o_fc_kpi__lbl">Delivered MTD</span>
</div>
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_month_billed" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num"><field name="count_month_billed" nolabel="1"/></span>
<span class="o_fc_kpi__lbl">Billed MTD</span>
</div>
</button>
</div>
</div>
<!-- ===== TOP PANELS ROW 2 ===== -->
<!-- PIPELINE $ BY STAGE (4 amount tiles) -->
<div class="row g-2 mb-3">
<div class="col-6 col-md-3">
<button name="action_open_pipeline_pre" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num">
<field name="pipeline_pre_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Pipeline · Pre-Submit</span>
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_pipeline_submitted" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num">
<field name="pipeline_submitted_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Pipeline · Submitted</span>
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num">
<field name="pipeline_approved_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Pipeline · Approved</span>
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_bill" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_kpi o_fc_kpi--secondary">
<span class="o_fc_kpi__num">
<field name="pipeline_ready_bill_amount"
widget="monetary" nolabel="1"
options="{'currency_field': 'currency_id'}"/>
</span>
<span class="o_fc_kpi__lbl">Pipeline · Ready to Bill</span>
</div>
</button>
</div>
</div>
<!-- QUICK ACTION PILLS -->
<div class="o_fc_actions mb-3">
<button name="action_create_adp_so" type="object"
class="o_fc_pill">+ ADP</button>
<button name="action_create_mod_so" type="object"
class="o_fc_pill">+ MOD</button>
<button name="action_create_odsp_so" type="object"
class="o_fc_pill">+ ODSP</button>
<button name="action_create_wsib_so" type="object"
class="o_fc_pill">+ WSIB</button>
<button name="action_create_insurance_so" type="object"
class="o_fc_pill">+ Insurance</button>
<button name="action_create_mdc_so" type="object"
class="o_fc_pill">+ MDC</button>
<button name="action_create_hardship_so" type="object"
class="o_fc_pill">+ Hardship</button>
<button name="action_create_private_so" type="object"
class="o_fc_pill">+ Private</button>
</div>
<!-- RESPONSIVE GRID — 5/7 on lg, 3/5/4 on xl (≥1200px) -->
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<field name="panel3_title" nolabel="1"/>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel3_html" class="w-100" nolabel="1"/>
</div>
<!-- COLUMN 1: Personal / actionable (Activities + Bottlenecks) -->
<div class="col-12 col-lg-5 col-xl-3">
<!-- Your Activities -->
<div class="o_fc_activities mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-thumb-tack me-2"/>
Your Activities
<span class="o_fc_tag">
<field name="my_activities_count" nolabel="1"/>
</span>
<button name="action_open_my_activities" type="object"
class="btn btn-link btn-sm ms-auto p-0">
View all
</button>
</h6>
<field name="my_activities_html" nolabel="1"/>
</div>
<!-- Bottlenecks -->
<div class="o_fc_bottleneck mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-exclamation-triangle me-2"/>
Bottlenecks
</h6>
<button name="action_open_bottleneck_no_pod" type="object"
class="o_fc_bottleneck_row btn btn-link p-0">
Approved without POD:
<span class="fw-bold ms-1">
<field name="bottleneck_no_pod_count" nolabel="1"/>
</span>
</button>
<button name="action_open_bottleneck_no_response" type="object"
class="o_fc_bottleneck_row btn btn-link p-0">
Submitted &gt; 14d, no response:
<span class="fw-bold ms-1">
<field name="bottleneck_no_response_count" nolabel="1"/>
</span>
</button>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card" style="border-radius: 14px; overflow: hidden;">
<div class="card-header fw-bold text-white py-2" style="background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);">
<field name="panel4_title" nolabel="1"/>
<!-- COLUMN 2: Workflow center (ADP + MOD) -->
<div class="col-12 col-lg-7 col-xl-5">
<!-- ADP Pre-Approval -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">ADP
<span class="o_fc_tag">Pre-Approval</span>
</h6>
<div class="row g-2">
<div class="col-6 col-md-3">
<button name="action_open_adp_waiting_app" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="adp_waiting_app_count" nolabel="1"/>
</span>Waiting App
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_app_received" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_app_received_count" nolabel="1"/>
</span>App Received
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_submit" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_ready_submit_count" nolabel="1"/>
</span>Ready Submit
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_needs_correction" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="adp_needs_correction_count" nolabel="1"/>
</span>Needs Correction
</div>
</button>
</div>
</div>
<div class="card-body p-0" style="max-height: 400px; overflow-y: auto;">
<field name="panel4_html" class="w-100" nolabel="1"/>
</div>
<!-- ADP Post-Approval -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">ADP
<span class="o_fc_tag">Post-Approval</span>
</h6>
<div class="row g-2">
<div class="col-6 col-md-3">
<button name="action_open_adp_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_approved_count" nolabel="1"/>
</span>Approved
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_delivery" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_ready_delivery_count" nolabel="1"/>
</span>Ready Delivery
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_ready_bill" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="adp_ready_bill_count" nolabel="1"/>
</span>Ready Bill
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_adp_on_hold" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="adp_on_hold_count" nolabel="1"/>
</span>On Hold
</div>
</button>
</div>
</div>
</div>
<!-- MOD -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">MOD</h6>
<div class="row g-2">
<div class="col-6 col-md-2">
<button name="action_open_mod_awaiting_funding" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_awaiting_funding_count" nolabel="1"/>
</span>Awaiting
</div>
</button>
</div>
<div class="col-6 col-md-2">
<button name="action_open_mod_funding_approved" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_funding_approved_count" nolabel="1"/>
</span>Approved
</div>
</button>
</div>
<div class="col-6 col-md-2">
<button name="action_open_mod_pca_received" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_pca_received_count" nolabel="1"/>
</span>PCA
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_mod_project_complete" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_project_complete_count" nolabel="1"/>
</span>Proj. Done
</div>
</button>
</div>
<div class="col-6 col-md-3">
<button name="action_open_mod_pod_submitted" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="mod_pod_submitted_count" nolabel="1"/>
</span>POD Submitted
</div>
</button>
</div>
</div>
</div>
</div>
<!-- COLUMN 3: Analytics (Aging + Other Funders + Recent Exports)
Full-width below cols 1+2 on lg, dedicated right-column on xl -->
<div class="col-12 col-xl-4">
<!-- Aging buckets -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-clock-o me-2"/>
Aging
</h6>
<div class="row g-2">
<div class="col-4">
<button name="action_open_aging_30" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="aging_30_count" nolabel="1"/>
</span>30 59d
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_aging_60" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="aging_60_count" nolabel="1"/>
</span>60 89d
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_aging_90" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile o_fc_tile--urgent">
<span class="o_fc_tile__num">
<field name="aging_90_count" nolabel="1"/>
</span>90+ d
</div>
</button>
</div>
</div>
</div>
<!-- Other Funders -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">Other Funders</h6>
<div class="row g-2">
<div class="col-4">
<button name="action_open_odsp_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_odsp" nolabel="1"/>
</span>ODSP
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_wsib_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_wsib" nolabel="1"/>
</span>WSIB
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_insurance_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_insurance" nolabel="1"/>
</span>Insurance
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_mdc_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_mdc" nolabel="1"/>
</span>MDC
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_hardship_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_hardship" nolabel="1"/>
</span>Hardship
</div>
</button>
</div>
<div class="col-4">
<button name="action_open_acsd_cases" type="object"
class="btn p-0 w-100 border-0">
<div class="o_fc_tile">
<span class="o_fc_tile__num">
<field name="count_acsd" nolabel="1"/>
</span>ACSD
</div>
</button>
</div>
</div>
</div>
<!-- Recent ADP Exports (last 5) -->
<div class="o_fc_section mb-3">
<h6 class="o_fc_h6">
<i class="fa fa-file-text-o me-2"/>
Recent ADP Exports
<span class="o_fc_tag">
<field name="recent_exports_count" nolabel="1"/>
</span>
<button name="action_open_recent_exports" type="object"
class="btn btn-link btn-sm ms-auto p-0">
View all
</button>
</h6>
<field name="recent_exports_html" nolabel="1"/>
</div>
</div>
</div>
</sheet>
</form>
</field>
@@ -162,4 +547,13 @@
<field name="view_id" ref="view_fusion_claims_dashboard_form"/>
<field name="target">current</field>
</record>
<!-- Dashboard Menu — top of the Fusion Claims app, sequence=1 so it
renders before "All Orders" (sequence=2) and becomes the default
landing when clicking the app icon. -->
<menuitem id="menu_fusion_claims_dashboard"
name="Dashboard"
parent="menu_adp_claims_root"
action="action_fusion_claims_dashboard"
sequence="1"/>
</odoo>

View File

@@ -1,14 +1,23 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import base64
import logging
from datetime import date
from markupsafe import Markup
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from markupsafe import Markup
import logging
_logger = logging.getLogger(__name__)
try:
import pdfrw
except ImportError: # pragma: no cover
pdfrw = None
class ApplicationReceivedWizard(models.TransientModel):
"""Wizard to upload ADP application documents when application is received."""
@@ -21,25 +30,43 @@ class ApplicationReceivedWizard(models.TransientModel):
required=True,
readonly=True,
)
intake_mode = fields.Selection(
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',
help=(
'Bundled: a single PDF that already contains the signed pages 11 & 12.\n'
'Separate: original application + a separate PDF with the signed pages 11 & 12.\n'
'Remote: send Page 11 to a family member / agent for digital signing.'
),
)
# Document uploads
original_application = fields.Binary(
string='Original ADP Application',
required=True,
help='Upload the original ADP application PDF received from the client',
)
original_application_filename = fields.Char(
string='Application Filename',
original_application_filename = fields.Char(string='Application Filename')
original_page_count = fields.Integer(
string='Original PDF Page Count',
compute='_compute_original_page_count',
help='Number of pages detected in the uploaded original PDF.',
)
signed_pages_11_12 = fields.Binary(
string='Signed Pages 11 & 12',
help='Upload the signed pages 11 and 12 from the application. '
'Not required if a remote signing request has been sent.',
)
signed_pages_filename = fields.Char(
string='Pages Filename',
help='Upload the signed pages 11 and 12 from the application '
'(only used in Separate-file mode).',
)
signed_pages_filename = fields.Char(string='Pages Filename')
has_pending_page11_request = fields.Boolean(
compute='_compute_has_pending_page11_request',
@@ -47,12 +74,15 @@ class ApplicationReceivedWizard(models.TransientModel):
has_signed_page11 = fields.Boolean(
compute='_compute_has_pending_page11_request',
)
notes = fields.Text(
string='Notes',
help='Any notes about the received application',
)
# ------------------------------------------------------------------
# COMPUTED
# ------------------------------------------------------------------
@api.depends('sale_order_id')
def _compute_has_pending_page11_request(self):
for wiz in self:
@@ -70,103 +100,136 @@ class ApplicationReceivedWizard(models.TransientModel):
wiz.has_pending_page11_request = False
wiz.has_signed_page11 = False
@api.depends('original_application')
def _compute_original_page_count(self):
for wiz in self:
wiz.original_page_count = wiz._count_pdf_pages(wiz.original_application)
@staticmethod
def _count_pdf_pages(b64_data):
"""Return PDF page count, or 0 if unknown/unparseable."""
if not b64_data or pdfrw is None:
return 0
try:
raw = base64.b64decode(b64_data)
reader = pdfrw.PdfReader(fdata=raw)
return len(reader.pages) if reader and reader.pages else 0
except Exception: # pragma: no cover (corrupted PDFs)
return 0
# ------------------------------------------------------------------
# DEFAULTS
# ------------------------------------------------------------------
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self._context.get('active_id')
if active_id:
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
if order.x_fc_original_application:
res['original_application'] = order.x_fc_original_application
res['original_application_filename'] = order.x_fc_original_application_filename
if order.x_fc_signed_pages_11_12:
res['signed_pages_11_12'] = order.x_fc_signed_pages_11_12
res['signed_pages_filename'] = order.x_fc_signed_pages_filename
if not active_id:
return res
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
if order.x_fc_original_application:
res['original_application'] = order.x_fc_original_application
res['original_application_filename'] = order.x_fc_original_application_filename
if order.x_fc_signed_pages_11_12:
res['signed_pages_11_12'] = order.x_fc_signed_pages_11_12
res['signed_pages_filename'] = order.x_fc_signed_pages_filename
# Choose initial intake mode based on order state.
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'
return res
# ------------------------------------------------------------------
# CONSTRAINTS (filename defence-in-depth)
# ------------------------------------------------------------------
@api.constrains('original_application_filename')
def _check_application_file_type(self):
for wizard in self:
if wizard.original_application_filename:
if not wizard.original_application_filename.lower().endswith('.pdf'):
raise UserError(
"Original Application must be a PDF file.\n"
f"Uploaded file: '{wizard.original_application_filename}'"
)
name = wizard.original_application_filename
if name and not name.lower().endswith('.pdf'):
raise UserError(
f"Original Application must be a PDF file.\n"
f"Uploaded file: '{name}'"
)
@api.constrains('signed_pages_filename')
def _check_pages_file_type(self):
for wizard in self:
if wizard.signed_pages_filename:
if not wizard.signed_pages_filename.lower().endswith('.pdf'):
raise UserError(
"Signed Pages 11 & 12 must be a PDF file.\n"
f"Uploaded file: '{wizard.signed_pages_filename}'"
)
name = wizard.signed_pages_filename
if name and not name.lower().endswith('.pdf'):
raise UserError(
f"Signed Pages 11 & 12 must be a PDF file.\n"
f"Uploaded file: '{name}'"
)
# ------------------------------------------------------------------
# ACTIONS
# ------------------------------------------------------------------
def action_confirm(self):
"""Save documents and mark application as received."""
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 receive application from 'Waiting for Application' status.")
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.")
page11_covered = bool(
self.signed_pages_11_12
or order.x_fc_signed_pages_11_12
or order.page11_sign_request_ids.filtered(
lambda r: r.state in ('sent', 'signed')
)
)
if not page11_covered:
raise UserError(
"Signed Pages 11 & 12 are required.\n\n"
"You can either upload the file here, or use the "
"'Request Page 11 Signature' button on the sale order "
"to send it for remote signing before confirming."
)
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.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
if self.intake_mode == 'separate':
if not (self.signed_pages_11_12 or order.x_fc_signed_pages_11_12):
raise UserError(
"Signed Pages 11 & 12 file is required when "
"'Separate file' mode is selected."
)
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(
"No remote-signing request found. Click "
"'Request Remote Signature' first, or pick a different mode."
)
order.with_context(skip_status_validation=True).write(vals)
# Post to chatter
from datetime import date
notes_html = f'<p style="margin: 4px 0 0 0;"><strong>Notes:</strong> {self.notes}</p>' if self.notes else ''
order.message_post(
body=Markup(
'<div style="background: #e8f4fd; border-left: 4px solid #17a2b8; padding: 12px; margin: 8px 0; border-radius: 4px;">'
'<h4 style="color: #17a2b8; margin: 0 0 8px 0;"><i class="fa fa-file-text-o"/> Application Received</h4>'
f'<p style="margin: 0;"><strong>Date:</strong> {date.today().strftime("%B %d, %Y")}</p>'
'<p style="margin: 8px 0 4px 0;"><strong>Documents Uploaded:</strong></p>'
'<ul style="margin: 0; padding-left: 20px;">'
f'<li><i class="fa fa-check text-success"/> Original ADP Application: {self.original_application_filename}</li>'
f'<li><i class="fa fa-check text-success"/> Signed Pages 11 & 12: {self.signed_pages_filename}</li>'
'</ul>'
f'{notes_html}'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
self._post_chatter(order)
return {'type': 'ir.actions.act_window_close'}
def action_request_page11_signature(self):
"""Open the Page 11 remote signing wizard from within the Application Received wizard."""
"""Open the Page 11 remote signing wizard from within this wizard."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
@@ -176,3 +239,66 @@ class ApplicationReceivedWizard(models.TransientModel):
'target': 'new',
'context': {'default_sale_order_id': self.sale_order_id.id},
}
# ------------------------------------------------------------------
# HELPERS
# ------------------------------------------------------------------
@staticmethod
def _validate_pdf_bytes(b64_data, label):
"""Raise UserError if the uploaded binary is not a real PDF."""
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 "
f"(content check failed — the file does not start with %PDF-)."
)
def _post_chatter(self, order):
"""Post a mode-aware Application Received message to the chatter."""
self.ensure_one()
mode = self.intake_mode
if mode == 'bundled':
headline = 'Application Received — bundled'
detail = 'Pages 11 & 12 included in original PDF'
elif mode == 'separate':
headline = 'Application Received — separate files'
detail = 'Original + separate signed pages uploaded'
else: # remote
n = len(order.page11_sign_request_ids.filtered(
lambda r: r.state in ('sent', 'signed')
))
headline = 'Application Received — remote signature pending'
detail = f'Page 11 sent for remote signature ({n} request(s) outstanding)'
notes_html = (
f'<p style="margin: 4px 0 0 0;"><strong>Notes:</strong> {self.notes}</p>'
if self.notes else ''
)
body = Markup(
'<div style="background:#e8f4fd;border-left:4px solid #17a2b8;'
'padding:12px;margin:8px 0;border-radius:4px;">'
'<h4 style="color:#17a2b8;margin:0 0 8px 0;">'
'<i class="fa fa-file-text-o"/> {headline}</h4>'
'<p style="margin:0;"><strong>Date:</strong> {today}</p>'
'<p style="margin:8px 0 4px 0;">{detail}</p>'
'<p style="margin:0;color:#666;">'
'Original: {orig_name}</p>'
'{notes}'
'</div>'
).format(
headline=headline,
today=date.today().strftime('%B %d, %Y'),
detail=detail,
orig_name=self.original_application_filename or '(no filename)',
notes=notes_html,
)
order.message_post(
body=body,
message_type='notification',
subtype_xmlid='mail.mt_note',
)

View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Application Received Wizard Form View -->
<record id="view_application_received_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.application.received.wizard.form</field>
<field name="model">fusion_claims.application.received.wizard</field>
@@ -8,64 +7,86 @@
<form string="Application Received">
<div class="alert alert-info mb-3" role="alert">
<strong><i class="fa fa-info-circle"/> Upload Required Documents</strong>
<p class="mb-0">Please upload the ADP application documents received from the client.</p>
<p class="mb-0">
Please upload the ADP application documents received from the client,
then tell the system how pages 11 &amp; 12 were provided.
</p>
</div>
<field name="sale_order_id" invisible="1"/>
<field name="has_pending_page11_request" invisible="1"/>
<field name="has_signed_page11" invisible="1"/>
<separator string="How were pages 11 &amp; 12 provided?"/>
<group col="1">
<field name="intake_mode" widget="radio" nolabel="1"/>
</group>
<group>
<group string="Original ADP Application">
<field name="original_application" filename="original_application_filename"
<field name="original_application"
filename="original_application_filename"
widget="binary" class="oe_inline"/>
<field name="original_application_filename" invisible="1"/>
<field name="original_page_count" readonly="1"
string="Detected pages"
invisible="not original_application"/>
</group>
<group string="Signed Pages 11 &amp; 12">
<field name="signed_pages_11_12" filename="signed_pages_filename"
widget="binary" class="oe_inline"/>
<field name="signed_pages_filename" invisible="1"/>
<div invisible="has_signed_page11" class="mt-2">
<span class="text-muted small">Don't have signed pages? </span>
<group string="Signed Pages 11 &amp; 12"
invisible="intake_mode != 'separate'">
<field name="signed_pages_11_12"
filename="signed_pages_filename"
widget="binary" class="oe_inline"
required="intake_mode == 'separate'"/>
<field name="signed_pages_filename" invisible="1"/>
</group>
<group string="Remote Signature"
invisible="intake_mode != 'remote'">
<div invisible="has_pending_page11_request or has_signed_page11"
class="mt-2">
<span class="text-muted small">
Don't have signed pages? Send a remote signing link to a family
member or agent.
</span>
<button name="action_request_page11_signature" type="object"
string="Request Remote Signature"
class="btn btn-sm btn-outline-warning"
icon="fa-pencil-square-o"
help="Send Page 11 to a family member or agent for digital signing"/>
icon="fa-pencil-square-o"/>
</div>
<div invisible="not has_pending_page11_request" class="mt-2">
<div class="alert alert-warning mb-0 py-2 px-3">
<i class="fa fa-clock-o"/> A remote signing request has been sent.
You can proceed without uploading signed pages -- they will be auto-filled when signed.
<i class="fa fa-clock-o"/>
A remote signing request has been sent. You can confirm now -
the signed PDF will be auto-attached when received.
</div>
</div>
<div invisible="not has_signed_page11 or signed_pages_11_12" class="mt-2">
<div invisible="not has_signed_page11" class="mt-2">
<div class="alert alert-success mb-0 py-2 px-3">
<i class="fa fa-check-circle"/> Page 11 has been signed remotely.
<i class="fa fa-check-circle"/>
Page 11 has been signed remotely.
</div>
</div>
</group>
</group>
<group>
<field name="notes" placeholder="Any notes about the received application..."/>
</group>
<footer>
<button name="action_confirm" type="object"
string="Confirm Application Received" class="btn-primary"
icon="fa-check"/>
<button name="action_confirm" type="object"
string="Confirm Application Received"
class="btn-primary" icon="fa-check"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<!-- Action for the wizard -->
<record id="action_application_received_wizard" model="ir.actions.act_window">
<field name="name">Application Received</field>
<field name="res_model">fusion_claims.application.received.wizard</field>

View File

@@ -78,7 +78,7 @@ class CaseCloseVerificationWizard(models.TransientModel):
def _compute_document_status(self):
for wizard in self:
order = wizard.sale_order_id
wizard.has_signed_pages = bool(order.x_fc_signed_pages_11_12)
wizard.has_signed_pages = order.x_fc_has_signed_pages_11_12
wizard.has_final_application = bool(order.x_fc_final_submitted_application)
wizard.has_proof_of_delivery = bool(order.x_fc_proof_of_delivery)
wizard.has_vendor_bills = len(order.x_fc_vendor_bill_ids) > 0

View File

@@ -92,7 +92,7 @@ class ReadyForSubmissionWizard(models.TransientModel):
wizard.has_authorization_date = bool(order.x_fc_claim_authorization_date)
wizard.has_client_refs = bool(order.x_fc_client_ref_1 and order.x_fc_client_ref_2)
wizard.has_reason = bool(order.x_fc_reason_for_application)
wizard.has_documents = bool(order.x_fc_original_application and order.x_fc_signed_pages_11_12)
wizard.has_documents = bool(order.x_fc_original_application and order.x_fc_has_signed_pages_11_12)
@api.model
def default_get(self, fields_list):
@@ -145,7 +145,7 @@ class ReadyForSubmissionWizard(models.TransientModel):
# Check documents
if not order.x_fc_original_application:
missing.append('Original ADP Application (upload in Application Received step)')
if not order.x_fc_signed_pages_11_12:
if not order.x_fc_has_signed_pages_11_12:
missing.append('Page 11 & 12 Signed (upload in Application Received step)')
if missing:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
{
"name": "Fusion PDF Preview",
"version": "19.0.2.0.0",
"version": "19.0.2.1.0",
"depends": ["web"],
"author": "Nexa Systems Inc",
"category": "web",
@@ -41,6 +41,7 @@ Key Features:
"assets": {
"web.assets_backend": [
"fusion_pdf_preview/static/src/js/pdf_preview.js",
"fusion_pdf_preview/static/src/js/open_attachment_action.js",
"fusion_pdf_preview/static/src/js/user_menu.js",
"fusion_pdf_preview/static/src/xml/pdf_viewer_dialog.xml",
],

View File

@@ -3,5 +3,6 @@
from . import res_users
from . import ir_http
from . import ir_actions_report
from . import ir_attachment
from . import res_config_settings
from . import preview_log

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
from odoo import models
class IrAttachment(models.Model):
_inherit = "ir.attachment"
def action_fusion_preview(self, title=None, model_name=None, record_ids=None):
"""Return the right action to "view" this attachment.
- PDF attachments → fusion_pdf_preview's client dialog (preview
+ print + download all in one place, with audit log).
- Anything else (ZPL, CSV, XML, images, etc.) → legacy
new-tab/download URL since the PDF viewer can't render them.
Drop-in replacement for the common pattern:
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=true' % att.id,
'target': 'new',
}
Use as: return att.action_fusion_preview(title='My Doc')
See CLAUDE.md "PDF Preview" for the full contract.
"""
self.ensure_one()
is_pdf = (
(self.mimetype or '').lower() == 'application/pdf'
or (self.name or '').lower().endswith('.pdf')
)
if is_pdf:
return {
'type': 'ir.actions.client',
'tag': 'fusion_pdf_preview.open_attachment',
'params': {
'attachment_id': self.id,
'title': title or self.name or 'Document',
'model_name': model_name or '',
'record_ids': str(record_ids) if record_ids else '',
},
}
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=true' % self.id,
'target': 'new',
}

View File

@@ -0,0 +1,42 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { openPDFViewer } from "./pdf_preview";
/**
* Client action: open an ir.attachment in the PDF preview dialog.
*
* Python callers return:
* {
* 'type': 'ir.actions.client',
* 'tag': 'fusion_pdf_preview.open_attachment',
* 'params': {
* 'attachment_id': <int>,
* 'title': <str>, // optional, defaults to "Document"
* 'model_name': <str>, // optional, for audit log
* 'record_ids': <str>, // optional, comma-sep for audit log
* 'report_name': <str>, // optional, for audit log
* },
* }
*
* Non-PDF attachments fall back to opening in a new browser tab — the
* preview dialog only renders PDF. ZPL labels (text/plain) should NOT
* use this action; route those through the direct download act_url.
*/
registry.category("actions").add(
"fusion_pdf_preview.open_attachment",
async (env, action) => {
const params = action.params || {};
const attachmentId = params.attachment_id;
if (!attachmentId) {
return;
}
const title = params.title || "Document";
const url = `/web/content/${attachmentId}`;
openPDFViewer(env, url, title, {
modelName: params.model_name || "",
recordIds: params.record_ids || "",
reportName: params.report_name || "",
});
}
);

View File

@@ -3,9 +3,13 @@
## Project
Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and metal finishing shops. Built by Nexa Systems for EN Technologies (the client). Replaces Steelhead Software.
## Recent Session Handoff — 2026-05-17
## Recent Session Handoff — 2026-05-17 (Portal Redesign + Sub-A IA approved)
> **For the next Claude session.** All changes below are LIVE on entech (db `admin` on LXC 111 / pve-worker5). Local repo has uncommitted changes against base commit `9ebf89b`; run `git status` to see them. All durable conventions added during this session are folded into the Critical Rules below (rules 6a, 14, 14a, 14b) — read those FIRST before changing reports / stickers / signatures / SO line tax fields.
> **For the next Claude session.** Portal dashboard + jobs + detail + configurator are LIVE on entech at `fusion_plating_portal 19.0.3.7.0`. Sub-A (Portal IA + Sidebar) is brainstormed, spec'd, planned, NOT yet executed — pick up there. Full handoff (live state, decisions, gotchas, how-to-deploy, what's deferred): **[`docs/superpowers/handoffs/2026-05-17-portal-redesign-handoff.md`](docs/superpowers/handoffs/2026-05-17-portal-redesign-handoff.md)**. Approved plan ready to execute: **[`docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md`](docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md)** (11 tasks, 4 phases). Don't re-brainstorm; just execute.
### Previous handoff (pre-portal redesign — superseded but kept for context)
> All changes below are LIVE on entech (db `admin` on LXC 111 / pve-worker5). Local repo has uncommitted changes against base commit `9ebf89b`; run `git status` to see them. All durable conventions added during this session are folded into the Critical Rules below (rules 6a, 14, 14a, 14b) — read those FIRST before changing reports / stickers / signatures / SO line tax fields.
### Shipped this session
@@ -23,6 +27,27 @@ Fusion Plating is a multi-module Odoo 19 ERP for electroless nickel plating and
| **Signature unification** | All FP reports (WO Detail, CoC, CoC Chronological) now read signatures from a single source: `signer_user.x_fc_signature_image` (Plating Signature). Retired: HR Employee signature lookup AND `res.company.x_fc_coc_signature_override` (UI removed; column kept, no migration). See rule 14b. | `fusion_plating_certificates`, `fusion_plating_reports`, `fusion_plating_jobs` |
| **Report palette overhaul** | Green `res.company.primary_color` → hardcoded neutral palette: `#c1c1c1` header backgrounds, `#1d1f1e` th text, `#2e2e2e` h2/h4 titles (bumped to 20pt portrait / 22pt landscape). Grand Total row also `#c1c1c1`. Work Order Detail blue `#1a4d80` retired in favour of the same palette. Title format now "Type # Number" (Quotation # …, Sales Order # …, Invoice # …, Packing Slip # …, Work Order Traveller # …). See rule 14a. | `fusion_plating_reports` 19.0.11.14.0, `fusion_plating_jobs` 19.0.10.8.0 |
| **Report border rendering** | After two failed attempts (px→mm conversion + dpi bump; then `border-collapse: separate` single-side-per-cell), settled on **`border-collapse: collapse` + longhand borders + `background-clip: padding-box`**. Verticals are a hair softer than horizontals on entech wkhtmltopdf — accepted as the lesser evil vs misaligned tables. See rule 14a, last paragraph. **Don't retry the single-side pattern.** | `fusion_plating_reports` |
| **Page-break-inside: avoid placement** | When a long QWeb report dumps content into multi-page PDFs via wkhtmltopdf, the company header (rendered as `--header-html`) can overlap body content if a page break lands mid-row in a table. **Apply `page-break-inside: avoid` to `<tr>` elements** (and to wrapper `<div>`s that wrap whole logical sections like signature blocks), not to `<table>`. On entech wkhtmltopdf, `<table>`-level `page-break-inside` is unreliable when the table is long enough to definitely break; per-row is honoured. Pattern: keep individual readings/rows together so the wkhtmltopdf header zone never overlaps mid-row content. Wrap the larger logical block (cert thickness section, signature + certification statement) in `<div style="page-break-inside: avoid;">` to keep it together when it fits and naturally wrap to a fresh page when it doesn't. | `fusion_plating_reports/report/report_coc.xml` |
| **`opacity` + `italic` muted text renders jagged on entech wkhtmltopdf** | The obvious pattern for a subtle footnote — `font-style: italic; opacity: 0.7;` (used by `.fp-coc .small-label`) — produces washed-out, jagged characters that look "broken" or "messed up" on the printed PDF. Visually it reads as garbled text even though the source is clean. **Use solid grey (`color: #555`) at normal weight instead** for muted secondary text. Same workaround applies to any `opacity`-driven greyed-out element bound for wkhtmltopdf. The existing `.small-label` class still exists for legacy callers but new code should prefer an explicit `color:` style. | `fusion_plating_reports` |
| **wkhtmltopdf header overlap — paperformat.margin_top, NOT body padding-top** | The wkhtmltopdf header zone is sized by `report.paperformat.margin_top` (and `header_spacing`). If the `web.external_layout` header (logo + address etc.) renders ~28mm tall but paperformat reserves only 8mm, page 2+ has the header bleeding over body content (the overlap shows up as the company logo printed *on top of* the signature, readings table, etc.). The anti-pattern is "fix" it by adding `padding-top: 50mm` to the body wrapper — this only pads page 1 (single one-shot padding) and does nothing for subsequent pages, while also wasting 50mm of usable space on page 1. **Right fix (the CoC pattern — proven to work):** use a TINY `margin_top` (≤8mm) + `header_spacing=0` + `header_line=False`, then put a generous `padding-top: 20mm` on the body wrapper class (`.fp-coc`, `.fp-sale`, etc.). The header HTML is allowed to overflow the reserved zone INTO the body area; the body's wrapper padding is what actually clears it. This is more robust than trying to size `margin_top` to the rendered header height — any future header change (extra phone line, taller logo) immediately causes overlap with the "sized exactly" approach, whereas the overflow+padding approach has slack built in. Reference setup: `paperformat_fp_coc` and `paperformat_fp_a4_portrait` both use `margin_top=8`, `header_spacing=0`, `header_line=False`; CoC's `.fp-coc` and SO's `.fp-sale` both use `padding-top: 20mm` on the wrapper. **For landscape custom-header reports, reuse `paperformat_fp_a4_landscape_compact`** (same shape, just rotated) — invoice landscape uses this; don't create yet-another-landscape-compact. Each report can have its own paperformat — `report_coc_en` / `report_coc_fr` use "Fusion Plating CoC" (id 13); the legacy `report_coc` uses "A4 Landscape (Fusion Plating)" (id 12); SO portrait uses "Fusion Plating A4 Portrait (Compact)". Update the right one and don't bleed changes across reports. **Corollary — don't use negative `margin-top` to "tighten" the gap** (e.g. `.my-page { margin-top: -10px; }` to pull the H1 up under the header). The body wrapper sits at the bottom edge of the reserved margin_top zone; any negative margin pushes content INTO the header band, where wkhtmltopdf clips the top of glyphs (looks like the title is half-eaten). If the gap really feels too big, shrink the title font instead, or reduce `paperformat.margin_top` so the entire header zone is shorter. **For customer-facing portrait reports** (SO confirmation, quote, invoice, packing slip, BoL) the canonical compact paperformat is `fusion_plating_reports.paperformat_fp_a4_portrait` (margin_top=22mm, header_spacing=3mm, keeps the standard header band). Bind it via `<field name="paperformat_id" ref="fusion_plating_reports.paperformat_fp_a4_portrait"/>` rather than creating yet-another-one. **Two compounding-padding traps to be aware of:** (1) Odoo's `.page` class has `padding: 1cm` baked in (Bootstrap-derived). If you wrap your body in `<div class="page">` AND add a body `padding-top: 15mm`, you get the paperformat margin_top + 10mm Odoo + 15mm yours = ~65mm of dead space above the title. To remove the .page contribution without losing its left/right padding, override only the top: `.fp-report.fp-sale .page { padding-top: 0 !important; }`. CoC sidesteps this by NOT using an inner `.page` div — it wraps directly in `<div class="fp-coc">` and puts padding on that. (2) The base `.fp-report table.bordered th, .fp-report table.bordered td` rule applies borders explicitly, BUT a separate cascade still bleeds borders onto NESTED `<table>` elements even when the inner table has no `.bordered` class — `border: 0 !important` on the cells does NOT reliably override it (some wkhtmltopdf rendering paths still draw the lines). **Don't use a `<table>` for non-bordered layouts** like a title/barcode strip; use `<div>` + `float: right` / flexbox instead. Saves an hour of CSS specificity arguments with wkhtmltopdf. (3) **CSS comments inside QWeb `<style>` blocks are XML-parsed** — writing `/* don't use a <table> here */` makes lxml see a literal `<table>` opening tag and the file fails to load with `XMLSyntaxError: Opening and ending tag mismatch`. Strip the angle brackets from any HTML-like literals in CSS comments: write `/* don't use a table here */` or quote it as `"<table>"`. (4) **XML comments cannot contain `--` (double-hyphen)** per the XML spec — `<!-- needs wkhtmltopdf --footer-html -->` fails with `XMLSyntaxError: Comment must not contain '--' (double-hyphen)`. Rewrite without the double-hyphen: `<!-- needs a wkhtmltopdf footer-html arg -->`. Bites when documenting CLI flags or option names in QWeb comments. | `fusion_plating_reports`, `report.paperformat` |
| **CoC + thickness = ONE cert (page 2 merge OR inline body)** | When a customer has both `x_fc_send_coc` and `x_fc_send_thickness_report` on (or part has `certificate_requirement='coc_thickness'`), `_resolve_required_cert_types` returns **`{'coc'}` only**. Standalone `thickness_report` certs are only created when CoC is OFF and thickness is ON (rare). The earlier "two certs" behavior was a bug — don't restore it. **Two rendering paths exist for the thickness data in the CoC PDF:** (1) **Page-2 PDF merge** via `_fp_merge_thickness_into_pdf` — used when there's a real PDF source (operator uploaded a Fischerscope PDF, or QC has `thickness_report_pdf_id`). (2) **Inline readings table in the CoC body** — used when `thickness_reading_ids` is populated but there's no PDF source (e.g. RTF upload parsed to readings, manually typed readings). Lives in `report_coc.xml` between the parts table and the signature block, gated on `doc.thickness_reading_ids`. Both can coexist on a cert — PDF merges as page 2, readings render inline; usually only one path has data per cert. | `fusion_plating_jobs`, `fusion_plating_certificates`, `fusion_plating_reports` |
| **Smart-button "create or view" pattern** | For a smart button that toggles between "create" and "view" states, use **one** idempotent button with `widget="statinfo"`, not two sibling buttons gated by mutually-exclusive `invisible` expressions. Custom `<div class="o_stat_info">` without `<span class="o_stat_value">` renders awkwardly in Odoo 19 (numbers + label expected); `statinfo` handles the standard structure automatically. The action method itself should branch on whether the linked record exists (create-then-open or just open). | any module with smart buttons |
| **stock.move.name removed** | Odoo 19 dropped the `name` field on `stock.move`. Passing `name` in a create dict raises `ValueError: Invalid field 'name' on model 'stock.move'`. Use `description_picking` instead (the operator-facing line label on the picking). The DB column is gone too — `name` doesn't exist as a stored field. | any code that builds stock.move records |
| **stock.picking.move_ids_without_package removed** | Odoo 19 dropped `move_ids_without_package` on `stock.picking``<t t-foreach="doc.move_ids_without_package">` raises `AttributeError: 'stock.picking' object has no attribute 'move_ids_without_package'` at QWeb render. Use `move_ids` instead (all stock moves on the picking). The filtered "without_package" variant no longer exists; if you really need to exclude packaged moves, filter `move_ids` in QWeb (`move_ids.filtered(lambda m: not m.package_level_id)`) or in a python helper. Same gotcha applies to any old report/template ported from Odoo 16/17. | any report/view iterating over picking moves |
| **Recordsets use `__slots__` — no transient attrs** | Odoo 19's `BaseModel` declares `__slots__ = ['env', '_ids', '_prefetch_ids']`, so `picking._my_stash = data` raises `AttributeError: 'stock.picking' object has no attribute '_my_stash'`. The error reads like a missing field but it's actually Python rejecting the assignment. Don't stash transient state on a recordset between method calls — pass it as a method arg, store on the caller's `self`, or use `env.context` for cross-frame plumbing. Caught here because `fp_receiving._fp_build_shipping_picking` tried to attach `_fp_outbound_packages` to the picking before handing off to `_fp_apply_shipping_result`; the catch-all `except Exception` swallowed it and surfaced the misleading "Carrier API call failed" wizard. | any code that wants to attach data to a recordset between calls |
| **labelary.com dependency for ZPL→PDF** | `fusion_plating_receiving` POSTs ZPL labels to `https://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/` to get a PDF rasterization, so one FedEx ship call can populate both the PDF and ZPL smart buttons on the receiving form. **Privacy:** every outbound label's shipping address + tracking number leaves the network and hits labelary's servers (no payment data, but real customer info). **Operational:** anonymous tier is ~5 req/s; add an API key in the labelary helper if you ever ship more than that. PDF→ZPL is intentionally not attempted — that direction is impractical and FedEx's `/ship` endpoint only returns one format per shipment, so the carrier MUST be configured for ZPLII (not PDF) for the dual-format flow to work. Switching the carrier back to PDF will silently drop the ZPL button. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
| **FedEx ZPL ships with `^POI` — strip it** | FedEx's REST `/ship` endpoint returns ZPL with `^POI` (Print Orientation = Invert) baked in, which flips the label 180° on the printer. On a desktop direct-thermal like the Zebra ZD450 that prints upside-down for the operator, and labelary mirrors the inversion in the PDF preview. `_fp_apply_shipping_result` creates a `*-fixed.zpl` copy of the FedEx attachment with `^POI` removed and points the shipment + smart buttons at the cleaned copy; the original FedEx ZPL stays on the picking for audit. **Don't restore `^POI`** — both the PDF preview and the Zebra output need it stripped. If a future printer needs inverted orientation, configure the printer driver instead of putting `^POI` back. | `fusion_plating_receiving/models/fp_receiving.py` (`_fp_apply_shipping_result`) |
| **Per-shipment service override via `fp_service_type_override` context key** | Operator picks a FedEx service tier on `fp.receiving.x_fc_outbound_service_type` (Priority Overnight, 2Day, Ground, etc.). `action_generate_outbound_label` passes the chosen code through to `carrier.send_shipping` via `with_context(fp_service_type_override=…)`. `fusion_shipping.fusion_fedex_rest_send_shipping` reads the context key and overrides `srm.service_type` for that call only — carrier default is untouched. Empty/blank override falls back to `carrier.fedex_rest_service_type`. Only FedEx is wired up right now; mirroring this for Canada Post / UPS is a separate task. | `fusion_plating_receiving/models/fp_receiving.py``fusion_shipping/models/delivery_carrier.py` |
| **`mail.template.body_html` is `Markup` + jsonb** | Two gotchas: (1) `tpl.body_html` returns a `markupsafe.Markup` object. `Markup.replace(old, new)` *escapes both args* — quotes in `old` become `&#39;` so the literal pre-escape string never matches. **Cast to `str(tpl.body_html)` before calling `.replace`**. (2) The DB column is `jsonb` (translatable). Direct `UPDATE ... SET body_html = '...'` SQL fails with `invalid input syntax for type json`; either use ORM `tpl.write({'body_html': ...})` or wrap raw SQL with `jsonb_build_object('en_US', ...)`. (3) Mail-template XML data files typically use `<odoo noupdate="1">` so `-u <module>` does NOT reload them — users can edit templates in the UI and the module won't overwrite. To sync XML edits to existing records: temporarily flip the wrapper to `<odoo noupdate="0">`, redeploy and `-u`, then revert (and `UPDATE ir_model_data SET noupdate=true ...` to restore protection). Alternatively, post-migration script or odoo shell write. (4) **`mail.template.report_name` was removed in Odoo 19** — the dynamic PDF-filename field now lives on `ir.actions.report.print_report_name` instead. Old `<field name="report_name">` entries in mail-template data files silently survive while protected by noupdate=1, but the moment you force-reload they error with `Invalid field 'report_name' in 'mail.template'`. Strip them or move the expression to the report action. | any code scripting `mail.template.body_html` |
| **`message_post(body=...)` HTML-escapes by default** | A plain `str` body with `<b>` tags renders as literal `<b>foo</b>` text in chatter — operators see angle brackets, not bold. Wrap the template in `Markup(_('... <b>%s</b> ...'))` and use `%`/`format_map` for substitutions; markupsafe escapes the substituted values automatically so user input still can't inject HTML. Pattern: `self.message_post(body=Markup(_('Tracking: <b>%s</b>')) % tracking)`. | any model posting HTML-formatted chatter |
| **OWL `t-out` escapes plain JS strings — wrap with `markup()`** | The JS-side analogue of the `message_post` markup gotcha. `t-out="state.html"` only renders unescaped HTML when the value is a `markup()`-tagged string from `@odoo/owl`; a plain string (e.g. straight off an RPC response) gets HTML-escaped and the user sees literal `<p>foo</p>` text. Caught here because `fp_record_inputs_dialog.js` was assigning `this.state.instructionsHtml = data.instructions_html` raw — recipe author's `<p>...</p>` rendered as visible tags in the operator dialog. **Fix:** `import { markup } from "@odoo/owl"` and wrap RPC-returned HTML: `this.state.html = markup(data.html || "")`. Same rule for any OWL component that ingests HTML from the server and pushes it through `t-out`. | any OWL component rendering server-returned HTML via `t-out` |
| **entech apt is broken — install new packages via `dpkg -i` bypass** | LXC 111's apt state has pre-existing breakage that blocks ANY `apt install`: `python3-lxml-html-clean` not installable on Bookworm but odoo's deb depends on it, `postgresql-15-pgvector` Breaks `postgresql-15-jit-llvm (< 19)`, `libglu1-mesa`/`libglx-mesa0` installed without their Mesa sub-deps (libopengl0, libdrm2, libxfixes3…), `postgresql-15` itself in `iF` half-configured state. Apt's global resolver refuses ALL installs until these are fixed. Workaround that worked for ImageMagick + libwmf: `apt-get download` the target debs into a tmp dir, then `dpkg -i *.deb` — dpkg only checks the direct deps of what you're installing, not the system-wide health. Use this pattern when entech needs new system packages; **don't try `apt --fix-broken install`** without coordinating with whoever owns the box — fixing pgvector/lxml-html-clean could cascade into Odoo or PostgreSQL changes. Installed this way: `imagemagick`, `imagemagick-6-common`, `imagemagick-6.q16`, `libmagickcore-6.q16-6`, `libmagickwand-6.q16-6`, `libwmf-0.2-7`, `libwmflite-0.2-7`, `libwmf-bin`, `libfftw3-double3`, `liblqr-1-0`, `hicolor-icon-theme` (2026-05-21, ~4 MB total). WMF→raster path: `wmf2svg input.wmf -o out.svg` writes a thin SVG referencing `out-N.png` side-files (libwmf unpacks raster blocks inside the metafile). ImageMagick's `convert` lacks the WMF delegate on Debian Bookworm — use wmf2svg for raster extraction, not `convert input.wmf out.png`. | any new system package install on entech LXC 111 |
| **Fischerscope XDAL 600 `.doc` files are actually RTF** | Helmut Fischer's XDAL 600 XRF software exports thickness reports with a `.doc` extension but the file contents are **RTF** (`{\\rtf1\\ansi…`), not Microsoft Word binary `.doc`. `file(1)` confirms: `Rich Text Format data, version 1`. python-docx will refuse to open it, and the filename-based dispatch (`endswith('.docx')`) silently skips parsing. **Don't reach for libreoffice/antiword.** Detect by **magic bytes** (`raw_bytes[:5] == b'{\\\\rtf'`) and route through `_fp_parse_fischerscope_rtf` instead — it strips RTF control words with regex and runs the same Fischerscope reading regex as the .docx path. The image data embedded as hex inside `{\\pict ...}` blocks must be stripped FIRST or the reading regex will choke on multi-MB image hex. | `fusion_plating_jobs/wizards/fp_cert_issue_wizard.py` |
| **entech apt — which conversion tools are available** | The host has pre-existing broken deps (`python3-lxml-html-clean` missing, `postgresql-15-pgvector` vs `postgresql-15-jit-llvm` conflict, various Mesa packages) that make new `apt install` calls fragile — they often abort partway through dep resolution. **Currently installed and usable:** `convert` (ImageMagick 6), `wmf2svg`, `wmf2eps` (libwmf-bin). **Not installed:** `libreoffice`, `unoconv`, `pandoc`, `wmf2png`. Don't assume the next `apt install` will go through — always run `which <tool>` first and design the feature to soft-fail if the tool isn't there (see `_fp_extract_rtf_images` for the pattern: shell out, catch `FileNotFoundError`/`TimeoutExpired`, fall back to "no image" instead of crashing the cert flow). For WMF → PNG specifically: `wmf2svg` writes both SVG and a side-file `*-N.png` per embedded raster — use that, not `convert input.wmf` (no WMF delegate). For new tools: check pure-Python alternatives first (Pillow without backends, pypdf, openpyxl) before reaching for apt. | any feature wanting to convert docs/images server-side |
| **Custom-header reports need `.article` wrapper for UTF-8 — use `fp_external_layout_clean`, not raw `html_container`** | Pattern that bit us: building a custom-header QWeb report (logo + address LEFT, title + barcode RIGHT in one row, no Odoo company band) by dropping `<t t-call="web.external_layout">` and using only `<t t-call="web.html_container">`. **Result:** every accented French character (é, è, °, em-dash) rendered as Latin-1 mojibake in the PDF (`Adresse d'expédition``Adresse d'expédition`, `N° de pièce``N° de pièce`, `—``â€"`). Root cause: Odoo's report renderer expects a `<div class="article">` wrapper to dispatch content through the proper UTF-8-aware pipeline; raw `html_container` doesn't have it. **The CSS-hide approach DOESN'T work either** (e.g. `body > .header, div.header { display: none !important; }`) — the `.header` and `.footer` divs from `external_layout_standard` get **extracted from the body and pushed into wkhtmltopdf's separate `--header-html` / `--footer-html` streams BEFORE the body's CSS gets a chance to apply**, so they render in the page margins regardless of any CSS rule. **Right pattern:** `<t t-call="fusion_plating_reports.fp_external_layout_clean">` (defined in `report_fp_sale.xml`) — this variant provides just the `.article` wrapper that Odoo's pipeline needs, with NO auto `.header` div. It DOES keep a minimal `.footer` div carrying only `Page <span class="page"/> / <span class="topage"/>` — those page-number placeholders **only get substituted with the current/total page when the `.footer` div is extracted into wkhtmltopdf's `--footer-html` stream**, so if you want page numbers in a custom-layout report, include a minimal `.footer` div with just those spans (rendering "Page X / Y") — don't try to set them from QWeb or compute the page count yourself. The layout also prints an optional **internal form code** on the footer's left side when the calling report sets `<t t-set="form_code" t-value="'FRM-XXX'"/>` BEFORE the `<t t-call="...fp_external_layout_clean">`. Sale Order Confirmation uses `FRM-006`; other reports adopt their own as they're standardized. Reports that don't set `form_code` leave the left side blank — the right side always carries `Page X / Y`. Canonical example: `report_fp_sale.xml` (SO confirmation portrait). | any custom-header PDF report on entech wkhtmltopdf |
| **QWeb `t-field` requires a dotted path — bare variables fail at compile** | Odoo 19 enforces `assert "." in el.get('t-field')` in `_compile_directive_field`. Writing `<div t-field="partner" t-options="{'widget': 'contact', ...}"/>` (where `partner` came from a `<t t-set="partner" t-value="..."/>` in the calling template) **fails at template-compile time** with `AssertionError: t-field must have at least a dot like 'record.field_name'`. The error message points at the line, but the broader trap is that **you can't write a generic "render-a-partner-as-contact" sub-template that takes a record via t-set** — the contact-widget pattern only works on real field traversals like `doc.partner_id` baked into the template at author time. **Workarounds:** (a) Inline the partner rendering at each call site so the `t-field` has a dotted path (`<div t-field="doc.partner_invoice_id" t-options=...`). (b) Render the address parts manually in the sub-template using `t-esc` on explicit fields (`partner.street`, `partner.city`, etc.) — verbose but works with bare variables. Pattern (b) is what `fp_packing_slip_addr_block` uses now after this trap was hit. Same applies to `t-out` with `widget` options. | any QWeb sub-template trying to render a record via `t-field` |
| **Assigning a `Date` to a `Datetime` field shifts the day in negative-UTC timezones** | When a transient/wizard `fields.Date` value is written into a target `fields.Datetime` field (e.g. wizard `customer_deadline` → SO `commitment_date`), Odoo stores midnight UTC of the picked date. Rendered back in any negative-UTC timezone (Eastern UTC-4/-5, all of CA/US), midnight UTC = 8pm the previous day — so the user picks "May 25" in the wizard and sees "May 24" on the SO header / PDF report. **Fix:** combine the date with noon before writing: `datetime.combine(self.my_date, time(12, 0))` — noon UTC stays on the same calendar date in every reasonable timezone (±12hr). Caught here on `fp.direct.order.wizard._prepare_order_vals` writing `commitment_date`. Watch for the same pattern any time a wizard/configurator with a Date field hands off to a Datetime target. The reverse (`Datetime` field read into a Date-display) is fine if `t-options="{'widget':'date'}"` is used — Odoo handles the tz-aware date extraction. | any wizard writing a Date value into a Datetime field |
| **Customer-facing reports use bilingual EN/FR labels** | Every customer-facing report label (column titles, section banners, totals, document title) renders English first and French second. **Default to inline slash format** ("English / French" on one line) — easier to scan and saves vertical space. **Use the stacked variant only for cells too narrow** for the French word to fit on the same line (QTY, UOM, narrow column headers in dense tables). CSS classes live in the `fp_sale_bilingual_styles` template in `report_fp_sale.xml`. **Inline (default):** `.fp-bl-en { font-weight:bold; }` + `.fp-bl-sep { color:#999; margin:0 3px; }` + `.fp-bl-fr { font-weight:normal; font-style:italic; color:#555; }`. Pattern: `<span class="fp-bl-en">English</span><span class="fp-bl-sep">/</span><span class="fp-bl-fr">French</span>`. **Stacked (narrow cells):** `.fp-bl-en-stk` + `.fp-bl-fr-stk` (each `display:block`). **Always render both spans even when EN and FR are the same word** (e.g. "Description / Description", "Taxes / Taxes") — visual consistency across the row matters more than the redundancy; dropping the FR span on identical-word labels leaves an obvious gap when scanning down a column of headers. When a report has a barcode block, encode `doc.name` via `ir.actions.report.barcode_data_uri('Code128', doc.name, 600, 100)` (the helper inlines a data URI — don't `/report/barcode/...` over HTTP, wkhtmltopdf network fetches fail on entech). Apply to ALL outward-facing reports (SO confirmation, quote, invoice, CoC, packing slip, BoL); internal-only reports (job traveller, WO sticker) can stay English. | `fusion_plating_reports/report/report_fp_sale.xml` (canonical), every customer-facing report |
### Pending — IN PROGRESS when this session ended
@@ -170,6 +195,12 @@ These modules have **source code in this repo** but are **intentionally NOT inst
14. **Sticker template — leave the CSS units alone**: `report_fp_wo_sticker_inner` is calibrated for **px units at paperformat dpi=300** on entech's wkhtmltopdf. Do NOT "modernise" it by converting px→mm or by bumping paperformat dpi — both have been tried (2026-05-16) and both collapsed the layout (tiny logo, tiny QR, body grid shorter than the body band, font sizes visually smaller despite using pt). The math suggests the conversions should be equivalent, but wkhtmltopdf's px↔mm↔dpi mapping doesn't follow the obvious model on this image. Trust the working geometry, change only what you came to change. **Barcode size cap**: Odoo core raises `ValueError("Barcode too large")` when `width * height > 1_200_000` OR `max(width, height) > 10000` (see `base/models/ir_actions_report.py::barcode`). Largest safe square is ~1095×1095 — we use 1000×1000 to stay clear of the ceiling. **Em-dash mojibake**: wkhtmltopdf's default font on entech mojibakes em-dash (—), en-dash (), smart quotes, and ellipsis into `â€"` etc. — strip them defensively for any free-text field that bleeds into the sticker (thickness, notes, line.name). The strip pattern is `.replace(u'—', '-').replace(u'', '-')...` in `report_fp_wo_sticker_inner`.
15. **Recipe editor parity**: Step-level UX features (image attachments, prompt editing, settings toggles, preview affordances, etc.) MUST be implemented in BOTH the **Simple Editor** (`fusion_plating/static/src/{js,xml,scss}/simple_recipe_editor.*` + `controllers/simple_recipe_controller.py`) AND the **Tree Editor** (`fusion_plating/static/src/{js,xml,scss}/recipe_tree_editor.*` + `controllers/recipe_controller.py`). Authors choose between editors per-recipe via `preferred_editor`; if a feature only lands in one, half the userbase silently misses it. Default assumption: most clients use the Simple Editor — when in doubt, ship Simple first, then port to Tree in the same change. Backend model + view changes (e.g. new fields on `fusion.plating.process.node`, new tabs on the node form) automatically reach both editors via the related model — only the editor-specific JS/XML/SCSS needs duplicating.
16. **HTTP controller route override = method name must match parent**: To override a route on an inherited controller (e.g. `portal.CustomerPortal.home()` at `/my/home`), the override method MUST share the parent's method name. Declaring a new method name with the same `@http.route()` URL does NOT override — Odoo registers BOTH handlers as siblings and the parent typically wins, silently. Pattern: `class FpCustomerPortal(CustomerPortal): @http.route() def home(self, **kw): ...`. Bit us 2026-05-17 in `fusion_plating_portal/controllers/portal.py``portal_my_home_dashboard()` failed to override stock `home()`; symptom was the rich FP dashboard never rendering at `/my/home` even though the template was active in DB.
17. **Test scaffolding — creating account.move in tests**: Two custom gates block direct invoice creation in tests:
- `fusion_plating_jobs` blocks all `out_invoice`/`out_refund`/`out_receipt` creates unless `context.get('fp_from_so_invoice')` is set or `invoice_origin` matches an SO name. Bypass: `self.env['account.move'].with_context(fp_from_so_invoice=True).create(...)`.
- `fusion_plating_invoicing` blocks `action_post()` when `invoice_payment_term_id` is unset. Bypass: pass `'invoice_payment_term_id': self.env.ref('account.account_payment_term_immediate').id` in the create vals.
Both are test-data scaffolding; neither weakens assertions and neither must appear in production code paths.
18. **Portal list pages — no pagination, 500-record cap**: All FP portal list routes (quote requests, jobs, certifications, deliveries) load up to 500 records and rely on client-side JS filtering. Do NOT re-add `portal_pager` to these routes. The `fp_portal_list_controls` macro + `fp_portal_list_search.js` handle filtering, counting, and the sort dropdown. Hidden `<td class="d-none">` cells inside each row carry extra searchable text (part number, customer PO, contact) that isn't displayed but is matched by the JS.
19. **QWeb `t-value` is Python, not Jinja**: `t-value="orders|length"` does NOT call a filter — Python parses `|` as bitwise/recordset OR, so on a non-empty recordset it tries `recordset | length_var` and raises `TypeError: unsupported operand types in: sale.order(…) | None` (when `length` is undefined) or returns a merged recordset (when `length` happens to be another recordset). Use `len(orders)` or `bool(orders)` or `(orders and orders[0]) or False` — explicit Python. Same trap applies to `|default`, `|first`, `|join`, etc. — none of these Jinja filters exist in QWeb. Bit us 2026-05-18 on `fp_sale_order_portal.xml` injecting `result_total` into the list-controls macro.
## Naming
- **New custom models** (post-2026-04): `fp.*` prefix (e.g. `fp.part.catalog`, `fp.certificate`)

View File

@@ -0,0 +1,153 @@
# Portal Redesign — Session Handoff (2026-05-17)
> **Read this first.** This session ran long; the next session picks up here. Everything below is intentionally short. Authoritative details live in the linked spec / plan files.
## TL;DR
Customer-portal redesign across two long sessions. Dashboard + jobs + detail page + configurator are LIVE on entech. The next step (sidebar nav + page audit + Account Summary view) has an APPROVED PLAN ready to execute — do not re-brainstorm, just execute.
**Immediately actionable:** execute [`docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md`](../plans/2026-05-17-portal-ia-sidebar-plan.md) via `superpowers:subagent-driven-development` or `superpowers:executing-plans`. User was offered both at handoff time and chose subagent-driven (preferred). 11 tasks across 4 phases.
## Live state on entech (2026-05-17)
| Module | Version live | Notes |
|---|---|---|
| `fusion_plating_portal` | `19.0.3.7.0` | Dashboard, job cards, configurator, detail page, doc downloads, repeat order, animations — all shipped |
| `fusion_plating_jobs` | `19.0.10.8.0` + write-hook + create-init | fp.job → fp.portal.job state-sync hook on write, initial state derive on create |
| `fusion_plating_reports` | `19.0.11.15.0` | Customer Acceptance / Authorized Representative signature blocks removed from `report_fp_sale_portrait/landscape` |
| All 5 portal unit tests green | | `--test-tags=fp_portal` |
Branch: `main`. Local repo is many commits ahead of `origin/main`; user has not been asked to push (per system-prompt safety default). Run `git log --oneline origin/main..HEAD` at session start to see what's outstanding.
## What shipped this session (high-level)
1. **Dashboard rebuild**`/my/home` → jobs-forward layout (KPI tiles → Active Work Orders hero → 5 secondary panels). Welcome line summarises status in plain words. EN Plating teal brand palette with gradient CTAs.
2. **Job card upgrade** — shared `fp_portal_job_card` macro (used by `/my/home` + `/my/jobs`). Wrap div + inner anchor + sibling actions footer (4 doc download chips + Repeat Order POST form). Part info + ship-to address pulled inline. Pulse animation on the active step circle + matching detail-page timeline dot.
3. **Detail page** — V2 stepper + V3 timestamps + 5-group document panel (From You / Specifications / Work Order / Quality / Shipping). Sales Order Confirmation, Work Order Detail, CoC, Packing Slip all sudo-render from the FP custom reports. Hero shows part + ship-to.
4. **Configurator fixes**`/my/configurator/coating` 500 fixed (`fp.coating.config``fusion.plating.process.type`). Manual measurements hidden in step 1. Split single-file upload into Drawing (PDF) + 3D Model.
5. **Sale report cleanup** — Customer Acceptance / Authorized Representative signature block removed.
6. **Misc**`/my` route added, button sizing normalised, hover-underline suppressed globally, sidebar of legacy stuff redirected, dashboard expanded to 5 panels (Quote Requests + Purchase Orders added).
24+ commits this session, all on `main`. Browse `git log --oneline -30` for the full sweep.
## What's queued for execution
**Sub-A (Portal IA + Sidebar):** plan ready, not yet executed. Brainstorm decisions baked in:
| Decision | Choice |
|---|---|
| Sidebar shape | **B** — Dashboard top, then grouped Activity / Documents / Account sections |
| Account Summary tabs | 3 (Invoices / Credit Memos / Statements) + Open Balance pill in header |
| Statements V1 | Placeholder card ("Coming soon") — real statement generation deferred |
| Legacy URL redirects | `/my/fp_invoices``/my/account_summary`; `/my/purchase_orders``/my/orders` (Odoo default); `/my/quote_requests/new` GET → `/my/configurator/new` |
| Future Users / Search slots | Omit from V1 (no "coming soon" placeholders); add when sub-B/sub-C ship |
Spec: [`docs/superpowers/specs/2026-05-17-portal-ia-sidebar-design.md`](../specs/2026-05-17-portal-ia-sidebar-design.md)
## What's deferred (do NOT re-litigate in next session)
These were explicitly scoped OUT during brainstorming. Open new brainstorm sessions for each when their turn comes:
- **Sub-B Multi-user account management** — invite teammates, role per user, per-action ACLs. Will add a Users item under the Account section of the sidebar.
- **Sub-C Portal search** — global search across jobs / quotes / invoices / certs. Search input slot above Dashboard in the sidebar.
- **Saved drafts (RFQ)** — user mentioned wanting drafts during configurator. Three scoping options proposed (minimal/medium/big); awaiting user direction. Not part of sub-A.
- **Real Statements generation** — account.followup integration OR cron-precomputed monthly PDFs. Decide during sub-A Phase 3 implementation or defer to its own follow-up.
- **Top Recurring Parts / Favorites / SerialNumber Lookup** — competitor-style features; deferred until customer demand confirmed.
- **RMA customer portal** — sub-12 RMA backend exists; portal exposure is its own sub-project.
## Gotchas that bit us this session
Future Claude will hit these too unless documented. Most are already inline in CLAUDE.md or MEMORY.md. Worth a re-skim before touching the portal:
1. **`fp.coating.config` is retired** (Sub-11 cleanup). Use `fusion.plating.process.type` as the customer-facing coating taxonomy. Multiple `*.py` files still reference the dead model in COMMENTS — don't pattern-match from those.
2. **Portal users can't read `fp.job` directly.** Controllers that return `fp.portal.job` records to a template MUST `sudo()` the search if the template traverses `job.x_fc_job_id`. Same pattern is already used for `sale.order`, `account.move`, `stock.picking`. Domain still filters to commercial partner tree.
3. **`sale_pdf_quote_builder` gates on `report_name == 'sale.report_saleorder'`** (already in MEMORY.md). For customer-facing SO PDFs on the portal, render the FP custom `fusion_plating_reports.report_fp_sale_portrait` instead, and use a dedicated portal route that sudo-renders so the QWeb template can walk into `fp.part.catalog` etc.
4. **Forms inside anchors is invalid HTML.** When making a whole card clickable AND embedding a Repeat-Order form inside, use a wrap div + inner anchor (main click target) + sibling actions footer (form lives here). Don't nest `<form>` inside `<a>`.
5. **Groups list indexing drift.** `_fp_group_documents` builds the docs panel by appending to `groups[N]`. If you reorder the initial list or insert a new group mid-helper, every `groups[N]` reference shifts. The code has an inline warning comment now; respect it.
6. **Per-stage timestamps are NULL on records created before the write hook deployed.** `_fp_get_stage_timeline` has a Date-fallback chain (received_date → received_at; actual_ship_date → shipped_at) plus linear interpolation for middle stages. Records created post-hook get real datetimes from the `fp.job.write()` mirror.
7. **Stepper SCSS — `.o_fp_step_line` MUST stay nested inside `.o_fp_stepper`** (inline comment in the SCSS warns about this). When `flex:1` isn't applied because the rule slipped outside the parent, circles cluster on the left of the row.
8. **Stepper labels align via absolute positioning per-unit** (not as a separate flex container). Wider labels like "Inspected" overflow equally to both sides of their circle's centre. Don't revert to the dual-container approach.
9. **`fp.portal.job` state-sync map** uses `_FP_JOB_STATE_TO_PORTAL_STATE` in `fusion_plating_jobs/models/fp_job.py`. `on_hold` and `cancelled` deliberately NOT mirrored to the customer-facing state. Manager decision what to surface.
## How to deploy (entech LXC 111 on pve-worker5)
Same recipe used 20+ times this session. Per file:
```bash
cat K:/Github/Odoo-Modules/fusion_plating/<module>/<path> | \
ssh pve-worker5 "pct exec 111 -- bash -c 'cat > /mnt/extra-addons/custom/<module>/<path>'"
```
Then upgrade module + 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_portal --test-tags=fp_portal --stop-after-init 2>&1 | tail -25\" && \
systemctl start odoo'"
```
Bust asset cache for SCSS/JS changes:
```bash
ssh pve-worker5 "pct exec 111 -- su - postgres -c \
\"psql -d admin -c \\\"DELETE FROM ir_attachment WHERE url LIKE '/web/assets/%';\\\"\""
```
Service status / version check:
```bash
ssh pve-worker5 "pct exec 111 -- bash -c 'systemctl is-active odoo'"
ssh pve-worker5 "pct exec 111 -- su - postgres -c \
\"psql -d admin -t -c \\\"SELECT latest_version FROM ir_module_module \
WHERE name='fusion_plating_portal';\\\"\""
```
## How to start the next session
1. Open Claude Code in `K:\Github\Odoo-Modules\fusion-plating` (or `K:\Github\Odoo-Modules\fusion_plating` — both work, the dash dir has only 3 modules but it's the active working dir for the user's terminal).
2. First message: "Resume the portal sub-A IA work — execute the approved plan from this session."
3. New session should:
- Read `CLAUDE.md` (auto-loaded) — the "Recent Session Handoff" section at the top points back to this file
- Read this handoff doc
- Read the plan: `docs/superpowers/plans/2026-05-17-portal-ia-sidebar-plan.md`
- Invoke `superpowers:subagent-driven-development` (or `executing-plans` for inline mode)
- Execute the 11 tasks across 4 phases
4. Optional but useful: re-run the existing test suite first to confirm starting from green: `--test-tags=fp_portal --stop-after-init`.
## Brainstorm artifacts
Visual companion mockups for this session live in `.superpowers/brainstorm/*/content/` (gitignored). Useful for visual comparison if needed:
- `design-direction.html` — Modern SaaS / Corporate / Industrial picker
- `saas-refinements.html` — V1/V2/V3 card variants
- `dashboard-layout.html` — 6-card grid vs jobs-forward
- `job-detail.html`, `branded-job-detail.html` — detail page mockups
- `branded-dashboard.html` — final brand-applied dashboard
- `sidebar-structure.html` — flat vs grouped vs hybrid (chose grouped)
Brainstorm server idles out after 30 min. Restart command:
```bash
"C:/Users/gur_p/.claude/plugins/cache/claude-plugins-official/superpowers/5.0.7/skills/brainstorming/scripts/start-server.sh" \
--project-dir "K:/Github/Odoo-Modules/fusion_plating"
```
(Run in background; URL appears in `.superpowers/brainstorm/*/state/server-info`.)
## Critical files modified this session
If the next session needs to read context fast:
- `fusion_plating_portal/controllers/portal.py` — most changes here
- `fusion_plating_portal/controllers/portal_configurator.py` — coating model swap + dual upload
- `fusion_plating_portal/views/fp_portal_dashboard.xml` — jobs-forward layout
- `fusion_plating_portal/views/fp_portal_templates.xml` — jobs list + detail rewrites
- `fusion_plating_portal/views/fp_portal_macros.xml``fp_portal_job_card`, `fp_portal_stepper`, `fp_portal_status_badge`, `fp_portal_doc_chip`, `fp_portal_doc_group`
- `fusion_plating_portal/static/src/scss/_fp_portal_tokens.scss` — brand tokens
- `fusion_plating_portal/static/src/scss/fp_portal_*.scss` — 7 partials (buttons, badges, cards, stepper, timeline, dashboard, legacy catch-all)
- `fusion_plating_portal/models/fp_portal_job.py` — per-stage Datetime fields + write/create snapshot hooks
- `fusion_plating_jobs/models/fp_job.py` — fp.job → fp.portal.job state-sync hook
- `fusion_plating_portal/tests/test_portal_dashboard.py` — 5 tests, all green
## What user feedback is still outstanding
Nothing concrete waiting on user. Last thing the user did was approve the plan and say "create a handsoff script so i start a new session" — i.e., they want to pause here. Next session resumes execution.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
# Customer Portal — Information Architecture + Sidebar Nav
**Module**: `fusion_plating_portal` (touches `portal.portal_layout` inherit)
**Date**: 2026-05-17
**Status**: Design locked, awaiting implementation plan
**Surface**: every `/my/*` page on `https://enplating.com`
**Sub-project**: A (of A/B/C); B = multi-user, C = portal search — deferred to separate brainstorms.
## Problem
The post-2026-05-17 portal redesign gave us a credible dashboard + jobs-detail page, but the navigation between pages is still "scroll the standard Odoo portal cards and hope you find the right entry point." Eight distinct customer surfaces (`/my/home`, `/my/jobs`, `/my/quote_requests`, `/my/configurator`, `/my/purchase_orders`, `/my/fp_invoices`, `/my/deliveries`, `/my/certifications`) and there's no persistent way to move between them. The customer's competitor screenshot (Mobility Specialties Inc / Drive Medical) shows the right pattern: a sticky left sidebar that lists every section, current page highlighted, secondary "Company Account" group at the bottom.
This spec restructures the portal around that sidebar pattern, audits the existing pages (replace thin custom pages with Odoo defaults where the default is better), and adds one missing page — a consolidated **Account Summary** with tabbed Invoices / Credit Memos / Statements + an Open Balance pill — that the existing thin `/my/fp_invoices` page doesn't deliver.
## User stories
1. **As a returning customer**, I want a persistent sidebar showing every section so I can jump between Quote Requests and Work Orders without going through the dashboard.
2. **As an accounting clerk**, when I open the portal I want a single Account Summary page with Open Balance + filterable invoices + credit memos + downloadable monthly statements — without hunting through three separate menu items.
3. **As any customer**, I want the active page visually marked so I always know where I am.
4. **As a mobile user**, the sidebar should collapse to a hamburger so the page content gets the screen.
## Locked design decisions (from brainstorming 2026-05-17)
| Decision | Choice | Why |
|---|---|---|
| Decomposition | A first (IA), B (multi-user) + C (search) deferred to separate brainstorms | Sidebar + pages are the foundation; building search before pages exist or a Users tab before the sidebar shape is locked would be rework. |
| Sidebar shape | Option B — Dashboard at top, then 3 grouped sections (Activity / Documents / Account) | 10 items needs grouping to scan; matches how the redesigned dashboard already groups (KPI tiles → jobs hero → secondary panels). |
| Account Summary tabs | 3 tabs: Invoices · Credit Memos · Statements, plus an "Open Balance: $X" pill in the page header | Mirrors competitor; one summary number front-of-mind, three drilldowns. |
| Future placeholders | NEITHER "Users (soon)" nor a search input shown in the sidebar today | Empty placeholders add visual noise; ship them when sub-B / sub-C land. |
| Sidebar persistence | Sticky on scroll; visible on every `/my/*` page (including Odoo defaults via `portal.portal_layout` inherit); sub-pages keep their parent highlighted | Industry standard. Consistency means the customer never loses their place. |
| Mobile collapse | Below 768px the sidebar collapses to a hamburger button in the page header; opens as a slide-in drawer | Standard portal pattern, no content rearrangement needed. |
| Single quote-creation path | `/my/quote_requests/new` redirects to `/my/configurator/new` | Two paths to the same outcome confuses customers; the configurator is the more complete flow. |
| Sign Out placement | Bottom of sidebar, separated by a hairline border | Matches competitor; gets sign-out off the page chrome. |
## Scope
**IN SCOPE — pages restructured / new:**
- `/my/home` — keep dashboard, gets sidebar
- `/my/jobs` — keep list, gets sidebar
- `/my/jobs/<id>` — keep detail, gets sidebar (highlight parent)
- `/my/quote_requests` — keep list, gets sidebar
- `/my/quote_requests/<id>` — keep detail, gets sidebar
- `/my/quote_requests/new`**REDIRECT** to `/my/configurator/new`
- `/my/configurator` — keep landing, gets sidebar
- `/my/configurator/new`, `.../coating`, `.../estimate` — keep wizard, gets sidebar
- `/my/purchase_orders`**REDIRECT** to Odoo default `/my/orders`; controller + template deleted
- `/my/fp_invoices`**REDIRECT** to new `/my/account_summary`; controller + template deleted
- `/my/account_summary`**NEW** tabbed page (this spec)
- `/my/deliveries` — keep, gets sidebar
- `/my/certifications` — keep, gets sidebar
- `/my/account` — Odoo default, gets sidebar
- `/my/orders/<id>` — Odoo default, gets sidebar
**IN SCOPE — chrome:**
- New `fp_portal_shell` template that inherits `portal.portal_layout` and wraps every `o_portal` page body with a sticky 240px sidebar on the left.
- Sidebar SCSS partial (`fp_portal_sidebar.scss`) — brand-teal active state, mint gradient highlight, hairline section dividers.
- Mobile breakpoint: hamburger toggle + slide-in drawer below 768px.
- All Odoo default portal pages (`/my/account`, `/my/orders`, `/my/orders/<id>`, `/my/invoices/<id>`, etc.) get the sidebar via the `portal.portal_layout` inherit — zero per-page edits.
**OUT OF SCOPE — deferred to other sub-projects:**
- Multi-user account management (sub-project B): Users tab in sidebar, invitation flow, per-action ACLs.
- Portal search (sub-project C): global search input above Dashboard, search-result page.
- Saved drafts (separate brainstorm — needs its own scoping).
- Top Recurring Parts / Favorites / SerialNumber Lookup (defer until customer demand confirmed).
- RMA customer portal (sub-project after RMA backend ships).
**OUT OF SCOPE — explicit non-goals:**
- Top-bar navigation, breadcrumbs redesign, footer changes — none of these are part of A.
- Restyling Odoo default `/my/account` or `/my/orders/<id>` page BODIES. We give them the sidebar via the layout inherit, but their content stays Odoo-standard.
## Architecture
### Sidebar shell
```
fusion_plating_portal/views/fp_portal_shell.xml
└── inherits portal.portal_layout
└── injects .o_fp_portal_shell wrapper that contains:
├── <aside class="o_fp_portal_sidebar"> (sticky, 240px)
│ └── partner header + 4 sections + sign-out
└── <main class="o_fp_portal_main"> (existing portal body)
```
Per Odoo's `portal.portal_layout` extension pattern, we inherit and use `<xpath expr="//div[@id='wrap']" position="replace">` (or `position="inside"` on the right anchor — TBD during implementation) to wrap the existing layout. The sidebar is a single shared template (`fp_portal_sidebar`) rendered above the existing portal page body.
Active-state marker: each sidebar `<a>` reads the current `page_name` from the template context (already set by every FP route — `fp_dashboard`, `fp_jobs`, etc.) and applies `o_fp_sidebar_active` when matched. Falls back to URL prefix match for Odoo default pages (`/my/orders` → Purchase Orders highlighted, `/my/account` → Profile highlighted).
### Sidebar items (final list)
```
ACME AEROSPACE <-- partner.commercial_partner_id.name
─────────────────────────────────────────
🏠 Dashboard /my/home
ACTIVITY
📄 Quote Requests /my/quote_requests
+ Get a Quote /my/configurator
🛒 Purchase Orders /my/orders (Odoo)
⚙️ Work Orders /my/jobs
DOCUMENTS
📑 Certifications /my/certifications
📦 Packing Slips /my/deliveries
💰 Account Summary /my/account_summary (NEW)
ACCOUNT
👤 Profile /my/account (Odoo)
─────────────────────────────────────────
↪ Sign Out /web/session/logout
```
Section headers (`ACTIVITY` / `DOCUMENTS` / `ACCOUNT`) are display-only, not links. The whole list is rendered from a single Python data structure in the template context (passed by a small helper on `FpCustomerPortal`), so adding the future Users / Drafts / Search items is a one-line addition.
### Account Summary page
**URL**: `/my/account_summary`
**Controller method**: `portal_account_summary(self, **kw)` on `FpCustomerPortal`
**Template**: `portal_my_account_summary` in `fp_portal_account_summary.xml` (new file)
**Page structure:**
```
[ Account Summary ] Open Balance: $4,820.00
─────────────────────────────────────────────────────────────────────────────
[ Invoices ] [ Credit Memos ] [ Statements ]
─────────────────────────────────────────────────────────────────────────────
Showing: Open · Closed · All [Search PO or #__________ ] [Sort ▾]
─────────────────────────────────────────────────────────────────────────────
# | Status | Posted On | PO # | Due Date | Balance | View PDF
─────────────────────────────────────────────────────────────────────────────
0035180274 | ● Open | May 13, 2026 | 53469 | Jun 12, 2026 | C$305.73 | View PDF
...
◀ Prev 1 2 3 4 5 Next ▶
```
**Data sources (per tab):**
| Tab | Model + domain | Notes |
|---|---|---|
| Invoices | `account.move` where `move_type='out_invoice'`, `partner_id child_of commercial`, `state='posted'` | Today's `/my/fp_invoices` already does this; relocated here. |
| Credit Memos | `account.move` where `move_type='out_refund'`, `partner_id child_of commercial`, `state='posted'` | Surfaces RMA credits when sub-12 RMA flow runs. Tab shows empty state with "No credits yet" when partner has none. |
| Statements | Generated PDF per month via `account.followup` or a custom QWeb cron — **decided during implementation; preferred = use account.followup report directly per-customer with date filter** | Tab UI: month picker + Download button. |
**Open Balance pill** = sum of `amount_residual` across all open `out_invoice` records (regardless of tab). Computed in the controller, shown in the page header.
**Search box** = case-insensitive substring match on `name` (invoice number) OR `ref` (customer PO). Server-side filter, not JS.
**Sort options:** Newest → Oldest (default), Oldest → Newest, Largest balance, Smallest balance.
**Filter pills:** `Open` (residual > 0) / `Closed` (residual = 0) / `All`.
**Pagination:** 10 per page, server-side via `portal_pager`.
Invoice detail = existing Odoo `/my/invoices/<id>` page (no rewrite); the table's "View PDF" link goes to `/my/invoices/<id>?report_type=pdf&download=true` per Odoo's standard portal pattern.
### Mobile behavior
```scss
@media (max-width: 768px) {
.o_fp_portal_sidebar {
transform: translateX(-100%);
transition: transform 0.2s ease;
position: fixed; top: 0; left: 0; bottom: 0;
z-index: 1040;
}
.o_fp_portal_sidebar.o_fp_open {
transform: translateX(0);
}
.o_fp_portal_hamburger { display: inline-flex; }
}
@media (min-width: 769px) {
.o_fp_portal_hamburger { display: none; }
}
```
Hamburger button lives in the page header (above the main content). Click toggles `o_fp_open` on the sidebar via 5-line vanilla JS (no framework). Backdrop click closes the drawer.
## Files
**NEW:**
- `fusion_plating_portal/views/fp_portal_shell.xml``portal.portal_layout` inherit + sidebar markup
- `fusion_plating_portal/views/fp_portal_account_summary.xml``portal_my_account_summary` template
- `fusion_plating_portal/static/src/scss/fp_portal_sidebar.scss` — sidebar styling (sticky, active state, sections, mobile drawer)
- `fusion_plating_portal/static/src/js/fp_portal_sidebar.js` — hamburger toggle (vanilla JS, no OWL)
**MODIFY:**
- `fusion_plating_portal/controllers/portal.py`
- NEW route `portal_account_summary` at `/my/account_summary`
- DELETE route `portal_my_fp_invoices` (the thin invoice list at `/my/fp_invoices`)
- REPLACE route `portal_my_purchase_orders` body with `return request.redirect('/my/orders')`
- REPLACE the GET handler for `portal_my_quote_request_new` with `return request.redirect('/my/configurator/new')` (or delete entirely if the configurator already exposes the equivalent form)
- NEW helper `_fp_sidebar_items(self)` returning the sidebar data structure (consumed by `fp_portal_sidebar` template via inherited `_prepare_portal_layout_values`)
- Extend `_prepare_portal_layout_values()` to inject `fp_sidebar_items` + `fp_partner_display_name` into every portal page's context so the sidebar renders correctly on Odoo default pages too.
- `fusion_plating_portal/views/fp_portal_templates.xml` — delete `portal_my_fp_invoices` template body (route is gone). Remaining templates (jobs list, jobs detail, deliveries, certifications) get the sidebar **for free** via the `portal.portal_layout` inherit; no per-template edits.
- `fusion_plating_portal/views/fp_portal_dashboard.xml` — dashboard template gets the sidebar via the layout inherit; no edits needed.
- `fusion_plating_portal/__manifest__.py` — version bump + register the new XML/SCSS/JS files. Add `fp_portal_shell.xml` near the TOP of the `data` list (loaded before any template that uses sidebar variables).
**DELETE (or stub):**
- The `portal_my_fp_invoices` template body and the `portal_my_purchase_orders` template body. Routes redirected, templates unused. Keep route stubs so existing bookmarks 302 cleanly instead of 404.
## Migration / backward compatibility
| Old URL | New behavior |
|---|---|
| `/my/fp_invoices` | 302 → `/my/account_summary` |
| `/my/purchase_orders` | 302 → `/my/orders` |
| `/my/quote_requests/new` | 302 → `/my/configurator/new` |
No DB migration. No template namespace changes that break inherits. The page audit removes routes from the controller and templates from the data list; Odoo's module-upgrade cycle handles the ORM-side cleanup.
## Open items to verify during implementation
1. **`portal.portal_layout` extension pattern** — confirm the cleanest xpath for injecting the sidebar wrapper without breaking Odoo's existing portal CSS (`#wrap`, `.o_portal`). Likely `position="before"` on the main content slot. If unclear, fall back to inheriting at the `website.layout` level and writing a wholly new shell template.
2. **Statements tab data source** — decide between (a) inline render of `account.followup` report per requested month, vs (b) precomputed monthly statement PDFs stored as attachments. Latter is simpler for V1; cron generates last-month statement on the 1st.
3. **Mobile hamburger placement** — header anchor: a small button at the top-left of the main content area (above the page title) on mobile only. Confirm during Phase 4 visual pass.
4. **Page-name → active-item mapping** — most FP routes set a clean `page_name` (e.g., `fp_jobs`, `fp_dashboard`). Odoo defaults don't; we'll match by URL prefix (`/my/orders``purchase_orders` item). One-helper `_fp_resolve_active_sidebar_item(url, page_name)` keeps the mapping in one place.
5. **Account Summary Statements scope** — confirm whether monthly statements are something EN Plating currently generates, or if this is a new artifact we need to define a template for. If the latter, that's a separate small spec.
## What ships in a "done" state
- Every `/my/*` page (FP + Odoo default) shows the new sidebar.
- Active page is visually marked.
- Sidebar collapses to hamburger drawer below 768px.
- `/my/account_summary` exists with 3 tabs, Open Balance pill, search + filter pills + sort + pagination.
- 3 legacy URLs (`/my/fp_invoices`, `/my/purchase_orders`, `/my/quote_requests/new`) 302-redirect to their new homes.
- Unit tests cover the new account_summary controller (3 tabs return the right counts, filter/search produce the right subset, Open Balance sums residuals correctly).
- Module version bumped, deployed to entech, all 5 existing portal tests still green plus 3+ new tests for Account Summary.
---
*Sub-projects B (multi-user) and C (portal search) are tracked separately — they'll consume the sidebar slot conventions (insertion under ACCOUNT for Users, above DASHBOARD for the search input) defined here.*

View File

@@ -0,0 +1,196 @@
# Certificate Creation Timing + Data Completeness Gates
**Date:** 2026-05-18
**Status:** Approved for implementation
**Author:** Brainstorming session (gsinghpal)
**Triggering incident:** WO-30040 marked done with no CoC produced — chatter showed `Cert auto-create (coc) failed: name 'coating' is not defined` (regression in `fusion_plating_jobs/models/fp_job.py:1706` where `coating` was referenced but never bound).
## Goal
Two things, decided as one unit of work:
1. **Fix the broken cert-creation path** so jobs marked done always produce the expected draft certs.
2. **Harden the data-completeness gates** so a CoC cannot be issued with missing critical information.
## Out of scope
- Redesigning the cert lifecycle timing (kept at `button_mark_done()`).
- Wizard-based "Issue CoC" flow (Approach C, rejected).
- SO-confirm cert-stub flow (Approach B, rejected).
- Email delivery refactor — issuance still triggers existing `fp.notification.template` dispatch.
## Decisions reached
| # | Decision | Rationale |
|---|---|---|
| D1 | Cert creation stays at `fp.job.button_mark_done()` | All upstream data should be settled by then; existing architecture is sound — only the bug masks that. |
| D2 | Receiving must close before job-done | qty_received blank or unreconciled blocks `button_mark_done`. Guarantees the cert always points to a closed receiving. |
| D3 | Strict qty accounting | `qty_received ≡ qty_done + qty_scrapped + qty_visual_inspection_rejects`. NC qty on cert = `qty_scrapped + qty_visual_inspection_rejects`. |
| D4 | Per-company default signer | New `res.company.x_fc_default_coc_signer_id`. Customer-spec signer_user_id wins if set. |
| D5 | Per-partner default CoC contact | New `res.partner.x_fc_default_coc_contact_id`. Sales sets it once per customer. |
| D6 | Mandatory fields at `action_issue()` | spec_reference (existing), process_description, certified_by_id, contact_partner_id with valid email, qty reconciliation. |
| D7 | Backfill action for closed jobs missing certs | One-shot server action — walks `state='done'` jobs whose `_resolve_required_cert_types()` is non-empty and have no matching cert; calls `_fp_create_certificates()`. |
## Architecture
```
┌─ JOB EXECUTION ─────────────────────────────────────────────────┐
│ Steps run → Bake → QC → Receiving closed │
│ │ │
│ ▼ │
│ button_mark_done() [HARDENED GATE] │
│ existing checks PLUS: │
│ qty_received present AND │
│ qty_received ≡ qty_done + qty_scrapped + qty_rejects │
│ │ │
│ ▼ │
│ _fp_create_certificates() (bug fixed + richer prefill) │
│ Resolved sources: │
│ process_description ← job.recipe_id.name │
│ certified_by_id ← customer_spec.signer_user_id │
│ OR company.x_fc_default_coc_signer_id│
│ contact_partner_id ← partner.x_fc_default_coc_contact_id │
│ nc_quantity ← qty_scrapped + qty_visual_rejects │
│ │ │
│ ▼ │
│ Draft cert(s) — milestone advances to "Issue Certs" │
└─────────────────────────────────────────────────────────────────┘
┌─ ISSUANCE ──────────────────────────────────────────────────────┐
│ Manager opens cert → action_issue() [HARDENED GATE] │
│ existing checks PLUS: │
│ process_description present │
│ certified_by_id present │
│ contact_partner_id present, with email │
│ qty reconciliation (belt-and-suspenders vs Gate 1) │
│ │ │
│ ▼ │
│ state → issued, PDF generated, attached │
└─────────────────────────────────────────────────────────────────┘
```
## Schema changes (additive)
| Model | New field | Type | Notes |
|---|---|---|---|
| `res.company` | `x_fc_default_coc_signer_id` | M2O `res.users` | Default signing authority. Set once per facility. |
| `res.partner` | `x_fc_default_coc_contact_id` | M2O `res.partner` (children of self) | Sales sets per customer. |
Both are additive — no data migration needed.
## Module changes
| Module | Version bump | Files |
|---|---|---|
| `fusion_plating` | 19.0.20.1.0 → 19.0.20.2.0 | `models/res_company.py`, `views/res_company_views.xml` (or settings view) |
| `fusion_plating_certificates` | 19.0.6.1.0 → 19.0.6.2.0 | `models/res_partner.py`, `models/fp_certificate.py`, `views/res_partner_views.xml` |
| `fusion_plating_jobs` | 19.0.10.8.0 → 19.0.10.9.0 | `models/fp_job.py` (mark_done gate + cert prefill bug fix + backfill action) |
## Gate logic — `button_mark_done()`
Inside the existing `if not skip_qty_gate and job.qty:` block, add:
```python
if not job.qty_received:
raise UserError(_(
"Job %s cannot be marked Done — Quantity Received is blank. "
"Close the receiving record for SO %s before completing this job."
) % (job.name, job.sale_order_id.name if job.sale_order_id else '?'))
accounted_out = (job.qty_done or 0) + (job.qty_scrapped or 0) \
+ (job.qty_visual_inspection_rejects or 0)
if abs(job.qty_received - accounted_out) > 0.0001:
raise UserError(_(
"Job %s qty mismatch — received %g, but qty_done (%g) + "
"qty_scrapped (%g) + visual rejects (%g) = %g. "
"Reconcile before closing."
) % (job.name, job.qty_received, job.qty_done or 0,
job.qty_scrapped or 0, job.qty_visual_inspection_rejects or 0,
accounted_out))
```
Manager bypass: existing `fp_skip_qty_reconcile=True` context covers both.
## Cert prefill table (`_fp_create_certificates`)
| Cert field | Source |
|---|---|
| partner_id | `job.partner_id` (existing) |
| sale_order_id | `job.sale_order_id` (existing) |
| x_fc_job_id | `job.id` (existing) |
| certificate_type | `_resolve_required_cert_types()` (existing) |
| part_number | `job.part_catalog_id.part_number` (existing) |
| entech_wo_number | `job.name` (existing) |
| po_number | `job.sale_order_id.x_fc_po_number` (existing) |
| customer_job_no | `job.sale_order_id.x_fc_customer_job_number` (existing) |
| spec_reference | from `customer_spec.code [+ " Rev " + revision]` (existing) |
| customer_spec_id | `job.customer_spec_id` (existing) |
| quantity_shipped | `qty_done - qty_scrapped` (existing) |
| **nc_quantity** | **`qty_scrapped + qty_visual_inspection_rejects`** (NEW) |
| **process_description** | **`job.recipe_id.name`** (NEW; was broken — `coating` was undefined) |
| **certified_by_id** | **`customer_spec.signer_user_id` OR `company.x_fc_default_coc_signer_id`** (NEW) |
| **contact_partner_id** | **`partner.x_fc_default_coc_contact_id`** (NEW) |
## Gate logic — `action_issue()` (added in sequence before `state = 'issued'`)
1. **process_description present** — raise with hint to set coating-config / fill manually.
2. **certified_by_id present** — raise with hint to set company default.
3. **contact_partner_id present AND `email` non-empty** — raise with specific hint.
4. **qty reconciliation** — defensive; reads `x_fc_job_id` if linked.
Order: cheapest checks first; first failure wins.
## Edge cases
| Case | Behavior |
|---|---|
| Job has no recipe_id | `process_description = False` → action_issue blocks → manager fills manually. |
| Company has no default signer | `certified_by_id` blank → action_issue blocks. |
| Partner has no default contact | `contact_partner_id` blank → action_issue blocks. |
| Contact has no email | Action_issue blocks specifically on email. |
| Customer-spec overrides company signer | `customer_spec.signer_user_id` wins (already used by signature unification). |
| Multi-line SO with different recipes | First line with a recipe wins for process_description; manager can override. |
| Re-running `_fp_create_certificates` | Idempotent by (job_id, certificate_type); NEW fields only set on initial create. |
| Older jobs with NULL `qty_visual_inspection_rejects` | Coerce to 0; no migration needed. |
| Receiving never existed (internal rework) | Mark_done blocks; manager bypass via `fp_skip_qty_reconcile=True`. |
## Backwards compatibility
- WO-30040 itself (already `done`, no cert) is not auto-fixed by this change.
- New server action **"Generate missing certs for closed jobs"** walks `fp.job` records where `state='done'` AND `_resolve_required_cert_types()` is non-empty AND no matching cert exists. Surfaced in the Jobs menu so the user can run once after deploy.
## Test plan
**Unit tests** (in `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py` and new `fusion_plating_certificates/tests/test_action_issue_gates.py`):
- `test_mark_done_blocks_on_blank_qty_received`
- `test_mark_done_blocks_on_qty_received_mismatch`
- `test_mark_done_passes_with_clean_qty_reconcile`
- `test_mark_done_bypass_skips_qty_received_check`
- `test_create_cert_resolves_recipe_name` (replaces "coating" wording)
- `test_create_cert_handles_job_with_no_recipe`
- `test_create_cert_prefills_signer_from_company`
- `test_create_cert_prefills_signer_from_customer_spec`
- `test_create_cert_prefills_contact_from_partner`
- `test_create_cert_computes_nc_quantity`
- `test_create_cert_handles_null_visual_rejects`
- `test_action_issue_blocks_on_missing_process_description`
- `test_action_issue_blocks_on_missing_certified_by`
- `test_action_issue_blocks_on_missing_contact`
- `test_action_issue_blocks_on_contact_without_email`
- `test_action_issue_blocks_on_qty_mismatch`
- `test_action_issue_passes_when_all_data_present`
- `test_create_cert_idempotency`
**Manual verification on entech (post-deploy):**
1. Run "Generate missing certs for closed jobs" → confirm WO-30040 gets 2 draft certs.
2. Try `action_issue` → expect blockers for unset defaults.
3. Configure defaults; retry → cert issues, PDF renders, attaches.
## Deployment
- Push to `K:/Github/Odoo-Modules/fusion_plating/` (git path).
- Mirror to docker mount as needed.
- Update on entech LXC 111 via the deploy commands in `project_entech_session_handoff.md`.
- Module install order: `fusion_plating``fusion_plating_certificates``fusion_plating_jobs`.

View File

@@ -0,0 +1,162 @@
# Phase A — Shipping Carrier Foundation
**Date:** 2026-05-18
**Status:** Approved for implementation
**Author:** Brainstorming session (gsinghpal)
**Project:** Full shipping integration (Phases AF). This spec covers Phase A only — the field-level foundation linking plating records to `fusion_shipping`'s existing shipment infrastructure.
## Goal
Replace the free-text `carrier_name` on `fp.receiving` with a proper M2O to `delivery.carrier`, and link both `fp.receiving` and `fp.delivery` to the `fusion.shipment` model that already exists in `fusion_shipping`. After Phase A, the receiver can pick a carrier from a 15-option dropdown and create a draft outbound shipment record — wiring is in place for Phase B (manual label entry) and Phase E (auto-label generation at receiving time).
## Out of scope
- Weight, dimensions, label PDF, tracking number on `fp.receiving` / `fp.delivery` themselves — these live on the linked `fusion.shipment` record (already implemented by `fusion_shipping`).
- Bridge module (Phase C), Purolator integration (Phase D), at-receiving auto-label (Phase E), printer hookup (Phase F).
- Modifying `fusion_shipping`'s existing models — Phase A is additive on the plating side only.
## Decisions reached
| # | Decision | Rationale |
|---|---|---|
| D1 | Carrier field: M2O to `delivery.carrier` (not Selection) | Matches `fusion_shipping`'s framework; allows API integration on the same record without conversion. |
| D2 | Architecture: mirror fields on both `fp.receiving` and `fp.delivery`, auto-sync at delivery creation | Self-contained records; loose coupling; shipping crew can override per-stage. |
| D3 | Source of truth for weight / dims / label / tracking: `fusion.shipment`, NOT mirrored on plating records | Shipment model already has every field; avoid duplicating + the sync logic. |
| D4 | 15 carriers seeded as `delivery.carrier` data records (XML), all `delivery_type='fixed'` initially | Phase D will flip Purolator (and any others added) to their integration types. Manual carriers (Customer Pickup etc.) stay `fixed` permanently. |
| D5 | Existing `carrier_name` (Char) and `carrier_tracking` (Char) kept as legacy | Migration populates `x_fc_carrier_id` by name match; unmatched text stays for operator review. |
| D6 | `fusion_plating_receiving` + `fusion_plating_logistics` gain hard `fusion_shipping` dependency | The M2O to `fusion.shipment` requires the model to exist; no conditional compilation. |
## Architecture
```
┌─ fp.receiving ─────────────────────────────────────────────────────┐
│ NEW: x_fc_carrier_id M2O delivery.carrier │
│ NEW: x_fc_outbound_shipment_id M2O fusion.shipment │
│ NEW: x_fc_outbound_shipment_count Integer (smart-button counter) │
│ Existing: carrier_name (Char) — legacy, populated by migration │
│ Existing: carrier_tracking (Char) — legacy │
│ │
│ ACTION: action_create_outbound_shipment() │
│ → creates fusion.shipment with sale_order_id + carrier_id │
│ → idempotent: returns existing if already linked │
│ ACTION: action_view_outbound_shipment() │
│ → opens linked fusion.shipment in form view │
│ ONCHANGE: x_fc_carrier_id propagates to linked shipment │
│ → only if shipment.status == 'draft' │
└────────────────────────────────────────────────────────────────────┘
copy at delivery creation (fp.job._fp_create_delivery)
┌─ fp.delivery ──────────────────────────────────────────────────────┐
│ NEW: x_fc_carrier_id M2O delivery.carrier │
│ NEW: x_fc_outbound_shipment_id M2O fusion.shipment │
│ NEW: x_fc_outbound_shipment_count Integer │
│ Same ACTIONs and propagation as fp.receiving │
└────────────────────────────────────────────────────────────────────┘
┌─ delivery.carrier (seed data) ─────────────────────────────────────┐
│ Already on entech: Standard delivery, Canada Post, Customer Pickup│
│ Phase A adds (delivery_type='fixed', product_id=delivery. │
│ product_product_delivery): │
│ UPS, FedEx, USPS, DHL, Purolator, CCT, Canpar Express, │
│ GLS Canada, Loomis Express, Day & Ross, Dicom Transportation, │
│ Customer Drop-off, Local Delivery │
│ Idempotent: XML uses noupdate=1 + record ids check existing names │
└────────────────────────────────────────────────────────────────────┘
```
## Field details
**On `fp.receiving`:**
```python
x_fc_carrier_id = fields.Many2one(
'delivery.carrier', string='Outbound Carrier', tracking=True,
ondelete='set null',
help='Who picks up the parts when work is done. Used to generate '
'the return shipping label on the linked Outbound Shipment.',
)
x_fc_outbound_shipment_id = fields.Many2one(
'fusion.shipment', string='Outbound Shipment', tracking=True,
ondelete='set null',
help='The shipment record carrying weight, dimensions, label PDF, '
'and tracking. Created via the "Create Outbound Shipment" '
'button.',
)
x_fc_outbound_shipment_count = fields.Integer(
compute='_compute_x_fc_outbound_shipment_count',
)
```
Identical pair on `fp.delivery`.
## Module changes
| Module | Bump | Files |
|---|---|---|
| `fusion_plating_receiving` | 19.0.3.9.0 → 19.0.3.10.0 | manifest (+depends), `models/fp_receiving.py`, `views/fp_receiving_views.xml`, `data/delivery_carrier_seed_data.xml` (NEW), `migrations/19.0.3.10.0/post-migrate.py` (NEW), `tests/test_carrier_fields.py` (NEW) |
| `fusion_plating_logistics` | bump | manifest (+depends), `models/fp_delivery.py`, `views/fp_delivery_views.xml`, `tests/test_delivery_shipping_fields.py` (NEW) |
| `fusion_plating_jobs` | bump | `models/fp_job.py` (mirror at `_fp_create_delivery`), extend existing milestone-cascade test class |
## Migration logic (post-migrate)
```python
def migrate(cr, version):
# Name-match existing carrier_name text → delivery.carrier.name
cr.execute("""
UPDATE fp_receiving r
SET x_fc_carrier_id = dc.id
FROM delivery_carrier dc
WHERE r.carrier_name IS NOT NULL
AND r.carrier_name <> ''
AND r.x_fc_carrier_id IS NULL
AND LOWER(TRIM(r.carrier_name)) =
LOWER(TRIM((dc.name->>'en_US')))
""")
```
`delivery.carrier.name` is jsonb in Odoo 19 (translatable). The migration strips to `en_US` for the match.
## Edge cases
| Case | Behavior |
|---|---|
| Receiving has no SO link | Shipment creation works without `sale_order_id` (set_null on shipment side). |
| Carrier picked but no shipment yet | Smart button reads "Create Outbound Shipment" → one click creates + opens. |
| User changes carrier on receiving after shipment exists | Onchange propagates only when `shipment.status == 'draft'`. Confirmed/shipped shipments are left alone. |
| Two receivings on same SO (split deliveries) | Each has its own `x_fc_outbound_shipment_id`. Mirror picks first one; user can change. |
| Migration finds ambiguous name | Case-insensitive exact match only. Unmatched stays in `carrier_name` text. |
| Shipment is deleted | `ondelete='set null'` — receiving keeps carrier but smart button reverts to "Create". |
| `fusion_shipping` not installed | Manifest dependency fails fast on module load — correct failure mode. |
## Test plan
**Unit tests** in `fusion_plating_receiving/tests/test_carrier_fields.py`:
- `test_carrier_id_field_exists_on_receiving`
- `test_outbound_shipment_id_field_exists_on_receiving`
- `test_action_create_outbound_shipment_creates_draft`
- `test_action_create_outbound_shipment_idempotent`
- `test_carrier_id_change_propagates_to_draft_shipment`
- `test_carrier_id_change_does_not_propagate_to_confirmed_shipment`
**Unit tests** in `fusion_plating_logistics/tests/test_delivery_shipping_fields.py`:
- `test_carrier_id_field_exists_on_delivery`
- `test_outbound_shipment_id_field_exists_on_delivery`
**Unit tests** extending `fusion_plating_jobs/tests/test_fp_job_milestone_cascade.py`:
- `test_create_delivery_mirrors_carrier_from_receiving`
- `test_create_delivery_mirrors_outbound_shipment`
- `test_create_delivery_no_receiving_no_mirror`
**Manual verification post-deploy:**
1. Open RCV-30041 → carrier dropdown shows 15 options.
2. Pick FedEx → click "Create Outbound Shipment" → fusion.shipment opens in draft.
3. Confirm `x_fc_outbound_shipment_id` is populated on RCV-30041.
4. Confirm same fields/buttons on a fresh fp.delivery record auto-created via mark-done.
## Deployment
- 3 module upgrades: `fusion_plating_receiving`, `fusion_plating_logistics`, `fusion_plating_jobs`.
- `fusion_shipping` is already installed — no action needed.
- Migration runs automatically; spot-check by querying `fp_receiving.x_fc_carrier_id` post-deploy.

View File

@@ -0,0 +1,224 @@
# Phase C — Generate Label End-to-End
**Date:** 2026-05-18
**Status:** Approved for implementation
**Author:** Brainstorming session (gsinghpal)
**Project:** Shipping integration phase 3 of 5 (after Phase A foundation; Phase B was merged in as a fallback path).
## Goal
Complete the at-receiving outbound-label workflow: receiver enters weight + dimensions + picks carrier, clicks one button, system generates the carrier's shipping label PDF + tracking number (API when available, manual fallback when not). Operator prints the label, ships the box, customer gets the tracking link by email and on the portal.
## Workflow
```
[Receiver] enters weight + dims + picks carrier on RECEIVING FORM
Click "Generate Outbound Label"
Carrier has API integration?
├─ YES → carrier.send_shipping([picking]) → label PDF + tracking
│ saved to fusion.shipment
└─ NO/API FAILS → open manual entry wizard
operator pastes PDF + types tracking
saved to fusion.shipment
[Shipping] "Print Label" button → opens PDF in browser print dialog
[Notification] fp.notification.template fires (event: shipment_labeled)
with tracking_number + tracking_url placeholders
[Portal] Job page renders tracking_number as clickable link to
carrier.tracking_url template
```
## Decisions reached
| # | Decision | Rationale |
|---|---|---|
| D1 | Weight + dimensions live on fp.receiving as `related=` fields → fusion.shipment | Receiver enters them on the receiving form (their workflow); shipment stays as source of truth. |
| D2 | One button: "Generate Outbound Label". API path is primary; manual is fallback | One UX, two branches inside. No separate "Manual Label Entry" flow surfaced to operator. |
| D3 | Manual fallback opens automatically on API failure OR when carrier has no API integration | Operator never has to think about which path to take. |
| D4 | Adapter approach: synthesize a stock.picking just for the API call (locked Phase C question) | Max reuse of existing fusion_shipping methods; picking is hidden from operator UIs. |
| D5 | Notification trigger fires whenever tracking_number gets set (API OR manual), not at label generation | Same downstream behavior regardless of how the label was obtained. |
| D6 | Portal renders tracking as `<a href="...">` using delivery.carrier.tracking_url template | Standard Odoo carrier tracking URL pattern. |
## Out of scope
- Purolator integration (Phase D — independent).
- Auto-print to a network printer (Phase F).
- Multi-package shipments (single package per shipment in Phase C).
- Rate quote / carrier shopping (just label generation).
- Job sticker auto-print at same moment (Phase F).
- Return labels (different API call; can come later).
## Files changing
| File | Change |
|---|---|
| `fusion_plating_receiving/models/fp_receiving.py` | NEW related fields: `x_fc_weight`, `x_fc_weight_uom`, `x_fc_length`, `x_fc_width`, `x_fc_height`, `x_fc_dim_uom` (related to fusion.shipment / fusion.order.package). NEW `x_fc_shipping_picking_id` (M2O stock.picking, back-link). NEW `action_generate_outbound_label()`. NEW `action_print_label()`. NEW helper `_fp_build_shipping_picking()`. |
| `fusion_plating_receiving/wizards/__init__.py` (NEW) | Wizard module init. |
| `fusion_plating_receiving/wizards/fp_label_manual_wizard.py` (NEW) | Transient model: `receiving_id`, `label_pdf` (Binary), `label_filename` (Char), `tracking_number` (Char), `note` (Char — context why manual fallback). `action_confirm()` writes to fusion.shipment + closes wizard. |
| `fusion_plating_receiving/wizards/fp_label_manual_wizard_views.xml` (NEW) | Wizard form view. |
| `fusion_plating_receiving/views/fp_receiving_views.xml` | Add weight + dimensions group (Reception group). Add header buttons "Generate Outbound Label" + "Print Label". |
| `fusion_plating_receiving/__manifest__.py` | Bump 19.0.3.10.0 → 19.0.3.11.0. Register new wizard files. Add `stock`, `delivery` to depends. |
| `fusion_plating_receiving/security/ir.model.access.csv` | ACLs for the new wizard models. |
| `fusion_plating_notifications/data/notification_templates.xml` (EXISTING — extend) | Add `shipment_labeled` trigger entry with default template. |
| `fusion_plating_portal/views/fp_portal_templates.xml` (EXISTING — extend) | Render tracking_number as `<a>` link on job page. |
| Tests | Three new files + extensions. |
## Implementation details
### Related fields on fp.receiving
```python
x_fc_weight = fields.Float(
related='x_fc_outbound_shipment_id.weight',
readonly=False, store=False,
)
# Similar for length/width/height — these come from fusion.order.package, not fusion.shipment directly.
# Decision: write to the shipment's first package (auto-create if absent).
```
Wait — `fusion.shipment.weight` exists, but length/width/height live on `fusion.order.package`. The shipment has a one2many relationship via `sale_order_id.package_ids`. For Phase C, the simplest path: store dimensions on the shipment by adding them as fields, OR auto-create a package per shipment.
**Resolved:** Phase C reads/writes weight + dimensions on the shipment record directly. If `fusion.shipment` doesn't have dimension fields, we add them via inheritance from this side (this is in fusion_shipping's model — would require touching it). Alternative: store on a synthetic fusion.order.package.
**Decision for spec:** add length/width/height + dim_uom as new fields directly on `fusion.shipment` via inheritance from `fusion_plating_receiving` (or move to fusion_shipping if appropriate during implementation). Cleaner than the package indirection for a single-package flow.
### action_generate_outbound_label
```python
def action_generate_outbound_label(self):
self.ensure_one()
self._fp_validate_label_inputs() # carrier, weight, recipient addr, shipment exists
carrier = self.x_fc_carrier_id
if carrier.delivery_type == 'fixed':
return self._fp_open_manual_label_wizard(
note=_('Carrier "%s" has no API integration. Enter the '
'label PDF and tracking number manually.') % carrier.name,
)
try:
picking = self._fp_build_shipping_picking()
shipping_data = carrier.send_shipping([picking]) # standard Odoo call
self._fp_apply_shipping_result(picking, shipping_data)
except Exception as e:
_logger.warning("Label gen failed for %s: %s", self.name, e)
return self._fp_open_manual_label_wizard(
note=_('API call failed: %s\n\nEnter the label manually below.') % e,
)
return self._fp_open_outbound_shipment_action() # smart-button target
```
### Manual fallback wizard
Small transient model `fp.label.manual.wizard` with:
- `receiving_id` (M2O fp.receiving, required)
- `label_pdf` (Binary, required at confirm time)
- `label_filename` (Char)
- `tracking_number` (Char, required at confirm time)
- `note` (Char, readonly — explanatory message)
`action_confirm()`:
- Validate label + tracking present.
- Write to the receiving's linked fusion.shipment: `label_attachment_id` (create ir.attachment) + `tracking_number` + `status='confirmed'`.
- Close wizard, post chatter to receiving.
### Synthetic stock.picking
```python
def _fp_build_shipping_picking(self):
self.ensure_one()
Picking = self.env['stock.picking']
warehouse = self.env['stock.warehouse'].search([
('company_id', '=', self.env.company.id)
], limit=1)
picking_type = warehouse.out_type_id
so = self.sale_order_id
return Picking.create({
'partner_id': so.partner_shipping_id.id,
'picking_type_id': picking_type.id,
'origin': so.name,
'sale_id': so.id,
'carrier_id': self.x_fc_carrier_id.id,
# Synthetic single move from a generic shipping product:
'move_ids': [(0, 0, {
'name': 'Outbound Shipment %s' % self.name,
'product_id': self.env.ref('product.product_product_4').id, # default service-type
'product_uom_qty': 1,
'product_uom': self.env.ref('uom.product_uom_unit').id,
'location_id': picking_type.default_location_src_id.id,
'location_dest_id': picking_type.default_location_dest_id.id,
})],
'x_fc_fp_receiving_id': self.id, # back-link, defined on stock.picking
})
```
Then immediately after `send_shipping` succeeds:
- `picking.action_confirm()` + `picking.action_assign()` + `picking.button_validate()` to take the picking to 'done' state (so it doesn't sit as draft in operator views).
### Notification trigger
Add event `shipment_labeled` to fp.notification.template selection. Default email template:
```
Subject: Your order is ready to ship — Tracking #{{ tracking_number }}
Body: Hi {{ partner_name }},
Your order for SO {{ sale_order_name }} has shipped.
Tracking number: {{ tracking_number }}
Track here: {{ tracking_url }}
```
Fired by an `on_write` hook on `fusion.shipment` when `tracking_number` transitions from empty to non-empty.
### Portal display
In `fusion_plating_portal/views/fp_portal_templates.xml`, locate the job-card / job-detail rendering. Wherever tracking_ref is shown, replace with:
```xml
<t t-if="job.delivery_id and job.delivery_id.x_fc_outbound_shipment_id">
<a t-att-href="job.delivery_id.x_fc_outbound_shipment_id.tracking_url"
target="_blank">
<t t-esc="job.delivery_id.x_fc_outbound_shipment_id.tracking_number"/>
</a>
</t>
```
`tracking_url` is a computed field on `fusion.shipment` that resolves the `delivery.carrier.tracking_url` template (already exists in Odoo).
## Test plan
| Test | Verifies |
|---|---|
| `test_generate_label_blocks_when_no_carrier` | UserError raised |
| `test_generate_label_blocks_when_no_shipment` | UserError raised |
| `test_generate_label_blocks_when_no_weight` | UserError raised |
| `test_generate_label_routes_manual_for_fixed_carrier` | Wizard opens, no API call made |
| `test_generate_label_calls_api_for_integrated_carrier` | carrier.send_shipping called once (mocked) |
| `test_generate_label_writes_result_to_shipment_on_success` | tracking_number + label_attachment populated |
| `test_generate_label_falls_back_to_wizard_on_api_failure` | Mock raises → wizard opens with note |
| `test_manual_wizard_confirm_writes_shipment` | label + tracking saved; status confirmed |
| `test_print_label_returns_attachment_action` | Action dict points to the label PDF |
| `test_notification_fires_when_tracking_set` | fp.notification.template._dispatch called with shipment_labeled event |
| `test_portal_renders_tracking_link` | Render contains `<a href="...">` with tracking URL |
## Edge cases
| Case | Behavior |
|---|---|
| No warehouse configured | UserError: "No warehouse for the company — configure one in Settings > Warehouse." |
| sale_order.partner_shipping_id missing | Falls back to `sale_order.partner_id`. |
| Multi-package SO (rare) | Phase C single-package only. Multi-package raises with a "Phase E" note. |
| Carrier API timeout | Caught as `Exception` in the try block; manual wizard opens with error in note. |
| Operator generates label twice | Second call sees existing tracking, refuses and prompts to void/regenerate. |
| Customer changes weight after label generated | Block weight edit when shipment.status == 'confirmed'. Manager can void shipment to re-generate. |
## Deployment
3 modules upgraded: `fusion_plating_receiving` (main), `fusion_plating_notifications` (trigger), `fusion_plating_portal` (link).
Manual verification on entech:
1. Open RCV-30041. Set weight (e.g. 5), dimensions, carrier = FedEx.
2. Click Generate Outbound Label. Expected: UserError because the seeded FedEx carrier has `delivery_type='fixed'` — manual wizard opens.
3. Paste a sample PDF + tracking number in wizard. Confirm.
4. Verify fusion.shipment has the label and tracking saved.
5. Verify Print Label button works (opens PDF).
6. (If admin configures FedEx REST credentials and changes delivery_type) — re-test API path.

View File

@@ -0,0 +1,123 @@
# Receiving Gate on Step Start / Finish
**Date:** 2026-05-18
**Status:** Approved for implementation
**Author:** Brainstorming session (gsinghpal)
**Triggering observation:** WO-30040 closed with `qty_received` blank and chatter warnings on Post-plate Inspection / Final Inspection ("Step started before parts were received"). The existing soft chatter warning is not strong enough — operators ignore it and the job still completes.
## Goal
Block step transitions (start AND finish) on any non-Contract-Review step until the SO's receiving record is closed. Future-proof for custom steps added later. Allow manager bypass via the existing `fp_skip_*` context-flag pattern.
## Decisions reached
| # | Decision | Rationale |
|---|---|---|
| D1 | Scope: all step kinds EXCEPT Contract Review | CR is paperwork — doesn't need parts on the floor. Every other step (including future custom steps) involves physical work. |
| D2 | Timing: both `button_start` AND `button_finish` | Strongest. Operator can't begin OR complete physical work without receiving closed. Catches both "started too early" and "started before parts arrived, completed before they did". |
| D3 | Threshold: `sale_order.x_fc_receiving_status == 'received'` | Post-Sub-8 (and the 2026-05-18 cleanup), `received` is the terminal receiving state. `not_received` and `partial` block. |
| D4 | Manager bypass: `fp_skip_receiving_gate=True` context flag | Matches existing `fp_skip_*` pattern (qty_reconcile, qc_gate, step_gate, bake_gate). Auditor trail via chatter on the state transition. |
| D5 | Implementation: single helper called from both buttons | Mirrors existing `_fp_check_contract_review_complete` pattern. DRY — same code tested once. |
## Out of scope
- Receiving model's state machine (already correct post-Sub-8).
- The `_update_so_receiving_status` mapping (already maps `closed → received`).
- Other gates (qty_reconcile, qc_gate, bake_gate) — untouched.
- Schema changes — pure behavior change.
## Architecture
```
fp.job.step.button_start fp.job.step.button_finish
1. Sequential-order gate (existing) 1. _fp_check_contract_review_complete (existing)
2. _fp_check_receiving_gate() ← NEW 2. _fp_check_receiving_gate() ← NEW
3. Contract Review auto-open (existing) 3. super().button_finish() + downstream (existing)
4. Racking auto-open (existing)
5. Standard path + serial promote (existing)
[old soft chatter warning removed]
```
## Helper method
```python
def _fp_check_receiving_gate(self):
"""Block step transitions until parts are physically received.
Applied to every step EXCEPT Contract Review. Fires from both
button_start and button_finish. Manager bypass via context flag
`fp_skip_receiving_gate=True`.
"""
if self.env.context.get('fp_skip_receiving_gate'):
return
for step in self:
if step._fp_is_contract_review_step():
continue
so = step.job_id.sale_order_id
if not so:
continue # internal rework — gate doesn't apply
if 'x_fc_receiving_status' not in so._fields:
continue # defensive: configurator not installed
if so.x_fc_receiving_status != 'received':
label = dict(
so._fields['x_fc_receiving_status'].selection
).get(so.x_fc_receiving_status, so.x_fc_receiving_status or 'unknown')
raise UserError(_(
'Step "%(step)s" cannot proceed — parts not received yet '
'(SO %(so)s receiving status: %(status)s).\n\n'
'Close the receiving record (Sales > %(so)s > Receiving) '
'before starting or finishing work on this step. A '
'manager can bypass this gate for documented exceptions.'
) % {
'step': step.name,
'so': so.name or '?',
'status': label,
})
```
## Module changes
| Module | Bump | Files |
|---|---|---|
| `fusion_plating_jobs` | 19.0.10.12.0 → 19.0.10.13.0 | `models/fp_job_step.py` (helper + 2 callers + remove soft warning); `tests/test_fp_job_milestone_cascade.py` (new TestReceivingGate class) |
## Edge cases
| Case | Behavior |
|---|---|
| Step on job with no SO link (internal rework) | Gate doesn't fire — `continue`. |
| Configurator module not installed (`x_fc_receiving_status` field absent) | Gate doesn't fire — `continue`. |
| Contract Review step on `not_received` SO | Gate exempt; step proceeds (paperwork). |
| Step on `partial` SO | Blocks — `partial` is not `received`. Operator waits for all boxes to land. |
| Manager bypass via context | All gates skipped uniformly. Audit trail preserved via state-transition tracking. |
## Test plan
8 unit tests in new `TestReceivingGate` class in `test_fp_job_milestone_cascade.py`:
- `test_start_blocks_when_not_received`
- `test_start_allows_when_received`
- `test_start_skips_contract_review`
- `test_start_bypass_via_context`
- `test_finish_blocks_when_not_received`
- `test_finish_allows_when_received`
- `test_finish_skips_contract_review`
- `test_finish_bypass_via_context`
**Manual verification on entech post-deploy:**
1. Open SO-30041 (currently `not_received`) → fp.job → try `button_start` on first non-CR step → UserError raised.
2. Close the receiving record (counted → staged → closed) → SO flips to `received`.
3. Re-try `button_start` → succeeds.
4. Repeat the start/finish flow with `fp_skip_receiving_gate=True` from a shell to verify bypass.
## Backwards compatibility
- The old soft chatter warning at fp_job_step.py:894-907 is removed. The information is no longer useful — it was a soft warning for a behavior we're now hard-blocking. The job's chatter still tracks the state transition via Odoo's tracking.
- Jobs already in `in_progress` on `not_received` SOs at deploy time: any future button_finish will block. Manager must either close receiving OR use bypass.
- No DB migration needed.
## Deployment
- Single-module deploy to entech LXC 111 (`fusion_plating_jobs`).
- No restart of dependent modules required.
- Verify with manual flow above.

View File

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

View File

@@ -30,6 +30,79 @@ def post_init_hook(env):
_backfill_contract_review_template(env)
_seed_rack_tags_if_empty(env)
_migrate_legacy_uom_columns(env)
_seed_starter_recipes_once(env)
def _seed_starter_recipes_once(env):
"""Load starter recipe XML files on FIRST install only.
Before 19.0.20.5.0 the recipe XML files (ENP-STEEL-BASIC, ENP-SP,
ENP-ALUM-BASIC, etc.) lived in the manifest's ``data`` list. With
``noupdate="1"`` we expected user edits / deletions to survive
module upgrades — but Odoo only treats noupdate=1 as "don't update
existing records". If a record's ir.model.data row is deleted via
unlink, Odoo on the next ``-u`` sees the xmlid as missing and
RE-CREATES the record from XML. Bug reported 2026-05-20: every
time the user deleted a substep from a starter recipe, the next
upgrade brought it back.
Fix: pull those files out of the manifest's data list, load them
here via convert_file ONCE per xmlid. Each file gets a sentinel
check (does the root recipe's xmlid exist in ir.model.data?); if
yes, skip. The hook is itself idempotent so it's safe to run on
every upgrade as well — but the sentinel ensures recipe content
is only seeded the very first time.
"""
from odoo.tools import convert
Module = env['ir.module.module']
mod = Module.search([('name', '=', 'fusion_plating')], limit=1)
if not mod:
return
# (xmlid_to_check, data_file_path) pairs.
# If the xmlid already exists in ir.model.data, the file is skipped.
sentinels = [
('fusion_plating.recipe_enp_alum_basic',
'data/fp_recipe_enp_alum_basic.xml'),
('fusion_plating.recipe_enp_steel_basic',
'data/fp_recipe_enp_steel_basic.xml'),
('fusion_plating.recipe_enp_sp',
'data/fp_recipe_enp_sp.xml'),
('fusion_plating.recipe_general_processing',
'data/fp_recipe_general_processing.xml'),
('fusion_plating.recipe_anodize',
'data/fp_recipe_anodize.xml'),
('fusion_plating.recipe_chem_conversion',
'data/fp_recipe_chem_conversion.xml'),
]
IMD = env['ir.model.data']
for xmlid, filepath in sentinels:
module_name, name = xmlid.split('.', 1)
if IMD.search_count([('module', '=', module_name), ('name', '=', name)]):
# Recipe already in DB (either from a previous install, or
# already loaded by an earlier hook run). Don't touch — user
# may have made edits.
continue
# File not yet loaded for this DB. Run it once.
try:
with open_module_data_file(filepath) as fh:
convert.convert_file(
env, module_name, filepath, idref={}, mode='init',
noupdate=True,
)
_logger.info('Seeded starter recipe %s', xmlid)
except FileNotFoundError:
_logger.warning('Starter recipe file %s not found, skipping',
filepath)
except Exception as exc:
_logger.warning('Could not seed %s: %s', xmlid, exc)
def open_module_data_file(relpath):
"""Open a file relative to the fusion_plating module root."""
import os
here = os.path.dirname(__file__)
return open(os.path.join(here, relpath), 'rb')
def _resolve_kind_id(env, code):

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating',
'version': '19.0.20.1.0',
'version': '19.0.20.6.2',
'category': 'Manufacturing/Plating',
'summary': 'Core plating / metal finishing ERP: facilities, processes, tanks, baths, jobs, operators.',
'description': """
@@ -120,12 +120,19 @@ Copyright (c) 2026 Nexa Systems Inc. All rights reserved.
'views/fp_jobs_menu.xml',
'data/fp_work_role_data.xml',
'views/fp_work_role_views.xml',
'data/fp_recipe_enp_alum_basic.xml',
'data/fp_recipe_enp_steel_basic.xml',
'data/fp_recipe_enp_sp.xml',
'data/fp_recipe_general_processing.xml',
'data/fp_recipe_anodize.xml',
'data/fp_recipe_chem_conversion.xml',
# Starter recipes are NOT in 'data' on purpose. They get
# loaded once via post_init_hook → _seed_starter_recipes_once
# so user edits / deletions survive every -u upgrade. Putting
# them back here would re-create deleted nodes on every
# module upgrade (the noupdate="1" flag only blocks UPDATE,
# not CREATE-when-missing — Odoo treats a missing ir.model.data
# record as "needs creating").
# 'data/fp_recipe_enp_alum_basic.xml',
# 'data/fp_recipe_enp_steel_basic.xml',
# 'data/fp_recipe_enp_sp.xml',
# 'data/fp_recipe_general_processing.xml',
# 'data/fp_recipe_anodize.xml',
# 'data/fp_recipe_chem_conversion.xml',
'data/fp_step_template_data.xml',
],
'post_init_hook': 'post_init_hook',

View File

@@ -9,7 +9,7 @@ enforced by the underlying ACL on fp.step.template + process.node:
operators get read; supervisors+ get write.
"""
from odoo import http
from odoo import _, http
from odoo.http import request
@@ -63,12 +63,93 @@ class SimpleRecipeController(http.Controller):
def load(self, recipe_id):
recipe = request.env['fusion.plating.process.node'].browse(recipe_id)
recipe.check_access('read')
steps = recipe.child_ids.sorted('sequence')
# Tree-Editor-authored recipes carry FOUR node levels:
# recipe → sub_process → operation → step
# The Tree Editor shows all of them. The Simple Editor used to
# only show direct children of the recipe — so for
# ENP-STEEL-BASIC (1 sub_process + 16 operations + 26 step
# nodes), authors saw 10 rows out of 43. Work-order generation
# walked the full tree and emitted operations as fp.job.step
# rows with step-nodes folded in as instruction text.
#
# We now walk the full tree depth-first and surface EVERY
# operation and step node, in traversal order, each tagged
# with:
# - `nested_under`: chained sub-process path ("Steel Line",
# "Steel Line Cleaner", etc.)
# - `node_type`: 'operation' or 'step'
# - `is_substep`: True for `step` nodes (renders indented)
#
# The Simple Editor's drag/insert/reorder semantics still
# treat operations as headline rows; substeps are read-only
# by default in the UI but their fields can be edited via the
# existing step_write endpoint (which doesn't care about
# node_type).
flat_nodes = self._flatten_recipe_nodes(recipe)
return {
'recipe': self._recipe_payload(recipe),
'steps': [self._step_payload(s) for s in steps],
'steps': [
dict(self._step_payload(node),
nested_under=path,
node_type=node.node_type,
is_substep=(node.node_type == 'step'))
for node, path in flat_nodes
],
}
def _flatten_recipe_operations(self, recipe):
"""Legacy helper — returns ONLY operations.
Kept for back-compat with callers and tests that asked for the
operations-only view. Most paths should now use
``_flatten_recipe_nodes`` which also surfaces step children.
"""
return [
(n, p) for n, p in self._flatten_recipe_nodes(recipe)
if n.node_type == 'operation'
]
def _flatten_recipe_nodes(self, recipe):
"""Walk the recipe DFS, return [(node, path_label)].
Surfaces both `operation` and `step` nodes. The traversal order
matches what the Tree Editor displays:
recipe → recurse → operation (emit) → its step children (emit)
recipe → recurse → sub_process → recurse → operation → steps
Step children are emitted IMMEDIATELY after their parent
operation so the editor can render them as a contiguous block.
"""
out = []
def _walk(node, path):
if node.node_type == 'operation':
out.append((node, path))
# Emit step children right after the operation so the
# editor sees: [Op, step, step, NextOp, step, ...].
# The path label for a substep names its parent
# operation, chained from the sub-process if present.
sub_path = (
f"{path} {node.name}" if path else node.name
)
for child in node.child_ids.sorted('sequence'):
if child.node_type == 'step':
out.append((child, sub_path))
return
if node.node_type in ('recipe', 'sub_process'):
sub_path = (
path if node.node_type == 'recipe'
else (f"{path} {node.name}" if path else node.name)
)
for child in node.child_ids.sorted('sequence'):
_walk(child, sub_path)
# `step` nodes that are direct children of a recipe (rare,
# legacy seed data) are silently dropped — _generate_steps
# has always skipped them.
_walk(recipe, '')
return out
def _recipe_payload(self, recipe):
return {
'id': recipe.id,
@@ -80,6 +161,11 @@ class SimpleRecipeController(http.Controller):
[recipe.process_type_id.id, recipe.process_type_id.name]
if recipe.process_type_id else False
),
# 2026-05-20 — drives the visibility of admin-only affordances
# in the Simple Editor (e.g. "+ New kind…" inline create).
'user_is_manager': request.env.user.has_group(
'fusion_plating.group_fusion_plating_manager'
),
}
def _step_payload(self, step):
@@ -418,12 +504,32 @@ class SimpleRecipeController(http.Controller):
@http.route('/fp/simple_recipe/kinds/create',
type='jsonrpc', auth='user')
def kinds_create(self, name, code=''):
"""Sub 14b — Inline create for "+ New kind…" in the library
form. Auto-derives a code from the name if blank."""
"""Inline create for "+ New kind…" in the library form.
Auto-derives a code from the name if blank.
2026-05-20 lockdown: manager group only. Kinds drive gates,
milestones, and operator routing — a user-created kind with no
corresponding behaviour is a silent foot-gun. The dropdown is
the curated catalog; adding a new kind requires manager
approval and follow-up code work to wire the new code into the
downstream behaviour map.
"""
Kind = request.env['fp.step.kind']
if not name or not name.strip():
return {'ok': False, 'error': 'name_required'}
# check_access via create attempt — supervisors+ allowed (ACL).
if not request.env.user.has_group(
'fusion_plating.group_fusion_plating_manager'
):
return {
'ok': False, 'error': 'forbidden',
'message': (
'Only Plating Managers can add new Step Kinds. The '
'catalog is curated because each kind drives gates, '
'milestones, and operator routing. Pick "Other" if '
'no existing kind fits — or ask a manager to add the '
'new kind once the downstream behaviour is wired up.'
),
}
if not code:
code = name.strip().lower().replace(' ', '_').replace('/', '_')
existing = Kind.search([('code', '=', code)], limit=1)
@@ -586,11 +692,137 @@ class SimpleRecipeController(http.Controller):
@http.route('/fp/simple_recipe/step/reorder', type='jsonrpc', auth='user')
def step_reorder(self, node_ids):
"""Renumber sequence within each parent group.
Naive version (pre-19.0.20.5.0): renumber the entire flat list
1..N regardless of parent. Broke when the flat list mixed
operations and substeps — siblings got out-of-order numbers
because the list interleaved them.
New version: group node ids by their parent_id, then renumber
within each parent. Substeps stay sequenced under their
operation; operations stay sequenced under the recipe / sub-
process. Drop-across-parent shows up as a same-position no-op
— the UI's Promote/Demote buttons are the way to change
parents.
"""
Node = request.env['fusion.plating.process.node']
for i, nid in enumerate(node_ids, start=1):
Node.browse(nid).write({'sequence': i * 10})
nodes = Node.browse([int(n) for n in node_ids])
# Group by parent_id (preserve client-provided order within each).
from collections import OrderedDict
by_parent = OrderedDict()
for n in nodes:
by_parent.setdefault(n.parent_id.id, []).append(n)
for parent_id, siblings in by_parent.items():
for i, n in enumerate(siblings, start=1):
target = i * 10
if n.sequence != target:
n.sequence = target
return {'ok': True}
@http.route('/fp/simple_recipe/step/promote', type='jsonrpc', auth='user')
def step_promote(self, node_id):
"""Promote a substep (`step` node) to an operation under the
recipe root.
Use case: author added a sub-step under an operation in the
Tree Editor, but actually wants it as a standalone operation
that the operator clocks separately. This call:
1. Flips node_type 'step''operation'
2. Re-parents to the recipe root (or sub-process root if
the parent operation lives inside a sub_process)
3. Places the new operation immediately after its old
parent (so it shows up in a sensible position in the
editor list)
"""
Node = request.env['fusion.plating.process.node']
node = Node.browse(int(node_id))
if not node.exists():
return {'ok': False, 'error': 'not_found'}
node.check_access('write')
if node.node_type != 'step':
return {'ok': False, 'error': 'not_a_substep',
'message': 'Only substeps can be promoted.'}
parent_op = node.parent_id
if not parent_op or parent_op.node_type != 'operation':
return {'ok': False, 'error': 'no_parent_op',
'message': 'Substep has no operation parent to promote out of.'}
new_parent = parent_op.parent_id
if not new_parent or new_parent.node_type not in ('recipe', 'sub_process'):
return {'ok': False, 'error': 'no_grandparent',
'message': 'Cannot find a recipe / sub-process to promote into.'}
# Place the new operation right after parent_op.
new_seq = parent_op.sequence + 1
# Bump later siblings to make room (so we don't collide).
for sibling in new_parent.child_ids.filtered(
lambda s: s.sequence > parent_op.sequence and s.id != node.id
):
sibling.sequence = sibling.sequence + 10
node.write({
'node_type': 'operation',
'parent_id': new_parent.id,
'sequence': new_seq,
})
return {'ok': True, 'new_parent_id': new_parent.id,
'new_sequence': new_seq}
@http.route('/fp/simple_recipe/step/demote', type='jsonrpc', auth='user')
def step_demote(self, node_id, target_op_id=False):
"""Demote an operation to a substep under another operation.
If ``target_op_id`` is provided, the node becomes a substep of
that operation. Otherwise it falls under the operation
immediately preceding it in the editor list (most common case
— author drops a header into the preceding section).
"""
Node = request.env['fusion.plating.process.node']
node = Node.browse(int(node_id))
if not node.exists():
return {'ok': False, 'error': 'not_found'}
node.check_access('write')
if node.node_type != 'operation':
return {'ok': False, 'error': 'not_an_operation',
'message': 'Only operations can be demoted to substeps.'}
# Substeps of operations don't recurse further — bail if this
# operation has its own step children (would lose them on demote).
if node.child_ids:
return {'ok': False, 'error': 'has_children',
'message': (
'Operation "%s" has %d child step(s). Remove '
'or promote them first before demoting this '
'operation.'
) % (node.name, len(node.child_ids))}
# Resolve target operation.
if target_op_id:
target = Node.browse(int(target_op_id))
if not target.exists() or target.node_type != 'operation':
return {'ok': False, 'error': 'invalid_target',
'message': 'Target must be an operation.'}
else:
# Find the preceding operation in the same parent.
parent = node.parent_id
if not parent:
return {'ok': False, 'error': 'no_parent'}
siblings = parent.child_ids.sorted('sequence')
before = [s for s in siblings if s.sequence < node.sequence
and s.node_type == 'operation']
if not before:
return {'ok': False, 'error': 'no_preceding_op',
'message': (
'There is no preceding operation to demote '
'into. Add one above this step first, or '
'pick an operation manually.'
)}
target = before[-1]
# Place the substep at the end of the target operation's children.
last_seq = max(target.child_ids.mapped('sequence') or [0])
node.write({
'node_type': 'step',
'parent_id': target.id,
'sequence': last_seq + 10,
})
return {'ok': True, 'new_parent_id': target.id}
# -------------------------------------------------------------- template
@http.route('/fp/simple_recipe/template/list', type='jsonrpc', auth='user')
def template_list(self):

View File

@@ -1,11 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- 24 seeded Step Kinds — XML IDs use the original Selection
keys so post-migrate can map old default_kind = 'cleaning'
to env.ref('fusion_plating.step_kind_cleaning').
<!-- Step Kind catalog.
noupdate=1 so user edits to defaults survive `-u`. -->
noupdate=1 so user edits to defaults survive `-u`.
2026-05-20 curation (19.0.20.6.0):
- Cut from 24 → 12 active kinds. The dropped ones
(cleaning, electroclean, etch, rinse, strike, dry,
wbf_test, demask, derack, replenishment, hardness_test,
adhesion_test, salt_spray, packaging, gating) are kept
in this XML for history but flipped active=False by the
migration script so they no longer appear in the
dropdown — and bulk-remapped onto the new `other` /
`wet_process` kinds.
- New: `other` (catch-all, default) and `wet_process`
(covers all bath-based steps).
- `mask` covers Masking + De-Masking, `racking` covers
Racking + De-Racking — operators differentiate by the
step name. -->
<!-- ============================================================ -->
<!-- ACTIVE KINDS — visible in dropdown -->
<!-- ============================================================ -->
<record id="step_kind_other" model="fp.step.kind">
<field name="code">other</field>
<field name="name">Other</field>
<field name="sequence">5</field>
<field name="icon">fa-circle-o</field>
</record>
<record id="step_kind_wet_process" model="fp.step.kind">
<field name="code">wet_process</field>
<field name="name">Wet Process (Clean / Rinse / Etch / Dry / etc.)</field>
<field name="sequence">55</field>
<field name="icon">fa-tint</field>
</record>
<record id="step_kind_receiving" model="fp.step.kind">
<field name="code">receiving</field>

View File

@@ -0,0 +1,41 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# 2026-05-20 Step Kind curation — post-migrate.
#
# Runs AFTER the schema settles. Marks the 15 retired kinds inactive so
# they no longer appear in the dropdown. We keep them in the DB rather
# than deleting because:
# - ir.model.data rows would dangle and break a future re-import
# - audit trail / reports may still reference them by code
# - users who undo the curation get one switch back to active=True
#
# Pre-migrate has already re-mapped every template + node pointing at
# these kinds, so flipping active=False has no operator-facing data
# impact — it only hides them from pickers.
import logging
_logger = logging.getLogger(__name__)
_RETIRED_CODES = [
'cleaning', 'electroclean', 'etch', 'rinse', 'strike', 'dry',
'wbf_test', 'demask', 'derack', 'replenishment', 'hardness_test',
'adhesion_test', 'salt_spray', 'packaging', 'gating',
]
def migrate(cr, version):
cr.execute("""
UPDATE fp_step_kind
SET active = false
WHERE code = ANY(%s)
AND active = true
""", (_RETIRED_CODES,))
n = cr.rowcount
if n:
_logger.info(
'Step Kind curation: retired %d kinds (active=False): %s',
n, _RETIRED_CODES,
)

View File

@@ -0,0 +1,259 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# 2026-05-20 Step Kind curation — pre-migrate.
#
# Runs BEFORE the model schema is applied so `kind_id` can become
# required=True without choking on existing NULL rows. Three jobs:
#
# 1. Ensure the new `other` and `wet_process` kinds exist in the DB.
# The data XML hasn't loaded yet at pre-migrate time, so we SQL
# them in directly. The XML on the install path will see them and
# skip via noupdate.
#
# 2. Re-map every template + recipe-node pointing at a RETIRED kind
# to its new home:
# cleaning, electroclean, etch, rinse, strike, dry, wbf_test
# → wet_process
# plate
# → wet_process (but we KEEP `plate` separately for the
# Plated milestone trigger; only auto-remap when the
# caller explicitly wants to retire it. Plate stays
# active.)
# demask → mask
# derack → racking
# replenishment, hardness_test, adhesion_test, salt_spray,
# packaging, gating
# → other
#
# 3. Backfill every NULL kind_id via name-matching heuristic. Anything
# that doesn't match → 'other'.
#
# After this script the schema can safely add NOT NULL to kind_id.
import logging
_logger = logging.getLogger(__name__)
# -- Remap table — retired-kind code -> new-kind code ----------------------
# IMPORTANT: `plate` stays active (its own milestone trigger). Only the
# wet-bath specialisations roll up into wet_process.
_REMAP = {
'cleaning': 'wet_process',
'electroclean': 'wet_process',
'etch': 'wet_process',
'rinse': 'wet_process',
'strike': 'wet_process',
'dry': 'wet_process',
'wbf_test': 'wet_process',
'demask': 'mask',
'derack': 'racking',
'replenishment': 'other',
'hardness_test': 'other',
'adhesion_test': 'other',
'salt_spray': 'other',
'packaging': 'other',
'gating': 'other',
}
# -- Name-match heuristic for NULL backfill --------------------------------
# Each rule: (substring to match in lower(name), target kind code). First
# match wins. Order matters — more specific patterns come first.
_NAME_HEURISTIC = [
# Most specific
('qa-005', 'contract_review'),
('contract review', 'contract_review'),
('final inspect', 'final_inspect'),
('final inspection', 'final_inspect'),
('post plate inspect', 'final_inspect'),
# Bake / oven
('bake', 'bake'),
('oven', 'bake'),
('he relief', 'bake'),
('embrittlement', 'bake'),
('stress relief', 'bake'),
# Receiving / shipping
('receiv', 'receiving'),
('incoming inspect', 'receiving'),
('ship', 'ship'),
('pack', 'ship'),
# Racking
('de-rack', 'racking'),
('deracking', 'racking'),
('derack', 'racking'),
('rack', 'racking'),
# Masking
('de-mask', 'mask'),
('demask', 'mask'),
('unmask', 'mask'),
('mask', 'mask'),
# Inspection
('inspect', 'inspect'),
# Plating
('plate', 'plate'),
('plating', 'plate'),
('nickel', 'plate'),
('chrome', 'plate'),
('anodi', 'plate'),
# Wet processes (broad)
('soak clean', 'wet_process'),
('electroclean', 'wet_process'),
('clean', 'wet_process'),
('rinse', 'wet_process'),
('etch', 'wet_process'),
('activ', 'wet_process'),
('strike', 'wet_process'),
('desmut', 'wet_process'),
('zincate', 'wet_process'),
('acid', 'wet_process'),
('dry', 'wet_process'),
('water break', 'wet_process'),
('wbf', 'wet_process'),
# Gating / ready / wait — soft sequencers, no behaviour
('ready for', 'other'),
('ready to', 'other'),
]
def migrate(cr, version):
# 1. Ensure `other` and `wet_process` exist. Use SQL directly so
# we don't depend on the XML having loaded yet.
_ensure_kind(cr, 'other', 'Other', 'fa-circle-o', 5)
_ensure_kind(cr, 'wet_process', 'Wet Process (Clean / Rinse / Etch / Dry / etc.)', 'fa-tint', 55)
# 2. Build a code → id map for ALL kinds present in DB.
cr.execute("SELECT id, code FROM fp_step_kind")
by_code = {code: kid for kid, code in cr.fetchall()}
if 'other' not in by_code:
_logger.error('pre-migrate: `other` kind missing after _ensure_kind — aborting')
return
other_id = by_code['other']
# 3. Re-map references to retired kinds.
# `default_kind` is a stored related on `kind_id.code` — updating
# kind_id via SQL doesn't auto-recompute the stored copy, so we
# write both columns together.
for retired_code, new_code in _REMAP.items():
retired_id = by_code.get(retired_code)
new_id = by_code.get(new_code) or other_id
if not retired_id:
continue # not in this DB — nothing to remap
cr.execute("""
UPDATE fp_step_template
SET kind_id = %s, default_kind = %s
WHERE kind_id = %s
""", (new_id, new_code, retired_id))
tpl_n = cr.rowcount
cr.execute("""
UPDATE fusion_plating_process_node
SET kind_id = %s, default_kind = %s
WHERE kind_id = %s
""", (new_id, new_code, retired_id))
node_n = cr.rowcount
if tpl_n or node_n:
_logger.info(
'Step Kind curation: remapped %d template(s) + %d node(s) '
'from %s%s', tpl_n, node_n, retired_code, new_code,
)
# 4. Backfill NULL kind_id on both tables via name heuristic.
# `name` is jsonb on fp_step_template (translatable in Odoo 19) but
# plain varchar on fusion_plating_process_node. Sniff the column
# type so the right expression is used.
for table in ('fp_step_template', 'fusion_plating_process_node'):
cr.execute("""
SELECT data_type FROM information_schema.columns
WHERE table_name = %s AND column_name = 'name'
""", (table,))
row = cr.fetchone()
col_type = (row[0] if row else '') or ''
if 'json' in col_type.lower():
name_expr = "COALESCE(name->>'en_US', name::text)"
else:
name_expr = 'name'
cr.execute(f"""
SELECT id, {name_expr} AS name_str
FROM {table}
WHERE kind_id IS NULL
""")
rows = cr.fetchall()
if not rows:
continue
# In-process classification to avoid pummelling the DB with
# one UPDATE per row.
per_kind = {} # kind_id → list of row ids
for rid, raw_name in rows:
target_code = _classify_by_name(raw_name)
target_id = by_code.get(target_code) or other_id
per_kind.setdefault(target_id, []).append(rid)
# Build a kid → code lookup so we can write default_kind together.
by_id = {kid: code for code, kid in by_code.items()}
for kid, ids in per_kind.items():
cr.execute(
f"UPDATE {table} SET kind_id = %s, default_kind = %s "
f"WHERE id = ANY(%s)",
(kid, by_id.get(kid, 'other'), ids),
)
_logger.info(
'Step Kind curation: backfilled %d %s row(s) — '
'distribution: %s',
len(rows), table,
{next(c for c, i in by_code.items() if i == k): len(v)
for k, v in per_kind.items()},
)
def _classify_by_name(name):
"""Return a step-kind code based on a name match. Falls back to 'other'."""
if not name:
return 'other'
lower = name.lower()
for needle, code in _NAME_HEURISTIC:
if needle in lower:
return code
return 'other'
def _ensure_kind(cr, code, name, icon, sequence):
"""Create the kind via SQL if it doesn't exist yet. Idempotent.
fp_step_kind.name is a jsonb (translatable) column in Odoo 19, so
we wrap the string in jsonb_build_object('en_US', ...).
Also registers the ir.model.data entry so the subsequent XML data
load (which runs AFTER pre-migrate) sees the xmlid as already
bound and skips creation — otherwise we get duplicate records.
"""
cr.execute("SELECT id FROM fp_step_kind WHERE code = %s", (code,))
row = cr.fetchone()
if row:
kid = row[0]
else:
cr.execute("""
INSERT INTO fp_step_kind (code, name, sequence, icon, active,
create_uid, create_date, write_uid, write_date)
VALUES (%s, jsonb_build_object('en_US', %s::text), %s, %s, true,
1, NOW() AT TIME ZONE 'UTC', 1, NOW() AT TIME ZONE 'UTC')
RETURNING id
""", (code, name, sequence, icon))
kid = cr.fetchone()[0]
_logger.info('Step Kind curation: created kind %s (id=%s)', code, kid)
# Bind the xmlid so XML noupdate=1 finds the record on next load.
xmlid_name = 'step_kind_%s' % code
cr.execute("""
SELECT id FROM ir_model_data
WHERE module = 'fusion_plating' AND name = %s
""", (xmlid_name,))
if cr.fetchone():
return
cr.execute("""
INSERT INTO ir_model_data (module, name, model, res_id, noupdate,
create_uid, create_date, write_uid, write_date)
VALUES ('fusion_plating', %s, 'fp.step.kind', %s, true,
1, NOW() AT TIME ZONE 'UTC', 1, NOW() AT TIME ZONE 'UTC')
""", (xmlid_name, kid))
_logger.info('Step Kind curation: bound xmlid fusion_plating.%s -> id %s',
xmlid_name, kid)

View File

@@ -80,7 +80,15 @@ class FpParentNumberedMixin(models.AbstractModel):
"""
self.ensure_one()
so = self._fp_parent_sale_order()
if not so or not so.x_fc_parent_number:
# Defensive: the parent-number column lives in fusion_plating_jobs;
# downstream modules (e.g. fusion_plating_receiving) inherit the
# mixin but don't depend on jobs, so so.x_fc_parent_number can
# raise AttributeError at test time. hasattr keeps the mixin safe
# in either install topology — falls through to the legacy
# sequence when the column isn't there.
if not so or 'x_fc_parent_number' not in so._fields:
return False
if not so.x_fc_parent_number:
return False
counter_field = self._fp_parent_counter_field()
# Whitelist check — the field name is interpolated directly into

View File

@@ -493,9 +493,23 @@ class FpProcessNode(models.Model):
help='Sub 12b — opens the transition form before Mark Done.',
)
# Sub 14b — User-extensible Step Kinds (was Selection of 24).
# 2026-05-20: required + ondelete='restrict' — kind drives gates,
# workflow milestones, and operator routing. Optional was a foot-gun
# (operators silently picked Generic / nothing). Pre-migrate
# 19.0.20.6.0 backfills every existing row before this NOT NULL
# constraint hits the schema.
kind_id = fields.Many2one(
'fp.step.kind', string='Step Kind', ondelete='set null', index=True,
help='Pick from the catalog or create a new kind.',
'fp.step.kind', string='Step Kind',
ondelete='restrict', index=True,
required=True,
default=lambda self: self.env['fp.step.kind'].search(
[('code', '=', 'other')], limit=1,
).id or False,
help='Drives operator routing (auto-open Contract Review form / '
'Rack assignment dialog / Bake window), customer-portal '
'milestones (Received / Plated / Inspected / Shipped), and '
'tablet UI (icon, station filter). Pick "Other" only when '
'the step has no special behaviour.',
)
# Back-compat: code-string accessor that all legacy
# `node.default_kind == "cleaning"` comparisons keep using.

View File

@@ -89,11 +89,18 @@ class FpStepTemplate(models.Model):
help='Opens the transition form before Mark Done (Sub 12b).')
# Sub 14b — User-extensible Step Kinds (was Selection of 24).
# 2026-05-20: required — same rationale as on fusion.plating.process.node
# (kind drives every downstream gate / milestone / routing decision).
kind_id = fields.Many2one(
'fp.step.kind', string='Step Kind', ondelete='restrict',
index=True, tracking=True,
help='Pick from the catalog or create a new kind. Drives sane-'
'default input seeding.',
required=True,
default=lambda self: self.env['fp.step.kind'].search(
[('code', '=', 'other')], limit=1,
).id or False,
help='Drives sane-default input seeding plus downstream gates / '
'milestones / routing when authors instantiate the template. '
'Pick "Other" only when the step has no special behaviour.',
)
# Back-compat shim — every legacy `tpl.default_kind == "cleaning"`
# call site keeps working without a refactor. Stored=True so existing

View File

@@ -86,6 +86,20 @@ export class FpSimpleRecipeEditor extends Component {
}
async loadAll() {
// Preserve scroll position across the re-render. .o_fp_simple_editor
// is the overflow:auto scroll container — when `state.steps` is
// replaced with a fresh array, OWL tears down the t-foreach and
// rebuilds every row, which snaps scrollTop back to 0. Operators
// hate this: they save a step half-way down the recipe and the
// page jumps to the top. Capture the position before the RPC,
// restore it after the next paint.
//
// Regression note (2026-05-20): every save/insert/remove/promote
// handler calls loadAll, so this single choke point fixes scroll
// reset for the whole editor.
const scrollRoot = document.querySelector(".o_fp_simple_editor");
const savedScrollTop = scrollRoot ? scrollRoot.scrollTop : 0;
this.state.loading = true;
const [recipeData, libraryData, templateData] = await Promise.all([
rpc("/fp/simple_recipe/load", { recipe_id: this._recipeId }),
@@ -97,6 +111,21 @@ export class FpSimpleRecipeEditor extends Component {
this.state.library = libraryData.templates;
this.state.templateOptions = templateData.templates;
this.state.loading = false;
// Restore AFTER OWL repaints. Microtask runs before paint in OWL 2;
// we need rAF (or two of them, defensively) so the rebuilt DOM
// exists when we set scrollTop. Without this the assignment fires
// against the pre-render DOM and gets discarded.
if (savedScrollTop > 0) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const el = document.querySelector(".o_fp_simple_editor");
if (el) {
el.scrollTop = savedScrollTop;
}
});
});
}
}
async onSearchLibrary(ev) {
@@ -152,6 +181,61 @@ export class FpSimpleRecipeEditor extends Component {
await this.loadAll();
}
// ---- Promote / demote -------------------------------------------------
//
// Substep → operation: turn a child step into a top-level operation
// under the recipe root (or sub-process root if applicable).
// Operation → substep: tuck a top-level operation under the
// preceding operation as one of its substeps. Handy when the author
// realises a "header" should actually live as part of another
// operation's workflow.
async onPromoteStep(stepId) {
const proceed = await this._confirm(
_t(
"Promote this substep to a top-level operation? It will be " +
"moved out of its parent operation and placed directly under " +
"the recipe."
)
);
if (!proceed) return;
const res = await rpc("/fp/simple_recipe/step/promote", {
node_id: stepId,
});
if (!res.ok) {
this.notification.add(
res.message || _t("Could not promote step."),
{ type: "warning" }
);
return;
}
await this.loadAll();
this.notification.add(_t("Step promoted to operation."), { type: "success" });
}
async onDemoteStep(stepId) {
const proceed = await this._confirm(
_t(
"Demote this operation to a substep under the previous " +
"operation? It will be tucked underneath the operation " +
"immediately above it in the list."
)
);
if (!proceed) return;
const res = await rpc("/fp/simple_recipe/step/demote", {
node_id: stepId,
});
if (!res.ok) {
this.notification.add(
res.message || _t("Could not demote step."),
{ type: "warning" }
);
return;
}
await this.loadAll();
this.notification.add(_t("Operation demoted to substep."), { type: "success" });
}
async onAddInlineStep() {
await rpc("/fp/simple_recipe/step/insert", {
recipe_id: this._recipeId,
@@ -328,7 +412,10 @@ export class FpSimpleRecipeEditor extends Component {
name: name.trim(),
});
if (!data.ok) {
alert(data.error || "Could not create Step Kind.");
// 2026-05-20 — backend forbids non-managers from
// creating kinds. Surface the explanatory message
// instead of a generic error code.
alert(data.message || data.error || "Could not create Step Kind.");
return;
}
// Drop the cached list so the next ensure() refetches it.
@@ -642,11 +729,18 @@ export class FpSimpleRecipeEditor extends Component {
// Sub 14 — make sure the workflow-state catalog is cached so
// the dropdown in the inline form has options to render.
await this._fpEnsureWorkflowStatesLoaded();
// 2026-05-20 — Step Type dropdown is now driven by the
// fp.step.kind catalog (curated to 12 active kinds). Cache the
// list before opening the panel so the select renders with
// options instead of being empty.
await this._fpEnsureKindOptionsLoaded();
this.state.editingStepId = stepId;
this.state.editName = step.name || "";
this.state.editInstructions = this._htmlToText(step.description || "");
// Settings the user can now change WITHOUT delete + re-add.
this.state.editDefaultKind = step.default_kind || "";
// Default to 'other' when no kind is set — kind_id is required
// on the model so we never want a blank value to round-trip.
this.state.editDefaultKind = step.default_kind || "other";
this.state.editTriggersWorkflowStateId =
step.triggers_workflow_state_id || false;
this.state.editParallelStart = !!step.parallel_start;

View File

@@ -116,6 +116,10 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
// align-items: start so the library panel can be shorter than
// the recipe-step column without stretching to match its height
// — required for sticky positioning to behave.
align-items: start;
@media (max-width: 900px) {
grid-template-columns: 1fr;
@@ -137,6 +141,33 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
}
}
// Step Library — pin to the top of the scroll container so authors
// can drag from it into the recipe without scrolling back up.
// Recipes can be 40+ steps long; before this, the library scrolled
// off with the page and you had to scroll to the top, grab a step,
// scroll back down, drop. Bug reported 2026-05-20.
//
// Sticky inside the editor's overflow:auto container. max-height +
// internal overflow-y so the library's OWN content (could be 30+
// entries) doesn't blow past the viewport — it grows a scrollbar
// instead.
.o_fp_library_panel {
position: sticky;
top: 1rem; // matches the editor's padding
max-height: calc(100vh - 8rem); // leave room for headers + footer
overflow-y: auto;
// Keep a faint shadow on the sticky edge so it reads as a
// floating sidebar, not glued onto the recipe column.
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
@media (max-width: 900px) {
// Stacked layout — no sticky, behaves like a normal block.
position: static;
max-height: none;
box-shadow: none;
}
}
// ===================================================== Drop simulator
//
// Thin reservation line between rows that activates only when the
@@ -224,6 +255,52 @@ $fp-se-drop: var(--fp-drop-bg, #{$_fp_se_drop_hex});
padding: .125rem .5rem;
border-radius: 999px;
}
// Tree-editor-authored recipes can have operations nested inside a
// sub_process; the Simple Editor flattens those into the same list
// but tags them with a small "inside <sub-process>" badge so the
// author isn't confused about where they came from.
.o_fp_nested_under,
.o_fp_substep_parent {
font-size: .7rem;
font-weight: 500;
padding: .15rem .45rem;
border-radius: 999px;
background: $fp-se-page;
color: $fp-se-muted;
i { opacity: .7; }
}
// Step nodes inside an operation are rendered as indented sub-rows
// — same node model as operations, but they're sub-instructions
// (the WO generator folds them into the operation's instruction
// text). Visual treatment: smaller, indented, no drag handle, no
// numeric position so the eye can tell them apart from operations.
&.o_fp_substep_row {
padding-left: 2.5rem;
background: transparent;
font-size: .92em;
opacity: .85;
.o_fp_step_name { font-weight: 400; }
.o_fp_substep_indent {
color: $fp-se-muted;
cursor: default;
}
}
.o_fp_step_promote,
.o_fp_step_demote {
background: none;
border: none;
color: $fp-se-muted;
padding: .2rem .4rem;
cursor: pointer;
font-size: .85rem;
border-radius: 4px;
transition: background .12s ease, color .12s ease;
&:hover {
background: $fp-se-page;
color: $fp-se-accent;
}
}
.o_fp_step_edit,
.o_fp_step_remove {
background: none;

View File

@@ -68,14 +68,29 @@
<t t-foreach="state.steps" t-as="step" t-key="step.id">
<div class="o_fp_step_row"
t-att-class="state.editingStepId === step.id ? 'o_fp_step_row_editing' : ''"
t-att-class="(state.editingStepId === step.id ? 'o_fp_step_row_editing ' : '') + (step.is_substep ? 'o_fp_substep_row' : '')"
draggable="true"
t-on-dragstart="(ev) => this.onSelectedDragStart(step.id, ev)"
t-on-dragover="(ev) => this.onRowDragOver(step_index, ev)">
<span class="o_fp_drag_handle"></span>
<span class="o_fp_step_position"><t t-esc="step_index + 1"/>.</span>
<i t-att-class="'fa ' + (step.icon || 'fa-cog')"/>
<span class="o_fp_drag_handle" t-if="!step.is_substep"></span>
<span class="o_fp_drag_handle o_fp_substep_indent" t-if="step.is_substep" title="Drag to reorder among substeps of the same operation"></span>
<span class="o_fp_step_position" t-if="!step.is_substep">
<t t-esc="step_index + 1"/>.
</span>
<i t-att-class="'fa ' + (step.icon || (step.is_substep ? 'fa-circle-o' : 'fa-cog'))"/>
<span class="o_fp_step_name" t-esc="step.name"/>
<span class="o_fp_nested_under badge bg-light text-muted ms-1"
t-if="step.nested_under and !step.is_substep"
t-att-title="'Inside sub-process: ' + step.nested_under">
<i class="fa fa-sitemap me-1"/>
<t t-esc="step.nested_under"/>
</span>
<span class="o_fp_substep_parent badge bg-light text-muted ms-1"
t-if="step.is_substep and step.nested_under"
title="Sub-step of the operation above">
<i class="fa fa-level-up fa-rotate-90 me-1"/>
<t t-esc="step.nested_under"/>
</span>
<span class="o_fp_step_has_instructions"
t-if="step.description"
title="Has operator instructions">
@@ -92,6 +107,18 @@
<i class="fa fa-clipboard"/>
<t t-esc="step.measurements_badge_text"/>
</span>
<button class="o_fp_step_promote"
t-if="step.is_substep"
title="Promote: turn this substep into a top-level operation"
t-on-click="() => this.onPromoteStep(step.id)">
<i class="fa fa-arrow-up"/>
</button>
<button class="o_fp_step_demote"
t-if="!step.is_substep"
title="Demote: tuck this operation under the previous one as a substep"
t-on-click="() => this.onDemoteStep(step.id)">
<i class="fa fa-arrow-down"/>
</button>
<button class="o_fp_step_edit"
title="Edit name &amp; instructions"
t-on-click="() => this.onToggleEdit(step.id)">
@@ -130,34 +157,40 @@
below it lets them override per-step. -->
<div class="o_fp_edit_row" style="display: flex; gap: 16px; flex-wrap: wrap;">
<div class="o_fp_edit_field" style="flex: 1; min-width: 240px;">
<label>Step Type (Default Kind)</label>
<label>Step Type (Default Kind) *</label>
<!-- 2026-05-20: hard-coded option list
retired. The dropdown now drives
off `state.kindOptions` (fp.step.kind
records with active=True), which is
the curated catalog (Other,
Receiving, Contract Review, Racking,
Masking, Wet Process, Plating, Bake,
Inspection, Final Inspection,
Shipping). New kinds need a manager
+ code work to wire downstream gates;
see kinds_create lockdown. -->
<select class="form-select"
t-on-change="(ev) => { state.editDefaultKind = ev.target.value; }">
<option value="" t-att-selected="!state.editDefaultKind">— Generic —</option>
<option value="receiving" t-att-selected="state.editDefaultKind === 'receiving'">Receiving / Incoming Inspection</option>
<option value="contract_review" t-att-selected="state.editDefaultKind === 'contract_review'">Contract Review (QA-005)</option>
<option value="racking" t-att-selected="state.editDefaultKind === 'racking'">Racking</option>
<option value="mask" t-att-selected="state.editDefaultKind === 'mask'">Masking</option>
<option value="cleaning" t-att-selected="state.editDefaultKind === 'cleaning'">Cleaning</option>
<option value="electroclean" t-att-selected="state.editDefaultKind === 'electroclean'">Electroclean</option>
<option value="etch" t-att-selected="state.editDefaultKind === 'etch'">Etch / Activation</option>
<option value="rinse" t-att-selected="state.editDefaultKind === 'rinse'">Rinse</option>
<option value="strike" t-att-selected="state.editDefaultKind === 'strike'">Strike</option>
<option value="plate" t-att-selected="state.editDefaultKind === 'plate'">Plating</option>
<option value="replenishment" t-att-selected="state.editDefaultKind === 'replenishment'">Tank Replenishment</option>
<option value="wbf_test" t-att-selected="state.editDefaultKind === 'wbf_test'">Water Break Free Test</option>
<option value="dry" t-att-selected="state.editDefaultKind === 'dry'">Drying</option>
<option value="bake" t-att-selected="state.editDefaultKind === 'bake'">Bake</option>
<option value="demask" t-att-selected="state.editDefaultKind === 'demask'">De-Masking</option>
<option value="derack" t-att-selected="state.editDefaultKind === 'derack'">De-Racking</option>
<option value="inspect" t-att-selected="state.editDefaultKind === 'inspect'">Inspection</option>
<option value="final_inspect" t-att-selected="state.editDefaultKind === 'final_inspect'">Final Inspection</option>
<option value="ship" t-att-selected="state.editDefaultKind === 'ship'">Shipping</option>
<t t-foreach="state.kindOptions || []" t-as="k" t-key="k.id">
<option t-att-value="k.code"
t-att-selected="k.code === state.editDefaultKind">
<t t-esc="k.name"/>
</option>
</t>
</select>
<p class="o_fp_edit_hint">
Drives workflow milestone triggers (e.g. <code>final_inspect</code> fires
the Inspected status) and routing (e.g. <code>contract_review</code> opens
QA-005 instead of the input wizard).
Required. Drives operator routing
(<code>contract_review</code> opens
QA-005, <code>racking</code> opens
rack picker, <code>bake</code> ties
to bake-window state machine),
customer-portal milestones
(<code>receiving</code> / <code>plate</code>
/ <code>final_inspect</code> /
<code>ship</code>), and tablet UI
(icon, station-type filter). Pick
<strong>Other</strong> only when the
step has no special behaviour.
</p>
</div>
@@ -473,13 +506,19 @@
<select class="form-select"
t-on-change="(ev) => this.onKindChange(ev)"
t-att-value="state.libraryEditor.default_kind">
<option value="">Generic — no automatic behaviour</option>
<t t-foreach="state.kindOptions || []" t-as="k" t-key="k.id">
<option t-att-value="k.code" t-att-selected="k.code === state.libraryEditor.default_kind">
<t t-esc="k.name"/>
</option>
</t>
<option value="__new__">+ Add a new kind…</option>
<!-- Manager-only inline create. The
backend kinds_create endpoint
also gates on this group, so
hiding here is just to avoid
showing a button that
immediately errors. -->
<option value="__new__"
t-if="state.recipe and state.recipe.user_is_manager">+ Add a new kind…</option>
</select>
</div>
<div class="o_fp_le_field">

View File

@@ -2,3 +2,4 @@
from . import test_fp_work_centre
from . import test_fp_job_state_machine
from . import test_fp_job_step_state_machine
from . import test_simple_recipe_flatten

View File

@@ -0,0 +1,305 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Bug surfaced 2026-05-20 on ENP-STEEL-BASIC: a recipe authored in the
# Tree Editor has `sub_process` nodes holding more operations
# underneath. The Simple Editor used to walk `recipe.child_ids` only,
# silently hiding any operation nested inside a sub_process. The work
# order generator on the same recipe DID see them, so author + operator
# disagreed about what was in the recipe. This test pins the new
# depth-first flattening behaviour.
from odoo.tests.common import TransactionCase
class TestSimpleRecipeFlatten(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
Node = cls.env['fusion.plating.process.node']
# Tree shape:
# Recipe
# ├── Op A (top-level)
# ├── Sub-process X
# │ ├── Op B (nested under X)
# │ └── Op C (nested under X)
# └── Op D (top-level, after sub-process)
cls.recipe = Node.create({
'name': 'Test Tree Recipe',
'node_type': 'recipe',
'sequence': 10,
})
cls.op_a = Node.create({
'name': 'Op A', 'node_type': 'operation',
'parent_id': cls.recipe.id, 'sequence': 10,
})
cls.sub_x = Node.create({
'name': 'Sub-X', 'node_type': 'sub_process',
'parent_id': cls.recipe.id, 'sequence': 20,
})
cls.op_b = Node.create({
'name': 'Op B', 'node_type': 'operation',
'parent_id': cls.sub_x.id, 'sequence': 10,
})
cls.op_c = Node.create({
'name': 'Op C', 'node_type': 'operation',
'parent_id': cls.sub_x.id, 'sequence': 20,
})
cls.op_d = Node.create({
'name': 'Op D', 'node_type': 'operation',
'parent_id': cls.recipe.id, 'sequence': 30,
})
def _flatten(self):
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
import SimpleRecipeController
ctrl = SimpleRecipeController()
return ctrl._flatten_recipe_operations(self.recipe)
def test_flat_recipe_returns_top_level_only(self):
# Sanity: a flat recipe (no sub-processes) returns its direct
# operation children with empty path labels.
flat = self.env['fusion.plating.process.node'].create({
'name': 'Flat', 'node_type': 'recipe', 'sequence': 1,
})
for name in ('A', 'B', 'C'):
self.env['fusion.plating.process.node'].create({
'name': name, 'node_type': 'operation',
'parent_id': flat.id, 'sequence': 10,
})
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
import SimpleRecipeController
ops = SimpleRecipeController()._flatten_recipe_operations(flat)
self.assertEqual([n.name for n, _ in ops], ['A', 'B', 'C'])
self.assertEqual([p for _, p in ops], ['', '', ''])
def test_nested_operations_surface_with_path(self):
ops = self._flatten()
names = [n.name for n, _ in ops]
# Op B / Op C live INSIDE Sub-X — the old load returned 3 ops
# (Op A, Op D, plus Sub-X itself); the new one returns 4
# operations and skips the sub_process node.
self.assertEqual(names, ['Op A', 'Op B', 'Op C', 'Op D'])
def test_nested_under_label_carries_sub_process_name(self):
ops = self._flatten()
paths = {n.name: p for n, p in ops}
self.assertEqual(paths['Op A'], '')
self.assertEqual(paths['Op B'], 'Sub-X')
self.assertEqual(paths['Op C'], 'Sub-X')
self.assertEqual(paths['Op D'], '')
def test_sub_process_itself_is_not_surfaced(self):
ops = self._flatten()
node_types = {n.node_type for n, _ in ops}
self.assertEqual(node_types, {'operation'})
# Recipe + sub_process never appear as Simple Editor rows.
def test_operations_only_helper_skips_step_children(self):
# Back-compat: the legacy _flatten_recipe_operations helper
# still returns ONLY operations. New callers should use
# _flatten_recipe_nodes for the full list (operations + steps).
self.env['fusion.plating.process.node'].create({
'name': 'Substep 1', 'node_type': 'step',
'parent_id': self.op_a.id, 'sequence': 10,
})
ops = self._flatten()
names = [n.name for n, _ in ops]
self.assertNotIn('Substep 1', names)
self.assertEqual(names, ['Op A', 'Op B', 'Op C', 'Op D'])
def test_full_nodes_helper_surfaces_step_children(self):
# The Simple Editor's load endpoint uses _flatten_recipe_nodes,
# which DOES surface step children. They're emitted right after
# their parent operation so the editor renders them as a
# contiguous block.
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
import SimpleRecipeController
self.env['fusion.plating.process.node'].create({
'name': 'Substep 1', 'node_type': 'step',
'parent_id': self.op_a.id, 'sequence': 10,
})
self.env['fusion.plating.process.node'].create({
'name': 'Substep 2', 'node_type': 'step',
'parent_id': self.op_a.id, 'sequence': 20,
})
nodes = SimpleRecipeController()._flatten_recipe_nodes(self.recipe)
names = [n.name for n, _ in nodes]
# Substeps appear immediately after Op A, before Op B.
self.assertEqual(
names,
['Op A', 'Substep 1', 'Substep 2',
'Op B', 'Op C', 'Op D'],
)
def test_substeps_carry_parent_operation_in_path(self):
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
import SimpleRecipeController
self.env['fusion.plating.process.node'].create({
'name': 'My Substep', 'node_type': 'step',
'parent_id': self.op_b.id, 'sequence': 10,
})
nodes = SimpleRecipeController()._flatten_recipe_nodes(self.recipe)
paths = {n.name: p for n, p in nodes}
# Op B lives in Sub-X; its substep's path chains both.
self.assertEqual(paths['My Substep'], 'Sub-X Op B')
def test_load_payload_marks_substeps_with_is_substep(self):
# End-to-end check on the load endpoint payload: substeps get
# `is_substep=True` and `node_type='step'` so the UI can render
# them as indented sub-rows.
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
import SimpleRecipeController
self.env['fusion.plating.process.node'].create({
'name': 'A1', 'node_type': 'step',
'parent_id': self.op_a.id, 'sequence': 10,
})
# Mock the request — load() reads request.env.
from unittest.mock import patch
ctrl = SimpleRecipeController()
class FakeReq:
env = self.env
path_to_request = (
'odoo.addons.fusion_plating.controllers.'
'simple_recipe_controller.request'
)
with patch(path_to_request, FakeReq()):
payload = ctrl.load(self.recipe.id)
by_name = {s['name']: s for s in payload['steps']}
self.assertEqual(by_name['Op A']['node_type'], 'operation')
self.assertFalse(by_name['Op A']['is_substep'])
self.assertEqual(by_name['A1']['node_type'], 'step')
self.assertTrue(by_name['A1']['is_substep'])
self.assertEqual(by_name['A1']['nested_under'], 'Op A')
def test_load_endpoint_includes_nested_under_in_payload(self):
# Direct call to the controller's load (mirroring the JSONRPC).
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
import SimpleRecipeController
# The endpoint uses request.env; mock by patching the controller's
# internal helper to use self.env instead. The flat helper is the
# piece worth pinning here; integration with HTTP layer is
# exercised live on entech.
ctrl = SimpleRecipeController()
flat = ctrl._flatten_recipe_operations(self.recipe)
names_with_path = [(n.name, p) for n, p in flat]
self.assertIn(('Op B', 'Sub-X'), names_with_path)
self.assertIn(('Op A', ''), names_with_path)
def test_promote_turns_substep_into_operation(self):
# Add a substep under op_a, promote it, verify it moved.
sub = self.env['fusion.plating.process.node'].create({
'name': 'Sub1', 'node_type': 'step',
'parent_id': self.op_a.id, 'sequence': 10,
})
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
import SimpleRecipeController
from unittest.mock import patch
class FakeReq:
env = self.env
path = ('odoo.addons.fusion_plating.controllers.'
'simple_recipe_controller.request')
with patch(path, FakeReq()):
res = SimpleRecipeController().step_promote(sub.id)
self.assertTrue(res['ok'])
sub.invalidate_recordset()
self.assertEqual(sub.node_type, 'operation')
self.assertEqual(sub.parent_id.id, self.recipe.id)
def test_promote_rejects_non_substep(self):
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
import SimpleRecipeController
from unittest.mock import patch
class FakeReq:
env = self.env
path = ('odoo.addons.fusion_plating.controllers.'
'simple_recipe_controller.request')
with patch(path, FakeReq()):
res = SimpleRecipeController().step_promote(self.op_a.id)
self.assertFalse(res['ok'])
self.assertEqual(res['error'], 'not_a_substep')
def test_demote_turns_operation_into_substep_under_previous(self):
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
import SimpleRecipeController
from unittest.mock import patch
class FakeReq:
env = self.env
path = ('odoo.addons.fusion_plating.controllers.'
'simple_recipe_controller.request')
# Demote Op D into Sub-X (its preceding operation is op_a at
# the recipe root, but Sub-X is between them — the preceding
# OPERATION sibling at the recipe root is op_a).
with patch(path, FakeReq()):
res = SimpleRecipeController().step_demote(self.op_d.id)
self.assertTrue(res['ok'])
self.op_d.invalidate_recordset()
self.assertEqual(self.op_d.node_type, 'step')
# The preceding operation at the recipe root is op_a (Sub-X is
# not an operation, gets filtered out).
self.assertEqual(self.op_d.parent_id.id, self.op_a.id)
def test_demote_blocks_when_operation_has_children(self):
# op_a gets a substep — now demoting op_a should fail because
# it has children.
self.env['fusion.plating.process.node'].create({
'name': 'A-child', 'node_type': 'step',
'parent_id': self.op_a.id, 'sequence': 10,
})
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
import SimpleRecipeController
from unittest.mock import patch
class FakeReq:
env = self.env
path = ('odoo.addons.fusion_plating.controllers.'
'simple_recipe_controller.request')
with patch(path, FakeReq()):
res = SimpleRecipeController().step_demote(self.op_a.id)
self.assertFalse(res['ok'])
self.assertEqual(res['error'], 'has_children')
def test_reorder_renumbers_per_parent(self):
# Add two substeps under op_a so reorder has something to swap.
s1 = self.env['fusion.plating.process.node'].create({
'name': 's1', 'node_type': 'step',
'parent_id': self.op_a.id, 'sequence': 10,
})
s2 = self.env['fusion.plating.process.node'].create({
'name': 's2', 'node_type': 'step',
'parent_id': self.op_a.id, 'sequence': 20,
})
from odoo.addons.fusion_plating.controllers.simple_recipe_controller \
import SimpleRecipeController
from unittest.mock import patch
class FakeReq:
env = self.env
path = ('odoo.addons.fusion_plating.controllers.'
'simple_recipe_controller.request')
# Send reversed order — s2 should come out at seq=10, s1 at 20.
with patch(path, FakeReq()):
SimpleRecipeController().step_reorder([s2.id, s1.id])
s1.invalidate_recordset()
s2.invalidate_recordset()
self.assertEqual(s2.sequence, 10)
self.assertEqual(s1.sequence, 20)
def test_deeply_nested_sub_processes_chain_path_labels(self):
# Three levels: recipe → Sub-Outer → Sub-Inner → Op-Deep
outer = self.env['fusion.plating.process.node'].create({
'name': 'Sub-Outer', 'node_type': 'sub_process',
'parent_id': self.recipe.id, 'sequence': 40,
})
inner = self.env['fusion.plating.process.node'].create({
'name': 'Sub-Inner', 'node_type': 'sub_process',
'parent_id': outer.id, 'sequence': 10,
})
op_deep = self.env['fusion.plating.process.node'].create({
'name': 'Op-Deep', 'node_type': 'operation',
'parent_id': inner.id, 'sequence': 10,
})
ops = self._flatten()
deep_paths = {n.name: p for n, p in ops if n.name == 'Op-Deep'}
# Path chains the parent labels with ' '
self.assertEqual(deep_paths['Op-Deep'], 'Sub-Outer Sub-Inner')

View File

@@ -5,7 +5,7 @@
{
"name": "Fusion Plating — MRP Bridge",
'version': '19.0.13.0.2',
'version': '19.0.13.0.3',
'category': 'Manufacturing/Plating',
'summary': 'Bridge Fusion Plating facilities, baths and tanks to Odoo MRP work orders.',
'description': """

View File

@@ -420,14 +420,16 @@ class SaleOrder(models.Model):
if recv_status == 'not_received':
so.x_fc_workflow_stage = 'awaiting_parts'
continue
if recv_status == 'partial' or recv_status == 'received':
so.x_fc_workflow_stage = 'inspecting'
if recv_status == 'partial':
so.x_fc_workflow_stage = 'awaiting_parts'
continue
if recv_status == 'inspected':
if recv_status == 'received':
# Sub 8: 'received' is the terminal receiving state.
# Inspection happens in the recipe's racking step, not
# in receiving.
if not so.x_fc_assigned_manager_id:
so.x_fc_workflow_stage = 'assign_work'
continue
# Manager assigned, MOs exist → in production
so.x_fc_workflow_stage = 'in_production'
continue
@@ -450,17 +452,23 @@ class SaleOrder(models.Model):
return True
def action_fp_accept_parts(self):
"""Mark receiving as accepted; this unlocks manager assignment."""
"""Mark receiving as accepted; this unlocks manager assignment.
Sub 8: receiving's terminal state is 'closed' (post-Sub-8) or
'accepted' (legacy). Either maps to SO status 'received'. The
old 'inspected' SO status no longer exists.
"""
self.ensure_one()
Recv = self.env.get('fp.receiving')
if Recv is None:
return False
for rec in Recv.search([('sale_order_id', '=', self.id)]):
if rec.state in ('draft', 'inspecting'):
if rec.state in ('draft', 'counted', 'staged'):
rec.state = 'closed'
elif rec.state == 'inspecting':
rec.state = 'accepted'
# flip SO receiving status to 'inspected' if possible
if 'x_fc_receiving_status' in self._fields:
self.x_fc_receiving_status = 'inspected'
self.x_fc_receiving_status = 'received'
self.message_post(body=_('Parts accepted — ready to assign manager.'))
return True

View File

@@ -95,10 +95,20 @@ class FpFair(models.Model):
}
def action_view_signed_document(self):
"""Open the signed PDF attachment in a new browser tab."""
"""Open the signed PDF in the fusion_pdf_preview dialog.
Falls back to a new-tab URL when the helper isn't installed.
See CLAUDE.md "PDF Preview" for the contract.
"""
self.ensure_one()
if not self.x_fc_signed_pdf_id:
return False
if hasattr(self.x_fc_signed_pdf_id, 'action_fusion_preview'):
return self.x_fc_signed_pdf_id.action_fusion_preview(
title=self.x_fc_signed_pdf_id.name or 'Signed FAIR',
model_name=self._name,
record_ids=self.id,
)
return {
'type': 'ir.actions.act_url',
'url': '/web/content/%s?download=true' % self.x_fc_signed_pdf_id.id,

View File

@@ -3,4 +3,10 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
# Note: `lib/` is NOT eagerly imported here — Python's relative-import
# machinery would otherwise re-enter this package mid-init when the
# wizard module does `from ..lib.fischerscope_parser import …`, raising
# "cannot import name X from partially initialized module" on Python
# 3.11+. lib is imported lazily where it's used (action_parse).
from . import models
from . import wizards

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Certificates',
'version': '19.0.6.1.0',
'version': '19.0.7.8.0',
'category': 'Manufacturing/Plating',
'summary': 'Certificate registry for CoC, thickness reports, and quality documents.',
'description': """
@@ -37,6 +37,8 @@ Includes Fischerscope thickness measurement data capture.
'views/fp_certificate_views.xml',
'views/res_partner_views.xml',
'views/fp_certificates_menu.xml',
'wizards/fp_cert_void_wizard_views.xml',
'wizards/fp_thickness_upload_wizard_views.xml',
],
'installable': True,
'application': False,

View File

@@ -0,0 +1,3 @@
# Parser libraries for fusion_plating_certificates.
# Pure-Python modules, no Odoo imports — safe to unit-test in isolation.
from . import fischerscope_parser # noqa: F401

View File

@@ -0,0 +1,337 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Fischerscope XDAL 600 thickness-report parser.
#
# Input: bytes of a .docx or .pdf file exported by the gauge.
# Output: dict with `readings` (list of per-reading dicts), `metadata`
# (single dict with equipment/calibration/operator info), and `image`
# (raw bytes of the embedded microscope image, when extractable).
#
# Pure-Python, no Odoo imports. Suitable for direct unit testing.
import io
import logging
import re
from datetime import datetime
_logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Regexes — derived from the real Fischerscope XDAL 600 export layout.
# Sample line:
# n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %
# Spaces vary; allow flexible whitespace + optional channel digit after NiP/Ni/P.
# ---------------------------------------------------------------------------
_READING_RE = re.compile(
r"""n\s*=\s*(?P<n>\d+) # reading number
\s+NiP\s*\d*\s*=\s* # NiP label (channel number optional)
(?P<nip>[\d.]+)\s*mils # NiP thickness in mils
\s+Ni\s*\d*\s*=\s* # Ni label
(?P<ni>[\d.]+)\s*% # Ni percentage
\s+P\s*\d*\s*=\s* # P label
(?P<p>[\d.]+)\s*% # P percentage
""",
re.VERBOSE,
)
# Equipment model — first non-blank line that contains "Fischerscope" or
# similar gauge identifier. Captures everything up to end of line.
_EQUIPMENT_RE = re.compile(
r'(Fischerscope[^\n\r]*)',
re.IGNORECASE,
)
# Product ref: "Product: 2805031 / NiP/Al-alloys 2805030"
_PRODUCT_RE = re.compile(
r'Product\s*:\s*([^\n\r]+?)(?:\s*$|\s*\n)',
re.IGNORECASE | re.MULTILINE,
)
# Calibration set: "Calibr. Std. Set NiP/Al STD SET SN 100174568"
_CALIBR_RE = re.compile(
r'Calibr\.?\s*Std\.?\s*Set\s*([^\n\r]+?)(?:\s*$|\s*\n)',
re.IGNORECASE | re.MULTILINE,
)
# Measuring time: "Measuring time 120 sec"
_MEAS_TIME_RE = re.compile(
r'Measuring\s*time\s*:?\s*(\d+)\s*sec',
re.IGNORECASE,
)
# Operator: "Operator: BK" (initials or short name)
# Stop the capture at: 2+ whitespace, a newline, end-of-string, 2+ digits,
# or end-of-line in multiline mode. The bare "Operator: BK\nDate: ..."
# case (operator name immediately followed by newline + next field) was
# the bug that fell through every other branch.
_OPERATOR_RE = re.compile(
r'Operator\s*:?\s*([A-Za-z][A-Za-z0-9 .\-]{0,40}?)(?=\s{2,}|\n|$|\s*\d{2,})',
re.IGNORECASE | re.MULTILINE,
)
# Date + Time: "Date: 5/15/2026 Time: 12:24:46 PM"
_DATETIME_RE = re.compile(
r'Date\s*:?\s*(\d{1,2}/\d{1,2}/\d{2,4})'
r'\s*Time\s*:?\s*(\d{1,2}:\d{2}(?::\d{2})?\s*(?:AM|PM)?)',
re.IGNORECASE,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def parse_fischerscope_file(filename, content_bytes):
"""Parse a Fischerscope thickness report.
Branches on file extension:
.docx → python-docx (paragraphs + inline_shapes for the image)
.pdf → PyPDF2 (text per page; image extraction best-effort)
Returns:
{
'success': bool, # True if at least one reading was parsed
'readings': [ # list of per-reading dicts
{'reading_number': int, 'nip_mils': float,
'ni_percent': float, 'p_percent': float},
...
],
'metadata': { # may have None values for missing keys
'equipment_model': str | None,
'product_ref': str | None,
'calibration_std_ref': str | None,
'measuring_time_seconds': int | None,
'operator_name': str | None,
'reading_datetime': datetime | None,
},
'image': bytes | None, # microscope image, if extractable
'image_mime': str | None, # image/jpeg, image/png, etc.
'raw_text': str, # extracted text (for debug / fallback)
'errors': [str], # non-fatal warnings encountered
}
Never raises on parse failure — returns success=False with readings=[].
Raises only on unrecoverable I/O (e.g. corrupted file bytes).
"""
name = (filename or '').lower()
if name.endswith('.docx'):
return _parse_docx(content_bytes)
if name.endswith('.pdf'):
return _parse_pdf(content_bytes)
if name.endswith('.doc'):
return _failed_result(
raw_text='',
error=(
'Legacy .doc format not supported — re-export from the '
'gauge as .docx or .pdf. (python-docx reads .docx only; '
'old binary .doc needs LibreOffice conversion which '
"isn't installed.)"
),
)
return _failed_result(
raw_text='',
error='Unsupported file extension: %r. Expected .docx or .pdf.' % filename,
)
# ---------------------------------------------------------------------------
# Internals
# ---------------------------------------------------------------------------
def _parse_docx(content_bytes):
"""Parse a .docx Fischerscope report."""
errors = []
try:
import docx # python-docx
except ImportError:
return _failed_result(
raw_text='',
error='python-docx not installed — cannot parse .docx files.',
)
try:
doc = docx.Document(io.BytesIO(content_bytes))
except Exception as e:
return _failed_result(raw_text='', error='Could not open .docx: %s' % e)
# Build the raw text by walking paragraphs AND tables. Fischerscope
# exports vary — sometimes the readings are in a table, sometimes
# in justified paragraphs. Joining everything gives the regex a
# stable target.
parts = []
for para in doc.paragraphs:
text = para.text
if text:
parts.append(text)
for tbl in doc.tables:
for row in tbl.rows:
row_text = ' '.join(cell.text for cell in row.cells)
if row_text.strip():
parts.append(row_text)
raw_text = '\n'.join(parts)
# Image: walk inline_shapes + image-parts; pick the first one. The
# Fischerscope export embeds exactly one microscope image per report.
image_bytes = None
image_mime = None
try:
for rel in doc.part.rels.values():
if 'image' in (rel.reltype or '').lower():
img_part = rel.target_part
image_bytes = img_part.blob
image_mime = img_part.content_type
break
except Exception as e:
errors.append('image extraction failed: %s' % e)
return _build_result(raw_text, errors, image_bytes, image_mime)
def _parse_pdf(content_bytes):
"""Parse a .pdf Fischerscope report. Text-based PDFs only."""
errors = []
try:
from PyPDF2 import PdfReader
except ImportError:
return _failed_result(
raw_text='',
error='PyPDF2 not installed — cannot parse .pdf files.',
)
try:
reader = PdfReader(io.BytesIO(content_bytes))
except Exception as e:
return _failed_result(raw_text='', error='Could not open PDF: %s' % e)
raw_text_parts = []
for i, page in enumerate(reader.pages):
try:
raw_text_parts.append(page.extract_text() or '')
except Exception as e:
errors.append('page %d extract_text failed: %s' % (i + 1, e))
raw_text = '\n'.join(raw_text_parts)
# PDF image extraction is unreliable across PDF producers. Best-
# effort: walk page resources looking for /XObject /Image entries.
# If anything fails, drop image silently — the operator still has
# the original file attached.
image_bytes = None
image_mime = None
try:
for page in reader.pages:
resources = page.get('/Resources')
if not resources:
continue
xobjects = resources.get('/XObject')
if not xobjects:
continue
x_resolved = xobjects.get_object() if hasattr(xobjects, 'get_object') else xobjects
for obj_name in x_resolved:
obj = x_resolved[obj_name]
obj = obj.get_object() if hasattr(obj, 'get_object') else obj
if obj.get('/Subtype') == '/Image':
image_bytes = obj.get_data()
f = obj.get('/Filter')
if f == '/DCTDecode':
image_mime = 'image/jpeg'
elif f == '/FlateDecode':
image_mime = 'image/png'
else:
image_mime = 'application/octet-stream'
break
if image_bytes:
break
except Exception as e:
errors.append('PDF image extraction failed: %s' % e)
image_bytes = None
return _build_result(raw_text, errors, image_bytes, image_mime)
def _build_result(raw_text, errors, image_bytes, image_mime):
"""Run the regex extractor over raw_text and assemble the result dict."""
readings = []
for m in _READING_RE.finditer(raw_text):
try:
readings.append({
'reading_number': int(m.group('n')),
'nip_mils': float(m.group('nip')),
'ni_percent': float(m.group('ni')),
'p_percent': float(m.group('p')),
})
except (ValueError, TypeError) as e:
errors.append('reading parse error at offset %d: %s' % (m.start(), e))
metadata = {
'equipment_model': _capture(_EQUIPMENT_RE, raw_text),
'product_ref': _capture(_PRODUCT_RE, raw_text),
'calibration_std_ref': _capture(_CALIBR_RE, raw_text),
'measuring_time_seconds': _capture_int(_MEAS_TIME_RE, raw_text),
'operator_name': _capture(_OPERATOR_RE, raw_text),
'reading_datetime': _capture_datetime(raw_text),
}
return {
'success': bool(readings),
'readings': readings,
'metadata': metadata,
'image': image_bytes,
'image_mime': image_mime,
'raw_text': raw_text,
'errors': errors,
}
def _failed_result(raw_text, error):
return {
'success': False,
'readings': [],
'metadata': {
'equipment_model': None,
'product_ref': None,
'calibration_std_ref': None,
'measuring_time_seconds': None,
'operator_name': None,
'reading_datetime': None,
},
'image': None,
'image_mime': None,
'raw_text': raw_text,
'errors': [error] if error else [],
}
def _capture(rx, text):
m = rx.search(text or '')
if not m:
return None
val = m.group(1).strip()
return val or None
def _capture_int(rx, text):
m = rx.search(text or '')
if not m:
return None
try:
return int(m.group(1))
except (ValueError, TypeError):
return None
def _capture_datetime(text):
m = _DATETIME_RE.search(text or '')
if not m:
return None
date_str, time_str = m.group(1).strip(), m.group(2).strip()
# Try a few likely formats; the gauge can emit either MM/DD/YYYY or
# M/D/YY plus 12h or 24h.
for date_fmt in ('%m/%d/%Y', '%m/%d/%y', '%d/%m/%Y', '%d/%m/%y'):
for time_fmt in ('%I:%M:%S %p', '%I:%M %p', '%H:%M:%S', '%H:%M'):
try:
return datetime.strptime('%s %s' % (date_str, time_str),
'%s %s' % (date_fmt, time_fmt))
except ValueError:
continue
return None

View File

@@ -88,6 +88,99 @@ class FpCertificate(models.Model):
'fp.thickness.reading', 'certificate_id', string='Thickness Readings',
)
# ----- Inline Fischerscope PDF upload (cert-local) ----------------------
# The merge pipeline normally pulls the Fischerscope/XDAL PDF from the
# linked QC check. That works when the operator uploaded it via the
# tablet, but managers issuing certs after the fact don't want to
# navigate to the QC. This pair of fields gives them a direct upload
# path on the cert form. When set, _fp_merge_thickness_into_pdf uses
# this in preference to the QC-side upload.
x_fc_local_thickness_pdf = fields.Binary(
string='Fischerscope PDF (Upload Here)',
attachment=True,
help='Drop the Fischerscope / XDAL 600 XRF export PDF here. '
'When the cert is issued it will be appended as page 2 of '
'the CoC. Overrides any PDF on the linked QC check.',
)
x_fc_local_thickness_pdf_filename = fields.Char(
string='Fischerscope PDF filename',
)
# Non-PDF Fischerscope uploads (.doc / .docx / .xlsx / images) — the
# Issue Certs wizard stashes them here so the thickness-required gate
# can still pass. Unlike `x_fc_local_thickness_pdf`, this attachment
# is NOT merged into the CoC PDF as page 2 (we can't rasterize .doc
# server-side without LibreOffice). It rides along as a separate
# evidence attachment on the cert and on any email/portal delivery.
x_fc_local_thickness_evidence_id = fields.Many2one(
'ir.attachment',
string='Fischerscope Evidence (non-PDF)',
copy=False,
help='Original Fischerscope/XRF upload when not a PDF. Counts '
'as valid thickness evidence for the cert-issue gate but '
'is delivered as a separate attachment, not merged into '
'the CoC PDF.',
)
# Report-level Fischerscope metadata — populated by the Issue Certs
# wizard when parsing an RTF/.docx upload. Rendered on the CoC so
# the printed cert shows the same context an auditor would see on
# the original XDAL 600 export (equipment, operator, calibration,
# product/application, measuring time, date/time). Per-reading
# values (mils, Ni%, P%) live on fp.thickness.reading.
x_fc_thickness_equipment = fields.Char(
string='Thickness Equipment',
help='XRF/thickness gauge model (e.g. "Fischerscope XDAL 600").',
)
x_fc_thickness_operator = fields.Char(
string='Thickness Operator',
help='Operator initials/name as recorded by the gauge.',
)
x_fc_thickness_datetime = fields.Datetime(
string='Thickness Reading Date/Time',
help='When the readings were taken on the gauge.',
)
x_fc_thickness_product = fields.Char(
string='Thickness Product Profile',
help='XDAL 600 product line + part-family reference '
'(e.g. "2805031 / NiP/Al-alloys 2805030").',
)
x_fc_thickness_application = fields.Char(
string='Thickness Application',
help='XDAL 600 application profile '
'(e.g. "16 / NiP/Al-alloys").',
)
x_fc_thickness_directory = fields.Char(
string='Thickness Directory',
help='XDAL 600 directory the measurements were saved into.',
)
x_fc_thickness_measuring_time_sec = fields.Integer(
string='Thickness Measuring Time (sec)',
help='Per-reading measuring time configured on the gauge.',
)
x_fc_thickness_source_filename = fields.Char(
string='Thickness Source File',
help='Filename of the Fischerscope upload the readings were '
'parsed from.',
)
# Two paths populate this field, with operator upload winning:
# 1. RTF auto-extraction — Issue Certs wizard runs libwmf
# (wmf2svg) on the embedded WMF blocks and picks the
# largest raster (header banners filtered by area threshold).
# 2. Manual PNG/JPEG upload via the wizard's "Measurement
# Image" field — operator override path when the
# auto-extracted image is wrong, missing, or low-quality.
# See _apply_to_cert and _apply_image_to_cert in the wizard.
x_fc_thickness_image_id = fields.Many2one(
'ir.attachment',
string='Thickness Microscope Image',
copy=False,
help='Microscope photo of the measurement site. Auto-extracted '
'from the Fischerscope RTF export when libwmf can parse '
'the embedded WMF; operator can also upload a PNG/JPEG '
'directly via the Issue Certs wizard to override.',
)
# ---- Material traceability (T2.3) ----
batch_ids = fields.Many2many(
'fusion.plating.batch', compute='_compute_batch_ids',
@@ -330,6 +423,29 @@ class FpCertificate(models.Model):
for rec in self:
if rec.state != 'draft':
raise UserError(_('Only draft certificates can be issued.'))
# Lazy-fill from partner defaults BEFORE running the gates.
# Without this, a cert created before partner.x_fc_default_*
# was configured would still trip the gate even after sales
# set the default. Robust-by-construction: the defaults take
# effect retroactively at issue time.
if (not rec.contact_partner_id
and rec.partner_id
and 'x_fc_default_coc_contact_id' in rec.partner_id._fields
and rec.partner_id.x_fc_default_coc_contact_id):
rec.contact_partner_id = (
rec.partner_id.x_fc_default_coc_contact_id
)
# Guard with field-existence check — fp.certificate doesn't
# declare company_id directly; production picks it up from
# auto-creation context but tests can build a cert without
# one. Without the guard, AttributeError on the .company_id
# access bubbles up as a test error.
if (not rec.certified_by_id
and 'company_id' in rec._fields
and rec.company_id
and 'x_fc_owner_user_id' in rec.company_id._fields
and rec.company_id.x_fc_owner_user_id):
rec.certified_by_id = rec.company_id.x_fc_owner_user_id
# Spec reference is what the cert ATTESTS — without it the
# cert is just a piece of paper. AS9100 / Nadcap require
# naming the spec the work was performed to.
@@ -340,24 +456,131 @@ class FpCertificate(models.Model):
'(e.g. "AMS 2404", "MIL-C-26074") so the cert '
'states which standard the work meets.'
) % {'name': rec.name or rec.display_name})
# Aerospace / Nadcap customers: actual thickness readings
# must be on file BEFORE the cert is issued. The flag lives
# on the partner so commercial customers aren't blocked.
if (rec.partner_id
and 'x_fc_strict_thickness_required' in rec.partner_id._fields
and rec.partner_id.x_fc_strict_thickness_required
and rec.certificate_type == 'coc'):
if not rec.thickness_reading_ids:
# Process description (what was done to the parts). Without
# it the cert PDF just shows blank process text — customer
# has no idea what they paid for. Auto-filled from the
# recipe at create time; manager can override before issuing.
if not rec.process_description:
raise UserError(_(
'Cannot issue certificate "%(name)s" — Process '
'Description is blank.\n\nFill it manually (e.g. '
'"ELECTROLESS NICKEL PLATING PER AMS 2404") or '
'assign a recipe to the job so it auto-fills.'
) % {'name': rec.name or rec.display_name})
# Signing authority — the human who attests the work. Auto-
# filled from per-spec signer_user_id, falling back to
# company.x_fc_owner_user_id. If neither is configured, the
# manager must pick before issuing.
if not rec.certified_by_id:
raise UserError(_(
'Cannot issue certificate "%(name)s" — Certified By '
'is not set.\n\nPick the signing authority, or have '
'an admin configure the company\'s Certificate Owner '
'(Settings > Fusion Plating).'
) % {'name': rec.name or rec.display_name})
# Customer contact — the named recipient printed on the
# cert and emailed when it ships. Auto-filled from
# partner.x_fc_default_coc_contact_id when set.
if not rec.contact_partner_id:
raise UserError(_(
'Cannot issue certificate "%(name)s" — Customer '
'Contact is not set.\n\nPick the recipient contact, '
'or configure a Default CoC Contact on customer '
'"%(cust)s".'
) % {
'name': rec.name or rec.display_name,
'cust': rec.partner_id.name if rec.partner_id else '?',
})
if not (rec.contact_partner_id.email or '').strip():
raise UserError(_(
'Cannot issue certificate "%(name)s" — contact '
'"%(c)s" has no email address.\n\nAdd an email '
'to the contact before issuing (the cert is sent '
'by email post-issue).'
) % {
'name': rec.name or rec.display_name,
'c': rec.contact_partner_id.name,
})
# Thickness data requirement — unified gate covering both
# cert types. A customer needs thickness data on the cert
# when ANY of these is true:
# 1. cert type is thickness_report (the cert IS the data)
# 2. partner.x_fc_strict_thickness_required (aerospace /
# Nadcap — always strict)
# 3. partner.x_fc_send_thickness_report (the bundling
# rule — CoC carries thickness as page 2 by default
# for these customers; see CLAUDE.md "CoC + thickness
# = ONE cert (page 2 merge)")
# Acceptable data: logged readings on the cert OR a
# Fischerscope PDF on the linked QC OR a cert-local
# Fischerscope upload. Any one is enough.
partner = rec.partner_id
needs_thickness = (
rec.certificate_type == 'thickness_report'
or (rec.certificate_type == 'coc' and partner and (
('x_fc_strict_thickness_required' in partner._fields
and partner.x_fc_strict_thickness_required)
or ('x_fc_send_thickness_report' in partner._fields
and partner.x_fc_send_thickness_report)
))
)
if needs_thickness:
has_readings = bool(rec.thickness_reading_ids)
has_qc_fischer_pdf = bool(
rec.x_fc_thickness_pdf_id
if 'x_fc_thickness_pdf_id' in rec._fields else False
)
has_local_pdf = bool(rec.x_fc_local_thickness_pdf)
has_local_evidence = bool(
rec.x_fc_local_thickness_evidence_id
)
if not (has_readings or has_qc_fischer_pdf
or has_local_pdf or has_local_evidence):
type_label = (
_('Thickness Report')
if rec.certificate_type == 'thickness_report'
else _('CoC')
)
raise UserError(_(
'Cannot issue CoC "%(name)s" — customer "%(cust)s" '
'requires actual thickness readings on every CoC '
'(Nadcap / aerospace).\n\nLog Fischerscope readings '
'against the job for SO %(so)s via the Tablet Station '
'before issuing.'
'Cannot issue %(type)s "%(name)s" — customer '
'"%(cust)s" requires thickness data on every '
'%(type)s. No readings, no Fischerscope PDF on '
'the linked QC, and no local Fischerscope upload '
'on this cert.\n\nUse the Issue Certs wizard '
'from the work order to upload the Fischerscope '
'report, or log readings against the job for '
'SO %(so)s via the Tablet Station.'
) % {
'type': type_label,
'name': rec.name or rec.display_name,
'cust': partner.name if partner else '?',
'so': rec.sale_order_id.name if rec.sale_order_id else '?',
})
# Defensive qty reconciliation — should already be guaranteed
# by fp.job.button_mark_done's gate, but re-checked here so
# certs created outside the job flow (manual, scripts) still
# can't issue with a mismatched job. No bypass — qty integrity
# is non-negotiable at issue.
job = (rec.x_fc_job_id
if 'x_fc_job_id' in rec._fields else False)
if job and job.qty_received:
rejects = job.qty_visual_inspection_rejects or 0
accounted = (
(job.qty_done or 0)
+ (job.qty_scrapped or 0)
+ rejects
)
if abs(job.qty_received - accounted) > 0.0001:
raise UserError(_(
'Cannot issue certificate "%(name)s" — job '
'%(job)s qty mismatch (received %(r)g vs '
'accounted-out %(a)g). Reconcile job '
'quantities before issuing.'
) % {
'name': rec.name or rec.display_name,
'cust': rec.partner_id.name,
'so': rec.sale_order_id.name if rec.sale_order_id else '?',
'job': job.name,
'r': job.qty_received,
'a': accounted,
})
rec.state = 'issued'
# Generate the CoC PDF and attach it so action_send_to_customer
@@ -371,8 +594,41 @@ class FpCertificate(models.Model):
_logger.warning(
'Cert %s: PDF render failed: %s', rec.name, e,
)
# Back-fill the CoC attachment onto the linked delivery
# if one exists already. Job._fp_create_delivery handles
# the create-time case (cert issued before delivery
# spawned); this handles the inverse (delivery spawned
# first, cert issued later). Best-effort.
try:
rec._fp_sync_coc_to_delivery()
except Exception as e:
_logger.warning(
'Cert %s: CoC->delivery sync failed: %s',
rec.name, e,
)
rec.message_post(body=_('Certificate issued.'))
def _fp_sync_coc_to_delivery(self):
"""Push this CoC's attachment onto its job's delivery so the
shipping crew sees the CoC ready to print without hunting for
the cert. Only acts on `coc` certs with an attachment_id;
delivery field must exist and be empty (don't overwrite an
operator's manual choice).
"""
self.ensure_one()
if self.certificate_type != 'coc' or not self.attachment_id:
return
job = self.x_fc_job_id if 'x_fc_job_id' in self._fields else False
if not job or not job.delivery_id:
return
delivery = job.delivery_id.sudo()
if 'coc_attachment_id' not in delivery._fields:
return
if delivery.coc_attachment_id:
# Operator already picked one; don't overwrite.
return
delivery.coc_attachment_id = self.attachment_id.id
def _fp_render_and_attach_pdf(self):
"""Render the CoC PDF via the bound report action, OPTIONALLY
merge the Fischerscope thickness report PDF (uploaded by the
@@ -445,35 +701,48 @@ class FpCertificate(models.Model):
self.ensure_one()
if self.certificate_type != 'coc':
return None
# Find the linked job. fp.certificate has either x_fc_job_id
# (preferred — added by fusion_plating_jobs) or job_id (older).
job = False
if 'x_fc_job_id' in self._fields:
job = self.x_fc_job_id
if not job and 'job_id' in self._fields:
job = self.job_id
if not job:
return None
# Find a passed QC on this job with an uploaded Fischerscope PDF.
# Prefer state=passed; fall through to any with a PDF.
QC = self.env.get('fusion.plating.quality.check')
if QC is None:
return None
qc = QC.sudo().search([
('job_id', '=', job.id),
('state', '=', 'passed'),
('thickness_report_pdf_id', '!=', False),
], order='completed_at desc', limit=1)
if not qc:
# Resolution order for the source of the Fischerscope bytes:
# 1. Cert-local upload (x_fc_local_thickness_pdf) — manager
# dropped it directly on the cert form
# 2. Linked QC's thickness_report_pdf_id — operator uploaded
# via the tablet during inspection
# Either path yields the same merged-PDF outcome.
fischer_bytes = b''
qc = False
if self.x_fc_local_thickness_pdf:
try:
fischer_bytes = _b64.b64decode(
self.x_fc_local_thickness_pdf or b''
)
except Exception:
fischer_bytes = b''
if not fischer_bytes:
# Fall through to the QC-side PDF.
job = False
if 'x_fc_job_id' in self._fields:
job = self.x_fc_job_id
if not job and 'job_id' in self._fields:
job = self.job_id
if not job:
return None
QC = self.env.get('fusion.plating.quality.check')
if QC is None:
return None
qc = QC.sudo().search([
('job_id', '=', job.id),
('state', '=', 'passed'),
('thickness_report_pdf_id', '!=', False),
], order='create_date desc', limit=1)
if not qc or not qc.thickness_report_pdf_id:
return None
fischer_bytes = _b64.b64decode(
qc.thickness_report_pdf_id.datas or b''
)
], order='completed_at desc', limit=1)
if not qc:
qc = QC.sudo().search([
('job_id', '=', job.id),
('thickness_report_pdf_id', '!=', False),
], order='create_date desc', limit=1)
if not qc or not qc.thickness_report_pdf_id:
return None
fischer_bytes = _b64.b64decode(
qc.thickness_report_pdf_id.datas or b''
)
if not fischer_bytes:
return None
# Merge — pypdf is the modern name; PyPDF2 still works on older
@@ -519,11 +788,41 @@ class FpCertificate(models.Model):
'CoC-only.', self.name,
)
return None
source = (
_('cert upload') if self.x_fc_local_thickness_pdf
else _('QC %s') % (qc.name if qc else '?')
)
self.message_post(body=_(
'Fischerscope thickness report from QC %s appended to CoC PDF.'
) % qc.name)
'Fischerscope thickness report (%s) appended to CoC PDF.'
) % source)
return merged
def action_reset_to_draft(self):
"""Move an issued/voided cert back to draft so the manager can
correct typos in the thickness metadata, swap the microscope
image, re-pick the void reason, etc. — then re-Issue.
Wipes the existing `attachment_id` so the next render picks up
whatever was changed. The original PDF stays around as a
regular ir.attachment on the cert (for audit) since we only
clear the FK, not the attachment record itself. Re-issue
creates a fresh PDF.
"""
for rec in self:
if rec.state == 'draft':
raise UserError(_(
'Certificate %s is already a draft.'
) % rec.name)
rec.state = 'draft'
old_att = rec.attachment_id
if old_att:
rec.attachment_id = False
rec.message_post(body=_(
'Reset to draft for edits. The previously-issued PDF '
'%s remains attached for audit; a fresh PDF will be '
'generated on re-issue.'
) % (old_att.name if old_att else '(none)'))
def action_void(self):
for rec in self:
if rec.state != 'issued':
@@ -533,6 +832,33 @@ class FpCertificate(models.Model):
rec.state = 'voided'
rec.message_post(body=_('Certificate voided. Reason: %s') % rec.void_reason)
def action_open_void_wizard(self):
"""Open the void-reason wizard. Bound to the Void header button
instead of action_void directly so the manager always supplies a
written reason (the underlying action_void still blocks on a
blank reason as a defensive last-line check)."""
self.ensure_one()
if self.state != 'issued':
raise UserError(_(
'Only issued certificates can be voided '
'(current state: %s).'
) % self.state)
Wizard = self.env.get('fp.cert.void.wizard')
if Wizard is None:
raise UserError(_(
'Void wizard not available. Reinstall '
'fusion_plating_certificates.'
))
wiz = Wizard.create({'cert_id': self.id})
return {
'type': 'ir.actions.act_window',
'name': _('Void %s') % self.name,
'res_model': Wizard._name,
'res_id': wiz.id,
'view_mode': 'form',
'target': 'new',
}
def action_view_traceability(self):
"""Show the batches (and their chemistry logs) that produced
these parts — auditor's dream, customer's RMA friend."""

View File

@@ -98,3 +98,18 @@ class ResPartner(models.Model):
'AS9100/ISO 9001 boilerplate. Useful for aerospace customers '
'who require specific NIST or DFARS language.',
)
# ---- Default CoC contact (cert addressee + email recipient) ----------
# The single named contact printed on the CoC and used as the email
# default when the cert ships. Sales sets it once per customer.
# Falls back to manual selection at action_issue time if blank.
x_fc_default_coc_contact_id = fields.Many2one(
'res.partner',
string='Default CoC Contact',
domain="[('parent_id', '=', id), ('is_company', '=', False)]",
tracking=True,
help='Default contact the Certificate of Conformance is addressed '
'to and emailed to. Pre-fills cert.contact_partner_id when a '
'job ships. Leave blank to force the manager to pick at '
'issue time. Must be a child contact of this company.',
)

View File

@@ -5,3 +5,9 @@ access_fp_certificate_manager,fp.certificate.manager,model_fp_certificate,fusion
access_fp_thickness_reading_operator,fp.thickness.reading.operator,model_fp_thickness_reading,fusion_plating.group_fusion_plating_operator,1,0,0,0
access_fp_thickness_reading_supervisor,fp.thickness.reading.supervisor,model_fp_thickness_reading,fusion_plating.group_fusion_plating_supervisor,1,1,1,0
access_fp_thickness_reading_manager,fp.thickness.reading.manager,model_fp_thickness_reading,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_cert_void_wiz_sup,fp.cert.void.wiz.supervisor,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_cert_void_wiz_mgr,fp.cert.void.wiz.manager,model_fp_cert_void_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_thickness_upload_wiz_sup,fp.thickness.upload.wiz.supervisor,model_fp_thickness_upload_wizard,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_thickness_upload_wiz_mgr,fp.thickness.upload.wiz.manager,model_fp_thickness_upload_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_thickness_upload_wiz_line_sup,fp.thickness.upload.wiz.line.supervisor,model_fp_thickness_upload_wizard_line,fusion_plating.group_fusion_plating_supervisor,1,1,1,1
access_fp_thickness_upload_wiz_line_mgr,fp.thickness.upload.wiz.line.manager,model_fp_thickness_upload_wizard_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_fp_thickness_reading_operator fp.thickness.reading.operator model_fp_thickness_reading fusion_plating.group_fusion_plating_operator 1 0 0 0
6 access_fp_thickness_reading_supervisor fp.thickness.reading.supervisor model_fp_thickness_reading fusion_plating.group_fusion_plating_supervisor 1 1 1 0
7 access_fp_thickness_reading_manager fp.thickness.reading.manager model_fp_thickness_reading fusion_plating.group_fusion_plating_manager 1 1 1 1
8 access_fp_cert_void_wiz_sup fp.cert.void.wiz.supervisor model_fp_cert_void_wizard fusion_plating.group_fusion_plating_supervisor 1 1 1 1
9 access_fp_cert_void_wiz_mgr fp.cert.void.wiz.manager model_fp_cert_void_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
10 access_fp_thickness_upload_wiz_sup fp.thickness.upload.wiz.supervisor model_fp_thickness_upload_wizard fusion_plating.group_fusion_plating_supervisor 1 1 1 1
11 access_fp_thickness_upload_wiz_mgr fp.thickness.upload.wiz.manager model_fp_thickness_upload_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
12 access_fp_thickness_upload_wiz_line_sup fp.thickness.upload.wiz.line.supervisor model_fp_thickness_upload_wizard_line fusion_plating.group_fusion_plating_supervisor 1 1 1 1
13 access_fp_thickness_upload_wiz_line_mgr fp.thickness.upload.wiz.line.manager model_fp_thickness_upload_wizard_line fusion_plating.group_fusion_plating_manager 1 1 1 1

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import test_action_issue_gates
from . import test_fischerscope_parser
from . import test_thickness_upload_wizard

View File

@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Issuance-gate tests for fp.certificate.action_issue.
Covers the 2026-05-18 hardening that adds blocking checks for
process_description, certified_by_id, contact_partner_id (with email),
and qty reconciliation. See
docs/superpowers/specs/2026-05-18-cert-creation-and-data-gates-design.md.
"""
from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase
class TestActionIssueGates(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.signer = cls.env['res.users'].create({
'name': 'Signer',
'login': 'signer_certissue',
'email': 'signer@example.com',
})
cls.contact_with_email = cls.env['res.partner'].create({
'name': 'Anne Recipient',
'email': 'anne@cust.example',
})
cls.contact_no_email = cls.env['res.partner'].create({
'name': 'Carl NoEmail',
})
cls.partner = cls.env['res.partner'].create({
'name': 'IssueCust',
'is_company': True,
# Default for x_fc_send_thickness_report is True, which would
# add a thickness-data gate to every issue test in this class.
# These tests are scoped to the OTHER gates (spec_ref,
# process_description, certified_by, contact). Turn off the
# thickness flag so we're testing one gate at a time.
'x_fc_send_thickness_report': False,
})
cls.contact_with_email.parent_id = cls.partner.id
cls.contact_no_email.parent_id = cls.partner.id
def _make_cert(self, **kw):
vals = {
'partner_id': self.partner.id,
'certificate_type': 'coc',
'state': 'draft',
'spec_reference': 'AMS 2404',
'process_description': 'ELECTROLESS NICKEL PER AMS 2404',
'certified_by_id': self.signer.id,
'contact_partner_id': self.contact_with_email.id,
}
vals.update(kw)
return self.env['fp.certificate'].create(vals)
# ---- the existing gate still works (spec_reference) ----
def test_blocks_on_missing_spec_reference(self):
cert = self._make_cert(spec_reference=False)
with self.assertRaises(UserError) as exc:
cert.action_issue()
self.assertIn('Spec Reference', str(exc.exception))
# ---- new gate: process_description ----
def test_blocks_on_missing_process_description(self):
cert = self._make_cert(process_description=False)
with self.assertRaises(UserError) as exc:
cert.action_issue()
self.assertIn('Process Description', str(exc.exception))
# ---- new gate: certified_by_id ----
def test_blocks_on_missing_certified_by(self):
cert = self._make_cert(certified_by_id=False)
with self.assertRaises(UserError) as exc:
cert.action_issue()
self.assertIn('Certified By', str(exc.exception))
# ---- new gate: contact_partner_id ----
def test_blocks_on_missing_contact(self):
cert = self._make_cert(contact_partner_id=False)
with self.assertRaises(UserError) as exc:
cert.action_issue()
self.assertIn('Customer Contact', str(exc.exception))
def test_blocks_on_contact_without_email(self):
cert = self._make_cert(contact_partner_id=self.contact_no_email.id)
with self.assertRaises(UserError) as exc:
cert.action_issue()
self.assertIn('no email', str(exc.exception))
# ---- happy path ----
def test_passes_when_all_data_present(self):
cert = self._make_cert()
cert.action_issue()
self.assertEqual(cert.state, 'issued')
# ---- order: spec_reference still wins (cheapest first) ----
def test_gate_order_spec_reference_first(self):
# Multiple missing → spec_reference message surfaces first.
cert = self._make_cert(
spec_reference=False,
process_description=False,
certified_by_id=False,
contact_partner_id=False,
)
with self.assertRaises(UserError) as exc:
cert.action_issue()
self.assertIn('Spec Reference', str(exc.exception))
# And NOT the process_description message (gate hit first).
self.assertNotIn('Process Description', str(exc.exception))
# ---- new gate: thickness_report cert needs thickness data ----
def test_blocks_thickness_report_with_no_data(self):
"""A thickness_report cert with zero readings and no Fischerscope
PDF is empty paper — must block at issue."""
cert = self._make_cert(certificate_type='thickness_report')
with self.assertRaises(UserError) as exc:
cert.action_issue()
self.assertIn('thickness data', str(exc.exception).lower())
def test_thickness_report_passes_with_readings(self):
cert = self._make_cert(certificate_type='thickness_report')
self.env['fp.thickness.reading'].create({
'certificate_id': cert.id,
'nip_mils': 0.4,
})
cert.action_issue()
self.assertEqual(cert.state, 'issued')
def test_coc_does_not_require_thickness_data_by_default(self):
"""Commercial CoC (no strict_thickness flag) should still pass
even without readings — only thickness_report type is gated."""
cert = self._make_cert(certificate_type='coc')
cert.action_issue()
self.assertEqual(cert.state, 'issued')

View File

@@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Unit tests for the Fischerscope thickness-report parser.
# Pure-Python tests — no Odoo DB needed. Builds synthetic .docx files
# matching the real XDAL 600 export layout and verifies extraction.
import io
from datetime import datetime
from odoo.tests.common import TransactionCase
# Lazy import inside methods to avoid the circular-import trap that
# fires during test-module discovery (the package __init__ pulls in
# `lib`; if tests/__init__ also resolves `..lib` at top-level, Python
# sees a partially-initialised parent package).
class TestFischerscopeParser(TransactionCase):
"""Round-trip tests against the parser. We build a known-shape .docx
in memory, parse it back, and assert the structure matches what the
real Fischerscope XDAL 600 produces (see screenshot 2026-05-19)."""
@classmethod
def setUpClass(cls):
super().setUpClass()
try:
import docx # python-docx — required for tests
cls.docx = docx
except ImportError:
cls.docx = None
# Resolve the parser by absolute path at first use — relative
# `from ..lib import` at module top trips the test loader's
# partially-initialised-package check.
from odoo.addons.fusion_plating_certificates.lib import (
fischerscope_parser as _fp,
)
cls.fischerscope_parser = _fp
def _make_sample_docx(self, with_image=False):
"""Build a .docx that matches the screenshot layout."""
if not self.docx:
self.skipTest('python-docx not available')
doc = self.docx.Document()
doc.add_paragraph('Fischerscope® XDAL 600')
doc.add_paragraph('Product: 2805031 / NiP/Al-alloys 2805030')
doc.add_paragraph('Directory: NiP products for flat samples')
doc.add_paragraph('Application: 16 / NiP/Al-alloys')
doc.add_paragraph('')
doc.add_paragraph('Calibr. Std. Set NiP/Al STD SET SN 100174568')
doc.add_paragraph('n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %')
doc.add_paragraph('n= 2 NiP 1= 0.5049 mils Ni 1 = 93.179 % P 1 = 6.8209 %')
doc.add_paragraph('n= 3 NiP 1= 0.5134 mils Ni 1 = 92.273 % P 1 = 7.7266 %')
doc.add_paragraph('')
doc.add_paragraph(' NiP 1 mils Ni 1 % P 1 %')
doc.add_paragraph('Mean 0.5689 92.258 7.7415')
doc.add_paragraph('Standard Deviation 0.1037 0.9282 0.9282')
doc.add_paragraph('CoV (%) 18.22 1.01 11.99')
doc.add_paragraph('Range 0.1836 1.8562 1.8562')
doc.add_paragraph('Number of readings 3 3 3')
doc.add_paragraph('Measuring time 120 sec')
doc.add_paragraph('Operator: BK 4755 1')
doc.add_paragraph('Date: 5/15/2026 Time: 12:24:46 PM')
if with_image:
# Embed a tiny valid 1x1 PNG so the image-extraction path
# is exercised. Bytes from
# https://github.com/mathiasbynens/small/blob/master/png-transparent.png
png = bytes.fromhex(
'89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4'
'890000000a49444154789c63000100000500010d0a2db40000000049454e44ae'
'426082'
)
img_buf = io.BytesIO(png)
doc.add_picture(img_buf)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
# ---- happy path: full Fischerscope export ----------------------------
def test_parse_extracts_three_readings(self):
result = self.fischerscope_parser.parse_fischerscope_file(
'sample.docx', self._make_sample_docx(),
)
self.assertTrue(result['success'])
self.assertEqual(len(result['readings']), 3)
self.assertEqual(result['readings'][0], {
'reading_number': 1,
'nip_mils': 0.6885,
'ni_percent': 91.323,
'p_percent': 8.6771,
})
self.assertEqual(result['readings'][2]['reading_number'], 3)
def test_parse_extracts_metadata(self):
result = self.fischerscope_parser.parse_fischerscope_file(
'sample.docx', self._make_sample_docx(),
)
meta = result['metadata']
self.assertIn('Fischerscope', (meta.get('equipment_model') or ''))
self.assertIn('XDAL 600', (meta.get('equipment_model') or ''))
self.assertEqual(meta.get('product_ref'),
'2805031 / NiP/Al-alloys 2805030')
self.assertEqual(meta.get('calibration_std_ref'),
'NiP/Al STD SET SN 100174568')
self.assertEqual(meta.get('measuring_time_seconds'), 120)
self.assertEqual(meta.get('operator_name'), 'BK')
self.assertEqual(meta.get('reading_datetime'),
datetime(2026, 5, 15, 12, 24, 46))
def test_parse_extracts_image_when_present(self):
result = self.fischerscope_parser.parse_fischerscope_file(
'sample.docx', self._make_sample_docx(with_image=True),
)
self.assertIsNotNone(result['image'])
self.assertGreater(len(result['image']), 50)
# python-docx writes the relationship type to image; mime is content_type.
self.assertTrue((result.get('image_mime') or '').startswith('image/'))
def test_parse_handles_no_image(self):
result = self.fischerscope_parser.parse_fischerscope_file(
'sample.docx', self._make_sample_docx(with_image=False),
)
self.assertIsNone(result['image'])
# ---- fallback / error paths -----------------------------------------
def test_parse_unknown_extension(self):
result = self.fischerscope_parser.parse_fischerscope_file(
'sample.csv', b'irrelevant',
)
self.assertFalse(result['success'])
self.assertEqual(result['readings'], [])
self.assertTrue(result['errors'])
self.assertIn('Unsupported', result['errors'][0])
def test_parse_legacy_doc_extension(self):
result = self.fischerscope_parser.parse_fischerscope_file(
'sample.doc', b'%PDF',
)
self.assertFalse(result['success'])
self.assertIn('.doc', result['errors'][0])
def test_parse_corrupt_docx(self):
result = self.fischerscope_parser.parse_fischerscope_file(
'sample.docx', b'not a real docx file',
)
self.assertFalse(result['success'])
self.assertEqual(result['readings'], [])
self.assertTrue(result['errors'])
def test_parse_empty_docx_no_readings(self):
if not self.docx:
self.skipTest('python-docx not available')
doc = self.docx.Document()
doc.add_paragraph('Just a blank report')
buf = io.BytesIO()
doc.save(buf)
result = self.fischerscope_parser.parse_fischerscope_file(
'blank.docx', buf.getvalue(),
)
self.assertFalse(result['success'])
self.assertEqual(result['readings'], [])
# raw_text should still be populated for debug
self.assertIn('blank report', result['raw_text'])
# ---- robustness: variation in spacing / channel digits --------------
def test_parse_tolerates_whitespace_variation(self):
if not self.docx:
self.skipTest('python-docx not available')
doc = self.docx.Document()
doc.add_paragraph('Calibr. Std. Set TESTSTD SN 999')
# Tighter spacing, no channel digit (some exports omit "1")
doc.add_paragraph('n=1 NiP= 0.50 mils Ni = 92.0 % P = 8.0 %')
# Looser spacing, channel digit "1"
doc.add_paragraph('n = 2 NiP 1 = 0.55 mils Ni 1 = 91.5 % P 1 = 8.5 %')
buf = io.BytesIO()
doc.save(buf)
result = self.fischerscope_parser.parse_fischerscope_file(
'variant.docx', buf.getvalue(),
)
self.assertTrue(result['success'])
self.assertEqual(len(result['readings']), 2)
self.assertAlmostEqual(result['readings'][0]['nip_mils'], 0.50)
self.assertAlmostEqual(result['readings'][1]['nip_mils'], 0.55)

View File

@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# End-to-end tests for the thickness-upload wizard.
import base64
import io
from odoo.exceptions import UserError
from odoo.tests.common import TransactionCase
class TestThicknessUploadWizard(TransactionCase):
"""Walk the wizard from upload → parse → save and verify the side
effects on the certificate."""
@classmethod
def setUpClass(cls):
super().setUpClass()
try:
import docx
cls.docx = docx
except ImportError:
cls.docx = None
cls.partner = cls.env['res.partner'].create({
'name': 'WizardCust',
'email': 'wizardcust@example.com',
})
cls.cert = cls.env['fp.certificate'].create({
'partner_id': cls.partner.id,
'certificate_type': 'coc',
'state': 'draft',
})
def _sample_docx_bytes(self):
if not self.docx:
self.skipTest('python-docx not available')
doc = self.docx.Document()
doc.add_paragraph('Fischerscope® XDAL 600')
doc.add_paragraph('Product: 2805031 / NiP/Al-alloys 2805030')
doc.add_paragraph('Calibr. Std. Set NiP/Al STD SET SN 100174568')
doc.add_paragraph('n= 1 NiP 1= 0.6885 mils Ni 1 = 91.323 % P 1 = 8.6771 %')
doc.add_paragraph('n= 2 NiP 1= 0.5049 mils Ni 1 = 93.179 % P 1 = 6.8209 %')
doc.add_paragraph('n= 3 NiP 1= 0.5134 mils Ni 1 = 92.273 % P 1 = 7.7266 %')
doc.add_paragraph('Measuring time 120 sec')
doc.add_paragraph('Operator: BK')
doc.add_paragraph('Date: 5/15/2026 Time: 12:24:46 PM')
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
def _make_wizard(self, file_bytes, filename='fischer.docx'):
return self.env['fp.thickness.upload.wizard'].create({
'certificate_id': self.cert.id,
'file_data': base64.b64encode(file_bytes),
'file_name': filename,
})
# ---- parse step ------------------------------------------------------
def test_action_parse_populates_review_state(self):
wiz = self._make_wizard(self._sample_docx_bytes())
wiz.action_parse()
self.assertEqual(wiz.state, 'review')
self.assertEqual(wiz.reading_count, 3)
self.assertEqual(len(wiz.reading_line_ids), 3)
# Spot-check the second row carries the values we expect.
line_2 = wiz.reading_line_ids.filtered(lambda l: l.reading_number == 2)
self.assertEqual(len(line_2), 1)
self.assertAlmostEqual(line_2.nip_mils, 0.5049, places=4)
def test_action_parse_unparseable_goes_to_manual_state(self):
wiz = self._make_wizard(b'not a docx', filename='garbage.docx')
wiz.action_parse()
self.assertEqual(wiz.state, 'manual')
self.assertEqual(wiz.reading_count, 0)
self.assertFalse(wiz.reading_line_ids)
def test_action_parse_extracts_metadata(self):
wiz = self._make_wizard(self._sample_docx_bytes())
wiz.action_parse()
self.assertIn('Fischerscope', wiz.parsed_equipment_model or '')
self.assertEqual(wiz.parsed_calibration_std_ref,
'NiP/Al STD SET SN 100174568')
self.assertEqual(wiz.parsed_measuring_time_seconds, 120)
self.assertEqual(wiz.parsed_operator_name, 'BK')
# ---- save step -------------------------------------------------------
def test_action_save_creates_thickness_readings(self):
wiz = self._make_wizard(self._sample_docx_bytes())
wiz.action_parse()
wiz.action_save()
readings = self.env['fp.thickness.reading'].search([
('certificate_id', '=', self.cert.id),
])
self.assertEqual(len(readings), 3)
# Same metadata on every row (decision 2026-05-19).
for r in readings:
self.assertEqual(r.calibration_std_ref,
'NiP/Al STD SET SN 100174568')
self.assertIn('Fischerscope', r.equipment_model or '')
self.assertEqual(r.measuring_time_seconds, 120)
def test_action_save_attaches_original_file(self):
wiz = self._make_wizard(
self._sample_docx_bytes(), filename='fischer-WO-30040.docx',
)
wiz.action_parse()
wiz.action_save()
self.cert.invalidate_recordset(
['x_fc_local_thickness_pdf', 'x_fc_local_thickness_pdf_filename'],
)
self.assertTrue(self.cert.x_fc_local_thickness_pdf)
self.assertEqual(
self.cert.x_fc_local_thickness_pdf_filename, 'fischer-WO-30040.docx',
)
def test_action_save_posts_chatter(self):
wiz = self._make_wizard(self._sample_docx_bytes())
wiz.action_parse()
before = len(self.cert.message_ids)
wiz.action_save()
after = len(self.cert.message_ids)
self.assertGreater(after, before)
last = self.cert.message_ids[0]
self.assertIn('thickness', (last.body or '').lower())
def test_action_save_blocks_on_non_draft_cert(self):
# Force the cert into 'voided' so action_save's gate fires.
self.cert.state = 'voided'
wiz = self._make_wizard(self._sample_docx_bytes())
wiz.action_parse()
with self.assertRaises(UserError):
wiz.action_save()
def test_action_save_manual_fallback_still_attaches_file(self):
"""When parse fails (state=manual), Save must still attach the
original file so the merge path / audit trail are populated."""
wiz = self._make_wizard(b'unparseable')
wiz.action_parse()
self.assertEqual(wiz.state, 'manual')
wiz.action_save()
self.cert.invalidate_recordset(['x_fc_local_thickness_pdf'])
self.assertTrue(self.cert.x_fc_local_thickness_pdf)
# No readings should have been created.
n = self.env['fp.thickness.reading'].search_count([
('certificate_id', '=', self.cert.id),
])
self.assertEqual(n, 0)

View File

@@ -42,12 +42,27 @@
<button name="action_issue" string="Issue"
type="object" class="btn-primary"
invisible="state != 'draft'"/>
<button name="action_void" string="Void"
<!-- Print = the same EN report action the gear-menu
Print > Certificate of Conformance (English)
calls. Routes through fusion_pdf_preview's
report interceptor automatically. For the
French variant or any other language report,
use the gear menu. -->
<button name="%(fusion_plating_reports.action_report_coc_en)d"
string="Print"
type="action" class="btn-secondary"
icon="fa-print"/>
<button name="action_open_void_wizard" string="Void"
type="object" class="btn-danger"
invisible="state != 'issued'"/>
<button name="action_send_to_customer" string="Send to Customer"
type="object"
invisible="state != 'issued'"/>
<button name="action_reset_to_draft" string="Reset to Draft"
type="object" class="btn-secondary"
icon="fa-undo"
confirm="Reset this certificate to draft? You'll be able to edit and re-issue. The previously-issued PDF stays attached for audit."
invisible="state == 'draft'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,issued"/>
</header>
@@ -67,48 +82,52 @@
<field name="name" readonly="1"/>
</h1>
</div>
<!-- Main info — collapsed from 3 separate groups
into 1 to eliminate the dead rows that
appeared when one sub-group ran shorter than
the other. Left column is identity / signer /
dates; right column is part / process / qty /
derived stats. Reorganized 2026-05-21. -->
<group>
<group>
<field name="certificate_type"/>
<field name="partner_id"/>
<field name="sale_order_id"/>
<field name="portal_job_id"/>
<field name="issue_date"/>
</group>
<group>
<field name="part_number"/>
<field name="po_number"/>
<field name="entech_wo_number"/>
<field name="customer_job_no"/>
<field name="process_description"/>
<field name="spec_reference"/>
<field name="quantity_shipped"/>
<field name="nc_quantity"/>
<field name="contact_partner_id"
options="{'no_create': True}"
invisible="not partner_id"/>
</group>
</group>
<group>
<group>
<field name="sale_order_id"/>
<field name="entech_wo_number"/>
<field name="portal_job_id"/>
<field name="issue_date"/>
<field name="issued_by_id"/>
<field name="certified_by_id"/>
<field name="body_style"/>
</group>
<group>
<field name="part_number"/>
<field name="process_description"/>
<field name="spec_reference"/>
<field name="po_number"/>
<field name="customer_job_no"/>
<field name="quantity_shipped"/>
<field name="nc_quantity"/>
<field name="reading_count" readonly="1"/>
<field name="mean_nip_mils" readonly="1"/>
</group>
</group>
<!-- SPC rebalanced — spec/min/max on the left,
derived stats on the right; trend_explanation
spans both columns so the long message doesn't
get cropped. -->
<group string="SPC — Statistical Process Control">
<group>
<field name="spec_min_mils"/>
<field name="spec_max_mils"/>
<field name="min_reading_mils" readonly="1"/>
<field name="max_reading_mils" readonly="1"/>
<field name="std_dev_mils" readonly="1"/>
</group>
<group>
<field name="std_dev_mils" readonly="1"/>
<field name="cpk" readonly="1"/>
<field name="cpk_status" readonly="1" widget="badge"
decoration-success="cpk_status in ('capable','excellent')"
@@ -119,9 +138,9 @@
decoration-success="trend_alert == 'ok'"
decoration-warning="trend_alert == 'warning'"
decoration-danger="trend_alert == 'alert'"/>
<field name="trend_explanation" readonly="1"
invisible="trend_alert == 'ok'"/>
</group>
<field name="trend_explanation" readonly="1" colspan="2"
invisible="trend_alert == 'ok'"/>
</group>
<notebook>
<page string="Thickness Readings" name="readings">

View File

@@ -32,6 +32,17 @@
<field name="x_fc_send_bol" widget="boolean_toggle"/>
</group>
</group>
<separator string="Default CoC Contact"/>
<p class="text-muted">
The named contact this customer's CoC is addressed
to and emailed to. Pre-fills cert records when a
job ships. Leave blank to force the manager to pick
at issue time.
</p>
<group>
<field name="x_fc_default_coc_contact_id"
options="{'no_create': True}"/>
</group>
<separator string="Cert Statement Override (Sub 12c+)"/>
<p class="text-muted">
Boilerplate text printed in the "Certification Statement"

View File

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

View File

@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Void Certificate Wizard.
Opened from an issued cert's "Void" button. Prompts the manager for a
written reason, then calls action_void on the cert with the reason
populated. The cert's chatter records the void event with the reason
inline via the existing _logger / message_post in action_void.
"""
from odoo import _, fields, models
from odoo.exceptions import UserError
class FpCertVoidWizard(models.TransientModel):
_name = 'fp.cert.void.wizard'
_description = 'Fusion Plating — Void Certificate Wizard'
cert_id = fields.Many2one(
'fp.certificate', string='Certificate', required=True, readonly=True,
)
cert_name = fields.Char(related='cert_id.name', readonly=True)
partner_id = fields.Many2one(
related='cert_id.partner_id', readonly=True,
)
void_reason = fields.Text(
string='Void Reason',
help='Why this certificate is being voided. Printed on the '
'cert chatter and visible in audit trails. Required for '
'AS9100 / Nadcap document control. Validation happens at '
'confirm time so the wizard can open empty.',
)
def action_confirm(self):
self.ensure_one()
if not (self.void_reason or '').strip():
raise UserError(_(
'Please enter a void reason before voiding. The reason '
'is logged to the cert chatter and printed on the audit '
'trail (AS9100 / Nadcap requirement).'
))
if self.cert_id.state != 'issued':
raise UserError(_(
'Only issued certificates can be voided '
'(current state: %s).'
) % self.cert_id.state)
# Write the reason FIRST so the cert's action_void gate passes.
self.cert_id.void_reason = self.void_reason
self.cert_id.action_void()
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Plating product family.
-->
<odoo>
<record id="view_fp_cert_void_wizard_form" model="ir.ui.view">
<field name="name">fp.cert.void.wizard.form</field>
<field name="model">fp.cert.void.wizard</field>
<field name="arch" type="xml">
<form string="Void Certificate">
<sheet>
<div class="oe_title">
<h2>
Void Certificate <field name="cert_name"
readonly="1"
nolabel="1"
class="oe_inline"/>
</h2>
</div>
<div class="alert alert-warning" role="alert">
<i class="fa fa-exclamation-triangle"/>
Voiding marks this certificate as no longer
valid. The audit trail keeps the record visible
but flagged. Required for AS9100 / Nadcap
document control.
</div>
<group>
<field name="partner_id" readonly="1"/>
</group>
<group>
<field name="void_reason"
placeholder="e.g. Customer rejected lot — re-plating required. Replaced by CoC-30041."
nolabel="1"/>
</group>
</sheet>
<footer>
<button name="action_confirm" type="object"
string="Void Certificate"
class="btn-danger"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,244 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
#
# Thickness-report upload wizard. Operator picks a Fischerscope export
# (.docx or .pdf); the wizard parses readings + metadata via the
# fischerscope_parser library, shows the result for review, and on Save
# writes per-reading rows into fp.thickness.reading + stores the
# original file in fp.certificate.x_fc_local_thickness_pdf.
#
# When the parser extracts ≥1 reading, the wizard enters "review" state
# and the editable reading table is shown. When 0 readings are found,
# the wizard enters "manual" state — the operator can still save the
# file as-is (attach-only fallback). Either way the file ends up in
# place to satisfy the action_issue thickness gate.
import base64
import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
# Lazy parser import — `from ..lib.fischerscope_parser import …` at
# module top fails on Python 3.11+ because the parent package
# `fusion_plating_certificates` is still mid-init when wizards/__init__
# imports this file (relative traversal into a partially-loaded parent
# raises "cannot import name from partially initialized module"). The
# parser is referenced once inside action_parse so deferring is fine.
_logger = logging.getLogger(__name__)
class FpThicknessUploadWizard(models.TransientModel):
"""Upload + parse a Fischerscope thickness report onto a certificate."""
_name = 'fp.thickness.upload.wizard'
_description = 'Thickness Report Upload Wizard'
certificate_id = fields.Many2one(
'fp.certificate', string='Certificate', required=True, ondelete='cascade',
)
partner_id = fields.Many2one(
related='certificate_id.partner_id', string='Customer', readonly=True,
)
state = fields.Selection(
[('upload', 'Upload file'),
('review', 'Review parsed readings'),
('manual', 'Parse failed — attach only')],
default='upload', required=True,
)
# File ----------------------------------------------------------------
file_data = fields.Binary(string='Fischerscope Report', required=True)
file_name = fields.Char(string='File Name')
# Parsed metadata (readonly after parse) ------------------------------
parsed_equipment_model = fields.Char(string='Equipment', readonly=True)
parsed_product_ref = fields.Char(string='Product Ref', readonly=True)
parsed_calibration_std_ref = fields.Char(string='Calibration Std', readonly=True)
parsed_measuring_time_seconds = fields.Integer(
string='Measuring Time (sec)', readonly=True,
)
parsed_operator_name = fields.Char(string='Operator', readonly=True)
parsed_reading_datetime = fields.Datetime(
string='Reading Date/Time', readonly=True,
)
# Image preview -------------------------------------------------------
parsed_image = fields.Binary(string='Microscope Image', readonly=True)
parsed_image_mime = fields.Char(readonly=True)
# Editable reading rows -----------------------------------------------
reading_line_ids = fields.One2many(
'fp.thickness.upload.wizard.line', 'wizard_id', string='Readings',
)
# Parse status --------------------------------------------------------
parse_messages = fields.Text(string='Parser notes', readonly=True)
reading_count = fields.Integer(string='Parsed Readings', readonly=True)
# ------------------------------------------------------------------
# Actions
# ------------------------------------------------------------------
def action_parse(self):
"""Run the parser; populate metadata + reading_line_ids."""
self.ensure_one()
if not self.file_data:
raise UserError(_('Pick a file before parsing.'))
try:
content = base64.b64decode(self.file_data)
except (TypeError, ValueError) as e:
raise UserError(_('File data is corrupt: %s') % e) from e
from ..lib.fischerscope_parser import parse_fischerscope_file
result = parse_fischerscope_file(self.file_name or '', content)
# Wipe previous attempt so a retry doesn't pile up rows.
self.reading_line_ids.unlink()
self.parsed_equipment_model = result['metadata'].get('equipment_model')
self.parsed_product_ref = result['metadata'].get('product_ref')
self.parsed_calibration_std_ref = result['metadata'].get('calibration_std_ref')
self.parsed_measuring_time_seconds = (
result['metadata'].get('measuring_time_seconds') or 0
)
self.parsed_operator_name = result['metadata'].get('operator_name')
self.parsed_reading_datetime = result['metadata'].get('reading_datetime')
if result.get('image'):
self.parsed_image = base64.b64encode(result['image'])
self.parsed_image_mime = result.get('image_mime')
# Build editable rows for review/edit.
Line = self.env['fp.thickness.upload.wizard.line']
for r in result['readings']:
Line.create({
'wizard_id': self.id,
'reading_number': r['reading_number'],
'nip_mils': r['nip_mils'],
'ni_percent': r['ni_percent'],
'p_percent': r['p_percent'],
})
self.reading_count = len(result['readings'])
self.parse_messages = '\n'.join(result.get('errors') or []) or False
self.state = 'review' if result['success'] else 'manual'
return self._reopen()
def action_save(self):
"""Commit parsed readings + file to the certificate."""
self.ensure_one()
cert = self.certificate_id
if not cert:
raise UserError(_('Wizard has no certificate to write to.'))
if cert.state != 'draft':
raise UserError(_(
'Cannot attach thickness data — certificate %s is in '
'state %s. Only draft certificates can be edited.'
) % (cert.display_name, cert.state))
# Attach the original file so the merge logic + audit trail still
# have it (also covers the "parse failed" manual fallback case).
if self.file_data:
cert.write({
'x_fc_local_thickness_pdf': self.file_data,
'x_fc_local_thickness_pdf_filename': self.file_name or False,
})
# Persist the microscope image as a cert-level attachment (decision
# confirmed 2026-05-19). One image per report, not per-reading.
if self.parsed_image:
ext = self._guess_image_ext(self.parsed_image_mime)
self.env['ir.attachment'].create({
'name': 'microscope-%s%s' % (cert.name or 'cert', ext),
'datas': self.parsed_image,
'res_model': cert._name,
'res_id': cert.id,
'mimetype': self.parsed_image_mime or 'image/jpeg',
})
# Write reading rows — same metadata copied onto every row
# (decision confirmed 2026-05-19, so each row is fully self-
# describing for downstream queries / reports).
if self.reading_line_ids:
Reading = self.env['fp.thickness.reading']
for line in self.reading_line_ids:
Reading.create({
'certificate_id': cert.id,
'reading_number': line.reading_number,
'nip_mils': line.nip_mils,
'ni_percent': line.ni_percent,
'p_percent': line.p_percent,
'position_label': line.position_label or False,
'equipment_model': self.parsed_equipment_model
or 'Fischerscope XDAL 600',
'product_ref': self.parsed_product_ref or False,
'calibration_std_ref': (
self.parsed_calibration_std_ref
or 'NiP/Al STD SET SN 100174568'
),
'reading_datetime': (
self.parsed_reading_datetime
or fields.Datetime.now()
),
'measuring_time_seconds': (
self.parsed_measuring_time_seconds or 120
),
})
# Chatter audit
n = len(self.reading_line_ids)
body = (
_('Fischerscope thickness report uploaded — %d reading(s) '
'parsed from %s.') % (n, self.file_name or 'file')
if n else
_('Fischerscope thickness file attached (parse returned no '
'readings). File: %s') % (self.file_name or 'unnamed')
)
cert.message_post(body=body)
return {
'type': 'ir.actions.act_window',
'res_model': cert._name,
'res_id': cert.id,
'view_mode': 'form',
'target': 'current',
}
def _reopen(self):
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
@staticmethod
def _guess_image_ext(mime):
return {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/tiff': '.tiff',
}.get((mime or '').lower(), '.bin')
class FpThicknessUploadWizardLine(models.TransientModel):
"""Editable reading row in the upload wizard."""
_name = 'fp.thickness.upload.wizard.line'
_description = 'Thickness Upload Wizard — Reading'
_order = 'reading_number'
wizard_id = fields.Many2one(
'fp.thickness.upload.wizard', required=True, ondelete='cascade',
)
reading_number = fields.Integer(string='#', required=True)
nip_mils = fields.Float(string='NiP (mils)', digits=(10, 4))
ni_percent = fields.Float(string='Ni %', digits=(6, 3))
p_percent = fields.Float(string='P %', digits=(6, 4))
position_label = fields.Char(
string='Position',
help='Optional — where on the part this reading was taken.',
)

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Thickness-report upload wizard view.
-->
<odoo>
<!-- ================================================================== -->
<!-- Wizard form -->
<!-- ================================================================== -->
<record id="fp_thickness_upload_wizard_form" model="ir.ui.view">
<field name="name">fp.thickness.upload.wizard.form</field>
<field name="model">fp.thickness.upload.wizard</field>
<field name="arch" type="xml">
<form string="Upload Thickness Report">
<field name="state" invisible="1"/>
<!-- Upload step -->
<div invisible="state != 'upload'">
<p>
Drop the Fischerscope XDAL 600 export below
(<code>.docx</code> or <code>.pdf</code>). I'll read the
readings, gauge calibration, and operator info, then
let you review the values before they land on
certificate <field name="certificate_id" readonly="1" nolabel="1"
class="oe_inline" options="{'no_open': True, 'no_create': True}"/>.
</p>
<group>
<field name="file_data" filename="file_name"/>
<field name="file_name"/>
</group>
<footer>
<button name="action_parse" string="Parse File"
type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</div>
<!-- Review step -->
<div invisible="state != 'review'">
<div class="alert alert-success" role="alert">
Parsed <field name="reading_count" readonly="1"
nolabel="1" class="oe_inline"/> reading(s)
from <field name="file_name" readonly="1" nolabel="1"
class="oe_inline"/>. Review/edit below,
then click Save to record on the certificate.
</div>
<group string="Equipment + Calibration">
<field name="parsed_equipment_model"/>
<field name="parsed_product_ref"/>
<field name="parsed_calibration_std_ref"/>
<field name="parsed_measuring_time_seconds"/>
<field name="parsed_operator_name"/>
<field name="parsed_reading_datetime"/>
</group>
<group string="Microscope Image"
invisible="not parsed_image">
<field name="parsed_image" widget="image"
options="{'preview_image': 'parsed_image'}"
nolabel="1"/>
</group>
<field name="reading_line_ids" nolabel="1">
<list editable="bottom">
<field name="reading_number"/>
<field name="nip_mils"/>
<field name="ni_percent"/>
<field name="p_percent"/>
<field name="position_label"/>
</list>
</field>
<group invisible="not parse_messages">
<field name="parse_messages" readonly="1"
widget="text" nolabel="1"/>
</group>
<footer>
<button name="action_save" string="Save"
type="object" class="btn-primary"/>
<button string="Re-upload" class="btn-secondary"
name="action_parse" type="object"
invisible="not file_data"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</div>
<!-- Manual fallback step -->
<div invisible="state != 'manual'">
<div class="alert alert-warning" role="alert">
<strong>Couldn't parse readings.</strong>
The file format didn't match what we recognise
(Fischerscope XDAL 600 export). You can still save it
as-is — the file will attach to the certificate and
flow into the CoC PDF as page 2, but the readings
won't appear as queryable rows.
</div>
<group>
<field name="file_name" readonly="1"/>
</group>
<group invisible="not parse_messages">
<field name="parse_messages" readonly="1"
widget="text" nolabel="1"/>
</group>
<footer>
<button name="action_save"
string="Attach file anyway"
type="object" class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</div>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- Window action — opened from the cert form button -->
<!-- ================================================================== -->
<record id="action_fp_thickness_upload_wizard" model="ir.actions.act_window">
<field name="name">Upload Thickness Report</field>
<field name="res_model">fp.thickness.upload.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="model_fp_thickness_upload_wizard"/>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
"""Drop the 'inspected' value from sale_order.x_fc_receiving_status.
Sub 8 (2026-04-22) moved part inspection out of receiving and into the
recipe's racking step. The SO-level receiving status no longer needs
'inspected' as a terminal value — 'received' (boxes counted/staged/
closed) is now the final state.
This migration flips any existing rows with the obsolete value to the
new terminal value. On a freshly-installed instance there are zero rows;
the migration is defensive for instances that had pre-Sub-8 records.
"""
def migrate(cr, version):
cr.execute("""
UPDATE sale_order
SET x_fc_receiving_status = 'received'
WHERE x_fc_receiving_status = 'inspected'
""")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -120,7 +120,12 @@
<h4>Add Variant from Template</h4>
<div class="d-flex gap-2 align-items-center flex-wrap">
<label class="me-2">Template:</label>
<select class="form-select" style="max-width: 280px;"
<!-- Bumped min-width 280px → 360px and let it
flex-grow so long template names (e.g.
"Chemical Conversion — Iridite Type II Cl 3")
don't truncate to "Chem…". Reported 2026-05-20. -->
<select class="form-select"
style="min-width: 360px; flex: 1 1 360px; max-width: 560px;"
t-on-change="onSelectTemplate">
<t t-foreach="state.templates" t-as="tpl" t-key="tpl.id">
<option t-att-value="tpl.id"
@@ -129,14 +134,22 @@
</option>
</t>
</select>
<input class="form-control" style="max-width: 240px;"
<input class="form-control"
style="min-width: 220px; flex: 1 1 220px; max-width: 320px;"
placeholder="Variant label (e.g. Standard ENP)"
t-att-value="state.newVariantLabel"
t-on-input="onNewLabelInput"/>
<button class="btn btn-primary"
t-on-click="onAddVariantFromTemplate"
t-att-disabled="state.busy or !state.selectedTemplateId">
<i class="fa fa-plus"/> Add Variant
t-on-click="() => this.onAddVariantFromTemplate('tree')"
t-att-disabled="state.busy or !state.selectedTemplateId"
title="Add the variant and open it in the Tree Editor">
<i class="fa fa-sitemap me-1"/> Add — Tree
</button>
<button class="btn btn-primary"
t-on-click="() => this.onAddVariantFromTemplate('simple')"
t-att-disabled="state.busy or !state.selectedTemplateId"
title="Add the variant and open it in the Simple Editor">
<i class="fa fa-list me-1"/> Add — Simple
</button>
</div>
<p class="text-muted small mt-1">

View File

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

View File

@@ -12,6 +12,19 @@
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<!-- Header buttons: make draft Confirm the primary CTA, demote/rename
Send to "Send Email" (red), and reorder so Confirm sits first. -->
<xpath expr="//header/button[@name='action_confirm' and not(@id)]" position="attributes">
<attribute name="class">btn-primary</attribute>
</xpath>
<xpath expr="//header/button[@id='quotation_send_primary']" position="attributes">
<attribute name="string">Send Email</attribute>
<attribute name="class">btn-danger</attribute>
</xpath>
<xpath expr="//header/button[@id='quotation_send_primary']" position="before">
<xpath expr="//header/button[@name='action_confirm' and not(@id)]" position="move"/>
</xpath>
<!-- Hide standard Delivery button: our Transfers button (below) shows
all stock.picking records - inbound receipts AND outbound deliveries -
which matches the plating workflow better than outbound-only. -->
@@ -98,6 +111,27 @@
string="Job Groups"/>
</button>
</xpath>
<!-- Surface Delivery Date (commitment_date) right after Order
Date in the header info group. The standard view only
shows it buried under the Delivery section in the Other
Info tab — having it at the top keeps it visible without
scrolling, since most operators are setting it on every
order. The same field is also surfaced lower in our
Plating tab Scheduling group as "Customer Deadline"; both
reference the same field so edits sync. -->
<xpath expr="//group[@name='order_details']/field[@name='payment_term_id']" position="before">
<field name="commitment_date" string="Delivery Date"
readonly="state in ('cancel',)"/>
</xpath>
<!-- Job Sorting sits right under Payment Terms — a free-form
bucket that groups the SO in the "Sale Orders by Sorting"
list. Quick-create from the dropdown. -->
<xpath expr="//group[@name='order_details']/field[@name='payment_term_id']" position="after">
<field name="x_fc_job_sort_id"
options="{'no_create_edit': False, 'no_open': True}"
placeholder="Type to create a new bucket..."/>
</xpath>
<xpath expr="//notebook" position="inside">
<page string="Plating" name="plating_tab">
<!-- Multi-part summary: read-only list of every order line
@@ -131,11 +165,9 @@
string="Job #"/>
</list>
</field>
<!-- Row 1: RFQ/PO (left) + Scheduling (right) — pairs the two
tallest groups so neither column dangles empty. -->
<group>
<group string="Configurator (legacy)" invisible="not x_fc_configurator_id">
<field name="x_fc_configurator_id" readonly="1"/>
<field name="x_fc_process_summary" readonly="1"/>
</group>
<group string="RFQ / PO">
<field name="x_fc_po_number"/>
<field name="upload_rfq_file"
@@ -161,29 +193,6 @@
<field name="x_fc_po_override_reason"
invisible="not x_fc_po_override"/>
</group>
</group>
<group>
<group string="Invoicing">
<field name="x_fc_invoice_strategy"/>
<field name="x_fc_deposit_percent"
invisible="x_fc_invoice_strategy != 'deposit'"/>
<field name="x_fc_progress_initial_percent"
invisible="x_fc_invoice_strategy != 'progress'"/>
<field name="x_fc_final_invoice_id" readonly="1"
invisible="not x_fc_final_invoice_id"/>
</group>
<group string="Delivery">
<field name="x_fc_rush_order"/>
<field name="x_fc_delivery_method"/>
<field name="x_fc_receiving_status"/><!-- Will become computed when fusion_plating_receiving is installed -->
</group>
</group>
<group>
<group string="Customer Reference">
<field name="x_fc_customer_job_number"/>
<field name="x_fc_contact_phone"/>
<field name="x_fc_ship_via"/>
</group>
<group string="Scheduling">
<field name="x_fc_planned_start_date"/>
<field name="x_fc_internal_deadline"/>
@@ -201,9 +210,45 @@
</div>
<field name="x_fc_is_blanket_order"/>
<field name="x_fc_block_partial_shipments"/>
<!-- Lead Time range. Both 0 = "Standard" on
the PDF; otherwise renders "X-Y days"
(or "X days" if min==max or one is 0). -->
<label for="x_fc_lead_time_min_days" string="Lead Time (days)"/>
<div class="o_row">
<field name="x_fc_lead_time_min_days" class="oe_inline" style="width: 4em;"/>
<span> to </span>
<field name="x_fc_lead_time_max_days" class="oe_inline" style="width: 4em;"/>
</div>
<field name="x_fc_lead_time_display" readonly="1"/>
</group>
</group>
<!-- Row 2: Invoicing + Delivery (unchanged pairing). -->
<group>
<group string="Invoicing">
<field name="x_fc_invoice_strategy"/>
<field name="x_fc_deposit_percent"
invisible="x_fc_invoice_strategy != 'deposit'"/>
<field name="x_fc_progress_initial_percent"
invisible="x_fc_invoice_strategy != 'progress'"/>
<field name="x_fc_final_invoice_id" readonly="1"
invisible="not x_fc_final_invoice_id"/>
</group>
<group string="Delivery">
<field name="x_fc_rush_order"/>
<field name="x_fc_delivery_method"/>
<field name="x_fc_receiving_status"/><!-- Will become computed when fusion_plating_receiving is installed -->
</group>
</group>
<!-- Row 3: Customer Reference + Margin — both short groups, so
pairing them keeps the right column from going blank. -->
<group>
<group string="Customer Reference">
<field name="x_fc_customer_job_number"/>
<field name="x_fc_contact_phone"/>
<field name="x_fc_ship_via"/>
</group>
<group string="Margin">
<div colspan="2"
invisible="x_fc_margin_available"
@@ -222,14 +267,29 @@
<field name="x_fc_margin_available" invisible="1"/>
</group>
</group>
<!-- Row 4: Notes — two side-by-side textareas instead of the
previous broken separator-in-group layout. -->
<group>
<group string="Internal Notes">
<field name="x_fc_internal_note" nolabel="1"
placeholder="Internal notes for estimator / planner / shop floor..."/>
</group>
<separator string="External Notes (customer-visible)"/>
<field name="x_fc_external_note"
<group string="External Notes (customer-visible)">
<field name="x_fc_external_note" nolabel="1"
placeholder="Notes that appear on the acknowledgement and portal..."/>
</group>
</group>
<!-- Legacy configurator block — invisible on new SOs (only
the handful that came through the old quote configurator
flow have x_fc_configurator_id set). Kept at the bottom
so it doesn't waste vertical space on the common case. -->
<group invisible="not x_fc_configurator_id">
<group string="Configurator (legacy)">
<field name="x_fc_configurator_id" readonly="1"/>
<field name="x_fc_process_summary" readonly="1"/>
</group>
</group>
</page>
</xpath>
@@ -307,29 +367,39 @@
<field name="name">sale.order.list.fp</field>
<field name="model">sale.order</field>
<field name="arch" type="xml">
<list string="Sale Orders" decoration-info="state == 'draft'"
<list string="Sale Orders" create="0" decoration-info="state == 'draft'"
decoration-muted="state == 'cancel'"
decoration-danger="x_fc_is_late_forecast">
<header>
<button name="%(action_fp_direct_order_wizard)d"
type="action"
string="+ New Direct Order"
string="New Order"
class="btn-primary"
display="always"/>
</header>
<field name="name"/>
<field name="partner_id"/>
<field name="x_fc_po_number"/>
<field name="name" optional="show"/>
<field name="partner_id" optional="show"/>
<field name="x_fc_po_number" optional="show"/>
<field name="x_fc_customer_job_number" optional="show"/>
<field name="x_fc_internal_deadline" optional="show"/>
<field name="commitment_date" string="Customer Deadline" optional="show"/>
<field name="x_fc_order_completion_date" string="Completion" optional="show"/>
<field name="x_fc_is_late_forecast" optional="hide" widget="boolean_toggle"/>
<field name="x_fc_deadline_countdown" optional="show"/>
<field name="x_fc_deadline_urgency" column_invisible="1"/>
<field name="x_fc_deadline_countdown" optional="show"
decoration-danger="x_fc_deadline_urgency == 'overdue'"
decoration-warning="x_fc_deadline_urgency == 'urgent'"
decoration-success="x_fc_deadline_urgency == 'safe'"/>
<field name="x_fc_wo_completion" optional="show"/>
<field name="x_fc_planned_start_date" optional="hide"/>
<field name="x_fc_part_catalog_id" optional="hide"/>
<field name="amount_total" sum="Total"/>
<!-- "Part" column — walks order_line.x_fc_part_catalog_id
and shows a compact summary (e.g. "M1234, M5678
(+3 more)"). The header x_fc_part_catalog_id field
is rarely populated in the configurator flow; the
line carries the authoritative part link. -->
<field name="x_fc_part_numbers_summary" string="Part"
optional="show"/>
<field name="amount_total" sum="Total" optional="show"/>
<field name="x_fc_invoiced_amount" sum="Invoiced" optional="hide"
widget="monetary"
options="{'currency_field': 'currency_id'}"/>
@@ -339,12 +409,21 @@
<field name="x_fc_margin_percent" optional="hide"
widget="percentage"/>
<field name="x_fc_is_blanket_order" optional="hide"/>
<!-- Single Job Status pill. Renders as HTML with a
per-kind class (.fp-kind-*) so every phase carries
its own distinct tint — see
static/src/scss/fp_job_status_pill.scss. -->
<field name="x_fc_fp_job_status" widget="html"
string="Job Status" optional="show"
readonly="1"/>
<field name="x_fc_receiving_status" widget="badge"
optional="hide"
decoration-warning="x_fc_receiving_status == 'not_received'"
decoration-success="x_fc_receiving_status in ('received','inspected')"/>
decoration-info="x_fc_receiving_status == 'partial'"
decoration-success="x_fc_receiving_status == 'received'"/>
<field name="x_fc_delivery_method" optional="hide"/>
<field name="currency_id" column_invisible="1"/>
<field name="state" widget="badge"/>
<field name="state" widget="badge" optional="show"/>
</list>
</field>
</record>
@@ -414,8 +493,8 @@
<field name="model">sale.order</field>
<field name="arch" type="xml">
<list string="Quotations" decoration-muted="state == 'cancel'">
<field name="name"/>
<field name="partner_id"/>
<field name="name" optional="show"/>
<field name="partner_id" optional="show"/>
<field name="x_fc_part_numbers_summary" optional="show"/>
<field name="x_fc_po_number" optional="hide"/>
<field name="x_fc_customer_job_number" optional="hide"/>
@@ -423,15 +502,16 @@
<field name="validity_date" string="Expires" optional="show"/>
<field name="x_fc_follow_up_date" optional="show"/>
<field name="x_fc_follow_up_user_id" optional="show"/>
<field name="amount_total" sum="Total"/>
<field name="amount_total" sum="Total" optional="show"/>
<field name="x_fc_is_signed" widget="boolean_toggle"
string="Signed" optional="show"/>
<field name="x_fc_email_status" widget="badge"
optional="show"
decoration-info="x_fc_email_status == 'sent'"
decoration-warning="x_fc_email_status == 'opened'"
decoration-success="x_fc_email_status == 'won'"/>
<field name="currency_id" column_invisible="1"/>
<field name="state" widget="badge"/>
<field name="state" widget="badge" optional="show"/>
</list>
</field>
</record>
@@ -531,7 +611,10 @@
</field>
</record>
<!-- ===== Window Action — Confirmed Sale Orders ===== -->
<!-- ===== Window Action — Confirmed Sale Orders =====
The kanban view_mode + kanban view_id are appended in
fp_so_job_sort_views.xml after the kanban view is defined, so
we don't hit a missing-ref at module load. -->
<record id="action_fp_sale_orders" model="ir.actions.act_window">
<field name="name">Sale Orders</field>
<field name="res_model">sale.order</field>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from datetime import timedelta
from datetime import datetime, time, timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
@@ -79,6 +79,13 @@ class FpDirectOrderWizard(models.Model):
help="Customer's internal job number for cross-referencing. "
"Appears on work orders and invoices.",
)
job_sort_id = fields.Many2one(
'fp.so.job.sort',
string='Job Sorting',
help='Free-form bucket that groups the new SO in the '
'"Sale Orders by Sorting" list. Type a new label in the '
'dropdown to create a section on the fly.',
)
# ---- Scheduling ----
planned_start_date = fields.Date(
@@ -86,6 +93,11 @@ class FpDirectOrderWizard(models.Model):
)
internal_deadline = fields.Date(string='Internal Deadline')
customer_deadline = fields.Date(string='Customer Deadline', tracking=True)
# Lead Time — promised production window. Mirrors directly to
# x_fc_lead_time_min_days / x_fc_lead_time_max_days on the SO via
# _prepare_order_vals. Leaving both 0 = Standard (no commitment).
lead_time_min_days = fields.Integer(string='Lead Time Min (days)')
lead_time_max_days = fields.Integer(string='Lead Time Max (days)')
# ---- Order flags (Phase B) ----
is_blanket_order = fields.Boolean(
@@ -528,9 +540,20 @@ class FpDirectOrderWizard(models.Model):
'x_fc_po_pending': self.po_pending,
'x_fc_po_expected_date': self.po_expected_date or False,
'x_fc_customer_job_number': self.customer_job_number or False,
'x_fc_job_sort_id': self.job_sort_id.id or False,
'x_fc_planned_start_date': self.planned_start_date,
'x_fc_internal_deadline': self.internal_deadline,
'commitment_date': self.customer_deadline,
'x_fc_lead_time_min_days': self.lead_time_min_days or 0,
'x_fc_lead_time_max_days': self.lead_time_max_days or 0,
# commitment_date is a Datetime; customer_deadline is a Date.
# Assigning a bare Date stores midnight UTC, which renders as
# the PREVIOUS day in any negative-UTC timezone (Eastern shifts
# May 25 00:00 UTC → May 24 8pm). Combining with noon keeps
# the date stable across all reasonable user timezones.
'commitment_date': (
datetime.combine(self.customer_deadline, time(12, 0))
if self.customer_deadline else False
),
'x_fc_invoice_strategy': self.invoice_strategy,
'x_fc_deposit_percent': self.deposit_percent,
'x_fc_progress_initial_percent': self.progress_initial_percent,

View File

@@ -70,6 +70,9 @@
options="{'no_create_edit': True}"
invisible="not partner_id"/>
<field name="customer_job_number"/>
<field name="job_sort_id"
options="{'no_create_edit': False, 'no_open': True}"
placeholder="Type to create a new bucket..."/>
</group>
<group string="Purchase Order">
<field name="po_number"
@@ -95,7 +98,21 @@
<group string="Scheduling">
<field name="planned_start_date"/>
<field name="internal_deadline"/>
<field name="customer_deadline"/>
<!-- Labelled "Delivery Date" here to match
the SO header field of the same name —
same field, same value, just consistent
wording end-to-end. Backing field is
still `customer_deadline` (wizard) →
`commitment_date` (SO). -->
<field name="customer_deadline" string="Delivery Date"/>
<!-- Lead time range (min/max business days).
Both 0 = "Standard" on the SO confirm PDF. -->
<label for="lead_time_min_days" string="Lead Time (days)"/>
<div class="o_row">
<field name="lead_time_min_days" class="oe_inline" style="width: 4em;"/>
<span> to </span>
<field name="lead_time_max_days" class="oe_inline" style="width: 4em;"/>
</div>
<field name="is_blanket_order"/>
<field name="block_partial_shipments"/>
</group>

View File

@@ -3,7 +3,7 @@
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Plating — Native Jobs',
'version': '19.0.10.8.0',
'version': '19.0.10.19.0',
'category': 'Manufacturing/Plating',
'summary': 'Native plating job model — replaces mrp.production / mrp.workorder bridge.',
'author': 'Nexa Systems Inc.',
@@ -57,19 +57,21 @@ full design rationale and §6.2 of the implementation plan for task list.
# so the statusbar's m2o has its targets available at view-render time).
'data/fp_workflow_state_data.xml',
'views/fp_workflow_state_views.xml',
'views/res_config_settings_views.xml',
'views/fp_job_step_quick_look_views.xml',
'views/fp_job_form_inherit.xml',
'views/fp_job_quality_buttons.xml',
'views/sale_order_views.xml',
'views/fp_receiving_views.xml',
'views/fp_certificate_views.xml',
'views/fp_job_consumption_views.xml',
'views/fp_step_priority_views.xml',
'views/jobs_in_shopfloor_menu.xml',
'views/legacy_menu_hide.xml',
'views/fp_job_cert_backfill.xml',
'views/res_users_views.xml',
'wizards/fp_job_step_move_wizard_views.xml',
'wizards/fp_job_step_input_wizard_views.xml',
'wizards/fp_cert_issue_wizard_views.xml',
'report/report_fp_job_sticker.xml',
'report/report_fp_job_traveller.xml',
'report/report_fp_job_wo_detail.xml',
@@ -86,6 +88,7 @@ full design rationale and §6.2 of the implementation plan for task list.
'fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss',
'fusion_plating_jobs/static/src/scss/fp_finish_btn.scss',
'fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js',
'fusion_plating_jobs/static/src/js/fp_cert_issue_wizard_autoedit.js',
'fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml',
],
'web.assets_web_dark': [
@@ -93,6 +96,7 @@ full design rationale and §6.2 of the implementation plan for task list.
'fusion_plating_jobs/static/src/scss/fp_record_inputs_dialog.scss',
'fusion_plating_jobs/static/src/scss/fp_finish_btn.scss',
'fusion_plating_jobs/static/src/js/fp_record_inputs_dialog.js',
'fusion_plating_jobs/static/src/js/fp_cert_issue_wizard_autoedit.js',
'fusion_plating_jobs/static/src/xml/fp_record_inputs_dialog.xml',
],
},

View File

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

View File

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

View File

@@ -56,7 +56,8 @@ class FpCertificate(models.Model):
'merged = already in the issued CoC PDF',
)
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id')
@api.depends('x_fc_job_id', 'state', 'message_ids', 'attachment_id',
'x_fc_local_thickness_pdf')
def _compute_fischer_visibility(self):
QC = self.env.get('fusion.plating.quality.check')
empty_qc = self.env['fusion.plating.quality.check'] if QC is not None else None
@@ -65,7 +66,14 @@ class FpCertificate(models.Model):
qc = empty_qc
pdf = empty_att
status = 'none'
if QC is not None and rec.x_fc_job_id:
# Cert-local upload wins over QC-side PDF (matches the
# merge resolution order in fp_certificate.py).
if rec.x_fc_local_thickness_pdf:
if rec.state == 'issued' and rec.attachment_id:
status = 'merged'
else:
status = 'pending'
elif QC is not None and rec.x_fc_job_id:
# Same lookup the merge method uses — passed-first,
# then any QC with a PDF.
qc = QC.sudo().search([

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