222 Commits

Author SHA1 Message Date
gsinghpal
894eea7ce2 Merge branch 'main' of https://github.com/gsinghpal/Odoo-Modules 2026-05-21 05:18:40 -04:00
gsinghpal
b395600a1c changes 2026-05-21 05:18:32 -04:00
gsinghpal
612394c987 Merge branch 'main' of https://github.com/gsinghpal/Odoo-Modules 2026-05-21 04:48:06 -04:00
gsinghpal
d6d6249857 changes 2026-05-21 04:47:45 -04:00
gsinghpal
3440e4b7c6 feat(fusion_claims): force full-width sheet + 3-col responsive layout at xl
Aggressive sheet override: flex-basis 100%%, !important on width and
max-width to beat parent flex/media-query constraints. Also overrides
the o_form_sheet_bg wrapper.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 03:29:23 -04:00
gsinghpal
48dd7718e2 feat(fusion_repairs): Bundle 10 - align pricing to Westin's printed rate card
User shared their actual published service-rate card. Bundle 9's seeded
numbers were placeholders that no longer match. Realigned the rate card,
added the LIFT & ELEVATING SERVICE class, added the in-shop labour
rate path, added the delivery / pickup charge model, added rush as a
proper tier (distinct from after-hours), and added 30-min increment
rounding on top of the existing 1-hour minimum.

EQUIPMENT CLASS

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

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

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

CALLOUT RATE CARD

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

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

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

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

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

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

DELIVERY / PICKUP RATE CARD

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

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

PRICING ENGINE UPDATES (repair.order._compute_callout_quote)

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

NEW FIELDS

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

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

MIGRATION SCRIPT

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

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

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

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

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

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

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

Bumped to 19.0.2.1.0.

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

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

Loading pattern (intentional):

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

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

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

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

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

NEW MODELS

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

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

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

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

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

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

PRICING ENGINE ON REPAIR.ORDER

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

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

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

SECURITY

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

VISIT-REPORT WIZARD EXTENSIONS

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

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

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

NAVIGATION

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bumped to 19.0.1.9.1.

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

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

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

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

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

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

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

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

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

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

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

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

Bumped to 19.0.1.8.0.

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

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

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

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

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

Bumped to 19.0.1.7.0.

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

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

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

Bumped to 19.0.1.6.0.

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

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

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

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

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

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

Bumped to 19.0.1.5.0.

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

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

Bumped to 19.0.1.4.1.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bumped to 19.0.1.3.0.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bumped to 19.0.1.2.2.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bumped to 19.0.1.1.1.

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

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

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

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

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

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

Bumped to 19.0.1.1.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 23:27:43 -04:00
gsinghpal
f1cea2fb35 fix(fusion_schedule): stop archiving valid events on @removed=changed
Microsoft Graph's delta API returns @removed={reason:'changed'} when an
event drifts outside the original delta-query window — the event still
exists upstream. The old code treated any truthy @removed the same as a
real delete and archived the local calendar.event. Combined with
_find_existing_event filtering by active=True, every subsequent sync
recreated a duplicate (then archived it on the next pass), accumulating
5x duplicates and emptying the user's calendar.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 23:21:15 -04:00
gsinghpal
d15d9e4303 fix(fusion_repairs): admin + office users get full read/schedule access
When admin (gsingh, uid=2) opened a repair on the dashboard:
  "Sorry, Gurpreet Singh (id=2) doesn't have 'read' access to:
   - Repair Order, RO-202605-04 (repair.order: 34)
   Blame the following rules:
   - Repair Order: Technician sees own repairs"

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

Fix - added two pairs of permissive rules:

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

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

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

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

Bumped to 19.0.1.0.7.

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

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

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

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

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

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

Bumped to 19.0.1.0.5 so the asset bundle hash refreshes.

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

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

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

Bumped to 19.0.1.0.4.

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

Fixed every message_post call:

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

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

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

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

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

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

Bumped to 19.0.1.0.3.

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

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

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

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

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

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

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

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

Bumped version to 19.0.1.0.2 to bust the asset bundle hash.

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

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

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

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

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

2. Missing static/description/icon.png

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 22:31:38 -04:00
gsinghpal
8ef57a4bb1 fix(task_sync): defend against silent sync_id integrity violations
The cross-instance sync silently drops tasks when x_fc_tech_sync_id is
missing on the technician, and silently collapses duplicates via dict
comprehension. Both make sync break in ways that are invisible until
someone notices a missing task on the other instance.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 21:22:01 -04:00
gsinghpal
d4fb1eebbf changes 2026-05-20 21:01:58 -04:00
gsinghpal
2e4d957a47 fix(certs): auto-edit first row in Issue Certs wizard so upload is visible
Previous attempt (e5928b96) used CSS to force the binary widget's
"Upload your file" button visible in display mode. Problem: it
rendered a non-clickable stub in every row, then DUPLICATED when
the operator clicked into edit mode (two upload links stacked).

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

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

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

Module: fusion_plating_jobs 19.0.10.16.1 → 19.0.10.16.2.

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

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

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

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

Module: fusion_plating_jobs 19.0.10.16.0 → 19.0.10.16.1.

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

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

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

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

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

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

Module: fusion_plating_jobs 19.0.10.15.0 → 19.0.10.16.0.

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

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

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

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

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

Module: fusion_plating_receiving 19.0.3.19.0 → 19.0.3.20.0.

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

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

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

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

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

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

Module: fusion_plating_configurator 19.0.21.5.0 → 19.0.21.5.1.

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

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

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

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

Module: fusion_plating 19.0.20.6.1 → 19.0.20.6.2.

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

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

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

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

Module: fusion_plating 19.0.20.6.0 → 19.0.20.6.1.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Module: fusion_plating 19.0.20.4.0 → 19.0.20.5.0.

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

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

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

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

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

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

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

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

Module: fusion_plating 19.0.20.3.0 → 19.0.20.4.0.

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

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

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

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

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

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

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

Module: fusion_plating 19.0.20.2.0 → 19.0.20.3.0.

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

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

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

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

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

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

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

Modules: fusion_plating 19.0.20.2.0, fusion_plating_receiving
19.0.3.19.0, fusion_plating_jobs 19.0.10.15.0.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Module: fusion_plating_certificates 19.0.6.4.0 → 19.0.7.0.0

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

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

All 8 TestReceivingGate tests now pass on entech.

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

Reverts and renames in this commit:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Thin templates deleted: portal_my_fp_invoices, portal_my_purchase_orders.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Version bump: 19.0.3.6.0 -> 19.0.3.7.0.

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

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

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

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

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

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

Version bump: 19.0.3.5.0 -> 19.0.3.6.0.

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

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

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

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

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

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

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

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

Version bump: 19.0.3.4.0 -> 19.0.3.5.0.

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

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

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

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

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

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

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

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

Version bump: fusion_plating_reports 19.0.11.14.0 -> 19.0.11.15.0.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also adds _fp_size_label helper for friendly file-size strings.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Version bump: 19.0.2.2.0 -> 19.0.2.3.0.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No model / data changes. Pure menu metadata.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Pure additive. Foundation for Phases B-E.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Audit scan turned up three more identical patterns:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:25:37 -04:00
2788 changed files with 278012 additions and 6872 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
fusion_accounting/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

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