Merge fusion_repairs maintenance foundation (Plan 1) + 2 install fixes + CLAUDE.md rule 17 into main
This commit is contained in:
@@ -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`.
|
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
|
## 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:
|
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
|
```css
|
||||||
@@ -96,7 +98,7 @@ Odoo content-hashes the compiled bundle URL (`/web/assets/<hash>/...`). When CSS
|
|||||||
|
|
||||||
## Module-Specific Notes
|
## 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_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
|
## Workflow
|
||||||
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
- Local dev: `docker exec odoo-modsdev-app odoo -d fusion-dev -u <module> --stop-after-init`
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Repairs',
|
'name': 'Fusion Repairs',
|
||||||
'version': '19.0.2.2.6',
|
'version': '19.0.2.3.0',
|
||||||
'category': 'Inventory/Repairs',
|
'category': 'Inventory/Repairs',
|
||||||
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
|
'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal',
|
||||||
'description': """
|
'description': """
|
||||||
|
|||||||
@@ -125,8 +125,8 @@
|
|||||||
We would love to hear how it went - your feedback helps other clients
|
We would love to hear how it went - your feedback helps other clients
|
||||||
find us and helps us improve.
|
find us and helps us improve.
|
||||||
</p>
|
</p>
|
||||||
<!-- H4: URL-encode the company name so the fallback URL survives ampersands + spaces. -->
|
<!-- 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?' + url_encode({'q': object.company_id.name or ''}))"/>
|
<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;">
|
<div style="text-align:center;margin:0 0 24px 0;">
|
||||||
<a t-att-href="review_url"
|
<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;">
|
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
|
is due for its next scheduled maintenance visit on
|
||||||
<strong><t t-out="object.next_due_date" t-options="{'widget': 'date'}"/></strong>.
|
<strong><t t-out="object.next_due_date" t-options="{'widget': 'date'}"/></strong>.
|
||||||
</p>
|
</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;">
|
<div style="text-align:center;margin:0 0 24px 0;">
|
||||||
<a t-attf-href="/repairs/maintenance/book/{{ object.booking_token }}"
|
<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;">
|
style="display:inline-block;padding:14px 28px;background-color:#38a169;color:#ffffff;text-decoration:none;border-radius:6px;font-size:16px;font-weight:600;">
|
||||||
|
|||||||
@@ -80,6 +80,26 @@ class FusionRepairMaintenanceContract(models.Model):
|
|||||||
'res.company', default=lambda self: self.env.company,
|
'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(
|
_booking_token_unique = models.Constraint(
|
||||||
'unique(booking_token)',
|
'unique(booking_token)',
|
||||||
'Booking token must be unique.',
|
'Booking token must be unique.',
|
||||||
@@ -195,11 +215,18 @@ class FusionRepairMaintenanceContract(models.Model):
|
|||||||
class SaleOrder(models.Model):
|
class SaleOrder(models.Model):
|
||||||
_inherit = 'sale.order'
|
_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):
|
def _spawn_maintenance_contracts(self):
|
||||||
"""Create maintenance contracts for any delivered SO line whose
|
"""Create a priced maintenance contract per maintainable unit on a confirmed SO.
|
||||||
product has x_fc_maintenance_interval_months > 0."""
|
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()
|
Contract = self.env['fusion.repair.maintenance.contract'].sudo()
|
||||||
today = fields.Date.context_today(self)
|
|
||||||
for so in self:
|
for so in self:
|
||||||
if so.state not in ('sale', 'done'):
|
if so.state not in ('sale', 'done'):
|
||||||
continue
|
continue
|
||||||
@@ -207,21 +234,42 @@ class SaleOrder(models.Model):
|
|||||||
product = line.product_id
|
product = line.product_id
|
||||||
if not product:
|
if not product:
|
||||||
continue
|
continue
|
||||||
interval = product.product_tmpl_id.x_fc_maintenance_interval_months or 0
|
tmpl = product.product_tmpl_id
|
||||||
if interval <= 0:
|
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
|
continue
|
||||||
existing = Contract.search([
|
fee = tmpl.x_fc_maintenance_fee or (
|
||||||
('partner_id', '=', so.partner_id.id),
|
category.x_fc_maintenance_fee if category else 0.0)
|
||||||
('product_id', '=', product.id),
|
# Capture serial only if fusion_claims' line field is present.
|
||||||
('original_sale_order_id', '=', so.id),
|
serial = ''
|
||||||
], limit=1)
|
if 'x_fc_serial_number' in line._fields:
|
||||||
if existing:
|
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
|
continue
|
||||||
Contract.create({
|
anchor = so._fc_maintenance_anchor_date(line)
|
||||||
'partner_id': so.partner_id.id,
|
# One contract per serialized unit; without a serial, per quantity.
|
||||||
'product_id': product.id,
|
count = 1 if serial else max(int(line.product_uom_qty or 1), 1)
|
||||||
'original_sale_order_id': so.id,
|
for _i in range(count):
|
||||||
'interval_months': interval,
|
Contract.create({
|
||||||
'next_due_date': today + relativedelta(months=interval),
|
'partner_id': so.partner_id.id,
|
||||||
'state': 'active',
|
'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 '
|
help='If > 0, delivering a unit of this product auto-creates a maintenance contract '
|
||||||
'with this recurring interval. Phase 3 feature.',
|
'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(
|
x_fc_intake_template_id = fields.Many2one(
|
||||||
'fusion.repair.intake.template',
|
'fusion.repair.intake.template',
|
||||||
string='Intake Template Override',
|
string='Intake Template Override',
|
||||||
|
|||||||
@@ -53,6 +53,31 @@ 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.',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── 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(
|
_code_unique = models.Constraint(
|
||||||
'unique(code)',
|
'unique(code)',
|
||||||
'Category code must be unique.',
|
'Category code must be unique.',
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ class SaleOrder(models.Model):
|
|||||||
# Bundle 9: spawn store labor warranties for any product line with
|
# Bundle 9: spawn store labor warranties for any product line with
|
||||||
# x_fc_labor_warranty_years > 0.
|
# x_fc_labor_warranty_years > 0.
|
||||||
self._fc_spawn_labor_warranties()
|
self._fc_spawn_labor_warranties()
|
||||||
|
self._spawn_maintenance_contracts()
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def _fc_spawn_labor_warranties(self):
|
def _fc_spawn_labor_warranties(self):
|
||||||
|
|||||||
2
fusion_repairs/tests/__init__.py
Normal file
2
fusion_repairs/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import test_maintenance_foundation
|
||||||
100
fusion_repairs/tests/test_maintenance_foundation.py
Normal file
100
fusion_repairs/tests/test_maintenance_foundation.py
Normal 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)
|
||||||
@@ -57,6 +57,13 @@
|
|||||||
action="action_repair_part_order"
|
action="action_repair_part_order"
|
||||||
sequence="38"/>
|
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"
|
<menuitem id="menu_fusion_repairs_emergency_charges"
|
||||||
name="Emergency Surcharges"
|
name="Emergency Surcharges"
|
||||||
parent="menu_fusion_repairs_configuration"
|
parent="menu_fusion_repairs_configuration"
|
||||||
@@ -100,13 +107,6 @@
|
|||||||
action="action_repair_labor_warranty"
|
action="action_repair_labor_warranty"
|
||||||
sequence="36"/>
|
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"
|
<menuitem id="menu_fusion_repairs_categories"
|
||||||
name="Equipment Categories"
|
name="Equipment Categories"
|
||||||
parent="menu_fusion_repairs_configuration"
|
parent="menu_fusion_repairs_configuration"
|
||||||
|
|||||||
@@ -40,6 +40,13 @@
|
|||||||
<field name="active"/>
|
<field name="active"/>
|
||||||
</group>
|
</group>
|
||||||
</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..."/>
|
<field name="description" placeholder="Describe what equipment falls into this category..."/>
|
||||||
</sheet>
|
</sheet>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user