Nguồn Dữ Liệu
Trang này ghi lại nguồn gốc dữ liệu của từng biểu mẫu sổ kế toán, ý nghĩa của từng trường ở cấp độ nguồn, và các hành vi không rõ ràng cần xử lý khi làm việc với fetcher hoặc debug kết quả. Trang này bổ sung cho tài liệu rendering tại HKD Templates.
Mỗi fetcher triển khai AbstractLedgerDataFetcherService.fetch() và được đăng ký trong LedgerDataFetcherService qua FETCHER_MAP với khóa là TLedgerIdentifierCode.
S1a-HKD
Tên biểu mẫu: SỔ DOANH THU BÁN HÀNG, DỊCH VỤ Căn cứ pháp lý: Thông tư 152/2025/TT-BTC Fetcher: S1aHkdDataFetcherService (packages/ledger/src/services/fetchers/s1a-hkd-data-fetcher.service.ts) Trạng thái dữ liệu: Thực (truy vấn DB trực tiếp)
Tổng quan
S1a là sổ doanh thu chính — mỗi dòng tương ứng một SaleOrder đã hoàn thành trong kỳ. Biểu mẫu không phân tách thuế; chỉ ghi tổng doanh thu. Áp dụng cho mọi hộ kinh doanh bất kể phương pháp tính thuế.
Quá trình fetch chạy hai truy vấn song song: thông tin header thương nhân (từ TaxInfo) và danh sách đơn hàng hoàn thành (từ SaleOrder).
Tra cứu nhanh — Ý nghĩa các trường
Trường Header Thương Nhân
Các trường này dùng chung cho mọi biểu mẫu, được lấy từ TaxInfo join với VnWard và VnProvince.
| Trường đầu ra | Nguồn | Ghi chú |
|---|---|---|
businessName | TaxInfo.fullName | Fallback sang Merchant.name (default → vi → en) nếu fullName rỗng |
businessAddress | Ghép từ các trường | [TaxInfo.addressLine, VnWard.fullName, VnProvince.fullName].join(', ') khi có đủ phường và tỉnh; nếu không dùng TaxInfo.fullAddress hoặc TaxInfo.addressLine |
businessTaxCode | TaxInfo.taxCode | Mã số thuế Việt Nam |
currentDay/Month/Year | dayjs() tại thời điểm sinh sổ | Ngày ký trên biểu mẫu in — không phải ngày cuối kỳ |
periodDescription | Tính từ Ledger.period | Nhãn tiếng Việt, ví dụ Quý 1 năm 2026, Tháng 5 năm 2026 |
title | Hằng số 'SỔ DOANH THU BÁN HÀNG, DỊCH VỤ' | Hardcode trong fetcher |
Trường Dữ Liệu Dòng (mỗi dòng là một SaleOrder)
Lấy từ SaleOrderRepository.findCompletedInPeriod() — chỉ select orderNumber, total, completedAt từ SaleOrder.
| Trường đầu ra | Cột nguồn | Kiểu | Ghi chú |
|---|---|---|---|
entries[].code | SaleOrder.orderNumber | string | Mã đơn hàng hiển thị trên phiếu |
entries[].transDate | SaleOrder.completedAt | string (ISO) | Thời điểm hoàn thành đơn hàng, không phải thời điểm tạo |
entries[].description | Hằng số 'Thanh toán giao dịch' | string | Hardcode — không có mô tả riêng theo đơn hàng |
entries[].amount | SaleOrder.total | number | Number(order.total) — total lưu dạng numeric trong Postgres, Drizzle trả về string |
Trường Tổng Hợp
| Trường đầu ra | Nguồn | Ghi chú |
|---|---|---|
totalRevenue | entries.reduce((sum, e) => sum + e.amount, 0) | Tổng SaleOrder.total trong kỳ |
Pipeline Xử Lý
periodStart/periodEnd
→ _getStartAndEndOfPeriod() [đầu ngày → cuối ngày hôm sau, exclusive]
→ SaleOrder WHERE status=COMPLETED
AND completedAt >= start
AND completedAt < end
AND merchantId = merchantId
AND deletedAt IS NULL
→ entries[] (mỗi entry là một đơn hàng, sắp xếp theo completedAt ASC)Khoảng thời gian được chuẩn hóa trong fetcher: periodStart về 00:00:00 ngày bắt đầu, periodEnd về 00:00:00 ngày kế tiếp (upper bound exclusive) để tránh lệch múi giờ.
Lưu ý
| # | Vấn đề | Chi tiết |
|---|---|---|
| 1 | total là string từ Drizzle | Postgres numeric trả về dạng string. Fetcher dùng Number(order.total). Nếu coercion thất bại, entries[].amount sẽ là NaN âm thầm — sẽ bị bắt ở bước Zod validation. |
| 2 | completedAt là thời điểm hoàn thành, không phải thời điểm thanh toán | Đơn đặt vào ngày N-1 nhưng xác nhận thanh toán vào ngày N sẽ thuộc kỳ của ngày N. Bộ lọc kỳ dùng completedAt. |
| 3 | Không có phân tách thuế | S1a chỉ ghi tổng doanh thu. Chi tiết thuế nằm ở S2a. Không nhầm SaleOrder.total (có thể bao gồm VAT) với doanh thu tính thuế. |
| 4 | Đơn hàng xóa mềm bị loại trừ | findCompletedInPeriod thêm isNull(SaleOrderSchema.deletedAt). Đơn đã hủy và xóa mềm không xuất hiện. |
S2a-HKD
Tên biểu mẫu: SỔ DOANH THU BÁN HÀNG HOÁ, DỊCH VỤ Căn cứ pháp lý: Thông tư 152/2025/TT-BTC, áp dụng cho HKD theo phương pháp khai thuế TRỰC TIẾP (tỷ lệ % trên doanh thu) Fetcher: S2aHkdDataFetcherService (packages/ledger/src/services/fetchers/s2a-hkd-data-fetcher.service.ts) Trạng thái dữ liệu: Thực (truy vấn DB trực tiếp)
Tổng quan
S2a phân tách doanh thu theo ngành nghề (nhóm thuế) — mỗi ngành có một nhóm cột riêng gồm doanh thu, thuế GTGT và thuế TNCN. Các ngành được phát hiện động từ các đơn hàng trong kỳ; không có danh sách cố định. Biểu mẫu yêu cầu thương nhân phải theo phương pháp thuế DIRECT — fetcher kiểm tra điều kiện này trước khi sinh sổ.
Quá trình fetch gồm ba giai đoạn:
- Lấy items (mỗi dòng là một
SaleOrderItem) và header thương nhân song song - Phân giải
taxSetIdcủa từng item thànhTaxGroup(ngành nghề) - Tổng hợp items thành entries theo đơn hàng và totals theo ngành
Tra cứu nhanh — Ý nghĩa các trường
Trường Header Thương Nhân
Nguồn giống S1a — xem Trường Header Thương Nhân ở trên.
Truy vấn thô — findCompletedItemsInPeriod trả về gì
Truy vấn JOIN SaleOrder ⨝ SaleOrderItem (INNER JOIN), do đó trả về một dòng mỗi item, không phải mỗi đơn hàng. Một đơn hàng có 3 items tạo ra 3 dòng, mỗi dòng có cùng orderNumber và completedAt nhưng priceMetadata khác nhau.
| Cột | Nguồn | Kiểu |
|---|---|---|
orderNumber | SaleOrder.orderNumber | string |
completedAt | SaleOrder.completedAt | string (ISO) |
priceMetadata | SaleOrderItem.priceMetadata | TPriceMetadata | null (JSONB) |
Cấu trúc JSONB priceMetadata
SaleOrderItem.priceMetadata là một snapshot đóng băng được ghi tại thời điểm thanh toán. Các trường liên quan đến S2a:
| Đường dẫn JSONB | Kiểu | Ý nghĩa |
|---|---|---|
priceMetadata.pricing.taxSetId | string | undefined | ID của dòng pricing.TaxSet đang hoạt động cho item này tại thời điểm thanh toán — neo để phân giải ngành nghề |
priceMetadata.pricing.appliedTaxes[] | array | Tất cả thuế áp dụng cho item này tại thanh toán — đóng băng; thay đổi thuế sau đó không ảnh hưởng |
appliedTaxes[].amount | string (decimal) | Số tiền thuế (VND) — lưu dạng string decimal, phải dùng Number() trước khi tính toán |
appliedTaxes[].taxableBase | string (decimal) | Doanh thu làm căn cứ tính thuế — cũng là string decimal |
appliedTaxes[].isVat | boolean | true = GTGT, false = TNCN |
appliedTaxes[].taxId | string | ID của dòng Tax — không dùng để phân giải ngành nghề (xem lưu ý bên dưới) |
Trường Ngành Nghề (Tax Group)
Sau khi phân giải, mỗi ngành có một khóa dùng xuyên suốt ba map tổng hợp:
| Khái niệm | Giá trị | Mô tả |
|---|---|---|
groupKey | UUID của TaxGroup | Khóa duy nhất cho một ngành đã phân giải được |
groupKey | 'other' | Khóa fallback cho items không phân giải được thành TaxGroup nào |
groupName | TaxGroup.name.vi (hoặc en / default) | Tên ngành hiển thị trên tiêu đề cột |
label | Chuỗi tỷ lệ thuế tính toán | Chú thích tỷ lệ trên tiêu đề cột — xem Phân giải tỷ lệ bên dưới |
Đầu ra — taxGroupChunks
Các ngành được sắp xếp (ngành có tên trước, 'other' luôn cuối) rồi chia thành từng nhóm GROUPS_PER_CHUNK = 3 cho bố cục PDF ngang. Mỗi nhóm thành một trang.
| Trường đầu ra | Kiểu | Mô tả |
|---|---|---|
taxGroupChunks | ITaxGroup[][] | Mảng 2 chiều — ngoài = trang, trong = ngành trên trang đó |
taxGroupChunks[i][j].key | string | groupKey (UUID hoặc 'other') |
taxGroupChunks[i][j].groupName | string | Tên hiển thị ngành |
taxGroupChunks[i][j].label | string | Chú thích tỷ lệ (chuỗi rỗng nếu không cần) |
taxGroupChunks[i][j].totalRevenue | number | Tổng doanh thu kỳ cho ngành này |
taxGroupChunks[i][j].totalVat | number | Tổng GTGT kỳ cho ngành này |
taxGroupChunks[i][j].totalPit | number | Tổng TNCN kỳ cho ngành này |
Đầu ra — entries
Mỗi entry là một orderNumber duy nhất, bất kể đơn hàng có bao nhiêu items.
| Trường đầu ra | Kiểu | Mô tả |
|---|---|---|
entries[].code | string | SaleOrder.orderNumber |
entries[].transDate | string | SaleOrder.completedAt (ISO) |
entries[].description | string | Hằng số 'Thanh toán giao dịch' |
entries[].taxValues | Record<groupKey, TSectorTotal> | Totals theo ngành cho riêng đơn hàng này; khóa là groupKey (UUID hoặc 'other'); không có khóa = đơn hàng không có item nào trong ngành đó |
Chuỗi Phân Giải Ngành Nghề
Mỗi SaleOrderItem được ánh xạ vào đúng một ngành qua ba bước tra cứu:
Tại sao dùng taxSetId thay vì appliedTaxes[].taxId:
taxSetIdlà một giá trị duy nhất trên snapshot pricing của item — một lần tra cứu cho mỗi tập tax ID được dùngtaxIdnằm ở mỗi entry trong applied-taxes: item có hai thuế áp dụng cần ba bước (Tax → TaxGroupItem → TaxGroup) cho mỗi entrytaxSetIdchỉ cần hai bước và luôn có scope ở cấp item, không phải cấp tax-entry
Cả hai tra cứu đều được batch — tất cả taxSetId duy nhất trong kỳ được phân giải qua hai truy vấn song song (TaxSet rồi TaxGroup).
Phân Giải Tỷ Lệ Thuế & Logic Nhãn
Tỷ lệ thuế hiệu dụng của một ngành được tính từ dữ liệu appliedTaxes quan sát được — không lấy từ bất kỳ tỷ lệ nào lưu trong catalog. Đây là thiết kế chủ ý: TaxProvisioningService đóng băng tỷ lệ vào Tax.value tại thời điểm provisioning, và các thay đổi sau đó không lan truyền. Snapshot là nguồn sự thật.
Cách theo dõi tỷ lệ:
Cho mỗi entry trong appliedTaxes, fetcher tính rate = amount / taxableBase và ghi lại min và max theo (groupKey, isVat):
lần đầu quan sát → { min: rate, max: rate }
các lần sau → { min: Math.min(cur.min, rate), max: Math.max(cur.max, rate) }Cách xây dựng nhãn (_buildRateLabel):
| Điều kiện | Nhãn đầu ra |
|---|---|
| Ngành có tên, tỷ lệ đồng nhất | '' (rỗng) — tên ngành đã thể hiện tỷ lệ |
| Ngành có tên, VAT dao động (min ≠ max) | 'VAT 8.0%–10.0%' |
| Ngành có tên, TNCN dao động | 'TNCN 0.3%–0.5%' |
| Ngành có tên, cả hai dao động | 'VAT 8.0%–10.0% - TNCN 0.3%–0.5%' |
Ngành 'other', có VAT | 'VAT 10.0%' (luôn hiển thị — tên không mang thông tin tỷ lệ) |
Ngành 'other', có cả hai | 'VAT 10.0% - TNCN 0.5%' |
Ngưỡng xác định "dao động" là Math.abs(max - min) >= 1e-9 (epsilon guard cho sai số floating-point).
Lưu ý
| # | Vấn đề | Chi tiết |
|---|---|---|
| 1 | TaxSet xóa mềm → âm thầm fallback vào "Khác" | TaxSetRepository kế thừa SoftDeletableRepository, tự động lọc WHERE deleted_at IS NULL. Nếu cấu hình pricing của sản phẩm được cập nhật sau khi đơn hàng đã đặt, TaxSet cũ có thể bị xóa mềm. priceMetadata snapshot vẫn giữ taxSetId gốc, nhưng truy vấn ORM loại trừ nó âm thầm — mọi item tham chiếu TaxSet đó rơi vào 'other'. Raw SQL sẽ tìm thấy record; ORM thì không. |
| 2 | Các trường trong appliedTaxes là decimal string | amount và taxableBase lưu dạng Postgres numeric, Drizzle trả về string. Luôn dùng Number() trước khi tính toán. Nếu không, phép cộng trở thành nối chuỗi — '100' + '50' = '10050'. |
| 3 | Tỷ lệ thuế có thể khác nhau trong cùng một kỳ | Hai items trong cùng TaxGroup có thể có tỷ lệ amount/taxableBase khác nhau. Điều này xảy ra khi tỷ lệ thuế thay đổi giữa các lần provisioning (mỗi lần provisioning đóng băng tỷ lệ tại thời điểm đó), hoặc khi áp dụng thuế inclusive vs. exclusive khác nhau. Nhãn tỷ lệ dao động (VAT 8.0%–10.0%) là cách hiển thị điều này cho người dùng. |
| 4 | Một SaleOrder có thể trải qua nhiều ngành thuế | Phân giải ở cấp item, không phải cấp đơn hàng. Đơn hàng có hai items thuộc hai TaxGroup khác nhau đóng góp vào hai cột ngành. Trong entries[].taxValues, entry đó sẽ có hai khóa. |
| 5 | GROUPS_PER_CHUNK = 3 kiểm soát phân trang | Hơn 3 ngành trong một kỳ tạo thêm các trang PDF nằm ngang. XLSX gộp tất cả ngành vào một sheet bất kể số chunk. |
| 6 | 'other' luôn là ngành cuối cùng | Vòng lặp groups giữ bucket "other" lại và thêm vào sau tất cả ngành có tên, bất kể thứ tự chèn vào Map. Đây là thiết kế chủ ý — các ngành có tên phải ổn định về vị trí qua các lần tái tạo sổ. |
| 7 | Kiểm tra phương pháp thuế | validate() ném MERCHANT_TAX_METHOD_NOT_DIRECT nếu Merchant.taxMethod !== 'DIRECT'. Kiểm tra này thực hiện trước fetch — job bị rejected ở bước validation, không phải giữa chừng fetch. |
S2b–S2e-HKD
Các biểu mẫu này hiện sử dụng dữ liệu fixture từ resources/fixtures/ và chưa có fetcher thực. Tài liệu nguồn dữ liệu sẽ được bổ sung khi fetcher thực được triển khai.
Trang liên quan
- HKD Templates — hình dạng dữ liệu rendering, cấu trúc PDF/XLSX
- Generation Pipeline — fetcher nằm ở đâu trong luồng worker
- Domain Model — các bảng
Ledger,LedgerJob,LedgerSnapshot