Skip to content

Đặ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ảnNgàyTác giảThay đổi
2.2.030/04/2026PO/Tech + Security LeadTrỏ 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.030/04/2026PO/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.030/04/2026PO/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.728/04/2026PO/TechBổ 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.628/04/2026PO/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.128/04/2026PO/TechFix 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.528/04/2026PO/TechHardening 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.428/04/2026PO/TechChuẩ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.227/04/2026PO/TechSử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.127/04/2026PO/TechChố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.327/04/2026PO/TechThê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.227/04/2026POAlign 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.121/04/2026POReduce 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.021/04/2026POInitial dev spec — 16 tables, 15+ endpoints, 70 tasks, 4 phases + pre-req

Đầu vào chuẩn (Canonical Inputs)

FileVai tròNếu conflict
SOURCE_OF_TRUTH.mdNguồn sự thật chuẩn + Solution LockƯu tiên cao nhất
EVIDENCE_PACK.mdFact code/DB/config hiện tại + thành phần có thể reuseƯu tiên fact từ discovery
prd.mdFR, lifecycle, công thức nghiệp vụ, rào phạm viA8 thắng mọi mô tả formula khác
decision-brief.mdTóm tắt package + ảnh hưởng bàn giaoChỉ đị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.mdprd.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ạiCountChi tiết
DB tables mới19clinical_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 extend1branch.features JSONB
Seed migrations4icd10_code (~22k rows TT46/2018 BYT), form_template (7 templates), allergy_check_policy (4 rows), module_permission_action cho clinical modules/actions
Hasura actions17import_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/views9get_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 triggers3order_insert (notify cần BA), appointment_completed (schedule missing BA reminder), clinical_record_status_change (audit log)
Schedulers2clinical_record_missing_reminderclinic_daily_close_reminder theo close_reminder_times/close_cutoff_time của CN
Pre-req refactor1Appointment.OrderItemID*uuid.UUID + audit/null-guard toàn repo + FE codegen audit
Hasura roles new0Khô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 độOwnerLý do
IM-P1-01pkg/store/appointment.go:52OrderItemID uuid.UUID*uuid.UUIDCriticalBE DevNon-pointer = silent bug khi DB value NULL (walk-in case)
IM-P1-02services/ecommerce-api/event/appointment_insert.go:76Null-guard query order_item🔴 CriticalBE DevEvent trigger assume có order_item_id — walk-in sẽ panic
IM-P1-03services/ecommerce-api/event/appointment_insert.go:176Null-guard push OrderItemID vào project task🔴 CriticalBE DevTương tự
IM-P1-04services/ecommerce-api/event/appointment_insert.go:202Null-guard push OrderItemID🔴 CriticalBE DevTương tự
IM-P1-05services/ecommerce-api/event/appointment_update.go:190Null-guard query order_itemCriticalBE DevTương tự
IM-P1-05ARepo-wide OrderItemID/order_item_id usage, gồm ticket/task/store/report và FE generated typesCompile audit + nil guardCriticalBE/FE DevReview phát hiện usage ngoài 4 call site; walk-in sẽ fail nếu còn dereference ngầm
IM-P1-06services/ecommerce-api/event/order_insert.goThêm trigger check service_clinical_classification.status = mapped_requires_ba → notify BS "cần BA"🟡 ExtendBE DevFR-013
IM-P1-07diva-admin/src/modules/ecommerce/pages/Appointments.tsx + appointment form componentsCodegen audit appointment_insert_input.order_item_id optionalCheckFE DevĐảm bảo FE truyền null hợp lệ theo màn hiện tại
IM-P1-08diva-admin/src/modules/user/pages/CustomerDetail.tsxThêm parent tab clinical_records; CustomerInfo.tsx giữ vai trò summary card🟡 ExtendFE DevFR-001
IM-P1-09diva-admin/src/modules/user/pages/BranchDetail.tsxThêm tab "Phòng khám" 5 bước wizard🟡 ExtendFE DevFR-002
IM-P1-10services/controller/metadata/databases/ecommerce/tables/Thêm clinical tables + permission YAML/view/action theo role runtime user/adminExtendBE DevCore feature; bảng clinical phải gần branch/appointment/order/product/reference_file
IM-P1-11diva-admin/src/modules/user/pages/BranchDetail.tsxThêm readiness navigator + publish hộp thoại/thời điểm áp dụng🟡 ExtendFE DevFR-015

C2.2) Phase 2 — Data sai nếu không sửa

#LocationĐổi gìLý do
IM-P2-01Production log 3 tháng gần nhấtAudit: 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-02branch 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-03MinIO configThêm bucket clinical-record-scans (nếu chưa có), lifecycle rule 10 nămCompliance 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_file upload: pkg/file/upload.go đã xử lý MinIO generic — chỉ cần set reference_id = clinical_record_idtype = 'clinical_record_scan' hoặc 'clinical_before_after'.
  • Appointment scheduler reminder: pattern appointment_reminder.go reuse — 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 / ownerThay đổi quy ướcBắt buộc thêmKhông được làmKiểm chứng
Settings / Branch DetailExtend BranchDetail với tab "Phòng khám", readiness và publish/thời điểm áp dụngRoute/menu/button guard clinic_module.configure/publish, preview impact, R-01..R-10Không mở live bằng clinic_enabled đơn lẻQA TC-064..068 + REG-DIR-001
Dynamic Permission / AuthExtend action catalog/resolvermodule_permission_action, role_module.actions, resolver action+portal+branch_mode, FE constantsKhông tạo role nghiệp vụ mới hoặc check role name ở UIQA TC-052A..052F + REG-DIR-002
Appointment / POS / OrderExtend nullability và late-linkOrderItemID *uuid.UUID, null-guard repo-wide, reference_appointment_id helper, notify order needs BAKhông dereference order_item_id như non-null; không tạo reminder cho explicitly_no_baQA TC-013..016, TC-057..059 + REG-DIR-003
Product / Service classificationExtend service configservice_clinical_classification, drift alert, block publish khi còn unclassifiedKhông mặc định unclassified = no_baQA TC-009..012 + readiness R-04
Customer / CRM / Sales-safeExtend customer detail + build safe summary/handoffTab BA theo quyền, masked view/action, safe dictionarySale/CSKH không thấy diagnosis, scan, y lệnh, đơn thuốc, raw formQA TC-048..052F, TC-069..072 + REG-DIR-004
Clinical forms / Reference file / PrintBuild clinical core, reuse file/print pipelineJSON Schema validator, form version, checklist bản scan đã ký, private signed URLKhông expose raw clinical_form_instance mutation/select cho runtime user; không coi 1 file scan bất kỳ là signedQA TC-017..047A + REG-DIR-005
Notification / Export / LogsExtend notification-v2 + build secure exportsDedupe keys, no-tier3 payload, allowlist export fields, access log tầng 3Không dùng generic export raw form_data; không gửi diagnosis qua pushQA TC-053..060 + REG-DIR-006
Workbench / Intake / Ops closeBuild operational surfacesWorkbench 6 bucket, intake token validation, Chốt ngày phòng khám cutoff/reminder per CNKhông tạo task giả cho DV không cần BA; không close nếu còn P0QA TC-073..089 + REG-DIR-007
Module gián tiếpChỉ smoke/guardAudit null-guard hoặc safe deeplink/runbook khi có chạm kỹ thuậtKhông thêm KPI/report/complaint/task workflow clinical Day-1QA REG-IND-001..004
Module không ảnh hưởngKhông đổi scopeSmoke regression theo matrixKhông đổi menu, permission, transaction, report, job hoặc schema các module nàyQA 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:
    • typeclinical_profile.form_type ('DL' | 'TM')
    • branch_codebranch.code
    • year ← EXTRACT(YEAR FROM clinical_profile.created_at)
    • sequence_5_digitsequence_generator.current_number
  • Index: sequence_generator UNIQUE (entity_type, scope_key, COALESCE(year, 0)) với SELECT 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_codebranch.code
    • yearappointment.from YEAR (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_id trực tiếp và walk-in late-link qua order.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:
    • numeratorclinical_record WHERE completed_at::date = appointment.from
    • denominatorappointment WHERE 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 NULL partial 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 as unknown và 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 (schema public) vì cần FK/runtime gần branch, appointment, order, order_item, product, reference_file. account thuộc source default, vì vậy mọi user/account id trong bảng clinical lưu dạng TEXT và validate qua service/relationship, không tạo FK chéo source. Tất timestamp TIMESTAMPTZ lưu UTC, UI convert Asia/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 qua medical_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 source ecommerce trong services/controller/metadata/databases/ecommerce/tables/.

Runtime route lock: ecommerce-api expose route chung POST /actions, POST /events, POST /schedulers. Metadata không được trỏ tới /actions/foo, /events/foo, /schedulers/foo; dispatcher xử lý theo action.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 updated

C5.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ưu unknown, 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_instances

C5.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 → notification

C5.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_record live 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 theo form_type.
  • Validate source_type; paper_fallback chỉ dùng khi downtime/manual fallback được Ops mở và phải có audit reason trong service log.
  • Upsert clinical_form_instance bằng transaction; raw Hasura insert/update vào clinical_form_instance.form_data không cấp cho role user.
  • Nếu status='completed': enforce required legal fields (signature_typed_text, allergy history, consent checkboxes) và ghi medical_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ào clinical_form_instance audit columns hoặc medical_record_access_log field extra_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 sau compositionend.
  • 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
//    END

Permission seed mới:

ModuleActionDefault seedGhi chú
clinical_recordrefuse_procedureBS, Y tá trưởng cùng CNBị thu hồi → CTA ẩn ở SCR-08; gọi action trực tiếp → 403

Validation matrix:

Tình huốngStatusResponseTC ref
Happy pathOK200 + RefuseClinicalProcedureOutputTC-090
witness_nurse_id rỗng/không thuộc CN422error.refusal_witness_requiredTC-090A
Đã có Bệnh án DL signed cùng appointmentOK200, preserved_dl_record_id không nullTC-090B
User thiếu clinical_record.refuse_procedure403"Bạn không có quyền thực hiện thao tác này"TC-090C
User thiếu order.update422"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ật422"Order item không phải dịch vụ thủ thuật"(P2)
Re-call action trên record đã từ chối409"Đã 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_ids mỗ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ặc clinical_after_photo
  • Recompute clinical_record.mandatory_scan_checklist theo 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 = true khi checklist pass. Nếu thiếu, giữ printed và 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:

FieldGhi chú
intake_idID record đã tạo ở status draft
intake_tokenRaw one-time token, chỉ trả về 1 lần cho tablet/kiosk
expires_atnow() + 15 minutes theo server
customer_display_nameTê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 theo clinic_module_publication.status='live' AND effective_at <= now(), appointment nếu có phải thuộc đúng customer_id + branch_id; không tạo thêm session nếu đã có active session cùng customer_id + appointment_id; generate raw token trả về 1 lần cho tablet, DB chỉ lưu session_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ận customer_id/branch_id từ client; submit xong set consumed_at, submitted_at, clear client session và gửi NTF-13.
  • review_customer_clinical_intake: chỉ BS/y tá có quyền CN; validate intake status=submitted; nếu có target_clinical_record_id thì record phải cùng customer_id + branch_id và cùng appointment_id khi intake có appointment; accept mới prefill/tạo clinical_form_instance với source_type = customer_intake, source_ref_id = intake_id, source_accepted_by, source_accepted_at; intake row cập nhật reviewed_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.

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_id trong ngày.
  • Nếu user chọn link, payload tạo đơn hiện có sẽ set reference_appointment_id = appointment.id.
  • order_insert.go tiế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_id cho flow từ đơn sẵn có
    • order.reference_appointment_id cho 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_idTEXT để 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_handoffintake_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:

FieldTypeGhi chú
customer_iduuidCustomer đang tư vấn
branch_iduuidBranch context của CRM/Sale
service_history_summaryjsonbDV 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_typejsonbCount BA DL/TM theo status an toàn
safe_alert_levelsafe_alert_level_enumnone/info/warning/block
safe_alert_codetextCode nằm trong dictionary bên dưới
safe_alert_texttextText đã duyệt, không render raw medical text
can_create_handoffbooleanRole + branch scope đủ để gửi BS
latest_handoff_statustext nullablesent/acknowledged/closed nếu có

Allowed dictionary:

safe_alert_levelsafe_alert_codesafe_alert_text
noneno_known_alertChưa ghi nhận cảnh báo an toàn
infohistory_existsKH có thông tin sức khoẻ cần BS xem lại trước tư vấn
warningallergy_or_conditionCó cảnh báo dị ứng/bệnh nền — cần BS xác nhận
blockdoctor_requiredKhô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ành

Trigger 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 reminders

Trigger 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:

CodeTitle VIBody VIChannelRecipient roleDedupe
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 + pushbranch_manager + assigned_saleper 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: 21600

C6.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 now khớp một mốc trong close_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/insert clinic_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 ecommerce vì clinical tables cần FK/runtime gần appointment/order/product/reference_file. Không đặt ở default trừ seed/view thật sự thuộc source default.

Phase 0 — Pre-requisite (không migration, chỉ code change)

StepFileAction
0.1pkg/store/appointment.go:52OrderItemID uuid.UUID*uuid.UUID
0.2services/ecommerce-api/event/appointment_insert.go:76,176,202Null-guard
0.3services/ecommerce-api/event/appointment_update.go:190Null-guard
0.3ARepo-wide OrderItemID usagesAudit + nil guard trong ticket/task/store/report/FE generated paths
0.4FE codegen auditGenerate types — verify order_item_id?: string optional
0.5Integration testInsert appointment với order_item_id = NULL + trigger event không panic

Phase 1 — Core tables (Tuần 1-2)

#MigrationPurpose
01766199999000_create_clinical_prereq_typesCREATE EXTENSION pg_trgm + enum types C4.0 trước mọi table/index
11766200000000_extend_branch_featuresALTER branch ADD features JSONB
21766200001000_create_branch_technical_configCREATE table + index + close cutoff/reminder config
31766200002000_create_technical_categoryCREATE table + trgm index
41766200003000_create_service_clinical_classificationCREATE table
51766200004000_create_icd10_codeCREATE + seed ~22k rows (split data migration 1766200004500_seed_icd10)
61766200005000_create_form_templateCREATE + seed 7 templates (split data migration 1766200005500_seed_form_templates)

Phase 2 — Clinical record (Tuần 3-4)

#MigrationPurpose
71766200006000_create_clinical_recordCore table + indexes + validate trigger
81766200007000_create_clinical_form_instance+ GIN index + source audit fields
91766200008000_create_treatment_progress_entryTM only table
101766200009000_create_prescriptionprescription + prescription_item

Phase 3 — Safety + audit (Tuần 5-6)

#MigrationPurpose
111766200010000_create_allergy_check_policy+ seed 4 rows (unknown/block, low/warn, medium/confirm_skip, high/block) + allergy_check_skip_log
121766200011000_create_medical_record_access_logaudit access tầng 3 + emergency override

Phase 4 — Operational surfaces + sequence + metadata (Tuần 7)

#MigrationPurpose
131766200012000_create_sequence_generator+ plpgsql function get_next_sequence
141766200013000_create_clinical_sales_handoffSale-safe handoff
151766200014000_create_customer_clinical_intakePhiếu khách tự khai + one-time token hash
161766200015000_create_clinic_daily_closeEnd-of-day close + cutoff snapshot
171766200016000_create_clinical_viewsWorkbench, appointment customer resolver, ops dashboard, sales-safe summary views
181766200017000_hasura_permissions_clinical_recordTấ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 role user
  • 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.md v1.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.md P1.1 — catalog 22 action × portal × sensitivity (canonical seed migration)
  • permission-spec.md P2.1 — default seed matrix 22 action × 9 role × branch_mode × is_pos_only
  • permission-spec.md P3.1 — Go signature ResolveClinicalPermission() + 11-step algorithm
  • permission-spec.md P4 — TypeScript constants CLINICAL_MODULES + CLINICAL_ACTIONS + label VI
  • permission-spec.md P5-P6 — branch_mode behavior + portal isolation + is_pos_only
  • permission-spec.md P7 — emergency override governance (rate limit, session TTL, cron expire, manual revoke)
  • permission-spec.md P8 — cache topology Redis 60s + WebSocket pubsub
  • permission-spec.md P9 — migration M101-M107 + M105 default seed SQL
  • permission-spec.md P10 — field-level masking matrix per view_mode
  • permission-spec.md P11 — 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:

ModulePortalActionDefault seedBackend resolver output
clinic_moduleAdminaccess, configure, publishAdmin/Ops; QL CN readonly accesssettings_hidden, readonly, can_configure, can_publish
clinical_recordAdmin, Staffview_summaryBS, Y tá, Lễ tân, QL CN, Admin/Opssummary
clinical_recordAdmin, Staffview_medical_detailBS cùng CN, Medical Lead, Admin auditfull_tier3 hoặc locked
clinical_recordAdmin, Staffedit_medical_form, edit_admin_sharedBS/Medical Lead; Y tá chỉ edit_admin_sharedField allowlist theo sensitivity tag
clinical_recordAdmin, Staffprint, upload_scan, export_visit_log, emergency_overrideBS/Y tá/QL/Admin theo seedAction allow/deny + audit tier
clinical_recordAdmin, Staffrefuse_procedure (DEC-024)BS, Y tá trưởng cùng CNAction allow/deny; cần kết hợp order.update ở tầng order để huỷ order_item
clinical_salesCRM, Adminview_safe_summarySale CRM, QL CN, Admin/Ops, BSsales_safe_summary
clinical_sales_handoffCRM, Admin, Staffcreate, acknowledge_closeSale tạo; BS nhận/đóngHandoff action allow/deny
doctor_workbenchStaff, Adminaccess, view_branch_queueBS/Y tá; BS trưởng/QL CNQueue scope self, branch, multi_branch
customer_clinical_intakePOS, Staff, Adminopen_session, review_acceptLễ tân/Y tá mở; BS acceptToken action + accept allow/deny
clinic_opsAdminview_branch_dashboard, view_all_dashboardQL CN; Admin/OpsDashboard branch scope
clinic_daily_closeAdmin, Staffclose_branch_dayQL CN, Admin/OpsClose 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à default role_module.actions theo 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 ClinicalPermissionAction và 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_id hoặ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_detail cho 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ầngColumnsRole access
Tầng 1 (an toàn)Safe alert đã chuẩn hóa từ allergy/medical historyStaff 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_atTấ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 mediaStrict: 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 raw clinical_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-Id nế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_access mới bypass qua service account và ghi audit.
  • JSONB guard: clinical_form_instance.form_data khô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ết

C8.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

EndpointLimitScope
emergency_override_access5/dayper user (alert admin nếu overused)
upload_clinical_record_scans50/hourper user
search_icd10300/minuteper user (BS UX nhanh)

C8.6) PII và bảo vệ dữ liệu

  • customer.phone, customer.cccd không lưu trong form_data JSON (lưu reference qua customer_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

MetricTargetMeasurement
Form BA DL/TM load time (initial)<2s p95Lighthouse + custom timing
Autosave round-trip<500ms p95Prometheus histogram
ICD-10 search response<100ms p95GIN 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 p95MinIO 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-api down → 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, money 1,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

MetricTypeLabels
clinical_record_created_totalcounterbranch, form_type, status
clinical_record_completion_minuteshistogrambranch, form_type, doctor
autosave_latency_mshistogram (p50, p95, p99)
autosave_failure_totalcounterreason
allergy_check_skip_totalcounterrisk_level, technical_category
sales_handoff_ack_latency_hourshistogrambranch, doctor
emergency_override_totalcounterbranch, doctor
icd10_search_latency_mshistogram
print_pdf_generation_mshistogramform_count
scan_upload_byteshistogram
sales_handoff_totalcounterbranch, status
doctor_workbench_task_age_minuteshistogrambranch, task_type
customer_intake_submitted_totalcounterbranch, status
clinic_daily_close_on_time_totalcounterbranch
clinic_daily_close_late_totalcounterbranch, reason

C10.3) Cảnh báo

AlertConditionSeverityChannel
Autosave failure spikerate(autosave_failure_total[5m]) > 0.05P1PagerDuty Ops
Emergency override spikecount(emergency_override_total[1d]) > 3 per doctorP2Slack admin + email QL CN
Missing BA daily count highscheduler output > 20% appointmentsP3Slack branch-manager
Sales handoff pending > 4hcount(handoff status=sent age>4h) > 0P3Branch manager
Daily close latelive branch chưa close sau 20:00 VNP2Ops/Admin email + Slack
MinIO storage > 80%disk_used > 0.8P2PagerDuty DevOps
DB clinical_record write latency p95 > 1shistogram > 1000P1PagerDuty

C10.4) Dashboard Grafana

Board: "Clinical Record Ops" 🆕

Panels:

  1. BA volume per day/CN (line chart, stacked by form_type)
  2. BA completion rate % (gauge + time series)
  3. Avg completion minutes per doctor (bar chart)
  4. Allergy skip counts by risk level (stacked bar)
  5. Access request status distribution (pie)
  6. Emergency override events (table with details)
  7. MinIO storage growth (line, projection)
  8. Autosave health (p95 latency + failure rate)
  9. Bàn việc bác sĩ backlog by task type
  10. Daily close on-time rate by branch
  11. Sales handoff pending/acknowledged rate

C10.5) Tracing

  • OpenTelemetry (existing setup) — propagate trace_id từ 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)

IDTaskFile/OwnerEffortPriorityBlocker
C11-PREREQ-001Refactor Appointment.OrderItemID uuid.UUID*uuid.UUIDpkg/store/appointment.go:52 — BE Dev0.5 dayP0Walk-in case
C11-PREREQ-002Null-guard known event call sites (appointment_insert.go:76,176,202 + appointment_update.go:190)BE Dev1 dayP0Event trigger panic
C11-PREREQ-002ARepo-wide audit/null-guard mọi usage OrderItemID/order_item_id ngoài event (ticket/task/store/report/FE generated)BE + FE1 dayP0Walk-in regression ngoài 4 call site
C11-PREREQ-003FE codegen audit — verify appointment_insert_input.order_item_id?: string optionaldiva-admin/src/modules/ecommerce/pages/Appointments.tsx + appointment form components — FE Dev0.5 dayP0FE nullability
C11-PREREQ-004Integration test: insert_appointment_one với order_item_id = NULL + event trigger không panicQA + BE0.5 dayP0Regression safety
C11-PREREQ-005Audit 3 tháng production log: có appointment với NULL order_item_id đang bug silent?BE Dev0.5 dayP0R-1 mitigation
C11-PREREQ-006Audit ả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 planningTL + QA + BE + FE0.5 dayP0Chố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)

IDTaskFile/OwnerEffortPriority
C11-P1-000Migration prerequisite pg_trgm + clinical enum typesBE0.5dP0
C11-P1-001Migration extend_branch_featuresBE0.5dP0
C11-P1-002Migration create_branch_technical_config + Hasura metadataBE0.5dP0
C11-P1-003Migration create_technical_category + trgm indexBE0.5dP0
C11-P1-004Migration create_service_clinical_classificationBE0.5dP0
C11-P1-005Migration create_icd10_code + seed ~22k rows TT46/2018BE + Data2dP0
C11-P1-006Migration create_form_template + seed 7 templatesBE1dP0
C11-P1-007Action publish_clinic_module_config handlerBE0.5dP0
C11-P1-007AAction publish_clinic_module handler — readiness R-01..R-10, xem trước tác động, scheduled/live updateBE1dP0
C11-P1-008Action import_technical_categories_csv handlerBE1dP0
C11-P1-009Admin FE: tab "Phòng khám" in BranchDetailFE1dP0
C11-P1-010Admin FE: 5-bước wizard (loại PK → giấy phép → KT → mapping → phân quyền)FE3dP0
C11-P1-011Admin FE: Import KT CSV UI + preview + error displayFE1.5dP0
C11-P1-012Admin FE: Phân loại dịch vụ — kỹ thuật UI với bulk actionFE2dP0
C11-P1-013Admin FE: Cấu hình allergy_risk_level per KT (bulk edit)FE1dP0
C11-P1-014Unit test + integration test Phase 1QA + BE1.5dP0
C11-P1-015Docs: Admin user guide cho cấu hình 5 bướcPO + BA0.5dP1

Phase 1 total: ~18.5 days mixed.

C11.2. Phase 2 — Clinical record core + Form renderer (Tuần 3-4)

IDTaskFile/OwnerEffortPriority
C11-P2-001Migration create_clinical_record + indexes + trigger validate_icd10BE1dP0
C11-P2-002Migration create_clinical_form_instance + GIN indexBE0.5dP0
C11-P2-003Migration create_treatment_progress_entryBE0.5dP0
C11-P2-004Action create_clinical_record handler — bao gồm generate codesBE2dP0
C11-P2-005Action complete_clinical_record handler + allergy guardBE2dP0
C11-P2-005AAction validate_clinical_form_data — JSON Schema/sensitivity/required/audit + upsert form instance; khóa raw mutation role userBE1.5dP0
C11-P2-006Reuse create_order.reference_appointment_id cho walk-in late-link + helper query/filter appointment mởBE1dP0
C11-P2-007FE: Form renderer engine JSON Schema Draft-07FE3dP0
C11-P2-008FE: Form BA DL — implement schema theo ~30 fieldsFE2dP0
C11-P2-009FE: Form Cam đoan (shared) — DEC-021 tự gõ xác nhận + regex validateFE1.5dP0
C11-P2-010FE: Form Cam kết (shared)FE1dP0
C11-P2-011FE: Form Đơn thuốc — multi-row prescription_itemFE1.5dP0
C11-P2-012FE: Form Tiền sử dị ứng — hardcode TT51/2017FE1dP0
C11-P2-013FE: Autosave 30s + status badge "Đang soạn thảo"FE1dP0
C11-P2-014FE: Menu "BA nháp" list + filter + resumeFE1dP0
C11-P2-015FE: Parent tab "Hồ sơ BA" trong CustomerDetail; CustomerInfo giữ nguyênFE1dP0
C11-P2-016FE: Tạo lượt khám flow (walk-in + từ đơn)FE2dP0
C11-P2-017FE: ICD-10 searchable dropdown với debounce 300msFE1dP0
C11-P2-018QA + integration test Phase 2QA + BE2dP0
C11-P2-019FE: Cross-branch locked state UI + copy hướng dẫn SOP manual ngoài appFE0.5dP0
C11-P2-020FE: Order create UI — popup "KH này có lượt khám mở" + set reference_appointment_idFE1dP0

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)

IDTaskFile/OwnerEffortPriority
C11-P3-001Form template seed: BA_TM JSON Schema ~60 fields (Mắt/Mũi/Môi/Khác)BE + PO2dP0
C11-P3-002FE: Form BA TM renderer với 4 sectionFE3dP0
C11-P3-003FE: DEC-019 auto-hiện section theo DV POS — logic detect from order_itemFE1.5dP0
C11-P3-004FE: Form Phiếu theo dõi điều trị — multi-row timeline, entry cũ readonlyFE2dP0
C11-P3-005Action + Hasura mutation: insert_treatment_progress_entryBE0.5dP0
C11-P3-006Migration create_allergy_check_policy + seed 4 rowsBE0.5dP0
C11-P3-007Action create_allergy_skip_log handlerBE0.5dP0
C11-P3-008FE: Allergy guard popup (block / confirm_skip / warn)FE2dP0
C11-P3-009FE: DEC-012 reuse dị ứng "không đổi" — copy form_data 12 monthsFE1.5dP0
C11-P3-010Admin FE: Cài đặt chính sách an toàn (edit allergy_check_policy)FE1dP1
C11-P3-011BE: allergy_check_policy cache (30s TTL) trong handlerBE0.5dP1
C11-P3-012FE: Số đo Mũi numeric validation + unit hintFE1dP0
C11-P3-013QA + integration test Phase 3 (BA TM full + allergy flows)QA + BE2dP0
C11-P3-014PO: Review allergy risk list với BS Phát (OQ-1 resolve)PO + BS1dP0
C11-P3-015Docs: BS user guide cho BA TM 4 phân hệPO + BA1dP1

Phase 3 total: ~20 days mixed.

C11.4. Phase 4 — Permission 3 tầng + Audit (Tuần 7)

IDTaskFile/OwnerEffortPriority
C11-P4-001Migration create_medical_record_access_logBE0.5dP0
C11-P4-002Migration/seed Dynamic Permission v2: module_permission_action + role_module.actions cho clinical modules theo portal/default seedBE1dP0
C11-P4-003Hasura permissions YAML: runtime user/admin + secure views/actions, không expose raw clinical table cho userBE3dP0
C11-P4-004PostgreSQL view/action sales_safe_clinical_summary + Hasura track cho role runtime userBE1dP0
C11-P4-005Middleware medical_record_access_auditBE1.5dP0
C11-P4-006Backend resolver ResolveClinicalPermission: action + portal + branch_mode + hard deny Sale tầng 3 + least-data field allowlistBE1.5dP0
C11-P4-007FE constants/types ClinicalPermissionAction + route/menu/button guards theo Dynamic Permission, không check role nameFE1dP0
C11-P4-008Action emergency_override_access handler + realtime notification adminBE1.5dP0
C11-P4-009Quy ướ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/reloginBE + FE0.5dP0
C11-P4-010FE: Permission tầng 3 UI — mask + emergency override button + reason hộp thoại + no-permission state theo action/portalFE2dP0
C11-P4-011QA/Dev smoke Dynamic Permission UI: no action, grant, revoke, portal split, branch_mode, is_pos_onlyQA + BE + FE1dP0

Phase 4 total: ~14 days mixed.

C11.5. Phase 5 — Sổ khám + Notification + Scheduler (Tuần 8)

IDTaskFile/OwnerEffortPriority
C11-P5-001FE: Sổ khám bệnh view với filter + paginationFE2dP1
C11-P5-002FE: Sổ thủ thuật viewFE1dP1
C11-P5-003FE: Export Excel (sổ khám + sổ thủ thuật)FE1.5dP1
C11-P5-004FE: Export PDF (sổ khám)FE1dP1
C11-P5-005Scheduler clinical_record_missing_reminder dailyBE1dP1
C11-P5-006Event trigger extend order_insert.go → notify BS "cần BA" + đăng ký dispatcher trong /events theo table/trigger payloadBE1dP0
C11-P5-007Event trigger appointment_completed → schedule reminder + đăng ký dispatcher trong /eventsBE0.5dP0
C11-P5-008Notification template seeding: order_needs_ba, appointment_completed_no_ba, emergency_override, sales_handoff, daily_closeBE0.5dP0

Phase 5 total: ~8.5 days mixed.

C11.5B. Phase 5B — Sales/Ops operational surfaces (Tuần 8-9)

IDTaskFile/OwnerEffortPriority
C11-P5B-001Migration create_clinical_sales_handoff + Hasura permissionsBE0.5dP0
C11-P5B-002Action create_clinical_sales_handoff + acknowledge_sales_handoffBE1dP0
C11-P5B-003FE: SCR-13 Trang xem an toàn (Sale) + safe-alert dictionary + handoff formFE2dP0
C11-P5B-004Query/view doctor_workbench_view đủ 6 bucket + stable task_key + resolver resolve_appointment_customer_id không null với appointment cần BABE1dP0
C11-P5B-005FE: SCR-14 Bàn việc bác sĩ task queueFE3dP0
C11-P5B-006Migration/action customer_clinical_intake + token session + review/source audit handlerBE2dP1
C11-P5B-007FE: SCR-15 Phiếu khách tự khai tablet + review drawerFE3dP1
C11-P5B-008Migration clinic_daily_close + close_clinic_day actionBE1dP0
C11-P5B-009FE: SCR-16 Trang điều phối phòng khám + SCR-17 Chốt ngày phòng khámFE3dP0
C11-P5B-010Scheduler clinic_daily_close_reminder đọc cutoff/reminder theo CN + NTF-12..15BE1dP0

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)

IDTaskFile/OwnerEffortPriority
C11-P6-001Action print_clinical_record — HTML → PDF pipelineBE2dP0
C11-P6-002Print template: 7 biểu mẫu HTML với placeholder {{branch.license_header}}BE + PO3dP0
C11-P6-003Action upload_clinical_record_scans + validate mime/sizeBE1dP0
C11-P6-004FE: Print preview hộp thoại + nút "In bộ hồ sơ"FE1.5dP0
C11-P6-005FE: Tải bản scan đã ký UI — cả bộ / từng tờ + preview + reuploadFE2dP0
C11-P6-006FE: Trạng thái in v2 "Superseded" + lý do sửaFE1dP1
C11-P6-007FE: KH từ chối ký UI — lý do + y tá làm chứng chọnFE1dP0

Phase 6 total: ~11.5 days mixed.

C11.7) Tổng effort

PhaseEffort (man-days)Dependencies
Pre-req3.5
Phase 118.5Pre-req done
Phase 225Phase 1 migration done
Phase 320Phase 2 form engine done
Phase 413Phase 2 clinical_record exists
Phase 58.5Phase 4 permission done
Phase 5B17Phase 4 permission + Phase 5 notifications
Phase 611.5Phase 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)

FRACTablesAPI endpointsTC
FR-001 Bật/tắt moduleAC-001.1 to 4branch.features, branch_technical_configpublish_clinic_module_configTC-001 to 004
FR-002 Cấu hình 5 bướcAC-002.1 to 5branch_technical_config, technical_category, service_clinical_classificationimport_technical_categories_csv + CRUD tech_cat + classificationTC-005 to 008
FR-003 Phân loại dịch vụ — kỹ thuậtAC-003.1 to 5service_clinical_classificationHasura mutations insert/update_service_clinical_classificationTC-009 to 012
FR-004 Tạo lượt khám + walk-inAC-004.1 to 5appointment (existing, pre-req refactor), clinical_recordcreate_clinical_record + reuse create_order.reference_appointment_idTC-013 to 016
FR-005 Điền BA DLAC-005.1 to 5clinical_form_instance form_template=BA_DLvalidate_clinical_form_dataTC-017 to 021
FR-006 Điền BA TMAC-006.1 to 5clinical_form_instance form_template=BA_TMvalidate_clinical_form_dataTC-022 to 026
FR-007 5 biểu mẫu sharedAC-007.1 to 6clinical_form_instance, prescription, prescription_item, treatment_progress_entryvalidate_clinical_form_data + insert_treatment_progress_entry action/view guardTC-027 to 032
FR-008 Autosave 30sAC-008.1 to 5clinical_form_instance (update)validate_clinical_form_data autosave modeTC-033 to 037
FR-009 In + tải bản scan đã kýAC-009.1 to 6clinical_record status, reference_fileprint_clinical_record, upload_clinical_record_scansTC-038 to 042A
FR-010 Allergy safetyAC-010.1 to 6allergy_check_policy, allergy_check_skip_log, technical_category.allergy_risk_levelcomplete_clinical_record (guard inside), create_allergy_skip_logTC-043 to 047 + TC-047A
FR-011 Permission 3 tầng + auditAC-011.1 to 12module_permission_action, role_module.actions, medical_record_access_log, masked views/actionsResolveClinicalPermission, clinical_record_medical_detail_action, emergency_override_access, sales_safe_clinical_summaryTC-048 to 052 + TC-052A to 052F
FR-012 Sổ khám + sổ thủ thuậtAC-012.1 to 4clinical_record_secure_summary_view, procedure secure viewSecure Hasura queries get_clinical_records_by_branch, get_procedures_by_branchTC-053 to 056
FR-013 Notification cần BAAC-013.1 to 3— (event driven)Event trigger order_insert, scheduler clinical_record_missing_reminderTC-057 to 059
FR-014 Đánh mã BA + sổAC-014.1 to 4sequence_generator, plpgsql function get_next_sequencegenerate_next_sequence (internal)TC-060 to 063
FR-015 Kiểm tra sẵn sàng & publish safetyAC-015.1 to 5clinic_module_publication, branch.featurespublish_clinic_moduleTC-064 to 068
FR-016 Trang xem an toàn (Sale)AC-016.1 to 7clinical_sales_handoff, sales_safe_clinical_summary, safe_alert_level/code/text, permission action seedcreate_clinical_sales_handoff, sales_safe_clinical_summaryTC-069 to 072, TC-085, TC-085A, TC-085B
FR-017 Bàn việc bác sĩAC-017.1 to 7doctor_workbench_view, clinical_record, clinical_sales_handoff, customer_clinical_intake, permission action seedHasura query doctor_workbench_viewTC-073 to 076, TC-088
FR-018 Phiếu khách tự khaiAC-018.1 to 8customer_clinical_intake, clinical_form_instance.source_type/source_ref_id, permission action seedopen_customer_clinical_intake_session, submit_customer_clinical_intake_by_token, review_customer_clinical_intakeTC-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ámAC-019.1 to 6clinic_daily_close, branch_technical_config.close_cutoff_time, clinic_ops_dashboard_viewclose_clinic_day, scheduler clinic_daily_close_reminderTC-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

FilePurpose
services/controller/metadata/databases/ecommerce/tables/public_branch.yamlThêm feature flag column permission
services/controller/metadata/databases/ecommerce/tables/public_clinical_record.yamlRaw table track cho relationship/admin ops; không cấp runtime user select/update
services/controller/metadata/databases/ecommerce/tables/public_clinical_form_instance.yamlRaw 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.yamlView/action masked cho Sale-safe summary
services/controller/metadata/databases/ecommerce/tables/public_clinical_sales_handoff.yamlPhiếu chuyển bác sĩ tư vấn permissions
services/controller/metadata/databases/ecommerce/tables/public_customer_clinical_intake.yamlIntake permissions
services/controller/metadata/databases/ecommerce/tables/public_clinic_daily_close.yamlDaily close permissions
services/controller/metadata/databases/ecommerce/tables/public_doctor_workbench_view.yamlWorkbench view permissions
services/controller/metadata/databases/ecommerce/tables/public_clinic_ops_dashboard_view.yamlTrang điều phối phòng khám view permissions
services/controller/metadata/actions.yamlAppend 16 actions
services/controller/metadata/cron_triggers.yamlAppend 2 schedulers

Phụ lục B — Điểm còn mở

#ItemOwnerDeadlineImpact nếu không resolve
B-1OQ-1 allergy risk level list cho ~27 KT Tân Bình II + 37 Cao LãnhAnh Phát + BSTrước Phase 3Nếu chưa phân loại thì giữ unknown, block readiness và hoàn thành BA
B-2OQ-11 ICD-10 version 2015 vs 2021 — confirm seed sourceAnh PhongTrước C11-P1-005Dùng 2015 default, admin có path import phase 2
B-3OQ-2 text consent cross-branch legal review cho workflow Phase 2Anh Phong + luật sưTrước khi mở Phase 2 cross-branchChưa số hoá được request/consent cross-branch
B-4Infra confirm MinIO capacity 10 năm + archive strategyInfra + OpsTrước go-livePhase 2 cold storage cần implement
B-5Dev Lead: effort review + team allocation confirmDev Lead (TBD)Sau review specTimeline có thể shift ±20%
B-6Xác nhận cách enforce masking tầng 3 bằng view/action + business permission, không expose raw clinical_record cho SaleBE DevTrước C11-P4-003Fallback là sales_safe_clinical_summary, không phải clinical_record_public
B-7Pilot có tablet/quầy cho Phiếu khách tự khai khôngOps + QL CNTrước pilot trainingNế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.mdprd.md; team dev dùng để estimate, tách sprint và kiểm tra traceability cùng _consistency-matrix.md trước kickoff full build.