diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index a4288acc..d265c364 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -4,7 +4,7 @@ { 'name': 'Fusion Repairs', - 'version': '19.0.2.2.6', + 'version': '19.0.2.3.0', 'category': 'Inventory/Repairs', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'description': """ diff --git a/fusion_repairs/data/mail_template_data.xml b/fusion_repairs/data/mail_template_data.xml index fb12c0e4..290fd354 100644 --- a/fusion_repairs/data/mail_template_data.xml +++ b/fusion_repairs/data/mail_template_data.xml @@ -433,6 +433,12 @@ is due for its next scheduled maintenance visit on .

+
+

+ Maintenance visit fee: + + applicable tax. +

+
diff --git a/fusion_repairs/models/maintenance_contract.py b/fusion_repairs/models/maintenance_contract.py index f9fa93b1..13b6d3de 100644 --- a/fusion_repairs/models/maintenance_contract.py +++ b/fusion_repairs/models/maintenance_contract.py @@ -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', + }) diff --git a/fusion_repairs/models/product_template.py b/fusion_repairs/models/product_template.py index 778710d9..2e451083 100644 --- a/fusion_repairs/models/product_template.py +++ b/fusion_repairs/models/product_template.py @@ -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', diff --git a/fusion_repairs/models/repair_product_category.py b/fusion_repairs/models/repair_product_category.py index 296430f1..16868a1f 100644 --- a/fusion_repairs/models/repair_product_category.py +++ b/fusion_repairs/models/repair_product_category.py @@ -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.', diff --git a/fusion_repairs/models/repair_service_plan.py b/fusion_repairs/models/repair_service_plan.py index 41cc41e0..403739ef 100644 --- a/fusion_repairs/models/repair_service_plan.py +++ b/fusion_repairs/models/repair_service_plan.py @@ -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): diff --git a/fusion_repairs/tests/__init__.py b/fusion_repairs/tests/__init__.py new file mode 100644 index 00000000..d096a047 --- /dev/null +++ b/fusion_repairs/tests/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import test_maintenance_foundation diff --git a/fusion_repairs/tests/test_maintenance_foundation.py b/fusion_repairs/tests/test_maintenance_foundation.py new file mode 100644 index 00000000..6f91076d --- /dev/null +++ b/fusion_repairs/tests/test_maintenance_foundation.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +from odoo.tests import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestMaintenanceFoundation(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env['res.partner'].create({'name': 'Mrs. Test Client'}) + cls.category = cls.env['fusion.repair.product.category'].create({ + 'name': 'Stair Lift', 'code': 'stairlift_test', + 'equipment_class': 'lift_elevating', 'safety_critical': True, + 'x_fc_maintenance_enabled': True, + 'x_fc_maintenance_interval_months': 6, + 'x_fc_maintenance_fee': 149.0, + }) + + # ---- Tasks 1/2/3: fields exist ---- + def test_category_policy_fields(self): + self.assertTrue(self.category.x_fc_maintenance_enabled) + self.assertEqual(self.category.x_fc_maintenance_interval_months, 6) + self.assertEqual(self.category.x_fc_maintenance_fee, 149.0) + self.assertTrue(self.category.currency_id) + + def test_product_fee_override_field(self): + tmpl = self.env['product.template'].create({ + 'name': 'Handicare Freecurve Stairlift', + 'x_fc_repair_category_id': self.category.id, + 'x_fc_maintenance_fee': 199.0, + }) + self.assertEqual(tmpl.x_fc_maintenance_fee, 199.0) + + def test_contract_extension_fields(self): + product = self.env['product.product'].create({'name': 'Unit'}) + c = self.env['fusion.repair.maintenance.contract'].create({ + 'partner_id': self.partner.id, + 'product_id': product.id, + 'next_due_date': '2026-12-01', + 'x_fc_source': 'sale', + 'x_fc_device_serial': 'SN-123', + 'x_fc_maintenance_fee': 149.0, + }) + self.assertEqual(c.x_fc_source, 'sale') + self.assertEqual(c.x_fc_device_serial, 'SN-123') + self.assertEqual(c.x_fc_maintenance_fee, 149.0) + + # ---- Task 4: spawn priced contracts on sale confirm ---- + def _make_product(self, **kw): + vals = {'name': 'Stairlift Unit', 'type': 'consu', + 'x_fc_repair_category_id': self.category.id} + vals.update(kw) + return self.env['product.product'].create(vals) + + def _confirm_so(self, product, commitment='2026-01-10'): + so = self.env['sale.order'].create({ + 'partner_id': self.partner.id, + 'commitment_date': commitment, + 'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 1})], + }) + so.action_confirm() + return so + + def _contracts_for(self, so): + return self.env['fusion.repair.maintenance.contract'].search( + [('original_sale_order_id', '=', so.id)]) + + def test_no_contract_when_category_not_maintainable(self): + cat = self.env['fusion.repair.product.category'].create( + {'name': 'Cane', 'code': 'cane_test', 'x_fc_maintenance_enabled': False}) + so = self._confirm_so(self._make_product(x_fc_repair_category_id=cat.id)) + self.assertFalse(self._contracts_for(so)) + + def test_contract_created_via_category_policy(self): + so = self._confirm_so(self._make_product()) + contracts = self._contracts_for(so) + self.assertEqual(len(contracts), 1) + c = contracts + self.assertEqual(c.interval_months, 6) + self.assertEqual(c.x_fc_maintenance_fee, 149.0) + self.assertEqual(c.x_fc_source, 'sale') + self.assertEqual(c.x_fc_policy_category_id, self.category) + # anchor = commitment_date (2026-01-10) + 6 months + self.assertEqual(str(c.next_due_date), '2026-07-10') + + def test_product_override_beats_category(self): + p = self._make_product() + p.product_tmpl_id.x_fc_maintenance_interval_months = 3 + p.product_tmpl_id.x_fc_maintenance_fee = 199.0 + so = self._confirm_so(p) + c = self._contracts_for(so) + self.assertEqual(c.interval_months, 3) + self.assertEqual(c.x_fc_maintenance_fee, 199.0) + + def test_idempotent_on_reconfirm(self): + p = self._make_product() + so = self._confirm_so(p) + so._spawn_maintenance_contracts() # call again -> no duplicate + self.assertEqual(len(self._contracts_for(so)), 1) diff --git a/fusion_repairs/views/repair_product_category_views.xml b/fusion_repairs/views/repair_product_category_views.xml index 0ca1a2e7..02930f7a 100644 --- a/fusion_repairs/views/repair_product_category_views.xml +++ b/fusion_repairs/views/repair_product_category_views.xml @@ -40,6 +40,13 @@ + + + + + + +