diff --git a/CLAUDE.md b/CLAUDE.md index 6e456f3b..b697f76c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,8 @@ 16. **Renaming a module's technical name needs a DB rename, not just a folder rename.** The technical name is baked into the database: `ir_module_module.name`, every external ID in `ir_model_data.module`, each view's `ir_ui_view.key` prefix, and the `ir_module_module_dependency.name` rows of every module that depends on it. Rename only the folder + in-code references and Odoo treats the new name as a fresh uninstalled module — installing it **duplicates** groups/templates/menus and **orphans** all existing data. On every DB that already has it installed, run an in-place SQL rename (the 4 tables above) **before** `-u `; a fresh DB needs nothing. Reference script + full rationale: [`fusion_portal/rename_module.sql`](fusion_portal/rename_module.sql) (written for the `fusion_authorizer_portal` → `fusion_portal` rename). Also update cross-module `depends`, `inherit_id=".view"`, `t-call`, `env.ref('.xmlid')`, asset paths (`/static/...`), and `from odoo.addons.... import`. +17. **`url_encode` (and werkzeug url helpers) are NOT available in the Odoo 19 `mail.template` QWeb render context.** Using `url_encode({...})` inside a template `body_html` (e.g. to build a fallback link) makes the template fail Odoo's save-time render validation **at install**, surfacing as the opaque `ParseError: ... Oops! We couldn't save your template due to an issue with this value: ` (the real `NameError` is hidden, and `--log-handler odoo.tools.convert:DEBUG` does NOT reveal it). Build URLs with plain string methods instead: `'https://…?q=' + (value or '').replace(' ', '+')`. Found installing `fusion_repairs` (post-visit NPS template). **That same opaque "issue with this value" error wraps ANY render failure in a mail.template body** — when you see it, suspect an undefined name / bad field reference in the template, not malformed XML. + ## Card Styling — Copy Odoo's Kanban Pattern Don't rely on `var(--bs-border-color)` or `var(--bs-body-bg)` for card surfaces — they drift between themes/addons and often render **invisible**. Odoo's own kanban (`.o_kanban_record`) uses **explicit hex** values: ```css @@ -96,7 +98,7 @@ Odoo content-hashes the compiled bundle URL (`/web/assets//...`). When CSS ## Module-Specific Notes - **fusion_clock** — developed in **Claude Code** (no longer Cursor; no concurrent-editing conflicts). Changed a lot recently (NFC kiosk: tap-to-clock, enrollment + program-from-unknown-tap, manager page, sounds, screen lock, guided profile-photo capture, faster animations). Still read files fresh before editing rather than assuming the layout. Live on entech (`odoo-entech` / LXC 111 on `pve-worker5`). -- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.2.4`.** Bundles 1–11 shipped in repo (intake, portals, dashboard, pricing, flowcharts, parts/PO). **Not production-deployed** to Westin as of 2026-05-27. Local: `docker exec odoo-modsdev-app odoo -d fusion-dev -u fusion_repairs --stop-after-init`. Outstanding: RingCentral SMS, C2 history sidebar UI, office follow-up crons (config keys only), `tests/`, more flowchart content, sales-rep dashboard tile in `fusion_portal`. +- **fusion_repairs** — read [`fusion_repairs/cloud.md`](fusion_repairs/cloud.md) before feature work. **Version `19.0.2.3.0`** (Plan-1 maintenance foundation added 2026-06-02). **NOT Community-installable** — it transitively pulls in Enterprise `ai` + `knowledge` (`fusion_repairs → fusion_portal → fusion_claims → ai`; `fusion_portal → knowledge`), so it can NOT be installed or tested on local `odoo-modsdev` (Community) — the old `-d fusion-dev -u fusion_repairs` recipe does NOT work. **Test on Enterprise:** an isolated `westin-fr-test` DB on the `odoo-westin` host (clone of prod `westin-v19`; a fresh-DB clone install also needs a one-time orphaned-FK cleanup because prod has orphaned account/tax m2m rows). First-ever clean install surfaced + fixed 2 bugs (url_encode → rule 17; menu parent defined after its children) in commit `903ceb10`. **Not production-deployed** to Westin yet. **Test-runner gotchas on that prod-config container:** `--test-enable` SILENTLY SKIPS all tests without `--workers 0`; the conf's `log_level=warn` hides test output (add `--log-level=test`); the post_install phase also trips on a pre-existing module, so verify behaviour via `odoo shell` rather than the test runner. `mail_template_data.xml` is `noupdate=1` → template edits load on a FRESH install (the prod deploy) but NOT on `-u` of an already-installed DB. Outstanding: maintenance booking (Plan 2), visit log (Plan 3), backfill wizard (Plan 4), office follow-up crons (Plan 5), RingCentral SMS. ## Workflow - Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u --stop-after-init` 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 b6bbe0d1..290fd354 100644 --- a/fusion_repairs/data/mail_template_data.xml +++ b/fusion_repairs/data/mail_template_data.xml @@ -125,8 +125,8 @@ We would love to hear how it went - your feedback helps other clients find us and helps us improve.

- - + +
@@ -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/menus.xml b/fusion_repairs/views/menus.xml index 61e33661..775ba4a3 100644 --- a/fusion_repairs/views/menus.xml +++ b/fusion_repairs/views/menus.xml @@ -57,6 +57,13 @@ action="action_repair_part_order" sequence="38"/> + + + - - - + + + + + + +