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:
196
fusion_service_charges/__init__.py
Normal file
196
fusion_service_charges/__init__.py
Normal 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),
|
||||
)
|
||||
58
fusion_service_charges/__manifest__.py
Normal file
58
fusion_service_charges/__manifest__.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user