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>