TATrương Tuấn AnhTuna / truongtuananh.vn
Tất cả bài viết
ngày ghi
phút đọc
6
nguồn dẫn
6

Production của tôi từng hiển thị 1.000 dự án bịa

Ngày 17/07/2026, DB production trống cả 20 bảng nhưng web vẫn hiện 1.000 dự án và 300 khách hàng từ file demo. Vì sao 'không bịa số liệu' là luật số 1 của ERP.

Chủ đề: erp, dữ liệu, ai có người duyệt

Ngày 17/07/2026 tôi ngồi đếm lại từng bảng trên DB production của NoLimit ERP. Kết quả: cả 20 bảng đều bằng 0. app_state 0, deals_projects 0, customers 0, profiles 0. Chưa có tài khoản nào, nghĩa là chưa ai đăng nhập được. Web và bot thì đã deploy xong từ trước, đang chạy production.

Vấn đề là mở /deals, /customers, /pl, /revenue lên, tôi thấy 1.000 dự án và 300 khách hàng, đầy đủ như thật. Không có banner demo. Một hệ thống DB trống trơn nhưng màn hình thì đầy số.

Sự cố: DB trống, màn hình đầy số

Nguồn gốc nằm ở một dòng trong lớp lấy dữ liệu. Hàm mainDb() đọc app_state từ Supabase, không có gì thì rơi về file JSON: s?.main ?? jsonMain. File JSON đó là demo-data.json, bộ dữ liệu seed 1.000 dự án tôi dùng để xem trước giao diện khi dev local chưa cấu hình env.

Logic này hợp lý lúc mới viết. Máy dev không có Supabase thì đọc file demo, ai cũng hiểu số là giả. Nhưng production có Supabase, và app_state của nó lúc đó rỗng vì seed chưa chạy. Đúng thực tế hôm đó. Code không phân biệt được "chưa cấu hình DB" với "đã cấu hình nhưng DB chưa có dữ liệu". Cả hai đều rơi vào cùng một nhánh fallback.

Thêm một yếu tố nữa: biến NEXT_PUBLIC_DATASET không được set trên production nên mặc định là demo. Vậy là app phục vụ đúng bộ 1.000 dự án + 300 khách hàng bịa như thể số thật.

Về thứ tự go-live: mục 2 (tạo 3 tài khoản) và mục 6 (seed) trong checklist bị bỏ qua, mục 7 (deploy) thì đã làm. Đó là lỗi vận hành của tôi. Nhưng lỗi đó lẽ ra chỉ dẫn tới màn hình trống, không phải màn hình bịa.

Vì sao banner demo không bật

App có sẵn banner demo để người xem biết số là giả. Cờ bật banner là isDemo, và isDemo chỉ bật khi thiếu NEXT_PUBLIC_SUPABASE_URL. Production có biến này. Vậy nên banner tắt.

Hai điều kiện lệch nhau. Nguồn dữ liệu rơi về demo theo tiêu chí "app_state rỗng". Banner bật theo tiêu chí "thiếu URL Supabase". Khi hai tiêu chí không cùng nhìn vào một sự thật, sẽ có lúc số giả chạy mà không có nhãn. Tôi gọi đây là sai lặng lẽ: không lỗi, không cảnh báo, build xanh, trang render đẹp. Chỉ có số là không có thật.

Tệ hơn là người xem không có cách nào tự biết. Một giám đốc mở dashboard thấy 1.000 dự án sẽ tin đó là số hệ thống. Không ai đi đếm lại DB khi màn hình trông ổn.

Cách sửa: đã có Supabase thì rỗng là rỗng

Bản vá (commit 0757b56) đổi nguyên tắc: khi supaConfigured (đã có URL Supabase) mà app_state không có dữ liệu, trả về cấu trúc rỗng EMPTY_MAIN thay vì file JSON. KPI bằng 0, danh sách trống, kèm ghi chú "app_state chưa có dữ liệu". Các trang hiện empty-state đúng với thực tế.

JSON demo chỉ còn được phép dùng trong hai trường hợp đầu của bảng dưới, và cả hai đều có chủ ý. Dòng thứ ba là empty-state, không phải demo:

Trường hợpNguồn dữ liệuBanner demo
Dev local, chưa cấu hình SupabaseJSON demoBật
Cố ý bật demo bằng NEXT_PUBLIC_DEMO=1JSON demoBật
Production có Supabase, app_state rỗngEmpty-state (KPI 0, danh sách rỗng)Tắt, vì số 0 là thật

Điểm mấu chốt: sau khi vá, cờ cho phép fallback (jsonFallbackAllowed) và cờ bật banner (isDemo) được tính từ cùng một cặp điều kiện. Số giả thì luôn đi kèm nhãn giả. Ở đường đọc, số giả không còn chạy được mà không có nhãn - còn đường ghi thì chưa, như mục dưới kể.

Tôi cũng ghi thẳng lý do vào comment ngay trên đoạn code, trỏ về CLAUDE.md: "Không bịa số liệu - số trên dashboard phải đến từ query thật". Để phiên Claude Code sau đọc code hiểu vì sao có đoạn này, không "tối ưu" ngược lại thành fallback như cũ.

Một ngày sau, rà soát còn thấy một đường nữa

Ngày 18/07/2026 tôi cho 14 agent đọc code song song, mỗi agent một vùng chức năng, lỗi mức cao được một agent thứ hai thẩm định đối kháng, cố bác bỏ. Kết quả về phần này: bản vá đường đọc đúng, nhưng đường ghi vẫn còn chỗ rơi về JSON demo khi app_state chưa có dữ liệu. Vá một đường chưa đủ. Cùng một giả định "không có thì lấy demo" nằm ở nhiều hơn một chỗ.

Khi một fallback tồn tại trong codebase, nó sẽ được sao chép. Cấm ở đường đọc thì phải grep hết mọi nơi khác cùng làm thế, rồi rà lại bằng một đợt review độc lập, không tin bản vá đầu tiên.

Vì sao đây phải là luật số 1

CLAUDE.md của repo có dòng: "Không bịa số liệu - số trên dashboard/bot phải đến từ query thật; không trend giả, không placeholder số." Trước sự cố tôi coi đó là quy tắc về giao diện. Sau sự cố tôi hiểu nó rộng hơn: mọi con số người dùng nhìn thấy phải có đường truy về một query thật. Không có query thì hiện rỗng, không hiện gì thay thế.

Cùng nguyên tắc đó là cách tôi để AI làm việc trong ERP. AI chỉ soạn nháp, nháp mang watermark "AI Draft - cần duyệt", con người duyệt rồi mới thành dữ liệu. Watermark và banner demo là một thứ: nhãn cho nội dung chưa phải sự thật. Số bịa không nhãn và nháp AI không watermark nguy hiểm theo cùng một cách. Chúng trông giống thật, và không ai kiểm.

Ngay cả file mô tả hệ thống tôi cũng viết theo luật này. Dòng đầu ghi "mọi số liệu trong file này lấy từ code hiện tại hoặc đếm trực tiếp trên DB production, không chép lại từ tài liệu cũ". Dòng cuối ghi "nếu đọc file này về sau, hãy đếm lại trước khi tin". Lý do là bản v2 trước đó của chính file này đã mô tả sai dự án.

Điều tôi rút ra

  • Fallback về dữ liệu mẫu chỉ an toàn khi nó luôn đi kèm nhãn. Cờ chọn nguồn và cờ hiện nhãn phải tính từ cùng một điều kiện.
  • "Đã cấu hình DB nhưng DB rỗng" là một trạng thái riêng, cần empty-state riêng. Không gộp với "chưa cấu hình".
  • Vá một đường chưa đủ. Cùng một giả định thường nằm ở nhiều hàm, phải rà hết và cho người khác review lại.
  • Luật "không bịa số liệu" áp cho cả số trên dashboard, nháp AI và tài liệu mô tả hệ thống. Không có nguồn thì để rỗng.

Nếu công ty bạn đang quản lý bằng Zalo và Excel và muốn một ERP mà số trên màn hình luôn truy được về nguồn, ta nói chuyện 15 phút.

Bài này chạm đúng việc bạn đang làm? Mười lăm phút nói chuyện không mất gì.