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):
- ⭐ Full-simulation dry-run (Q6.3) — parse → resolve FKs → uniqueness check → MoPH match → per-row verdict with reason, before any DB write
- ⭐ 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) - ⭐ Idempotent re-import (Q6.3) — external_id first, business-key fallback. Re-running the same file = 0 new records, not 800 duplicates
- ⭐ 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.
- ⭐ 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.batchmodel,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 anir.model.dataentry per new record (standard Odoo behaviour) - Business-key resolver — primary idempotency path (equipment:
ref(Odoo standard, unique perroots_hemms_asset_master'sref_uniqconstraint); vendor:vat→name+country_id→name; location:complete_name) - FK resolution (equipment→vendor, equipment→department, equipment →device_class)
- Strict MoPH 82-class matcher against Q5.2 seed (
name_enORname_th, case-sensitive, whitespace-trimmed) - Uniqueness check (no two new rows with same business key)
- Per-row verdict (OK / WARN / FAIL) with
reasoncolumn - Returns
ImportPreviewdata 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.batchrecord (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 thisimport_batch_idhemms.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.productfor 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, noHttpCase - Fixtures: minimal XLSX files in
tests/fixtures/(valid_equipment.xlsx, fk_orphan.xlsx, oversized_5001.xlsx, mixed_verdicts.xlsx) - Use
openpyxldirectly to construct fixtures inline (avoid binary files in git where possible) - Green gate: run on
hemms_demoANDhemms_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 fallback — vat → name+country_id → name. 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_id—maintenance.equipment,res.partner,hr.departmentare SHARED Odoo models; another module may also defineimport_batch_id. Namespace asroots_hemms_import_batch_idto avoid silent overwrite. - Q6.1
res.partnerextension on multi-company — make sure new fieldcompany_id-aware (orcompany_dependent=False); HEMMS demo data is single-company butres.partneris shared across companies. - Q6.2 template generation runs in user lang — server-rendered XLSX must use the user's
res.langfor column headers so a TH user sees Thai headers, an EN user sees English. Usewith_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
idcolumn 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 existingres.partnerrecords share the samename(e.g., medical-imaging Siemens and office-equipment Siemens), the resolver matches the first one and produces a silent miscategorisation. WARN on everyname-only match (not FAIL), surface the candidate IDs in the verdict reason, and document the post-import dedupe path (Odoo's standardres.partnermerge 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'sself.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=Falsefor 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_demoandhemms_privateneed Q5 installed before Q6 tests run. - Q6.6 docs sequencing —
docs/modules/mass-import/index.mdmust be added tomkdocs.ymlnav; 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 vat→name+country→name (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:
- Re-read this plan +
project_q6_status.mdfor full context - Confirm dependencies installed cleanly:
- Q1 (
roots_hemms_pm) — for PM event aggregation roots_hemms_asset_master— for theref_uniqSQL constraint onmaintenance.equipment.ref(the equipment business key per D6)- Q5 (
roots_hemms_ha_report) — forhemms.ha.device.classMoPH seed + EM-score fields on equipment - Execute Q6.1 — skeleton + batch model + sequence + cron stub (Shipped 2026-05-22 — installs cleanly on
hemms_demo+hemms_private) - After each commit:
pre-commit run --all-files; run module tests (after Q6.6); smoke-install on bothhemms_demoandhemms_private - 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