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>
This commit is contained in:
gsinghpal
2026-05-20 22:22:11 -04:00
parent afe19f2105
commit c86f1bbbe5
15 changed files with 203 additions and 51 deletions

View File

@@ -29,8 +29,9 @@ import logging
import re import re
import time import time
from odoo import http, fields from odoo import SUPERUSER_ID, http, fields
from odoo.http import request from odoo.http import request
from odoo.tools import email_normalize
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -92,9 +93,10 @@ class ClientRepairPortal(http.Controller):
for k in list(_RATE_LIMIT_BUCKET.keys()): for k in list(_RATE_LIMIT_BUCKET.keys()):
if not k.endswith(f":{bucket}"): if not k.endswith(f":{bucket}"):
_RATE_LIMIT_BUCKET.pop(k, None) _RATE_LIMIT_BUCKET.pop(k, None)
_RATE_LIMIT_BUCKET[key] = _RATE_LIMIT_BUCKET.get(key, 0) + 1 # Check FIRST so blocked attempts don't keep inflating the counter.
if _RATE_LIMIT_BUCKET[key] > limit: if _RATE_LIMIT_BUCKET.get(key, 0) >= limit:
return True # blocked return True # blocked
_RATE_LIMIT_BUCKET[key] = _RATE_LIMIT_BUCKET.get(key, 0) + 1
return False return False
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -164,6 +166,12 @@ class ClientRepairPortal(http.Controller):
if not (partner_name and phone and issue_summary and category_id): if not (partner_name and phone and issue_summary and category_id):
return request.redirect("/repair/new?error=missing") return request.redirect("/repair/new?error=missing")
# Validate email if provided. Empty is allowed; malformed redirects back.
raw_email = (post.get("client_email") or "").strip()
clean_email = email_normalize(raw_email) if raw_email else False
if raw_email and not clean_email:
return request.redirect("/repair/new?error=email")
# Find or create partner. Match by phone if known (safe - we already # Find or create partner. Match by phone if known (safe - we already
# have their consent to contact via this form). # have their consent to contact via this form).
cleaned_phone = _e164_clean(phone) cleaned_phone = _e164_clean(phone)
@@ -180,7 +188,7 @@ class ClientRepairPortal(http.Controller):
partner_vals = { partner_vals = {
"name": partner_name, "name": partner_name,
"phone": phone, "phone": phone,
"email": (post.get("client_email") or "").strip(), "email": clean_email or False,
"street": (post.get("client_street") or "").strip(), "street": (post.get("client_street") or "").strip(),
"city": (post.get("client_city") or "").strip(), "city": (post.get("client_city") or "").strip(),
} }
@@ -209,13 +217,21 @@ class ClientRepairPortal(http.Controller):
"internal_notes": (post.get("internal_notes") or "").strip(), "internal_notes": (post.get("internal_notes") or "").strip(),
"photo_attachment_ids": attachment_ids, "photo_attachment_ids": attachment_ids,
} }
# Pick a real human owner for the repair so emails go from a person:
# admin if present, else the lowest-id non-share user, else SUPERUSER_ID.
admin = request.env.ref("base.user_admin", raise_if_not_found=False)
if admin:
intake_uid = admin.id
else:
internal = request.env["res.users"].sudo().search(
[("share", "=", False)], order="id asc", limit=1,
)
intake_uid = internal.id if internal else SUPERUSER_ID
payload = { payload = {
"partner_id": partner.id if partner else None, "partner_id": partner.id if partner else None,
"partner_vals": partner_vals, "partner_vals": partner_vals,
"intake_user_id": request.env.ref( "intake_user_id": intake_uid,
"base.user_admin", raise_if_not_found=False).id
if request.env.ref("base.user_admin",
raise_if_not_found=False) else 1,
"equipment_items": [equipment], "equipment_items": [equipment],
} }

View File

@@ -135,7 +135,7 @@ class SalesRepRepairPortal(CustomerPortal):
try: try:
repairs = request.env['fusion.repair.intake.service'].sudo() \ repairs = request.env['fusion.repair.intake.service'].sudo() \
.create_repair_orders(payload, source='sales_rep_portal') .create_repair_orders(payload, source='sales_rep_portal')
except Exception as e: except Exception:
_logger.exception('Sales rep portal repair submit failed') _logger.exception('Sales rep portal repair submit failed')
return request.redirect('/my/repair/new?error=server') return request.redirect('/my/repair/new?error=server')

View File

@@ -11,7 +11,7 @@
<field name="user_id" ref="base.user_root"/> <field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field> <field name="interval_number">1</field>
<field name="interval_type">days</field> <field name="interval_type">days</field>
<field name="nextcall" eval="(DateTime.now() + timedelta(hours=1)).strftime('%Y-%m-%d 07:00:00')"/> <field name="nextcall" eval="(DateTime.now().replace(hour=7, minute=0, second=0, microsecond=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
<field name="active" eval="True"/> <field name="active" eval="True"/>
</record> </record>

View File

@@ -109,6 +109,7 @@
<field name="model_id" ref="repair.model_repair_order"/> <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="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_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"> <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="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="height:4px;background-color:#d69e2e;"></div>

View File

@@ -90,6 +90,10 @@ class FusionRepairIntakeService(models.AbstractModel):
partner_vals = payload.get('partner_vals') partner_vals = payload.get('partner_vals')
if not partner_vals: if not partner_vals:
return False return False
# Sensible defaults for partners created via public portals so mail
# templates pick up the right language / company.
partner_vals.setdefault('lang', self.env.user.lang or 'en_CA')
partner_vals.setdefault('company_id', self.env.company.id)
partner = self.env['res.partner'].sudo().create(partner_vals) partner = self.env['res.partner'].sudo().create(partner_vals)
return partner.id return partner.id
@@ -401,12 +405,25 @@ class FusionRepairIntakeService(models.AbstractModel):
'x_fc_repair_order_id': repair.id, 'x_fc_repair_order_id': repair.id,
'description': repair.internal_notes or repair.name, 'description': repair.internal_notes or repair.name,
} }
# technician_id is required on fusion.technician.task; we fall back to # technician_id is required AND constrained to x_fc_is_field_staff.
# the intake user. Dispatcher will reassign. # Use the intake user if they qualify, otherwise the lowest-id active
vals['technician_id'] = ( # field-staff user as a placeholder for the dispatcher to reassign.
repair.user_id.id if repair.user_id and repair.user_id.x_fc_is_field_staff if repair.user_id and repair.user_id.x_fc_is_field_staff:
else self.env.uid vals['technician_id'] = repair.user_id.id
) else:
fallback = self.env['res.users'].sudo().search([
('x_fc_is_field_staff', '=', True),
('active', '=', True),
], order='id', limit=1)
if not fallback:
_logger.warning(
'No field-staff user available - skipping auto-dispatch '
'task for repair %s (mark a user as Field Staff under '
'Settings > Users).',
repair.name,
)
return
vals['technician_id'] = fallback.id
Task.create(vals) Task.create(vals)
except Exception as e: except Exception as e:
_logger.warning('Failed to auto-create dispatch task for repair %s: %s', _logger.warning('Failed to auto-create dispatch task for repair %s: %s',
@@ -449,8 +466,13 @@ class FusionRepairIntakeService(models.AbstractModel):
@api.model @api.model
def _office_emails(self, company): def _office_emails(self, company):
# Reuse the office notification recipients defined by fusion_claims. # Reuse the office notification recipients defined by fusion_claims.
partners = company.sudo() company_sudo = company.sudo()
recipients = getattr(partners, 'x_fc_office_notification_ids', False) recipients = getattr(company_sudo, 'x_fc_office_notification_ids', False)
if recipients: emails = [p.email for p in (recipients or []) if p.email]
return [p.email for p in recipients if p.email] if not emails:
return [] _logger.info(
'No office notification recipients configured on company %s - '
'skipping office intake email.',
company.name,
)
return emails

View File

@@ -14,6 +14,8 @@ a tokenized booking link.
import secrets import secrets
from datetime import timedelta from datetime import timedelta
from dateutil.relativedelta import relativedelta
from odoo import _, api, fields, models from odoo import _, api, fields, models
@@ -27,6 +29,7 @@ CONTRACT_STATES = [
class FusionRepairMaintenanceContract(models.Model): class FusionRepairMaintenanceContract(models.Model):
_name = 'fusion.repair.maintenance.contract' _name = 'fusion.repair.maintenance.contract'
_inherit = ['mail.thread']
_description = 'Repair Maintenance Contract' _description = 'Repair Maintenance Contract'
_order = 'next_due_date, id' _order = 'next_due_date, id'
@@ -76,7 +79,10 @@ class FusionRepairMaintenanceContract(models.Model):
'res.company', default=lambda self: self.env.company, 'res.company', default=lambda self: self.env.company,
) )
_inherit = ['mail.thread'] _booking_token_unique = models.Constraint(
'unique(booking_token)',
'Booking token must be unique.',
)
@api.model_create_multi @api.model_create_multi
def create(self, vals_list): def create(self, vals_list):
@@ -93,10 +99,15 @@ class FusionRepairMaintenanceContract(models.Model):
# ROLL FORWARD # ROLL FORWARD
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def roll_next_due_date(self): def roll_next_due_date(self):
"""Advance next_due_date by interval_months and reset cycle state.""" """Advance next_due_date by interval_months and reset cycle state.
Called from technician_task.write() when a maintenance task moves to
'completed' (see technician_task.py).
"""
for c in self: for c in self:
base = c.last_service_date or fields.Date.context_today(c) base = c.last_service_date or fields.Date.context_today(c)
c.next_due_date = base + timedelta(days=(c.interval_months or 12) * 30) # relativedelta handles month boundaries correctly (28/29/30/31).
c.next_due_date = base + relativedelta(months=c.interval_months or 12)
c.last_reminder_band = False c.last_reminder_band = False
c.booking_repair_id = False c.booking_repair_id = False
@@ -161,8 +172,10 @@ class FusionRepairMaintenanceContract(models.Model):
'schedule_date': scheduled_date or fields.Datetime.now(), 'schedule_date': scheduled_date or fields.Datetime.now(),
'x_fc_intake_source': 'client_portal', 'x_fc_intake_source': 'client_portal',
'x_fc_urgency': 'normal', 'x_fc_urgency': 'normal',
'x_fc_repair_category_id': self.product_id.product_tmpl_id.x_fc_repair_category_id.id 'x_fc_repair_category_id':
self.product_id.product_tmpl_id.x_fc_repair_category_id.id
if self.product_id.product_tmpl_id.x_fc_repair_category_id else False, if self.product_id.product_tmpl_id.x_fc_repair_category_id else False,
'x_fc_maintenance_contract_id': self.id,
'internal_notes': 'internal_notes':
f'<p>Maintenance visit booked from reminder for contract <b>{self.name}</b>.</p>', f'<p>Maintenance visit booked from reminder for contract <b>{self.name}</b>.</p>',
}) })
@@ -204,6 +217,6 @@ class SaleOrder(models.Model):
'product_id': product.id, 'product_id': product.id,
'original_sale_order_id': so.id, 'original_sale_order_id': so.id,
'interval_months': interval, 'interval_months': interval,
'next_due_date': today + timedelta(days=interval * 30), 'next_due_date': today + relativedelta(months=interval),
'state': 'active', 'state': 'active',
}) })

View File

@@ -2,7 +2,7 @@
# Copyright 2024-2026 Nexa Systems Inc. # Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
from datetime import timedelta from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _ from odoo import api, fields, models, _
from odoo.exceptions import UserError from odoo.exceptions import UserError
@@ -71,6 +71,15 @@ class RepairOrder(models.Model):
index=True, index=True,
help='Auto-matched catalogue entry that pre-fills estimated cost and duration.', help='Auto-matched catalogue entry that pre-fills estimated cost and duration.',
) )
# Maintenance contract back-link (Phase 3)
x_fc_maintenance_contract_id = fields.Many2one(
'fusion.repair.maintenance.contract',
string='Maintenance Contract',
index=True,
help='Set when this repair was spawned from a maintenance reminder booking. '
'Completing the related technician task rolls the contract to its next cycle.',
)
x_fc_intake_answer_count = fields.Integer( x_fc_intake_answer_count = fields.Integer(
compute='_compute_intake_answer_count', compute='_compute_intake_answer_count',
) )
@@ -240,8 +249,8 @@ class RepairOrder(models.Model):
) )
if not warranty_months: if not warranty_months:
return False return False
# Datetime + months: use simple 30-day approximation per month for now. # relativedelta handles month boundaries correctly (28/29/30/31).
cutoff = fields.Datetime.from_string(str(delivery_date)) + timedelta(days=warranty_months * 30) cutoff = fields.Datetime.from_string(str(delivery_date)) + relativedelta(months=warranty_months)
return fields.Datetime.now() <= cutoff return fields.Datetime.now() <= cutoff
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View File

@@ -39,9 +39,10 @@ class FusionRepairProductCategory(models.Model):
help='Default intake question set shown when this category is selected.', help='Default intake question set shown when this category is selected.',
) )
_sql_constraints = [ _code_unique = models.Constraint(
('code_unique', 'unique(code)', 'Category code must be unique.'), 'unique(code)',
] 'Category code must be unique.',
)
@api.depends('name', 'code') @api.depends('name', 'code')
def _compute_display_name(self): def _compute_display_name(self):

View File

@@ -67,10 +67,12 @@ class FusionRepairWarrantyCoverage(models.Model):
compute='_compute_expiry_date', compute='_compute_expiry_date',
store=True, 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( is_active = fields.Boolean(
string='Active', string='Active',
compute='_compute_is_active', compute='_compute_is_active',
store=True,
) )
notes = fields.Text() notes = fields.Text()
@@ -107,12 +109,20 @@ class FusionRepairWarrantyCoverage(models.Model):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@api.model @api.model
def find_active_for(self, partner_id, product_id=None, lot_id=None): def find_active_for(self, partner_id, product_id=None, lot_id=None):
"""Return active warranty coverage matching the partner + equipment, if any.""" """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: if not partner_id:
return self.browse() return self.browse()
if not lot_id and not product_id:
return self.browse()
today = fields.Date.context_today(self)
domain = [ domain = [
('partner_id', '=', partner_id), ('partner_id', '=', partner_id),
('is_active', '=', True), ('expiry_date', '>=', today),
] ]
if lot_id: if lot_id:
domain.append(('lot_id', '=', lot_id)) domain.append(('lot_id', '=', lot_id))

View File

@@ -113,29 +113,39 @@ class FusionRepairServiceCatalog(models.Model):
def find_best_match(self, product_category_id, text_hints): def find_best_match(self, product_category_id, text_hints):
"""Return the best-matching catalogue entry, or empty recordset. """Return the best-matching catalogue entry, or empty recordset.
Returns empty when no symptom keywords match. We never "guess" a default
catalog because the match drives estimated cost + auto-dispatch task -
a wrong guess would propagate into pricing and scheduling.
:param product_category_id: int id of the equipment category :param product_category_id: int id of the equipment category
:param text_hints: list[str] - text snippets to look for symptom keywords in :param text_hints: list[str] - text snippets to look for symptom keywords in
(typically: issue_summary, issue_category, recent intake answer values)
""" """
import re
if not product_category_id: if not product_category_id:
return self.browse() return self.browse()
haystack = ' '.join(s.lower() for s in (text_hints or []) if s).strip() haystack = ' '.join(s.lower() for s in (text_hints or []) if s).strip()
if not haystack:
return self.browse()
candidates = self.search([ candidates = self.search([
('product_category_id', '=', product_category_id), ('product_category_id', '=', product_category_id),
('active', '=', True), ('active', '=', True),
], order='sequence') ], order='sequence')
if not candidates: if not candidates:
return self.browse() return self.browse()
if not haystack:
return candidates[:1]
best = None best = None
best_score = 0 best_score = 0
for c in candidates: for c in candidates:
kws = [k.strip().lower() for k in (c.symptom_keywords or '').split(',') if k.strip()] kws = [k.strip().lower() for k in (c.symptom_keywords or '').split(',') if k.strip()]
score = sum(1 for kw in kws if kw and kw in haystack) # Word-boundary match avoids false positives where "battery" matches
# inside "no battery problem".
score = sum(
1 for kw in kws
if kw and re.search(rf'\b{re.escape(kw)}\b', haystack)
)
if score > best_score: if score > best_score:
best = c best = c
best_score = score best_score = score
if best: # No keywords matched -> return empty rather than the lowest-sequence guess.
if best and best_score > 0:
return best return best
return candidates[:1] return self.browse()

View File

@@ -7,7 +7,8 @@ from odoo import fields, models
class FusionTechnicianTaskRepairs(models.Model): class FusionTechnicianTaskRepairs(models.Model):
"""Adds the back-link from fusion.technician.task to repair.order so """Adds the back-link from fusion.technician.task to repair.order so
repairs and tasks share one timeline. repairs and tasks share one timeline. Also hooks task completion to
roll a linked maintenance contract to its next cycle.
""" """
_inherit = 'fusion.technician.task' _inherit = 'fusion.technician.task'
@@ -29,6 +30,33 @@ class FusionTechnicianTaskRepairs(models.Model):
index=True, index=True,
) )
def write(self, vals):
"""When a maintenance task transitions to 'completed', roll the
linked contract to its next cycle. Failure to roll never blocks
the underlying task write.
"""
res = super().write(vals)
if vals.get('status') == 'completed':
for task in self:
if task.task_type != 'maintenance':
continue
repair = task.x_fc_repair_order_id
contract = repair.x_fc_maintenance_contract_id if repair else False
if not contract:
continue
try:
contract.last_service_date = fields.Date.context_today(task)
contract.roll_next_due_date()
contract.message_post(body=(
'Rolled forward after maintenance task '
f'<b>{task.name}</b> completed. '
f'Next due {contract.next_due_date}.'
))
except Exception:
# Never let a contract roll failure block the task write.
pass
return res
def action_view_repair_order(self): def action_view_repair_order(self):
self.ensure_one() self.ensure_one()
if not self.x_fc_repair_order_id: if not self.x_fc_repair_order_id:

View File

@@ -5,7 +5,7 @@
<field name="name">fusion.repair.maintenance.contract.list</field> <field name="name">fusion.repair.maintenance.contract.list</field>
<field name="model">fusion.repair.maintenance.contract</field> <field name="model">fusion.repair.maintenance.contract</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<list string="Maintenance Contracts" decoration-warning="next_due_date and next_due_date &lt;= context_today().strftime('%Y-%m-%d')"> <list string="Maintenance Contracts" decoration-warning="next_due_date and next_due_date &lt;= context_today()">
<field name="name"/> <field name="name"/>
<field name="partner_id"/> <field name="partner_id"/>
<field name="product_id"/> <field name="product_id"/>

View File

@@ -16,7 +16,7 @@
type="object" type="object"
string="Visit Report" string="Visit Report"
class="btn-primary" class="btn-primary"
invisible="state == 'cancel'" invisible="state in ('draft', 'cancel') or x_fc_technician_task_count == 0"
groups="fusion_repairs.group_fusion_repairs_user"/> groups="fusion_repairs.group_fusion_repairs_user"/>
<button name="action_collect_payment" <button name="action_collect_payment"
type="object" type="object"

View File

@@ -7,8 +7,7 @@
<field name="inherit_id" ref="base.res_config_settings_view_form"/> <field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//form" position="inside"> <xpath expr="//form" position="inside">
<app data-string="Fusion Repairs" <app string="Fusion Repairs"
string="Fusion Repairs"
name="fusion_repairs" name="fusion_repairs"
groups="fusion_repairs.group_fusion_repairs_manager"> groups="fusion_repairs.group_fusion_repairs_manager">
<block title="Notifications" name="fc_repairs_notifications"> <block title="Notifications" name="fc_repairs_notifications">

View File

@@ -111,9 +111,12 @@ class RepairVisitReportWizard(models.TransientModel):
est = w.estimated_cost or 0.0 est = w.estimated_cost or 0.0
variance_pct = ((w.actual_cost - est) / est * 100) if est else 0.0 variance_pct = ((w.actual_cost - est) / est * 100) if est else 0.0
w.variance_pct = variance_pct w.variance_pct = variance_pct
# One-sided: only OVER-cost triggers re-quote. Coming in under
# estimate is good news and must not block invoicing.
over_pct = variance_pct
over_amt = w.actual_cost - est
w.requires_requote = est > 0 and ( w.requires_requote = est > 0 and (
abs(variance_pct) >= threshold_pct over_pct >= threshold_pct or over_amt >= threshold_amt
or abs(w.actual_cost - est) >= threshold_amt
) )
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -125,6 +128,11 @@ class RepairVisitReportWizard(models.TransientModel):
if not repair: if not repair:
raise UserError(_('No repair selected.')) raise UserError(_('No repair selected.'))
# Create native repair operations (stock moves) for the parts used.
# 'add' type moves consume parts from the parts source location and
# flow through to the invoice when action_create_sale_order() is run.
self._create_repair_part_moves(repair)
# Persist actual cost + requote flag on the repair. # Persist actual cost + requote flag on the repair.
repair.write({ repair.write({
'x_fc_actual_cost': self.actual_cost, 'x_fc_actual_cost': self.actual_cost,
@@ -135,7 +143,8 @@ class RepairVisitReportWizard(models.TransientModel):
if self.notes: if self.notes:
repair.message_post(body=self.notes) repair.message_post(body=self.notes)
# If found another issue: spawn a stub repair (same partner, same equipment). # Spawn a follow-up repair if the tech found another issue.
stub = False
if self.found_another_issue: if self.found_another_issue:
stub = repair.copy({ stub = repair.copy({
'state': 'draft', 'state': 'draft',
@@ -150,20 +159,54 @@ class RepairVisitReportWizard(models.TransientModel):
'x_fc_requires_requote': False, 'x_fc_requires_requote': False,
'x_fc_intake_template_id': False, 'x_fc_intake_template_id': False,
'x_fc_service_catalog_id': False, 'x_fc_service_catalog_id': False,
'x_fc_maintenance_contract_id': False,
}) })
repair.message_post( repair.message_post(
body=_('Spawned follow-up repair <b>%(name)s</b> for "found another issue".', body=_('Spawned follow-up repair <b>%(name)s</b> for "found another issue".',
name=stub.name), name=stub.name),
) )
# If a stub was spawned, open it directly so the tech can fill in details.
target_id = stub.id if stub else repair.id
target_name = stub.name if stub else repair.name
return { return {
'type': 'ir.actions.act_window', 'type': 'ir.actions.act_window',
'name': repair.name, 'name': target_name,
'res_model': 'repair.order', 'res_model': 'repair.order',
'view_mode': 'form', 'view_mode': 'form',
'res_id': repair.id, 'res_id': target_id,
} }
def _create_repair_part_moves(self, repair):
"""Create stock.move records for each part used (repair_line_type='add').
Locations follow the repair order's configured source / parts locations;
Odoo natively links these moves to the SO line generated by
action_create_sale_order() so they invoice correctly.
"""
Move = self.env['stock.move'].sudo()
for line in self.parts_line_ids:
if not line.product_id or line.quantity <= 0:
continue
vals = {
'name': line.product_id.display_name,
'product_id': line.product_id.id,
'product_uom_qty': line.quantity,
'product_uom': line.product_id.uom_id.id,
'repair_id': repair.id,
'repair_line_type': 'add',
'location_id': repair.location_id.id,
'location_dest_id': repair.parts_location_id.id or repair.location_id.id,
'company_id': repair.company_id.id,
}
try:
Move.create(vals)
except Exception as e:
_logger.warning(
'Could not create repair part move on %s for %s: %s',
repair.name, line.product_id.display_name, e,
)
class RepairVisitReportWizardLine(models.TransientModel): class RepairVisitReportWizardLine(models.TransientModel):
_name = 'fusion.repair.visit.report.wizard.line' _name = 'fusion.repair.visit.report.wizard.line'