Skip to content

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 VnWardVnProvince.

Trường đầu raNguồnGhi chú
businessNameTaxInfo.fullNameFallback sang Merchant.name (default → vi → en) nếu fullName rỗng
businessAddressGhé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
businessTaxCodeTaxInfo.taxCodeMã số thuế Việt Nam
currentDay/Month/Yeardayjs() 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ỳ
periodDescriptionTính từ Ledger.periodNhãn tiếng Việt, ví dụ Quý 1 năm 2026, Tháng 5 năm 2026
titleHằ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 raCột nguồnKiểuGhi chú
entries[].codeSaleOrder.orderNumberstringMã đơn hàng hiển thị trên phiếu
entries[].transDateSaleOrder.completedAtstring (ISO)Thời điểm hoàn thành đơn hàng, không phải thời điểm tạo
entries[].descriptionHằng số 'Thanh toán giao dịch'stringHardcode — không có mô tả riêng theo đơn hàng
entries[].amountSaleOrder.totalnumberNumber(order.total)total lưu dạng numeric trong Postgres, Drizzle trả về string

Trường Tổng Hợp

Trường đầu raNguồnGhi chú
totalRevenueentries.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
1total là string từ DrizzlePostgres 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.
2completedAt 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.
3Khô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:

  1. Lấy items (mỗi dòng là một SaleOrderItem) và header thương nhân song song
  2. Phân giải taxSetId của từng item thành TaxGroup (ngành nghề)
  3. 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 orderNumbercompletedAt nhưng priceMetadata khác nhau.

CộtNguồnKiểu
orderNumberSaleOrder.orderNumberstring
completedAtSaleOrder.completedAtstring (ISO)
priceMetadataSaleOrderItem.priceMetadataTPriceMetadata | 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 JSONBKiểuÝ nghĩa
priceMetadata.pricing.taxSetIdstring | undefinedID 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[]arrayTấ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[].amountstring (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[].taxableBasestring (decimal)Doanh thu làm căn cứ tính thuế — cũng là string decimal
appliedTaxes[].isVatbooleantrue = GTGT, false = TNCN
appliedTaxes[].taxIdstringID của dòng Taxkhô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ệmGiá trịMô tả
groupKeyUUID của TaxGroupKhó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
groupNameTaxGroup.name.vi (hoặc en / default)Tên ngành hiển thị trên tiêu đề cột
labelChuỗi tỷ lệ thuế tính toánChú 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 raKiểuMô tả
taxGroupChunksITaxGroup[][]Mảng 2 chiều — ngoài = trang, trong = ngành trên trang đó
taxGroupChunks[i][j].keystringgroupKey (UUID hoặc 'other')
taxGroupChunks[i][j].groupNamestringTên hiển thị ngành
taxGroupChunks[i][j].labelstringChú thích tỷ lệ (chuỗi rỗng nếu không cần)
taxGroupChunks[i][j].totalRevenuenumberTổng doanh thu kỳ cho ngành này
taxGroupChunks[i][j].totalVatnumberTổng GTGT kỳ cho ngành này
taxGroupChunks[i][j].totalPitnumberTổ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 raKiểuMô tả
entries[].codestringSaleOrder.orderNumber
entries[].transDatestringSaleOrder.completedAt (ISO)
entries[].descriptionstringHằng số 'Thanh toán giao dịch'
entries[].taxValuesRecord<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:

  • taxSetId là 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ùng
  • taxId nằ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 entry
  • taxSetId chỉ 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 minmax 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ệnNhã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
1TaxSet 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.
2Các trường trong appliedTaxes là decimal stringamounttaxableBase 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'.
3Tỷ 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.
4Mộ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.
5GROUPS_PER_CHUNK = 3 kiểm soát phân trangHơ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ùngVò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ổ.
7Kiể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

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.