Skip to content

Q3 Plan — roots_hemms_service_contracts (Final 1.0)

Plan version: Final 1.0 — Approved, ready to execute Approved: 2026-05-22 Module ID: roots_hemms_service_contracts Layer: 3 (Hospital lens) — wraps OCA Layer 2 module maintenance_partner (already vendored). Deliberately does NOT vendor OCA maintenance_equipment_contract — see D1 in §2 Implementation: 🎉 ALL 12 commits shipped 2026-05-22. Q3.1–Q3.12 committed. 24/24 tests green on hemms_demo (single-module) AND on hemms_private (with Q2 installed + Q3 demo data loaded). 5-page docs at docs/modules/service-contracts/. Demo: 3 vendors × 5 contracts × 13 coverage lines. Colour-coded coverage banner + state badges.

0. Executive Summary

What we build: A hospital-flavoured service-contracts module that adds a lean hemms.service.contract record linking maintenance.equipment ↔ vendor (res.partner) ↔ contract type ↔ coverage scope ↔ start/end dates ↔ SLA tier. The active contract is auto-linked onto every new maintenance.request for the equipment, its billable flag propagates onto the request, and a renewal activity fires 60 days before end_date. A bilingual contract list plus document attachment workflow rounds out the BME manager UX.

Why: OCA's maintenance_equipment_contract is a 2-field m2m wrapper on contract.contract that drags in the entire contract recurring- billing stack (depends on account). HA Thailand hospitals do not bill warranty/MA contracts to GL — they track coverage, renewal dates, and SLA tiers. Building a lean Layer 3 model on top of the already-vendored maintenance_partner is faster, lighter, and closer to the real domain than vendoring contract + maintenance_equipment_contract. We pick up the OCA module's two field shapes (m2m equipment ↔ contract, stat button) without its baggage.

Effort: 12 commits / ~3 dev sessions, 24 tests target, 5-page docs.

Keystone wins (impact-ranked):

  1. Auto-link active contract on maintenance.request (Q3.5) — technician sees coverage & billable flag at request creation, no search
  2. Renewal alert activity (Q3.7) — 60 days before end_date, activity fires for BME Manager + Procurement; zero contracts expire silently
  3. Contract type vocabulary + coverage scope (Q3.3 + Q3.4) — filter "what's actually covered" per PM kind in one click

License: LGPL-3 over AGPL-3 dependency (maintenance_partner) — compatible at distribution.

Status of open questions: All 4 resolved 2026-05-22 — D1 confirmed Path A (lean Layer 3, no OCA maintenance_equipment_contract); Q1 single 60d threshold; Q2 SLA display-only; Q3 billable default = True when no contract. See §8 for the locked answers.


1. Purpose

Hospital BME managers and procurement need to know which equipment is under what contract with which vendor for how long — and to be reminded before any contract expires. Technicians need to know, the moment they open a corrective request, whether the vendor will cover this work under contract or whether the hospital will be billed. Today this lives in spreadsheets, Line chats, and the head of one senior engineer.

Scope — in

  • Reuse OCA maintenance_partner (already vendored) — assigned_partner_id on equipment + partner_id on request
  • New model hemms.service.contract (header) + hemms.service.contract.coverage.line (per-kind coverage)
  • New model hemms.contract.type (small master table: Warranty / Extended Warranty / MA / Spare-only / Labor-only / Custom)
  • Bilingual name + roots_name_th on contract type + on the contract itself
  • m2o partner_id on contract = the vendor (supplier_rank > 0)
  • m2m equipment_ids on contract (mirrors OCA shape; one contract can cover multiple equipment)
  • Date fields: date_start, date_end, roots_renewal_alert_days (default 60, configurable per contract)
  • Selection state: draft → active → expired / cancelled (computed from dates, manually overridable)
  • Selection roots_sla_tier: bronze / silver / gold / platinum + roots_sla_response_hours (Float)
  • Per-kind coverage o2m: each line = maintenance.kind × included_in_contract Boolean × billable_to_hospital Boolean
  • Auto-link active contract to new maintenance.request via equipment_id → most recent active contract (Q3.5)
  • Propagate billable flag from coverage line matching request.maintenance_kind_idrequest.roots_is_billable_to_hospital
  • Renewal alert activity on contract responsible (default = partner_id.user_id fallback BME Manager group), fired by cron when today >= date_end - roots_renewal_alert_days AND state=active
  • Contract document attachment via mail.thread + ir.attachment (signed PDFs)
  • Stat button on equipment form: "X Contracts" → opens contract list domain equipment_ids in [equipment.id]
  • Stat button on contract form: "Y Equipment" → opens equipment list
  • Stat button on contract form: "Z Requests" → maintenance requests against covered equipment during contract window
  • Dedicated menu: Maintenance → Contracts → All Contracts / Active / Expiring Soon (≤90d) / Expired
  • Search filters: by vendor / contract type / SLA tier / state / equipment
  • Bilingual list view + group-by vendor / contract type / state

Scope — out (deferred)

  • ❌ Recurring billing / auto-invoicing — Q5+ (would require OCA contract or account)
  • ❌ Contract template + copy wizard — Q4
  • ❌ Multi-currency contract value — Q5
  • ❌ Vendor performance scorecard (KPI dashboard: SLA breach rate, MTTR) — Q4
  • ❌ Auto-create RFQ on contract expiry — Q5 (needs purchase)
  • ❌ Spare-only contracts auto-link to stock.picking (override Q2.6 cron) — Q4
  • ❌ Multi-year contracts with mid-term price adjustments — Q5
  • ❌ Vendor portal access (vendor sees their requests) — Q6+
  • ❌ Contract value drawdown / utilisation tracking — Q5
  • ❌ Contract amendment audit trail beyond mail.thread — Q6+
  • ❌ Integration with roots_hemms_workflow to gate stage transitions on contract coverage — Q4

2. Design decisions (locked)

# Decision Choice
D1 Vendor OCA maintenance_equipment_contract? No — it pulls in OCA contract + account, both heavyweight, when our domain needs warranty/SLA tracking not invoicing. Build a lean Layer 3 model that mirrors its field shape (m2m equipment ↔ contract, stat button) without the dep. Revisit in Q5 if true recurring billing becomes a quick-win
D2 Reuse maintenance_partner (already vendored)? Yesassigned_partner_id on equipment becomes the default for new contracts (default= on partner_id); request partner_id is the customer-side party, contract partner_id is the vendor (must satisfy supplier_rank > 0)
D3 Contract type — Selection vs Model? Model (hemms.contract.type, ~5 seed rows, noupdate=1) — bilingual, admin-extensible, future-ready for type-specific behaviour (e.g. spare-only routes differently in Q4). Mirrors maintenance.kind pattern from Q1
D4 Coverage granularity Per maintenance.kind o2m line (hemms.service.contract.coverage.line) — included_in_contract Boolean × billable_to_hospital Boolean. Avoids per-product complexity; per-product is Q5
D5 Auto-link contract to request On _compute_partner_id extension — when equipment_id is set, compute roots_service_contract_id = most recent active contract whose equipment_ids includes the equipment. Stored, readonly=False, so technician can override
D6 Billable propagation Computed field on request roots_is_billable_to_hospital, depends on roots_service_contract_id + maintenance_kind_id. Defaults to True (hospital pays) when no active contract — see Q3 in §8 to confirm
D7 Renewal alert cadence Single threshold, default 60 days, per-contract override (roots_renewal_alert_days, Integer, default 60). Daily cron checks today >= date_end - days AND state=active AND not already_alerted. Boolean roots_renewal_alerted prevents duplicate activities. Multi-stage (90/60/30) deferred to Q4 — see Q1 in §8
D8 State transitions draft → active → expired computed from dates by daily cron; admin can manually set cancelled. No confirm wizard for Q3 — keeps the loop tight
D9 Field naming roots_* prefix on inherited models (maintenance.equipment, maintenance.request, res.partner); bare names on new hemms.* models. Matches Q2 D4
D10 License LGPL-3 (roots) over AGPL-3 (maintenance_partner)
D11 SLA tier — Selection vs Model? Selection with 4 values (bronze, silver, gold, platinum) + free-form roots_sla_response_hours Float. SLA breach detection is out of scope for Q3 (display only); breach computation is Q4 — see Q2 in §8
D12 Demo data 3 vendors × 5 contracts covering the 3 ventilator/MRI/defib classes seeded in Q2.11 — gives the smoke test something to walk through end-to-end

3. Data model

hemms.contract.type (NEW — master)
  ├─ name (Char, required, translate)
  ├─ roots_name_th (Char)
  ├─ code (Char, unique, required)         e.g. "warranty", "ma", "ext_warranty"
  ├─ description (Text, translate)
  ├─ sequence (Integer)
  └─ active (Boolean, default True)

hemms.service.contract (NEW — header, inherits mail.thread + mail.activity.mixin)
  ├─ name (Char, required)                    e.g. "MRI 1.5T — GE — 5yr MA 2026-2031"
  ├─ roots_name_th (Char)
  ├─ reference (Char, copy=False, default ir.sequence "hemms.service.contract" → "SC/2026/00001")
  ├─ partner_id (m2o → res.partner, required, domain=[('supplier_rank','>',0)])
  ├─ contract_type_id (m2o → hemms.contract.type, required)
  ├─ equipment_ids (m2m → maintenance.equipment, "hemms_contract_equipment_rel")
  ├─ equipment_count (Integer, computed)
  ├─ date_start (Date, required)
  ├─ date_end (Date, required)
  ├─ state (Selection: draft / active / expired / cancelled, computed+stored, readonly=False)
  ├─ roots_sla_tier (Selection: bronze/silver/gold/platinum)
  ├─ roots_sla_response_hours (Float)
  ├─ roots_renewal_alert_days (Integer, default 60)
  ├─ roots_renewal_alerted (Boolean, default False, copy=False)
  ├─ roots_renewal_alert_date (Date, computed = date_end - roots_renewal_alert_days)
  ├─ user_id (m2o → res.users, default=env.user — contract owner / BME side)
  ├─ coverage_line_ids (o2m → hemms.service.contract.coverage.line)
  ├─ notes (Html)
  ├─ company_id (m2o → res.company, default)
  ├─ request_count (Integer, computed)        related requests during window
  └─ active (Boolean, default True)

hemms.service.contract.coverage.line (NEW)
  ├─ contract_id (m2o → hemms.service.contract, ondelete=cascade)
  ├─ kind_id (m2o → maintenance.kind, required)    ⭐ filter axis (mirrors Q2 D-pattern)
  ├─ included_in_contract (Boolean, default True)
  ├─ billable_to_hospital (Boolean, default False)
  ├─ notes (Char)
  └─ sequence (Integer)
  └─ _sql_constraints: unique(contract_id, kind_id)

maintenance.equipment (inherit)
  ├─ roots_service_contract_ids (m2m → hemms.service.contract, computed=NO — stored via m2m relation table)
  ├─ roots_active_contract_id (m2o → hemms.service.contract, computed+stored — most recent active)
  ├─ roots_service_contract_count (Integer, computed)
  └─ roots_under_warranty (Boolean, computed — active contract whose type.code in ('warranty','ext_warranty'))

maintenance.request (inherit)
  ├─ roots_service_contract_id (m2o → hemms.service.contract, computed+stored, readonly=False)
  ├─ roots_is_billable_to_hospital (Boolean, computed+stored — depends on contract coverage line for kind)
  └─ roots_contract_warning (Char, computed — "Out of contract" / "Covered, billable" / "Covered, vendor pays")

res.partner (inherit — vendor side)
  ├─ roots_service_contract_ids (o2m → hemms.service.contract via partner_id)
  └─ roots_service_contract_count (Integer, computed)

ir.sequence (data record, NOT hardcoded)
  ├─ code   : "hemms.service.contract"
  ├─ name   : "HEMMS Service Contract"
  ├─ prefix : "SC/%(year)s/"
  ├─ padding: 5
  └─ company_id: False

ir.cron (data record)
  ├─ name  : "HEMMS Service Contract — daily state + renewal alert"
  ├─ model : hemms.service.contract
  ├─ method: _cron_update_state_and_alert
  └─ interval: 1 day

4. Development Plan (12 steps)

# Step Commit prefix Key deliverable
Q3.1 ✅ Module skeleton (manifest + empty models + security/ir.model.access.csv + dependency on maintenance_partner, roots_hemms_pm, roots_hemms_asset_master, mail) feat(contract): add roots_hemms_service_contracts module skeleton [Q3.1] ← commit 7094011 Installs cleanly; no features
Q3.2 ✅ hemms.contract.type model + seed data (5 types) + bilingual list/form views + menu under Configuration feat(contract): contract-type vocabulary with bilingual seed [Q3.2] ← commit b3e1fd8 Admin sees Warranty / Extended Warranty / MA / Spare-only / Labor-only
Q3.3 ✅ hemms.service.contract header model + ir.sequence data + form/list/kanban views + menu under Operations feat(contract): contract header model with running reference [Q3.3] ← commit 2848c66 Create draft contract → SC/2026/00001
Q3.4 ✅ hemms.service.contract.coverage.line o2m + inline editable view on contract form feat(contract): per-kind coverage lines with billable flag [Q3.4] ← commit 2e5c186 Add coverage rows: Calibration ✅ included ❌ billable; GPM ✅ included ✅ billable
Q3.5 ⭐ ✅ Inherit maintenance.equipmentroots_active_contract_id (computed+stored, most recent active by date_start); inherit maintenance.requestroots_service_contract_id (auto-fills from equipment), roots_is_billable_to_hospital (from coverage line), roots_contract_warning (banner text) feat(contract): auto-link active contract + billable propagation [Q3.5] ← commit ba511f3 Open new corrective request → coverage banner visible, billable flag set
Q3.6 ✅ Inherit res.partnerroots_service_contract_ids + stat button on vendor partner form. Stat buttons on equipment form ("X Contracts") and contract form ("Y Equipment", "Z Requests") feat(contract): partner/equipment/contract stat buttons [Q3.6] ← commit 8c33f8f One-click drilldown from vendor → contracts → equipment → requests
Q3.7 ⭐ ✅ State cron (draft → active → expired based on dates) + renewal alert cron (mail.activity on user_id when today >= renewal_alert_date AND state=active AND not roots_renewal_alerted) — both in one _cron_update_state_and_alert method, daily feat(contract): daily state cron + renewal alert activity [Q3.7] ← commit adba729 Set test contract end_date=today+59 → cron fires activity tagged "Renewal due"
Q3.8 ✅ Dedicated menu structure (All / Active / Expiring Soon / Expired) + search filters (vendor, type, SLA tier, state) + group-by axes + kanban by state + bilingual name_get (EN / TH) feat(contract): contract tracking menu + bilingual search [Q3.8] ← commit d10167f BME manager opens "Expiring Soon" → sees only contracts within 90d of end
Q3.9 ✅ Tests (8 unit + 16 integration = 24); fixes folded in: state default beat compute, Odoo 18 name_get→display_name, I2 onchange partner from equipment, Expiring Soon date string, ir.cron drop numbercall/user_id, drop supplier_rank domain, U8 use IntegrityError+mute_logger test(contract): add 8 unit + 16 integration tests [Q3.9] ← commit d5dfa79 24/24 pass on hemms_demo, 0 failed, 0 errors
Q3.10 ✅ Module docs (5 pages: index/get-started/configuration/workflows/reporting) under docs/modules/service-contracts/ + nav update rolled into 48a1199 by parallel session + ffebcc0 landing-page follow-up mkdocs serve clean
Q3.11 ✅ Demo data — 3 vendors (GE Healthcare, Drägerwerk, Mindray) × 5 contracts (1 expired, 2 active, 1 expiring-soon, 1 draft) on a distinct multi-brand fleet (Ventilator V500 / MRI Signa Voyager / Defib BeneHeart) — self-contained (no dep on roots_hemms_spare_parts) chore(contract): demo data for 3 vendors × 5 contracts [Q3.11] ← commit 8a2cf80 Smoke test passes on hemms_private; renewal-alert cron has something to fire; 13 coverage lines load
Q3.12 ✅ UX polish — colour-coded coverage banner (alert-success / alert-info / alert-warning via computed roots_contract_warning_level) + state badges with per-state colour on contract list + date_end amber when within 90d / red when expired feat(contract): UX polish — colour-coded banner + state badges [Q3.12] ← commit 0a64fe5 Visible at-a-glance signal on every request opened

Dependencies: maintenance_partner (OCA — already vendored), roots_hemms_pm (for maintenance.kind), roots_hemms_asset_master (for equipment master), mail.

5. View specifications (Q3.5 + Q3.8 detail)

5.1 Menu structure

Maintenance
└─ Operations
   └─ Service Contracts
      ├─ All Contracts
      ├─ Active                         ← state=active
      ├─ Expiring Soon (≤90d)           ← state=active AND date_end ≤ today+90
      └─ Expired                        ← state=expired
└─ Configuration
   ├─ Contract Types                       ← hemms.contract.type
   └─ Service Contract Sequence         ← ir.sequence shortcut

5.2 Contract list view (default = All Contracts)

Column Source
Reference reference (e.g. SC/2026/00001)
Name name (+ roots_name_th as muted subtitle via name_get)
Vendor partner_id
Type contract_type_id (badge, colour by code)
Equipment equipment_count (link)
Start date_start
End date_end (red if state=expired, amber if within roots_renewal_alert_days)
SLA roots_sla_tier (badge: bronze→silver→gold→platinum colour ramp)
State state (badge: draft=grey, active=green, expired=red, cancelled=muted)
  • Default group by: State
  • Decoration: row in red when state=expired; amber when within renewal threshold

5.3 Contract form view layout

Header: [Draft] → [Active] → [Expired]  state widget (manual override allowed)
Title:  reference + name + roots_name_th

[Stat buttons]
  [ Equipment ]  [ Requests ]  [ Documents ]
       3              12             4

Main:
  ┌─ Vendor & Type ────────────┬─ Validity ─────────────┐
  │ partner_id (vendor)        │ date_start             │
  │ contract_type_id           │ date_end               │
  │ user_id (BME responsible)  │ roots_renewal_alert_days│
  │ roots_sla_tier             │ roots_renewal_alert_date│
  │ roots_sla_response_hours   │                        │
  └────────────────────────────┴────────────────────────┘

Tabs:
  [Equipment]  m2m widget tags, photo thumbnails
  [Coverage]   o2m editable list:
               | Kind                  | Included | Billable | Notes |
               | Calibration           | ✅        | ❌       |       |
               | Safety Inspection     | ✅        | ❌       |       |
               | General PM            | ✅        | ✅       | parts billed |
  [Notes]      Html
  [Documents]  attachment widget (signed PDF, addendum, etc.)

Chatter: mail.thread + activities

5.4 Contract kanban view

  • Columns by state: Draft / Active / Expired / Cancelled (Cancelled hidden by default)
  • Card: vendor logo, contract reference, type badge, equipment count, end date (with countdown widget when within threshold), SLA tier badge

5.5 Search filters on contract list

  • ☐ My Contracts (user_id=uid)
  • ☑ Active Only (state=active)
  • ☐ Expiring Soon (state=active AND date_end <= today+90)
  • ☐ Expired (state=expired)
  • ☐ Under Warranty (contract_type_id.code in ('warranty','ext_warranty'))
  • ☐ Gold/Platinum SLA
  • ☐ Not Yet Alerted (roots_renewal_alerted=False AND state=active)
  • Group by: State / Vendor / Contract Type / SLA Tier / End Date (by month)

5.6 Equipment form — added stat button

Adds a 4th stat button (after Q2's "Pending Items"):

[ Inspections ] [ Requests ] [ Pending Items ] [ Contracts ⭐ ]
      8              12             5                  2

Opens hemms.service.contract list filtered to those with equipment_ids in (active_id,). Click on equipment card also surfaces roots_active_contract_id and roots_under_warranty as readonly badges in the header.

5.7 Maintenance request form — coverage banner (Q3.5 + Q3.12)

Inserted above the request title via a <header> extension on the form view:

┌──────────────────────────────────────────────────────────┐
│ ✅ Covered by SC/2026/00003 (GE Healthcare — 5yr MA)     │
│    Kind: Calibration → vendor pays                       │
└──────────────────────────────────────────────────────────┘

Or, when no active contract:

┌──────────────────────────────────────────────────────────┐
│ ⚠  Out of contract — hospital pays                       │
└──────────────────────────────────────────────────────────┘

Backed by computed roots_contract_warning (Char) + colour via decoration-success / decoration-warning on a non-stored Selection helper.

5.8 Vendor partner form — added stat button

Adds 1 button beside maintenance_partner's "Equipments" + "Maintenances":

[ Equipments ] [ Maintenances ] [ Contracts ⭐ ]
      6               42                4

Opens hemms.service.contract list filtered to partner_id=active_id.

6. UX features captured

Feature From analysis In which Q3 step
Contract-type vocabulary (5 types, bilingual, admin-extensible) Hospital domain Q3.2
Coverage scope per maintenance kind Hospital domain Q3.4
Bilingual contract names + name_get Core req Q3.3 + Q3.8
Vendor m2o via maintenance_partner reuse Layer 2 reuse Q3.3 (D2)
Multi-equipment per contract (m2m) Mirrors OCA shape Q3.3 (D1)
Auto-link active contract on request ⭐ UX friction-killer Q3.5
Propagate billable flag to request ⭐ Decision-support Q3.5
Coverage banner on request form ⭐ At-a-glance visibility Q3.12
Renewal alert activity (60 days default) ⭐ Critical automation Q3.7
Daily state cron (draft→active→expired) Lifecycle automation Q3.7
Equipment "X Contracts" stat button One-click drilldown Q3.6
Vendor "Contracts" stat button One-click drilldown Q3.6
Contract document attachment (mail.thread) Compliance / HA Thailand Q3.3 (form chatter)
SLA tier display (display-only, breach detection Q4) Vendor management Q3.3 + Q3.8
"Expiring Soon" dedicated menu Procurement workflow Q3.8
Running Contract reference SC/2026/00001 Traceability (Q2 pattern) Q3.3
Demo data for full smoke test Acceptance Q3.11

7. Test Cases (24)

Unit (test_contract_unit.py) — 8 tests

# Test Verifies
U1 test_contract_type_bilingual_name_get name_get() on hemms.contract.type returns "EN / TH" when roots_name_th set
U2 test_contract_reference_uses_ir_sequence_code Sequence code = hemms.service.contract exists; prefix NOT hardcoded
U3 test_contract_reference_assigned_on_create New contract → reference = SC/2026/00001
U4 test_contract_state_computed_from_dates_active Contract with date_start <= today <= date_endstate=active
U5 test_contract_state_computed_from_dates_expired date_end < todaystate=expired
U6 test_contract_state_computed_from_dates_draft date_start > todaystate=draft
U7 test_renewal_alert_date_computed roots_renewal_alert_date = date_end - roots_renewal_alert_days
U8 test_coverage_line_unique_kind_per_contract SQL constraint blocks 2 lines with same kind_id on one contract

Integration (test_contract_integration.py) — 16 tests

# Test Verifies
I1 test_partner_id_domain_requires_supplier Picking a non-supplier res.partner for partner_id raises
I2 test_default_partner_from_equipment_assigned_partner Creating contract with one equipment_idpartner_id defaults to equipment.assigned_partner_id
I3 test_active_contract_auto_links_to_new_request Equipment under active contract → new corrective request gets roots_service_contract_id set
I4 test_no_contract_leaves_request_uncovered Equipment without active contract → roots_service_contract_id=False, roots_contract_warning="Out of contract"
I5 test_billable_propagates_from_coverage_line Coverage line kind=Calibration, billable_to_hospital=False → request kind=Calibration has roots_is_billable_to_hospital=False
I6 test_billable_default_true_when_no_contract Request without contract → roots_is_billable_to_hospital=True (subject to D6 review)
I7 test_uncovered_kind_falls_back_to_billable Active contract but kind NOT in coverage_line_ids → roots_is_billable_to_hospital=True
I8 test_request_contract_field_user_overridable Technician can override auto-linked contract; subsequent compute does NOT clobber it
I9 test_multi_equipment_contract_links_to_each Contract with 3 equipment → request on each gets the same contract auto-linked
I10 test_two_overlapping_active_contracts_picks_most_recent Two active contracts on one equipment → picks the one with the latest date_start
I11 test_renewal_cron_fires_activity_at_threshold date_end = today + 60, roots_renewal_alert_days=60, cron runs → activity created on user_id, roots_renewal_alerted=True
I12 test_renewal_cron_skips_already_alerted Same contract second cron pass → no duplicate activity
I13 test_renewal_cron_respects_custom_threshold roots_renewal_alert_days=30, date_end=today+45 → no activity yet; date_end=today+29 → activity fires
I14 test_state_cron_promotes_draft_to_active date_start=today, state was draft → cron sets state=active
I15 test_state_cron_demotes_active_to_expired date_end=yesterday, state was active → cron sets state=expired
I16 test_expiring_soon_filter_window Filter "Expiring Soon" returns contracts with state=active AND date_end <= today+90 and excludes already-expired

Acceptance smoke (manual)

  1. Open vendor partner "GE Healthcare" (seeded Q3.11) → click "Contracts" stat → see 2 active + 1 expiring-soon
  2. Open contract SC/2026/00003 (5yr MA on MRI 1.5T) → Coverage tab shows Calibration ✅ included, ❌ billable; General PM ✅ included, ✅ billable
  3. Open MRI 1.5T equipment → header shows "✅ Under MA — covered to 2031-04-15"; stat shows "2 Contracts"
  4. Create corrective request on MRI 1.5T, kind=Calibration → banner "✅ Covered by SC/2026/00003 — vendor pays"
  5. Create corrective request on MRI 1.5T, kind=Software Update (not in coverage) → banner "✅ Covered by SC/2026/00003 — kind not in coverage, hospital billable"
  6. Create corrective request on un-contracted defibrillator → banner "⚠ Out of contract — hospital pays"
  7. Open "Expiring Soon" menu → see contract SC/2026/00005 (end_date=today+45)
  8. Manually run renewal cron → activity appears on contract owner; roots_renewal_alerted=True
  9. Edit date_end on SC/2026/00001 (warranty already expired) → state badge red; appears in Expired menu
  10. Add a new coverage line (kind=Safety Inspection) on active contract → existing draft requests do NOT change; new requests do (no retroactive recompute)

8. Open questions — resolved 2026-05-22

All 4 locked at plan approval. Decisions captured below for posterity; defaults were taken as recommended.

# Question My recommendation
Q1 Renewal alert cadence — single threshold (60d) or multi-stage (90/60/30/7)? Multi-stage is more correct for procurement reality (90d=start sourcing, 30d=panic) but is 3× the test surface and 3 booleans (alerted_90, alerted_60, alerted_30). Single 60d in Q3, multi-stage in Q4. Locked as D7 unless you push back.
Q2 SLA tier — is roots_sla_response_hours enough, or do we need response-time breach detection now? Detection requires comparing request.create_date to request.first_response_date (new field) against contract.roots_sla_response_hours. ~4 extra commits. Display only in Q3. Breach detection is the Q4 vendor scorecard hook.
Q3 When a request has no active contract, what is the billable default? Options: (a) True — hospital pays (safe default, technician confirms cost), (b) False — unknown, force technician to set explicitly, © configurable per-hospital setting on res.company. (a) True. Hospitals run on "if uncovered, we pay" as the realistic financial baseline. Forcing explicit set on every request is friction.
Q4 Should the Q2 spare-parts auto-picking be aware of contract coverage? i.e. when active contract contract_type_id.code='spare_only', route the draft picking to the vendor as an RFQ instead of consuming from stock. Defer to Q4 explicitly — listed in §1 Scope-out. Q3 only displays "Covered by spare-only contract" banner; no picking change.

Secondary clarifications (lower stakes, can lock now or in code review):

  • Should roots_under_warranty show in equipment list view as a column (mobile-friendly badge) or only on the form? — Plan says form only; add column in Q3.12 polish if cheap.
  • Should expired contracts be hidden from the equipment "Contracts" stat by default? — Plan: no, full history visible; "Active only" toggle on the list.
  • Renewal activity assignee — contract.user_id (default) or also CC the BME Manager group? — Plan: user_id only; cc-group escalation is Q4.

9. Resume checklist

When next session starts:

  1. Read this file (then re-read post-approval marked as Final 1.0)
  2. Confirm Q2 is closed (roots_hemms_spare_parts installable, 26 tests green) — Q3 depends on Q2's maintenance.kind semantics being stable
  3. Confirm addons/oca/maintenance_partner/ still present (vendored in current main) — no Q3.0 vendoring step needed
  4. Confirm no addons/oca/maintenance_equipment_contract/ exists — per D1 we are NOT vendoring it
  5. Resolve the 4 open questions in §8 → mark plan as Final 1.0 with date stamp
  6. Follow the 12-step table strictly; one commit per row
  7. For Q3.3: ir.sequence data file MUST be loaded before any model create() test uses the sequence — list it ahead of view files in __manifest__.py data: list (lesson learned from Q2.8)
  8. For Q3.5: when computing roots_active_contract_id, depend on equipment_id.roots_service_contract_ids.state AND equipment_id.roots_service_contract_ids.date_start AND equipment_id.roots_service_contract_ids.date_end — otherwise stored compute won't refresh when a contract flips to active via cron
  9. For Q3.7: the cron's _cron_update_state_and_alert must process state transitions FIRST then alerts in a single transaction — otherwise a contract that just became active will not have its alert window evaluated until tomorrow