Files
Odoo-Modules/fusion_repairs/models/repair_warranty.py
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

132 lines
4.1 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""Repair warranty coverage.
Tracks the 30/90-day warranty we offer on completed repair work.
When a new repair is created on the same equipment within the
coverage window, the intake wizard / portal shows a banner:
"This repair may be covered by our warranty - no charge".
Phase 2 ships the model + manual creation from a completed repair.
Phase 4 will add automatic creation when a repair moves to 'done'.
"""
from datetime import timedelta
from odoo import api, fields, models
class FusionRepairWarrantyCoverage(models.Model):
_name = 'fusion.repair.warranty.coverage'
_description = 'Repair Warranty Coverage'
_order = 'expiry_date desc, id desc'
name = fields.Char(string='Reference', compute='_compute_name', store=True)
repair_id = fields.Many2one(
'repair.order',
string='Original Repair',
required=True,
ondelete='cascade',
index=True,
)
partner_id = fields.Many2one(
'res.partner',
string='Client',
related='repair_id.partner_id',
store=True,
index=True,
)
product_id = fields.Many2one(
'product.product',
string='Equipment',
related='repair_id.product_id',
store=True,
index=True,
)
lot_id = fields.Many2one(
'stock.lot',
string='Serial Number',
related='repair_id.lot_id',
store=True,
)
start_date = fields.Date(
string='Start Date',
required=True,
default=fields.Date.context_today,
)
coverage_days = fields.Integer(
string='Coverage Window (days)',
default=30,
required=True,
)
expiry_date = fields.Date(
string='Expires',
compute='_compute_expiry_date',
store=True,
)
# Non-stored compute - DO NOT add store=True. The 'active vs not' status is
# time-dependent (today >= expiry_date), and a stored compute would never
# auto-refresh as days pass. find_active_for() filters by expiry_date directly.
is_active = fields.Boolean(
string='Active',
compute='_compute_is_active',
)
notes = fields.Text()
company_id = fields.Many2one(
'res.company',
string='Company',
related='repair_id.company_id',
store=True,
)
@api.depends('repair_id.name', 'expiry_date')
def _compute_name(self):
for w in self:
w.name = (
f"Warranty {w.repair_id.name or '?'} (until {w.expiry_date or '?'})"
)
@api.depends('start_date', 'coverage_days')
def _compute_expiry_date(self):
for w in self:
if w.start_date and w.coverage_days:
w.expiry_date = w.start_date + timedelta(days=w.coverage_days)
else:
w.expiry_date = False
@api.depends('expiry_date')
def _compute_is_active(self):
today = fields.Date.context_today(self)
for w in self:
w.is_active = bool(w.expiry_date and w.expiry_date >= today)
# ------------------------------------------------------------------
# LOOKUP
# ------------------------------------------------------------------
@api.model
def find_active_for(self, partner_id, product_id=None, lot_id=None):
"""Return active warranty coverage matching the partner + equipment, if any.
Requires at least one of lot_id or product_id - without an equipment
identifier we would match any warranty on the partner, which would
falsely flag unrelated equipment as covered.
"""
if not partner_id:
return self.browse()
if not lot_id and not product_id:
return self.browse()
today = fields.Date.context_today(self)
domain = [
('partner_id', '=', partner_id),
('expiry_date', '>=', today),
]
if lot_id:
domain.append(('lot_id', '=', lot_id))
elif product_id:
domain.append(('product_id', '=', product_id))
return self.search(domain, order='expiry_date desc', limit=1)