# -*- 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), )