Merge fusion_repairs maintenance foundation (Plan 1) + 2 install fixes + CLAUDE.md rule 17 into main

This commit is contained in:
gsinghpal
2026-06-02 09:03:17 -04:00
11 changed files with 225 additions and 30 deletions

View File

@@ -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 <newname>`; 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="<old>.view"`, `t-call`, `env.ref('<old>.xmlid')`, asset paths (`<old>/static/...`), and `from odoo.addons.<old>... 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 entire body html>` (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/<hash>/...`). 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 111 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 <module> --stop-after-init`

View File

@@ -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': """

View File

@@ -125,8 +125,8 @@
We would love to hear how it went - your feedback helps other clients
find us and helps us improve.
</p>
<!-- H4: URL-encode the company name so the fallback URL survives ampersands + spaces. -->
<t t-set="review_url" t-value="object.company_id.x_fc_google_review_url or ('https://www.google.com/search?' + url_encode({'q': object.company_id.name or ''}))"/>
<!-- H4: build a fallback search URL without url_encode (not available in the mail QWeb render context); replace spaces so the URL survives. -->
<t t-set="review_url" t-value="object.company_id.x_fc_google_review_url or ('https://www.google.com/search?q=' + (object.company_id.name or '').replace(' ', '+'))"/>
<div style="text-align:center;margin:0 0 24px 0;">
<a t-att-href="review_url"
style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">
@@ -433,6 +433,12 @@
is due for its next scheduled maintenance visit on
<strong><t t-out="object.next_due_date" t-options="{'widget': 'date'}"/></strong>.
</p>
<div t-if="object.x_fc_maintenance_fee" style="border-left:3px solid #38a169;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">
Maintenance visit fee:
<strong><t t-out="object.x_fc_maintenance_fee" t-options="{'widget': 'monetary', 'display_currency': object.currency_id}"/></strong><span style="opacity:0.6;"> + applicable tax</span>.
</p>
</div>
<div style="text-align:center;margin:0 0 24px 0;">
<a t-attf-href="/repairs/maintenance/book/{{ object.booking_token }}"
style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">

View File

@@ -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',
})

View File

@@ -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',

View File

@@ -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.',

View File

@@ -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):

View File

@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import test_maintenance_foundation

View File

@@ -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)

View File

@@ -57,6 +57,13 @@
action="action_repair_part_order"
sequence="38"/>
<!-- Configuration parent: must be defined before the children that reference it below -->
<menuitem id="menu_fusion_repairs_configuration"
name="Configuration"
parent="menu_fusion_repairs_root"
sequence="90"
groups="fusion_repairs.group_fusion_repairs_manager"/>
<menuitem id="menu_fusion_repairs_emergency_charges"
name="Emergency Surcharges"
parent="menu_fusion_repairs_configuration"
@@ -100,13 +107,6 @@
action="action_repair_labor_warranty"
sequence="36"/>
<!-- Configuration -->
<menuitem id="menu_fusion_repairs_configuration"
name="Configuration"
parent="menu_fusion_repairs_root"
sequence="90"
groups="fusion_repairs.group_fusion_repairs_manager"/>
<menuitem id="menu_fusion_repairs_categories"
name="Equipment Categories"
parent="menu_fusion_repairs_configuration"

View File

@@ -40,6 +40,13 @@
<field name="active"/>
</group>
</group>
<group string="Maintenance Policy">
<field name="x_fc_maintenance_enabled"/>
<field name="x_fc_maintenance_interval_months" invisible="not x_fc_maintenance_enabled"/>
<field name="x_fc_maintenance_fee" invisible="not x_fc_maintenance_enabled"/>
<field name="x_fc_maintenance_service_product_id" invisible="not x_fc_maintenance_enabled"/>
<field name="currency_id" invisible="1"/>
</group>
<field name="description" placeholder="Describe what equipment falls into this category..."/>
</sheet>
</form>