Đồ án tốt nghiệp của mình ở Bách khoa (2024) là một hệ thống cho phòng Vật tư thiết bị y tế của bệnh viện: các khoa đề xuất mua sắm, phòng Vật tư đưa đề xuất qua quy trình đấu thầu, và thiết bị trúng thầu được nhập vào danh mục quản lý. Bản đang chạy ở bid.vuminhhoa.com.
Phần khó nhất không phải code mà là hiểu và mô hình hoá quy trình. Đấu thầu mua sắm công có rất nhiều bước, mỗi bước sinh ra giấy tờ, và người làm nghiệp vụ nghĩ theo bước chứ không theo bảng dữ liệu. Bài này ghi lại các quyết định mô hình hoá chính, cái nào ổn, cái nào nếu làm lại mình sẽ làm khác.
Quy trình cần số hoá
Rút gọn lại, một lần mua sắm đi qua ba chặng:
Sáu giai đoạn của hoạt động mua sắm bám theo trình tự lựa chọn nhà thầu, trong đó các bước "đăng tải" là đăng lên Hệ thống mạng đấu thầu quốc gia:
| Giai đoạn | Ví dụ các bước |
|---|---|
| Báo giá | Đăng yêu cầu báo giá, hạn nhận báo giá |
| Tổ chuyên gia | Họp hội đồng mua sắm, phê duyệt dự toán, thành lập tổ chuyên gia và tổ thẩm định |
| Kế hoạch lựa chọn nhà thầu (KHLCNT) | Lập, thẩm định, phê duyệt, đăng tải kế hoạch |
| Hồ sơ mời thầu (E-HSMT) | Dự thảo, báo cáo xây dựng, các bước phê duyệt, đăng thông báo mời thầu |
| Kết quả lựa chọn nhà thầu | Báo cáo đánh giá, thương thảo, thẩm định, phê duyệt kết quả |
| Ký kết hợp đồng | Thông báo kết quả, bảo lãnh thực hiện hợp đồng, ký hợp đồng, bàn giao, bảo lãnh bảo hành |
Tổng cộng khoảng 30 bước, và gần như bước nào cũng có chung một hình dạng: một ngày và một văn bản. Nhận ra điều này sớm giúp mọi thứ phía sau đơn giản hơn nhiều (phần cuối bài kể lúc mình chưa nhận ra).
Đề xuất và hoạt động là một bản ghi
Đề xuất của khoa và hoạt động mua sắm dùng chung một bảng, phân biệt bằng cờ isProposal:
- Khoa tạo đề xuất: một dòng mới,
isProposal = true, trạng thái đề xuất "Chờ duyệt". - Phòng Vật tư chấp thuận: cùng dòng đó chuyển
isProposal = falsevà bắt đầu trạng thái hoạt động "Chờ xử lý".
Lợi ích lớn nhất: một hoạt động mua sắm luôn giữ nguyên lịch sử của nó, từ ai đề xuất, ai duyệt, ngày nào, đến từng bước đấu thầu, mà không phải nối hai bảng hay sao chép dữ liệu khi chuyển chặng. Cái giá là một bảng có nhiều cột chỉ có nghĩa ở một chặng (người duyệt đề xuất, người duyệt hoạt động...). Với một quy trình mà chặng sau luôn kế thừa chặng trước, mình thấy đánh đổi này đáng.
Trạng thái thì tách làm hai trường, vì hai chặng có vòng đời riêng:
Một điều mình rút ra: liệt kê rõ các bước chuyển hợp lệ thành một bảng và kiểm ở server, mỗi bước chuyển là một hành động riêng ("nộp", "phê duyệt", "huỷ") chứ không phải một trường cho phép ghi tự do. Như vậy luật nghiệp vụ nằm ở một chỗ, giao diện chỉ việc hỏi "từ trạng thái này được làm gì".
Mỗi giai đoạn là một cột JSON
Ba mươi bước, mỗi bước một ngày và một văn bản, nếu làm thành cột thì là khoảng sáu mươi cột. Thay vào đó, mỗi giai đoạn là một cột JSON, và cấu trúc mặc định nằm trong code:
export const defaultContractorPlan = {
plan: { date: null, document: null },
review: { date: null, document: null },
approval: { date: null, document: null },
decision: { date: null, document: null },
publish: { date: null },
};
Khi đọc, server trộn giá trị trong database lên trên cấu trúc mặc định:
contractorPlan: { ...defaultContractorPlan, ...(bidding.contractorPlan || {}) }
Nhờ vậy, thêm một bước chỉ cần sửa hằng số và form, không cần migration. Các hoạt động cũ tự có bước mới với giá trị rỗng.
Đánh đổi cần biết trước: dữ liệu trong JSON khó truy vấn và khó ràng buộc. Database không kiểm được kiểu, không đánh index tiện như cột thường. Cách này hợp khi các bước chủ yếu để ghi nhận và hiển thị; nếu cần báo cáo kiểu "bao nhiêu gói thầu đang dừng ở bước thẩm định E-HSMT", nên tách ít nhất trạng thái tiến độ ra cột riêng.
Giấy tờ phải đi cùng vòng đời của bản ghi
Mỗi bước có một file đính kèm, lưu trên dịch vụ lưu trữ file. Rất dễ rơi vào cảnh file mồ côi: người dùng thay file mới, file cũ vẫn nằm đó; xoá hoạt động, file vẫn còn.
Cách xử lý là để form tự theo dõi thay đổi theo đường dẫn trường, và gửi kèm ba danh sách khi lưu:
{
...data,
createdFields: ['technical_bid.draft.document'],
updatedFields: ['contract_signing.contract.document'],
deletedFields: ['expert_team.purchase_meeting.document'],
}
Server đi qua từng danh sách: created thì tải lên; updated thì xoá file cũ rồi tải file mới; deleted thì xoá file và đặt trường về null. Khi xoá cả hoạt động, server duyệt qua sáu cột JSON để dọn hết văn bản.
Bài học chung: đừng để server đoán thay đổi bằng cách so sánh cả object, vì với dữ liệu lồng nhau thì so sánh vừa tốn vừa dễ sai. Client là nơi biết rõ người dùng vừa làm gì; gửi kèm "đã đổi những gì" giúp server làm đúng việc. Một điểm nữa là thao tác với dịch vụ lưu trữ không nằm trong transaction của database, nên thứ tự các bước cần nghĩ kỹ: nếu có lỗi giữa chừng, phần sót lại nên là file thừa (dọn được), chứ không phải bản ghi trỏ tới file đã mất.
Nghiệm thu là một transaction
Bước cuối, phê duyệt hoạt động, cũng là lúc thiết bị trúng thầu được nhập vào danh mục. Danh sách thiết bị được chuẩn bị từ trước bằng file Excel, định dạng quen thuộc với phòng Vật tư, và lưu tạm trên hoạt động dưới dạng "danh sách xem trước". Thiết bị chỉ thật sự tồn tại khi hoạt động được phê duyệt.
Trùng ký mã hiệu được kiểm ở hai tầng:
- Trong file, ngay trên trình duyệt khi vừa đọc Excel: các dòng trùng nhau được tô ra để sửa trước khi gửi.
- Với dữ liệu đã có, ở server, bên trong transaction phê duyệt.
Rút gọn từ biddingService.ts:
await sequelize.transaction(async (t) => {
await Bidding.update(approval, { where: { id }, transaction: t });
const duplicates = await Equipment.findAll({
where: { modelNumber: { [Op.in]: modelNumbers } }, transaction: t,
});
if (duplicates.length > 0) throw new ConflictError('duplicateEquipment', duplicates);
await Equipment.bulkCreate(equipmentList, { transaction: t });
await Activity.create({ actor, action: `đã nhập ${equipmentList.length} thiết bị...` }, { transaction: t });
});
// Sau khi commit: cập nhật chỉ mục tìm kiếm.
Ba điều quan trọng ở đây:
- Đổi trạng thái, tạo thiết bị và ghi nhật ký cùng thành hoặc cùng bại. Không có chuyện hoạt động đã "Hoàn thành" mà danh mục chưa có thiết bị.
- Lỗi trùng trả về chính các dòng bị trùng, để giao diện hiện ra cho người dùng sửa, thay vì một câu "Có lỗi xảy ra".
- Chỉ mục tìm kiếm cập nhật sau khi commit. Database là nguồn sự thật; chỉ mục là bản sao có thể dựng lại bất cứ lúc nào bằng một script reindex. Nếu cập nhật chỉ mục bên trong transaction mà transaction rollback, chỉ mục sẽ chứa thiết bị không tồn tại. (Cách cấu hình tìm kiếm tiếng Việt cho danh mục này mình viết riêng ở bài tìm kiếm tiếng Việt không dấu với Elasticsearch.)
Cũng cần biết giới hạn chung của kiểu "kiểm trước rồi mới ghi": hai giao dịch chạy song song có thể cùng thấy "chưa trùng". Lớp chặn chắc chắn cho loại lỗi này là ràng buộc unique ở database; phần kiểm bằng code vẫn đáng giữ để trả về thông báo dễ hiểu.
Quyền hạn theo ngôn ngữ nghiệp vụ
Quyền trong hệ thống được đặt tên đúng như cách phòng Vật tư nói: "Tạo đề xuất hoạt động mua sắm", "Phê duyệt hoạt động mua sắm đấu thầu", "Xem tất cả thiết bị"... Người quản trị tự tạo vai trò và tick các quyền, nên khi cách phân công thay đổi thì không cần sửa code.
Có hai trục cần tách bạch:
- Quyền trả lời "được làm hành động gì": tạo, sửa, phê duyệt.
- Phạm vi trả lời "được thấy dữ liệu của ai": nhân viên một khoa chỉ thấy đề xuất và thiết bị của khoa mình, trừ khi có quyền "xem tất cả".
Gộp hai trục này làm một (ví dụ tạo vai trò "Nhân viên khoa Nội") thì số vai trò sẽ nhân lên theo số khoa. Tách ra thì một vai trò dùng chung cho mọi khoa, phạm vi lấy từ khoa của người dùng.
Để kiểm phân quyền, dự án có một script mô phỏng cả phòng Vật tư: tạo năm vai trò, năm nhân sự gắn với các khoa, chạy đủ sáu giai đoạn đấu thầu đến nghiệm thu. Điểm mình thấy đáng học nhất từ script này: mỗi người dùng được thử cả việc được phép lẫn việc không được phép. Kiểm thử phân quyền mà chỉ thử đường "được phép" thì chỉ chứng minh được một nửa.
Nhật ký hoạt động cho con người đọc
Mỗi thao tác quan trọng ghi một dòng gồm ai, làm gì, trên đối tượng nào, kiểu "… đã nhập 12 thiết bị từ hoạt động mua sắm …". Trang tổng quan hiện dòng thời gian này để cả phòng biết việc đang đến đâu.
Nhật ký này phục vụ theo dõi công việc, không nhằm làm bằng chứng không thể chối cãi. Nếu cần lịch sử mà ngay cả người quản trị database cũng không sửa âm thầm được, có thể xem cách làm trong bài audit log chống sửa trong PostgreSQL.
Nhìn lại sau hai năm
Đồ án làm từ tháng 3 đến tháng 7/2024. Sau đó mình quay lại vài lần: chuyển sang TypeScript, thêm tìm kiếm thiết bị bằng Elasticsearch, cache, đăng nhập Google và đăng nhập một lần (SSO).
Khung mô hình dữ liệu thì giữ nguyên từ bản 2024: một bảng với cờ isProposal, sáu cột JSON cho sáu giai đoạn, danh sách thiết bị xem trước. Thứ phải làm lại là hình dạng bên trong các cột JSON. Bản 2024 đặt tên trường theo tiếng Việt không dấu và để phẳng, mỗi bước hai trường riêng:
// 2024
{ ngayHopHoiDongMuaSam: null, taiLieuHopHoiDongMuaSam: null,
ngayPheDuyetDuToan: null, taiLieuPheDuyetDuToan: null, ... }
// sau này
{ purchaseMeeting: { date: null, document: null },
budgetApproval: { date: null, document: null }, ... }
Khi mỗi bước là một object { date, document }, form chỉ cần một component cho mọi bước, và code dọn file chỉ cần tìm khoá document thay vì nhớ sáu chục tên trường. Đó là cái giá của việc nhận ra "mọi bước đều là ngày + văn bản" muộn.
Nếu bạn cũng đang làm phần mềm cho một quy trình hành chính nhiều bước, lời khuyên của mình là ngồi với người làm nghiệp vụ và vẽ quy trình trước khi vẽ bảng: liệt kê các bước, mỗi bước sinh ra giấy tờ gì, ai được duyệt, rồi tìm hình dạng chung giữa các bước. Database thường tự hiện ra từ đó.
Tóm tắt
- Phần lớn trong khoảng 30 bước mua sắm có chung một hình dạng: một ngày và một văn bản. Nhận ra điều đó sớm thì form, cách lưu và việc dọn file đều đơn giản hơn; mình nhận ra muộn nên phải sửa lại cấu trúc JSON.
- Đề xuất và hoạt động mua sắm là cùng một hàng, phân biệt bằng cờ
isProposal, nên toàn bộ lịch sử nằm trong một bản ghi, đổi lại là có những cột chỉ có nghĩa ở một giai đoạn. - Viết các bước chuyển trạng thái hợp lệ thành bảng, kiểm tra trên server, và mỗi bước chuyển là một hành động riêng.
- Mỗi giai đoạn một cột JSON, gộp lên trên hình dạng mặc định trong code, cho phép thêm bước mà không cần migration, nhưng khó truy vấn và ràng buộc hơn cột thường.
- Để client gửi danh sách trường được tạo, sửa và xoá, nhờ vậy server quản lý file đính kèm mà không phải so cả object lồng nhau.
- Phê duyệt, thêm thiết bị và ghi nhật ký nằm trong một transaction; index tìm kiếm cập nhật sau khi commit, và ràng buộc unique mới là lớp chặn trùng đáng tin.
- Tách quyền (được làm hành động gì) khỏi phạm vi (được xem dữ liệu của ai), và với mỗi người dùng, test cả việc được phép lẫn việc không được phép.
Nếu bạn từng mô hình hoá quy trình tương tự theo cách khác, mình rất muốn được nghe. Email của mình ở trang chủ.