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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- /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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
_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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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.
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.
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.
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.
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'.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
@@ -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`:
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:
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, '
`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.
('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:
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.
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):
importbase64
ifnotb64_data:
return
try:
head=base64.b64decode(b64_data)[:5]
exceptException:
raiseUserError(f"{label}: could not decode uploaded file.")
ifhead!=b'%PDF-':
raiseUserError(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` |
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)
- 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.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.