mirror of
https://github.com/tiennm99/thptqg.git
synced 2026-10-05 10:13:46 +00:00
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.
147 lines
3.7 KiB
Go
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)
|
|
}
|