Files
Odoo-Modules/fusion_repairs/security/security.xml
gsinghpal f41426c5b9 feat(fusion_repairs): Bundle 9 - service callout pricing + store labor warranty
Full home-service pricing engine plus the store labor warranty model. The
call price now itemises base callout + extra techs + hourly labour (with
the 30-min-included + 1-hour-minimum rule) + travel both ways past
threshold, with three independent waive paths: in-warranty / manager
override / sales-rep override. CS cannot waive (RBAC).

NEW MODELS

fusion.repair.callout.rate (rate card)
  Per (tier, company) row. Tiers: regular / after_hours / weekend / holiday.
  Fields:
    - base_callout_fee   (INCLUDES first 30 min for inspection / report)
    - second_tech_fee    + additional_tech_fee  (3rd, 4th tech)
    - hourly_labor_rate  + minimum_labor_hours  (default 1.0 floor)
    - travel_distance_threshold_km  + travel_per_km_fee
    - effective_from     (newer rows supersede older)
  Seeded with 4 default rows (regular $120/$95/0.85, after-hours
  $180/$140/1.10, weekend $240/$170/1.35, holiday $300/$200/1.50).

fusion.repair.labor.warranty (store labor warranty)
  Per (partner, product/lot, sale_order) record with warranty_years +
  start_date + computed end_date. State machine: active / expired / void
  / consumed. Void reasons spec'd by the user: user_negligence /
  gross_negligence / misuse / over_recommended_use / accidental_damage
  / not_covered_part / other.

  find_active_for(partner, product, lot) - lot-first then product+partner
  then partner-only fallback so warranty resolution survives partner-
  contact / product-variant differences.

  action_void(reason, notes) - manager-only; audit stamps voided_by_id
  + voided_at + reason; posts chatter.

PRODUCT EXTENSION
  product.template.x_fc_labor_warranty_years (Integer, default 0).

SALE-ORDER EXTENSION
  sale.order.action_confirm now also runs _fc_spawn_labor_warranties()
  which creates one fusion.repair.labor.warranty per unit of any product
  with x_fc_labor_warranty_years > 0. Lives alongside the existing
  service-plan spawn so a 5y-LW stairlift sold with a maintenance plan
  spawns both records in one go.

PRICING ENGINE ON REPAIR.ORDER

  9 new fields:
    x_fc_callout_tier            (regular/after_hours/weekend/holiday)
    x_fc_callout_distance_km     (one-way; system bills both ways)
    x_fc_callout_techs           (1, 2, 3+)
    x_fc_callout_labor_hours     (hours above the 30 min the callout covers)
    x_fc_labor_warranty_id       (auto-resolved on visit)
    x_fc_labor_warranty_status   (not_checked / eligible / not_covered /
                                  expired / void_misuse / waived)
    x_fc_labor_waived            + _by_id + _at + _reason

  6 computed quote fields:
    x_fc_quote_callout_base    (base_callout_fee)
    x_fc_quote_extra_techs     (second + additional fees)
    x_fc_quote_labor           (max(hours, min_hours) * rate * techs)
    x_fc_quote_travel          (max(distance - threshold, 0) * 2 * per_km * techs)
    x_fc_quote_waived          (= labor if warranty eligible OR labor waived)
    x_fc_quote_total           (sum minus waived; stored, indexable)
  + a human-readable x_fc_quote_breakdown_text used in the email template.

  3 new actions:
    action_check_labor_warranty  (anyone) - resolves the warranty and
       stamps x_fc_labor_warranty_status. Called automatically by the
       visit-report wizard.
    action_waive_labor_fee       (SECURITY GATED) - raises UserError unless
       caller is in group_fusion_repairs_manager OR
       group_fusion_repairs_sales_rep. CS users get the explicit message
       'Only Repairs Managers and Sales Reps can waive the labor fee.'
    action_acknowledge_rush      - Bundle 8 carryover.

SECURITY

  New group_fusion_repairs_sales_rep
    Independent group so a sales rep can waive labor on their accounts
    without becoming a Repairs Dispatcher / Manager. Manager IMPLIES
    sales_rep so managers automatically inherit the right.
  ACLs: callout.rate user-read / manager-full; labor.warranty user-read /
    sales_rep-write / manager-full / technician-read+write.

VISIT-REPORT WIZARD EXTENSIONS

  Pricing block (visible when outcome=completed):
    callout_tier / techs / distance_km / labor_hours_used (default 1.0
    minimum). Live quote_total_preview + breakdown shown to the tech so
    they can confirm the price with the client right at the door.

  Warranty block:
    labor_warranty_id_preview + labor_warranty_status_preview (badge
    coloured by status). 'warranty_void_reason' selection lets the tech
    void the warranty in real time when they find misuse / negligence /
    accidental damage - on submit the matching warranty record is voided
    permanently (action_void) AND the repair's labor charge re-computes
    without the waive.

  On confirm the wizard:
    1. Persists callout_labor_hours_used to the repair
    2. Calls repair.action_check_labor_warranty()
    3. If warranty_void_reason set + warranty resolved -> voids it,
       posts chatter, repair labor_warranty_status -> void_misuse

NAVIGATION

  Repair form 4 new header buttons:
    Check Labor Warranty   (anyone)
    Waive Labor Fee        (sales_rep + manager only, server-side gated)
    (plus the Bundle 8 Squeeze + Ack Rush from before)

  New 'Callout Pricing' notebook tab on repair form with:
    inputs, warranty/waiver, and the 6-line quote breakdown.

  New menus:
    Fusion Repairs > Labor Warranties
    Configuration > Callout Rate Card
    Configuration > Emergency Surcharges (Bundle 8 carryover)

VERIFICATION END-TO-END (7 scenarios, 0 bugs)

  A. Sale of a product with 5y LW -> LW-00002 spawned, expires 2031-05-21.
  B. In-warranty regular 12km 20-min repair:
       base 120 + labor 95 - waived 95 = $120 (callout only)
  C. After-hours 2-tech 40km 1.5h, NO warranty:
       180 + 90 + (1.5*140*2) + (15*2*1.10*2) = $756.00 exact
  D. In-warranty visit -> tech ticks misuse void_reason:
       Warranty record -> state=void / reason=misuse.
       Repair labor_warranty_status -> void_misuse.
       Quote re-computes WITHOUT waive: labor 1.5 * 95 = $142.50 charged.
  E. Manager waives labor on a no-warranty repair:
       Pre-waive $310 -> post-waive $120 (labor $190 -> waived).
       Audit: waived_by_id stamped to gsingh@.
  F. CS rep tries to waive: correctly denied with the spec'd error
       'Only Repairs Managers and Sales Reps can waive the labor fee.'
  G. Weekend 1-tech 30km 30-min:
       240 + (1.0*170) + (5*2*1.35) = $423.50 exact (min-1h floor
       correctly applied to the 0.5h actual work).

Bumped to 19.0.2.0.0 (minor version bump - new public-facing model).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-21 01:56:09 -04:00

177 lines
10 KiB
XML

<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ==================================================================== -->
<!-- MODULE CATEGORY -->
<!-- ==================================================================== -->
<record id="module_category_fusion_repairs" model="ir.module.category">
<field name="name">Fusion Repairs</field>
<field name="sequence">47</field>
</record>
<!-- ==================================================================== -->
<!-- FUSION REPAIRS PRIVILEGE (Odoo 19 res.groups.privilege pattern) -->
<!-- ==================================================================== -->
<record id="res_groups_privilege_fusion_repairs" model="res.groups.privilege">
<field name="name">Fusion Repairs</field>
<field name="sequence">47</field>
<field name="category_id" ref="module_category_fusion_repairs"/>
</record>
<!-- ==================================================================== -->
<!-- GROUPS -->
<!-- ==================================================================== -->
<record id="group_fusion_repairs_user" model="res.groups">
<field name="name">Repairs: User (CS Intake)</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_repairs"/>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="comment">CS / front-office staff who take repair intake calls and view repairs.</field>
</record>
<record id="group_fusion_repairs_dispatcher" model="res.groups">
<field name="name">Repairs: Dispatcher</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_repairs"/>
<field name="implied_ids" eval="[(4, ref('group_fusion_repairs_user'))]"/>
<field name="comment">Assigns technicians to repairs, reschedules visits, manages parts pre-pull picklists.</field>
</record>
<!-- Bundle 9: sales-rep group. Distinct from CS so labor-fee waiving can
be authorised by either a manager or a sales rep, but never a front-
office CS user. Manager implies it. -->
<record id="group_fusion_repairs_sales_rep" model="res.groups">
<field name="name">Repairs: Sales Rep</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_repairs"/>
<field name="implied_ids" eval="[(4, ref('group_fusion_repairs_user'))]"/>
<field name="comment">Sales reps who can waive labor fees on their accounts (CS cannot waive).</field>
</record>
<record id="group_fusion_repairs_manager" model="res.groups">
<field name="name">Repairs: Manager</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_repairs"/>
<field name="implied_ids" eval="[(4, ref('group_fusion_repairs_dispatcher')), (4, ref('group_fusion_repairs_sales_rep'))]"/>
<field name="comment">Configures intake templates, pricing, maintenance contracts, on-call rotation, variance overrides. Implies all lower groups including sales rep.</field>
</record>
<!-- =====================================================================
Admin auto-membership: anyone with base.group_system (Settings /
Administration) automatically gets Repairs Manager, which implies
Dispatcher and User. So admin users see the Fusion Repairs app
and have full access without needing to be added manually.
===================================================================== -->
<record id="base.group_system" model="res.groups">
<field name="implied_ids" eval="[(4, ref('fusion_repairs.group_fusion_repairs_manager'))]"/>
</record>
<!-- ==================================================================== -->
<!-- RECORD RULES -->
<!-- ==================================================================== -->
<!-- Multi-company isolation on repair.order -->
<record id="rule_repair_order_company" model="ir.rule">
<field name="name">Repair Order: Multi-Company</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
<field name="global" eval="True"/>
</record>
<!-- Field technicians (from fusion_tasks) see only repairs they're assigned to as technician on a linked task.
Uses STORED fields (technician_id + additional_technician_ids) - not the computed all_technician_ids.
NOTE: per-group rules in Odoo are OR'd. A user who is BOTH a field
technician AND a Repairs User/Dispatcher/Manager will see all repairs
because the permissive Repairs rules below grant access via the OR. -->
<record id="rule_repair_order_technician_own" model="ir.rule">
<field name="name">Repair Order: Technician sees own repairs</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="domain_force">['|', ('x_fc_technician_task_ids.technician_id', '=', user.id), ('x_fc_technician_task_ids.additional_technician_ids', 'in', [user.id])]</field>
<field name="groups" eval="[(4, ref('fusion_tasks.group_field_technician'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Repairs office users (User / Dispatcher / Manager) see all repairs
across companies they have access to. OR'd with the technician rule
above so admin / dispatchers who happen to also be in field_technician
still see everything. -->
<record id="rule_repair_order_repairs_user_full" model="ir.rule">
<field name="name">Repair Order: Repairs Office Full Access</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_repairs_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_repair_order_repairs_manager_unlink" model="ir.rule">
<field name="name">Repair Order: Repairs Manager Can Delete</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_repairs_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Repairs office users can read AND schedule technician tasks. This is
what makes "office can dispatch / reschedule" work without requiring
them to also be in the sales_team groups that fusion_tasks normally
keys off of. -->
<record id="rule_technician_task_repairs_office" model="ir.rule">
<field name="name">Technician Task: Repairs Office Access</field>
<field name="model_id" ref="fusion_tasks.model_fusion_technician_task"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_repairs_user'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<record id="rule_technician_task_repairs_manager_unlink" model="ir.rule">
<field name="name">Technician Task: Repairs Manager Can Delete</field>
<field name="model_id" ref="fusion_tasks.model_fusion_technician_task"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_repairs_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Intake answer access scoped to repair access -->
<record id="rule_repair_intake_answer_company" model="ir.rule">
<field name="name">Repair Intake Answer: Multi-Company</field>
<field name="model_id" ref="model_fusion_repair_intake_answer"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
<field name="global" eval="True"/>
</record>
<!-- Inspection certs: only manager can edit AFTER issue (everyone else read-only).
Visit-report wizard uses sudo() to create new certs from a tech visit. -->
<record id="rule_inspection_cert_readonly" model="ir.rule">
<field name="name">Inspection Certificate: Read-only for non-managers</field>
<field name="model_id" ref="model_fusion_repair_inspection_certificate"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('group_fusion_repairs_dispatcher'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Sales Rep Portal: sees only repair orders they submitted -->
<record id="rule_repair_order_sales_rep_portal" model="ir.rule">
<field name="name">Repair Order: Sales Rep Portal - Own Repairs</field>
<field name="model_id" ref="repair.model_repair_order"/>
<field name="domain_force">[('x_fc_intake_user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
</odoo>