From ecca8e357f7be8e2d68595e82b6a5c661b19bb05 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 21 May 2026 02:08:52 -0400 Subject: [PATCH] feat(billing): seed Westin/Mobility service charges on first install only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- fusion_service_charges/__init__.py | 196 +++++++++++++++++++++++++ fusion_service_charges/__manifest__.py | 58 ++++++++ 2 files changed, 254 insertions(+) create mode 100644 fusion_service_charges/__init__.py create mode 100644 fusion_service_charges/__manifest__.py diff --git a/fusion_service_charges/__init__.py b/fusion_service_charges/__init__.py new file mode 100644 index 00000000..ba959657 --- /dev/null +++ b/fusion_service_charges/__init__.py @@ -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), + ) diff --git a/fusion_service_charges/__manifest__.py b/fusion_service_charges/__manifest__.py new file mode 100644 index 00000000..e6daaa6b --- /dev/null +++ b/fusion_service_charges/__manifest__.py @@ -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, +}