Chuyển tới nội dung chính

Mô hình dữ liệu

Tài liệu kỹ thuật

Trang này dành cho người lập trình. Người dùng cuối không cần đọc.

Nguyên tắc thiết kế

Cơ sở dữ liệu phải chịu được việc mở rộng mà không phải sửa dữ liệu đã có. Hai nguyên tắc áp dụng:

1. Mở rộng bằng cách THÊM, không phải SỬA. Chỗ nào biết chắc sẽ đi từ một lên nhiều thì làm bảng nối ngay từ đầu, kể cả khi bản dùng thử chỉ cần một.

2. Ràng buộc toàn vẹn là thứ bảo vệ khả năng mở rộng. Dữ liệu bẩn mới là thứ làm chuyển đổi về sau đau đớn, không phải hình dạng bảng. Bảng nối cho phép hai người phụ trách trùng nhau thì đến ngày mở nhiều người phụ trách vẫn phải dọn dữ liệu trước.

Bốn chỗ được thiết kế sẵn cho tương lai dù bản dùng thử chưa cần:

ChỗBản dùng thửVề sau
task_assignees là bảng nốiChỉ mục ép đúng 1 người phụ trách chínhBỏ chỉ mục là có nhiều người phụ trách
tasks.parent_id tự trỏ vào chính bảngLuôn để rỗngDùng được ngay cho việc con nhiều cấp
user_departments là bảng nốiMỗi người đúng 1 dòngThêm dòng là hỗ trợ kiêm nhiệm
notification_deliveries tách khỏi notificationsChỉ có kênh trong phần mềmThêm dòng là gửi thêm qua Zalo

Hệ quản trị

PostgreSQL 16.

Lý do chọnGiải thích
Kiểu JSONBLưu dữ liệu chưa định hình được, không phải đổi cấu trúc bảng
Chỉ mục có điều kiệnĐánh chỉ mục riêng cho việc chưa xong, bảng lớn vẫn nhanh
Ràng buộc kiểm tra mạnhÉp quy tắc nghiệp vụ ngay ở tầng dữ liệu
Miễn phí, chạy tốt trên máy chủ nhỏPhù hợp quy mô một văn phòng

Khoá chính dùng bigint tự tăng.

Múi giờ — bắt buộc đặt trước khi tạo bảng

ALTER DATABASE adang SET TimeZone = 'Asia/Ho_Chi_Minh';

Ảnh máy chủ PostgreSQL mặc định chạy giờ UTC. Nếu để mặc định, mọi phép so sánh giữa due_date (kiểu date) và submitted_at (kiểu timestamptz) sẽ lệch 7 tiếng, khiến BR-08 tính sai ai trễ hạn.

Định nghĩa chuẩn: hạn hoàn thành nghĩa là hết ngày due_date theo giờ Việt Nam. Mọi truy vấn liên quan phải viết tường minh, không dựa vào ép kiểu ngầm:

-- Việc trễ hạn
WHERE due_date IS NOT NULL
AND status NOT IN ('done','cancelled')
AND now() >= ((due_date + 1)::timestamp AT TIME ZONE 'Asia/Ho_Chi_Minh');

-- Nhân viên nộp đúng hạn (BR-08, trường hợp 2)
WHERE submitted_at < ((due_date + 1)::timestamp AT TIME ZONE 'Asia/Ho_Chi_Minh');

Sơ đồ quan hệ

organizations ──┬── departments ──┐ (tự trỏ: Trường → Phòng → Tổ)
│ │ │
│ ├────────┴── user_departments ── users ──┬── user_sessions
│ │ │ │
│ │ roles │
│ │ │
└────── tasks ──┐ (tự trỏ: việc → việc con) │
│ │ │
├──────┴── task_assignees ────────────────┤
├── task_comments ────────────────────────┤
├── task_attachments ─────────────────────┤
├── task_status_history ──────────────────┤
└── notifications ── notification_deliveries

activity_log (ghi vết mọi thay đổi, không khoá ngoại cứng)

Nhiều trường dùng chung hệ thống

Khách hàng là cán bộ ở sở, nên khả năng nhiều trường cùng dùng một hệ thống là kịch bản thật chứ không phải giả định xa vời.

Cách rẻ nhất là đặt organization_id ngay từ bản dùng thử, với đúng một dòng trong bảng organizations. Chi phí lúc này gần bằng không. Nếu để sau mới thêm thì phải điền ngược giá trị cho toàn bộ dữ liệu cũ và đổi ràng buộc duy nhất trên email — đó đúng là đợt chuyển đổi mà thiết kế này muốn tránh.

organizations

CộtKiểuGhi chú
idbigserial
namevarchar(255)Tên trường
codevarchar(50)Duy nhất, dùng trong đường dẫn
statusvarchar(20)
created_at, updated_attimestamptz

organization_id là cột bắt buộc trên users, departmentstasks. Ba bảng này là gốc của mọi truy vấn phân quyền.

Quy tắc bắt buộc khi lập trình

Phạm vi của Ban Giám hiệu là "toàn trường", không phải "không lọc gì". Ngay cả khi hệ thống chỉ có một trường, tầng phân quyền vẫn phải giới hạn theo organization_id của người đăng nhập. Viết "BGH thì bỏ qua bộ lọc" là lỗ hổng ngủ đông: ngày thêm trường thứ hai, Ban Giám hiệu trường A sẽ nhìn thấy toàn bộ công việc của trường B.

Cây phòng ban

Bảng departments tự trỏ vào chính nó qua parent_id:

Trường THPT X (parent_id = NULL, cấp 0)
├── Văn phòng Hành chính (cấp 1) ← bản dùng thử triển khai ở đây
├── Phòng Đào tạo (cấp 1)
│ ├── Tổ Toán (cấp 2)
│ └── Tổ Văn (cấp 2)
└── Phòng Tài vụ (cấp 1)

Cấu trúc này cho phép mở ra phòng ban khác và thêm tổ chuyên môn dưới phòng mà không đổi bảng — chỉ là thêm dòng dữ liệu.

Duyệt cây bằng truy vấn đệ quy sẵn có của PostgreSQL:

WITH RECURSIVE sub AS (
SELECT id FROM departments WHERE id = $1
UNION ALL
SELECT d.id FROM departments d JOIN sub ON d.parent_id = sub.id
)
SELECT id FROM sub;
Vì sao không dùng ltree

Bản thảo đầu dùng kiểu ltree để truy vấn cả nhánh trong một lần. Đã bỏ, vì ba lý do:

  1. Prisma không hỗ trợ ltree — cột sẽ thành Unsupported, phải viết truy vấn thô cho mọi phép tính phạm vi. Mất đúng cái lợi đã chọn Prisma vì nó.
  2. Hai nguồn sự thật cho một cấu trúc. pathparent_id phải luôn khớp nhau, cần trigger đồng bộ cả cây con mỗi lần đổi cha. Quên một lần là phân quyền sai âm thầm, không báo lỗi.
  3. Bảng này chỉ có khoảng 5–15 dòng. Truy vấn đệ quy chạy dưới một phần nghìn giây.

Khi nào cây thật sự sâu và bảng lớn, thêm cột path là thao tác thêm — đúng nguyên tắc, và lúc đó làm được dạng cột tự sinh.

Ràng buộc chống vòng lặp:

ALTER TABLE departments ADD CONSTRAINT chk_dept_no_self_parent
CHECK (parent_id IS NULL OR parent_id <> id);

Ràng buộc này chỉ chặn phòng tự làm cha chính nó. Chu trình dài hơn (A → B → A) phải kiểm ở tầng dịch vụ mỗi lần đổi parent_id, vì PostgreSQL không diễn đạt được ràng buộc đó bằng CHECK.

Chi tiết các bảng

users

CộtKiểuGhi chú
idbigserial
organization_idbigintBắt buộc
emailvarchar(255)Xem ràng buộc duy nhất bên dưới
password_hashvarchar(255)Mã hoá bằng bcrypt
full_namevarchar(255)
phonevarchar(20)Cho phép rỗng
zalo_user_idvarchar(64)Cho phép rỗng, để sẵn cho giai đoạn sau
statusvarchar(20)active hoặc disabled
last_login_attimestamptz
created_at, updated_attimestamptz
-- Email không phân biệt hoa thường, duy nhất trong phạm vi một trường
CREATE UNIQUE INDEX uq_users_org_email
ON users (organization_id, lower(email));
ALTER TABLE users ADD CONSTRAINT chk_user_status
CHECK (status IN ('active','disabled'));

So sánh varchar trong PostgreSQL phân biệt hoa thường. Không chuẩn hoá thì [email protected][email protected] thành hai tài khoản khác nhau, và cán bộ gõ email viết hoa chữ đầu — điện thoại tự động viết hoa — sẽ không đăng nhập được dù mật khẩu đúng. Tầng dịch vụ phải chuẩn hoá bằng lower(trim(email)) cả khi tạo lẫn khi đăng nhập.

Tài khoản bị khoá thì đổi status, không xoá dòng.

departments

CộtKiểuGhi chú
idbigserial
organization_idbigintBắt buộc
parent_idbigintTự trỏ. Rỗng nghĩa là gốc cây
namevarchar(255)
statusvarchar(20)
created_at, updated_attimestamptz

roles

CộtKiểuGhi chú
idbigserial
codevarchar(50)bgh, truong_phong, nhan_vienduy nhất
namevarchar(100)Tên hiển thị tiếng Việt
ALTER TABLE roles ADD CONSTRAINT uq_roles_code UNIQUE (code);

Thiếu ràng buộc duy nhất này thì hai dòng cùng mã truong_phong sẽ khiến phân quyền hoạt động tuỳ theo dòng nào được đọc trước.

Vai trò để trong bảng riêng thay vì kiểu liệt kê cố định. Thêm vai trò mới chỉ là thêm một dòng.

user_departments

CộtKiểuGhi chú
user_idbigint
department_idbigint
role_idbigint
is_primarybooleanNOT NULL, mặc định true
created_attimestamptz
ALTER TABLE user_departments ADD PRIMARY KEY (user_id, department_id);
CREATE UNIQUE INDEX uq_user_primary_dept
ON user_departments (user_id) WHERE is_primary = true;

Khoá chính là cặp (user_id, department_id), không gồm role_id. Nếu đưa role_id vào khoá chính thì một người có thể vừa là nhan_vien vừa là truong_phong trong cùng một phòng — tầng phân quyền nhận về hai dòng và hành vi phụ thuộc dòng nào đọc trước. Đó là lỗ hổng nâng quyền im lặng. Ngoài ra đổi vai trò sẽ phải xoá rồi thêm lại, mất mốc thời gian.

Bản dùng thử: mỗi người đúng một dòng, is_primary = true. Giao diện chỉ cho chọn một phòng.

Về sau: hỗ trợ kiêm nhiệm thì thêm dòng thứ hai với is_primary = false. Cấu trúc không đổi.

tasks

CộtKiểuGhi chú
idbigserial
organization_idbigintBắt buộc
department_idbigintPhòng sở hữu việc
parent_idbigintTự trỏ. Bản dùng thử luôn rỗng
titlevarchar(500)
descriptiontext
statusvarchar(30)new, in_progress, pending_review, done, cancelled
priorityvarchar(20)normal, urgent
due_datedateCho phép rỗng theo BR-04
board_positionintegerCho phép rỗng. Để sẵn cho bảng kéo thả (FR-VIEW-05)
created_bybigintNgười giao việc
submitted_attimestamptzLúc chuyển sang Chờ xác nhận
completed_attimestamptzLúc được xác nhận hoàn thành
cancelled_attimestamptz
cancel_reasontext
lock_versionintegerĐếm số lần sửa, phục vụ chống ghi đè khi hai người cùng thao tác
metadatajsonbDữ liệu phát sinh chưa định hình
created_at, updated_attimestamptz

Giữ department_id trên chính công việc thay vì suy ra từ phòng của người phụ trách. Nhờ vậy nhân viên chuyển phòng thì việc cũ vẫn thuộc phòng cũ, đúng như quy định trong ma trận phân quyền.

submitted_at phục vụ BR-08: có cột này mới phân biệt được nhân viên nộp muộn với người giao việc duyệt muộn.

Quy tắc bắt buộc về submitted_at

Khi người giao trả lại việc (Chờ xác nhận → Đang làm), phải đặt submitted_at về rỗng.

Giữ nguyên giá trị cũ sẽ khiến một việc đã bị trả lại, đang nằm ở nhân viên và đã quá hạn, bị đếm nhầm là chờ người giao duyệt. Hậu quả: ô đếm trên màn hình tổng quan sai, và thông báo quá hạn chỉ gửi cho trưởng phòng còn nhân viên không nhận.

Đây là lỗi âm thầm — không báo lỗi, không ai phát hiện cho tới khi nhìn báo cáo cuối tháng.

Nhân viên nộp lại lần sau thì ghi đè submitted_at bằng thời điểm mới.

Dữ liệu nào cần lọc hoặc thống kê thì phải làm thành cột thật, không nhét vào metadata.

task_assignees

CộtKiểuGhi chú
task_idbigint
user_idbigint
is_primarybooleanNOT NULL, mặc định true
assigned_bybigintAi giao
assigned_attimestamptz
ALTER TABLE task_assignees ADD PRIMARY KEY (task_id, user_id);
ALTER TABLE task_assignees ALTER COLUMN is_primary SET NOT NULL;
ALTER TABLE task_assignees ALTER COLUMN is_primary SET DEFAULT true;

-- Ép quy tắc BR-02: mỗi việc nhiều nhất một người phụ trách chính
CREATE UNIQUE INDEX uq_task_primary_assignee
ON task_assignees (task_id) WHERE is_primary = true;

Bốn ràng buộc trên đều cần thiết, thiếu một cái là mở một lỗ:

ThiếuHậu quả
Khoá chính (task_id, user_id)Cùng một người gán hai lần vào một việc. Màn hình Việc của tôi hiện trùng, báo cáo đếm gấp đôi
is_primary NOT NULLGiá trị rỗng không lọt vào chỉ mục có điều kiện, quy tắc một người bị vô hiệu
is_primary DEFAULT trueChèn thiếu cột sẽ tạo dòng không phải người phụ trách chính
Chỉ mục duy nhấtMột việc có nhiều người phụ trách chính

Chỉ mục này ép "nhiều nhất một", không ép "đúng một". Một việc có 0 người phụ trách vẫn hợp lệ với cơ sở dữ liệu, và việc đó sẽ không hiện trong màn hình của ai, không ai nhận thông báo — rơi vào im lặng. Cơ sở dữ liệu không diễn đạt được ràng buộc này, nên tầng dịch vụ phải tạo công việc và người phụ trách trong cùng một giao dịch.

Tương tự, cơ sở dữ liệu không chặn được việc gán người của phòng khác. Tầng dịch vụ phải kiểm task.department_id nằm trong phạm vi phòng của người được gán. Bỏ qua bước này là đường rò dữ liệu giữa các phòng, vì phạm vi của nhân viên tính theo task_assignees chứ không theo phòng ban.

Khi mở nhiều người phụ trách, chỉ cần bỏ uq_task_primary_assignee. Khoá chính vẫn giữ dữ liệu sạch.

task_comments

CộtKiểuGhi chú
idbigserial
task_idbigint
user_idbigint
contenttext
deleted_attimestamptzXoá mềm, giữ vết
created_at, updated_attimestamptz

task_attachments

CộtKiểuGhi chú
idbigserial
task_idbigint
uploaded_bybigint
file_namevarchar(500)Tên gốc người dùng thấy
storage_pathvarchar(1000)Đường dẫn thật trên máy chủ
file_sizebigintByte
mime_typevarchar(100)
deleted_attimestamptzXoá mềm
deleted_bybigint
created_attimestamptz

Tệp đính kèm cũng xoá mềm như bình luận. Xoá cứng sẽ mất dấu vết việc tệp từng tồn tại, trái với BR-03NFR-SEC-06. Trường hợp cần xoá thật — ví dụ đính nhầm tài liệu nhạy cảm — là thao tác riêng của người quản trị, có ghi vết.

Tách tên hiển thị khỏi đường dẫn lưu trữ để sau này đổi nơi lưu mà không đổi bảng.

task_status_history

CộtKiểuGhi chú
idbigserial
task_idbigint
from_status, to_statusvarchar(30)Rỗng nếu là lúc vừa tạo
from_assignee_id, to_assignee_idbigintPhục vụ BR-09
from_due_date, to_due_datedateGhi lại mỗi lần đổi hạn
changed_bybigint
notetext
created_attimestamptz

Hai cột from_due_date / to_due_date quan trọng hơn vẻ ngoài của chúng. Không có chúng thì cuối tháng một người có thể sửa hạn của tám việc trễ sang tháng sau, con số "Trễ hạn" về không, và không nơi nào truy vấn được điều đó đã xảy ra. Đây cũng là dữ liệu nền cho chức năng xin gia hạn ở giai đoạn sau.

notifications và notification_deliveries

Tách làm hai bảng: nội dung thông báo và từng lần gửi qua mỗi kênh.

notifications:

CộtKiểuGhi chú
idbigserial
user_idbigintNgười nhận
task_idbigintCho phép rỗng
typevarchar(50)Tám loại, xem ràng buộc bên dưới
titlevarchar(500)
bodytext
read_attimestamptzRỗng nghĩa là chưa đọc
dedup_keyvarchar(200)Khoá chống trùng
created_attimestamptz

notification_deliveries:

CộtKiểuGhi chú
idbigserial
notification_idbigint
channelvarchar(20)in_app. Sau thêm zalo, email
sent_attimestamptzRỗng nghĩa là chưa gửi
errortextLý do gửi hỏng
CREATE UNIQUE INDEX uq_notif_dedup
ON notifications (dedup_key) WHERE dedup_key IS NOT NULL;
ALTER TABLE notification_deliveries
ADD CONSTRAINT uq_delivery UNIQUE (notification_id, channel);

Vì sao tách hai bảng. Nếu để channel ngay trên dòng thông báo thì gửi cùng một sự kiện qua cả trong phần mềm lẫn Zalo phải tạo hai dòng — người dùng thấy hai thông báo trùng, đánh dấu đã đọc cái này không tắt cái kia, và số đếm chưa đọc bị nhân đôi. Trạng thái đã đọc thuộc về người, không thuộc về kênh. Đây chính là mẫu bảng nối đã áp dụng cho task_assignees.

Vì sao cần dedup_key. Tác vụ quét chạy 15 phút một lần. Không có khoá chống trùng thì mỗi việc quá hạn sinh 96 thông báo mỗi ngày cho mỗi người liên quan. Một việc bị quên trong 30 ngày tạo khoảng 5.700 dòng, ô đếm chưa đọc thành con số vô nghĩa và người dùng tắt thông báo ngay tuần đầu.

-- Tác vụ định kỳ chèn theo cách này
INSERT INTO notifications (..., dedup_key)
VALUES (..., 'overdue:task=42:user=7:2026-08-05')
ON CONFLICT (dedup_key) DO NOTHING;

user_sessions

CộtKiểuGhi chú
idbigserial
user_idbigint
token_hashvarchar(255)Duy nhất
last_seen_attimestamptzCập nhật mỗi lần có thao tác
expires_attimestamptz
revoked_attimestamptz
ip_addressinet
created_attimestamptz

Bảng này cần cho ba việc mà mã thông hành đứng một mình không làm được:

Yêu cầuVì sao cần bảng
NFR-SEC-03 — hết hạn sau 8 giờ không thao tácMã thông hành có hạn cố định, không biết người dùng có đang thao tác hay không. Cần last_seen_at
Đăng xuất thật sựKhông có bảng thì mã thông hành bị lộ vẫn dùng được tới khi hết hạn, dù đã bấm đăng xuất
Khoá tài khoản có hiệu lực ngayKhoá tài khoản phải đá được phiên đang mở ra

activity_log

CộtKiểuGhi chú
idbigserial
actor_idbigintAi thao tác. Cho phép rỗng — tác vụ định kỳ không có người thao tác
entity_typevarchar(50)task, user, department
entity_idbigint
actionvarchar(50)create, update, delete, login
changesjsonbGiá trị trước và sau
ip_addressinet
created_attimestamptz

Không đặt khoá ngoại cứng vào entity_id để ghi được cho bảng bất kỳ, kể cả bảng thêm về sau.

Danh sách cột không bao giờ được ghi vào changes

password_hash, token_hash, và mọi giá trị bí mật khác.

Cách cài đặt tự nhiên nhất là so sánh toàn bộ dòng trước và sau rồi ghi phần khác nhau. Làm vậy thì PATCH /users/:id và đặt lại mật khẩu sẽ đẩy mã băm mật khẩu vào một bảng không có phân quyền theo phòng ban. Phải lọc danh sách cột này trước khi ghi.

Chỉ mục

-- Danh sách việc theo phòng và trạng thái (truy vấn hay dùng nhất)
CREATE INDEX idx_tasks_dept_status ON tasks (department_id, status);

-- Việc trễ hạn: chỉ đánh chỉ mục việc chưa xong, bảng lớn vẫn nhẹ
CREATE INDEX idx_tasks_due_open ON tasks (due_date)
WHERE status NOT IN ('done','cancelled') AND due_date IS NOT NULL;

-- Màn hình Việc của tôi
CREATE INDEX idx_assignees_user ON task_assignees (user_id);

-- Khoá ngoại phía tham chiếu — PostgreSQL KHÔNG tự tạo
CREATE INDEX idx_comments_task ON task_comments (task_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_attach_task ON task_attachments (task_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_history_task ON task_status_history (task_id, created_at);

-- Thông báo
CREATE INDEX idx_notif_unread ON notifications (user_id) WHERE read_at IS NULL;
CREATE INDEX idx_notif_user_time ON notifications (user_id, created_at DESC);

-- Ghi vết: bảng lớn nhất hệ thống
CREATE INDEX idx_alog_entity ON activity_log (entity_type, entity_id, created_at DESC);
CREATE INDEX idx_alog_actor ON activity_log (actor_id, created_at DESC);

-- Phiên đăng nhập
CREATE INDEX idx_sessions_user ON user_sessions (user_id) WHERE revoked_at IS NULL;

PostgreSQL không tự tạo chỉ mục cho phía tham chiếu của khoá ngoại. Thiếu chúng thì mỗi lần mở màn hình chi tiết một công việc là ba lần quét toàn bảng. activity_log ghi vết mọi thao tác nên sẽ là bảng lớn nhất — không có chỉ mục thì mọi truy vấn truy vết đều quét toàn bộ.

Hai chỉ mục có điều kiện giữ hiệu năng khi dữ liệu tích luỹ nhiều năm: việc đã xong chiếm phần lớn số dòng nhưng không bao giờ bị hỏi tới trong hai truy vấn đó.

Ràng buộc ép quy tắc nghiệp vụ

Đặt ràng buộc ngay ở cơ sở dữ liệu, không chỉ kiểm tra trong mã nguồn. Mã nguồn có thể sót đường đi, cơ sở dữ liệu thì không.

ALTER TABLE tasks ADD CONSTRAINT chk_task_status
CHECK (status IN ('new','in_progress','pending_review','done','cancelled'));
ALTER TABLE tasks ADD CONSTRAINT chk_task_priority
CHECK (priority IN ('normal','urgent'));
ALTER TABLE tasks ADD CONSTRAINT chk_done_has_time
CHECK (status <> 'done' OR completed_at IS NOT NULL);
ALTER TABLE tasks ADD CONSTRAINT chk_cancelled_has_time
CHECK (status <> 'cancelled' OR cancelled_at IS NOT NULL);
ALTER TABLE tasks ADD CONSTRAINT chk_no_self_parent
CHECK (parent_id IS NULL OR parent_id <> id);
ALTER TABLE tasks ALTER COLUMN department_id SET NOT NULL;
ALTER TABLE tasks ALTER COLUMN lock_version SET DEFAULT 0;

ALTER TABLE notifications ADD CONSTRAINT chk_notif_type
CHECK (type IN ('task_assigned','task_reassigned','task_returned',
'task_completed','task_cancelled','comment_added',
'due_soon','overdue'));
ALTER TABLE notification_deliveries ADD CONSTRAINT chk_channel
CHECK (channel IN ('in_app','zalo','email'));
ALTER TABLE departments ADD CONSTRAINT chk_dept_status
CHECK (status IN ('active','disabled'));

Mọi khoá ngoại đặt ON DELETE RESTRICT, phù hợp với BR-03: hệ thống không xoá vĩnh viễn dữ liệu nào.

Cách mở rộng cho 6 chức năng giai đoạn sau

Đây là cách kiểm chứng NFR-MAINT-03. Bảng liệt kê đúng 6 chức năng mang mức ưu tiên "Giai đoạn sau" trong danh sách chức năng.

Chức năngViệc cần làmChuyển dữ liệu cũ
FR-TASK-10Danh sách mục con trong việcThêm 2 bảng task_checklists, task_checklist_itemsKhông
FR-TASK-11Xin gia hạnThêm 1 bảng task_extension_requests. Lịch sử đổi hạn đã có sẵn trong task_status_historyKhông
FR-TASK-12Việc con, duyệt nhiều bướcDùng tasks.parent_id có sẵnKhông
FR-VIEW-05Bảng kéo thảDùng cột tasks.board_position có sẵnKhông
FR-VIEW-06Báo cáo khối lượng theo ngườiTruy vấn trên task_status_history có sẵnKhông
FR-NOTI-04Thông báo qua ZaloThêm dòng channel = 'zalo' vào notification_deliveriesKhông

Ngoài 6 chức năng trên, việc nới quy tắc BR-02 cho nhiều người phụ trách cũng không cần chuyển dữ liệu: chỉ bỏ chỉ mục uq_task_primary_assignee. Đây không phải một chức năng trong danh sách 26 mã, mà là hệ quả của việc đổi quy tắc nghiệp vụ.

Những gì cố tình chưa làm

Không làmVì saoKhi nào xem lại
Kiểu ltree cho cây phòng banPrisma không hỗ trợ, bảng chỉ 5–15 dòngKhi cây vượt 5 cấp hoặc trên 500 phòng ban
Tìm kiếm bỏ dấu tiếng ViệtBản dùng thử chấp nhận tìm có dấu bằng ILIKEKhi người dùng phàn nàn, thêm unaccent + pg_trgm
Phân vùng bảng activity_log theo thángChưa đủ dữ liệu để cầnKhi vượt 10 triệu dòng
Khoá tài khoản sau N lần đăng nhập saiBản dùng thử chặn tạm theo địa chỉ mạng ở tầng ứng dụng, đủ cho mạng nội bộKhi mở ra ngoài mạng nội bộ — khi đó thêm cột failed_login_countlocked_until vào users

Quy ước chuyển đổi cấu trúc

  • Mỗi lần đổi cấu trúc là một tệp riêng, đánh số tăng dần, không sửa tệp đã chạy
  • Thêm cột mới phải cho phép rỗng hoặc có giá trị mặc định, để chạy được khi hệ thống đang hoạt động
  • Không xoá cột ngay: đánh dấu ngừng dùng, để ít nhất một đợt phát hành rồi mới xoá
  • Mỗi tệp phải viết được phần quay lui