New module `fusion_service_charges` that creates the standard
service-billing product catalog for Westin Healthcare and Mobility
Specialties:
Standard Service
SVC-STD-CALL Service Call (incl. 30 min) $95
SVC-STD-LABOUR Standard Labour (hourly) $85
SVC-INSHOP-LABOUR In-Shop Labour (hourly) $75
SVC-RUSH-CALL Rush Service Call $120
SVC-AH-CALL After-Hours Service Call $140
Lift & Elevating
SVC-LIFT-CALL Lift Service Call (incl. 30 min) $160
SVC-LIFT-LABOUR Lift Labour (hourly) $110
Delivery / Pickup
DEL-LOCAL Local (within Brampton) $35
DEL-OUT Outside Local Area $60
DEL-RUSH Rush Delivery / Pickup $60
DEL-LIFT-CHAIR Lift Chair Delivery + Set-up $120
DEL-HOSP-BED Hospital Bed Delivery + Set-up $120
DEL-STAIRLIFT Stairlift Delivery + Set-up $300
SVC-STAIRLIFT-RM Stairlift Removal $300
Loading pattern (intentional):
- Products created via post_init_hook on FIRST install only.
- Manifest's `data` list is EMPTY so no XML is loaded on `-u`.
- Hook is idempotent — sentinel ir.model.data xmlid check skips
records that already exist. Safe to re-run.
- User edits / deletes survive every upgrade (proven on entech-
westin: edited SVC-STD-CALL price to $999.99 → ran -u → price
stuck. Reset to $95 after test.).
- Uninstall + reinstall does re-seed (ir.model.data sentinels drop
on uninstall, fresh install treats it as new).
Per-km surcharges (Rush, Outside Local, After-Hours) are noted in
the product description so the dispatcher knows to add a separate
mileage line. Formula-based pricelist for auto-mileage is out of
scope — matches current manual workflow on both shops.
Odoo 19 compatibility: dropped uom_po_id from the create vals
(retired in 18; uom_id is now the single source of truth for sale
and purchase UoM on product.template).
Deployed and verified on:
- odoo-westin / westin-v19 (Docker: odoo-dev-app) — 14 products
- odoo-mobility / mobility (Docker: odoo-mobility-app) — 14 products
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
7.8 KiB
Python
197 lines
7.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Copyright 2026 Nexa Systems Inc.
|
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
|
"""Fusion — Service Charges.
|
|
|
|
Seeds the service-billing product catalog (Service Call, Labour,
|
|
Delivery, Stairlift set-up, etc.) on FIRST install only.
|
|
|
|
Architecture decision: no data XML in the manifest. All products are
|
|
created imperatively in a post_init_hook. This guarantees:
|
|
|
|
- Users can edit prices / names / accounting tags freely after
|
|
install — upgrades won't overwrite them.
|
|
- Users can delete products that don't apply to their shop —
|
|
upgrades won't resurrect them (Odoo's "noupdate=1" doesn't
|
|
actually prevent re-creation when the ir.model.data row is
|
|
missing, only updates; see fusion_plating 19.0.20.5.0 hook for
|
|
the same pattern + investigation).
|
|
- Re-installing the module after uninstall DOES re-seed (the
|
|
ir.model.data sentinels are dropped on uninstall, so the next
|
|
install's hook treats it as a fresh seed).
|
|
|
|
Per-shop pricing — currently identical on Westin Healthcare and
|
|
Mobility Specialties; if they diverge we can add a setting per
|
|
shop or a pricelist override.
|
|
"""
|
|
import logging
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rate schedule — single source of truth.
|
|
#
|
|
# Each row creates one product.template via the post_init_hook.
|
|
# Tuple structure: (xmlid_suffix, name, category, uom_xmlid, list_price,
|
|
# description)
|
|
#
|
|
# Per-km surcharges (Rush / Outside Local / After-Hours) are noted in
|
|
# the description so the dispatcher knows to add a km line manually.
|
|
# A formula-based pricelist would automate this but is out of scope —
|
|
# matching today's manual workflow on both shops.
|
|
# ---------------------------------------------------------------------------
|
|
_SERVICE_CHARGES = [
|
|
# (xmlid_suffix, name, default_code, uom_xmlid, price, description)
|
|
|
|
# ---- Standard Service ----
|
|
('standard_service_call',
|
|
'Standard Service Call',
|
|
'SVC-STD-CALL', 'uom.product_uom_unit', 95.00,
|
|
'Service Call — appointment outside a Westin Healthcare location. '
|
|
'Billed once per service request. Includes the first 30 minutes '
|
|
'of labour; additional time billed at the Standard Labour Rate. '
|
|
'Excludes parts (covered by manufacturer warranty when applicable).'),
|
|
('standard_labour',
|
|
'Standard Labour (Hourly)',
|
|
'SVC-STD-LABOUR', 'uom.product_uom_hour', 85.00,
|
|
'Standard hourly labour rate. Pro-rated in 30-minute increments. '
|
|
'Starts after the 30 minutes included in the Service Call. '
|
|
'Applies per technician when multiple are on the job.'),
|
|
('in_shop_labour',
|
|
'In-Shop Labour (Hourly)',
|
|
'SVC-INSHOP-LABOUR', 'uom.product_uom_hour', 75.00,
|
|
'Hourly labour rate when the work is done at the Westin Healthcare '
|
|
'shop instead of on-site. Pro-rated in 30-minute increments.'),
|
|
('rush_service_call',
|
|
'Rush Service Call',
|
|
'SVC-RUSH-CALL', 'uom.product_uom_unit', 120.00,
|
|
'Rush dispatch — same-day / priority response. Adds $0.70 per '
|
|
'km (2-way) on top of this flat fee; add the mileage as a '
|
|
'separate line.'),
|
|
('after_hours_service_call',
|
|
'After-Hours Service Call',
|
|
'SVC-AH-CALL', 'uom.product_uom_unit', 140.00,
|
|
'Service call outside standard business hours. Adds $0.70 per '
|
|
'km (2-way) on top of this flat fee; add the mileage as a '
|
|
'separate line.'),
|
|
|
|
# ---- Lift & Elevating Service ----
|
|
('lift_service_call',
|
|
'Lift & Elevating Service Call',
|
|
'SVC-LIFT-CALL', 'uom.product_uom_unit', 160.00,
|
|
'Service Call for stairlift / lift / elevating equipment. '
|
|
'Includes the first 30 minutes of labour. Excludes parts '
|
|
'unless covered by manufacturer warranty.'),
|
|
('lift_labour',
|
|
'Lift & Elevating Labour (Hourly)',
|
|
'SVC-LIFT-LABOUR', 'uom.product_uom_hour', 110.00,
|
|
'Hourly labour rate for stairlift / lift / elevating equipment. '
|
|
'Pro-rated in 30-minute increments. Per-technician.'),
|
|
|
|
# ---- Delivery / Pickup ----
|
|
('delivery_local',
|
|
'Local Delivery / Pickup',
|
|
'DEL-LOCAL', 'uom.product_uom_unit', 35.00,
|
|
'Drop-off or pick-up within Brampton.'),
|
|
('delivery_outside_local',
|
|
'Outside Local Delivery / Pickup',
|
|
'DEL-OUT', 'uom.product_uom_unit', 60.00,
|
|
'Drop-off or pick-up outside Brampton.'),
|
|
('delivery_rush',
|
|
'Rush Delivery / Pickup',
|
|
'DEL-RUSH', 'uom.product_uom_unit', 60.00,
|
|
'Same-day delivery or pickup. Adds $0.70 per km (2-way) on top '
|
|
'of this flat fee; add the mileage as a separate line.'),
|
|
('delivery_lift_chair',
|
|
'Lift Chair Delivery + Set-up',
|
|
'DEL-LIFT-CHAIR', 'uom.product_uom_unit', 120.00,
|
|
'Delivery and in-home set-up of a lift chair.'),
|
|
('delivery_hospital_bed',
|
|
'Hospital Bed Delivery + Set-up',
|
|
'DEL-HOSP-BED', 'uom.product_uom_unit', 120.00,
|
|
'Delivery and in-home set-up of a hospital bed.'),
|
|
('delivery_stairlift',
|
|
'Stairlift Delivery + Set-up',
|
|
'DEL-STAIRLIFT', 'uom.product_uom_unit', 300.00,
|
|
'Delivery and installation of a stairlift.'),
|
|
('removal_stairlift',
|
|
'Stairlift Removal',
|
|
'SVC-STAIRLIFT-RM', 'uom.product_uom_unit', 300.00,
|
|
'On-site removal of an existing stairlift.'),
|
|
]
|
|
|
|
|
|
def post_init_hook(env):
|
|
_seed_service_charges_once(env)
|
|
|
|
|
|
def _seed_service_charges_once(env):
|
|
"""Create product.template rows for the service catalog.
|
|
|
|
Idempotent — each row guarded by an ir.model.data xmlid check.
|
|
If the xmlid already resolves to a record, that product is left
|
|
alone (its price / name / accounting tags may have been edited
|
|
by the shop). New rows are created with an ir.model.data sentinel
|
|
so a future run sees them as already-seeded.
|
|
|
|
Re-running the hook by hand:
|
|
env['ir.module.module'].search([('name', '=', 'fusion_service_charges')]).button_upgrade()
|
|
# (post_init_hook only fires on first install in Odoo 19; for
|
|
# a re-seed you'd uninstall+reinstall, which is fine because
|
|
# ir.model.data is dropped on uninstall)
|
|
"""
|
|
Product = env['product.template'].sudo()
|
|
IMD = env['ir.model.data'].sudo()
|
|
module_name = 'fusion_service_charges'
|
|
|
|
created = []
|
|
skipped = []
|
|
for (xmlid_suffix, name, default_code, uom_xmlid, price,
|
|
description) in _SERVICE_CHARGES:
|
|
existing = IMD.search([
|
|
('module', '=', module_name),
|
|
('name', '=', xmlid_suffix),
|
|
], limit=1)
|
|
if existing:
|
|
skipped.append(default_code)
|
|
continue
|
|
uom = env.ref(uom_xmlid, raise_if_not_found=False)
|
|
if not uom:
|
|
_logger.warning(
|
|
'fusion_service_charges: UoM %s not found, '
|
|
'falling back to product_uom_unit', uom_xmlid,
|
|
)
|
|
uom = env.ref('uom.product_uom_unit')
|
|
# Odoo 19 retired uom_po_id on product.template — uom_id is the
|
|
# single source of truth for sale + purchase.
|
|
product = Product.create({
|
|
'name': name,
|
|
'type': 'service',
|
|
'default_code': default_code,
|
|
'list_price': price,
|
|
'sale_ok': True,
|
|
'purchase_ok': False,
|
|
'uom_id': uom.id,
|
|
'description_sale': description,
|
|
})
|
|
IMD.create({
|
|
'module': module_name,
|
|
'name': xmlid_suffix,
|
|
'model': 'product.template',
|
|
'res_id': product.id,
|
|
'noupdate': True,
|
|
})
|
|
created.append(default_code)
|
|
|
|
if created:
|
|
_logger.info(
|
|
'fusion_service_charges: seeded %d product(s) — %s',
|
|
len(created), ', '.join(created),
|
|
)
|
|
if skipped:
|
|
_logger.info(
|
|
'fusion_service_charges: skipped %d existing product(s) — %s',
|
|
len(skipped), ', '.join(skipped),
|
|
)
|