Files
Odoo-Modules/fusion_repairs/data/mail_template_data.xml
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

151 lines
12 KiB
XML

<?xml version="1.0" encoding="utf-8"?>
<!--
Fusion Repairs Mail Templates.
Styling: 4px accent bar, 600px max-width, dark/light safe.
Mirrors fusion_claims/data/mail_template_data.xml ADP templates for consistency.
-->
<odoo>
<data noupdate="1">
<!-- ============================================================== -->
<!-- Repair Intake Received - Client Confirmation -->
<!-- ============================================================== -->
<record id="email_template_intake_received_client" model="mail.template">
<field name="name">Repair: Intake Received (Client)</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="subject">{{ object.company_id.name }} - Service Call {{ object.name or 'received' }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#2B6CB0;"></div>
<div style="padding:32px 28px;">
<p style="color:#2B6CB0;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">We received your service request</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Hello <t t-out="object.partner_id.name or 'there'"/>, thank you for letting us know about your equipment.
Your service call reference is <strong><t t-out="object.name"/></strong>.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Service Call Details</td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Reference</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name"/></td></tr>
<t t-if="object.product_id">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Equipment</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.product_id.display_name"/></td></tr>
</t>
<t t-if="object.schedule_date">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Scheduled</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.schedule_date" t-options="{'widget': 'datetime'}"/></td></tr>
</t>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;">Status</td><td style="padding:10px 14px;font-size:14px;"><t t-out="dict(object._fields['state'].selection).get(object.state)"/></td></tr>
</table>
<div style="border-left:3px solid #2B6CB0;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">
A team member will be in touch shortly to confirm the next steps.
If you need to reach us before then, please contact our office directly.
</p>
</div>
<t t-if="not is_html_empty(object.user_id.signature)" data-o-mail-quote-container="1">
<div data-o-mail-quote="1">--<br data-o-mail-quote="1"/><t t-out="object.user_id.signature or ''" data-o-mail-quote="1"/></div>
</t>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- Maintenance Due Reminder -->
<!-- ============================================================== -->
<record id="email_template_maintenance_due_reminder" model="mail.template">
<field name="name">Repair: Maintenance Due Reminder</field>
<field name="model_id" ref="model_fusion_repair_maintenance_contract"/>
<field name="subject">{{ object.company_id.name }} - Time to schedule your equipment maintenance</field>
<field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="partner_to">{{ object.partner_id.id }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#38a169;"></div>
<div style="padding:32px 28px;">
<p style="color:#38a169;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<t t-out="object.company_id.name"/>
</p>
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Your equipment is due for maintenance</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Hello <t t-out="object.partner_id.name or 'there'"/>, your
<strong><t t-out="object.product_id.display_name or 'equipment'"/></strong>
is due for its next scheduled maintenance visit on
<strong><t t-out="object.next_due_date" t-options="{'widget': 'date'}"/></strong>.
</p>
<div style="text-align:center;margin:0 0 24px 0;">
<a t-attf-href="/repairs/maintenance/book/{{ object.booking_token }}"
style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">
Book my maintenance visit
</a>
</div>
<div style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">
Regular maintenance keeps your equipment safe and reliable. Use the
button above to confirm and we will reach out to schedule a time that works for you.
</p>
</div>
<p style="opacity:0.55;font-size:12px;margin:0;">
Contract reference <strong><t t-out="object.name"/></strong>.
If you no longer have this equipment, you can ignore this email.
</p>
</div>
</div>
</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- ============================================================== -->
<!-- Repair Intake Received - Office Notification -->
<!-- ============================================================== -->
<record id="email_template_intake_received_office" model="mail.template">
<field name="name">Repair: Intake Received (Office)</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="subject">[New Service Call] {{ object.partner_id.name or 'Walk-in' }} - {{ object.name or 'n/a' }}</field>
<field name="email_from">{{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_to">{{ ','.join(p.email for p in (object.company_id.x_fc_office_notification_ids if 'x_fc_office_notification_ids' in object.company_id._fields else []) if p.email) or (object.company_id.email or '') }}</field>
<field name="body_html" type="html">
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<div style="height:4px;background-color:#d69e2e;"></div>
<div style="padding:32px 28px;">
<p style="color:#d69e2e;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
Internal: New Service Call
</p>
<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">A new repair has been submitted</h2>
<p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
Submitted by <strong><t t-out="object.x_fc_intake_user_id.name or object.user_id.name or 'system'"/></strong>
via the <strong><t t-out="dict(object._fields['x_fc_intake_source'].selection).get(object.x_fc_intake_source) or 'intake'"/></strong>.
</p>
<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Details</td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Reference</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.name"/></td></tr>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Client</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.partner_id.name or 'Walk-in / unknown'"/></td></tr>
<t t-if="object.partner_id.phone">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Phone</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.partner_id.phone"/></td></tr>
</t>
<t t-if="object.product_id">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Equipment</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.product_id.display_name"/></td></tr>
</t>
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Urgency</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="dict(object._fields['x_fc_urgency'].selection).get(object.x_fc_urgency) or 'normal'"/></td></tr>
<t t-if="object.x_fc_third_party_equipment">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Third-party</td><td style="padding:10px 14px;color:#d69e2e;font-size:14px;font-weight:600;border-bottom:1px solid rgba(128,128,128,0.15);">Yes - equipment not sold by us</td></tr>
</t>
<t t-if="object.under_warranty">
<tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Warranty</td><td style="padding:10px 14px;color:#38a169;font-size:14px;font-weight:600;border-bottom:1px solid rgba(128,128,128,0.15);">Under warranty</td></tr>
</t>
</table>
</div>
</div>
</field>
<field name="auto_delete" eval="True"/>
</record>
</data>
</odoo>