feat(billing): seed Westin/Mobility service charges on first install only

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>
This commit is contained in:
gsinghpal
2026-05-21 02:08:52 -04:00
parent f41426c5b9
commit ecca8e357f
2 changed files with 254 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
# -*- 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),
)

View File

@@ -0,0 +1,58 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion — Service Charges',
'version': '19.0.1.0.0',
'category': 'Sales',
'summary': (
'Standard service-call, labour, delivery, and installation '
'products for Westin Healthcare and Mobility Specialties.'
),
'description': """
Fusion — Service Charges
==========================
Seeds the service-billing product catalog used by Westin Healthcare
and Mobility Specialties:
* Standard Service: Service Call, Labour (hourly), In-Shop Labour,
Rush Service Call, After-Hours Service Call
* Lift & Elevating Service: Service Call, Labour (hourly)
* Delivery / Pickup: Local, Outside Local Area, Rush, Lift Chair
set-up, Hospital Bed set-up, Stairlift set-up, Stairlift Removal
Loading pattern (deliberate):
* Products are created via post_init_hook on FIRST install only.
* No data XML is registered in the manifest, so ``-u`` upgrades
never touch the records. Edits and deletions made by sales/ops
survive every upgrade.
* The hook is idempotent — sentinel xmlid check skips already-
seeded products. Re-running the hook by hand is safe.
* Re-installing the module after uninstall re-creates the products
(the ir.model.data sentinels go away on uninstall, so the next
install treats it as fresh).
Per-km surcharges (Rush, Outside Local) ARE captured on the
product as a hint in the product description; actual km billing
is left as a manual SO-line tweak by the dispatcher (matches
current shop workflow — formula-based pricing would need a
sale.order.line.onchange to compute, out of scope here).
""",
'author': 'Nexa Systems Inc.',
'website': 'https://www.nexasystems.ca',
'license': 'OPL-1',
'depends': [
'product',
'uom',
],
# Empty on purpose — no data XML. See the docstring on
# _seed_service_charges_once() for why every product is created
# imperatively via the post_init_hook.
'data': [],
'post_init_hook': 'post_init_hook',
'installable': True,
'application': False,
'auto_install': False,
}