Commit Graph

4 Commits

Author SHA1 Message Date
gsinghpal
65c4d8801c feat(fusion_repairs): Bundle 4 - M1 compliance inspection certificates
New fusion.repair.inspection.certificate model for the annual safety
inspections required on stairlifts, porch lifts, and power wheelchairs
in many jurisdictions.

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

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

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

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

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

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

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

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

Fixed every message_post call:

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

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

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

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

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

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

Bumped to 19.0.1.0.3.

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 21:57:33 -04:00