Files
thptqg/parser/internal/audit/audit.go
T
tiennm99 933adf70c9 refactor: comments state current behavior, not project history
Comments across the tree justified the code by pointing at a Rust
implementation that is no longer in the repository, citing files and line
numbers (config.rs:132, schema.rs:26-54, reader.rs:42) that cannot be
opened, plus crates and datasets that are equally gone. A reader could not
check any of it.

Every invariant those comments carried is kept and restated so it stands on
its own: the bytewise sort that decides which row survives a duplicate exam
number, the literal U+0300..U+036F range that must match the site's toAscii,
the trailing space in "SINH ", the BIFF and shared-string corrections, the
VACUUM-after-COMMIT rule, the deploy-from-main guard.

The reader's contract is now anchored to the frozen oracle in
parser/testdata, which still exists and is still checked, rather than to the
tool that originally produced it.

TestDDLMatchesRust becomes TestDDLIsFrozen: it compares against a copy of
the DDL inside the test and never read schema.rs, so both the name and the
failure message were misleading.

ToAscii no longer claims the d-replacement must precede lowercasing. Both
cases map to 'd' and ToLower runs last, so the order has no effect.
2026-08-14 09:40:41 +07:00

147 lines
3.7 KiB
Go

// Package audit compares distinct SBDs in the source spreadsheets against the
// row count in a built database.
//
// Two divergences from the build path are deliberate:
//
// - only .xlsx files are considered
// - only sheet 0 is read, regardless of sheet_mode
//
// They are intentional, not oversights. Do not "fix" them.
package audit
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/tiennm99/thptqg/parser/internal/config"
"github.com/tiennm99/thptqg/parser/internal/ingest"
"github.com/tiennm99/thptqg/parser/internal/reader"
"github.com/tiennm99/thptqg/parser/internal/sqlitedb"
)
// Result carries the audit counters.
type Result struct {
TotalDataRows uint64
BothEmpty uint64
EmptyName uint64
EmptySbd uint64
DistinctSbds int
DBCount int64
Matched bool
}
// Run collects distinct SBDs from the .xlsx files in inputDir and compares the
// total against the student row count in dbPath.
func Run(inputDir, dbPath string, cfg *config.DatasetConfig) (*Result, error) {
entries, err := os.ReadDir(inputDir)
if err != nil {
return nil, fmt.Errorf("read input dir %s: %w", inputDir, err)
}
var files []string
for _, e := range entries {
if e.IsDir() {
continue
}
if strings.EqualFold(filepath.Ext(e.Name()), ".xlsx") {
files = append(files, filepath.Join(inputDir, e.Name()))
}
}
sort.Strings(files)
res := &Result{}
seen := make(map[string]struct{})
// Format-detection configs carry no Columns block, so the audit falls back
// to fixed positional columns for them.
hoTenCol, sbdCol := 1, 0
if cfg.Columns != nil {
hoTenCol, sbdCol = cfg.Columns.HoTen, cfg.Columns.SoBaoDanh
}
for _, file := range files {
wb, err := reader.Open(file)
if err != nil {
return nil, err
}
sheets := wb.Sheets()
if len(sheets) == 0 {
wb.Close()
continue
}
firstRow := true
err = wb.EachRow(sheets[0].Index, func(_ reader.Sheet, _ int, row []reader.Cell) error {
if firstRow {
firstRow = false
if ingest.IsHeaderRow(row, cfg.Header.Tokens) {
return nil
}
}
res.TotalDataRows++
hoTen := cellAt(row, hoTenCol)
sbd := cellAt(row, sbdCol)
if hoTen == "" && sbd == "" {
res.BothEmpty++
return nil
}
if hoTen == "" {
res.EmptyName++
}
if sbd == "" {
res.EmptySbd++
}
if sbd != "" {
seen[sbd] = struct{}{}
}
return nil
})
wb.Close()
if err != nil {
return nil, err
}
}
// Opened read-only: the audit must never mutate the database it inspects.
db, err := sql.Open(sqlitedb.DriverName, "file:"+dbPath+"?mode=ro")
if err != nil {
return nil, fmt.Errorf("open db %s: %w", dbPath, err)
}
defer db.Close()
if err := db.QueryRow("SELECT COUNT(*) FROM student").Scan(&res.DBCount); err != nil {
return nil, fmt.Errorf("count rows: %w", err)
}
res.DistinctSbds = len(seen)
res.Matched = int64(res.DistinctSbds) == res.DBCount
return res, nil
}
// PrintReport writes the source-versus-database comparison block.
func PrintReport(r *Result) {
fmt.Println("=== Source vs DB ===")
fmt.Printf("Source: total data rows across all files: %d\n", r.TotalDataRows)
fmt.Printf("Source: rows with empty name AND sbd (skipped): %d\n", r.BothEmpty)
fmt.Printf("Source: rows with missing name only: %d\n", r.EmptyName)
fmt.Printf("Source: rows with missing sbd only: %d\n", r.EmptySbd)
fmt.Printf("Source: distinct SBDs: %d\n", r.DistinctSbds)
fmt.Printf("DB: row count: %d\n", r.DBCount)
if r.Matched {
fmt.Println("Match: YES — all unique SBDs accounted for")
} else {
fmt.Printf("Match: NO — gap of %d\n", int64(r.DistinctSbds)-r.DBCount)
}
}
func cellAt(row []reader.Cell, idx int) string {
if idx < 0 || idx >= len(row) {
return ""
}
return strings.TrimSpace(row[idx].Str)
}