Luồng nghiệp vụ
toàn hệ thống D-Pro
Bản đồ chức năng và các luồng nghiệp vụ đầu–cuối, dựng từ mã nguồn đang chạy chứ không phải từ thiết kế trên giấy. Mục đích: nhìn được chỗ nào nối vào chỗ nào để ra quyết định kiến trúc — nhất là ba quyết định đang mở ở cuối tài liệu.
Cách đọc tài liệu này
Mọi con số đến từ hệ thống đang chạy: bounded context là hợp của src/Core/* và src/Application/*, topic lấy từ mã nguồn, số màn hình lấy từ navigation.ts. Sơ đồ vẽ quan hệ thật, kể cả những quan hệ đáng lẽ không nên có — chúng được tô riêng, vì đó chính là thứ cần quyết định.
Bản đồ chức năng — 41 bounded context
Nhóm theo vai trò trong nhà máy, không nhóm theo tầng kỹ thuật. Màu nền phân biệt bốn khối: điều hành sản xuất (MES), thương mại, kế toán, và nền tảng.
lệnh SX · công đoạn · chấm công"] PLAN["Planning
MRP · CRP · xếp lịch"] BOM["BOM
định mức đa cấp"] ROUTE["Routing
quy trình công nghệ"] QUAL["Quality
kiểm · NCR · SPC"] MAINT["Maintenance
bảo trì"] TOOL["Tooling
khuôn · đồ gá"] SUB["Subcontracting
gia công ngoài"] SHIFT["Shift"] CAPA["CAPA"] TRACE["Traceability
truy xuất lô"] ECO["ECO
thay đổi kỹ thuật"] CALIB["Calibration"] SENSOR["Sensors
OEE từ máy"] end subgraph COM["THƯƠNG MẠI"] direction LR PROC["Procurement
đơn mua"] SALES["Sales
đơn bán"] PRICE["Pricing
bảng giá · hạn mức"] RET["Returns
trả hàng 2 chiều"] INV["Inventory
tồn kho · chuyển kho"] WH["Warehouse
vị trí kệ · kiểm kê"] end subgraph ACC["KẾ TOÁN"] direction LR LEDGER["Ledger
sổ cái · kỳ · bút toán"] AR["AR
phải thu"] AP["AP
phải trả · 3 chiều"] FA["FixedAssets
TSCĐ · khấu hao"] PAY["Payroll
tiền lương"] FIN["Finance
giá thành"] EINV["EInvoice
hoá đơn điện tử"] end subgraph PLAT["NỀN TẢNG"] direction LR AUTH["Auth
vai trò · phân hệ · nhà máy"] MD["MasterData
SP · máy · trạm · NV"] SITES["Sites
nhà máy"] APPR["Approval
ma trận · định tuyến"] NOTIF["Notification"] DOC["Documents"] REP["Reporting"] ANA["Analytics"] IMPORT["CatalogImport"] end MES --> ACC COM --> ACC PLAT -.-> MES PLAT -.-> COM PLAT -.-> ACC style MES fill:#0F131A,stroke:#10B981 style COM fill:#0F131A,stroke:#06B6D4 style ACC fill:#0F131A,stroke:#F59E0B style PLAT fill:#0F131A,stroke:#E1AC36
Quy tắc chọn Event Sourcing
- 30 BC dùng ES — thực thể có vòng đời nghiệp vụ, và lý do đổi trạng thái cũng là dữ liệu cần giữ
- 6 là state store thuần: Auth · Documents · MasterData · Pricing · Sites · Warehouse
- Chọn sai theo hướng ES cho một bảng danh mục thì mỗi lần sửa tên sản phẩm lại đẻ một sự kiện vô nghĩa
- Chọn sai theo hướng state store cho một chứng từ thì mất hẳn lý do vì sao nó tới trạng thái hiện tại — mà đó là thứ kiểm toán hỏi
Cách các BC nói chuyện
- Chính thức: Outbox → Kafka → Projection. 30 topic
- Hook trong tiến trình: khi cần kết quả ngay (xuất vật tư lúc bắt đầu công đoạn)
- Đọc chéo đồng bộ: 6 BC đang làm — xem mục 7, đây là chỗ cần quyết định
Bán hàng → giao hàng → thu tiền
Điểm đáng chú ý: doanh thu ghi nhận lúc XUẤT HOÁ ĐƠN, không phải lúc giao hàng. Ghi ở cả hai chỗ là đúp số, và theo chuẩn Việt Nam doanh thu gắn với hoá đơn GTGT. Giao hàng chỉ ghi giá vốn.
Bốn chỗ đã hỏng trong im lặng ở luồng này
- Hoàn thành lệnh SX không nhập kho thành phẩm → không bán được cái vừa làm; giá vốn = 0 mà doanh thu vẫn đủ
- Hạn mức công nợ đọc read model → hai đơn 30tr xác nhận cách nhau vài giây đều lọt
- Người duyệt do phía gọi tự khai → duyệt thay người khác chỉ bằng đổi một trường
- Đơn bán seed bằng SQL không kèm sự kiện → hiện đủ trên lưới mà không thao tác được gì
Cả bốn đều để lại hệ thống mà tổng vẫn đúng và bảng cân đối vẫn cân. Đó là lý do make e2e đối chiếu từng khoản chứ không kiểm API trả 200.
Mua hàng → nhận hàng → trả tiền
Điểm cốt lõi: nhận hàng ghi vào tài khoản trung gian 3388, không ghi thẳng 331. Lúc nhận hàng chưa có hoá đơn NCC — chưa biết số hoá đơn, chưa có thuế đầu vào, số tiền có thể còn lệch. Số dư 3388 chính là chỉ tiêu “hàng đã nhận chưa có hoá đơn”.
→ nhập kho giá 0 → ZERO_AMOUNT → mua hàng không lên sổ MH->>AP: Nhận hoá đơn NCC AP->>AP: Đối chiếu 3 chiều
đơn mua ↔ SỐ ĐÃ NHẬN ↔ hoá đơn alt Không khớp AP-->>MH: MATCH_REQUIRED MH->>AP: Duyệt vượt + BẮT BUỘC nêu lý do end AP->>GL: Nợ 3388 + Nợ 133 / Có 331 MH->>AP: Phiếu chi (phải gán HẾT vào hoá đơn) AP->>GL: Nợ 331 / Có 1121
Vì sao đối chiếu theo SỐ ĐÃ NHẬN chứ không theo số đã đặt
Đặt 100 mới nhận 40 thì hoá đơn 100 là sai dù khớp đơn mua. Đây chính là điểm khác giữa đối chiếu 3 chiều và 2 chiều — và là chốt kiểm soát chi tiền quan trọng nhất của phần mua hàng.
Kế hoạch → sản xuất → nhập kho thành phẩm
Đây là luồng dài nhất và cũng là nơi sinh ra nhiều lỗi âm thầm nhất, vì nó chạm cả kho, giá thành lẫn sổ cái.
Planning"] -->|"Chạy MRP: nổ BOM Active × SL"| B["Nhu cầu vật tư
so tồn khả dụng"] B --> C{"Đủ vật tư?"} C -->|"Thiếu"| D["Shortage → đơn mua"] C -->|"Đủ"| E["CRP — kiểm năng lực trạm"] E --> F["Xếp lịch hữu hạn
hết năng lực thì đẩy sang ngày sau"] F --> G["Phát hành kế hoạch"] G --> H["Lệnh sản xuất — Planned"] H -->|"Release"| I["Chụp Routing Active
thành công đoạn của lệnh"] I -->|"Hook tự nộp duyệt"| APR["Approval"] I -->|"Start"| J{"Chốt duyệt
đọc EVENT STORE"} J -->|"Chưa duyệt"| K["APPROVAL_REQUIRED — chặn"] J -->|"Đã duyệt / không khai ma trận"| L["Bắt đầu công đoạn 1"] L --> M["Nổ BOM lọc theo MÃ CÔNG ĐOẠN
× SỐ NHẬN VÀO công đoạn"] M --> N["Quy đổi đơn vị BOM → đơn vị kho"] N -->|"Không quy đổi được"| O["TỪ CHỐI xuất"] N --> P["Trừ kho ref 'MO …/op…'"] P --> Q["Nợ 621 / Có 152"] L --> R["Ghi sản lượng: đạt + phế"] R --> S["Hoàn thành công đoạn"] S -->|"Bán thành phẩm chảy sang bước sau"| L S -->|"Bước cuối"| T["Hoàn thành lệnh"] T --> U["Nhập kho thành phẩm
đơn giá = giá vốn vật tư ĐÃ XUẤT"] T --> V["Tính giá thành
vật tư + nhân công + chi phí chung"] style J fill:#1C222E,stroke:#E1AC36 style O fill:#3B1219,stroke:#EF4444 style K fill:#3B1219,stroke:#EF4444 style M fill:#1C2B1E,stroke:#10B981
Ba cái bẫy đã sập ở luồng này
- Vật tư nổ theo số KẾ HOẠCH thay vì số nhận vào — bước cắt hỏng 2 thì bước may vẫn lĩnh chỉ cho 200 trong khi chỉ 198 cái mũ đi tới. Sai lệch cộng dồn theo phế phẩm phía trên, giá thành bị thổi lên (vá 26/08)
- Đơn giá nhập kho đọc read model — chấm công xảy ra vài giây trước khi kết thúc lệnh nên chưa kịp chiếu; giá thành ra 2.000.000 thay vì 2.760.000 mà vẫn là con số trông bình thường
- Làm tròn SỐ GIỜ thay vì thành tiền — 230 phút → 3,8333 giờ × 120.000 = 459.996 thay vì 460.000
Vì sao giờ máy là GIỜ CHUẨN
Giờ máy = SetupMinutes + MinutesPerUnit × GoodQty, không lấy hiệu số CompletedAt − StartedAt. Mốc thời gian là thời gian trôi qua: công đoạn bắt đầu chiều thứ Sáu xong sáng thứ Hai sẽ thành 72 giờ máy. Cùng công thức với CRP và xếp lịch.
Từ nghiệp vụ tới báo cáo tài chính
Mọi nghiệp vụ đổ về sổ cái qua bút toán tự động — đi qua Kafka, không dùng hook trong tiến trình. Hook chạy trong tiến trình gọi nên lỗi chỉ còn cách nuốt hoặc đánh hỏng nghiệp vụ gốc; qua Kafka có sẵn thử lại và DLQ, và bút toán trễ vài giây không ảnh hưởng xưởng.
152 / 3388"] I2["Xuất vật tư cho SX"] --> R2["PRODUCTION_ISSUE
621 / 152"] I3["Xuất kho bán"] --> R3["SALES_COGS
632 / 155"] I4["Xuất hoá đơn AR"] --> R4["131 / 511 · 131 / 3331"] I5["Hoá đơn NCC"] --> R5["3388 + 133 / 331"] I6["Duyệt bảng lương"] --> R6["622·627·641·642 / 334 · 338x · 3335"] I7["Trích khấu hao"] --> R7["627·641·642 / 214"] I8["Gia công ngoài"] --> R8["154 / 152 · 155 / 154"] end SRC -->|"Outbox → Kafka"| CONS["Consumer định khoản
tra ledger_account_mappings"] CONS --> GUARD{"LedgerPostingGuard
tài khoản hợp lệ? kỳ đang mở?"} GUARD -->|"Không"| FAIL["ledger_posting_failures
NGHIỆP VỤ KHÔNG BIẾN MẤT"] GUARD -->|"Có"| POST["gl_postings — chỉ thêm"] FAIL -->|"sửa nguyên nhân → retry"| POST POST --> CLOSE["TRÌNH TỰ CUỐI KỲ"] subgraph CLOSE2["Năm bước, KHÔNG hoán đổi được"] direction TB S1["1. Duyệt bảng lương
622 · 627 có số"] S2["2. Trích khấu hao E3
627 có số"] S3["3. Khấu trừ GTGT F1
3331 ↔ 133"] S4["4. Kết chuyển CPSX E2b
621·622·627 → 154 → 155"] S5["5. Kết chuyển kết quả E1
doanh thu · chi phí → 911 → 421"] S1 --> S2 --> S3 --> S4 --> S5 end CLOSE --> CLOSE2 S5 --> RPT["B01 Bảng cân đối
B02 Kết quả kinh doanh
B03 Lưu chuyển tiền tệ"] style FAIL fill:#3B2A12,stroke:#F59E0B style CLOSE2 fill:#0F131A,stroke:#F59E0B
Thứ tự năm bước là nguồn của BA lỗi đã ghi
Duyệt lương sau E2b thì toàn bộ chi phí nhân công của kỳ không bao giờ vào giá thành — và bảng cân đối vẫn cân. Nên phải chặn. Nhưng cách chặn hiện tại là mỗi bước tự truy vấn journal_entries để suy ra “bước sau đã chạy chưa”, và chính cách suy đó đã sai ba lần. Xem mục 8.
Nhân sự và tiền lương — trạng thái thật
D-Pro không có HRM. Nó có phân hệ tính lương, và hai thứ đó khác nhau. Sơ đồ dưới tô đỏ phần chưa có.
hiệu lực theo thời gian"] P2["Biểu thuế luỹ tiến TỪNG PHẦN
bậc cuối không trần"] P3["Hai mốc trần bảo hiểm
BHXH·BHYT theo lương cơ sở
BHTN theo lương tối thiểu VÙNG"] P4["Ba loại BH tách riêng
3383 · 3384 · 3386"] P5["Bốn mục đích lao động
622 · 627 · 641 · 642"] P6["Bảng kê BHXH
Tờ khai 05/KK-TNCN + XML"] P7["Đối chiếu lương ↔ sổ cái F5"] end subgraph GAP["CHƯA CÓ — quản trị nhân sự"] direction TB G1["Hợp đồng lao động
lương gõ tay MỖI THÁNG"] G2["MST cá nhân · số sổ BHXH
→ tờ khai thiếu dữ liệu bắt buộc"] G3["Người phụ thuộc
là một CON SỐ, không phải danh sách"] G4["Chấm công hành chính
chỉ có chấm công theo CÔNG ĐOẠN SX"] G5["Phép năm · ốm · thai sản"] G6["Trần tăng ca 200/300 giờ"] end ENTRY["PayrollEntry
11 SỐ GÕ TAY mỗi người mỗi tháng"] GAP -.->|"nếu có, sẽ tự điền"| ENTRY ENTRY --> HAVE style HAVE fill:#0F2318,stroke:#10B981 style GAP fill:#3B1219,stroke:#EF4444 style ENTRY fill:#3B2A12,stroke:#F59E0B
Vấn đề gốc: 11 số gõ tay × số người × 12 tháng
PayrollEntry nhận ContractSalary · ActualEarnings · OvertimePay · Dependents… tất cả gõ tay. Nghĩa là: ai tăng lương thì phải nhớ gõ số mới; hệ thống không lưu hợp đồng lao động nói gì — mà đó là thứ thanh tra lao động hỏi đầu tiên. Với xưởng gia công vài trăm đến vài nghìn công nhân thì không vận hành được.
Cách sửa: thêm LabourContract (Event Sourcing — vòng đời ký · phụ lục tăng lương · chấm dứt) và một hàm thuần PayrollProposal.Build(...) điền sẵn PayrollEntry. Giữ nguyên chữ ký PayrollEntry nên bảng lương hiện tại không đổi một dòng, và giữ đường gõ tay làm lối thoát — đúng cách CalculateProductionCost vẫn cho truyền tay overheadCost.
Các BC nối vào nhau thế nào
Ba kiểu nối, và chỉ kiểu thứ ba là vấn đề. Sơ đồ tô đỏ những quan hệ đồng bộ — chúng là thứ chặn mọi phương án tách module.
CÙNG transaction"] A2 --> A3["OutboxPublisher
poll 500ms"] A3 --> A4["Kafka — 30 topic"] A4 --> A5["Projection → read model"] A4 --> A6["Consumer định khoản → sổ cái"] end subgraph K2["2⃣ HOOK TRONG TIẾN TRÌNH — khi cần kết quả NGAY"] direction LR B1["StartOperation"] --> B2["IProductionMaterialIssuer"] B3["ReceiveGoods"] --> B4["IGoodsReceiptInventoryHook"] B5["ShipGoods"] --> B6["IShipmentInventoryHook"] B7["CompleteOrder"] --> B8["IProductionOutputReceiver"] B2 -.->|"lỗi thì vào"| B9["inventory_hook_failures
hiện ra, không nuốt"] end subgraph K3["3⃣ ĐỌC CHÉO ĐỒNG BỘ — 6 BC đang làm"] direction LR C1["Payroll"] -->|"HasResultClosingAsync"| CX["journal_entries"] C2["FixedAssets"] -->|"HasResultClosingAsync"| CX C3["Inventory"] -->|"HasActiveEntryInEventStore"| CX C4["AR"] --> CX C5["AP"] --> CX C6["Ledger"] --> CX C1 -->|"đọc giờ công"| CY["production_labor_bookings"] end style K1 fill:#0F2318,stroke:#10B981 style K2 fill:#0F131A,stroke:#06B6D4 style K3 fill:#3B1219,stroke:#EF4444
| Kiểu nối | Dùng khi | Vượt được ranh giới service? |
|---|---|---|
| Kafka | Bút toán tự động, dựng read model, thông báo | Được — đã là hợp đồng qua mạng rồi |
| Hook | Cần kết quả ngay trong cùng lệnh (giá vốn xuất kho) | Khó — nhưng đều là best-effort có hàng đợi, chịu được |
| Đọc chéo đồng bộ | Chốt kiểm soát thứ tự và chống ghi trùng | Không — đây là chỗ phải sửa trước |
Kỳ kế toán: thứ tự được SUY RA, không được KHAI RA
Bảng accounting_periods chỉ có is_closed boolean. Nhưng thực tế một kỳ đi qua sáu pha. Thứ tự đó không được khai ở đâu — mỗi bước tự truy vấn journal_entries để đoán bước sau đã chạy chưa.
is_closed: bool"] N1["Duyệt lương"] -->|"query journal_entries
lọc reference_type"| N0 N2["Khấu hao E3"] -->|"query journal_entries"| N0 N3["Kết chuyển E2b"] -->|"query journal_entries"| N0 NB1["Lọc theo source thay vì reference_type"] NB2["Bút toán ĐÃ ĐẢO vẫn chặn"] NB3["Khấu trừ GTGT chặn luôn E2b và lương"] end subgraph NEW["ĐỀ XUẤT — pha tường minh"] direction TB M0["AccountingPeriod — aggregate có PHA"] M1["Open"] --> M2["PayrollClosed"] --> M3["Depreciated"] M3 --> M4["VatSettled"] --> M5["CostTransferred"] --> M6["ResultClosed"] --> M7["Closed"] MQ["Mọi bước chỉ hỏi MỘT câu:
kỳ đang ở pha nào"] M0 --- MQ end NOW -->|"sửa"| NEW style NOW fill:#3B1219,stroke:#EF4444 style NEW fill:#0F2318,stroke:#10B981
Ba lỗi, một nguyên nhân
| Lỗi đã xảy ra | Vì sao |
|---|---|
Lọc theo source thay vì reference_type | Mỗi chỗ tự suy, mỗi chỗ suy một kiểu |
| Bút toán đã đảo vẫn chặn — kế toán làm đúng hướng dẫn khắc phục rồi nhận y nguyên thông báo đó | Suy từ dữ liệu chỉ-thêm, không suy từ trạng thái |
| Khấu trừ GTGT (F1) chặn luôn E2b, khấu hao và duyệt lương | Hai nghiệp vụ khác nhau cùng ghi source='Closing' |
Cả ba là cùng một lỗi. Làm pha tường minh thì không còn chỗ để suy sai — và sáu BC kia thôi đọc chéo, vì pha của kỳ là một sự kiện nhỏ đổi chậm, đi qua Kafka được.
Ba quyết định đang mở
AccountingPeriod thành aggregate có pha"] D2["HRM
hợp đồng LĐ · người phụ thuộc · chấm công · phép"] D3["TÁCH SERVICE?
modulith rút được ra vs microservice"] D4["ĐA TIỀN TỆ
tỷ giá · TK 413 · đánh giá lại cuối kỳ"] D1 -->|"gỡ bỏ đọc chéo đồng bộ"| D2 D1 -->|"điều kiện tiên quyết"| D3 D2 -->|"schema hr.* + architecture test
= module rút được ra ĐẦU TIÊN"| D3 D4 -.->|"độc lập — nhưng chặn khách XUẤT KHẨU"| D2 style D1 fill:#1C222E,stroke:#E1AC36 style D4 fill:#3B2A12,stroke:#F59E0B
Pha kỳ kế toán — làm TRƯỚC
- Sửa ba lỗi có thật, không phải phòng xa
- Màn Cuối kỳ hiện đúng bước tiếp theo thay vì suy từ số liệu
- Điều kiện tiên quyết của mọi phương án tách
HRM — làm module rút được ra đầu tiên
- Schema riêng
hr.*+ role DB riêng → cô lập dữ liệu cá nhân (NĐ 13/2023) - Cần vai trò
HRmới — hiện lương nằm dướiFinance, mà nhân sự không được xem sổ cái - Tách
hr-profilekhỏihr-salary: tổ trưởng xem bảng công của tổ nhưng không xem lương - Architecture test đỏ nếu HR chạm schema khác → biến “composable” thành thứ được ép
Tách service — chưa, nhưng giữ đường
Codebase đã ở rất gần mức “modulith rút được ra”: mỗi BC đã có Registry riêng, sự kiện riêng, Kafka topic riêng, và src/Worker đã chứng minh mã chạy tách tiến trình được. Thiếu đúng hai thứ: schema riêng và bỏ đọc chéo đồng bộ.
Làm xong ① và ② thì việc rút một service ra trở thành bước cơ học. Điều kiện đảo ngược — sẽ tách thật khi có ít nhất một trong ba: HRM bán riêng cho khách không dùng ERP · có đội khác sở hữu với nhịp phát hành khác · yêu cầu pháp lý bắt dữ liệu nhân sự nằm hạ tầng tách biệt.
Đa tiền tệ — khoảng trống lớn nhất chưa nói tới
Đo được: 0 bảng có cột tỷ giá, mọi hoá đơn đều VND, không có TK 413 chênh lệch tỷ giá. Nhưng ngành mặc định vừa chọn là gia công giày OEM xuất khẩu — khách hàng trả bằng USD/EUR/JPY. Không có tỷ giá thì không xuất được một hoá đơn nào cho khách thật.
Nó độc lập với ba quyết định trên, nhưng chạm vào lõi kế toán nên càng để lâu càng đắt.
Và một khoảng trống pháp lý: gia công xuất khẩu
0 bảng hải quan. Gia công cho nước ngoài ở Việt Nam chạy theo loại hình E21/E52: nhập nguyên liệu miễn thuế → xuất thành phẩm → báo cáo quyết toán nguyên vật liệu với hải quan (mẫu 15/BCQT). Đó là nghĩa vụ pháp lý, không phải tiện ích. Không có nó thì xưởng gia công không dùng D-Pro cho việc chính của họ được.