Skip to content

Q6 Plan — roots_hemms_mass_import (Final 1.0)

Plan version: Final 1.0 — Approved, ready to execute Approved: 2026-05-22 Module ID: roots_hemms_mass_import Layer: 3 (Hospital lens) — depends on Q1 (PM, for ref (Odoo standard, unique per roots_hemms_asset_master's ref_uniq constraint)), Q5 (HA Report, for hemms.ha.device.class + EM-score fields on equipment) Implementation: Q6.1–Q6.6 ship sequentially, no external blockers (Q5 sequencing ahead of Q6)

0. Executive Summary

What we build: A new Layer 3 module roots_hemms_mass_import that imports maintenance.equipment, res.partner (vendors), and hr.department (locations) records from XLSX templates. Every import runs dry-run first with per-row OK / WARN / FAIL verdict, then commits in a single transaction. Every imported record is tagged with import_batch_id so a recent batch can be deleted as a unit. Re-imports are idempotent — Odoo external_id wins, else per-object business-key fallback (equipment by ref (Odoo standard, unique per roots_hemms_asset_master's ref_uniq constraint), vendor by vat/name+country, location by complete_name). Strict exact-match against the Q5 MoPH 82-device seed prevents silent miscategorisation of the HA report.

Why: A 200-bed hospital has 800–2,000 medical devices in existing Excel registers. Without mass import, HEMMS go-live = weeks of manual typing = transcription errors = corrupted HA report (Q5). Odoo's built-in base_import is too generic for non-Odoo-expert biomed staff — no MoPH validation, no dry-run preview, no batch undo. Q6 is the feature that makes HEMMS adoption viable at scale.

Effort: 6 commits / ~1 dev session, 16 tests target, 4-page docs, 1 demo batch (24 equipment / 5 vendors / 3 depts).

Keystone wins (impact-ranked):

  1. Full-simulation dry-run (Q6.3) — parse → resolve FKs → uniqueness check → MoPH match → per-row verdict with reason, before any DB write
  2. Batch-tagged delete (Q6.5) — every imported record carries import_batch_id; user can undo a recent batch in one click (within 7 days, if no Q1 PM events or Q3 contracts have referenced them)
  3. Idempotent re-import (Q6.3) — external_id first, business-key fallback. Re-running the same file = 0 new records, not 800 duplicates
  4. Strict MoPH 82-class match (Q6.3) — exact match against Q5.2 seed (TH or EN name); fuzzy matches REJECTED with clear error. Protects HA-report data quality.
  5. Error XLSX export (Q6.5) — failed rows downloadable as XLSX with reason column; user fixes + re-runs only the errors

License: LGPL-3, same boundary as Q5 / Q3 / Q4.

Status of open questions: All 6 resolved 2026-05-22 — OQ-A xml_id header convention; OQ-B vendor business-key vat then name+country; OQ-C no sheet-name auto-detect; OQ-D 1-hour preview session cache; OQ-E 7-day batch-delete window with related-event guard; OQ-F single-sheet error XLSX. See §8.


1. Purpose

Hospital HEMMS adoption fails on day 1 if the equipment register can't be loaded quickly. A 200-bed hospital has 800–2,000 devices, plus 30–80 vendors, plus 15–40 departments / locations. Manual entry is unrealistic at that scale and produces transcription errors that silently corrupt the HA report (Q5).

The reasonable assumption is hospitals already have this data in Excel (or a legacy system that exports Excel). Q6 turns "give us your Excel file" into a 5-minute operation that produces clean, validated records with full audit trail.

Scope — in

  • Q6.1: Module skeleton, manifest, hemms.import.batch model, ir.sequence (IMP/%(year)s/00001), security ACLs
  • Q6.2: Server-side XLSX template generator (3 objects: equipment / vendor / location); TH+EN column headers; dropdown lists for MoPH 82-class names + risk tier + EM-score ranges; download action on wizard
  • Q6.3: Parser + validator engine:
  • openpyxl XLSX reader (.xlsx only)
  • Header normalisation (TH+EN aliases → field key)
  • Row iteration with 5,000-row cap (system parameter, default 5000)
  • Optional external_id resolver — column id / External ID; if provided, it takes precedence; if blank, Odoo auto-generates an ir.model.data entry per new record (standard Odoo behaviour)
  • Business-key resolver — primary idempotency path (equipment: ref (Odoo standard, unique per roots_hemms_asset_master's ref_uniq constraint); vendor: vatname+country_idname; location: complete_name)
  • FK resolution (equipment→vendor, equipment→department, equipment →device_class)
  • Strict MoPH 82-class matcher against Q5.2 seed (name_en OR name_th, case-sensitive, whitespace-trimmed)
  • Uniqueness check (no two new rows with same business key)
  • Per-row verdict (OK / WARN / FAIL) with reason column
  • Returns ImportPreview data structure cached in session (1-hour TTL)
  • Q6.4: Import wizard (3-step):
  • Upload XLSX + pick object type (Equipment / Vendor / Location)
  • Dry-run preview — show first 50 rows with verdict badge + summary counts (✅ N OK / ⚠️ M WARN / ❌ K FAIL); "Download error XLSX" button surfaces if any FAIL
  • Commit — single transaction, all-or-nothing; on success, opens the new hemms.import.batch record (audit chain)
  • Q6.5: Batch-tagged delete + error XLSX export:
  • hemms.import.batch.action_delete_batch() — checks 7-day window, no related Q1 PM events, no related Q3 contracts; deletes all records tagged with this import_batch_id
  • hemms.import.batch.action_download_error_xlsx() — generates XLSX of FAIL rows from last dry-run preview (columns: row_num, field, severity, message, raw_value)
  • Q6.6: Tests (6 unit + 10 integration = 16 tests), 4-page docs under docs/modules/mass-import/, demo batch + this plan

Scope — out (deferred to Q6.x follow-ons)

  • ❌ Q2 spare parts import (stock.lot, product.product for parts) — lot/serial complexity, Q6.x follow-on
  • ❌ Q3 service contracts import (hemms.service.contract) — date-state derivation edge cases, Q6.x follow-on
  • ❌ Bulk photo / manual / certificate attach (ZIP + filename match) — attach manually post-import for v1
  • ❌ Full rollback (undo modifications, not just delete) — batch-tagged delete covers 90% of cases at 10% of the cost
  • ❌ CSV format support — XLSX only; CSV invites encoding chaos for TH
  • ❌ Legacy .xls (xlrd) — XLSX only; openpyxl already in Odoo deps
  • ❌ Cron-scheduled import from filesystem dropbox — manual upload only
  • ❌ Auto-detect object type from sheet name — explicit picker (OQ-C)
  • ❌ Multi-sheet XLSX with all 3 objects in one file — one-object-per-file enforces FK order (vendors → locations → equipment)
  • ❌ Webhook / API mass import — UI wizard only
  • ❌ Diff view (compare imported vs existing record) — pre-commit verdict is enough for v1

2. Design decisions (locked)

# Decision Choice
D1 Object scope in v1 Equipment + Vendors + Locations only. Q2 spares + Q3 contracts + lot/serials + bulk attachments deferred to Q6.x. Smaller scope (equipment-only) leaves migration half-done; larger scope inherits Q2/Q3 edge cases.
D2 Template strategy Server-generated on-demand. Wizard download action emits a fresh XLSX from current model schema. Avoids template-vs-schema drift; auto-includes any new field added to equipment by future modules.
D3 XLSX library openpyxl (already in Odoo dependency tree). No xlrd / legacy .xls support; XLSX-only by design.
D4 Dry-run depth Full simulation — parse, resolve FKs, check uniqueness, MoPH match, per-row OK/WARN/FAIL. NOT column-level only.
D5 Idempotency Business-key first; external_id optional. On re-import the resolver matches by per-object business key. Column id / External ID is optional — Odoo auto-generates an external_id in ir.model.data for every new record (standard Odoo import behaviour), so the user does NOT need to supply one. If the user does provide id, it takes precedence over the business-key match.
D6 Business keys Equipment: ref (Odoo standard, unique per roots_hemms_asset_master's ref_uniq constraint) (gov asset no — unique per Q1). Vendor: layered fallback for onboarding ease: vat if non-blank → name+country_id if country present → name alone. Collision risk on name-only is accepted for v1 (see §9 gotcha). Location: complete_name.
D7 Batch-tagged delete window 7 days + no related events. Wizard rejects delete if batch is >7d old, or if any tagged record has linked Q1 maintenance.request or Q3 hemms.service.contract. After 7d, requires explicit "force delete" with confirmation (admin only).
D8 MoPH 82-class matching Strict exact match against name_en OR name_th (whitespace-trimmed, case-sensitive). Fuzzy / Levenshtein REJECTED. Reason: silent miscategorisation corrupts the HA report; better to fail loudly.
D9 TH translations Active lang context (Odoo idiom). Wizard runs in user's session lang; translation column header label is the TH/EN name of the field. No parallel name_th columns.
D10 Row cap 5,000 per file (system parameter roots_hemms_mass_import.row_cap, default 5000). 5,001+ raises clear error before parse. Hospitals split larger registers across multiple files.
D11 Order enforcement One object per file. Wizard requires explicit object pick; if equipment file references unknown vendor / department, dry-run flags as FAIL. User imports vendors → locations → equipment in sequence.
D12 Error report Both — first 50 errors inline in wizard, full set downloadable as XLSX (single sheet, columns: row_num, field, severity, message, raw_value).
D13 Batch state model draft → previewed → committed → (archived|deleted). Preview produces a draft batch with data_json field cached; commit promotes to committed. Delete writes deleted_at.
D14 Transaction safety Single transaction per commit. Use cr.savepoint() around the line creation loop. Any FAIL row → entire commit rolled back, batch stays in previewed. All-or-nothing.
D15 Preview session cache hemms.import.batch.data_json Json field stores parsed + validated rows; promoted to committed records on commit. 1-hour TTL via cron cleanup of previewed batches older than 1h.
D16 License / status LGPL-3, Alpha, maintainer jakapolr.

3. Data model

3.1 New models

hemms.import.batch                          (Q6.1)
  ├─ name                  Char         readonly, ir.sequence IMP-YYYY-#####
  ├─ object_type           Selection    equipment / vendor / location
  ├─ state                 Selection    draft / previewed / committed / archived / deleted
  ├─ file_name             Char         original .xlsx filename
  ├─ file_data             Binary       uploaded XLSX (kept for audit)
  ├─ data_json             Json         parsed rows + verdicts (preview cache)
  ├─ row_count_total       Integer      computed, stored
  ├─ row_count_ok          Integer      computed, stored
  ├─ row_count_warn        Integer      computed, stored
  ├─ row_count_fail        Integer      computed, stored
  ├─ records_created       Integer      computed post-commit, stored
  ├─ records_updated       Integer      computed post-commit, stored (idempotent upsert hits)
  ├─ committed_at          Datetime
  ├─ committed_by_id       M2O res.users
  ├─ deleted_at            Datetime
  ├─ deleted_by_id         M2O res.users
  ├─ delete_blocked_reason Char         set when delete attempt fails the D7 check
  └─ active                Boolean
  + mail.thread + mail.activity.mixin

(no hemms.import.batch.line model — rows live in data_json blob; per-row
 verdicts are read-only after preview, not Odoo records)

3.2 Inherited models — import_batch_id tag

maintenance.equipment (INHERIT, Q6.1)
  └─ roots_hemms_import_batch_id   M2O hemms.import.batch  (null = manual entry)

res.partner (INHERIT, Q6.1)
  └─ roots_hemms_import_batch_id   M2O hemms.import.batch

hr.department (INHERIT, Q6.1)
  └─ roots_hemms_import_batch_id   M2O hemms.import.batch

The field is namespaced roots_hemms_import_batch_id to avoid collision with other modules adding import_batch_id to the same shared models.

4. Module layout

addons/roots/roots_hemms_mass_import/
  __init__.py
  __manifest__.py
  README.rst
  data/
    ir_sequence_data.xml                  # IMP-YYYY-#####
    ir_config_parameter_data.xml          # row_cap default 5000
    ir_cron_data.xml                      # cleanup previewed >1h
  demo/
    hemms_mass_import_demo.xml            # Q6.6 — 1 demo batch
  models/
    __init__.py
    hemms_import_batch.py                 # Q6.1 — batch model + actions
    maintenance_equipment.py              # Q6.1 — import_batch_id field
    res_partner.py                        # Q6.1 — import_batch_id field
    hr_department.py                      # Q6.1 — import_batch_id field
  services/
    __init__.py
    template_generator.py                 # Q6.2 — XLSX schema → workbook
    parser.py                             # Q6.3 — XLSX → row dicts
    resolver.py                           # Q6.3 — external_id + business-key
    validator.py                          # Q6.3 — FK + uniqueness + MoPH
    committer.py                          # Q6.4 — transactional commit
    error_export.py                       # Q6.5 — error XLSX generator
  wizard/
    __init__.py
    hemms_mass_import_wizard.py           # Q6.4 — 3-step wizard
    hemms_mass_import_wizard_views.xml
  views/
    hemms_import_batch_views.xml          # list/form + delete + download
    menus.xml
  security/
    ir.model.access.csv
    mass_import_security.xml              # restrict to BME / admin
  tests/
    __init__.py
    test_parser.py                        # Q6.6 — U1, U2
    test_resolver.py                      # Q6.6 — U3, U4
    test_moph_match.py                    # Q6.6 — U5
    test_row_cap.py                       # Q6.6 — U6
    test_dry_run.py                       # Q6.6 — I1, I2
    test_commit.py                        # Q6.6 — I3, I4
    test_idempotency.py                   # Q6.6 — I5
    test_order.py                         # Q6.6 — I6
    test_delete.py                        # Q6.6 — I7, I8
    test_error_xlsx.py                    # Q6.6 — I9
    test_wizard_render.py                 # Q6.6 — I10
  i18n/
    th.po

5. UX touchpoints

5.1 Mass Import wizard (Q6.4)

Step 1 — Upload + object pick:

┌─ Mass Import — Step 1: Upload ──────────────────────────────────┐
│ Object Type     [ Equipment ▼ ]                                  │
│                 ( Equipment / Vendor / Location )                │
│                                                                  │
│ XLSX File       [ Choose file...  hospital_register_2026.xlsx ]  │
│                                                                  │
│ [ Download Template ]  ← server-generates fresh XLSX             │
│                                                                  │
│ [ Cancel ]                                       [ Next: Preview ]│
└──────────────────────────────────────────────────────────────────┘

Step 2 — Dry-run preview:

┌─ Mass Import — Step 2: Preview ─────────────────────────────────┐
│ Batch: IMP-2026-00042 (draft)                                    │
│ File:  hospital_register_2026.xlsx                               │
│                                                                  │
│ Summary:  ✅ 794 OK   ⚠️ 4 WARN   ❌ 2 FAIL                       │
│                                                                  │
│ First 50 rows:                                                   │
│ | Row | Verdict | Asset No | Name (TH/EN)        | Reason     | │
│ | 2   | ✅ OK   | A-0001   | เครื่อง... / Anesth | —          | │
│ | 3   | ⚠️ WARN | A-0002   | (blank EN)          | EN name... | │
│ | 7   | ❌ FAIL | A-0006   | เครื่อง... / X-ray  | Vendor not | │
│ |     |        |          |                      |   found    | │
│ ...                                                              │
│                                                                  │
│ [ ⬇ Download error XLSX ]   (visible if FAIL > 0)                │
│ [ Cancel ]   [ ← Back ]                          [ Commit Batch ]│
└──────────────────────────────────────────────────────────────────┘

Commit button disabled if row_count_fail > 0 AND user is not admin (admins can commit partial; non-admins must clean first).

Step 3 — Result:

┌─ Mass Import — Step 3: Result ──────────────────────────────────┐
│ Batch: IMP-2026-00042 (committed)                                │
│                                                                  │
│ Records created: 790                                             │
│ Records updated: 4   ← idempotent upsert hits                    │
│ Records failed:  0                                               │
│                                                                  │
│ Committed at: 2026-05-22 14:32:11                                │
│ Committed by: jakapol@roots.tech                                 │
│                                                                  │
│ [ Open Batch ]  [ Done ]                                         │
└──────────────────────────────────────────────────────────────────┘

5.2 hemms.import.batch list view

| Reference        | Object    | State      | OK | WARN | FAIL | Created | Committed by |
| IMP-2026-00042   | Equipment | Committed  | 794| 4    | 0    | 790     | jakapol      |
| IMP-2026-00041   | Vendor    | Committed  | 24 | 0    | 0    | 24      | jakapol      |
| IMP-2026-00040   | Equipment | Previewed  | 100| 2    | 8    | —       | —            |
| IMP-2026-00039   | Equipment | Deleted    | 50 | 0    | 0    | 50→0    | (jakapol)    |

5.3 hemms.import.batch form

[ Header ]
  IMP-2026-00042                                       [ Committed ]

[ Header buttons ]
  [ ⬇ Download Original XLSX ]
  [ ⬇ Download Error XLSX ]    ← if row_count_fail > 0
  [ 🗑 Delete Batch ]            ← if state='committed' AND <7d AND no related events

[ Notebook tabs ]
  - Summary (counts, file, audit timeline)
  - Preview Rows (read-only table from data_json)
  - Delete Block Reason (visible if delete attempted + blocked)

[ Chatter ]
  Standard mail.thread — commit + delete events post here

6. UX features captured

Feature From scoping In which Q6 step
ir.sequence IMP-YYYY-##### Q3 / Q5 pattern Q6.1
hemms.import.batch audit model OQ scoping Q6.1
Namespaced roots_hemms_import_batch_id on shared models Collision safety Q6.1
Server-generated XLSX templates D2 Q6.2
TH+EN column headers + dropdowns D9 Q6.2
openpyxl parser, .xlsx only D3 Q6.3
5,000-row cap with config param D10 Q6.3
External_id (xml_id) wins over business-key D5 Q6.3
Per-object business-key fallback D6 Q6.3
Strict MoPH 82-class match D8 Q6.3
Per-row OK/WARN/FAIL verdict D4 Q6.3
1-hour preview cache + cron cleanup D15 Q6.1 + Q6.3
3-step wizard (upload → preview → commit) UX Q6.4
Single-transaction commit (all-or-nothing) D14 Q6.4
Idempotent upsert (records_updated counter) D5 Q6.4
Batch-tagged delete with 7-day + event guard D7 Q6.5
Error XLSX export (single sheet) D12 / OQ-F Q6.5
16 tests on dual DB Q3 / Q4 / Q5 pattern Q6.6
4-page docs scaled-down Q5 Q6.6
1 demo batch (24/5/3) Q5 pattern Q6.6

7. Test Cases (16)

Unit tests (6)

# File Test Verifies
U1 test_parser.py test_xlsx_header_aliases TH header "ชื่อเครื่อง" and EN header "Name (EN)" both map to correct field keys
U2 test_parser.py test_blank_row_skip Fully-blank rows in middle of file are silently skipped; no FAIL emitted
U3 test_resolver.py test_external_id_when_provided_wins When user provides id / External ID column, it takes precedence over business-key columns even if business keys mismatch
U4 test_resolver.py test_business_key_layered_fallback Primary path: no external_id → vendor matched by vat if present → else name+country_id if country present → else name alone; equipment by ref (Odoo standard, unique per roots_hemms_asset_master's ref_uniq constraint); correct layer reported on the per-row verdict
U5 test_moph_match.py test_strict_match_exact_only Exact name_en or name_th from Q5.2 seed → OK; trailing whitespace stripped; near-match ("Anesthesia Unit" vs "Anesthesia Units") → FAIL with clear message
U6 test_row_cap.py test_5001_rows_raises_before_parse 5,001-row file raises UserError BEFORE any record parsing; row_cap config param honoured if overridden

Integration tests (10)

# File Test Verifies
I1 test_dry_run.py test_preview_writes_no_db_records After dry-run, maintenance.equipment / res.partner counts unchanged; only hemms.import.batch (state=previewed) created
I2 test_dry_run.py test_per_row_verdict_counts Mixed 10-row file (7 valid, 2 warn, 1 fail) → batch counts row_count_ok=7, row_count_warn=2, row_count_fail=1; reasons populated in data_json
I3 test_commit.py test_commit_creates_records_with_batch_tag Commit creates equipment + vendor records; every new record has roots_hemms_import_batch_id == batch.id; batch state → committed
I4 test_commit.py test_fk_orphan_row_fails_savepoint_rollback Equipment row referencing unknown vendor → row FAIL; D14 savepoint rolls back entire batch; batch returns to previewed (no partial state)
I5 test_idempotency.py test_reimport_same_file_upserts_no_duplicates Import file → 24 created; import same file → 0 created, 24 updated (matched by business-key); maintenance.equipment count unchanged
I6 test_order.py test_equipment_before_vendor_fails Equipment file referencing vendor "Acme" → FAIL when no Acme exists; after vendor import creates Acme, same equipment file → OK
I7 test_delete.py test_batch_delete_removes_only_tagged Delete batch #1 → records tagged batch #1 deleted; batch #2's records remain; batch #1 state → deleted, deleted_at set
I8 test_delete.py test_delete_blocked_by_related_pm_or_contract Equipment in batch has linked maintenance.request (Q1 PM) → delete blocked, delete_blocked_reason populated, no records removed
I9 test_error_xlsx.py test_error_xlsx_contains_fail_rows After dry-run with FAIL rows, action_download_error_xlsx() returns XLSX with one row per FAIL containing row_num, field, severity, message, raw_value
I10 test_wizard_render.py test_wizard_renders_all_3_steps Wizard form renders step 1 (upload), step 2 (preview with first 50 rows), step 3 (result) without QWeb errors; correct buttons visible per state

Test infrastructure

  • All TransactionCase, no HttpCase
  • Fixtures: minimal XLSX files in tests/fixtures/ (valid_equipment.xlsx, fk_orphan.xlsx, oversized_5001.xlsx, mixed_verdicts.xlsx)
  • Use openpyxl directly to construct fixtures inline (avoid binary files in git where possible)
  • Green gate: run on hemms_demo AND hemms_private — same dual-DB gate as Q3 / Q4 / Q5

Coverage targets

Subsystem Coverage
XLSX parser (Q6.3) U1 + U2
External_id / business-key resolver (Q6.3) U3 + U4
MoPH strict match (Q6.3) U5
Row cap (Q6.3) U6
Dry-run engine (Q6.3) I1 + I2
Transactional commit (Q6.4) I3 + I4
Idempotency (Q6.3 + Q6.4) I5
Order enforcement (Q6.3) I6
Batch-tagged delete (Q6.5) I7 + I8
Error XLSX export (Q6.5) I9
Wizard UI (Q6.4) I10

8. Open Questions — resolved

# Question Resolution
OQ-A External_id column header — required column, or optional? Optional. Odoo's built-in import auto-generates external_ids in ir.model.data for every new record (__import__.X pattern). Q6 inherits this behaviour — user does NOT need to supply an id column. Idempotency on re-import is achieved via business-key match (D5). If the user does provide an id / External ID column (e.g., they're migrating from another Odoo), it takes precedence. Standard Odoo column header id or External ID accepted.
OQ-B Vendor business-key — vat-only, name-only, or composite? Layered fallbackvatname+country_idname. Onboarding ease takes priority: not all hospitals' vendor data has VAT or country populated. Single-name matching is a known collision risk (e.g., two "Siemens" entries) and is documented in §9 gotchas, but blocking import on missing VAT would defeat the purpose of mass import. Hospitals can dedupe post-import via the standard res.partner merge wizard.
OQ-C Auto-detect object type from sheet name (e.g., "Equipment" tab)? No — explicit picker. Auto-detect invites ambiguity (what if sheet is mis-named?). User picks Equipment / Vendor / Location in step 1; one file = one object.
OQ-D Preview session cache TTL — how long is a previewed batch kept? 1 hour. Long enough to let user split a large register into chunks (e.g., 2K + 2K + 1K) and import them sequentially in one session. Cron sweeps previewed batches older than 1h to keep data_json blobs from accumulating. Configurable via roots_hemms_mass_import.preview_ttl_hours.
OQ-E Batch-delete window — how strict is the "recent" gate? 7 days + no related Q1 PM events + no related Q3 contracts. After 7d OR if events exist, requires admin force-delete with explicit confirmation. Protects against undoing a batch that's now load-bearing for ops data.
OQ-F Error XLSX format — separate sheet per error vs single sheet? Single sheet, 5 columns (row_num, field, severity, message, raw_value). One row per error. Filters / sorts in Excel. Multi-sheet wastes user attention.

9. Plan execution gotchas

  • Q6.1 namespaced import_batch_idmaintenance.equipment, res.partner, hr.department are SHARED Odoo models; another module may also define import_batch_id. Namespace as roots_hemms_import_batch_id to avoid silent overwrite.
  • Q6.1 res.partner extension on multi-company — make sure new field company_id-aware (or company_dependent=False); HEMMS demo data is single-company but res.partner is shared across companies.
  • Q6.2 template generation runs in user lang — server-rendered XLSX must use the user's res.lang for column headers so a TH user sees Thai headers, an EN user sees English. Use with_context(lang=...) explicitly.
  • Q6.2 dropdown lists — XLSX data validation dropdowns for MoPH class names blow up at 82 entries on some Excel versions; cap dropdown display at first 50 + provide full reference sheet (__moph_classes) in same workbook.
  • Q6.3 openpyxl read-only mode — for 5K-row files, use openpyxl.load_workbook(path, read_only=True, data_only=True) to avoid loading formulas + styles into memory.
  • Q6.3 whitespace + zero-width characters — Thai Excel exports often contain non-breaking spaces (U+00A0) and zero-width spaces (U+200B); normalise with unicodedata.normalize('NFC', s).strip() before MoPH match comparison. Otherwise "เครื่องดมยาสลบ" with a sneaky NBSP fails strict match.
  • Q6.3 MoPH match — case sensitivity — D8 says case-sensitive, but TH characters don't have case. EN names DO. Keep case-sensitive for EN; document that "anesthesia units" ≠ "Anesthesia Units".
  • Q6.3 FK orphan detection order — resolve external_id refs FIRST (if id column provided), then layered business-key refs, before FK validation. Otherwise a row whose vendor is identified by external_id (and exists) gets falsely flagged because business-key fallback didn't find it.
  • Q6.3 vendor name-only collision (D6 / OQ-B) — if two existing res.partner records share the same name (e.g., medical-imaging Siemens and office-equipment Siemens), the resolver matches the first one and produces a silent miscategorisation. WARN on every name-only match (not FAIL), surface the candidate IDs in the verdict reason, and document the post-import dedupe path (Odoo's standard res.partner merge wizard) in the user docs. Acceptable trade-off for v1; revisit in Q6.x if hospitals report breakage.
  • Q6.3 same-file uniqueness — file with rows 5 and 17 both having asset_no=A-0042 must FAIL row 17 (duplicate), not silently last-wins. Track seen business keys during parse pass.
  • Q6.4 cr.savepoint() semantics — Odoo's self.env.cr.savepoint() is a context manager; ensure it wraps the ENTIRE record-creation loop, including any computed-field side-effects. Test I4 explicitly verifies rollback.
  • Q6.4 commit + computed fields invalidation — equipment records created in batch trigger Q5 risk-tier waterfall recompute; this is expected and adds ~30% to commit time on 1000-row batches. Budget accordingly; consider recompute=False for batch insert + explicit _recompute_done() after.
  • Q6.5 delete blocking — check via search count, not loop — for batches of 800 records, use a single self.env['maintenance.request'].search_count([('equipment_id', 'in', ids)]) to detect related PM events. Loop-per-record on a 800-batch is a perf trap.
  • Q6.5 error XLSX in user lang — same as template generation; column headers in user's res.lang.
  • Q6.6 dual-DB gate — Q5 added MoPH seed; Q6 hard-depends on Q5. Both hemms_demo and hemms_private need Q5 installed before Q6 tests run.
  • Q6.6 docs sequencingdocs/modules/mass-import/index.md must be added to mkdocs.yml nav; otherwise it won't render.
  • Q6.6 demo data — 24 equipment / 5 vendors / 3 depts as a SINGLE pre-committed batch makes the wizard discoverable; user opens the batch record and sees the audit trail.

10. Commit sequence (Q6.1 → Q6.6)

# Status Commit message Subject
Q6.1 f8dc4fa chore(import): skeleton + batch model + sequence [Q6.1] Module installs on both DBs; hemms.import.batch model, IMP/YYYY/##### sequence, namespaced roots_hemms_import_batch_id on equipment/partner/department; hourly GC cron stub; config params for row_cap + preview_ttl_hours; list/form/search views + menu; access CSV + uploader-scoped record rule.
Q6.2 8a55386 feat(import): XLSX template generator [Q6.2] Server-side openpyxl workbook builder for 3 object types; TH/EN headers picked from active lang; MoPH 82-class dropdown via hidden __moph_classes reference sheet; standalone Download Template wizard + menu.
Q6.3 0f23354 feat(import): parser + validator + dry-run engine [Q6.3] openpyxl read-only parser with NFC/whitespace normalisation; optional external_id resolver (D5); layered vendor business-key vatname+countryname (D6, WARN on name-only); equipment by ref; FK + uniqueness + MoPH strict match (D8); per-row OK/WARN/FAIL verdict in data_json; cross-row uniqueness in batch orchestrator; activates GC cron real cleanup.
Q6.4 96dbf4e feat(import): 3-step wizard + transactional commit [Q6.4] hemms.mass.import.wizard (Upload → Preview → Result) with HTML preview table + verdict badges; cr.savepoint()-wrapped commit (D14, all-or-nothing); idempotent upsert; records_created / records_updated counters; in-flow Download Template button; external_id auto-registration via ir.model.data. Mid-flight fix: removed supplier_rank=1 default (field requires purchase module not in HEMMS deps).
Q6.5 fea13a0 feat(import): batch-tagged delete + error XLSX [Q6.5] action_delete_batch() with full D7 guard (7-day window + Q1 PM-event guard + Q3 contract guard via runtime in self.env check + location→equipment guard); manager-only force-delete past either guard; savepoint-wrapped unlink. action_download_error_xlsx() single-sheet exporter (OQ-F). Form-view header buttons with confirm dialog.
Q6.6 ecb34e5 test+docs(import): 16 tests + 4-page docs + demo [Q6.6] 6 unit + 10 integration tests (16/16 green on hemms_demo + hemms_private); docs/modules/mass-import/ (4 pages) + mkdocs nav entry; pre-committed IMP/2026/DEMO1 vendor batch with 3 tagged demo partners (Acme Medical / Meditek Imaging / BioMed Service). Mid-flight fixes: rewrote I8 to test _check_delete_blockers directly (record-rule AccessError masked the guard); switched demo tagging from <function>-based to direct M2O field; XML-comment double-hyphen syntax fix.

11. Resume cue

When user says "เริ่ม Q6.1" / "start Q6" / "ship Q6" / continues this plan:

  1. Re-read this plan + project_q6_status.md for full context
  2. Confirm dependencies installed cleanly:
  3. Q1 (roots_hemms_pm) — for PM event aggregation
  4. roots_hemms_asset_master — for the ref_uniq SQL constraint on maintenance.equipment.ref (the equipment business key per D6)
  5. Q5 (roots_hemms_ha_report) — for hemms.ha.device.class MoPH seed + EM-score fields on equipment
  6. Execute Q6.1 — skeleton + batch model + sequence + cron stub (Shipped 2026-05-22 — installs cleanly on hemms_demo + hemms_private)
  7. After each commit: pre-commit run --all-files; run module tests (after Q6.6); smoke-install on both hemms_demo and hemms_private
  8. Final check at Q6.6: full end-to-end demo — open wizard → download template → fill 10 rows → upload → preview shows 10 OK → commit → delete batch → confirm records gone