Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Bài 33 — Common Patterns — Inventory, Booking, Approval

Data Modeling and ERD Mastery Bài 33/60

Mở đầu — vì sao bài này quan trọng

Sau hơn 30 bài học, bạn đã nắm vững các viên gạch nền tảng: entity, attribute, key, relationship, chuẩn hóa. Nhưng khi bước vào dự án thực tế, bạn sẽ nhận ra một điều thú vị: phần lớn các hệ thống nghiệp vụ không phải là những bài toán mới hoàn toàn. Chúng lặp đi lặp lại theo những "khuôn mẫu" (pattern) quen thuộc. Một sàn thương mại điện tử, một ứng dụng đặt phòng khách sạn, một hệ thống duyệt chi của phòng kế toán — dù khác nhau về bề ngoài, chúng đều dựa trên một số ít mô hình dữ liệu đã được kiểm chứng qua hàng nghìn dự án.

Bài học hôm nay tập trung vào ba pattern phổ biến và "đắt giá" nhất mà một Business Analyst (BA) hay nhà thiết kế dữ liệu gần như chắc chắn sẽ gặp: Inventory (quản lý tồn kho), Booking (đặt chỗ/đặt lịch), và Approval (quy trình duyệt). Khi bạn thuộc lòng cấu trúc của chúng, bạn sẽ thiết kế nhanh hơn, tránh được những lỗi kinh điển, và quan trọng hơn — bạn nói cùng "ngôn ngữ" với các kỹ sư và những BA giàu kinh nghiệm khác. Đây chính là khác biệt giữa một người "vẽ ERD theo cảm tính" và một người thiết kế dữ liệu chuyên nghiệp.

Khái niệm cốt lõi

Pattern trong mô hình hóa dữ liệu là một giải pháp mẫu cho một bài toán nghiệp vụ lặp lại. Nó không phải là code copy-paste, mà là một cấu trúc entity-relationship đã được tối ưu, kèm theo những "bài học xương máu" về việc nên và không nên làm gì. Chúng ta sẽ đi sâu vào ba pattern.

Pattern 1 — Inventory (Quản lý tồn kho)

Bài toán cốt lõi của tồn kho là trả lời câu hỏi: "Còn bao nhiêu hàng?" và "Hàng đang ở đâu?". Mức độ phức tạp tăng dần.

Mức 1 — Kho đơn (single warehouse). Khi chỉ có một kho duy nhất, bạn có thể lưu số lượng tồn ngay trên bản ghi sản phẩm:

CREATE TABLE product (
  id            BIGINT PRIMARY KEY,
  name          VARCHAR(200) NOT NULL,
  sku           VARCHAR(50) UNIQUE,
  stock_quantity INT NOT NULL DEFAULT 0 CHECK (stock_quantity >= 0)
);

Đơn giản, nhưng nó chỉ đúng khi doanh nghiệp thật sự chỉ có một điểm chứa hàng. Đừng khinh thường mức này — rất nhiều shop nhỏ trên Shopee chỉ cần đến đây.

Mức 2 — Đa kho (multi-warehouse). Khi có nhiều kho (Hà Nội, Đà Nẵng, TP.HCM), số lượng tồn không còn là thuộc tính của sản phẩm nữa, mà là thuộc tính của mối quan hệ giữa sản phẩm và kho. Đây là một bảng nối (junction table) có thuộc tính bổ sung:

CREATE TABLE warehouse (
  id   BIGINT PRIMARY KEY,
  name VARCHAR(200) NOT NULL,
  city VARCHAR(100)
);

CREATE TABLE inventory ( product_id BIGINT NOT NULL REFERENCES product(id), warehouse_id BIGINT NOT NULL REFERENCES warehouse(id), quantity INT NOT NULL DEFAULT 0 CHECK (quantity >= 0), PRIMARY KEY (product_id, warehouse_id) );

Mức 3 — Sổ cái giao dịch tồn kho (inventory ledger). Đây là điểm mà nhiều BA mới bỏ sót. Lưu một con số quantity duy nhất giống như chỉ ghi số dư tài khoản mà không có sao kê. Khi xảy ra sai lệch, bạn không biết vì sao. Pattern chuyên nghiệp là ghi lại mọi biến động (nhập, xuất, điều chỉnh) thành các dòng giao dịch, và số tồn hiện tại là tổng cộng dồn của chúng:

CREATE TABLE inventory_movement (
  id           BIGINT PRIMARY KEY,
  product_id   BIGINT NOT NULL REFERENCES product(id),
  warehouse_id BIGINT NOT NULL REFERENCES warehouse(id),
  quantity_change INT NOT NULL,        -- dương: nhập, âm: xuất
  reason       VARCHAR(50) NOT NULL,   -- 'purchase', 'sale', 'adjustment', 'return'
  reference_id BIGINT,                 -- trỏ về đơn hàng / phiếu nhập gốc
  created_at   TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Số tồn = SUM(quantity_change). Bảng inventory ở mức 2 lúc này trở thành một bảng tổng hợp (snapshot) để truy vấn nhanh, được cập nhật từ ledger.

Pattern 2 — Booking (Đặt chỗ / Đặt lịch)

Bài toán cốt lõi của booking là tránh double-booking — hai khách cùng đặt một tài nguyên (phòng, ghế, slot thời gian) cho cùng khoảng thời gian. Cấu trúc xương sống gồm ba phần: tài nguyên (resource), khoảng thời gian (time period), và bản ghi đặt chỗ (booking).

CREATE TABLE room (
  id        BIGINT PRIMARY KEY,
  hotel_id  BIGINT NOT NULL,
  room_no   VARCHAR(20),
  room_type VARCHAR(50)
);

CREATE TABLE booking ( id BIGINT PRIMARY KEY, room_id BIGINT NOT NULL REFERENCES room(id), customer_id BIGINT NOT NULL, check_in DATE NOT NULL, check_out DATE NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'confirmed', -- confirmed, cancelled, completed CHECK (check_out > check_in) );

Điểm tinh tế nhất của pattern này là ràng buộc không chồng lấn thời gian (no-overlap constraint). Bạn không thể chỉ dùng UNIQUE(room_id, check_in) vì hai khoảng thời gian có thể chồng nhau mà không trùng ngày bắt đầu. Trên PostgreSQL, người ta dùng exclusion constraint với kiểu daterange. Trên các DBMS không hỗ trợ, ràng buộc này phải được kiểm tra ở tầng ứng dụng kèm khóa giao dịch (transaction lock) để tránh race condition. BA cần nêu rõ quy tắc này trong tài liệu, vì nó là nguồn gốc của vô số lỗi sản xuất.

Pattern 3 — Approval (Quy trình duyệt)

Bài toán cốt lõi: một đối tượng (đơn nghỉ phép, phiếu chi, hợp đồng) phải đi qua nhiều bước duyệt, mỗi bước do một người/vai trò quyết định. Pattern này tách trạng thái hiện tại khỏi lịch sử duyệt.

CREATE TABLE expense_request (
  id          BIGINT PRIMARY KEY,
  requester_id BIGINT NOT NULL,
  amount      DECIMAL(15,2) NOT NULL,
  description TEXT,
  status      VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending, approved, rejected
  created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE approval_step ( id BIGINT PRIMARY KEY, request_id BIGINT NOT NULL REFERENCES expense_request(id), step_order INT NOT NULL, -- bước thứ mấy approver_id BIGINT NOT NULL, decision VARCHAR(20), -- approved, rejected, NULL nếu chưa duyệt comment TEXT, decided_at TIMESTAMP );

expense_request.status là trạng thái tổng hợp để hiển thị nhanh; approval_step lưu từng bước ai duyệt, khi nào, ý kiến ra sao. Đây chính là pattern audit trail (sẽ liên hệ với Bài 22). Nguyên tắc vàng: không bao giờ ghi đè quyết định cũ — mỗi quyết định là một dòng mới, bất biến.

Tình huống thực tế

Ví dụ 1 — Tiki và bài toán đa kho

Tiki vận hành mô hình TikiNOW với nhiều trung tâm phân phối tại Hà Nội, Đà Nẵng, TP.HCM, Cần Thơ. Giả sử ban đầu đội kỹ thuật lưu stock_quantity ngay trên bảng product. Mọi thứ ổn cho đến khi họ mở kho thứ hai. Lúc đó một khách ở Đà Nẵng đặt mua áo thun mã SKU-T123, hệ thống báo "còn hàng" vì tổng tồn toàn quốc là 5 chiếc — nhưng cả 5 chiếc đó đều nằm ở kho TP.HCM, dẫn đến giao hàng chậm 4 ngày thay vì 2 giờ như cam kết TikiNOW.

Diễn giải: vấn đề không nằm ở thiếu dữ liệu, mà ở việc đặt quantity sai chỗ — nó là thuộc tính của cặp (sản phẩm, kho), không phải của riêng sản phẩm. Khi tách ra bảng inventory với khóa chính kép (product_id, warehouse_id), hệ thống biết chính xác kho nào còn hàng và định tuyến đơn hàng đến kho gần khách nhất.

Bài học: Khi một con số "phụ thuộc vào hai chiều" (vừa theo sản phẩm vừa theo địa điểm), gần như chắc chắn nó thuộc về một bảng nối, không phải một cột trên entity gốc.

Ví dụ 2 — Chuỗi nha khoa và lỗi double-booking

Một chuỗi phòng khám nha khoa ở TP.HCM (giả định tên "Smile Center") triển khai hệ thống đặt lịch online. Bác sĩ Lan có lịch trống 9:00–10:00. Hai khách hàng cùng đặt vào lúc 8:59:58 và 8:59:59. Hệ thống kiểm tra "slot này đã có ai đặt chưa?" — cả hai truy vấn đều trả về "chưa", và cả hai cùng được ghi nhận. Sáng hôm sau, hai bệnh nhân ngồi chờ cùng một bác sĩ.

Diễn giải: Đây là race condition kinh điển. Việc kiểm tra rồi mới ghi (check-then-insert) không an toàn nếu không có khóa giao dịch hoặc ràng buộc chống chồng lấn ở tầng cơ sở dữ liệu. Smile Center khắc phục bằng cách dùng exclusion constraint trên khoảng thời gian, biến chính database thành "trọng tài" cuối cùng — nếu hai booking chồng nhau, lệnh INSERT thứ hai bị từ chối ngay lập tức.

Bài học: Quy tắc "không chồng lấn" phải được thực thi ở tầng dữ liệu, không thể chỉ tin vào logic ứng dụng. BA cần ghi rõ ràng buộc này như một business rule có độ ưu tiên cao.

Ví dụ 3 — Phòng kế toán Vietcombank và quy trình duyệt chi

Giả định một quy trình duyệt thanh toán nội bộ: phiếu chi dưới 50 triệu cần trưởng phòng duyệt; từ 50 triệu đến 500 triệu cần thêm giám đốc khối; trên 500 triệu cần thêm phó tổng. Đội phát triển ban đầu chỉ để một cột approved_bystatus trên bảng phiếu chi. Khi kiểm toán hỏi "phiếu 300 triệu này ai duyệt bước một, ai bước hai, mỗi người ý kiến gì", hệ thống không trả lời được vì chỉ lưu người duyệt cuối cùng.

Diễn giải: Trạng thái hiện tại (status) và lịch sử duyệt là hai khái niệm khác nhau. Bằng cách tách bảng approval_step, mỗi bước duyệt được ghi nhận độc lập kèm thời gian và bình luận. Số bước cần thiết được sinh ra dựa trên amount ngay khi tạo phiếu.

Bài học: Trong mọi quy trình duyệt nhiều cấp, hãy mô hình hóa từng bước thành một bản ghi riêng. Một cột status duy nhất chỉ đủ cho quy trình một bước.

Hướng dẫn từng bước

Khi gặp một nghiệp vụ mới, hãy áp dụng quy trình nhận diện pattern sau:

  • Nghe ra "từ khóa nghiệp vụ". "Còn bao nhiêu hàng", "nhập/xuất kho" → Inventory. "Đặt phòng/ghế/lịch hẹn", "khung giờ trống" → Booking. "Trình duyệt", "phê duyệt nhiều cấp", "ai ký" → Approval.
  • Xác định tài nguyên trung tâm. Inventory: sản phẩm. Booking: phòng/ghế/slot. Approval: đối tượng cần duyệt.
  • Hỏi về chiều phụ thuộc của con số. Số tồn phụ thuộc kho không? Nếu có → bảng nối. Đây là bước phân biệt mức 1 và mức 2.
  • Hỏi về nhu cầu lịch sử/truy vết. Có cần biết "vì sao số tồn thay đổi" hay "ai duyệt bước nào" không? Nếu có → thêm bảng ledger/audit. Nếu kiểm toán hoặc kế toán liên quan, câu trả lời gần như luôn là "có".
  • Xác định ràng buộc nghiệp vụ then chốt. Inventory: quantity >= 0. Booking: không chồng lấn thời gian. Approval: thứ tự bước, không ghi đè quyết định.
  • Tách trạng thái khỏi lịch sử. Giữ một cột status để truy vấn nhanh, nhưng luôn có bảng chi tiết bất biến đứng sau.
  • Ghi rõ ràng buộc vào data dictionary và tài liệu nghiệp vụ để kỹ sư không hiểu sai khi triển khai.

Lỗi thường gặp & mẹo

Lỗi 1 — Đặt số tồn sai vị trí. Để stock_quantity trên bảng sản phẩm rồi mới phát hiện có nhiều kho. Mẹo: ngay từ đầu hỏi "tương lai gần có khả năng mở thêm kho/chi nhánh không?". Nếu có, dùng bảng nối ngay.

Lỗi 2 — Tin rằng UNIQUE chống được double-booking. Khóa duy nhất chỉ chặn trùng giá trị tuyệt đối, không chặn chồng lấn khoảng. Mẹo: dùng exclusion constraint (PostgreSQL) hoặc khóa giao dịch + kiểm tra trong transaction.

Lỗi 3 — Cho phép số âm. Quên ràng buộc CHECK (quantity >= 0) khiến tồn kho âm sau các đợt trừ song song. Mẹo: luôn đặt CHECK ở tầng database, đừng phó mặc cho ứng dụng.

Lỗi 4 — Ghi đè quyết định duyệt. Dùng một cột approved_by và cập nhật đè khiến mất lịch sử. Mẹo: mỗi quyết định là một dòng mới (append-only), bất biến.

Lỗi 5 — Nhồi quy trình duyệt cứng vào schema. Hard-code "2 bước duyệt" trong cấu trúc bảng. Mẹo: dùng step_order linh hoạt để số bước thay đổi theo quy tắc nghiệp vụ (ví dụ theo số tiền) mà không phải sửa schema.

Mẹo chung: Pattern là điểm khởi đầu, không phải giáo điều. Một shop bán 10 đơn/ngày không cần inventory ledger; một hệ thống đặt vé máy bay thì bắt buộc. Hãy chọn mức phức tạp tương xứng với nhu cầu thực tế và khả năng mở rộng trong 1–2 năm tới.

Bài tập thực hành

Bài 1 (Inventory). Một chuỗi cửa hàng tiện lợi giả định "GreenMart" có 8 cửa hàng tại TP.HCM. Mỗi cửa hàng vừa bán hàng vừa là điểm chứa hàng nhỏ. Họ cần biết tồn kho từng mặt hàng tại từng cửa hàng và muốn truy vết mọi lần nhập/xuất để đối soát cuối tháng. Hãy vẽ ERD gồm các bảng product, store, inventory, inventory_movement, xác định khóa chính, khóa ngoại, và ít nhất hai ràng buộc CHECK.

Bài 2 (Booking). Thiết kế schema cho một sân cầu lông cho thuê theo giờ, có 6 sân, mở cửa 6:00–22:00, đặt theo slot 1 giờ. Yêu cầu: không cho phép hai khách đặt cùng sân cùng khung giờ; cho phép hủy đặt; lưu được trạng thái booking. Mô tả bằng lời cách bạn sẽ thực thi ràng buộc chống chồng lấn.

Bài 3 (Approval). Một công ty giả định có quy trình duyệt nghỉ phép: nghỉ ≤ 2 ngày chỉ cần quản lý trực tiếp; nghỉ 3–5 ngày cần thêm trưởng phòng; trên 5 ngày cần thêm giám đốc nhân sự. Thiết kế hai bảng leave_requestapproval_step. Viết bằng lời logic sinh ra số bước duyệt dựa trên số ngày nghỉ, và giải thích vì sao không nên gộp tất cả vào một bảng duy nhất.

Tóm tắt

Ba pattern Inventory, Booking và Approval bao phủ một phần rất lớn các hệ thống nghiệp vụ bạn sẽ gặp trong sự nghiệp BA. Điểm chung của cả ba là sự phân tách thông minh giữa trạng thái hiện tại (con số/cờ để truy vấn nhanh) và lịch sử chi tiết bất biến (ledger, audit trail, approval step) để phục vụ đối soát và kiểm toán. Với Inventory, hãy hỏi con số tồn phụ thuộc vào những chiều nào để đặt nó đúng chỗ. Với Booking, hãy nhớ rằng ràng buộc chống chồng lấn thời gian phải nằm ở tầng dữ liệu chứ không chỉ ở ứng dụng. Với Approval, hãy mô hình hóa mỗi bước duyệt thành một bản ghi riêng và không bao giờ ghi đè. Khi nhận diện đúng pattern, bạn không chỉ thiết kế nhanh hơn mà còn tránh được hàng loạt lỗi kinh điển — đó chính là dấu ấn của một người thiết kế dữ liệu trưởng thành.