feat(fusion_repairs): maintenance foundation - policy + priced auto-contracts on sale (Plan 1)
Plan 1 of fusion_maintenance, verified on the Westin Enterprise sandbox (westin-fr-test) via odoo shell. Maintenance policy (enabled/interval/flat fee/service product) on the equipment category + per-product fee override; contract gains fee/source/serial/policy/currency; fixed the dead _spawn_maintenance_contracts and wired it into the existing action_confirm (delivery-date anchor w/ fallback, two-regime serial dedup, fee resolution product->category); reminder email shows the flat fee; category form exposes the policy. Verified: trigger creates 1 priced contract (fee 149, next_due commitment+6mo, source=sale); idempotent on re-confirm; product override beats category; no contract when category not maintainable; fee renders as $149.00. v19.0.2.3.0. NOTE: mail_template_data.xml is noupdate=1 -> the fee line loads on fresh install (the prod deploy) but NOT on -u of an already-installed system. The Westin prod-config test container (workers + log_level=warn) does not run --test-enable post_install tests (a pre-existing module load issue under the test phase), so behaviour was verified by odoo shell instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,26 @@ class FusionRepairMaintenanceContract(models.Model):
|
||||
'res.company', default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
x_fc_maintenance_fee = fields.Monetary(
|
||||
string='Maintenance Fee', currency_field='currency_id',
|
||||
help='Flat fee shown to the client for this maintenance visit.',
|
||||
)
|
||||
x_fc_source = fields.Selection(
|
||||
[('sale', 'New Sale'), ('backfill', 'Backfill'),
|
||||
('claims', 'Claims Bridge'), ('manual', 'Manual')],
|
||||
string='Source', default='manual', index=True,
|
||||
)
|
||||
x_fc_source_sale_line_id = fields.Many2one(
|
||||
'sale.order.line', string='Source Sale Line', index=True, copy=False,
|
||||
)
|
||||
x_fc_device_serial = fields.Char(string='Serial (text)', index=True, copy=False)
|
||||
x_fc_policy_category_id = fields.Many2one(
|
||||
'fusion.repair.product.category', string='Maintenance Policy',
|
||||
)
|
||||
|
||||
_booking_token_unique = models.Constraint(
|
||||
'unique(booking_token)',
|
||||
'Booking token must be unique.',
|
||||
@@ -195,11 +215,18 @@ class FusionRepairMaintenanceContract(models.Model):
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
def _fc_maintenance_anchor_date(self, line):
|
||||
"""Best-available delivery anchor: commitment_date -> date_order -> today.
|
||||
(Non-ADP / lift units lack a delivery date; this fallback chain handles them.)"""
|
||||
so = line.order_id
|
||||
anchor = so.commitment_date or so.date_order
|
||||
return fields.Date.to_date(anchor) if anchor else fields.Date.context_today(self)
|
||||
|
||||
def _spawn_maintenance_contracts(self):
|
||||
"""Create maintenance contracts for any delivered SO line whose
|
||||
product has x_fc_maintenance_interval_months > 0."""
|
||||
"""Create a priced maintenance contract per maintainable unit on a confirmed SO.
|
||||
Policy = product interval override, else the product's category policy.
|
||||
Idempotent: by serial when captured, else by source sale line."""
|
||||
Contract = self.env['fusion.repair.maintenance.contract'].sudo()
|
||||
today = fields.Date.context_today(self)
|
||||
for so in self:
|
||||
if so.state not in ('sale', 'done'):
|
||||
continue
|
||||
@@ -207,21 +234,42 @@ class SaleOrder(models.Model):
|
||||
product = line.product_id
|
||||
if not product:
|
||||
continue
|
||||
interval = product.product_tmpl_id.x_fc_maintenance_interval_months or 0
|
||||
if interval <= 0:
|
||||
tmpl = product.product_tmpl_id
|
||||
category = tmpl.x_fc_repair_category_id
|
||||
product_interval = tmpl.x_fc_maintenance_interval_months or 0
|
||||
cat_enabled = bool(category) and category.x_fc_maintenance_enabled
|
||||
interval = product_interval or (
|
||||
category.x_fc_maintenance_interval_months if cat_enabled else 0)
|
||||
if interval <= 0 or not (product_interval > 0 or cat_enabled):
|
||||
continue
|
||||
existing = Contract.search([
|
||||
('partner_id', '=', so.partner_id.id),
|
||||
('product_id', '=', product.id),
|
||||
('original_sale_order_id', '=', so.id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
fee = tmpl.x_fc_maintenance_fee or (
|
||||
category.x_fc_maintenance_fee if category else 0.0)
|
||||
# Capture serial only if fusion_claims' line field is present.
|
||||
serial = ''
|
||||
if 'x_fc_serial_number' in line._fields:
|
||||
serial = (line.x_fc_serial_number or '').strip()
|
||||
# Idempotency: serial regime vs source-line regime (spec 6.2).
|
||||
if serial:
|
||||
dedup = [('state', '=', 'active'), ('x_fc_device_serial', '=', serial)]
|
||||
else:
|
||||
dedup = [('state', '=', 'active'),
|
||||
('x_fc_source_sale_line_id', '=', line.id)]
|
||||
if Contract.search_count(dedup):
|
||||
continue
|
||||
Contract.create({
|
||||
'partner_id': so.partner_id.id,
|
||||
'product_id': product.id,
|
||||
'original_sale_order_id': so.id,
|
||||
'interval_months': interval,
|
||||
'next_due_date': today + relativedelta(months=interval),
|
||||
'state': 'active',
|
||||
})
|
||||
anchor = so._fc_maintenance_anchor_date(line)
|
||||
# One contract per serialized unit; without a serial, per quantity.
|
||||
count = 1 if serial else max(int(line.product_uom_qty or 1), 1)
|
||||
for _i in range(count):
|
||||
Contract.create({
|
||||
'partner_id': so.partner_id.id,
|
||||
'product_id': product.id,
|
||||
'original_sale_order_id': so.id,
|
||||
'x_fc_source_sale_line_id': line.id,
|
||||
'x_fc_source': 'sale',
|
||||
'x_fc_device_serial': serial,
|
||||
'x_fc_policy_category_id': category.id if category else False,
|
||||
'interval_months': interval,
|
||||
'x_fc_maintenance_fee': fee,
|
||||
'next_due_date': anchor + relativedelta(months=interval),
|
||||
'state': 'active',
|
||||
})
|
||||
|
||||
@@ -26,6 +26,10 @@ class ProductTemplate(models.Model):
|
||||
help='If > 0, delivering a unit of this product auto-creates a maintenance contract '
|
||||
'with this recurring interval. Phase 3 feature.',
|
||||
)
|
||||
x_fc_maintenance_fee = fields.Monetary(
|
||||
string='Maintenance Fee (override)', currency_field='currency_id',
|
||||
help='Per-product override of the category maintenance fee. 0 = use the category fee.',
|
||||
)
|
||||
x_fc_intake_template_id = fields.Many2one(
|
||||
'fusion.repair.intake.template',
|
||||
string='Intake Template Override',
|
||||
|
||||
@@ -53,6 +53,31 @@ class FusionRepairProductCategory(models.Model):
|
||||
help='Default intake question set shown when this category is selected.',
|
||||
)
|
||||
|
||||
# ── Maintenance policy (per equipment type) ──────────────────────────
|
||||
x_fc_maintenance_enabled = fields.Boolean(
|
||||
string='Offer Maintenance',
|
||||
help='If set, units in this category are enrolled in recurring preventive '
|
||||
'maintenance on sale (and via the backfill wizard).',
|
||||
)
|
||||
x_fc_maintenance_interval_months = fields.Integer(
|
||||
string='Maintenance Interval (Months)', default=6,
|
||||
help='Default months between preventive maintenance visits for this category. '
|
||||
'Overridden by the product field of the same name when that is > 0.',
|
||||
)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Currency',
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
x_fc_maintenance_fee = fields.Monetary(
|
||||
string='Maintenance Fee', currency_field='currency_id',
|
||||
help='Flat fee shown to the client for a maintenance visit of this equipment type.',
|
||||
)
|
||||
x_fc_maintenance_service_product_id = fields.Many2one(
|
||||
'product.product', string='Maintenance Service Product',
|
||||
help='Optional product used when drafting the priced visit line (Plan 2). '
|
||||
'Falls back to a generic visit product.',
|
||||
)
|
||||
|
||||
_code_unique = models.Constraint(
|
||||
'unique(code)',
|
||||
'Category code must be unique.',
|
||||
|
||||
@@ -247,6 +247,7 @@ class SaleOrder(models.Model):
|
||||
# Bundle 9: spawn store labor warranties for any product line with
|
||||
# x_fc_labor_warranty_years > 0.
|
||||
self._fc_spawn_labor_warranties()
|
||||
self._spawn_maintenance_contracts()
|
||||
return res
|
||||
|
||||
def _fc_spawn_labor_warranties(self):
|
||||
|
||||
Reference in New Issue
Block a user