Appearance
Đặc tả kỹ thuật (Dev Spec) — Hồ sơ bệnh án v2.2.0
File này dùng để làm gì: chốt ảnh hưởng kỹ thuật, quy ước dữ liệu, API, migration, security, observability, task và truy vết cho FE/BE/TL.
Nên đọc trước:
decision-brief.md→ C1 phạm vi → C2 tóm tắt ảnh hưởng → C3 quy tắc/công thức → C5 quy ước tích hợp.
Lịch sử thay đổi
| Phiên bản | Ngày | Tác giả | Thay đổi |
|---|---|---|---|
| 2.2.0 | 30/04/2026 | PO/Tech + Security Lead | Trỏ về permission-spec.md v1.0.0 làm canonical owner cho phân quyền: C8 chuyển từ "self-contained spec" sang "implementation reference" — chi tiết catalog 22 action, resolver ResolveClinicalPermission() Go signature, branch_mode 4-state behavior, portal isolation matrix, cache TTL 60s + WS push, migration M105 default seed preset, field masking matrix per view_mode, emergency_override_session table + cron expire + rate limit, permission_change_log audit nằm ở permission-spec P1-P12. Dev phải implement chính xác theo file đó. |
| 2.1.0 | 30/04/2026 | PO/Tech | Đóng các gap UI v2.2.0 + QA v2.1.0: (1) C4.0 thêm enum clinical_record_refusal_type_enum + 'refuse_procedure' vào access_log_action_enum; (2) C4.8 thêm cột customer_refused_procedure, refusal_type, refused_at, refused_by, cancelled_order_item_id vào clinical_record để cover DEC-024; (3) C5.5A bổ sung rule validate signature_typed_text (DEC-021): regex độ dài ≥20 ký tự + chứa từ khoá "đồng ý"/"thực hiện" + chống tự động bypass; (4) C5.5B mới — action refuse_clinical_procedure (huỷ order_item + đánh dấu BA + ghi audit + giữ BA DL nếu có) với permission seed clinical_record.refuse_procedure; (5) C5.16 thêm Trigger 4 clinical_procedure_refused (NTF-14) gửi notification an toàn cho QL CN + Sale phụ trách. |
| 2.0.0 | 30/04/2026 | PO/BA | Đồng bộ theo SOURCE_OF_TRUTH.md v2.0.0: khóa thuật ngữ canonical, giữ schema/action EN-code, đổi nhãn nghiệp vụ VI, cập nhật ref PRD A8, thêm _consistency-matrix.md vào quy ước truy vết. |
| 1.5.7 | 28/04/2026 | PO/Tech | Bổ sung C2.4 Quy ước ảnh hưởng liên feature và prereq audit để khóa module trực tiếp/gián tiếp/không ảnh hưởng trước sprint planning |
| 1.5.6 | 28/04/2026 | PO/Tech | Đồng bộ Dynamic Permission v2: thêm action seed catalog, resolver effective permission, FE constants, backend least-data và QA grant/revoke/portal split |
| 1.5.1 | 28/04/2026 | PO/Tech | Fix blocker triển khai: không expose raw clinical cho role user, thêm action validate/upsert form data, sửa formula procedure log, live gate theo publication/thời điểm áp dụng, rollback và scheduler late-link |
| 1.5 | 28/04/2026 | PO/Tech | Hardening sau review đa góc nhìn: khóa source DB ecommerce/default, endpoint Hasura runtime, role model thực tế, scan checklist signed, JSONB guard và audit OrderItemID toàn repo |
| 1.4 | 28/04/2026 | PO/Tech | Chuẩn hóa theo po-ba-workflow mới: khai báo đầu vào chuẩn, đổi heading tiếng Việt-first, giữ C3 chỉ là implementation delta |
| 1.3.2 | 27/04/2026 | PO/Tech | Sửa Workbench SQL để need_record join đúng order_item/product, thêm queue owner nullable doctor, validation intake cross-scope và output schema cho Trang xem an toàn (Sale) summary |
| 1.3.1 | 27/04/2026 | PO/Tech | Chốt quy ước thiếu của v1.3: form source audit, intake token, Workbench 6 bucket executable, cutoff theo CN và safe-alert dictionary |
| 1.3 | 27/04/2026 | PO/Tech | Thêm data/API/tasks cho Trang xem an toàn (Sale), Bàn việc bác sĩ, Phiếu khách tự khai, Trang điều phối phòng khám và Chốt ngày phòng khám |
| 1.2 | 27/04/2026 | PO | Align PRD v1.2: tách clinical_profile/clinical_record, dùng classification table cho dịch vụ — kỹ thuật, thêm clinic_module_publication, unknown allergy risk = block |
| 1.1 | 21/04/2026 | PO | Reduce Day-1 scope: bỏ request/approve cross-branch in-app, đổi walk-in sang late-link reference_appointment_id, neo FE shell vào BranchDetail / CustomerDetail |
| 1.0 | 21/04/2026 | PO | Initial dev spec — 16 tables, 15+ endpoints, 70 tasks, 4 phases + pre-req |
Đầu vào chuẩn (Canonical Inputs)
| File | Vai trò | Nếu conflict |
|---|---|---|
SOURCE_OF_TRUTH.md | Nguồn sự thật chuẩn + Solution Lock | Ưu tiên cao nhất |
EVIDENCE_PACK.md | Fact code/DB/config hiện tại + thành phần có thể reuse | Ưu tiên fact từ discovery |
prd.md | FR, lifecycle, công thức nghiệp vụ, rào phạm vi | A8 thắng mọi mô tả formula khác |
decision-brief.md | Tóm tắt package + ảnh hưởng bàn giao | Chỉ định hướng, không thay thế quy ước chi tiết |
Canonical rule: Dev Spec không tự tạo truth nghiệp vụ mới ngoài
SOURCE_OF_TRUTH.mdvàprd.md. Formula rule: C3 chỉ ghi implementation delta, tham chiếu PRD A8; không duplicate business definition.
C1) Phạm vi và deliverable
C1.1) Tóm tắt
Digital hoá hồ sơ bệnh án cho CN phòng khám DL + TM, reuse tối đa appointment + reference_file + notification-v2. Build mới layer clinical_profile + clinical_record + 7 biểu mẫu JSON Schema + permission 3 tầng + sổ khám/thủ thuật + kiểm tra sẵn sàng phát hành per CN + các surface vận hành thật: Trang xem an toàn (Sale), Bàn việc bác sĩ, Phiếu khách tự khai, Trang điều phối phòng khám và Chốt ngày phòng khám.
Profile: L · Total effort: ~10-12 tuần (8-10 BE-weeks + 6-8 FE-weeks + 3 QA-weeks).
C1.2) Deliverable
| Loại | Count | Chi tiết |
|---|---|---|
| DB tables mới | 19 | clinical_profile, clinical_record, clinical_form_instance, form_template, treatment_progress_entry, technical_category, service_clinical_classification, clinic_module_publication, prescription, prescription_item, icd10_code, allergy_check_policy, allergy_check_skip_log, medical_record_access_log, sequence_generator, branch_technical_config, clinical_sales_handoff, customer_clinical_intake, clinic_daily_close |
| DB table extend | 1 | branch.features JSONB |
| Seed migrations | 4 | icd10_code (~22k rows TT46/2018 BYT), form_template (7 templates), allergy_check_policy (4 rows), module_permission_action cho clinical modules/actions |
| Hasura actions | 17 | import_technical_categories_csv, publish_clinic_module_config, publish_clinic_module, create_clinical_record, complete_clinical_record, print_clinical_record, upload_clinical_record_scans, emergency_override_access, create_allergy_skip_log, generate_next_sequence, create_clinical_sales_handoff, acknowledge_sales_handoff, open_customer_clinical_intake_session, submit_customer_clinical_intake_by_token, review_customer_clinical_intake, close_clinic_day, validate_clinical_form_data |
| Hasura queries/views | 9 | get_clinical_records_by_branch, get_procedures_by_branch, get_drafts_by_doctor, get_customer_clinical_history, search_icd10, get_missing_ba_appointments, doctor_workbench_view, clinic_ops_dashboard_view, sales_safe_clinical_summary. Các query clinical phải là secure view/action; raw clinical table không là quy ước FE |
| Event triggers | 3 | order_insert (notify cần BA), appointment_completed (schedule missing BA reminder), clinical_record_status_change (audit log) |
| Schedulers | 2 | clinical_record_missing_reminder và clinic_daily_close_reminder theo close_reminder_times/close_cutoff_time của CN |
| Pre-req refactor | 1 | Appointment.OrderItemID → *uuid.UUID + audit/null-guard toàn repo + FE codegen audit |
| Hasura roles new | 0 | Không tạo Hasura role nghiệp vụ. Dùng Hasura role runtime user/admin + Dynamic Permission v2 (role_module.actions + portal + branch_mode) trong action/view |
C1.3) Không thuộc phạm vi dev (Phase 2+)
- Chữ ký số (TT46/2018 full e-signature)
- Dropdown thuốc + cảnh báo tương tác (đơn thuốc nâng cao)
- Archive cold storage cho MinIO (sau 1 năm)
- Mobile camera in-app cho ảnh trước/sau
- Workflow request/consent cross-branch in-app với SLA
C2) Tóm tắt ảnh hưởng
Phân loại: Phase 1 = bắt buộc fix trước khi merge feature (blocker). Phase 2 = data sai nếu không sửa (phải làm trước go-live). Tự xử lý = không cần sửa (Hasura/FW tự handle).
C2.1) Phase 1 — Blocker (bắt buộc sửa)
| # | File/Location | Đổi gì | Mức độ | Owner | Lý do |
|---|---|---|---|---|---|
| IM-P1-01 | pkg/store/appointment.go:52 | OrderItemID uuid.UUID → *uuid.UUID | Critical | BE Dev | Non-pointer = silent bug khi DB value NULL (walk-in case) |
| IM-P1-02 | services/ecommerce-api/event/appointment_insert.go:76 | Null-guard query order_item | 🔴 Critical | BE Dev | Event trigger assume có order_item_id — walk-in sẽ panic |
| IM-P1-03 | services/ecommerce-api/event/appointment_insert.go:176 | Null-guard push OrderItemID vào project task | 🔴 Critical | BE Dev | Tương tự |
| IM-P1-04 | services/ecommerce-api/event/appointment_insert.go:202 | Null-guard push OrderItemID | 🔴 Critical | BE Dev | Tương tự |
| IM-P1-05 | services/ecommerce-api/event/appointment_update.go:190 | Null-guard query order_item | Critical | BE Dev | Tương tự |
| IM-P1-05A | Repo-wide OrderItemID/order_item_id usage, gồm ticket/task/store/report và FE generated types | Compile audit + nil guard | Critical | BE/FE Dev | Review phát hiện usage ngoài 4 call site; walk-in sẽ fail nếu còn dereference ngầm |
| IM-P1-06 | services/ecommerce-api/event/order_insert.go | Thêm trigger check service_clinical_classification.status = mapped_requires_ba → notify BS "cần BA" | 🟡 Extend | BE Dev | FR-013 |
| IM-P1-07 | diva-admin/src/modules/ecommerce/pages/Appointments.tsx + appointment form components | Codegen audit appointment_insert_input.order_item_id optional | Check | FE Dev | Đảm bảo FE truyền null hợp lệ theo màn hiện tại |
| IM-P1-08 | diva-admin/src/modules/user/pages/CustomerDetail.tsx | Thêm parent tab clinical_records; CustomerInfo.tsx giữ vai trò summary card | 🟡 Extend | FE Dev | FR-001 |
| IM-P1-09 | diva-admin/src/modules/user/pages/BranchDetail.tsx | Thêm tab "Phòng khám" 5 bước wizard | 🟡 Extend | FE Dev | FR-002 |
| IM-P1-10 | services/controller/metadata/databases/ecommerce/tables/ | Thêm clinical tables + permission YAML/view/action theo role runtime user/admin | Extend | BE Dev | Core feature; bảng clinical phải gần branch/appointment/order/product/reference_file |
| IM-P1-11 | diva-admin/src/modules/user/pages/BranchDetail.tsx | Thêm readiness navigator + publish hộp thoại/thời điểm áp dụng | 🟡 Extend | FE Dev | FR-015 |
C2.2) Phase 2 — Data sai nếu không sửa
| # | Location | Đổi gì | Lý do |
|---|---|---|---|
| IM-P2-01 | Production log 3 tháng gần nhất | Audit: có appointment với order_item_id = NULL đang bug silent event trigger không? | R-1 từ Evidence Pack §6 — discover regression tiềm ẩn |
| IM-P2-02 | branch table (existing rows) | UPDATE branch SET features = '{}' WHERE features IS NULL (NOT NULL default handles; verify) | Đảm bảo query branch.features->>'clinic_enabled' không trả NULL |
| IM-P2-03 | MinIO config | Thêm bucket clinical-record-scans (nếu chưa có), lifecycle rule 10 năm | Compliance TT46/2018 |
C2.3) Tự xử lý (không cần sửa)
- Hasura schema: auto-reload sau migration — chỉ cần
hasura migrate apply. - Notification v2: dynamic template engine — thêm template mới qua config, không code.
reference_fileupload:pkg/file/upload.gođã xử lý MinIO generic — chỉ cần setreference_id = clinical_record_idvàtype = 'clinical_record_scan'hoặc'clinical_before_after'.- Appointment scheduler reminder: pattern
appointment_reminder.goreuse — thêm job mới theo cùng pattern.
C2.4) Quy ước ảnh hưởng liên feature
Ref: PRD A12 + SoT DEC-044. Đây là quy ước bắt buộc trước sprint planning để tránh feature lớn lan scope sang module ngoài phạm vi.
| Module / owner | Thay đổi quy ước | Bắt buộc thêm | Không được làm | Kiểm chứng |
|---|---|---|---|---|
| Settings / Branch Detail | Extend BranchDetail với tab "Phòng khám", readiness và publish/thời điểm áp dụng | Route/menu/button guard clinic_module.configure/publish, preview impact, R-01..R-10 | Không mở live bằng clinic_enabled đơn lẻ | QA TC-064..068 + REG-DIR-001 |
| Dynamic Permission / Auth | Extend action catalog/resolver | module_permission_action, role_module.actions, resolver action+portal+branch_mode, FE constants | Không tạo role nghiệp vụ mới hoặc check role name ở UI | QA TC-052A..052F + REG-DIR-002 |
| Appointment / POS / Order | Extend nullability và late-link | OrderItemID *uuid.UUID, null-guard repo-wide, reference_appointment_id helper, notify order needs BA | Không dereference order_item_id như non-null; không tạo reminder cho explicitly_no_ba | QA TC-013..016, TC-057..059 + REG-DIR-003 |
| Product / Service classification | Extend service config | service_clinical_classification, drift alert, block publish khi còn unclassified | Không mặc định unclassified = no_ba | QA TC-009..012 + readiness R-04 |
| Customer / CRM / Sales-safe | Extend customer detail + build safe summary/handoff | Tab BA theo quyền, masked view/action, safe dictionary | Sale/CSKH không thấy diagnosis, scan, y lệnh, đơn thuốc, raw form | QA TC-048..052F, TC-069..072 + REG-DIR-004 |
| Clinical forms / Reference file / Print | Build clinical core, reuse file/print pipeline | JSON Schema validator, form version, checklist bản scan đã ký, private signed URL | Không expose raw clinical_form_instance mutation/select cho runtime user; không coi 1 file scan bất kỳ là signed | QA TC-017..047A + REG-DIR-005 |
| Notification / Export / Logs | Extend notification-v2 + build secure exports | Dedupe keys, no-tier3 payload, allowlist export fields, access log tầng 3 | Không dùng generic export raw form_data; không gửi diagnosis qua push | QA TC-053..060 + REG-DIR-006 |
| Workbench / Intake / Ops close | Build operational surfaces | Workbench 6 bucket, intake token validation, Chốt ngày phòng khám cutoff/reminder per CN | Không tạo task giả cho DV không cần BA; không close nếu còn P0 | QA TC-073..089 + REG-DIR-007 |
| Module gián tiếp | Chỉ smoke/guard | Audit null-guard hoặc safe deeplink/runbook khi có chạm kỹ thuật | Không thêm KPI/report/complaint/task workflow clinical Day-1 | QA REG-IND-001..004 |
| Module không ảnh hưởng | Không đổi scope | Smoke regression theo matrix | Không đổi menu, permission, transaction, report, job hoặc schema các module này | QA REG-NO-001..006 + Go-Live TC-15 |
C3) Quy tắc và công thức (chỉ phần triển khai)
Canonical business definition: PRD A8. Section này CHỈ ghi SQL + index + source mapping delta — không duplicate business logic.
FORMULA-001: Mã hồ sơ BA chính
- Ref: PRD A8 FORMULA-001
- SQL (generation):sql
-- Gọi qua action generate_next_sequence SELECT get_next_sequence( 'clinical_profile', format('%s-%s', p_form_type, p_branch_code), -- scope_key NULL -- clinical_profile không reset sequence theo năm; year chỉ nằm trong display code ) AS next_num; -- Format output: format('DVA-%s-%s-%s-%s', p_form_type, p_branch_code, p_year, LPAD(next_num::text, 5, '0')) - Source mapping:
type←clinical_profile.form_type('DL' | 'TM')branch_code←branch.codeyear← EXTRACT(YEAR FROMclinical_profile.created_at)sequence_5_digit←sequence_generator.current_number
- Index:
sequence_generatorUNIQUE(entity_type, scope_key, COALESCE(year, 0))vớiSELECT FOR UPDATEđể thread-safe. - Performance: O(1) lookup via PK; ~5ms p95 under 100 concurrent inserts (tested pattern từ
print_invoice_sequence).
FORMULA-002: STT sổ khám bệnh
- Ref: PRD A8 FORMULA-002
- SQL:sql
SELECT get_next_sequence( 'visit_log', p_branch_code, EXTRACT(YEAR FROM (SELECT "from" FROM appointment WHERE id = p_appointment_id))::int ); - Source mapping:
branch_code←branch.codeyear←appointment.fromYEAR (không phải created_at)
- Reset: Mỗi 1/1 hàng năm, insert row mới
(visit_log, branch_code, new_year, 0)→ next_number bắt đầu từ 1.
FORMULA-003: STT sổ thủ thuật
- Ref: PRD A8 FORMULA-003
- SQL:sql
-- Chỉ gọi khi clinical_record có product với KT procedure. -- Không dùng order_item.appointment_id vì runtime hiện tại link appointment -> order_item qua appointment.order_item_id -- hoặc late-link qua order.reference_appointment_id. IF EXISTS ( WITH appt AS ( SELECT id, branch_id, order_item_id FROM appointment WHERE id = p_appointment_id AND branch_id = p_branch_id ), relevant_order_item AS ( SELECT oi.id, oi.product_id FROM appt a JOIN order_item oi ON oi.id = a.order_item_id UNION SELECT oi.id, oi.product_id FROM appt a JOIN public."order" o ON o.reference_appointment_id = a.id AND o.deleted_at IS NULL JOIN order_item oi ON oi.order_id = o.id ) SELECT 1 FROM relevant_order_item oi JOIN service_clinical_classification scc ON oi.product_id = scc.product_id AND scc.branch_id = p_branch_id JOIN technical_category tc ON scc.technical_category_id = tc.id WHERE tc.type = 'procedure' AND scc.classification_status = 'mapped_requires_ba' ) THEN SELECT get_next_sequence('procedure_log', p_branch_code, EXTRACT(YEAR FROM NOW())::int); END IF; - Source mapping: Same as FORMULA-002 + filter
technical_category.type = 'procedure'. - Index:
technical_category(type)B-tree cho filter nhanh. - Smoke test: chạy với 2 case
appointment.order_item_idtrực tiếp và walk-in late-link quaorder.reference_appointment_id.
FORMULA-004: % BA hoàn thành đúng ngày
- Ref: PRD A8 FORMULA-004
- SQL (materialized view refresh hourly hoặc query ad-hoc):sql
SELECT branch_id, DATE_TRUNC('day', a."from") AS day, ROUND( 100.0 * COUNT(cr.id) FILTER (WHERE cr.completed_at::date = a."from"::date AND cr.status IN ('completed','printed','signed')) / NULLIF(COUNT(a.id) FILTER (WHERE EXISTS ( SELECT 1 FROM order_item oi JOIN service_clinical_classification scc ON oi.product_id = scc.product_id WHERE scc.branch_id = a.branch_id AND ( oi.id = a.order_item_id OR EXISTS ( SELECT 1 FROM public."order" o WHERE o.id = oi.order_id AND o.reference_appointment_id = a.id AND o.deleted_at IS NULL ) ) AND scc.classification_status = 'mapped_requires_ba' )), 0), 2 ) AS pct FROM appointment a LEFT JOIN clinical_record cr ON cr.appointment_id = a.id WHERE a.status = 'completed' AND a."from" >= NOW() - INTERVAL '30 days' GROUP BY branch_id, day; - Source mapping:
numerator←clinical_recordWHEREcompleted_at::date = appointment.fromdenominator←appointmentWHERE có product với mapping
- Index:
clinical_record(appointment_id, form_type, completed_at),appointment(branch_id, "from", status). - Precision: 2 decimal. NULLIF cho denominator=0 → NULL → UI hiển thị "—".
FORMULA-005: Thời gian TB BS điền 1 BA
- Ref: PRD A8 FORMULA-005
- SQL:sql
SELECT primary_doctor_id, AVG(EXTRACT(EPOCH FROM (completed_at - created_at)) / 60.0) AS avg_minutes FROM clinical_record WHERE status IN ('completed','printed','signed') AND completed_at - created_at < INTERVAL '24 hours' -- exclude draft quá hạn AND completed_at - created_at > INTERVAL '30 seconds' -- exclude outlier GROUP BY primary_doctor_id; - Source mapping:
clinical_record.created_at, completed_at, primary_doctor_id. - Index:
clinical_record(primary_doctor_id, status, created_at).
FORMULA-006: Tỉ lệ BA đủ chữ ký/scan bắt buộc
- Ref: PRD A8 FORMULA-006
- SQL:sql
SELECT branch_id, ROUND( 100.0 * COUNT(*) FILTER (WHERE status = 'signed' AND mandatory_scan_checklist_passed = true) / NULLIF(COUNT(*) FILTER (WHERE status IN ('printed','signed')), 0), 2 ) AS pct_signed FROM clinical_record WHERE deleted_at IS NULL AND status <> 'superseded' GROUP BY branch_id; - Source mapping:
clinical_record.status,clinical_record.mandatory_scan_checklist_passed. - Index:
clinical_record(branch_id, status) WHERE deleted_at IS NULLpartial index.
FORMULA-007: Allergy risk level → action
- Ref: PRD A8 FORMULA-007
- SQL:sql
SELECT action, require_reason, audit_log FROM allergy_check_policy WHERE risk_level = p_risk_level; - Source mapping: Lookup 4-row table, cached trong Go handler (30s TTL).
- Default fallback:
technical_category.allergy_risk_level IS NULL→ treat asunknownvà block. - Index: PK
risk_levelđủ.
C4) Mô hình dữ liệu (SQL production-ready)
Source lock: Các bảng clinical Day-1 đặt trong Hasura source
ecommerce(schemapublic) vì cần FK/runtime gầnbranch,appointment,order,order_item,product,reference_file.accountthuộc sourcedefault, vì vậy mọi user/account id trong bảng clinical lưu dạngTEXTvà validate qua service/relationship, không tạo FK chéo source. Tất timestampTIMESTAMPTZlưu UTC, UI convertAsia/Ho_Chi_Minh.Execution order: C4 gom SQL theo domain để dễ đọc; thứ tự chạy migration phải theo C7 dependency order (enum/extension → table chính → FK/index → view/function → metadata), không chạy máy móc theo thứ tự heading nếu tool migration đã tách file.
C4.0) Extension + enum types (create once)
sql
-- Prerequisite cho mọi GIN trigram index trong technical_category/icd10_code.
-- Phải chạy trước idx_tech_cat_name_trgm.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- 🆕 Build mới
CREATE TYPE clinic_type_enum AS ENUM ('DL', 'TM', 'BOTH');
CREATE TYPE technical_category_type_enum AS ENUM ('examination', 'procedure');
CREATE TYPE allergy_risk_level_enum AS ENUM ('unknown', 'low', 'medium', 'high');
CREATE TYPE allergy_action_enum AS ENUM ('warn', 'confirm_skip', 'block');
CREATE TYPE clinic_publication_status_enum AS ENUM ('off', 'setup_draft', 'ready_to_publish', 'scheduled', 'live', 'paused');
CREATE TYPE service_classification_status_enum AS ENUM ('mapped_requires_ba', 'explicitly_no_ba', 'unclassified');
CREATE TYPE clinical_record_status_enum AS ENUM ('draft', 'completed', 'printed', 'signed', 'archived', 'superseded');
CREATE TYPE clinical_record_form_type_enum AS ENUM ('DL', 'TM');
CREATE TYPE form_instance_status_enum AS ENUM ('draft', 'completed', 'signed');
CREATE TYPE form_source_type_enum AS ENUM ('manual', 'customer_intake', 'reuse', 'import', 'paper_fallback');
CREATE TYPE access_log_action_enum AS ENUM ('view', 'edit', 'emergency_override', 'refuse_procedure');
CREATE TYPE clinical_record_refusal_type_enum AS ENUM ('sign', 'procedure'); -- DEC-024
CREATE TYPE clinical_sales_handoff_status_enum AS ENUM ('draft', 'sent', 'acknowledged', 'closed');
CREATE TYPE safe_alert_level_enum AS ENUM ('none', 'info', 'warning', 'block');
CREATE TYPE customer_intake_status_enum AS ENUM ('draft', 'submitted', 'reviewed', 'accepted', 'rejected');
CREATE TYPE clinic_daily_close_status_enum AS ENUM ('open', 'pending_issues', 'closed', 'escalated');C4.1. branch — extend features JSONB 🔧 Extend
Ref: FR-001, DEC-001
sql
-- Extend existing table
ALTER TABLE public.branch
ADD COLUMN IF NOT EXISTS features JSONB NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN public.branch.features IS
'Feature flag per CN. Structure: {"clinic_enabled": bool, "clinic_types": ["DL","TM"], "clinic_status": "off|setup_draft|ready_to_publish|scheduled|live|paused"}';
-- GIN index cho JSONB query
CREATE INDEX IF NOT EXISTS idx_branch_features_gin ON public.branch USING GIN (features);
-- Example query: SELECT * FROM branch WHERE features @> '{"clinic_enabled": true}';C4.2. branch_technical_config 🆕 Build
Ref: FR-002 bước 2, FR-019, DEC-001, DEC-036
sql
CREATE TABLE public.branch_technical_config (
branch_id UUID NOT NULL PRIMARY KEY REFERENCES public.branch(id) ON DELETE CASCADE,
clinic_type clinic_type_enum NOT NULL,
license_number VARCHAR(100) NOT NULL,
license_issue_date DATE NOT NULL,
license_issuer TEXT NOT NULL, -- VD: "Sở Y Tế Đồng Tháp"
license_header TEXT NOT NULL, -- HTML block dùng print header biểu mẫu
clinic_address TEXT, -- Địa chỉ in biểu mẫu (có thể khác branch.address)
clinic_phone VARCHAR(30),
close_cutoff_time TIME NOT NULL DEFAULT '20:00', -- DEC-036 default VN
close_reminder_times TIME[] NOT NULL DEFAULT ARRAY['17:30'::time, '19:00'::time],
timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Ho_Chi_Minh',
created_by TEXT NOT NULL,
updated_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL
);
COMMENT ON TABLE public.branch_technical_config IS
'Cấu hình giấy phép SYT + header in + cutoff vận hành cho CN có bật module PK. 1-1 với branch.';
CREATE INDEX idx_branch_technical_config_deleted_at ON public.branch_technical_config(deleted_at);C4.3. clinic_module_publication 🆕 Build
Ref: FR-015, DEC-030, DEC-031
sql
CREATE TABLE public.clinic_module_publication (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
branch_id UUID NOT NULL REFERENCES public.branch(id) ON DELETE CASCADE,
status clinic_publication_status_enum NOT NULL DEFAULT 'off',
readiness_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb,
config_version INT NOT NULL DEFAULT 1,
effective_at TIMESTAMPTZ NULL,
published_by TEXT NULL,
published_at TIMESTAMPTZ NULL,
paused_by TEXT NULL,
paused_at TIMESTAMPTZ NULL,
pause_reason TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (branch_id, config_version)
);
COMMENT ON TABLE public.clinic_module_publication IS
'Lifecycle publish safety cho module PK per CN. Runtime chỉ dùng config đã published/live.';
CREATE INDEX idx_clinic_pub_branch_status ON public.clinic_module_publication(branch_id, status);C4.4. technical_category 🆕 Build
Ref: FR-002 bước 3, FR-003, DEC-011
sql
CREATE TABLE public.technical_category (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
branch_id UUID NOT NULL REFERENCES public.branch(id) ON DELETE CASCADE,
code VARCHAR(30) NOT NULL, -- VD: 'KT-001', 'LASER-CO2'
name_vi TEXT NOT NULL, -- VD: 'Laser CO2 điều trị sẹo'
name_en TEXT NULL,
type technical_category_type_enum NOT NULL DEFAULT 'procedure',
allergy_risk_level allergy_risk_level_enum NOT NULL DEFAULT 'unknown',
license_category TEXT, -- VD: 'Nhóm 3 - Kỹ thuật cao'
active BOOLEAN NOT NULL DEFAULT true,
sort_order INT NOT NULL DEFAULT 0,
created_by TEXT NOT NULL,
updated_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT unique_branch_code UNIQUE (branch_id, code)
);
COMMENT ON TABLE public.technical_category IS
'Danh mục KT Sở YT per CN. Mỗi CN có giấy phép riêng → data scoped by branch_id.';
COMMENT ON COLUMN public.technical_category.allergy_risk_level IS
'Dùng cho allergy safety guard. unknown/high=block, medium=confirm_skip, low=warn. Admin/Medical Lead config per KT.';
CREATE INDEX idx_tech_cat_branch_active ON public.technical_category(branch_id, active) WHERE deleted_at IS NULL;
CREATE INDEX idx_tech_cat_type ON public.technical_category(type);
CREATE INDEX idx_tech_cat_name_trgm ON public.technical_category USING GIN (name_vi gin_trgm_ops);C4.5. service_clinical_classification 🆕 Build
Ref: FR-003, DEC-006
sql
CREATE TABLE public.service_clinical_classification (
branch_id UUID NOT NULL REFERENCES public.branch(id) ON DELETE CASCADE,
product_id UUID NOT NULL REFERENCES public.product(id) ON DELETE CASCADE,
classification_status service_classification_status_enum NOT NULL DEFAULT 'unclassified',
technical_category_id UUID NULL REFERENCES public.technical_category(id) ON DELETE RESTRICT,
no_ba_reason TEXT NULL,
confirmed_by TEXT NULL,
created_by TEXT NOT NULL,
updated_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (branch_id, product_id)
);
COMMENT ON TABLE public.service_clinical_classification IS
'Classification per CN: mapped_requires_ba / explicitly_no_ba / unclassified. Unclassified chặn publish module PK.';
ALTER TABLE public.service_clinical_classification
ADD CONSTRAINT scc_requires_tech_or_reason CHECK (
(classification_status = 'mapped_requires_ba' AND technical_category_id IS NOT NULL)
OR (classification_status = 'explicitly_no_ba' AND no_ba_reason IS NOT NULL AND confirmed_by IS NOT NULL)
OR (classification_status = 'unclassified')
);
CREATE INDEX idx_scc_product ON public.service_clinical_classification(product_id);
CREATE INDEX idx_scc_status ON public.service_clinical_classification(branch_id, classification_status);
CREATE INDEX idx_scc_tech_cat ON public.service_clinical_classification(technical_category_id);C4.6. form_template 🆕 Build
Ref: FR-005-007, DEC-004, DEC-005
sql
CREATE TABLE public.form_template (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code VARCHAR(50) NOT NULL UNIQUE, -- BA_DL, BA_TM, CONSENT_SURGERY, CONSENT_TREATMENT, PRESCRIPTION, ALLERGY_HISTORY, PROGRESS_NOTE
name TEXT NOT NULL,
description TEXT,
schema JSONB NOT NULL, -- JSON Schema Draft-07 cho form renderer
version INT NOT NULL DEFAULT 1,
active BOOLEAN NOT NULL DEFAULT true,
is_system BOOLEAN NOT NULL DEFAULT false, -- true = không cho admin edit (DEC-018 allergy hardcode)
created_by TEXT,
updated_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE public.form_template IS
'Template schema định nghĩa field structure cho mỗi loại biểu mẫu. Seed 7 templates Day-1.';
CREATE INDEX idx_form_template_code ON public.form_template(code) WHERE active = true;
CREATE INDEX idx_form_template_schema_gin ON public.form_template USING GIN (schema);C4.7. clinical_profile 🆕 Build
Ref: FR-014, DEC-002, DEC-013
sql
CREATE TABLE public.clinical_profile (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
branch_id UUID NOT NULL REFERENCES public.branch(id),
customer_id TEXT NOT NULL,
form_type clinical_record_form_type_enum NOT NULL,
profile_code VARCHAR(50) NOT NULL, -- FORMULA-001: DVA-DL-CL-2026-00001
active BOOLEAN NOT NULL DEFAULT true,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
archived_at TIMESTAMPTZ NULL,
UNIQUE (branch_id, customer_id, form_type),
UNIQUE (profile_code)
);
COMMENT ON TABLE public.clinical_profile IS
'Hồ sơ y tế ổn định theo KH/CN/loại PK. Giữ mã BA chính, không reset qua nhiều lượt khám.';
CREATE INDEX idx_cp_customer_branch ON public.clinical_profile(customer_id, branch_id) WHERE active = true;C4.8. clinical_record 🆕 Build (core)
Ref: FR-004, FR-005, FR-006, FR-014, DEC-002, DEC-013, DEC-023
sql
CREATE TABLE public.clinical_record (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clinical_profile_id UUID NULL REFERENCES public.clinical_profile(id),
appointment_id UUID NOT NULL REFERENCES public.appointment(id),
branch_id UUID NOT NULL REFERENCES public.branch(id),
customer_id TEXT NOT NULL,
visit_log_number VARCHAR(50) NOT NULL, -- FORMULA-002: DVA-CL-00147/2026
procedure_log_number VARCHAR(50) NULL, -- FORMULA-003: DVA-CL-TT-00089/2026 (chỉ ca thủ thuật)
status clinical_record_status_enum NOT NULL DEFAULT 'draft',
form_type clinical_record_form_type_enum NOT NULL, -- DL hoặc TM
primary_doctor_id TEXT NOT NULL,
assisting_nurse_id TEXT NULL,
icd10_primary_code VARCHAR(10) NULL REFERENCES public.icd10_code(code),
icd10_secondary_codes TEXT[] NOT NULL DEFAULT '{}', -- max 5
diagnosis_description TEXT, -- free text — tầng 3
customer_refused_signature BOOLEAN NOT NULL DEFAULT false,
customer_refused_procedure BOOLEAN NOT NULL DEFAULT false, -- DEC-024 KH từ chối thủ thuật (khác từ chối ký bản giấy)
refusal_reason TEXT NULL, -- dùng chung cho cả từ chối ký và từ chối thủ thuật; phân biệt qua flag
refusal_type clinical_record_refusal_type_enum NULL, -- 'sign' | 'procedure' | NULL
refused_at TIMESTAMPTZ NULL, -- thời điểm từ chối (cho cả 2 loại)
refused_by TEXT NULL, -- BS/Y tá xác nhận từ chối
cancelled_order_item_id UUID NULL REFERENCES public.order_item(id), -- order_item của thủ thuật bị huỷ khi từ chối thủ thuật
witness_nurse_id TEXT NULL, -- y tá làm chứng khi KH từ chối ký HOẶC từ chối thủ thuật (DEC-024)
mandatory_scan_checklist JSONB NOT NULL DEFAULT '{}'::jsonb,
mandatory_scan_checklist_passed BOOLEAN NOT NULL DEFAULT false,
superseded_by UUID NULL REFERENCES public.clinical_record(id), -- link tới bản v2 khi in lại (DEC-023)
supersedes UUID NULL REFERENCES public.clinical_record(id),
visit_only BOOLEAN NOT NULL DEFAULT false, -- khám không làm DV (flag trong sổ khám, không tạo form)
visit_reason TEXT NULL, -- required khi visit_only=true
visit_conclusion TEXT NULL, -- required khi visit_only=true
visit_handling_plan TEXT NULL, -- xử trí/hẹn tái khám/chuyển tuyến; required khi visit_only=true
has_procedure BOOLEAN NOT NULL DEFAULT false, -- denormalized cho filter sổ thủ thuật
created_by TEXT NOT NULL,
updated_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ NULL,
printed_at TIMESTAMPTZ NULL,
signed_at TIMESTAMPTZ NULL,
deleted_at TIMESTAMPTZ NULL, -- chỉ dùng cho draft chưa phát hành; completed/printed/signed không xoá
CONSTRAINT unique_appointment_form_type UNIQUE (appointment_id, form_type),
CONSTRAINT visit_only_minimum_fields CHECK (
visit_only = false OR (
visit_reason IS NOT NULL AND btrim(visit_reason) <> ''
AND visit_conclusion IS NOT NULL AND btrim(visit_conclusion) <> ''
AND visit_handling_plan IS NOT NULL AND btrim(visit_handling_plan) <> ''
)
)
);
COMMENT ON TABLE public.clinical_record IS
'Bệnh án lượt khám theo appointment + form_type. Lifecycle: draft→completed→printed→signed→archived. Không xoá BA đã completed/printed/signed (DEC-023).';
COMMENT ON COLUMN public.clinical_record.mandatory_scan_checklist IS
'Checklist scan bắt buộc theo case: BA chính, cam đoan/cam kết, dị ứng, đơn thuốc, tờ điều trị, refusal witness.';
COMMENT ON COLUMN public.clinical_record.visit_only IS
'Khám thuần không làm DV. Chỉ ghi sổ khám tối thiểu; không tạo clinical_form_instance, không in/scan, không tính denominator BA cần hoàn thành.';
COMMENT ON COLUMN public.clinical_record.visit_reason IS
'Lý do khám tối thiểu cho visit-only log. Không dùng thay diagnosis_description của BA đầy đủ.';
COMMENT ON COLUMN public.clinical_record.visit_conclusion IS
'Kết luận khám tối thiểu cho visit-only log.';
COMMENT ON COLUMN public.clinical_record.visit_handling_plan IS
'Xử trí/hẹn tái khám/chuyển tuyến tối thiểu cho visit-only log.';
COMMENT ON COLUMN public.clinical_record.icd10_secondary_codes IS
'Array max 5 codes. Validate via BEFORE INSERT trigger.';
COMMENT ON COLUMN public.clinical_record.superseded_by IS
'Bản cũ trỏ tới bản mới khi in v2. Bản cũ status=superseded, không cho edit.';
COMMENT ON COLUMN public.clinical_record.customer_refused_procedure IS
'DEC-024: Khách từ chối thủ thuật. Khác `customer_refused_signature` (chỉ từ chối ký bản giấy). Khi true → cancelled_order_item_id phải có giá trị + refusal_reason ≥30 ký tự + witness_nurse_id NOT NULL + refusal_type=''procedure''.';
COMMENT ON COLUMN public.clinical_record.refusal_type IS
'Phân loại từ chối: ''sign'' = từ chối ký bản giấy (vẫn làm thủ thuật), ''procedure'' = từ chối thủ thuật (huỷ order_item).';
COMMENT ON COLUMN public.clinical_record.cancelled_order_item_id IS
'DEC-024: order_item bị huỷ do KH từ chối thủ thuật. NULL nếu không có từ chối thủ thuật. Trace lại đơn để xem hoàn tiền/đổi DV.';
CREATE INDEX idx_cr_customer_branch ON public.clinical_record(customer_id, branch_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_cr_branch_created ON public.clinical_record(branch_id, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_cr_appointment ON public.clinical_record(appointment_id);
CREATE INDEX idx_cr_doctor_draft ON public.clinical_record(primary_doctor_id, status) WHERE status = 'draft' AND deleted_at IS NULL;
CREATE INDEX idx_cr_procedure ON public.clinical_record(branch_id, has_procedure, created_at DESC) WHERE has_procedure = true AND deleted_at IS NULL;
CREATE INDEX idx_cr_icd10_primary ON public.clinical_record(icd10_primary_code) WHERE deleted_at IS NULL;
-- Trigger validate secondary codes <= 5
CREATE OR REPLACE FUNCTION validate_icd10_secondary_length()
RETURNS TRIGGER AS $$
BEGIN
IF array_length(NEW.icd10_secondary_codes, 1) > 5 THEN
RAISE EXCEPTION 'icd10_secondary_codes limit exceeded (max 5)';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_validate_icd10_secondary
BEFORE INSERT OR UPDATE ON public.clinical_record
FOR EACH ROW EXECUTE FUNCTION validate_icd10_secondary_length();C4.9. clinical_form_instance 🆕 Build
Ref: FR-005-007, DEC-005, DEC-021, DEC-022
sql
CREATE TABLE public.clinical_form_instance (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clinical_record_id UUID NOT NULL REFERENCES public.clinical_record(id) ON DELETE CASCADE,
form_template_id UUID NOT NULL REFERENCES public.form_template(id),
form_data JSONB NOT NULL DEFAULT '{}'::jsonb, -- Schema validated runtime theo form_template.schema
status form_instance_status_enum NOT NULL DEFAULT 'draft',
signature_typed_text TEXT NULL, -- DEC-021 giấy cam đoan KH tự gõ xác nhận
reused_from_form_instance_id UUID NULL REFERENCES public.clinical_form_instance(id), -- DEC-012 dị ứng reuse
source_type form_source_type_enum NOT NULL DEFAULT 'manual',
source_ref_id UUID NULL, -- customer_intake/reuse/import source id
source_accepted_by TEXT NULL,
source_accepted_at TIMESTAMPTZ NULL,
created_by TEXT NOT NULL,
updated_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ NULL,
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT unique_record_template UNIQUE (clinical_record_id, form_template_id)
);
COMMENT ON TABLE public.clinical_form_instance IS
'1 tờ biểu mẫu trong bộ hồ sơ. Form data JSONB validate theo schema từ form_template.';
COMMENT ON COLUMN public.clinical_form_instance.form_data IS
'Không cho raw mutation bypass validation. Ghi/sửa qua action validate_clinical_form_data hoặc DB trigger/check theo DEC-039.';
COMMENT ON COLUMN public.clinical_form_instance.signature_typed_text IS
'DEC-021 — KH tự gõ 1 câu xác nhận (không pre-fill). Regex match 1/2 câu hợp lệ validate FE+BE.';
CREATE INDEX idx_cfi_record ON public.clinical_form_instance(clinical_record_id);
CREATE INDEX idx_cfi_source ON public.clinical_form_instance(source_type, source_ref_id) WHERE source_ref_id IS NOT NULL;
CREATE INDEX idx_cfi_template ON public.clinical_form_instance(form_template_id);
CREATE INDEX idx_cfi_form_data_gin ON public.clinical_form_instance USING GIN (form_data);C4.10. treatment_progress_entry 🆕 Build (TM only)
Ref: FR-007 tờ điều trị
sql
CREATE TABLE public.treatment_progress_entry (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clinical_record_id UUID NOT NULL REFERENCES public.clinical_record(id) ON DELETE CASCADE,
entry_time TIMESTAMPTZ NOT NULL,
diagnosis_notes TEXT NOT NULL, -- Diễn biến bệnh
medical_order TEXT NOT NULL, -- Y lệnh
doctor_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL -- soft delete cho audit; entry cũ không edit được (AC-007.6)
);
COMMENT ON TABLE public.treatment_progress_entry IS
'Phiếu theo dõi điều trị multi-row timeline cho BA TM. Entry cũ không cho edit, chỉ thêm mới.';
CREATE INDEX idx_tpe_record_time ON public.treatment_progress_entry(clinical_record_id, entry_time DESC) WHERE deleted_at IS NULL;C4.11. prescription + prescription_item 🆕 Build
Ref: FR-007, TT52/2017
sql
CREATE TABLE public.prescription (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
clinical_record_id UUID NOT NULL REFERENCES public.clinical_record(id) ON DELETE CASCADE,
prescription_code VARCHAR(50) NOT NULL UNIQUE, -- VD: 'DT-2026-00001' (separate sequence)
diagnosis_text TEXT,
instructions TEXT, -- Lời dặn
customer_weight_kg NUMERIC(5,2), -- Cân nặng (BHYT yêu cầu)
guardian_name TEXT, -- Tên người đưa trẻ (nếu trẻ <6 tuổi)
guardian_phone VARCHAR(30),
doctor_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL
);
COMMENT ON TABLE public.prescription IS 'Đơn thuốc theo TT52/2017 BYT.';
CREATE INDEX idx_prescription_record ON public.prescription(clinical_record_id);
CREATE TABLE public.prescription_item (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prescription_id UUID NOT NULL REFERENCES public.prescription(id) ON DELETE CASCADE,
drug_name TEXT NOT NULL,
dosage TEXT NOT NULL, -- VD: "500mg"
quantity INT NOT NULL CHECK (quantity > 0),
instruction TEXT NOT NULL, -- VD: "Uống 2 viên x 2 lần/ngày sau ăn"
days INT CHECK (days >= 0),
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE public.prescription_item IS 'Danh sách thuốc trong 1 đơn thuốc. Thứ tự theo sort_order.';
CREATE INDEX idx_prescription_item_pres ON public.prescription_item(prescription_id, sort_order);C4.12. icd10_code 🆕 Build (master catalog)
Ref: DEC-016, DEC-017
sql
-- Prerequisite pg_trgm đã tạo ở C4.0 trước mọi trigram index.
CREATE TABLE public.icd10_code (
code VARCHAR(10) PRIMARY KEY, -- VD: 'L40.0'
name_vi TEXT NOT NULL,
name_en TEXT,
chapter VARCHAR(10), -- VD: 'XII' (Da và mô dưới da)
parent_code VARCHAR(10) NULL REFERENCES public.icd10_code(code),
is_leaf BOOLEAN NOT NULL DEFAULT true, -- chỉ leaf mới chọn được (non-leaf là category)
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE public.icd10_code IS
'ICD-10 master catalog BYT VN 2015 (~22k rows). Seed via migration. Admin phase 2 có thể import bản mới.';
CREATE INDEX idx_icd10_name_trgm ON public.icd10_code USING GIN (name_vi gin_trgm_ops) WHERE active = true;
CREATE INDEX idx_icd10_chapter ON public.icd10_code(chapter);
CREATE INDEX idx_icd10_parent ON public.icd10_code(parent_code);C4.13. allergy_check_policy 🆕 Build
Ref: FORMULA-007, DEC-011
sql
CREATE TABLE public.allergy_check_policy (
risk_level allergy_risk_level_enum PRIMARY KEY,
action allergy_action_enum NOT NULL,
require_reason BOOLEAN NOT NULL,
audit_log BOOLEAN NOT NULL,
updated_by TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE public.allergy_check_policy IS
'Policy 4-row: map allergy_risk_level → action. Unknown luôn block và không được hạ mức.';
-- Seed Day-1 (trong migration 1766200010000)
-- unknown:(block, false, true)
-- low: (warn, false, true) -- không bắt lý do nhưng vẫn audit acknowledgement
-- medium: (confirm_skip, true, true)
-- high: (block, false, true)C4.14. allergy_check_skip_log 🆕 Build
Ref: AC-010.3
sql
CREATE TABLE public.allergy_check_skip_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
clinical_record_id UUID NOT NULL REFERENCES public.clinical_record(id),
technical_category_id UUID NOT NULL REFERENCES public.technical_category(id),
risk_level allergy_risk_level_enum NOT NULL,
reason TEXT,
skipped_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_acsl_record ON public.allergy_check_skip_log(clinical_record_id);
CREATE INDEX idx_acsl_tech_cat_date ON public.allergy_check_skip_log(technical_category_id, skipped_at DESC);C4.15. medical_record_access_log 🆕 Build
Ref: FR-011, DEC-010
sql
CREATE TABLE public.medical_record_access_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
clinical_record_id UUID NOT NULL REFERENCES public.clinical_record(id),
action access_log_action_enum NOT NULL,
tier SMALLINT NOT NULL CHECK (tier IN (1,2,3)),
reason TEXT NULL, -- required khi emergency_override
ip INET,
user_agent TEXT,
emergency_override BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE public.medical_record_access_log IS
'Audit log cho tầng 3 access. Write từ middleware trên mỗi request clinical_record. Emergency override realtime notify QL CN + admin.';
CREATE INDEX idx_mral_record_time ON public.medical_record_access_log(clinical_record_id, created_at DESC);
CREATE INDEX idx_mral_user_time ON public.medical_record_access_log(user_id, created_at DESC);
CREATE INDEX idx_mral_emergency ON public.medical_record_access_log(created_at DESC) WHERE emergency_override = true;C4.16. clinical_sales_handoff 🆕 Build
Ref: FR-016, DEC-032
sql
CREATE TABLE public.clinical_sales_handoff (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id TEXT NOT NULL,
branch_id UUID NOT NULL REFERENCES public.branch(id),
sales_id TEXT NOT NULL,
target_doctor_id TEXT NULL,
customer_expectation TEXT NOT NULL,
commercial_context JSONB NOT NULL DEFAULT '{}'::jsonb, -- service_ids, package_id, campaign source
safe_notes TEXT NULL, -- KH tự mô tả, không phải chẩn đoán
status clinical_sales_handoff_status_enum NOT NULL DEFAULT 'draft',
acknowledged_by TEXT NULL,
acknowledged_at TIMESTAMPTZ NULL,
closed_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL
);
COMMENT ON TABLE public.clinical_sales_handoff IS
'Handoff an toàn từ Sale sang BS. Không chứa dữ liệu tầng 3 hoặc chẩn đoán y khoa.';
CREATE INDEX idx_csh_customer ON public.clinical_sales_handoff(customer_id, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_csh_doctor_status ON public.clinical_sales_handoff(target_doctor_id, status, created_at DESC) WHERE deleted_at IS NULL;C4.17. customer_clinical_intake 🆕 Build
Ref: FR-018, DEC-034
sql
CREATE TABLE public.customer_clinical_intake (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id TEXT NOT NULL,
branch_id UUID NOT NULL REFERENCES public.branch(id),
appointment_id UUID NULL REFERENCES public.appointment(id),
opened_by TEXT NOT NULL, -- lễ tân/y tá mở session
reviewed_by TEXT NULL, -- BS review
session_token_hash TEXT NOT NULL UNIQUE, -- one-time token hash, raw token chỉ trả 1 lần khi open
token_last_four VARCHAR(8) NULL, -- hỗ trợ audit/debug, không dùng auth
allergy_answers JSONB NOT NULL DEFAULT '{}'::jsonb,
medical_history JSONB NOT NULL DEFAULT '{}'::jsonb,
current_medications JSONB NOT NULL DEFAULT '{}'::jsonb,
customer_confirmation_text TEXT NULL,
status customer_intake_status_enum NOT NULL DEFAULT 'draft',
source_device VARCHAR(50) NOT NULL DEFAULT 'branch_tablet',
device_id TEXT NULL,
submitted_ip INET NULL,
submitted_user_agent TEXT NULL,
submitted_at TIMESTAMPTZ NULL,
reviewed_at TIMESTAMPTZ NULL,
accepted_form_instance_id UUID NULL REFERENCES public.clinical_form_instance(id),
reject_reason TEXT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL
);
COMMENT ON TABLE public.customer_clinical_intake IS
'Phiếu khách tự khai trước khám. Chỉ có giá trị vào BA sau khi BS review/accept.';
CREATE INDEX idx_cci_customer_status ON public.customer_clinical_intake(customer_id, status, created_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX idx_cci_appointment ON public.customer_clinical_intake(appointment_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_cci_branch_submitted ON public.customer_clinical_intake(branch_id, submitted_at DESC) WHERE status = 'submitted';
CREATE INDEX idx_cci_token_active ON public.customer_clinical_intake(session_token_hash, expires_at)
WHERE deleted_at IS NULL AND consumed_at IS NULL;C4.18. clinic_daily_close 🆕 Build
Ref: FR-019, DEC-036
sql
CREATE TABLE public.clinic_daily_close (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
branch_id UUID NOT NULL REFERENCES public.branch(id),
close_date DATE NOT NULL,
status clinic_daily_close_status_enum NOT NULL DEFAULT 'open',
missing_record_count INT NOT NULL DEFAULT 0,
draft_overdue_count INT NOT NULL DEFAULT 0,
printed_not_scanned_count INT NOT NULL DEFAULT 0,
safety_issue_count INT NOT NULL DEFAULT 0,
issue_snapshot JSONB NOT NULL DEFAULT '[]'::jsonb,
close_cutoff_time TIME NOT NULL DEFAULT '20:00',
reminder_times_snapshot TIME[] NOT NULL DEFAULT ARRAY['17:30'::time, '19:00'::time],
timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Ho_Chi_Minh',
closed_by TEXT NULL,
closed_at TIMESTAMPTZ NULL,
escalated_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (branch_id, close_date)
);
COMMENT ON TABLE public.clinic_daily_close IS
'Snapshot chốt ngày phòng khám per CN. Dùng cho Trang điều phối phòng khám, escalation và audit.';
CREATE INDEX idx_cdc_branch_date ON public.clinic_daily_close(branch_id, close_date DESC);
CREATE INDEX idx_cdc_status_date ON public.clinic_daily_close(status, close_date DESC);C4.19) Workflow request khác chi nhánh — deferred Phase 2
Ref: DEC-010
Day-1 không tạo bảng medical_record_access_request.
- Case thường cross-branch: xử lý theo SOP manual ngoài app.
- Chỉ
emergency_overrideđược số hoá trong Day-1 và audit quamedical_record_access_log. - Nếu legal sign-off xong và mở Phase 2, mới bổ sung bảng request/approve riêng.
C4.20. sequence_generator 🆕 Build (generic)
Ref: FR-014, FORMULA-001/002/003
sql
CREATE TABLE public.sequence_generator (
entity_type VARCHAR(50) NOT NULL, -- VD: 'clinical_record', 'visit_log', 'procedure_log', 'prescription'
scope_key VARCHAR(100) NOT NULL, -- VD: 'DL-CL' (form_type-branch_code) hoặc 'CL' (branch_code)
year INT NOT NULL DEFAULT 0, -- 0 = không reset theo năm (clinical_profile)
current_number BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (entity_type, scope_key, year)
);
COMMENT ON TABLE public.sequence_generator IS
'Generic sequence generator thread-safe. Query via get_next_sequence(entity_type, scope_key, year) plpgsql function sử dụng SELECT FOR UPDATE.';
-- PostgreSQL function thread-safe
CREATE OR REPLACE FUNCTION get_next_sequence(
p_entity_type VARCHAR,
p_scope_key VARCHAR,
p_year INT
) RETURNS BIGINT AS $$
DECLARE
v_next BIGINT;
v_year INT := COALESCE(p_year, 0);
BEGIN
INSERT INTO public.sequence_generator (entity_type, scope_key, year, current_number)
VALUES (p_entity_type, p_scope_key, v_year, 0)
ON CONFLICT (entity_type, scope_key, year) DO NOTHING;
UPDATE public.sequence_generator
SET current_number = current_number + 1,
updated_at = NOW()
WHERE entity_type = p_entity_type
AND scope_key = p_scope_key
AND year = v_year
RETURNING current_number INTO v_next;
RETURN v_next;
END;
$$ LANGUAGE plpgsql;C5) Quy ước tích hợp API và Hasura
Tất action handler đặt trong
services/ecommerce-api/action/(trừ khi ghi rõ khác). Hasura metadata YAML bổ sung vào sourceecommercetrongservices/controller/metadata/databases/ecommerce/tables/.Runtime route lock:
ecommerce-apiexpose route chungPOST /actions,POST /events,POST /schedulers. Metadata không được trỏ tới/actions/foo,/events/foo,/schedulers/foo; dispatcher xử lý theoaction.name, event trigger payload hoặc scheduler payload.
C5.1. Action: publish_clinic_module_config 🆕
Ref: FR-001
yaml
# services/controller/metadata/actions.yaml (append)
- name: publish_clinic_module_config
definition:
kind: synchronous
handler: '{{ECOM_ACTION_URL}}/actions'
forward_client_headers: true
permissions:
- role: admin
# actions.graphql
type Mutation {
publish_clinic_module_config(
branch_id: uuid!
features: jsonb!
): PublishClinicModuleConfigOutput
}
type PublishClinicModuleConfigOutput {
branch_id: uuid!
features: jsonb!
}Handler: services/ecommerce-api/action/publish_clinic_module_config.go 🆕
go
// Logic:
// 1. Validate features JSON shape: {clinic_enabled: bool, clinic_types: ["DL"|"TM"], clinic_status: "..."}
// 2. Không cho set clinic_status='live' trực tiếp; live phải đi qua publish_clinic_module
// 3. Validate Legal/Ops/readiness artifacts nếu payload yêu cầu chuyển ready_to_publish
// 4. UPDATE branch SET features = $1 WHERE id = $2
// 5. Return updatedC5.2. Action: publish_clinic_module 🆕
Ref: FR-015
yaml
- name: publish_clinic_module
definition:
kind: synchronous
handler: '{{ECOM_ACTION_URL}}/actions'
permissions:
- role: admin
type Mutation {
publish_clinic_module(
branch_id: uuid!
effective_at: timestamptz
): PublishClinicModuleOutput
}
type PublishClinicModuleOutput {
branch_id: uuid!
status: String!
readiness_passed: Boolean!
blockers: [String!]!
}Handler: services/ecommerce-api/action/publish_clinic_module.go 🆕
go
// Logic:
// 1. Build readiness_snapshot R-01..R-10 từ branch config, classification, risk, permission, smoke test và Go-live packet.
// 2. Block nếu còn service_clinical_classification.status='unclassified' cho product active tại branch. (R-03)
// 3. Block nếu technical_category active còn allergy_risk_level='unknown'. (R-04)
// 4. Block nếu thiếu BS/Y tá/QL CN, support owner/on-call, Legal/SYT artifact hoặc scanner/printer/upload-account/naming training evidence. (R-05, R-08..R-10)
// 5. Nếu pass: insert clinic_module_publication:
// - effective_at <= now(): status='live'
// - effective_at > now(): status='scheduled'
// branch.features.clinic_enabled=true chỉ giữ nghĩa "module đã setup"; runtime actions/schedulers đọc publication live gate.C5.3. Action: import_technical_categories_csv 🆕
Ref: FR-002 bước 3, AC-002.3
yaml
- name: import_technical_categories_csv
definition:
kind: synchronous
handler: '{{ECOM_ACTION_URL}}/actions'
permissions:
- role: admin
type Mutation {
import_technical_categories_csv(
branch_id: uuid!
csv_file_id: uuid! # reference_file.id
): ImportTechnicalCategoriesOutput
}
type ImportTechnicalCategoriesOutput {
imported: Int!
skipped: Int!
errors: [String!]!
}Handler logic:
- Fetch CSV file from MinIO qua
reference_file.url - Parse columns:
code, name_vi, type, allergy_risk_level - Dòng thiếu
allergy_risk_level→ lưuunknown, không pass readiness cho KT active - Bulk insert với
ON CONFLICT (branch_id, code) DO UPDATE SET name_vi = EXCLUDED.name_vi, ... - Return count + errors
C5.4. Action: create_clinical_record 🆕
Ref: FR-004, FR-005, FR-006
yaml
- name: create_clinical_record
permissions:
- role: user
- role: admin
type Mutation {
create_clinical_record(
appointment_id: uuid! # walk-in vẫn tạo appointment trước, order_item_id có thể null
branch_id: uuid!
customer_id: String!
form_type: String! # "DL" | "TM"
primary_doctor_id: String!
visit_only: Boolean = false
visit_reason: String
visit_conclusion: String
visit_handling_plan: String
): ClinicalRecord
}Handler: services/ecommerce-api/action/clinical_record.go 🆕
go
// Pseudo-code:
// 1. Validate: branch.features.clinic_enabled = true AND latest clinic_module_publication.status='live' AND effective_at <= now()
// 2. Validate business permission: BS/Medical Lead được tạo BA; y tá chỉ hỗ trợ hành chính sau khi BS sở hữu
// 3. Validate appointment thuộc branch/customer bằng resolver từ order/order_item/reference_appointment/appointment_user
// 3a. Nếu visit_only=true: require visit_reason + visit_conclusion + visit_handling_plan; không tạo clinical_form_instance, không in/scan, không tính denominator BA cần hoàn thành.
// 4. Determine has_procedure:
// - If appointment.order_item_id != nil: query order_item trực tiếp
// - Else if có order.reference_appointment_id = appointment.id: query order_items của order đó
// - Else default false và chờ late-link khi order phát sinh sau
// 5. Generate codes:
// - visit_log = generate_next_sequence('visit_log', branch.code, YEAR(NOW())) for mọi lượt khám
// - profile_code = NULL nếu visit_only=true; ngược lại generate_next_sequence('clinical_profile', form_type + '-' + branch.code, NULL)
// - procedure_log = IF visit_only=false AND has_procedure THEN generate_next_sequence('procedure_log', branch.code, YEAR) ELSE NULL
// 6. INSERT clinical_record; unique by appointment_id + form_type; visit_only dùng clinical_profile_id=NULL.
// 7. Nếu visit_only=false, auto-create form instances based on form_type + has_procedure:
// - BA_DL hoặc BA_TM (required)
// - CONSENT_SURGERY (if has_procedure high-risk)
// - CONSENT_TREATMENT (always)
// - ALLERGY_HISTORY (if not exists in last 12 months)
// 8. Audit log action='edit' tier=3 source='action:create_clinical_record'
// 9. Return clinical_record + form_instancesC5.5. Action: complete_clinical_record 🆕
Ref: FR-008, FR-010
yaml
type Mutation {
complete_clinical_record(
clinical_record_id: uuid!
confirm_skip_reason: String
): CompleteClinicalRecordOutput
}
type CompleteClinicalRecordOutput {
clinical_record_id: uuid!
status: String!
allergy_block: AllergyBlockInfo # nếu block/confirm_skip cần BS xử lý
}
type AllergyBlockInfo {
action: String! # "block" | "confirm_skip"
technical_category: String!
risk_level: String!
message: String!
}Handler logic (allergy guard FR-010):
go
// 1. Load clinical_record + related order_item products + technical_categories
// 2. Highest allergy_risk_level trong DV
// 3. Check có ALLERGY_HISTORY form_instance status='completed' trong 12 tháng?
// 4. Lookup allergy_check_policy[risk_level] → action
// 5. If action = 'block' AND !has_allergy_form: return AllergyBlockInfo, don't commit
// 6. If action = 'confirm_skip': require confirm_skip_reason in input → allergy_check_skip_log.
// If action = 'warn': require UI acknowledgement; log skip/ack without mandatory reason.
// 7. UPDATE clinical_record SET status = 'completed', completed_at = NOW()
// 8. Event trigger fires clinical_record_status_change → notificationC5.5A. Action: validate_clinical_form_data 🆕
Ref: FR-005, FR-006, FR-007, FR-008, DEC-039, DEC-040
yaml
type Mutation {
validate_clinical_form_data(
clinical_record_id: uuid!
form_template_id: uuid!
form_data: jsonb!
status: String! # draft | completed
source_type: String = "manual"
source_ref_id: uuid
signature_typed_text: String
): ClinicalFormInstance
}Quy ước handler:
- Validate
clinical_recordlive gate, branch scope và business permission; Sale không được gọi. - Load
form_template.schema, validate JSON Schema server-side, required fields, field type, sensitivity tag và conditional sections theoform_type. - Validate
source_type;paper_fallbackchỉ dùng khi downtime/manual fallback được Ops mở và phải có audit reason trong service log. - Upsert
clinical_form_instancebằng transaction; raw Hasura insert/update vàoclinical_form_instance.form_datakhông cấp cho roleuser. - Nếu
status='completed': enforce required legal fields (signature_typed_text, allergy history, consent checkboxes) và ghimedical_record_access_log action='edit'. - Return normalized form instance + validation errors dạng field path nếu fail.
Rule signature_typed_text (DEC-021) — server-side enforce:
go
// validateSignatureTypedText áp dụng cho form Cam đoan (form_template.code='consent_procedure').
func validateSignatureTypedText(typed string, typedDurationMs int) error {
trimmed := strings.TrimSpace(typed)
runeLen := utf8.RuneCountInString(trimmed)
if runeLen < 20 {
return Errorf("error.confirmation_too_short",
"Câu xác nhận phải có ít nhất 20 ký tự (hiện tại %d).", runeLen)
}
lower := strings.ToLower(trimmed)
if !strings.Contains(lower, "đồng ý") && !strings.Contains(lower, "thực hiện") {
return Errorf("error.confirmation_too_short",
"Câu xác nhận phải chứa từ 'đồng ý' hoặc 'thực hiện'.")
}
// Chống auto-typer / paste bypass FE: tốc độ trung bình > 30 ký tự / giây = đáng ngờ.
// (Người gõ thuần thường ≤ 8 cps; macro/script thường ≥ 50 cps.)
if typedDurationMs > 0 {
cps := float64(runeLen) / (float64(typedDurationMs) / 1000.0)
if cps > 30 {
// Không reject; chỉ flag để compliance review.
log.Warn("suspicious typing speed", "cps", cps,
"rune_len", runeLen, "duration_ms", typedDurationMs)
// FE đã chặn paste; đến đây nghĩa là đã bypass FE → audit flag.
// Lưu flag vào audit, không block để tránh chặn nhầm KH gõ nhanh.
}
}
return nil
}- Lưu
typed_at(server-side),typed_duration_ms(FE đo) vàoclinical_form_instanceaudit columns hoặcmedical_record_access_logfieldextra_payload; không lưu raw keystroke timeline. - FE bắt event
pasteở textarea và ngăn dán mặc định; nếu user dùng IME composing → chỉ kiểm saucompositionend. - Phân biệt với form Cam kết (
consent_treatment): nếu Cam kết có textarea xác nhận khác, áp dụng cùng rule. Form khác KHÔNG bắt buộc rule này.
C5.5B. Action: refuse_clinical_procedure 🆕
Ref: DEC-024 (KH từ chối thủ thuật → huỷ DV trong đơn + ghi sổ khám), FR-007 phụ trợ Cam đoan, UI Spec SCR-08 B2.5 + B-edge 14b + B7 confirm.refuse_procedure.
Mục đích: Khi BS xác nhận khách từ chối thực hiện thủ thuật trong cuộc khám, hệ thống PHẢI: (a) huỷ order_item của thủ thuật; (b) đánh dấu clinical_record từ chối thủ thuật + lưu y tá làm chứng; (c) KHÔNG tạo Bệnh án thủ thuật mới; (d) giữ Bệnh án DL nếu đã có (không touch); (e) ghi audit log; (f) gửi notification NTF-14.
yaml
type Mutation {
refuse_clinical_procedure(
clinical_record_id: uuid!
order_item_id: uuid! # order_item của thủ thuật cần huỷ
refusal_reason: String! # ≥ 30 ký tự, theo dõi business
witness_nurse_id: uuid! # y tá làm chứng cùng CN
): RefuseClinicalProcedureOutput
}
type RefuseClinicalProcedureOutput {
clinical_record_id: uuid!
order_item_id: uuid!
cancelled_at: timestamptz!
visit_log_entry: String! # Mô tả ghi vào sổ khám "Khách từ chối thủ thuật {service_name}"
preserved_dl_record_id: uuid # Nếu có Bệnh án DL cùng appointment, trả ID; null nếu không có
}Handler logic:
go
// 1. Validate quyền: user phải có
// - clinical_record.refuse_procedure (mới) — kiểm tra trước hành động clinical
// - order.update — kiểm tra trước khi cancel order_item
// Nếu thiếu refuse_procedure → 403 ngay; nếu thiếu order.update → 422 "Liên hệ QL CN".
//
// 2. Validate input:
// - refusal_reason ≥ 30 ký tự (sau trim)
// - witness_nurse_id phải thuộc CN của clinical_record (cùng branch_id) và role nurse
// - order_item_id phải thuộc cùng appointment với clinical_record (qua appointment.order_id ↔ order_item.order_id)
// - order_item.product phải có technical_category_id thuộc nhóm 'procedure' (không phải tư vấn / khám thuần)
// - clinical_record.status PHẢI là 'draft' hoặc 'completed' (chưa printed/signed) — đã in/ký không cho huỷ
// - Idempotent: nếu clinical_record.customer_refused_procedure đã true → return 409 "Đã ghi nhận từ chối trước đó"
//
// 3. Transaction (BẮT BUỘC):
// BEGIN;
// UPDATE order_item
// SET status = 'cancelled',
// cancel_reason = 'customer_refused_procedure',
// cancelled_by = current_user_id,
// cancelled_at = NOW()
// WHERE id = order_item_id
// AND status NOT IN ('cancelled', 'completed', 'refunded'); -- guard
//
// UPDATE clinical_record
// SET customer_refused_procedure = true,
// refusal_reason = $refusal_reason,
// refusal_type = 'procedure',
// refused_at = NOW(),
// refused_by = current_user_id,
// cancelled_order_item_id = order_item_id,
// witness_nurse_id = witness_nurse_id,
// updated_at = NOW(),
// updated_by = current_user_id
// WHERE id = clinical_record_id;
//
// INSERT INTO medical_record_access_log
// (id, clinical_record_id, action, actor_user_id, branch_id,
// reason, extra_payload, created_at)
// VALUES (gen_random_uuid(), clinical_record_id, 'refuse_procedure',
// current_user_id, branch_id,
// refusal_reason,
// jsonb_build_object(
// 'order_item_id', order_item_id,
// 'witness_nurse_id', witness_nurse_id,
// 'cancelled_service_name', service_name
// ),
// NOW());
// COMMIT;
//
// 4. KHÔNG touch: Bệnh án DL cùng appointment (form_type='DL') — nó là record độc lập
// với form_type='TM' bị từ chối. Trả preserved_dl_record_id nếu tồn tại.
//
// 5. KHÔNG tạo Bệnh án thủ thuật mới: handler không gọi create_clinical_record cho form TM.
// Nếu đã tồn tại clinical_record TM nháp cùng appointment → vẫn để tồn tại nhưng đánh dấu
// customer_refused_procedure=true (giống logic ở bước 3).
//
// 6. Notification NTF-14: enqueue noti_clinical_procedure_refused (xem C5.16 event triggers).
// Payload AN TOÀN: { customer_id, branch_id, service_name, doctor_id, refusal_reason_excerpt (≤80 ký tự) }
// KHÔNG gồm: diagnosis, ICD, form_data, scan URL.
//
// 7. Sổ khám hiển thị (qua view clinical_record_secure_summary_view):
// Cột "Kết quả" = CASE
// WHEN customer_refused_procedure THEN 'Khách từ chối thủ thuật'
// WHEN visit_only THEN 'Khám tư vấn'
// WHEN status = 'signed' THEN 'Đã ký + scan đủ'
// ELSE status::text
// ENDPermission seed mới:
| Module | Action | Default seed | Ghi chú |
|---|---|---|---|
clinical_record | refuse_procedure | BS, Y tá trưởng cùng CN | Bị thu hồi → CTA ẩn ở SCR-08; gọi action trực tiếp → 403 |
Validation matrix:
| Tình huống | Status | Response | TC ref |
|---|---|---|---|
| Happy path | OK | 200 + RefuseClinicalProcedureOutput | TC-090 |
witness_nurse_id rỗng/không thuộc CN | 422 | error.refusal_witness_required | TC-090A |
Đã có Bệnh án DL signed cùng appointment | OK | 200, preserved_dl_record_id không null | TC-090B |
User thiếu clinical_record.refuse_procedure | 403 | "Bạn không có quyền thực hiện thao tác này" | TC-090C |
User thiếu order.update | 422 | "Bạn không có quyền huỷ đơn dịch vụ. Liên hệ QL CN." | TC-090C |
clinical_record.status='printed'/'signed' | 422 | "Bệnh án đã in/ký, không thể ghi nhận từ chối thủ thuật. Sửa qua bản v2 (DEC-023)." | (P2 — không có TC riêng) |
refusal_reason <30 ký tự | 422 | "Vui lòng ghi rõ lý do từ chối (≥30 ký tự)" | (covered TC-090) |
order_item không phải thủ thuật | 422 | "Order item không phải dịch vụ thủ thuật" | (P2) |
| Re-call action trên record đã từ chối | 409 | "Đã ghi nhận khách từ chối thủ thuật lúc {refused_at}" | (idempotency) |
C5.6. Action: print_clinical_record 🆕
Ref: FR-009
yaml
type Mutation {
print_clinical_record(
clinical_record_id: uuid!
): PrintOutput
}
type PrintOutput {
pdf_url: String! # MinIO signed URL TTL 15m
forms_included: [String!]! # list form codes
}Handler: reuse pattern action/print_invoice.go 🔧 Extend
- Generate HTML từ 7 templates với
{{branch.license_header}} - Convert HTML → PDF (wkhtmltopdf wrapper)
- Tải file lên MinIO, return signed URL
- UPDATE clinical_record SET status = 'printed', printed_at = NOW()
C5.7. Action: upload_clinical_record_scans 🆕
Ref: FR-009, DEC-022
yaml
type Mutation {
upload_clinical_record_scans(
clinical_record_id: uuid!
reference_file_ids: [uuid!]!
target_form_instance_ids: [uuid!] # nullable = attach vào cả bộ
): UploadScansOutput
}Handler:
- Validate
reference_file_idsmỗi file < 20MB, mime PDF/JPG/PNG - Nếu upload cả bộ: update
reference_file.reference_id = clinical_record_id,reference_file.type = 'clinical_signed_bundle' - Nếu upload từng tờ: update
reference_file.reference_id = clinical_form_instance_id,reference_file.type = 'clinical_signed_form:<form_code>' - Ảnh trước/sau: update
reference_file.reference_id = clinical_record_id,reference_file.type = 'clinical_before_photo'hoặcclinical_after_photo - Recompute
clinical_record.mandatory_scan_checklisttheo case: BA chính, cam đoan/cam kết nếu có, dị ứng nếu required, đơn thuốc nếu có, tờ điều trị nếu có entry, refusal witness nếu KH từ chối ký. - Chỉ UPDATE status →
signed,signed_at = NOW(),mandatory_scan_checklist_passed = truekhi checklist pass. Nếu thiếu, giữprintedvà trả danh sách missing form codes.
C5.8) Workflow request/approve khác chi nhánh — deferred Phase 2
Ref: DEC-010
Day-1 không expose request_cross_branch_access hoặc grant_cross_branch_access.
- UI case thường chỉ hiển thị trạng thái khóa + hướng dẫn SOP manual ngoài app.
- Khi legal sign-off xong, workflow request/approve mới được bổ sung thành Phase 2.
C5.9. Action: emergency_override_access 🆕
Ref: FR-011 AC-011.4
yaml
type Mutation {
emergency_override_access(
clinical_record_id: uuid!
reason: String! # required, min 20 chars
): ClinicalRecord
}Handler:
- Validate reason length
- Audit log action='emergency_override' tier=3 emergency_override=true
- Send realtime notification → QL CN + admin chuỗi
- Return clinical_record full (bypass RBAC filter via service account session variable)
C5.10. Action: create_allergy_skip_log 🆕
Ref: FR-010 AC-010.3
yaml
type Mutation {
create_allergy_skip_log(
clinical_record_id: uuid!
technical_category_id: uuid!
risk_level: String!
reason: String!
): AllergyCheckSkipLog
}Auto-call khi user confirm skip trong UI allergy warning popup.
C5.11. Action: generate_next_sequence 🆕 (internal)
Ref: FR-014
yaml
# Hasura action hoặc direct function call từ handler khác
# Exposed chỉ cho admin (emergency recovery)
type Mutation {
generate_next_sequence(
entity_type: String!
scope_key: String!
year: Int!
): GenerateSequenceOutput
}C5.12. Actions: Sales handoff + Phiếu khách tự khai + Chốt ngày phòng khám 🆕
Ref: FR-016, FR-018, FR-019
yaml
type Mutation {
create_clinical_sales_handoff(
customer_id: uuid!
branch_id: uuid!
target_doctor_id: uuid
customer_expectation: String!
commercial_context: jsonb
safe_notes: String
): ClinicalSalesHandoff
acknowledge_sales_handoff(
handoff_id: uuid!
status: String! # acknowledged|closed
): ClinicalSalesHandoff
open_customer_clinical_intake_session(
customer_id: uuid!
branch_id: uuid!
appointment_id: uuid
source_device: String
device_id: String
): CustomerClinicalIntakeSession
submit_customer_clinical_intake_by_token(
intake_token: String!
allergy_answers: jsonb!
medical_history: jsonb!
current_medications: jsonb!
customer_confirmation_text: String
): CustomerClinicalIntake
review_customer_clinical_intake(
intake_id: uuid!
decision: String! # accept|edit_accept|reject
target_clinical_record_id: uuid
reject_reason: String
): CustomerClinicalIntake
close_clinic_day(
branch_id: uuid!
close_date: date!
issue_snapshot: jsonb!
): ClinicDailyClose
}CustomerClinicalIntakeSession output:
| Field | Ghi chú |
|---|---|
intake_id | ID record đã tạo ở status draft |
intake_token | Raw one-time token, chỉ trả về 1 lần cho tablet/kiosk |
expires_at | now() + 15 minutes theo server |
customer_display_name | Tên KH đã mask đủ cho lễ tân/y tá kiểm tra đúng người |
Handler notes:
create_clinical_sales_handoff: user có business permission Sale được tạo; handler strip mọi field tầng 3 và validate handoff note không chứa pattern chẩn đoán/y lệnh Day-1; notification NTF-12 tới BS.open_customer_clinical_intake_session: chỉ lễ tân/y tá/QL CN được mở; validate user có scope CN, branch đang live theoclinic_module_publication.status='live' AND effective_at <= now(), appointment nếu có phải thuộc đúngcustomer_id + branch_id; không tạo thêm session nếu đã có active session cùngcustomer_id + appointment_id; generate raw token trả về 1 lần cho tablet, DB chỉ lưusession_token_hash;expires_at = now() + 15 minutes.submit_customer_clinical_intake_by_token: tablet/KH submit bằng token; handler hash token, verify chưa expired/chưa consumed, không nhậncustomer_id/branch_idtừ client; submit xong setconsumed_at,submitted_at, clear client session và gửi NTF-13.review_customer_clinical_intake: chỉ BS/y tá có quyền CN; validate intakestatus=submitted; nếu cótarget_clinical_record_idthì record phải cùngcustomer_id + branch_idvà cùngappointment_idkhi intake có appointment;acceptmới prefill/tạoclinical_form_instancevớisource_type = customer_intake,source_ref_id = intake_id,source_accepted_by,source_accepted_at; intake row cập nhậtreviewed_by,reviewed_at,accepted_form_instance_id.close_clinic_day: server recompute P0 issues trước khi close, không tin snapshot từ FE; block nếu còn printed chưa scan >24h hoặc BA cần tạo chưa có owner.
C5.13) Walk-in late-link — reuse existing create_order.reference_appointment_id
Ref: FR-004 case B walk-in, AC-004.4
Day-1 không tạo action mới để backfill appointment.order_item_id.
Quy ước:
- FE order flow query các appointment walk-in mở cùng
customer_idtrong ngày. - Nếu user chọn link, payload tạo đơn hiện có sẽ set
reference_appointment_id = appointment.id. order_insert.gotiếp tục là canonical điểm xử lý hậu link (mark appointment completed, fire notification/log liên quan).- Mọi truy vấn báo cáo cần xét cả:
appointment.order_item_idcho flow từ đơn sẵn cóorder.reference_appointment_idcho walk-in link muộn
C5.14) Hasura queries (views + filter)
Role/source lock: Hasura runtime vẫn dùng user/admin, nhưng clinical runtime không expose raw table cho user. Business role như BS/Y tá/Sale/QL CN được enforce bằng permission tables, action handlers hoặc SQL view có X-Hasura-User-Id; không tạo role Hasura surgeon, nurse, sales, branch_manager.
get_clinical_records_by_branch (sổ khám bệnh)
yaml
# Secure view exposure. Raw table public.clinical_record không có select_permissions role user.
- table:
name: clinical_record_secure_summary_view
schema: public
select_permissions:
- role: user
permission:
columns: [clinical_record_id, visit_log_number, customer_id, branch_id, status, form_type, primary_doctor_id, created_at, completed_at, can_open_detail]
filter:
can_view_summary: { _eq: true }
limit: 100
- role: admin
permission:
columns: [clinical_record_id, visit_log_number, customer_id, branch_id, status, form_type, primary_doctor_id, created_at, completed_at, can_open_detail]
filter:
can_view_summary: { _eq: true }
# Detail tầng 3 gọi action clinical_record_medical_detail_action; không query raw table.Raw clinical_record, clinical_form_instance, diagnosis/scan/media tables không cấp select/insert/update cho role user. Nếu cần truy cập admin ops, dùng role admin giới hạn môi trường + audit và không coi là quy ước API cho FE.
get_customer_clinical_history (tầng-based filter)
yaml
# Hasura permission cho customer tab "Hồ sơ BA":
# - BS cùng CN: xem full
# - BS CN khác: xem tầng 1+2 (dị ứng, lịch sử DV). Tầng 3 (diagnosis, notes) → null
# - Sale: chỉ xem view/action sales_safe_clinical_summary, không xem raw clinical_record.
- table: sales_safe_clinical_summary
select_permissions:
- role: user
permission:
columns:
- customer_id
- branch_id
- service_history_summary
- clinical_record_count_by_type
- safe_alert_level
- safe_alert_code
- safe_alert_text
- can_create_handoff
- latest_handoff_status
filter:
can_view: { _eq: true }Raw clinical_record không expose cho runtime user hoặc business role Sale. Nếu cần count, trả aggregate đã mask, không trả từng record id/code để tránh suy luận dữ liệu tầng 3.
search_icd10
yaml
# Standard Hasura table query với filter full-text trigram
# Query GraphQL:
query SearchICD10($keyword: String!) {
icd10_code(
where: {
_and: [
{ active: { _eq: true } },
{ is_leaf: { _eq: true } },
{
_or: [
{ code: { _ilike: "%keyword%" } },
{ name_vi: { _ilike: "%keyword%" } }
]
}
]
}
limit: 20
) { code name_vi chapter }
}doctor_workbench_view
Minimum output columns bắt buộc: task_key, task_type, source_table, source_id, clinical_record_id, customer_id, branch_id, doctor_id, queue_owner_type, queue_owner_id, record_status, priority_level, next_action, target_route, task_updated_at.
doctor_id được phép null khi task thuộc queue CN. queue_owner_id là TEXT để chứa cả account id và branch_id::text; FE lọc task bằng queue_owner_type/queue_owner_id: doctor + account_id hoặc branch_queue + branch_id. SQL smoke test phải cover bucket sales_handoff và intake_review để bắt lỗi COALESCE text/uuid.
Lưu ý: SQL dưới đây là quy ước view, không copy raw nếu chưa map schema runtime. appointment hiện không có customer_id/deleted_at/date; migration view phải triển khai resolver customer từ order/order_item/reference_appointment/clinical_record hoặc appointment_user theo schema thật. Không để helper ẩn chưa định nghĩa. Bảng order cần quote public."order" nếu DB cần.
sql
CREATE OR REPLACE FUNCTION public.resolve_appointment_customer_id(p_appointment_id UUID)
RETURNS TEXT AS $$
SELECT COALESCE(
cr.customer_id,
o_direct.customer_id::text,
o_ref.customer_id::text
)
FROM public.appointment a
LEFT JOIN public.clinical_record cr
ON cr.appointment_id = a.id
AND cr.deleted_at IS NULL
LEFT JOIN public.order_item oi_direct
ON oi_direct.id = a.order_item_id
LEFT JOIN public."order" o_direct
ON o_direct.id = oi_direct.order_id
AND o_direct.deleted_at IS NULL
LEFT JOIN public."order" o_ref
ON o_ref.reference_appointment_id = a.id
AND o_ref.deleted_at IS NULL
WHERE a.id = p_appointment_id
LIMIT 1;
$$ LANGUAGE sql STABLE;
CREATE VIEW public.doctor_workbench_view AS
SELECT
concat('need_record:', a.id::text) AS task_key,
'need_record' AS task_type,
'appointment' AS source_table,
a.id AS source_id,
NULL::uuid AS clinical_record_id,
a.branch_id,
public.resolve_appointment_customer_id(a.id) AS customer_id,
au.user_id AS doctor_id,
'doctor' AS queue_owner_type,
au.user_id AS queue_owner_id,
NULL::clinical_record_status_enum AS record_status,
CASE WHEN EXISTS (
SELECT 1
FROM public.order_item oi
JOIN public.service_clinical_classification scc
ON scc.product_id = oi.product_id
AND scc.branch_id = a.branch_id
AND scc.classification_status = 'mapped_requires_ba'
JOIN public.technical_category tc ON tc.id = scc.technical_category_id
WHERE (
oi.id = a.order_item_id
OR EXISTS (
SELECT 1
FROM public."order" o
WHERE o.id = oi.order_id
AND o.reference_appointment_id = a.id
AND o.deleted_at IS NULL
)
)
AND tc.allergy_risk_level IN ('unknown', 'high')
) THEN 'safety' ELSE 'normal' END AS priority_level,
'create_record' AS next_action,
concat('/clinical/workbench/create-record?appointment_id=', a.id::text) AS target_route,
a.updated_at AS task_updated_at
FROM public.appointment a
JOIN LATERAL (
SELECT au.user_id
FROM public.appointment_user au
WHERE au.appointment_id = a.id
ORDER BY au.created_at ASC
LIMIT 1
) au ON true
WHERE COALESCE(a.canceled, false) = false
AND public.resolve_appointment_customer_id(a.id) IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM public.clinical_record cr
WHERE cr.appointment_id = a.id AND cr.deleted_at IS NULL
)
AND EXISTS (
SELECT 1
FROM public.order_item oi
JOIN public.service_clinical_classification scc
ON scc.product_id = oi.product_id
AND scc.branch_id = a.branch_id
AND scc.classification_status = 'mapped_requires_ba'
WHERE (
oi.id = a.order_item_id
OR EXISTS (
SELECT 1
FROM public."order" o
WHERE o.id = oi.order_id
AND o.reference_appointment_id = a.id
AND o.deleted_at IS NULL
)
)
)
UNION ALL
SELECT
concat('draft:', cr.id::text) AS task_key,
'draft' AS task_type,
'clinical_record' AS source_table,
cr.id AS source_id,
cr.id AS clinical_record_id,
cr.branch_id,
cr.customer_id,
cr.primary_doctor_id AS doctor_id,
'doctor' AS queue_owner_type,
cr.primary_doctor_id AS queue_owner_id,
cr.status AS record_status,
CASE WHEN cr.updated_at < NOW() - INTERVAL '24 hours' THEN 'overdue' ELSE 'normal' END AS priority_level,
'continue_form' AS next_action,
concat('/clinical/records/', cr.id::text, '/edit') AS target_route,
cr.updated_at AS task_updated_at
FROM public.clinical_record cr
WHERE cr.deleted_at IS NULL AND cr.status = 'draft'
UNION ALL
SELECT
concat('need_print:', cr.id::text),
'need_print',
'clinical_record',
cr.id,
cr.id,
cr.branch_id,
cr.customer_id,
cr.primary_doctor_id,
'doctor',
cr.primary_doctor_id,
cr.status,
'normal',
'print',
concat('/clinical/records/', cr.id::text, '/print'),
cr.completed_at
FROM public.clinical_record cr
WHERE cr.deleted_at IS NULL AND cr.status = 'completed'
UNION ALL
SELECT
concat('need_scan:', cr.id::text),
'need_scan',
'clinical_record',
cr.id,
cr.id,
cr.branch_id,
cr.customer_id,
cr.primary_doctor_id,
'doctor',
cr.primary_doctor_id,
cr.status,
CASE WHEN cr.printed_at < NOW() - INTERVAL '4 hours' THEN 'overdue' ELSE 'normal' END,
'upload_scan',
concat('/clinical/records/', cr.id::text, '/upload-scan'),
cr.printed_at
FROM public.clinical_record cr
WHERE cr.deleted_at IS NULL AND cr.status = 'printed'
UNION ALL
SELECT
concat('sales_handoff:', csh.id::text),
'sales_handoff',
'clinical_sales_handoff',
csh.id,
NULL::uuid,
csh.branch_id,
csh.customer_id,
csh.target_doctor_id,
CASE WHEN csh.target_doctor_id IS NULL THEN 'branch_queue' ELSE 'doctor' END,
COALESCE(csh.target_doctor_id, csh.branch_id::text),
NULL::clinical_record_status_enum,
'normal',
'ack_handoff',
concat('/clinical/workbench/handoff/', csh.id::text),
csh.created_at
FROM public.clinical_sales_handoff csh
WHERE csh.deleted_at IS NULL AND csh.status = 'sent'
UNION ALL
SELECT
concat('intake_review:', cci.id::text),
'intake_review',
'customer_clinical_intake',
cci.id,
NULL::uuid,
cci.branch_id,
cci.customer_id,
au.user_id AS doctor_id,
CASE WHEN au.user_id IS NULL THEN 'branch_queue' ELSE 'doctor' END,
COALESCE(au.user_id, cci.branch_id::text),
NULL::clinical_record_status_enum,
'normal',
'review_intake',
concat('/clinical/intake/', cci.id::text, '/review'),
cci.submitted_at
FROM public.customer_clinical_intake cci
LEFT JOIN LATERAL (
SELECT au.user_id
FROM public.appointment_user au
WHERE au.appointment_id = cci.appointment_id
ORDER BY au.created_at ASC
LIMIT 1
) au ON true
WHERE cci.deleted_at IS NULL AND cci.status = 'submitted';sales_safe_clinical_summary
Expose cho user có business permission Sale qua secure view/action resolver; không tạo Hasura role sales:
- Customer + lịch sử DV thương mại
- Count BA theo type/status
- Cảnh báo tầng 1 dạng controlled fields:
safe_alert_level,safe_alert_code,safe_alert_text - Không expose
icd10_*,diagnosis_description,form_data, scan, ảnh trước/sau.
Output schema tối thiểu:
| Field | Type | Ghi chú |
|---|---|---|
customer_id | uuid | Customer đang tư vấn |
branch_id | uuid | Branch context của CRM/Sale |
service_history_summary | jsonb | DV thương mại đã làm, ngày, CN, trạng thái liệu trình; không có diagnosis |
clinical_record_count_by_type | jsonb | Count BA DL/TM theo status an toàn |
safe_alert_level | safe_alert_level_enum | none/info/warning/block |
safe_alert_code | text | Code nằm trong dictionary bên dưới |
safe_alert_text | text | Text đã duyệt, không render raw medical text |
can_create_handoff | boolean | Role + branch scope đủ để gửi BS |
latest_handoff_status | text nullable | sent/acknowledged/closed nếu có |
Allowed dictionary:
safe_alert_level | safe_alert_code | safe_alert_text |
|---|---|---|
none | no_known_alert | Chưa ghi nhận cảnh báo an toàn |
info | history_exists | KH có thông tin sức khoẻ cần BS xem lại trước tư vấn |
warning | allergy_or_condition | Có cảnh báo dị ứng/bệnh nền — cần BS xác nhận |
block | doctor_required | Không tư vấn DV xâm lấn trước khi BS đánh giá |
Resolver chỉ được map từ code/rule sang dictionary trên; không copy raw allergy name, ICD-10, diagnosis hoặc ghi chú BS vào response sales.
clinic_ops_dashboard_view
Aggregate theo branch_id, date:
- publication status, config drift counts
- missing BA, draft overdue, printed-not-scanned
- allergy block/skip, emergency override
- Chốt ngày phòng khám status
C5.15) Masking tầng 3
Approach: Không expose raw clinical_record cho Sale. Dùng view/action sales_safe_clinical_summary và business permission kiểm ở service. Hasura role runtime vẫn là user/admin.
yaml
# File: services/controller/metadata/databases/ecommerce/tables/public_sales_safe_clinical_summary.yaml
select_permissions:
- role: user
permission:
columns: [customer_id, branch_id, service_history_summary, clinical_record_count_by_type, safe_alert_level, safe_alert_code, safe_alert_text, can_create_handoff, latest_handoff_status]
filter:
can_view: { _eq: true }Không dùng fallback clinical_record_public trả record id/code cho Sale vì count/id riêng lẻ vẫn có thể làm lộ nhịp khám hoặc suy luận tầng 3. Nếu cần danh sách thương mại, lấy từ order/customer service history, không từ clinical record raw.
C5.16) Event triggers
Trigger 1: order_insert extend 🔧
File: services/ecommerce-api/event/order_insert.go (existing) — append logic
go
// Sau KPI + notification hiện tại:
// 1. For each order_item, check service_clinical_classification.status = mapped_requires_ba
// 2. If any product requires BA → enqueue notification:
// template: "order_needs_clinical_record"
// target: BS on-shift tại branch
// payload: { order_id, customer_name, form_type_hint }Trigger 2: appointment_completed 🆕
File: services/ecommerce-api/event/appointment_completed.go 🆕
yaml
# Hasura event trigger config
- name: appointment_completed_clinical_record_check
definition:
update:
columns: [status]
retry_conf:
num_retries: 3
interval_sec: 10
webhook: '{{ECOM_EVENT_URL}}/events'go
// Logic:
// 1. If NEW.status = 'completed' AND OLD.status != 'completed'
// 2. Check có clinical_record cho appointment này chưa?
// - NO: register in scheduled job clinical_record_missing_reminder
// - YES (status=draft): flag BS nhắc hoàn thànhTrigger 3: clinical_record_status_change 🆕
File: services/ecommerce-api/event/clinical_record_status_change.go 🆕
go
// 1. Log change → medical_record_access_log action='edit'
// 2. If status transition = 'draft→completed': notify primary_doctor_id (confirm)
// 3. If status = 'printed': trigger follow-up scheduler nhắc upload scan trong 24h
// 4. If status = 'signed': clear pending remindersTrigger 4: clinical_procedure_refused 🆕 (NTF-14, DEC-024)
File: services/ecommerce-api/event/clinical_procedure_refused.go 🆕
yaml
- name: clinical_procedure_refused
definition:
update:
columns: [customer_refused_procedure]
retry_conf:
num_retries: 3
interval_sec: 10
webhook: '{{ECOM_EVENT_URL}}/events'go
// Logic:
// 1. Trigger khi NEW.customer_refused_procedure = true AND OLD.customer_refused_procedure = false
// 2. Build safe payload (KHÔNG có diagnosis/ICD/form_data):
// {
// customer_id, customer_name_masked,
// branch_id, branch_name,
// service_name (từ cancelled_order_item_id → product),
// doctor_id, doctor_name,
// refusal_reason_excerpt: substr(refusal_reason, 1, 80) + '…' nếu dài
// }
// 3. Resolve recipients:
// - QL CN của branch_id (qua role_module branch_manager)
// - Sale phụ trách KH gần nhất (từ clinical_sales_handoff hoặc customer.assigned_sale_id)
// 4. Dedupe key: 'noti_clinical_procedure_refused_' + clinical_record_id
// 5. Channel: in-app + push (nếu mobile); KHÔNG email khách
// 6. Template:
// Title: "Khách {customer_name_masked} từ chối thủ thuật {service_name}"
// Body: "Tại {branch_name} · BS {doctor_name}. Lý do: {refusal_reason_excerpt}"
// Deeplink: /crm/customer/{customer_id}/sales-safe-clinical (SCR-13)Notification template seed:
| Code | Title VI | Body VI | Channel | Recipient role | Dedupe |
|---|---|---|---|---|---|
noti_clinical_procedure_refused | "Khách {customer_name} từ chối thủ thuật {service_name}" | "Tại {branch_name} · BS {doctor_name}. Lý do: {refusal_reason_excerpt}" | in-app + push | branch_manager + assigned_sale | per clinical_record_id |
C6) Scheduler
C6.1. clinical_record_missing_reminder 🆕
File: services/ecommerce-api/scheduler/clinical_record_missing_reminder.go 🆕 (reuse pattern appointment_reminder.go)
Cron: Chạy dispatcher định kỳ, nhưng branch-level reminder phải đọc branch_technical_config.close_reminder_times; không hardcode 19:00.
Logic:
go
// 1. Query:
// SELECT a.* FROM appointment a
// LEFT JOIN clinical_record cr ON cr.appointment_id = a.id AND cr.deleted_at IS NULL
// WHERE a.status IN ('completed', 'finished')
// AND a."from"::date = CURRENT_DATE
// AND cr.id IS NULL
// AND EXISTS (
// SELECT 1 FROM clinic_module_publication cmp
// WHERE cmp.branch_id = a.branch_id
// AND cmp.status = 'live'
// AND cmp.effective_at <= NOW()
// AND cmp.deleted_at IS NULL
// )
// AND EXISTS (
// SELECT 1 FROM order_item oi
// JOIN service_clinical_classification scc ON oi.product_id = scc.product_id
// WHERE scc.classification_status = 'mapped_requires_ba'
// AND scc.branch_id = a.branch_id
// AND (
// oi.id = a.order_item_id
// OR EXISTS (
// SELECT 1 FROM "order" o
// WHERE o.id = oi.order_id
// AND o.reference_appointment_id = a.id
// AND o.deleted_at IS NULL
// )
// )
// )
// 2. Group by (branch_id, primary_doctor_id)
// 3. Per BS: enqueue 1 notification "Bạn có N hẹn xong chưa có BA"
// 4. Also query clinical_record status='printed' with mandatory_scan_checklist_passed=false.
// 5. Per branch: enqueue 1 notification tới QL CN "N việc phòng khám hôm nay chưa hoàn thành"
// 6. Dedupe via notification key: f"missing_ba_{branch_id}_{doctor_id}_{today}_{reminder_slot}"Hasura cron config:
yaml
# services/controller/metadata/cron_triggers.yaml
- name: clinical_record_missing_reminder
webhook: '{{ECOM_SCHEDULER_URL}}/schedulers'
schedule: '*/15 * * * *' # dispatcher; handler chọn branch đến reminder slot
include_in_metadata: true
retry_conf:
num_retries: 2
timeout_seconds: 60
tolerance_seconds: 21600C6.2) Hết hạn request khác chi nhánh — deferred Phase 2
Day-1 không có scheduler access_request_expiration vì chưa build request/approve workflow trong app.
C6.3. clinic_daily_close_reminder 🆕
Ref: FR-019, DEC-036
File: services/ecommerce-api/scheduler/clinic_daily_close_reminder.go
Cron: chạy mỗi 15 phút trong khung 17:00-21:00 VN; handler đọc branch_technical_config.close_reminder_times, close_cutoff_time, timezone cho từng CN live.
Logic:
- Khi
nowkhớp một mốc trongclose_reminder_times: gửi QL CN danh sách việc còn lại. - Nếu chưa close hoặc còn P0 issue sau reminder cuối: nhắc QL CN + BS liên quan.
- Sau
close_cutoff_time: nếu chưa close, set/insertclinic_daily_close.status = escalated, gửi Ops/Admin. - Recompute counts server-side: missing BA, draft overdue, printed-not-scanned, safety issue.
C7) Chiến lược migration
Migration files đặt tại source
ecommercevì clinical tables cần FK/runtime gần appointment/order/product/reference_file. Không đặt ởdefaulttrừ seed/view thật sự thuộc sourcedefault.
Phase 0 — Pre-requisite (không migration, chỉ code change)
| Step | File | Action |
|---|---|---|
| 0.1 | pkg/store/appointment.go:52 | OrderItemID uuid.UUID → *uuid.UUID |
| 0.2 | services/ecommerce-api/event/appointment_insert.go:76,176,202 | Null-guard |
| 0.3 | services/ecommerce-api/event/appointment_update.go:190 | Null-guard |
| 0.3A | Repo-wide OrderItemID usages | Audit + nil guard trong ticket/task/store/report/FE generated paths |
| 0.4 | FE codegen audit | Generate types — verify order_item_id?: string optional |
| 0.5 | Integration test | Insert appointment với order_item_id = NULL + trigger event không panic |
Phase 1 — Core tables (Tuần 1-2)
| # | Migration | Purpose |
|---|---|---|
| 0 | 1766199999000_create_clinical_prereq_types | CREATE EXTENSION pg_trgm + enum types C4.0 trước mọi table/index |
| 1 | 1766200000000_extend_branch_features | ALTER branch ADD features JSONB |
| 2 | 1766200001000_create_branch_technical_config | CREATE table + index + close cutoff/reminder config |
| 3 | 1766200002000_create_technical_category | CREATE table + trgm index |
| 4 | 1766200003000_create_service_clinical_classification | CREATE table |
| 5 | 1766200004000_create_icd10_code | CREATE + seed ~22k rows (split data migration 1766200004500_seed_icd10) |
| 6 | 1766200005000_create_form_template | CREATE + seed 7 templates (split data migration 1766200005500_seed_form_templates) |
Phase 2 — Clinical record (Tuần 3-4)
| # | Migration | Purpose |
|---|---|---|
| 7 | 1766200006000_create_clinical_record | Core table + indexes + validate trigger |
| 8 | 1766200007000_create_clinical_form_instance | + GIN index + source audit fields |
| 9 | 1766200008000_create_treatment_progress_entry | TM only table |
| 10 | 1766200009000_create_prescription | prescription + prescription_item |
Phase 3 — Safety + audit (Tuần 5-6)
| # | Migration | Purpose |
|---|---|---|
| 11 | 1766200010000_create_allergy_check_policy | + seed 4 rows (unknown/block, low/warn, medium/confirm_skip, high/block) + allergy_check_skip_log |
| 12 | 1766200011000_create_medical_record_access_log | audit access tầng 3 + emergency override |
Phase 4 — Operational surfaces + sequence + metadata (Tuần 7)
| # | Migration | Purpose |
|---|---|---|
| 13 | 1766200012000_create_sequence_generator | + plpgsql function get_next_sequence |
| 14 | 1766200013000_create_clinical_sales_handoff | Sale-safe handoff |
| 15 | 1766200014000_create_customer_clinical_intake | Phiếu khách tự khai + one-time token hash |
| 16 | 1766200015000_create_clinic_daily_close | End-of-day close + cutoff snapshot |
| 17 | 1766200016000_create_clinical_views | Workbench, appointment customer resolver, ops dashboard, sales-safe summary views |
| 18 | 1766200017000_hasura_permissions_clinical_record | Tất Hasura YAML metadata commit qua hasura metadata apply |
Phase 5 — Migration metadata (không migration file, Hasura metadata)
- Thêm tất tables/views vào
services/controller/metadata/databases/ecommerce/tables/ - Thêm track secure views/actions theo Hasura role runtime
user/admin; raw clinical tables không cấp select/insert/update cho roleuser - Thêm actions
actions.yaml - Thêm event triggers
- Thêm cron_triggers
Backfill dữ liệu
UPDATE branch SET features = '{}' WHERE features IS NULL— handled by NOT NULL DEFAULT.- KHÔNG backfill
clinical_record(DEC-028 — không hồi cứu BA cũ).
Down migration (rollback)
Mỗi migration có file down.sql tương ứng cho dev/staging hoặc production trước D0 khi chưa có dữ liệu BA thật. Sau D0, production rollback không chạy drop clinical tables; rollback theo hướng app-level pause/code rollback, giữ DB và dữ liệu để forensic/audit. Down migration sau Day-1 chỉ dùng trong dev/staging.
C8) Bảo mật
C8.0) Quy ước Dynamic Permission v2
Canonical owner:
permission-spec.mdv1.0.0 (P1-P12). Section này chỉ là implementation reference. Mọi xung đột → ưu tiên permission-spec. Migration / Go resolver / FE constants / Hasura permission filter PHẢI tuân thủ:
permission-spec.mdP1.1 — catalog 22 action × portal × sensitivity (canonical seed migration)permission-spec.mdP2.1 — default seed matrix 22 action × 9 role ×branch_mode×is_pos_onlypermission-spec.mdP3.1 — Go signatureResolveClinicalPermission()+ 11-step algorithmpermission-spec.mdP4 — TypeScript constantsCLINICAL_MODULES+CLINICAL_ACTIONS+ label VIpermission-spec.mdP5-P6 — branch_mode behavior + portal isolation +is_pos_onlypermission-spec.mdP7 — emergency override governance (rate limit, session TTL, cron expire, manual revoke)permission-spec.mdP8 — cache topology Redis 60s + WebSocket pubsubpermission-spec.mdP9 — migration M101-M107 + M105 default seed SQLpermission-spec.mdP10 — field-level masking matrix perview_modepermission-spec.mdP11 — audit retention + Compliance UI (SCR-18)
Runtime permission cho Hồ sơ bệnh án phải được resolve bằng role_module.actions + portal + branch_mode + backend enforcement. Tên role nghiệp vụ chỉ dùng để seed quyền mặc định và test case; code không được hard-code BS, Nurse, Sale, BM như điều kiện bảo mật cuối cùng.
Catalog action phải seed vào module_permission_action:
| Module | Portal | Action | Default seed | Backend resolver output |
|---|---|---|---|---|
clinic_module | Admin | access, configure, publish | Admin/Ops; QL CN readonly access | settings_hidden, readonly, can_configure, can_publish |
clinical_record | Admin, Staff | view_summary | BS, Y tá, Lễ tân, QL CN, Admin/Ops | summary |
clinical_record | Admin, Staff | view_medical_detail | BS cùng CN, Medical Lead, Admin audit | full_tier3 hoặc locked |
clinical_record | Admin, Staff | edit_medical_form, edit_admin_shared | BS/Medical Lead; Y tá chỉ edit_admin_shared | Field allowlist theo sensitivity tag |
clinical_record | Admin, Staff | print, upload_scan, export_visit_log, emergency_override | BS/Y tá/QL/Admin theo seed | Action allow/deny + audit tier |
clinical_record | Admin, Staff | refuse_procedure (DEC-024) | BS, Y tá trưởng cùng CN | Action allow/deny; cần kết hợp order.update ở tầng order để huỷ order_item |
clinical_sales | CRM, Admin | view_safe_summary | Sale CRM, QL CN, Admin/Ops, BS | sales_safe_summary |
clinical_sales_handoff | CRM, Admin, Staff | create, acknowledge_close | Sale tạo; BS nhận/đóng | Handoff action allow/deny |
doctor_workbench | Staff, Admin | access, view_branch_queue | BS/Y tá; BS trưởng/QL CN | Queue scope self, branch, multi_branch |
customer_clinical_intake | POS, Staff, Admin | open_session, review_accept | Lễ tân/Y tá mở; BS accept | Token action + accept allow/deny |
clinic_ops | Admin | view_branch_dashboard, view_all_dashboard | QL CN; Admin/Ops | Dashboard branch scope |
clinic_daily_close | Admin, Staff | close_branch_day | QL CN, Admin/Ops | Close action allow/deny |
Implementation requirements:
- Migration seed phải thêm/đảm bảo tồn tại
module_permission_action(module_id, action, label_vi, portal, sensitivity, default_enabled)và defaultrole_module.actionstheo portal. Nếu schema hiện tại khác tên field, migration giữ pattern repo nhưng vẫn phải biểu diễn đủmodule_id,action,portal. - FE thêm enum/constant
ClinicalPermissionActionvà label tiếng Việt tương ứng; route/menu/button/block dùng permission store hiện có thay vì check role name. - Backend thêm helper/resolver, ví dụ
ResolveClinicalPermission(ctx, moduleID, action, portal, branchID, recordID), trảallowed,branch_scope,view_mode,field_allowlist,audit_required. - Backend không tin
view_mode,branch_idhoặc field allowlist do FE gửi lên. Mọi action/view clinical tự tính lại từ session/user id và DB permission state. branch_modeáp dụng sau khi action pass. Có action nhưng ngoài branch scope vẫn phải deny hoặc trả empty least-data.- Hard deny Sale tầng 3 nằm trong resolver, không chỉ trong Dynamic Permission UI. Kể cả Admin vô tình grant
clinical_record.view_medical_detailcho role Sale/CRM, resolver vẫn không trả diagnosis/form_data/scan/media tầng 3. - Sau mutation Dynamic Permission, FE phải refetch permission state hoặc yêu cầu user đăng nhập lại trước khi hiện menu/CTA mới; API enforce theo DB ngay.
C8.1) Phân quyền (3 tầng)
| Tầng | Columns | Role access |
|---|---|---|
| Tầng 1 (an toàn) | Safe alert đã chuẩn hóa từ allergy/medical history | Staff theo business permission; Sale chỉ thấy safe-alert dictionary, không raw allergy name nếu nguồn tầng 3 |
| Tầng 2 (kinh doanh) | clinical_profile.profile_code, visit_log_number, status, form_type, customer_id, branch_id, created_at | Tất staff toàn chuỗi |
| Tầng 3 (y tế nhạy) | icd10_primary_code, icd10_secondary_codes, diagnosis_description, form_data của BA DL/TM, progress entries, scan files, before/after media | Strict: BS/Medical Lead cùng CN; y tá chỉ hỗ trợ hành chính/shared/upload; Sale deny tuyệt đối; QL CN không xem nếu không có quyền y tế; BS CN khác mặc định bị khóa, chỉ mở bằng emergency_override_access |
C8.2) Triển khai permission Hasura
- Không tạo Hasura role nghiệp vụ mới. Dùng role runtime
user/admin+ Dynamic Permission v2 trong action/view. - Sale-safe qua view/action
sales_safe_clinical_summary, không expose rawclinical_record. - Row-level filter: branch scope lấy từ permission/account-branch mapping hoặc service resolver; không giả định luôn có
X-Hasura-Branch-Idnếu runtime hiện tại không set biến đó. - Cross-branch read tầng 3 Day-1: không tạo grant view. Mặc định Hasura filter vẫn chặn; chỉ action
emergency_override_accessmới bypass qua service account và ghi audit. - JSONB guard:
clinical_form_instance.form_datakhông cho raw mutation trực tiếp; mọi write đi qua action/service hoặc trigger validate schema + sensitivity + audit.
C8.3) Middleware audit
File: services/ecommerce-api/middleware/medical_record_access_audit.go 🆕
go
// Middleware wrap mọi action/view resolver clinical:
// 1. Extract user_id, clinical_record_id, action, tier, branch_scope, reason
// 2. INSERT medical_record_access_log trong transaction chính hoặc durable outbox; không dùng fire-and-forget nếu là emergency/cross-branch/tầng 3
// 3. Nếu emergency_override=true: commit audit trước khi trả data + realtime notification tới QL CN + admin
// 4. Nếu audit insert fail với action tầng 3: deny response để tránh xem dữ liệu không dấu vếtC8.4) Bảo mật MinIO
- Bucket
clinical-record-scans(hoặc reuse existing + type tag) PRIVATE - Signed URL TTL 15 minutes (không như public product images)
- Lifecycle rule: retain 10 năm (TT46/2018); không auto-delete
- Versioning enabled cho backup compliance
C8.5) Rate limiting
| Endpoint | Limit | Scope |
|---|---|---|
emergency_override_access | 5/day | per user (alert admin nếu overused) |
upload_clinical_record_scans | 50/hour | per user |
search_icd10 | 300/minute | per user (BS UX nhanh) |
C8.6) PII và bảo vệ dữ liệu
customer.phone,customer.cccdkhông lưu trongform_dataJSON (lưu reference quacustomer_id, join runtime).- Before/After media: filename random UUID, không include customer name.
- Export Excel sổ khám: mask số CCCD (
123***789) cho role không phải QL CN/admin.
C9) Yêu cầu phi chức năng
C9.1) Hiệu năng
| Metric | Target | Measurement |
|---|---|---|
| Form BA DL/TM load time (initial) | <2s p95 | Lighthouse + custom timing |
| Autosave round-trip | <500ms p95 | Prometheus histogram |
| ICD-10 search response | <100ms p95 | GIN trgm index |
| Clinical record list query | <300ms p95 (100 records) | Indexed query |
| Print PDF generation | <5s p95 (7 forms) | Async job metric |
| Tải bản scan đã ký 20MB | <10s p95 | MinIO direct upload |
C9.2) Quy mô
- Peak load: 500 clinical_record/day/CN (Tân Bình II), 50 CN = 25k/day → ~10k/hour peak.
- Concurrency: 50 BS điền form cùng lúc (autosave mỗi 30s) = ~1.7 req/s autosave — trivial.
- Data growth: ~25k clinical_record/day × 365 = ~9M/year. 10 năm = 90M rows. Phase 2 partitioning by year khi > 50M.
- Storage: Scan 2MB avg × 7 forms × 25k/day = 350GB/day tier-1 nếu peak. 10 năm ~1.3PB (cold archive cần thiết).
C9.3) Sẵn sàng hệ thống
- SLA: 99.5% uptime giờ hoạt động PK (8h-20h VN daily).
- Deployment window: 2-4h AM VN, zero-downtime rolling update (existing pattern).
- Graceful degradation: Nếu
notification-v2-apidown → clinical_record create vẫn thành công, notification queue retry async.
C9.4) Lưu trữ dữ liệu
- Clinical record + form_instance: 10 năm (TT46/2018 ngoại trú).
- Access log: 5 năm (đủ audit compliance).
- Scan file MinIO: Hot tier 1 năm, cold archive 9 năm (Phase 2 implementation via MinIO lifecycle rule).
- Access request expired: Giữ 2 năm rồi hard delete.
C9.5) Backup
- Daily snapshot PostgreSQL
clinical_record+clinical_form_instance+prescription+medical_record_access_log(existing DB backup pipeline). - MinIO versioning enabled cho bucket scan — protect accidental delete.
- Quarterly restore drill trên staging (Ops responsibility).
C9.6) Địa phương hóa và timezone
- All timestamp stored UTC, UI convert
Asia/Ho_Chi_Minh. - Form labels Vietnamese hardcode trong
form_template.schema.uiLabels(JSON). - Date format
DD/MM/YYYY, money1,234,567 đ(existing pattern).
C10) Quan sát hệ thống
C10.1) Log có cấu trúc
Format: JSON per line, fields standard:
json
{
"timestamp": "2026-04-21T14:30:00Z",
"level": "INFO",
"service": "ecommerce-api",
"trace_id": "abc-123",
"user_id": "uuid",
"branch_id": "uuid",
"clinical_record_id": "uuid",
"action": "complete_clinical_record",
"duration_ms": 234,
"result": "success"
}Sensitive fields masking: form_data, diagnosis_description, icd10_* → log chỉ hash, không raw.
C10.2) Chỉ số Prometheus
| Metric | Type | Labels |
|---|---|---|
clinical_record_created_total | counter | branch, form_type, status |
clinical_record_completion_minutes | histogram | branch, form_type, doctor |
autosave_latency_ms | histogram (p50, p95, p99) | — |
autosave_failure_total | counter | reason |
allergy_check_skip_total | counter | risk_level, technical_category |
sales_handoff_ack_latency_hours | histogram | branch, doctor |
emergency_override_total | counter | branch, doctor |
icd10_search_latency_ms | histogram | — |
print_pdf_generation_ms | histogram | form_count |
scan_upload_bytes | histogram | — |
sales_handoff_total | counter | branch, status |
doctor_workbench_task_age_minutes | histogram | branch, task_type |
customer_intake_submitted_total | counter | branch, status |
clinic_daily_close_on_time_total | counter | branch |
clinic_daily_close_late_total | counter | branch, reason |
C10.3) Cảnh báo
| Alert | Condition | Severity | Channel |
|---|---|---|---|
| Autosave failure spike | rate(autosave_failure_total[5m]) > 0.05 | P1 | PagerDuty Ops |
| Emergency override spike | count(emergency_override_total[1d]) > 3 per doctor | P2 | Slack admin + email QL CN |
| Missing BA daily count high | scheduler output > 20% appointments | P3 | Slack branch-manager |
| Sales handoff pending > 4h | count(handoff status=sent age>4h) > 0 | P3 | Branch manager |
| Daily close late | live branch chưa close sau 20:00 VN | P2 | Ops/Admin email + Slack |
| MinIO storage > 80% | disk_used > 0.8 | P2 | PagerDuty DevOps |
| DB clinical_record write latency p95 > 1s | histogram > 1000 | P1 | PagerDuty |
C10.4) Dashboard Grafana
Board: "Clinical Record Ops" 🆕
Panels:
- BA volume per day/CN (line chart, stacked by form_type)
- BA completion rate % (gauge + time series)
- Avg completion minutes per doctor (bar chart)
- Allergy skip counts by risk level (stacked bar)
- Access request status distribution (pie)
- Emergency override events (table with details)
- MinIO storage growth (line, projection)
- Autosave health (p95 latency + failure rate)
- Bàn việc bác sĩ backlog by task type
- Daily close on-time rate by branch
- Sales handoff pending/acknowledged rate
C10.5) Tracing
- OpenTelemetry (existing setup) — propagate
trace_idtừ FE → Hasura → ecommerce-api → PostgreSQL. - Span names:
action.create_clinical_record,db.query.clinical_record_insert,minio.upload.scan.
C11) Task triển khai
Format: ID | Task | File/Owner | Effort | Priority. P0 = blocker, P1 = must-have, P2 = nice-to-have Phase 2.
C11.0) Task prerequisite (BẮT BUỘC trước Phase 1)
| ID | Task | File/Owner | Effort | Priority | Blocker |
|---|---|---|---|---|---|
| C11-PREREQ-001 | Refactor Appointment.OrderItemID uuid.UUID → *uuid.UUID | pkg/store/appointment.go:52 — BE Dev | 0.5 day | P0 | Walk-in case |
| C11-PREREQ-002 | Null-guard known event call sites (appointment_insert.go:76,176,202 + appointment_update.go:190) | BE Dev | 1 day | P0 | Event trigger panic |
| C11-PREREQ-002A | Repo-wide audit/null-guard mọi usage OrderItemID/order_item_id ngoài event (ticket/task/store/report/FE generated) | BE + FE | 1 day | P0 | Walk-in regression ngoài 4 call site |
| C11-PREREQ-003 | FE codegen audit — verify appointment_insert_input.order_item_id?: string optional | diva-admin/src/modules/ecommerce/pages/Appointments.tsx + appointment form components — FE Dev | 0.5 day | P0 | FE nullability |
| C11-PREREQ-004 | Integration test: insert_appointment_one với order_item_id = NULL + event trigger không panic | QA + BE | 0.5 day | P0 | Regression safety |
| C11-PREREQ-005 | Audit 3 tháng production log: có appointment với NULL order_item_id đang bug silent? | BE Dev | 0.5 day | P0 | R-1 mitigation |
| C11-PREREQ-006 | Audit ảnh hưởng liên feature theo PRD A12/SoT 2.1: module trực tiếp/gián tiếp/không ảnh hưởng có owner, guard và owner smoke regression trước sprint planning | TL + QA + BE + FE | 0.5 day | P0 | Chống scope creep và regression ngoài module |
Tổng pre-req: ~3 man-days BE + 0.5 FE + 0.5 QA.
C11.1. Phase 1 — Data layer + Admin UI (Tuần 1-2)
| ID | Task | File/Owner | Effort | Priority |
|---|---|---|---|---|
| C11-P1-000 | Migration prerequisite pg_trgm + clinical enum types | BE | 0.5d | P0 |
| C11-P1-001 | Migration extend_branch_features | BE | 0.5d | P0 |
| C11-P1-002 | Migration create_branch_technical_config + Hasura metadata | BE | 0.5d | P0 |
| C11-P1-003 | Migration create_technical_category + trgm index | BE | 0.5d | P0 |
| C11-P1-004 | Migration create_service_clinical_classification | BE | 0.5d | P0 |
| C11-P1-005 | Migration create_icd10_code + seed ~22k rows TT46/2018 | BE + Data | 2d | P0 |
| C11-P1-006 | Migration create_form_template + seed 7 templates | BE | 1d | P0 |
| C11-P1-007 | Action publish_clinic_module_config handler | BE | 0.5d | P0 |
| C11-P1-007A | Action publish_clinic_module handler — readiness R-01..R-10, xem trước tác động, scheduled/live update | BE | 1d | P0 |
| C11-P1-008 | Action import_technical_categories_csv handler | BE | 1d | P0 |
| C11-P1-009 | Admin FE: tab "Phòng khám" in BranchDetail | FE | 1d | P0 |
| C11-P1-010 | Admin FE: 5-bước wizard (loại PK → giấy phép → KT → mapping → phân quyền) | FE | 3d | P0 |
| C11-P1-011 | Admin FE: Import KT CSV UI + preview + error display | FE | 1.5d | P0 |
| C11-P1-012 | Admin FE: Phân loại dịch vụ — kỹ thuật UI với bulk action | FE | 2d | P0 |
| C11-P1-013 | Admin FE: Cấu hình allergy_risk_level per KT (bulk edit) | FE | 1d | P0 |
| C11-P1-014 | Unit test + integration test Phase 1 | QA + BE | 1.5d | P0 |
| C11-P1-015 | Docs: Admin user guide cho cấu hình 5 bước | PO + BA | 0.5d | P1 |
Phase 1 total: ~18.5 days mixed.
C11.2. Phase 2 — Clinical record core + Form renderer (Tuần 3-4)
| ID | Task | File/Owner | Effort | Priority |
|---|---|---|---|---|
| C11-P2-001 | Migration create_clinical_record + indexes + trigger validate_icd10 | BE | 1d | P0 |
| C11-P2-002 | Migration create_clinical_form_instance + GIN index | BE | 0.5d | P0 |
| C11-P2-003 | Migration create_treatment_progress_entry | BE | 0.5d | P0 |
| C11-P2-004 | Action create_clinical_record handler — bao gồm generate codes | BE | 2d | P0 |
| C11-P2-005 | Action complete_clinical_record handler + allergy guard | BE | 2d | P0 |
| C11-P2-005A | Action validate_clinical_form_data — JSON Schema/sensitivity/required/audit + upsert form instance; khóa raw mutation role user | BE | 1.5d | P0 |
| C11-P2-006 | Reuse create_order.reference_appointment_id cho walk-in late-link + helper query/filter appointment mở | BE | 1d | P0 |
| C11-P2-007 | FE: Form renderer engine JSON Schema Draft-07 | FE | 3d | P0 |
| C11-P2-008 | FE: Form BA DL — implement schema theo ~30 fields | FE | 2d | P0 |
| C11-P2-009 | FE: Form Cam đoan (shared) — DEC-021 tự gõ xác nhận + regex validate | FE | 1.5d | P0 |
| C11-P2-010 | FE: Form Cam kết (shared) | FE | 1d | P0 |
| C11-P2-011 | FE: Form Đơn thuốc — multi-row prescription_item | FE | 1.5d | P0 |
| C11-P2-012 | FE: Form Tiền sử dị ứng — hardcode TT51/2017 | FE | 1d | P0 |
| C11-P2-013 | FE: Autosave 30s + status badge "Đang soạn thảo" | FE | 1d | P0 |
| C11-P2-014 | FE: Menu "BA nháp" list + filter + resume | FE | 1d | P0 |
| C11-P2-015 | FE: Parent tab "Hồ sơ BA" trong CustomerDetail; CustomerInfo giữ nguyên | FE | 1d | P0 |
| C11-P2-016 | FE: Tạo lượt khám flow (walk-in + từ đơn) | FE | 2d | P0 |
| C11-P2-017 | FE: ICD-10 searchable dropdown với debounce 300ms | FE | 1d | P0 |
| C11-P2-018 | QA + integration test Phase 2 | QA + BE | 2d | P0 |
| C11-P2-019 | FE: Cross-branch locked state UI + copy hướng dẫn SOP manual ngoài app | FE | 0.5d | P0 |
| C11-P2-020 | FE: Order create UI — popup "KH này có lượt khám mở" + set reference_appointment_id | FE | 1d | P0 |
Phase 2 total: ~26 days mixed.
C11.3. Phase 3 — BA TM + Phiếu theo dõi điều trị + Allergy safety (Tuần 5-6)
| ID | Task | File/Owner | Effort | Priority |
|---|---|---|---|---|
| C11-P3-001 | Form template seed: BA_TM JSON Schema ~60 fields (Mắt/Mũi/Môi/Khác) | BE + PO | 2d | P0 |
| C11-P3-002 | FE: Form BA TM renderer với 4 section | FE | 3d | P0 |
| C11-P3-003 | FE: DEC-019 auto-hiện section theo DV POS — logic detect from order_item | FE | 1.5d | P0 |
| C11-P3-004 | FE: Form Phiếu theo dõi điều trị — multi-row timeline, entry cũ readonly | FE | 2d | P0 |
| C11-P3-005 | Action + Hasura mutation: insert_treatment_progress_entry | BE | 0.5d | P0 |
| C11-P3-006 | Migration create_allergy_check_policy + seed 4 rows | BE | 0.5d | P0 |
| C11-P3-007 | Action create_allergy_skip_log handler | BE | 0.5d | P0 |
| C11-P3-008 | FE: Allergy guard popup (block / confirm_skip / warn) | FE | 2d | P0 |
| C11-P3-009 | FE: DEC-012 reuse dị ứng "không đổi" — copy form_data 12 months | FE | 1.5d | P0 |
| C11-P3-010 | Admin FE: Cài đặt chính sách an toàn (edit allergy_check_policy) | FE | 1d | P1 |
| C11-P3-011 | BE: allergy_check_policy cache (30s TTL) trong handler | BE | 0.5d | P1 |
| C11-P3-012 | FE: Số đo Mũi numeric validation + unit hint | FE | 1d | P0 |
| C11-P3-013 | QA + integration test Phase 3 (BA TM full + allergy flows) | QA + BE | 2d | P0 |
| C11-P3-014 | PO: Review allergy risk list với BS Phát (OQ-1 resolve) | PO + BS | 1d | P0 |
| C11-P3-015 | Docs: BS user guide cho BA TM 4 phân hệ | PO + BA | 1d | P1 |
Phase 3 total: ~20 days mixed.
C11.4. Phase 4 — Permission 3 tầng + Audit (Tuần 7)
| ID | Task | File/Owner | Effort | Priority |
|---|---|---|---|---|
| C11-P4-001 | Migration create_medical_record_access_log | BE | 0.5d | P0 |
| C11-P4-002 | Migration/seed Dynamic Permission v2: module_permission_action + role_module.actions cho clinical modules theo portal/default seed | BE | 1d | P0 |
| C11-P4-003 | Hasura permissions YAML: runtime user/admin + secure views/actions, không expose raw clinical table cho user | BE | 3d | P0 |
| C11-P4-004 | PostgreSQL view/action sales_safe_clinical_summary + Hasura track cho role runtime user | BE | 1d | P0 |
| C11-P4-005 | Middleware medical_record_access_audit | BE | 1.5d | P0 |
| C11-P4-006 | Backend resolver ResolveClinicalPermission: action + portal + branch_mode + hard deny Sale tầng 3 + least-data field allowlist | BE | 1.5d | P0 |
| C11-P4-007 | FE constants/types ClinicalPermissionAction + route/menu/button guards theo Dynamic Permission, không check role name | FE | 1d | P0 |
| C11-P4-008 | Action emergency_override_access handler + realtime notification admin | BE | 1.5d | P0 |
| C11-P4-009 | Quy ước FE/BE: cross-branch mặc định locked, không expose request/approve action Day-1; grant/revoke quyền bắt buộc refetch/relogin | BE + FE | 0.5d | P0 |
| C11-P4-010 | FE: Permission tầng 3 UI — mask + emergency override button + reason hộp thoại + no-permission state theo action/portal | FE | 2d | P0 |
| C11-P4-011 | QA/Dev smoke Dynamic Permission UI: no action, grant, revoke, portal split, branch_mode, is_pos_only | QA + BE + FE | 1d | P0 |
Phase 4 total: ~14 days mixed.
C11.5. Phase 5 — Sổ khám + Notification + Scheduler (Tuần 8)
| ID | Task | File/Owner | Effort | Priority |
|---|---|---|---|---|
| C11-P5-001 | FE: Sổ khám bệnh view với filter + pagination | FE | 2d | P1 |
| C11-P5-002 | FE: Sổ thủ thuật view | FE | 1d | P1 |
| C11-P5-003 | FE: Export Excel (sổ khám + sổ thủ thuật) | FE | 1.5d | P1 |
| C11-P5-004 | FE: Export PDF (sổ khám) | FE | 1d | P1 |
| C11-P5-005 | Scheduler clinical_record_missing_reminder daily | BE | 1d | P1 |
| C11-P5-006 | Event trigger extend order_insert.go → notify BS "cần BA" + đăng ký dispatcher trong /events theo table/trigger payload | BE | 1d | P0 |
| C11-P5-007 | Event trigger appointment_completed → schedule reminder + đăng ký dispatcher trong /events | BE | 0.5d | P0 |
| C11-P5-008 | Notification template seeding: order_needs_ba, appointment_completed_no_ba, emergency_override, sales_handoff, daily_close | BE | 0.5d | P0 |
Phase 5 total: ~8.5 days mixed.
C11.5B. Phase 5B — Sales/Ops operational surfaces (Tuần 8-9)
| ID | Task | File/Owner | Effort | Priority |
|---|---|---|---|---|
| C11-P5B-001 | Migration create_clinical_sales_handoff + Hasura permissions | BE | 0.5d | P0 |
| C11-P5B-002 | Action create_clinical_sales_handoff + acknowledge_sales_handoff | BE | 1d | P0 |
| C11-P5B-003 | FE: SCR-13 Trang xem an toàn (Sale) + safe-alert dictionary + handoff form | FE | 2d | P0 |
| C11-P5B-004 | Query/view doctor_workbench_view đủ 6 bucket + stable task_key + resolver resolve_appointment_customer_id không null với appointment cần BA | BE | 1d | P0 |
| C11-P5B-005 | FE: SCR-14 Bàn việc bác sĩ task queue | FE | 3d | P0 |
| C11-P5B-006 | Migration/action customer_clinical_intake + token session + review/source audit handler | BE | 2d | P1 |
| C11-P5B-007 | FE: SCR-15 Phiếu khách tự khai tablet + review drawer | FE | 3d | P1 |
| C11-P5B-008 | Migration clinic_daily_close + close_clinic_day action | BE | 1d | P0 |
| C11-P5B-009 | FE: SCR-16 Trang điều phối phòng khám + SCR-17 Chốt ngày phòng khám | FE | 3d | P0 |
| C11-P5B-010 | Scheduler clinic_daily_close_reminder đọc cutoff/reminder theo CN + NTF-12..15 | BE | 1d | P0 |
Phase 5B total: ~17.5 days mixed.
C11.6. Phase 6 — Print + Tải bản scan đã ký + BA lifecycle (parallel với P3-P5B)
| ID | Task | File/Owner | Effort | Priority |
|---|---|---|---|---|
| C11-P6-001 | Action print_clinical_record — HTML → PDF pipeline | BE | 2d | P0 |
| C11-P6-002 | Print template: 7 biểu mẫu HTML với placeholder {{branch.license_header}} | BE + PO | 3d | P0 |
| C11-P6-003 | Action upload_clinical_record_scans + validate mime/size | BE | 1d | P0 |
| C11-P6-004 | FE: Print preview hộp thoại + nút "In bộ hồ sơ" | FE | 1.5d | P0 |
| C11-P6-005 | FE: Tải bản scan đã ký UI — cả bộ / từng tờ + preview + reupload | FE | 2d | P0 |
| C11-P6-006 | FE: Trạng thái in v2 "Superseded" + lý do sửa | FE | 1d | P1 |
| C11-P6-007 | FE: KH từ chối ký UI — lý do + y tá làm chứng chọn | FE | 1d | P0 |
Phase 6 total: ~11.5 days mixed.
C11.7) Tổng effort
| Phase | Effort (man-days) | Dependencies |
|---|---|---|
| Pre-req | 3.5 | — |
| Phase 1 | 18.5 | Pre-req done |
| Phase 2 | 25 | Phase 1 migration done |
| Phase 3 | 20 | Phase 2 form engine done |
| Phase 4 | 13 | Phase 2 clinical_record exists |
| Phase 5 | 8.5 | Phase 4 permission done |
| Phase 5B | 17 | Phase 4 permission + Phase 5 notifications |
| Phase 6 | 11.5 | Phase 2 core done (parallel với 3-5B) |
| Total | ~114 man-days | — |
Split: ~64-66 BE-days + ~43-45 FE-days + ~8 QA-days.
Team allocation (3 dev: 1 BE senior + 1 BE + 1 FE): 11-13 tuần như PRD §A0.7.
C12) Ma trận truy vết
FR → AC → Data model → API endpoint → TC reference (xem qa-test-plan.md)
| FR | AC | Tables | API endpoints | TC |
|---|---|---|---|---|
| FR-001 Bật/tắt module | AC-001.1 to 4 | branch.features, branch_technical_config | publish_clinic_module_config | TC-001 to 004 |
| FR-002 Cấu hình 5 bước | AC-002.1 to 5 | branch_technical_config, technical_category, service_clinical_classification | import_technical_categories_csv + CRUD tech_cat + classification | TC-005 to 008 |
| FR-003 Phân loại dịch vụ — kỹ thuật | AC-003.1 to 5 | service_clinical_classification | Hasura mutations insert/update_service_clinical_classification | TC-009 to 012 |
| FR-004 Tạo lượt khám + walk-in | AC-004.1 to 5 | appointment (existing, pre-req refactor), clinical_record | create_clinical_record + reuse create_order.reference_appointment_id | TC-013 to 016 |
| FR-005 Điền BA DL | AC-005.1 to 5 | clinical_form_instance form_template=BA_DL | validate_clinical_form_data | TC-017 to 021 |
| FR-006 Điền BA TM | AC-006.1 to 5 | clinical_form_instance form_template=BA_TM | validate_clinical_form_data | TC-022 to 026 |
| FR-007 5 biểu mẫu shared | AC-007.1 to 6 | clinical_form_instance, prescription, prescription_item, treatment_progress_entry | validate_clinical_form_data + insert_treatment_progress_entry action/view guard | TC-027 to 032 |
| FR-008 Autosave 30s | AC-008.1 to 5 | clinical_form_instance (update) | validate_clinical_form_data autosave mode | TC-033 to 037 |
| FR-009 In + tải bản scan đã ký | AC-009.1 to 6 | clinical_record status, reference_file | print_clinical_record, upload_clinical_record_scans | TC-038 to 042A |
| FR-010 Allergy safety | AC-010.1 to 6 | allergy_check_policy, allergy_check_skip_log, technical_category.allergy_risk_level | complete_clinical_record (guard inside), create_allergy_skip_log | TC-043 to 047 + TC-047A |
| FR-011 Permission 3 tầng + audit | AC-011.1 to 12 | module_permission_action, role_module.actions, medical_record_access_log, masked views/actions | ResolveClinicalPermission, clinical_record_medical_detail_action, emergency_override_access, sales_safe_clinical_summary | TC-048 to 052 + TC-052A to 052F |
| FR-012 Sổ khám + sổ thủ thuật | AC-012.1 to 4 | clinical_record_secure_summary_view, procedure secure view | Secure Hasura queries get_clinical_records_by_branch, get_procedures_by_branch | TC-053 to 056 |
| FR-013 Notification cần BA | AC-013.1 to 3 | — (event driven) | Event trigger order_insert, scheduler clinical_record_missing_reminder | TC-057 to 059 |
| FR-014 Đánh mã BA + sổ | AC-014.1 to 4 | sequence_generator, plpgsql function get_next_sequence | generate_next_sequence (internal) | TC-060 to 063 |
| FR-015 Kiểm tra sẵn sàng & publish safety | AC-015.1 to 5 | clinic_module_publication, branch.features | publish_clinic_module | TC-064 to 068 |
| FR-016 Trang xem an toàn (Sale) | AC-016.1 to 7 | clinical_sales_handoff, sales_safe_clinical_summary, safe_alert_level/code/text, permission action seed | create_clinical_sales_handoff, sales_safe_clinical_summary | TC-069 to 072, TC-085, TC-085A, TC-085B |
| FR-017 Bàn việc bác sĩ | AC-017.1 to 7 | doctor_workbench_view, clinical_record, clinical_sales_handoff, customer_clinical_intake, permission action seed | Hasura query doctor_workbench_view | TC-073 to 076, TC-088 |
| FR-018 Phiếu khách tự khai | AC-018.1 to 8 | customer_clinical_intake, clinical_form_instance.source_type/source_ref_id, permission action seed | open_customer_clinical_intake_session, submit_customer_clinical_intake_by_token, review_customer_clinical_intake | TC-077 to 080, TC-086, TC-089 |
| FR-019 Trang điều phối phòng khám + Chốt ngày phòng khám | AC-019.1 to 6 | clinic_daily_close, branch_technical_config.close_cutoff_time, clinic_ops_dashboard_view | close_clinic_day, scheduler clinic_daily_close_reminder | TC-081 to 084, TC-087 |
Coverage: 19 FR · 109 AC · 99 TC. 100% FR traceable; AC-level step detail nằm ở QA execution sheet trước khi bắt đầu test cycle.
Phụ lục A — File path metadata Hasura
| File | Purpose |
|---|---|
services/controller/metadata/databases/ecommerce/tables/public_branch.yaml | Thêm feature flag column permission |
services/controller/metadata/databases/ecommerce/tables/public_clinical_record.yaml | Raw table track cho relationship/admin ops; không cấp runtime user select/update |
services/controller/metadata/databases/ecommerce/tables/public_clinical_form_instance.yaml | Raw table track cho relationship/admin ops; write runtime qua validate_clinical_form_data |
services/controller/metadata/databases/ecommerce/tables/public_technical_category.yaml | ↑ |
services/controller/metadata/databases/ecommerce/tables/public_service_clinical_classification.yaml | ↑ |
services/controller/metadata/databases/ecommerce/tables/public_form_template.yaml | ↑ |
services/controller/metadata/databases/ecommerce/tables/public_icd10_code.yaml | ↑ (read-only cho tất role) |
services/controller/metadata/databases/ecommerce/tables/public_medical_record_access_log.yaml | ↑ (insert-only từ middleware) |
services/controller/metadata/databases/ecommerce/tables/public_sales_safe_clinical_summary.yaml | View/action masked cho Sale-safe summary |
services/controller/metadata/databases/ecommerce/tables/public_clinical_sales_handoff.yaml | Phiếu chuyển bác sĩ tư vấn permissions |
services/controller/metadata/databases/ecommerce/tables/public_customer_clinical_intake.yaml | Intake permissions |
services/controller/metadata/databases/ecommerce/tables/public_clinic_daily_close.yaml | Daily close permissions |
services/controller/metadata/databases/ecommerce/tables/public_doctor_workbench_view.yaml | Workbench view permissions |
services/controller/metadata/databases/ecommerce/tables/public_clinic_ops_dashboard_view.yaml | Trang điều phối phòng khám view permissions |
services/controller/metadata/actions.yaml | Append 16 actions |
services/controller/metadata/cron_triggers.yaml | Append 2 schedulers |
Phụ lục B — Điểm còn mở
| # | Item | Owner | Deadline | Impact nếu không resolve |
|---|---|---|---|---|
| B-1 | OQ-1 allergy risk level list cho ~27 KT Tân Bình II + 37 Cao Lãnh | Anh Phát + BS | Trước Phase 3 | Nếu chưa phân loại thì giữ unknown, block readiness và hoàn thành BA |
| B-2 | OQ-11 ICD-10 version 2015 vs 2021 — confirm seed source | Anh Phong | Trước C11-P1-005 | Dùng 2015 default, admin có path import phase 2 |
| B-3 | OQ-2 text consent cross-branch legal review cho workflow Phase 2 | Anh Phong + luật sư | Trước khi mở Phase 2 cross-branch | Chưa số hoá được request/consent cross-branch |
| B-4 | Infra confirm MinIO capacity 10 năm + archive strategy | Infra + Ops | Trước go-live | Phase 2 cold storage cần implement |
| B-5 | Dev Lead: effort review + team allocation confirm | Dev Lead (TBD) | Sau review spec | Timeline có thể shift ±20% |
| B-6 | Xác nhận cách enforce masking tầng 3 bằng view/action + business permission, không expose raw clinical_record cho Sale | BE Dev | Trước C11-P4-003 | Fallback là sales_safe_clinical_summary, không phải clinical_record_public |
| B-7 | Pilot có tablet/quầy cho Phiếu khách tự khai không | Ops + QL CN | Trước pilot training | Nếu thiếu, FR-018 giữ P1 pilot |
End of Dev Spec v2.0.0. File này là quy ước kỹ thuật dẫn xuất từ SOURCE_OF_TRUTH.md và prd.md; team dev dùng để estimate, tách sprint và kiểm tra traceability cùng _consistency-matrix.md trước kickoff full build.