# Quan hệ giữa các module & chiến lược join dữ liệu chéo module

> 07/7/2026 · Bản HTML: [quan-he-module.html](quan-he-module.html) · Liên quan: [Sơ đồ WS/Module](so-do-ws-module-feature-2026-07-06.md) · [Schema](giai-doan-1-mvp/schema-du-lieu-2026-07-06.md) · [Review module](giai-doan-1-mvp/review-module-split-2026-07-06.md)
> **Cập nhật 15/7:** cơ chế platform thực thi các pattern này (entity contract `ctx.entities` · service registry · outbox) — xem [Cơ chế liên kết dữ liệu installed module](lien-ket-du-lieu-module-2026-07-15.md).
>
> Trả lời câu hỏi: **action cần join dữ liệu giữa bảng của nhiều module thì thực hiện thế nào?**
> Phương án rút từ ràng buộc thực của OpenGate + đối chiếu cách Odoo / ERPNext (Frappe) / Lark giải cùng bài toán.

---

## 0. Bối cảnh OpenGate quyết định lời giải

| Đặc điểm OpenGate | Hệ quả |
|---|---|
| Mỗi workspace = **1 DB Postgres chung** cho mọi module (Level 2) | JOIN SQL chéo module **khả thi về kỹ thuật** — vấn đề là kỷ luật, không phải công nghệ |
| Module **cài/gỡ độc lập** (`.ogapp`), không có ORM registry chung kiểu Odoo | Không thể join "tự do" — bảng module khác có thể **vắng mặt** hoặc **đổi schema** khi nâng version |
| Manifest v2 đã có `depends` (thứ tự cài) + **`scopes`** (`'module:feature:readonly'`) | Đã có sẵn chỗ **khai báo hợp thức** quyền đọc chéo — chỉ thiếu quy ước thực thi |
| Đã chốt: **không FK cứng xuyên module**, tham chiếu bằng khóa nghiệp vụ (`ma_kh, ma_gv, ma_xe, ma_dk, rfid, seri_dat`) | Join theo khóa nghiệp vụ, không theo id nội bộ |

## 1. Bản đồ quan hệ giữa các module

```
                    ┌────────── catalog (danh mục — mọi module depends) ──────────┐
                    ▼               ▼               ▼               ▼             ▼
   crm ──convert──▶ student ◀──ma_kh── course   teacher   vehicle ──seri_dat──▶ dat
                    ▲    ▲            ▲  ▲         ▲  ▲       ▲                  │
        (ma_dk,ma_kh,ma_csdt)         │  └─ma_gv───┘  └ma_xe──┘                  │
                    │    │            │         (enrollment — phân công)         │
   tuition ─────────┘    │            │                                          │
     │ callback: da_thu_dot1          │◀────────── callback: da_bao_so ──────────│
     ▼                                │                                          ▼
   finance (v1.5)                   report ◀───── đọc chéo: student+course+dat (đối soát)
                                                                                 ▲
   payroll (v1.5) ◀── giờ dạy hợp lệ (ma_gv) ────────────────────────────────────┘
   portal / bi / notify ◀── đọc chéo & nhận yêu cầu gửi từ mọi module
```

**5 loại quan hệ** (mỗi loại một cách xử lý khác nhau — mục 3):

| # | Loại | Ví dụ | Tính chất |
|---|---|---|---|
| Q1 | Tham chiếu **danh mục** | mọi module → `catalog` (hạng, checklist) | đọc, rất ổn định |
| Q2 | Tham chiếu **khóa nghiệp vụ** | student.`ma_kh` → course; vehicle.`seri_dat` → dat | đọc, cần enrich khi hiển thị |
| Q3 | **Consumer đọc rộng** | report, bi, payroll đọc nhiều module | đọc, khối lượng lớn, theo kỳ |
| Q4 | **Callback trạng thái** | tuition → student (`da_thu_dot1`); report → course (`da_bao_so`) | **ghi**, phải qua rule nghiệp vụ |
| Q5 | **Sự kiện / thông báo** | mọi module → notify; dat.alert → notify | ghi một chiều, fire-and-forget |

## 2. Học gì từ Odoo / ERPNext / Lark

| Hệ | Cách join chéo module | Đáng học | Rủi ro nếu bê nguyên |
|---|---|---|---|
| **Odoo** (modular monolith, 1 DB + 1 ORM registry) | Module load vào registry chung → ORM join tự do; `depends` trong manifest xếp thứ tự load; tích hợp 2 module optional bằng **bridge module** (vd `sale_stock` depends cả `sale` + `stock`) | ① **Bridge module** cho tích hợp optional × optional; ② đồ thị `depends` tường minh | Join tự do được vì có registry chung + loader — OpenGate **không có**; bê nguyên → module vỡ ngầm khi module khác nâng version |
| **ERPNext / Frappe** (DocType, 1 DB) | Link field (tham chiếu theo tên ≈ khóa nghiệp vụ) + **`fetch_from`** (copy giá trị về document **tại thời điểm lưu**); phản ứng chéo qua **hooks/doc_events**; báo cáo qua Query Report **read-only** | ① `fetch_from` = denormalize có chủ đích, hợp thức hóa "copy tại thời điểm phát sinh"; ② hooks = callback trạng thái chuẩn hóa; ③ báo cáo tách kênh read-only | Denorm tràn lan không đánh dấu → không biết cột nào là snapshot, cột nào phải sync |
| **Lark / Feishu Open Platform** (multi-app, không chung DB) | Mọi truy cập chéo qua **Open API + scopes cấp cho từng app** + **event subscription**; eventual consistency | ① `scopes` khai báo — khớp field `scopes` OpenGate đã có; ② event một chiều cho Q5; ③ app vắng mặt không làm app khác chết | Ép mọi thứ qua API trong khi **chung 1 DB** → N+1, chậm, phức tạp không cần thiết cho màn hình list |

**Kết luận tổng hợp:** OpenGate chung DB như Odoo nhưng vòng đời module rời như Lark → phương án tối ưu là **lai**: join trong DB cho đọc (như Odoo) nhưng **qua interface có hợp đồng** (tinh thần scopes của Lark), copy-tại-thời-điểm và hooks như ERPNext cho dữ liệu phát sinh & ghi chéo.

## 3. Phương án: 6 pattern theo loại action

| # | Pattern | Dùng cho | Nguồn cảm hứng |
|---|---|---|---|
| **P1** | **Contract view** — module chủ publish view read-only `<key>_v_<tên>`; consumer JOIN vào **view, không vào bảng gốc**; khai `scopes` trong manifest | Q1, Q2 — enrich list, lookup danh mục | Odoo (join chung DB) + Lark (scopes) |
| **P2** | **Copy tại thời điểm phát sinh** (fetch_from) — ghi kèm giá trị tham chiếu vào bản ghi sự kiện, **không sync ngược** | Dữ liệu lịch sử: `dat_phien_hoc` giữ `ma_gv/ma_xe` lúc phiên chạy; phiếu thu giữ số tiền lúc thu | ERPNext `fetch_from` |
| **P3** | **Service API + callback** — ghi chéo module **chỉ** qua API của module chủ; transition chạy rule + ghi lịch sử tại module chủ | Q4: tuition→student (`da_thu_dot1`), report→course (`da_bao_so`) | ERPNext hooks / Lark API |
| **P4** | **Event một chiều** (outbox nhẹ) — bắn sự kiện, không chờ kết quả; retry theo hàng đợi | Q5: mọi cảnh báo/nhắc hạn → notify | Lark event subscription |
| **P5** | **Bridge module** — tính năng cần 2 module optional thì đặt ở module thứ 3 `depends` cả hai, không nhét vào 1 trong 2 | payroll (cần dat + teacher); tương lai chip×booking | Odoo bridge module |
| **P6** | **Snapshot / materialized view** — đọc rộng theo kỳ thì chụp tại thời điểm lập (report) hoặc materialized view refresh định kỳ (bi) | Q3: báo cáo Sở, BI dashboard | ERPNext Query Report |

### Cây quyết định nhanh

```
Action cần dữ liệu module khác?
├─ Chỉ ĐỌC?
│   ├─ Hiển thị/enrich list, lookup        → P1 contract view (JOIN 1 query)
│   ├─ Giá trị gắn với sự kiện quá khứ     → P2 copy tại thời điểm (không join lại)
│   └─ Đọc rộng theo kỳ (báo cáo/BI)       → P6 snapshot / materialized view
├─ Phải GHI / đổi trạng thái module khác   → P3 service API + callback (cấm SQL trực tiếp)
├─ Chỉ thông báo cho nơi khác biết          → P4 event một chiều
└─ Tính năng nối 2 module optional          → P5 bridge module
```

## 4. Quy tắc cứng (bổ sung vào HUONG_DAN.md khi code)

1. **Không FK cứng xuyên module** — khóa nghiệp vụ (đã chốt trong review).
2. **Không JOIN vào bảng gốc module khác** — chỉ JOIN vào **contract view** `<key>_v_*` do module chủ publish trong migration của chính nó. View = public interface, version như API: thêm cột được, đổi/bỏ cột = tạo view mới `_v2`.
3. **Không ghi (INSERT/UPDATE) bảng module khác** trong bất kỳ hoàn cảnh nào — đi qua service API của module chủ để rule + lịch sử (`student_ho_so_lichsu`…) không bị bỏ qua.
4. **Khai báo phụ thuộc trong manifest**: `depends` cho phụ thuộc cài đặt; **`scopes`** (`'student:hoso:readonly'`) cho phụ thuộc đọc — làm được ngay vì schema v2 đã hỗ trợ.
5. **Chịu được module nguồn vắng mặt**: view không tồn tại → feature degrade (ẩn cột/tab), không crash. Check khi mount, không check mỗi request.
6. **Denormalize phải có chủ đích**: cột copy đặt tên/ghi chú rõ là snapshot (`*_luc_<sự kiện>` hoặc comment migration); cấm thêm cột denorm "cho tiện" (bài học `ten_kh` legacy).
7. **Không distributed transaction**: callback thất bại → retry qua outbox + job **đối soát định kỳ** (pattern reconcile đã có sẵn trong nghiệp vụ DAT/finance).
8. `catalog` là **stable tier**: mọi module được đọc view catalog trực tiếp; đổi danh mục chỉ thêm bản ghi hiệu lực mới, không sửa nghĩa bản cũ.

## 5. Áp vào các action cụ thể của hệ

| Action | Pattern | Thực hiện |
|---|---|---|
| List học viên kèm tên khóa, tên GV | **P1** | `student` JOIN `course_v_khoa_hoc` (ma_kh) + `teacher_v_giao_vien` (ma_gv); manifest student khai `scopes: ['course:course:readonly','teacher:teacher:readonly']` |
| Đối soát km/giờ DAT | **P1 + P2** | Chuẩn km/giờ đọc `catalog_v_hang_gplx`; phân công đọc `course_v_enrollment`; phiên đã import giữ nguyên `ma_gv/ma_xe` lúc chạy (P2) — lệch so với phân công hiện tại thì `hop_le=false`, không "sửa lại quá khứ" |
| Thu đủ đợt 1 → hồ sơ `da_thu_dot1` | **P3** | tuition ghi phiếu thu (bảng mình) → gọi API `student`: transition trạng thái; student ghi `student_ho_so_lichsu`. Lỗi mạng → outbox retry + job đối soát "đã thu mà chưa chuyển trạng thái" |
| Sinh báo cáo Sở XD | **P6 + P3** | `generate` = query các view student/course/dat **snapshot vào file** (immutable); sau `da_gui` → callback API course đánh dấu "đã báo Sở" (P3) |
| Lương GV theo giờ DAT | **P5 + P1** | payroll là bridge (depends dat + teacher + lms); đọc `dat_v_phien_hop_le` (view đã lọc `hop_le=true`) group theo `ma_gv` |
| App học viên xem tiến độ | **P1** | portal đọc `dat_v_doi_soat` qua service + record-scope (`ma_dk ∈ current_ma_dk_list` — mục 7.3 module) |
| Cảnh báo thiếu km/giờ → Zalo | **P4** | dat.alert bắn event vào notify (outbox); notify chọn kênh, ghi log gửi — dat không biết Zalo là gì |
| BI dashboard | **P6** | materialized view trong DB workspace, refresh theo lịch; drill-down mở bản ghi gốc bằng deep-link sang module chủ |

## 6. Việc cần làm ở platform để chạy phương án này

- [ ] **Quy ước tên view**: `<key>_v_<tên>` + đăng ký view trong migration của module chủ (idempotent `CREATE OR REPLACE VIEW`).
- [ ] SDK: helper `ctx.views.exists('course_v_khoa_hoc')` cho degrade (rule 5); enforce `scopes` khi module đọc chéo (hiện `scopes` mới validate hình dạng, chưa enforce).
- [ ] **Outbox nhẹ**: bảng `<key>_outbox` per-module + worker retry trong host — đủ cho P3/P4, chưa cần message broker.
- [ ] Định nghĩa contract view đợt đầu (MVP): `catalog_v_hang_gplx`, `course_v_khoa_hoc`, `course_v_enrollment`, `teacher_v_giao_vien`, `vehicle_v_xe`, `dat_v_phien_hop_le`, `dat_v_doi_soat`, `student_v_hoso` (đã lọc theo scope caller ở tầng service).
- [ ] Ghi quy tắc mục 4 vào `HUONG_DAN.md` của `ws_suns` (đã có sẵn việc này trong [review module](giai-doan-1-mvp/review-module-split-2026-07-06.md) §4 — gộp chung).


---

## 7. Cập nhật 08/7 — built-in thay module kế hoạch

Sau khảo sát 7 built-in module của WS ([review core §1b](core/core-review-2026-07-07.md)):

| Trước | Nay |
|---|---|
| Module mới `hr`, `payroll` (v1.5) | **Bỏ** → [hrm built-in](core/hrm-builtin-2026-07-08.md) + feature `timesheet-dat` (bridge: dat+teacher+lms) |
| Module mới `crm` (v1.5) | **Bỏ** → [crm built-in](core/crm-builtin-2026-07-08.md) + feature `convert` (P3 callback) + `commission` (mốc da_thu_dot1) |
| Danh mục phòng ban/chức danh tự tạo | [org built-in](core/org-builtin-2026-07-08.md) — nguồn duy nhất; data-scope `current_dept_id` đọc từ đây |
| GV riêng lẻ | **teacher ↔ hrm.Employee 1-1** (mã NV) |
| Adapter rải trong module | [workflow.connector](core/workflow-builtin-2026-07-08.md) — két adapter + credential một cửa |
| v1.5 = 7 module mới | v1.5 = **4 module mới** (lms, finance, fleet, portal) + 3 mở rộng built-in · workplace disabled |

Pattern P1–P6 và quy tắc cứng **không đổi** — built-in tuân cùng quy ước (contract view `hrm_v_*`, `org_v_*`; ghi chéo qua service API).
