refactor: one directory per pipeline stage, and an assembler to drive them

The repository now reads as the pipeline it is: crawler fetches, parser
converts, assembler verifies and publishes, with data/ and web/ as the stores
they hand work through. go-parser is renamed parser now that there is no other.

The assembler replaces build-db.js and assemble-site.js. It compiles the
parser, builds and verifies each database, compresses it, runs the Vite build
and assembles _site — one command, and the only place that knows the order.

It also closes a real hole: nothing previously asserted that a database reached
the site. An empty staging directory assembled happily, so every page rendered,
every query 404d and CI stayed green. The row-count and size guards could not
catch that, since they only run when a database was built at all.

Removing Node from the root forced the dataset list out of web/src/datasets.js,
which the assembler cannot import. datasets.json is now the registry both sides
read — JSON because Go and the browser both parse it without a dependency —
while presentation stays in the web app, keyed by id and cross-checked against
the registry so a half-added dataset fails instead of half-working.

Guards verified by making each one fail: a missing database, and an expected
row count one higher than the truth.
This commit is contained in:
tiennm99 committed 2026-08-13 22:50:05 +07:00
1 parent b04f9844f9
commit c359a0b444
61 files changed
+1341 -480

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
# parser
Reads the `.xls`/`.xlsx` source spreadsheets in `data/` and writes one SQLite
database per dataset.
```bash
go -C parser build -o bin/xlsxread ./cmd/xlsxread # compile
go -C parser test ./... # unit tests + the reader-fidelity suite
```
```
xlsxread build --schema parser/configs/<id>.yml --input data/<id> --output <db>
xlsxread audit --schema parser/configs/<id>.yml --input data/<id> --db <db>
```
This stage only produces a database. Verifying it against the expected row
count, compressing it and publishing it belong to `assembler/`, which compiles
this binary and drives it per dataset:
```bash
go -C assembler run ./cmd/assemble db
```
## Layout
| path | role |
|---|---|
| `internal/reader` | spreadsheet reading; the only place that knows about file formats |
| `internal/ingest` | dataset policy — sheet selection, header skipping, blank rows, the build loop, and the 2016 per-sheet format detection |
| `internal/transform` | `ToAscii`, score-regex parsing, row validation |
| `internal/schema` | the canonical 22-column table: DDL, INSERT, subject regexes |
| `internal/config` | per-dataset YAML parse rules |
| `internal/writer` | SQLite lifecycle and the stats block |
| `internal/audit` | source-vs-database SBD comparison |
The reader deliberately knows nothing about datasets: it reports every sheet and
every row verbatim. All policy lives in `ingest`. That split is what made the
reader independently verifiable against a hash oracle.
## Provenance
This is a port of a Rust crate that occupied this same path until the Go
implementation reached full parity, when it was built alongside as `go-parser/`
and moved back here once the Rust was removed. Source comments cite the original
by file and line (`parser/src/transform.rs:56` and similar) — those refer to the
Rust tree and resolve at the tag **`pre-go-parser-removal`**, the last commit
containing it.
The port was gated on a field-by-field comparison of both implementations across
the four datasets that existed then — 3,265,641 rows with identical full-table
SHA-256, identical per-column non-NULL counts, identical schema metadata and
identical build stdout. `scripts/differential-parity.mjs` is that comparator and
still runs against any two sets of databases.
Behaviour was matched bug-for-bug, deliberately. Several quirks look like
defects and are load-bearing for the published data:
- a parsed score of `0` becomes NULL in the 2016 separate-scores layout,
replicating a JavaScript falsy check;
- `ToAscii` strips combining marks in the literal range U+0300–U+036F rather
than by Unicode category, which is narrower;
- gender is a two-value allowlist, and anything else becomes NULL;
- `diem_thi` is read untrimmed while the other three fields are trimmed;
- the `"SINH "` header token carries a trailing space.
Each has a test naming it, so none can be tidied away by accident.
## Verification
`testdata/reader-fidelity-hashes.tsv` holds a SHA-256 per input file over a
canonical dump of every cell of every sheet. It is **frozen**: it was produced
by the Rust reader, which no longer exists, so it cannot be regenerated. It
still fails if any single cell of any of the 182 files reads differently.
The assembler refuses to publish a database whose row count does not match
the known figure, or whose artifact is under 90% of its usual size.
+88
View File
@@ -0,0 +1,88 @@
// Command dumpcells emits the canonical cell rendering of a spreadsheet, for
// comparison against the Rust/calamine ground truth produced by
// parser/examples/dump_cells.rs.
//
// The canonical stream carries geometry and rendered cell values only. The
// calamine Data variant is deliberately excluded: Data::Empty and
// Data::String("") both render "" and both count as blank everywhere
// downstream, so the distinction cannot affect the database.
//
// Usage: dumpcells <spreadsheet> [out-file]
package main
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/tiennm99/thptqg/parser/internal/reader"
)
// escape mirrors the Rust dumper so field separators can never break the format.
func escape(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, ch := range s {
switch ch {
case '\\':
b.WriteString(`\\`)
case '\t':
b.WriteString(`\t`)
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
default:
b.WriteRune(ch)
}
}
return b.String()
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: dumpcells <spreadsheet> [out-file]")
os.Exit(2)
}
path := os.Args[1]
out := os.Stdout
if len(os.Args) > 2 {
f, err := os.Create(os.Args[2])
if err != nil {
fmt.Fprintf(os.Stderr, "create: %v\n", err)
os.Exit(1)
}
defer f.Close()
out = f
}
w := bufio.NewWriterSize(out, 1<<20)
defer w.Flush()
wb, err := reader.Open(path)
if err != nil {
fmt.Fprintf(os.Stderr, "open: %v\n", err)
os.Exit(1)
}
defer wb.Close()
sheets := wb.Sheets()
fmt.Fprintf(w, "FILE\t%s\n", escape(path))
fmt.Fprintf(w, "SHEETCOUNT\t%d\n", len(sheets))
for _, sh := range sheets {
fmt.Fprintf(w, "SHEET\t%d\t%s\t%d\t%d\n", sh.Index, escape(sh.Name), sh.Height, sh.Width)
err := wb.EachRow(sh.Index, func(s reader.Sheet, rowIdx int, row []reader.Cell) error {
fmt.Fprintf(w, "ROW\t%d\t%d\t%d\n", s.Index, rowIdx, len(row))
for c, cell := range row {
fmt.Fprintf(w, "CELL\t%d\t%d\t%d\t%s\n", s.Index, rowIdx, c, escape(cell.Str))
}
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "rows: %v\n", err)
os.Exit(1)
}
}
}
+115
View File
@@ -0,0 +1,115 @@
// Command xlsxread reads .xls/.xlsx files and builds SQLite databases for the
// thptqg datasets.
//
// The CLI contract is fixed by parser/scripts/build-db.js and must match the
// Rust binary exactly:
//
// xlsxread build --schema <config.yml> --input <dir> --output <db>
// xlsxread audit --schema <config.yml> --input <dir> --db <db>
//
// Implemented with the standard flag package rather than a CLI framework: two
// subcommands with three flags each do not justify a dependency.
package main
import (
"flag"
"fmt"
"os"
"github.com/tiennm99/thptqg/parser/internal/audit"
"github.com/tiennm99/thptqg/parser/internal/config"
"github.com/tiennm99/thptqg/parser/internal/ingest"
)
func usage() {
fmt.Fprint(os.Stderr, `xlsxread — read .xls/.xlsx files and build SQLite databases for thptqg datasets
Usage:
xlsxread build --schema <config.yml> --input <dir> --output <db>
xlsxread audit --schema <config.yml> --input <dir> --db <db>
`)
}
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "build":
runBuild(os.Args[2:])
case "audit":
runAudit(os.Args[2:])
case "-h", "--help", "help":
usage()
default:
fmt.Fprintf(os.Stderr, "unknown subcommand %q\n\n", os.Args[1])
usage()
os.Exit(2)
}
}
func runBuild(args []string) {
fs := flag.NewFlagSet("build", flag.ExitOnError)
schemaPath := fs.String("schema", "", "path to the dataset YAML config file")
inputDir := fs.String("input", "", "directory containing the .xls / .xlsx source files")
outputPath := fs.String("output", "", "output SQLite database path")
fs.Parse(args)
if *schemaPath == "" || *inputDir == "" || *outputPath == "" {
fmt.Fprintln(os.Stderr, "build requires --schema, --input and --output")
os.Exit(2)
}
cfg, err := config.Load(*schemaPath)
if err != nil {
fatalf("Failed to load config: %v", err)
}
// The 2016 dataset selects its column layout per file at runtime; every other
// dataset uses the fixed columns: mapping (main.rs:63-67).
if cfg.FormatDetection != nil && *cfg.FormatDetection == "thptqg2016" {
if err := ingest.Detect2016(cfg, *inputDir, *outputPath); err != nil {
fatalf("%v", err)
}
return
}
if err := ingest.Standard(cfg, *inputDir, *outputPath); err != nil {
fatalf("%v", err)
}
}
func runAudit(args []string) {
fs := flag.NewFlagSet("audit", flag.ExitOnError)
schemaPath := fs.String("schema", "", "path to the dataset YAML config file")
inputDir := fs.String("input", "", "directory containing the .xlsx source files")
dbPath := fs.String("db", "", "SQLite database to compare against")
fs.Parse(args)
if *schemaPath == "" || *inputDir == "" || *dbPath == "" {
fmt.Fprintln(os.Stderr, "audit requires --schema, --input and --db")
os.Exit(2)
}
cfg, err := config.Load(*schemaPath)
if err != nil {
fatalf("Failed to load config: %v", err)
}
res, err := audit.Run(*inputDir, *dbPath, cfg)
if err != nil {
fatalf("%v", err)
}
audit.PrintReport(res)
// A mismatch is a non-zero exit even though the audit itself succeeded
// (main.rs:46-48) — CI treats it as a failure signal.
if !res.Matched {
os.Exit(1)
}
}
func fatalf(format string, a ...any) {
fmt.Fprintf(os.Stderr, "Error: "+format+"\n", a...)
os.Exit(1)
}
+32
View File
@@ -0,0 +1,32 @@
# thptqg2016 data/ — 4 .xls + 115 .xlsx mixed files.
#
# Three column layouts exist across the 119 files; the binary selects the
# right one per-file at runtime via format_detection: thptqg2016:
#
# separate-scores header SBD(0)/HOTEN(1)/TOAN(2)... — dhhanghai files
# mapped header SOBAODANH|SBD + DIEM_THI — most provinces
# default no header; positional 6-col layout — remaining files
#
# This is the only dataset that populates ten_cum_thi and gioi_tinh, and the
# only one whose files carry Tiếng Đức / Tiếng Nhật scores.
#
# sheet_mode = "all": several provinces overflow into Sheet2 (65k Excel row cap).
# strip_blank_rows = false: no blank-row anomaly observed in this dataset.
#
# Table shape, INSERT and subject regexes are canonical — see parser/internal/schema/schema.go.
format_detection: thptqg2016
reader:
sheet_mode: all
strip_blank_rows: false
validation:
require_numeric_sbd: false
require_nonempty_name: true
require_nonempty_sbd: true
header:
# Tokens that identify a header row by first-cell content (uppercased).
# Covers both SOBAODANH-style and SBD-style headers.
tokens: ["SOBAODANH", "SBD", "HO_TEN", "HOTEN", "HỌ TÊN", "STT"]
+25
View File
@@ -0,0 +1,25 @@
# thptqg2017 data/ — 63 .xls files from baotintuc.vn (current generation).
#
# sheet_mode = "all": Hà Nội and HCM overflow into Sheet2 (65k Excel row cap).
# SBD validation: no numeric guard — build-database.js did not apply ^\d+$.
# strip_blank_rows = false: no blank-row anomaly in this dataset.
#
# Table shape, INSERT and subject regexes are canonical — see parser/internal/schema/schema.go.
reader:
sheet_mode: all
strip_blank_rows: false
columns:
ho_ten: 0
ngay_sinh: 1
so_bao_danh: 2
diem_thi: 3
validation:
require_numeric_sbd: false
require_nonempty_name: true
require_nonempty_sbd: true
header:
tokens: ["HO_TEN", "HỌ TÊN", "STT"]
+30
View File
@@ -0,0 +1,30 @@
module github.com/tiennm99/thptqg/parser
go 1.26.5
require (
github.com/pbnjay/grate v0.0.0-20231006022435-3f8e65d74a14
github.com/xuri/excelize/v2 v2.11.0
golang.org/x/text v0.41.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.56.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.7 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+82
View File
@@ -0,0 +1,82 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pbnjay/grate v0.0.0-20231006022435-3f8e65d74a14 h1:ZfXdW7GIVZT3Z9oejLJ+GHrrQv/ezU2Bwqn0BF37s4g=
github.com/pbnjay/grate v0.0.0-20231006022435-3f8e65d74a14/go.mod h1:VaZEKQrYbYr2untVA/EFNdC6hM7GyARRNM+k4+5CmA0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0=
github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG88=
github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+148
View File
@@ -0,0 +1,148 @@
// Package audit compares distinct SBDs in the source spreadsheets against the
// row count in a built database — a port of parser/src/audit.rs.
//
// Two deliberate divergences from the build path are preserved, both inherited
// from audit-row-counts.js:
//
// - only .xlsx files are considered (audit.rs:53-59)
// - only sheet 0 is read, regardless of sheet_mode (audit.rs:81-84)
//
// These 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{})
// The audit uses positional defaults for format-detection configs, mirroring
// audit-row-counts.js's fixed column assumption (audit.rs:104-111).
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
// (audit.rs:139).
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 mirrors audit-row-counts.js:54-62 exactly.
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)
}
+113
View File
@@ -0,0 +1,113 @@
// Package config loads per-dataset parse rules — a port of parser/src/config.rs.
//
// The config deliberately carries no SQL. The table shape, the INSERT and the
// subject regexes are identical for every dataset and live in internal/schema;
// keeping them here meant four copies of the same DDL, which is how the 2016 and
// 2017 schemas drifted apart.
package config
import (
"bytes"
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// SheetMode selects which sheets of a workbook are read.
type SheetMode string
const (
// SheetModeAll iterates every sheet, which is what recovers the Hanoi and
// HCM rows that overflow past Excel's 65,536-row cap into a second sheet.
SheetModeAll SheetMode = "all"
// SheetModeFirst reads sheet 0 only.
SheetModeFirst SheetMode = "first"
)
// valid reports whether m is one of the two values the Rust enum accepts.
//
// Checked after decoding rather than via a custom unmarshaler: the decoder
// assigns named string types directly, so a typo would otherwise decode
// silently and be read as "not all" downstream.
func (m SheetMode) valid() bool {
return m == SheetModeAll || m == SheetModeFirst
}
// DatasetConfig is the per-dataset parse rule set.
type DatasetConfig struct {
Reader ReaderCfg `yaml:"reader"`
// Columns holds fixed column indices. Nil when FormatDetection handles
// per-file mapping — a pointer rather than a value because a zero ColumnMap
// would silently mean "every column is index 0".
Columns *ColumnMap `yaml:"columns"`
Validation ValidationCfg `yaml:"validation"`
Header HeaderCfg `yaml:"header"`
// FormatDetection, when set to "thptqg2016", enables per-file format
// auto-detection: each file's header row is inspected at runtime to choose
// the column layout (separate-scores / mapped / default-positional).
FormatDetection *string `yaml:"format_detection"`
}
type ReaderCfg struct {
SheetMode SheetMode `yaml:"sheet_mode"`
// StripBlankRows skips rows where every cell is empty before counting them
// as source rows (a 2017-old2 quirk).
StripBlankRows bool `yaml:"strip_blank_rows"`
}
// ColumnMap holds zero-indexed column positions in the source row. Used by the
// 2017 configs; 2016 uses runtime format detection instead.
type ColumnMap struct {
HoTen int `yaml:"ho_ten"`
NgaySinh int `yaml:"ngay_sinh"`
SoBaoDanh int `yaml:"so_bao_danh"`
DiemThi int `yaml:"diem_thi"`
}
type ValidationCfg struct {
// RequireNumericSbd mirrors build-database-old.js / -old2.js, which require
// so_bao_danh to match ^\d+$.
RequireNumericSbd bool `yaml:"require_numeric_sbd"`
RequireNonemptyName bool `yaml:"require_nonempty_name"`
RequireNonemptySbd bool `yaml:"require_nonempty_sbd"`
}
type HeaderCfg struct {
// Tokens are matched against the uppercased first cell to detect a header row.
Tokens []string `yaml:"tokens"`
}
// Parse decodes a config, rejecting any key the struct does not declare.
//
// Strictness is load-bearing and has its own test. Rust gets it from serde's
// deny_unknown_fields; yaml.v3 needs KnownFields(true) explicitly, and Go YAML
// decoders ignore unknown keys by default. Without it a leftover `schema:`
// mapping would look effective while internal/schema actually drove the build —
// the drift that produced two divergent schemas before the unification.
func Parse(src []byte) (*DatasetConfig, error) {
var cfg DatasetConfig
dec := yaml.NewDecoder(bytes.NewReader(src))
dec.KnownFields(true)
if err := dec.Decode(&cfg); err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
if !cfg.Reader.SheetMode.valid() {
return nil, fmt.Errorf("invalid sheet_mode %q (want %q or %q)",
cfg.Reader.SheetMode, SheetModeAll, SheetModeFirst)
}
return &cfg, nil
}
// Load reads and parses a config file.
func Load(path string) (*DatasetConfig, error) {
src, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
cfg, err := Parse(src)
if err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return cfg, nil
}
+224
View File
@@ -0,0 +1,224 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// sampleYAML mirrors SAMPLE_YAML in parser/src/config.rs.
const sampleYAML = `
reader:
sheet_mode: all
strip_blank_rows: false
columns:
ho_ten: 0
ngay_sinh: 1
so_bao_danh: 2
diem_thi: 3
validation:
require_numeric_sbd: false
require_nonempty_name: true
require_nonempty_sbd: true
header:
tokens: ["HO_TEN", "HỌ TÊN", "STT"]
`
// TestConfigRoundTrip ports config_round_trip (config.rs:115).
func TestConfigRoundTrip(t *testing.T) {
cfg, err := Parse([]byte(sampleYAML))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if cfg.Reader.SheetMode != SheetModeAll {
t.Errorf("sheet_mode = %q, want all", cfg.Reader.SheetMode)
}
if cfg.Reader.StripBlankRows {
t.Error("strip_blank_rows should be false")
}
if cfg.Columns == nil {
t.Fatal("columns should be present")
}
if cfg.Columns.HoTen != 0 {
t.Errorf("ho_ten = %d, want 0", cfg.Columns.HoTen)
}
if cfg.Columns.DiemThi != 3 {
t.Errorf("diem_thi = %d, want 3", cfg.Columns.DiemThi)
}
if cfg.Validation.RequireNumericSbd {
t.Error("require_numeric_sbd should be false")
}
if !cfg.Validation.RequireNonemptyName {
t.Error("require_nonempty_name should be true")
}
if len(cfg.Header.Tokens) != 3 {
t.Errorf("tokens = %d, want 3", len(cfg.Header.Tokens))
}
if cfg.FormatDetection != nil {
t.Errorf("format_detection = %v, want nil", *cfg.FormatDetection)
}
}
// TestConfigRejectsLeftoverSQLSections ports config_rejects_leftover_sql_sections
// (config.rs:132). This is the load-bearing one: Rust gets the behaviour free
// from serde's deny_unknown_fields, whereas Go YAML decoders ignore unknown keys
// unless KnownFields(true) is set. Without it a stale schema: mapping would look effective while
// internal/schema silently drove the build — exactly the drift that produced two
// divergent schemas before the unification.
func TestConfigRejectsLeftoverSQLSections(t *testing.T) {
withDDL := sampleYAML + "\nschema:\n ddl: \"CREATE TABLE student (so_bao_danh TEXT);\"\n"
if _, err := Parse([]byte(withDDL)); err == nil {
t.Fatal("config with a leftover schema: mapping must be rejected")
}
}
// TestConfigRejectsUnknownScalarKey guards the same property for a stray scalar,
// not just a stray mapping.
func TestConfigRejectsUnknownScalarKey(t *testing.T) {
withKey := sampleYAML + "\nunexpected_key: 1\n"
if _, err := Parse([]byte(withKey)); err == nil {
t.Fatal("config with an unknown scalar key must be rejected")
}
}
// TestConfigFirstSheetMode ports config_first_sheet_mode (config.rs:140).
func TestConfigFirstSheetMode(t *testing.T) {
src := []byte(replaceAll(sampleYAML, "sheet_mode: all", "sheet_mode: first"))
cfg, err := Parse(src)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if cfg.Reader.SheetMode != SheetModeFirst {
t.Errorf("sheet_mode = %q, want first", cfg.Reader.SheetMode)
}
}
// TestConfigRejectsUnknownSheetMode: the Rust enum accepts only "all"/"first",
// so anything else must fail rather than defaulting.
func TestConfigRejectsUnknownSheetMode(t *testing.T) {
src := []byte(replaceAll(sampleYAML, "sheet_mode: all", "sheet_mode: second"))
if _, err := Parse(src); err == nil {
t.Fatal("unknown sheet_mode must be rejected")
}
}
// TestConfigFormatDetectionField ports config_format_detection_field
// (config.rs:147): a 2016-style config has no columns: mapping at all.
func TestConfigFormatDetectionField(t *testing.T) {
const src = `
format_detection: thptqg2016
reader:
sheet_mode: all
strip_blank_rows: false
validation:
require_numeric_sbd: false
require_nonempty_name: true
require_nonempty_sbd: true
header:
tokens: ["SBD", "SOBAODANH", "STT"]
`
cfg, err := Parse([]byte(src))
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if cfg.FormatDetection == nil || *cfg.FormatDetection != "thptqg2016" {
t.Errorf("format_detection = %v, want thptqg2016", cfg.FormatDetection)
}
if cfg.Columns != nil {
t.Error("columns must be nil when format_detection drives the layout")
}
}
// TestLoadRealConfigs loads the shipped configs and asserts the per-dataset
// differences recorded during scouting.
func TestLoadRealConfigs(t *testing.T) {
root := repoRoot(t)
want := map[string]struct {
sheetMode SheetMode
stripBlank bool
numericSbd bool
hasColumns bool
formatDet string
tokenCount int
}{
"2016": {SheetModeAll, false, false, false, "thptqg2016", 6},
"2017": {SheetModeAll, false, false, true, "", 3},
}
for id, w := range want {
t.Run(id, func(t *testing.T) {
cfg, err := Load(filepath.Join(root, "parser", "configs", id+".yml"))
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Reader.SheetMode != w.sheetMode {
t.Errorf("sheet_mode = %q, want %q", cfg.Reader.SheetMode, w.sheetMode)
}
if cfg.Reader.StripBlankRows != w.stripBlank {
t.Errorf("strip_blank_rows = %v, want %v", cfg.Reader.StripBlankRows, w.stripBlank)
}
if cfg.Validation.RequireNumericSbd != w.numericSbd {
t.Errorf("require_numeric_sbd = %v, want %v", cfg.Validation.RequireNumericSbd, w.numericSbd)
}
if (cfg.Columns != nil) != w.hasColumns {
t.Errorf("columns present = %v, want %v", cfg.Columns != nil, w.hasColumns)
}
got := ""
if cfg.FormatDetection != nil {
got = *cfg.FormatDetection
}
if got != w.formatDet {
t.Errorf("format_detection = %q, want %q", got, w.formatDet)
}
if len(cfg.Header.Tokens) != w.tokenCount {
t.Errorf("tokens = %d, want %d", len(cfg.Header.Tokens), w.tokenCount)
}
// Every dataset requires a non-empty name and SBD.
if !cfg.Validation.RequireNonemptyName || !cfg.Validation.RequireNonemptySbd {
t.Error("both non-empty validations should be true for every dataset")
}
})
}
}
func replaceAll(s, old, new string) string {
out := ""
for {
i := indexOf(s, old)
if i < 0 {
return out + s
}
out += s[:i] + new
s = s[i+len(old):]
}
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
func repoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
for i := 0; i < 6; i++ {
if _, err := os.Stat(filepath.Join(dir, "parser", "configs")); err == nil {
return dir
}
dir = filepath.Dir(dir)
}
t.Fatal("could not locate repo root")
return ""
}
+382
View File
@@ -0,0 +1,382 @@
package ingest
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/tiennm99/thptqg/parser/internal/config"
"github.com/tiennm99/thptqg/parser/internal/reader"
"github.com/tiennm99/thptqg/parser/internal/transform"
"github.com/tiennm99/thptqg/parser/internal/writer"
)
// The 2016 dataset's 119 files were produced by inconsistent tooling and use
// three different column layouts, chosen per sheet at runtime. This is a port of
// parser/src/format_detect_2016.rs — institutional knowledge encoded as
// literals, with no abstraction to derive it from, so everything here is copied
// verbatim rather than rationalised.
// KnownHeaders are the upper-cased first-cell values that identify a header row
// (format_detect_2016.rs:36-54, mirroring KNOWN_HEADERS in build-database.js).
//
// NOTE "SINH " carries a TRAILING SPACE, exactly as in the Rust source. Trimming
// it would change which rows are recognised as headers.
var KnownHeaders = []string{
"SOBAODANH",
"SBD",
"HO_TEN",
"HOTEN",
"HỌ TÊN",
"NGAY_SINH",
"TEN_CUMTHI",
"GIOI_TINH",
"DIEM_THI",
"STT",
"TOAN",
"VAN",
"LY",
"HOA",
"SINH ",
"SU",
"DIA",
}
func isKnownHeader(s string) bool {
for _, h := range KnownHeaders {
if h == s {
return true
}
}
return false
}
// IsHeaderRow2016 reports whether row[0] is a known header token
// (format_detect_2016.rs:58-64).
//
// The guard here is len < 2, not the len < 3 used by the 2017 header check.
func IsHeaderRow2016(row []reader.Cell) bool {
if len(row) < 2 {
return false
}
return isKnownHeader(strings.ToUpper(strings.TrimSpace(row[0].Str)))
}
// FormatKind is one of the three 2016 layouts.
type FormatKind int
const (
// FormatSeparateScores has one column per subject rather than a free-text
// DIEM_THI cell — the dhhanghai-style files.
FormatSeparateScores FormatKind = iota
// FormatMapped resolves column indices from the header by name.
FormatMapped
// FormatDefault is the positional 6-column layout used when no header is
// recognised. It is FormatMapped with fixed indices, not a separate path.
FormatDefault
)
// Format is a detected layout plus, for the mapped case, its column indices.
type Format struct {
Kind FormatKind
Sbd int
HoTen int
NgaySinh *int
TenCumThi *int
GioiTinh *int
DiemThi int
}
// defaultFormat is FormatMapped with the fixed positional indices
// (format_detect_2016.rs:295-306).
func defaultFormat() Format {
two, three, four := 2, 3, 4
return Format{
Kind: FormatDefault, Sbd: 0, HoTen: 1,
NgaySinh: &two, TenCumThi: &three, GioiTinh: &four, DiemThi: 5,
}
}
// DetectFormat inspects a header row and decides which layout applies
// (format_detect_2016.rs:95-145).
func DetectFormat(headerRow []reader.Cell) Format {
cols := make([]string, len(headerRow))
for i, c := range headerRow {
cols[i] = strings.ToUpper(strings.TrimSpace(c.Str))
}
// Format 1: SBD in col 0 AND TOAN in col 2.
if len(cols) > 2 && cols[0] == "SBD" && cols[2] == "TOAN" {
return Format{Kind: FormatSeparateScores}
}
// Format 2: resolve indices by header name, order-independent.
var sbdIdx, hoTenIdx, ngaySinhIdx, tenCumThiIdx, gioiTinhIdx, diemThiIdx *int
for i := range cols {
idx := i
switch cols[i] {
case "SOBAODANH", "SBD":
sbdIdx = &idx
case "HO_TEN", "HOTEN", "HỌ TÊN":
hoTenIdx = &idx
case "NGAY_SINH":
ngaySinhIdx = &idx
case "TEN_CUMTHI":
tenCumThiIdx = &idx
case "GIOI_TINH":
gioiTinhIdx = &idx
case "DIEM_THI":
diemThiIdx = &idx
}
}
if sbdIdx != nil && diemThiIdx != nil {
hoTen := 1 // fallback: col 1, present in all known files
if hoTenIdx != nil {
hoTen = *hoTenIdx
}
return Format{
Kind: FormatMapped, Sbd: *sbdIdx, HoTen: hoTen,
NgaySinh: ngaySinhIdx, TenCumThi: tenCumThiIdx, GioiTinh: gioiTinhIdx,
DiemThi: *diemThiIdx,
}
}
// Unrecognised header, or none at all.
return defaultFormat()
}
// parseFloatCell parses a per-subject score cell.
//
// A parsed 0.0 becomes "no score" — this replicates JavaScript's
// `parseFloat(row[N]) || null`, where 0 is falsy (format_detect_2016.rs:165).
// It means a genuine zero is indistinguishable from a blank. Not obviously
// correct, but it is the shipped behaviour and the published data depends on it.
func parseFloatCell(row []reader.Cell, idx int) (float64, bool) {
s := cellAt(row, idx)
if s == "" {
return 0, false
}
v, err := strconv.ParseFloat(s, 64)
if err != nil || v == 0.0 {
return 0, false
}
return v, true
}
// processSeparateScoresRow handles the fixed 12-column layout
// (format_detect_2016.rs:176-216):
//
// 0=SBD 1=HOTEN 2=TOAN 3=VAN 4=LY 5=HOA 6=SINH 7=SU 8=DIA
// 9=NGOAINGUTN 10=NGOAINGUTL 11=NGOAINGU(total -> tieng_anh)
//
// tieng_phap / tieng_duc / tieng_nhat / tieng_trung are structurally unreachable
// in this format, and ngay_sinh / ten_cum_thi / gioi_tinh are always nil.
// Scores are read as floats directly; this layout has no free-text score cell,
// so the subject regexes never run.
func processSeparateScoresRow(row []reader.Cell) *transform.ParsedRow {
sbd := cellAt(row, 0)
hoTen := cellAt(row, 1)
if sbd == "" || hoTen == "" {
return nil
}
scores := make(map[string]float64)
for field, idx := range map[string]int{
"toan": 2, "ngu_van": 3, "vat_ly": 4, "hoa_hoc": 5,
"sinh_hoc": 6, "lich_su": 7, "dia_ly": 8,
"tieng_anh": 11, // NGOAINGU total
} {
if v, ok := parseFloatCell(row, idx); ok {
scores[field] = v
}
}
return &transform.ParsedRow{
SoBaoDanh: sbd,
HoTen: hoTen,
HoTenAscii: transform.ToAscii(hoTen),
Scores: scores,
}
}
// processMappedRow handles header-derived column indices
// (format_detect_2016.rs:226-289).
func processMappedRow(row []reader.Cell, f Format) *transform.ParsedRow {
sbd := cellAt(row, f.Sbd)
hoTen := cellAt(row, f.HoTen)
if sbd == "" || hoTen == "" {
return nil
}
// Leaked-header guard: a row whose SBD or name cell is itself a header token
// is a repeated header, not data (format_detect_2016.rs:244-250).
if isKnownHeader(strings.ToUpper(sbd)) || isKnownHeader(strings.ToUpper(hoTen)) {
return nil
}
optional := func(idx *int) *string {
if idx == nil {
return nil
}
if s := cellAt(row, *idx); s != "" {
return &s
}
return nil
}
// Gender is a two-value allowlist, not a general enum: anything other than
// exactly "Nam" or "Nữ" becomes nil (format_detect_2016.rs:263-271).
var gioiTinh *string
if f.GioiTinh != nil {
if s := cellAt(row, *f.GioiTinh); s == "Nam" || s == "Nữ" {
gioiTinh = &s
}
}
// Read untrimmed, matching format_detect_2016.rs:273-276.
diemThi := ""
if f.DiemThi >= 0 && f.DiemThi < len(row) {
diemThi = row[f.DiemThi].Str
}
return &transform.ParsedRow{
SoBaoDanh: sbd,
HoTen: hoTen,
HoTenAscii: transform.ToAscii(hoTen),
NgaySinh: optional(f.NgaySinh),
TenCumThi: optional(f.TenCumThi),
GioiTinh: gioiTinh,
Scores: transform.ParseScores(diemThi),
}
}
// ProcessRow2016 dispatches a data row through the detected layout. A nil return
// means the row is empty or invalid and should be skipped.
func ProcessRow2016(row []reader.Cell, f Format) *transform.ParsedRow {
if f.Kind == FormatSeparateScores {
return processSeparateScoresRow(row)
}
// FormatMapped and FormatDefault share one implementation; Default is just a
// fixed index tuple.
return processMappedRow(row, f)
}
// Detect2016 ingests the 2016 dataset — the port of run_build_2016 and
// process_file_2016 (main.rs:211-377).
func Detect2016(cfg *config.DatasetConfig, inputDir, outputPath string) error {
files, err := InputFiles(inputDir)
if err != nil {
return err
}
label := DatasetLabel(inputDir)
fmt.Printf("[build:2016] %s/ → %s (%d files)\n", label, outputPath, len(files))
db, err := writer.OpenDB(outputPath)
if err != nil {
return err
}
defer db.Close()
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
ins, err := writer.Prepare(tx)
if err != nil {
tx.Rollback()
return err
}
var st writer.Stats // Skipped stays 0 on this path, matching main.rs:251
for _, file := range files {
base := filepath.Base(file)
fileRows, err := processFile2016(file, cfg, ins, base, &st)
if err != nil {
fmt.Fprintf(os.Stderr, " [error] %s: %v\n", base, err)
st.Errors++
continue
}
fmt.Printf(" %s: %d rows\n", base, fileRows)
}
if err := ins.Close(); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return writer.Finish(db, outputPath, st, label, false)
}
// processFile2016 detects the layout per SHEET and processes that sheet's rows.
//
// Detection is per sheet, not per file (main.rs:344-349): within one workbook,
// sheet 2 may legitimately detect differently from sheet 1, which matters for
// the province files that overflow past Excel's row cap.
func processFile2016(path string, cfg *config.DatasetConfig, ins *writer.Inserter, base string, st *writer.Stats) (uint64, error) {
wb, err := reader.Open(path)
if err != nil {
return 0, err
}
defer wb.Close()
sheets := wb.Sheets()
if len(sheets) == 0 {
return 0, nil
}
if cfg.Reader.SheetMode == config.SheetModeFirst {
sheets = sheets[:1]
}
var fileRows uint64
for _, sh := range sheets {
var rows [][]reader.Cell
if err := wb.EachRow(sh.Index, func(_ reader.Sheet, _ int, row []reader.Cell) error {
rows = append(rows, row)
return nil
}); err != nil {
return fileRows, err
}
if len(rows) == 0 {
continue
}
f := defaultFormat()
startIdx := 0
if IsHeaderRow2016(rows[0]) {
f = DetectFormat(rows[0])
startIdx = 1
}
for _, row := range rows[startIdx:] {
// Rows shorter than 2 cells are dropped BEFORE the counter
// (main.rs:351-353), so they never appear in the source total.
if len(row) < 2 {
continue
}
st.SourceRows++
parsed := ProcessRow2016(row, f)
if parsed == nil {
// Empty or invalid. Note this is NOT counted as skipped — the
// 2016 path leaves that counter at zero (main.rs:251), so the
// stats block reports insertable == source rows and the Audit
// line absorbs the difference.
continue
}
if err := ins.Insert(parsed); err != nil {
st.Errors++
if st.Errors <= 5 {
fmt.Fprintf(os.Stderr, " [warn] %s: %v\n", base, err)
}
continue
}
fileRows++
}
}
return fileRows, nil
}
+252
View File
@@ -0,0 +1,252 @@
package ingest
import (
"testing"
"github.com/tiennm99/thptqg/parser/internal/reader"
)
// Ports the 11 tests in parser/src/format_detect_2016.rs, plus a guard per quirk.
//
// Phase 1 settled the Data -> string translation these fixtures need, so no
// guessing: Data::String(s) is s verbatim, Data::Float(8.0) renders "8" (Rust's
// f64 Display drops the .0, matching Go's FormatFloat(v,'f',-1,64)),
// Data::Float(8.5) renders "8.5", and Data::Empty is "" with IsEmpty true.
// --- header detection ---
func TestIsHeaderRow2016(t *testing.T) {
if !IsHeaderRow2016(cells("SBD", "HOTEN", "TOAN")) {
t.Error("SBD header not detected")
}
if !IsHeaderRow2016(cells("sobaodanh", "x")) {
t.Error("lowercase SOBAODANH not detected")
}
if IsHeaderRow2016(cells("Nguyen Van A", "01/01/2000")) {
t.Error("data row wrongly detected as header")
}
// The 2016 guard is len < 2, unlike the 2017 check's len < 3.
if IsHeaderRow2016(cells("SBD")) {
t.Error("single-cell row must not be a header")
}
if !IsHeaderRow2016(cells("SBD", "x")) {
t.Error("two-cell row with a header token must be a header")
}
}
// TestKnownHeadersHasTrailingSpaceToken pins the "SINH " literal. Trimming it
// would silently change which rows count as headers.
func TestKnownHeadersHasTrailingSpaceToken(t *testing.T) {
var found bool
for _, h := range KnownHeaders {
if h == "SINH " {
found = true
}
}
if !found {
t.Error(`KnownHeaders must contain "SINH " WITH its trailing space`)
}
if len(KnownHeaders) != 17 {
t.Errorf("KnownHeaders has %d tokens, want 17", len(KnownHeaders))
}
}
// --- format detection ---
func TestDetectFormatSeparateScores(t *testing.T) {
f := DetectFormat(cells("SBD", "HOTEN", "TOAN", "VAN"))
if f.Kind != FormatSeparateScores {
t.Errorf("Kind = %v, want FormatSeparateScores", f.Kind)
}
}
func TestDetectFormatMapped(t *testing.T) {
f := DetectFormat(cells("STT", "SOBAODANH", "HO_TEN", "NGAY_SINH", "TEN_CUMTHI", "GIOI_TINH", "DIEM_THI"))
if f.Kind != FormatMapped {
t.Fatalf("Kind = %v, want FormatMapped", f.Kind)
}
if f.Sbd != 1 || f.HoTen != 2 || f.DiemThi != 6 {
t.Errorf("indices sbd=%d ho_ten=%d diem_thi=%d", f.Sbd, f.HoTen, f.DiemThi)
}
if f.NgaySinh == nil || *f.NgaySinh != 3 || f.TenCumThi == nil || *f.TenCumThi != 4 || f.GioiTinh == nil || *f.GioiTinh != 5 {
t.Error("optional indices not resolved")
}
}
// TestDetectFormatMappedIsOrderIndependent: indices are resolved by name.
func TestDetectFormatMappedIsOrderIndependent(t *testing.T) {
f := DetectFormat(cells("DIEM_THI", "HO_TEN", "SBD"))
if f.Kind != FormatMapped || f.Sbd != 2 || f.DiemThi != 0 || f.HoTen != 1 {
t.Errorf("got %+v", f)
}
}
// TestDetectFormatMappedHoTenFallback ports the col-1 fallback
// (format_detect_2016.rs:132).
func TestDetectFormatMappedHoTenFallback(t *testing.T) {
f := DetectFormat(cells("SBD", "SOMETHING", "DIEM_THI"))
if f.Kind != FormatMapped {
t.Fatalf("Kind = %v, want FormatMapped", f.Kind)
}
if f.HoTen != 1 {
t.Errorf("ho_ten = %d, want fallback 1", f.HoTen)
}
}
// TestDetectFormatDefault: a header lacking SBD or DIEM_THI falls back to the
// positional layout.
func TestDetectFormatDefault(t *testing.T) {
f := DetectFormat(cells("A", "B", "C"))
if f.Kind != FormatDefault {
t.Errorf("Kind = %v, want FormatDefault", f.Kind)
}
if f.Sbd != 0 || f.HoTen != 1 || f.DiemThi != 5 {
t.Errorf("default indices wrong: %+v", f)
}
}
// --- row processing ---
func TestProcessSeparateScoresRow(t *testing.T) {
// 0=SBD 1=HOTEN 2=TOAN 3=VAN 4=LY 5=HOA 6=SINH 7=SU 8=DIA 9,10=NN 11=NN total
row := cells("1000", "Nguyễn Văn Đức", "8", "7.5", "", "6", "", "5", "", "", "", "9.25")
got := ProcessRow2016(row, Format{Kind: FormatSeparateScores})
if got == nil {
t.Fatal("row rejected")
}
if got.SoBaoDanh != "1000" || got.HoTenAscii != "nguyen van duc" {
t.Errorf("sbd=%q ascii=%q", got.SoBaoDanh, got.HoTenAscii)
}
want := map[string]float64{"toan": 8, "ngu_van": 7.5, "hoa_hoc": 6, "lich_su": 5, "tieng_anh": 9.25}
if len(got.Scores) != len(want) {
t.Errorf("scores = %v, want %v", got.Scores, want)
}
for k, v := range want {
if got.Scores[k] != v {
t.Errorf("%s = %v, want %v", k, got.Scores[k], v)
}
}
// These columns are structurally unreachable in this layout.
for _, absent := range []string{"tieng_phap", "tieng_duc", "tieng_nhat", "tieng_trung", "khtn", "khxh", "gdcd"} {
if _, ok := got.Scores[absent]; ok {
t.Errorf("%s must be unreachable in separate-scores", absent)
}
}
if got.NgaySinh != nil || got.TenCumThi != nil || got.GioiTinh != nil {
t.Error("ngay_sinh/ten_cum_thi/gioi_tinh are always nil in separate-scores")
}
}
// TestZeroScoreBecomesNull pins the JS falsy quirk: parseFloat(x) || null means
// a literal 0 is indistinguishable from "no score" (format_detect_2016.rs:165).
func TestZeroScoreBecomesNull(t *testing.T) {
row := cells("1000", "A", "0", "0.0", "1", "", "", "", "", "", "", "")
got := ProcessRow2016(row, Format{Kind: FormatSeparateScores})
if got == nil {
t.Fatal("row rejected")
}
if _, ok := got.Scores["toan"]; ok {
t.Error(`a "0" score must become NULL, not 0`)
}
if _, ok := got.Scores["ngu_van"]; ok {
t.Error(`a "0.0" score must become NULL, not 0`)
}
if got.Scores["vat_ly"] != 1 {
t.Error("a non-zero score must survive")
}
}
// TestGenderAllowlist ports format_detect_2016.rs:263-271 — exactly two values.
func TestGenderAllowlist(t *testing.T) {
f := defaultFormat()
for in, want := range map[string]string{"Nam": "Nam", "Nữ": "Nữ"} {
row := cells("1", "A", "", "", in, "")
got := ProcessRow2016(row, f)
if got == nil || got.GioiTinh == nil || *got.GioiTinh != want {
t.Errorf("gioi_tinh for %q not preserved", in)
}
}
for _, in := range []string{"nam", "NAM", "Unknown", "M", "F", "nữ", ""} {
row := cells("1", "A", "", "", in, "")
got := ProcessRow2016(row, f)
if got == nil {
t.Fatalf("row rejected for %q", in)
}
if got.GioiTinh != nil {
t.Errorf("gioi_tinh for %q = %q, want nil", in, *got.GioiTinh)
}
}
}
// TestLeakedHeaderRowSkipped ports format_detect_2016.rs:244-250.
func TestLeakedHeaderRowSkipped(t *testing.T) {
f := defaultFormat()
if ProcessRow2016(cells("SBD", "HO_TEN", "", "", "", ""), f) != nil {
t.Error("a repeated header row must be skipped")
}
if ProcessRow2016(cells("123", "SOBAODANH", "", "", "", ""), f) != nil {
t.Error("a row whose name cell is a header token must be skipped")
}
if ProcessRow2016(cells("123", "Nguyen Van A", "", "", "", ""), f) == nil {
t.Error("a genuine data row must not be skipped")
}
}
// TestMappedRowEmptyFieldsRejected: either identity field empty rejects the row.
func TestMappedRowEmptyFieldsRejected(t *testing.T) {
f := defaultFormat()
if ProcessRow2016(cells("", "A", "", "", "", ""), f) != nil {
t.Error("empty sbd must reject")
}
if ProcessRow2016(cells("1", "", "", "", "", ""), f) != nil {
t.Error("empty ho_ten must reject")
}
}
// TestMappedRowPopulates2016OnlyColumns: this is the only dataset that fills
// ten_cum_thi and gioi_tinh.
func TestMappedRowPopulates2016OnlyColumns(t *testing.T) {
row := cells("123", "Lê Văn Long", "01/01/1998", "Cụm thi số 1", "Nam", "Toán: 7.5 Tiếng Đức: 6")
got := ProcessRow2016(row, defaultFormat())
if got == nil {
t.Fatal("row rejected")
}
if got.NgaySinh == nil || *got.NgaySinh != "01/01/1998" {
t.Errorf("ngay_sinh = %v", got.NgaySinh)
}
if got.TenCumThi == nil || *got.TenCumThi != "Cụm thi số 1" {
t.Errorf("ten_cum_thi = %v", got.TenCumThi)
}
if got.Scores["toan"] != 7.5 || got.Scores["tieng_duc"] != 6 {
t.Errorf("scores = %v", got.Scores)
}
}
// TestDefaultIsMappedWithFixedIndices: FormatDefault must not be a separate code
// path (format_detect_2016.rs:295-306).
func TestDefaultIsMappedWithFixedIndices(t *testing.T) {
row := cells("123", "A", "01/01/2000", "Cluster", "Nữ", "Toán: 5")
viaDefault := ProcessRow2016(row, defaultFormat())
two, three, four := 2, 3, 4
viaMapped := ProcessRow2016(row, Format{
Kind: FormatMapped, Sbd: 0, HoTen: 1,
NgaySinh: &two, TenCumThi: &three, GioiTinh: &four, DiemThi: 5,
})
if viaDefault == nil || viaMapped == nil {
t.Fatal("row rejected")
}
if viaDefault.SoBaoDanh != viaMapped.SoBaoDanh ||
*viaDefault.TenCumThi != *viaMapped.TenCumThi ||
*viaDefault.GioiTinh != *viaMapped.GioiTinh ||
viaDefault.Scores["toan"] != viaMapped.Scores["toan"] {
t.Error("FormatDefault must behave identically to the equivalent FormatMapped")
}
}
// TestShortRowGuardIsSeparateFromValidation documents that rows under 2 cells
// are dropped by the loop before the counter (main.rs:351-353), not here.
func TestShortRowGuardIsSeparateFromValidation(t *testing.T) {
if got := ProcessRow2016([]reader.Cell{{Str: "123"}}, defaultFormat()); got != nil {
t.Error("a 1-cell row has no name and must be rejected")
}
}
+243
View File
@@ -0,0 +1,243 @@
// Package ingest owns all dataset policy: which sheets to read, which rows are
// headers, which are blank, and how rows are counted.
//
// The reader deliberately has none of this — it reports every sheet and every
// row exactly as calamine would, which is what made its fidelity independently
// testable. This package ports the build loop in parser/src/main.rs plus the
// header/blank helpers in parser/src/reader.rs.
package ingest
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/tiennm99/thptqg/parser/internal/config"
"github.com/tiennm99/thptqg/parser/internal/reader"
"github.com/tiennm99/thptqg/parser/internal/transform"
"github.com/tiennm99/thptqg/parser/internal/writer"
)
// IsHeaderRow reports whether row is a header, by matching its uppercased first
// cell against the configured tokens (reader.rs:28-34).
//
// Rows shorter than 3 cells are never headers (reader.rs:29) — a 1- or 2-cell
// row is a stray fragment, not a real header.
func IsHeaderRow(row []reader.Cell, tokens []string) bool {
if len(row) < 3 {
return false
}
first := strings.ToUpper(strings.TrimSpace(row[0].Str))
for _, t := range tokens {
if strings.ToUpper(t) == first {
return true
}
}
return false
}
// IsAllBlank reports whether every cell is empty or whitespace-only
// (reader.rs:40-43).
//
// Compares on Str only. Cell.IsEmpty is diagnostic: calamine distinguishes
// Data::Empty from an empty string cell, but both render "" and both count as
// blank here, so branching on the flag would invent a distinction the Rust
// original never acts on.
func IsAllBlank(row []reader.Cell) bool {
for _, c := range row {
if strings.TrimSpace(c.Str) != "" {
return false
}
}
return true
}
// InputFiles lists a dataset directory's spreadsheets, sorted.
//
// The sort is load-bearing, not cosmetic: INSERT OR REPLACE is last-wins, so
// file order decides which row survives a duplicate SBD. Rust collects read_dir
// then calls files.sort() (main.rs:82-97) — a bytewise sort on the full path.
func InputFiles(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("cannot read input dir %s: %w", dir, err)
}
var out []string
for _, e := range entries {
if e.IsDir() {
continue
}
switch strings.ToLower(filepath.Ext(e.Name())) {
case ".xls", ".xlsx":
out = append(out, filepath.Join(dir, e.Name()))
}
}
sort.Strings(out)
return out, nil
}
// DatasetLabel derives the stats-wording key from the input directory basename,
// matching main.rs:98-101.
func DatasetLabel(inputDir string) string {
base := filepath.Base(strings.TrimRight(inputDir, string(filepath.Separator)))
if base == "" || base == "." || base == string(filepath.Separator) {
return "data"
}
return base
}
// RowFn consumes one data row of one sheet, after header skipping.
type RowFn func(sheetIdx int, row []reader.Cell)
// ProcessFile applies sheet selection and per-sheet header skipping, invoking fn
// for every remaining row — the port of reader.rs:54-105.
//
// The header check is per SHEET, not per file: first_row resets inside the sheet
// loop (reader.rs:91), so a workbook whose second sheet repeats the header has
// it skipped there too.
func ProcessFile(path string, cfg *config.DatasetConfig, fn RowFn) error {
wb, err := reader.Open(path)
if err != nil {
return err
}
defer wb.Close()
sheets := wb.Sheets()
if len(sheets) == 0 {
return fmt.Errorf("no sheets in %s", path)
}
if cfg.Reader.SheetMode == config.SheetModeFirst {
sheets = sheets[:1]
}
for _, sh := range sheets {
firstRow := true
err := wb.EachRow(sh.Index, func(s reader.Sheet, _ int, row []reader.Cell) error {
if firstRow {
firstRow = false
if IsHeaderRow(row, cfg.Header.Tokens) {
return nil
}
}
fn(s.Index, row)
return nil
})
if err != nil {
return err
}
}
return nil
}
// Standard runs the fixed-column path for the 2017-family datasets — the port of
// run_build_standard (main.rs:74-199).
func Standard(cfg *config.DatasetConfig, inputDir, outputPath string) error {
files, err := InputFiles(inputDir)
if err != nil {
return err
}
label := DatasetLabel(inputDir)
fmt.Printf("[build] %s/ → %s (%d files)\n", label, outputPath, len(files))
db, err := writer.OpenDB(outputPath)
if err != nil {
return err
}
defer db.Close()
isOld2 := strings.Contains(label, "old2")
stripBlank := cfg.Reader.StripBlankRows
// One transaction spans the whole dataset directory (main.rs:120,184).
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin: %w", err)
}
ins, err := writer.Prepare(tx)
if err != nil {
tx.Rollback()
return err
}
var st writer.Stats
for _, file := range files {
base := filepath.Base(file)
var fileRows, fileSkipped, fileErrors uint64
procErr := ProcessFile(file, cfg, func(_ int, row []reader.Cell) {
allBlank := IsAllBlank(row)
// 2017-old2: blank rows drop out BEFORE the source-row counter
// (main.rs:134-137, ahead of the increment at :140).
if stripBlank && allBlank {
return
}
st.SourceRows++
hoTen, soBaoDanh := "", ""
if cols := cfg.Columns; cols != nil {
hoTen = cellAt(row, cols.HoTen)
soBaoDanh = cellAt(row, cols.SoBaoDanh)
}
switch transform.ValidateRow(hoTen, soBaoDanh, &cfg.Validation, stripBlank, allBlank) {
case transform.SkipBlankRow:
// Falls through to transform and insert, matching main.rs:150.
// Unreachable here: BlankRow requires stripBlank && allBlank,
// which returned above. Kept so the two call sites with opposite
// outcomes stay visibly distinct.
case transform.SkipNone:
// proceed
default:
fileSkipped++
return
}
parsed, err := transform.TransformRow(row, cfg)
if err != nil {
fileErrors++
return
}
if err := ins.Insert(parsed); err != nil {
fileErrors++
// Only the first five insert warnings print (main.rs:164).
if st.Errors+fileErrors <= 5 {
fmt.Fprintf(os.Stderr, " [warn] %s: %v\n", base, err)
}
return
}
fileRows++
})
if procErr != nil {
// A file that cannot be read is logged and counted, never fatal
// (main.rs:171-177) — one corrupt file must not abandon the batch.
fmt.Fprintf(os.Stderr, " [error] %s: %v\n", base, procErr)
fileErrors++
}
st.Skipped += fileSkipped
st.Errors += fileErrors
fmt.Printf(" %s: %d rows\n", base, fileRows)
}
if err := ins.Close(); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
// VACUUM only after COMMIT — SQLite refuses it inside a transaction.
return writer.Finish(db, outputPath, st, label, isOld2)
}
// cellAt returns the trimmed cell at idx, or "" when out of range — the
// unwrap_or_default() behaviour of transform.rs:163-167.
func cellAt(row []reader.Cell, idx int) string {
if idx < 0 || idx >= len(row) {
return ""
}
return strings.TrimSpace(row[idx].Str)
}
+131
View File
@@ -0,0 +1,131 @@
package ingest
import (
"os"
"path/filepath"
"testing"
"github.com/tiennm99/thptqg/parser/internal/reader"
)
// Ports the 7 tests in parser/src/reader.rs:116-197. They were listed under
// Phase 1 originally, which was wrong: they exercise header and blank-row
// policy, which lives here rather than in the reader package.
func cells(vals ...string) []reader.Cell {
out := make([]reader.Cell, len(vals))
for i, v := range vals {
out[i] = reader.Cell{Str: v, IsEmpty: v == ""}
}
return out
}
var stdTokens = []string{"HO_TEN", "HỌ TÊN", "STT"}
// header_detects_ho_ten (reader.rs:128)
func TestHeaderDetectsHoTen(t *testing.T) {
if !IsHeaderRow(cells("HO_TEN", "NGAY_SINH", "SBD"), stdTokens) {
t.Error("HO_TEN header not detected")
}
}
// header_detects_stt (reader.rs:139)
func TestHeaderDetectsStt(t *testing.T) {
if !IsHeaderRow(cells("STT", "B", "C"), stdTokens) {
t.Error("STT header not detected")
}
}
// header_detects_ho_ten_unicode (reader.rs:150)
func TestHeaderDetectsHoTenUnicode(t *testing.T) {
if !IsHeaderRow(cells("HỌ TÊN", "B", "C"), stdTokens) {
t.Error("HỌ TÊN header not detected")
}
}
// header_rejects_data_row (reader.rs:161)
func TestHeaderRejectsDataRow(t *testing.T) {
if IsHeaderRow(cells("Nguyen Van A", "01/01/2000", "12345678"), stdTokens) {
t.Error("data row wrongly detected as header")
}
}
// header_rejects_short_row (reader.rs:172) — the <3 cell guard.
func TestHeaderRejectsShortRow(t *testing.T) {
if IsHeaderRow(cells("HO_TEN", ""), stdTokens) {
t.Error("a 2-cell row must never be a header, even with a matching token")
}
}
// header_case_insensitive (reader.rs:179)
func TestHeaderCaseInsensitive(t *testing.T) {
if !IsHeaderRow(cells("ho_ten", "B", "C"), stdTokens) {
t.Error("lowercase header not detected")
}
}
// blank_row_detection (reader.rs:190) — note the third cell is an empty *string*
// cell, not Data::Empty, and must still count as blank.
func TestBlankRowDetection(t *testing.T) {
blank := []reader.Cell{{IsEmpty: true}, {IsEmpty: true}, {Str: ""}}
if !IsAllBlank(blank) {
t.Error("row of empty cells should be blank")
}
if IsAllBlank(cells("Nguyen", "", "")) {
t.Error("row with content should not be blank")
}
}
// TestIsAllBlankIgnoresIsEmptyFlag pins the rule that blankness is decided by
// the rendered string, never by Cell.IsEmpty — calamine emits empty-but-not-Empty
// cells, and treating the flag as authoritative would diverge.
func TestIsAllBlankIgnoresIsEmptyFlag(t *testing.T) {
// Content present but IsEmpty wrongly set: still not blank.
if IsAllBlank([]reader.Cell{{Str: "x", IsEmpty: true}}) {
t.Error("a cell with content must not be blank regardless of IsEmpty")
}
// Whitespace only: blank, matching Rust's trim().is_empty().
if !IsAllBlank([]reader.Cell{{Str: " "}, {Str: "\t"}}) {
t.Error("whitespace-only cells should be blank")
}
}
// TestDatasetLabel covers the basename derivation that drives the stats wording.
func TestDatasetLabel(t *testing.T) {
for in, want := range map[string]string{
"data/2017": "2017",
"data/2017-old2": "2017-old2",
"data/2017-old2/": "2017-old2",
"/abs/path/2016": "2016",
} {
if got := DatasetLabel(in); got != want {
t.Errorf("DatasetLabel(%q) = %q, want %q", in, got, want)
}
}
}
// TestInputFilesSortedAndFiltered: the sort decides which duplicate SBD survives
// INSERT OR REPLACE, so it is behaviour, not presentation.
func TestInputFilesSortedAndFiltered(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"b.xlsx", "a.xls", "c.XLSX", "notes.txt", "d.csv"} {
if err := writeEmpty(filepath.Join(dir, name)); err != nil {
t.Fatal(err)
}
}
got, err := InputFiles(dir)
if err != nil {
t.Fatal(err)
}
want := []string{"a.xls", "b.xlsx", "c.XLSX"}
if len(got) != len(want) {
t.Fatalf("got %d files %v, want %d", len(got), got, len(want))
}
for i := range want {
if filepath.Base(got[i]) != want[i] {
t.Errorf("file %d = %q, want %q", i, filepath.Base(got[i]), want[i])
}
}
}
func writeEmpty(path string) error { return os.WriteFile(path, nil, 0o644) }
+128
View File
@@ -0,0 +1,128 @@
package reader_test
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/tiennm99/thptqg/parser/internal/reader"
)
// TestReaderFidelity asserts the Go reader reproduces calamine byte-for-byte on
// every real input file.
//
// The oracle is a committed SHA-256 per file over a canonical cell dump. The
// dumps themselves are real student names and birthdates, so only the hashes are
// committed — regenerate the dumps from the Rust side on demand
// (parser/examples/dump_cells.rs), which is possible because parser/ still
// builds.
//
// The canonical form carries geometry and rendered cell values. The calamine
// Data variant is excluded on purpose: Data::Empty and Data::String("") both
// render "" and both count as blank in is_all_blank and transform, so the
// distinction cannot reach the database.
// Runs by default so CI and `go test ./...` keep the full guarantee; skipped
// under -short, which is how to iterate without paying ~77s to re-read 418 MB.
func TestReaderFidelity(t *testing.T) {
if testing.Short() {
t.Skip("-short: skipping the 299-file corpus sweep")
}
root := repoRoot(t)
manifest := filepath.Join(root, "parser", "testdata", "reader-fidelity-hashes.tsv")
f, err := os.Open(manifest)
if err != nil {
t.Fatalf("open manifest: %v", err)
}
defer f.Close()
var checked int
sc := bufio.NewScanner(f)
for sc.Scan() {
line := sc.Text()
if line == "" || strings.HasPrefix(line, "#") {
continue
}
rel, want, ok := strings.Cut(line, "\t")
if !ok {
t.Fatalf("malformed manifest line: %q", line)
}
path := filepath.Join(root, rel)
if _, err := os.Stat(path); err != nil {
t.Skipf("input data not present (%s); skipping fidelity suite", rel)
}
checked++
t.Run(rel, func(t *testing.T) {
t.Parallel()
got, err := canonicalHash(path)
if err != nil {
t.Fatalf("hash %s: %v", rel, err)
}
if got != want {
t.Errorf("cell dump diverges from calamine\n want %s\n got %s", want, got)
}
})
}
if err := sc.Err(); err != nil {
t.Fatalf("read manifest: %v", err)
}
if checked == 0 {
t.Fatal("manifest contained no entries")
}
}
// canonicalHash renders one file in the canonical form and hashes it. Kept
// byte-identical to the awk canonicalisation used to build the manifest.
func canonicalHash(path string) (string, error) {
wb, err := reader.Open(path)
if err != nil {
return "", err
}
defer wb.Close()
h := sha256.New()
sheets := wb.Sheets()
fmt.Fprintf(h, "SHEETCOUNT\t%d\n", len(sheets))
for _, sh := range sheets {
fmt.Fprintf(h, "SHEET\t%d\t%s\t%d\t%d\n", sh.Index, escape(sh.Name), sh.Height, sh.Width)
err := wb.EachRow(sh.Index, func(s reader.Sheet, rowIdx int, row []reader.Cell) error {
fmt.Fprintf(h, "ROW\t%d\t%d\t%d\n", s.Index, rowIdx, len(row))
for c, cell := range row {
fmt.Fprintf(h, "CELL\t%d\t%d\t%d\t%s\n", s.Index, rowIdx, c, escape(cell.Str))
}
return nil
})
if err != nil {
return "", err
}
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func escape(s string) string {
return strings.NewReplacer("\\", `\\`, "\t", `\t`, "\n", `\n`, "\r", `\r`).Replace(s)
}
// repoRoot walks up from the test's working directory to the directory holding
// the data/ corpus.
func repoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
for i := 0; i < 6; i++ {
if _, err := os.Stat(filepath.Join(dir, "data")); err == nil {
return dir
}
dir = filepath.Dir(dir)
}
t.Fatal("could not locate repo root (no data/ directory found)")
return ""
}
+79
View File
@@ -0,0 +1,79 @@
// Package reader wraps the two spreadsheet libraries behind one streaming
// contract, mirroring parser/src/reader.rs (which wraps calamine's
// open_workbook_auto for both formats).
//
// The contract is deliberately row-streaming rather than whole-workbook
// materialising: data/2017/ha-noi.xls alone holds 72k rows across two sheets,
// and the Rust original keeps at most one sheet range live at a time.
package reader
import (
"fmt"
"path/filepath"
"strings"
)
// Cell is one spreadsheet cell rendered the way calamine's Data::to_string()
// renders it.
//
// IsEmpty tracks calamine's Data::Empty variant separately from a string cell
// that happens to be empty. Rust distinguishes them at reader.rs:42, but both
// render "" and both count as blank in is_all_blank and in transform, so the
// flag is diagnostic only — never compare on it.
type Cell struct {
Str string
IsEmpty bool
}
// Sheet carries a sheet's identity and geometry. Height and Width describe the
// used range, matching calamine's Range::height()/width(); every used range in
// the corpus starts at (0,0), verified across all 299 files.
type Sheet struct {
Index int
Name string
Height int
Width int
}
// RowFunc receives each row of each sheet. Rows are padded to the sheet's used
// width — width is load-bearing because every column read downstream is
// positional with an unwrap_or_default() equivalent, so a short row silently
// NULLs its tail columns.
type RowFunc func(sheet Sheet, rowIdx int, row []Cell) error
// Workbook is one opened spreadsheet.
type Workbook interface {
// Sheets returns sheet identity and geometry in workbook order.
Sheets() []Sheet
// EachRow streams every row of the given sheet in order.
EachRow(sheetIdx int, fn RowFunc) error
Close() error
}
// Open dispatches on file extension, mirroring calamine's open_workbook_auto.
func Open(path string) (Workbook, error) {
switch strings.ToLower(filepath.Ext(path)) {
case ".xls":
return openXLS(path)
case ".xlsx", ".xlsm":
return openXLSX(path)
default:
return nil, fmt.Errorf("unsupported extension: %s", path)
}
}
// padRow extends row to width with empty cells, and truncates if longer.
func padRow(row []Cell, width int) []Cell {
if len(row) == width {
return row
}
if len(row) > width {
return row[:width]
}
out := make([]Cell, width)
copy(out, row)
for i := len(row); i < width; i++ {
out[i] = Cell{IsEmpty: true}
}
return out
}
+119
View File
@@ -0,0 +1,119 @@
package reader
import (
"fmt"
"github.com/pbnjay/grate"
// Registers the BIFF backend with grate.Open.
_ "github.com/pbnjay/grate/xls"
)
// xlsWorkbook reads legacy BIFF through pbnjay/grate.
//
// grate replaced extrame/xls, which was measured against calamine ground truth
// and found to corrupt 69% of cells and drop a further 28%: undecoded UTF-16LE
// and BIFF record framing leaked into cell values, content moved between rows
// and columns, and tail rows came back blank. That was charset-independent and
// unfixable from the outside. grate reproduces calamine exactly on the same
// files.
//
// The one normalisation grate needs is trailing blank rows: it yields rows past
// the end of calamine's used range (one for a populated sheet, two for an empty
// one), so trailing all-blank rows are trimmed. Note this is the opposite of
// the xlsx path, where excelize already trims and calamine keeps a 1x1 empty
// range — in both cases the rule is "match calamine's used range".
type xlsWorkbook struct {
sheets []Sheet
rows [][][]Cell
}
func openXLS(path string) (Workbook, error) {
wb, err := grate.Open(path)
if err != nil {
return nil, fmt.Errorf("grate open %s: %w", path, err)
}
defer wb.Close()
names, err := wb.List()
if err != nil {
return nil, fmt.Errorf("grate list %s: %w", path, err)
}
out := &xlsWorkbook{}
for idx, name := range names {
sh, err := wb.Get(name)
if err != nil {
return nil, fmt.Errorf("grate get %s/%s: %w", path, name, err)
}
var raw [][]string
for sh.Next() {
row := sh.Strings()
cp := make([]string, len(row))
for i, v := range row {
cp[i] = demergeMarker(v)
}
raw = append(raw, cp)
}
// Trim to calamine's used range.
height := len(raw)
for height > 0 && rowAllBlank(raw[height-1]) {
height--
}
raw = raw[:height]
width := 0
for _, r := range raw {
if len(r) > width {
width = len(r)
}
}
cells := make([][]Cell, len(raw))
for i, r := range raw {
row := make([]Cell, len(r))
for j, v := range r {
row[j] = Cell{Str: v, IsEmpty: v == ""}
}
cells[i] = padRow(row, width)
}
out.sheets = append(out.sheets, Sheet{Index: idx, Name: name, Height: height, Width: width})
out.rows = append(out.rows, cells)
}
return out, nil
}
// demergeMarker blanks grate's merged-cell continuation markers.
//
// grate fills the cells covered by a merge with sentinel runes; calamine
// reports them as empty. In this corpus they occur only in the merged title
// block of the 2016 spreadsheets (19 cells in rows 0-2 of one file). Only an
// exact whole-value match is blanked, so a real cell that merely contains an
// arrow is untouched.
func demergeMarker(v string) string {
switch v {
case grate.ContinueColumnMerged, grate.EndColumnMerged,
grate.ContinueRowMerged, grate.EndRowMerged:
return ""
}
return v
}
func (w *xlsWorkbook) Sheets() []Sheet { return w.sheets }
func (w *xlsWorkbook) EachRow(sheetIdx int, fn RowFunc) error {
if sheetIdx < 0 || sheetIdx >= len(w.sheets) {
return fmt.Errorf("sheet index %d out of range", sheetIdx)
}
sh := w.sheets[sheetIdx]
for i, row := range w.rows[sheetIdx] {
if err := fn(sh, i, row); err != nil {
return err
}
}
return nil
}
func (w *xlsWorkbook) Close() error { return nil }
+105
View File
@@ -0,0 +1,105 @@
package reader
import (
"fmt"
"github.com/xuri/excelize/v2"
)
// xlsxWorkbook reads OOXML through excelize.
//
// Two excelize behaviours must be corrected to match calamine:
//
// 1. GetRows applies the cell number format by default, while calamine renders
// the underlying value. RawCellValue: true disables that.
// 2. GetRows trims trailing blank cells, so rows are ragged; calamine returns a
// rectangular used range. Rows are padded back out to the sheet width.
type xlsxWorkbook struct {
f *excelize.File
sheets []Sheet
rows [][][]Cell // [sheetIdx][rowIdx][colIdx]
}
func openXLSX(path string) (Workbook, error) {
f, err := excelize.OpenFile(path)
if err != nil {
return nil, fmt.Errorf("excelize open %s: %w", path, err)
}
wb := &xlsxWorkbook{f: f}
crFixups := buildCRFixups(path)
for idx, name := range f.GetSheetList() {
raw, err := f.GetRows(name, excelize.Options{RawCellValue: true})
if err != nil {
f.Close()
return nil, fmt.Errorf("excelize GetRows %s/%s: %w", path, name, err)
}
// Do NOT trim trailing blank rows: calamine's used range keeps them, and
// excelize's GetRows already drops trailing fully-empty rows itself.
//
// One correction is needed. 63 sheets in 2017-old and 53 in 2017-old2
// hold a single empty shared-string cell at A1; calamine reports those
// as a 1x1 range, while GetRows returns nothing. A genuinely empty sheet
// (230 of them in 2016) is height 0 on both sides. GetCellType tells the
// two apart: the empty-shared-string cell exists in the XML and types as
// CellTypeSharedString, an absent cell types as CellTypeUnset.
if len(raw) == 0 {
if t, terr := f.GetCellType(name, "A1"); terr == nil && t != excelize.CellTypeUnset {
raw = [][]string{{""}}
}
}
height := len(raw)
width := 0
for _, r := range raw {
if len(r) > width {
width = len(r)
}
}
cells := make([][]Cell, len(raw))
for i, r := range raw {
row := make([]Cell, len(r))
for j, v := range r {
if fixed, ok := crFixups[v]; ok {
v = fixed
} else {
v = normalizeNumeric(f, name, j, i, v)
}
row[j] = Cell{Str: v, IsEmpty: v == ""}
}
cells[i] = padRow(row, width)
}
wb.sheets = append(wb.sheets, Sheet{Index: idx, Name: name, Height: height, Width: width})
wb.rows = append(wb.rows, cells)
}
return wb, nil
}
func rowAllBlank(r []string) bool {
for _, v := range r {
if v != "" {
return false
}
}
return true
}
func (w *xlsxWorkbook) Sheets() []Sheet { return w.sheets }
func (w *xlsxWorkbook) EachRow(sheetIdx int, fn RowFunc) error {
if sheetIdx < 0 || sheetIdx >= len(w.sheets) {
return fmt.Errorf("sheet index %d out of range", sheetIdx)
}
sh := w.sheets[sheetIdx]
for i, row := range w.rows[sheetIdx] {
if err := fn(sh, i, row); err != nil {
return err
}
}
return nil
}
func (w *xlsxWorkbook) Close() error { return w.f.Close() }
+155
View File
@@ -0,0 +1,155 @@
package reader
import (
"archive/zip"
"bytes"
"encoding/xml"
"io"
"strconv"
"github.com/xuri/excelize/v2"
)
// normalizeNumeric reproduces calamine's rendering of a numeric cell.
//
// calamine parses a numeric cell to f64 and renders it with Rust's f64 Display,
// so the stored literal "6.0" becomes "6". RawCellValue hands back the literal.
//
// The cell type must be consulted, not guessed: "01063476", "6.00" and "NAN" are
// all shared strings that survive ParseFloat, and renumbering them would drop a
// leading zero, drop a trailing zero, or recase NaN. GetCellType is only called
// when re-rendering would actually change the text, which keeps it off the hot
// path for the ~99% of cells that are already canonical or plainly non-numeric.
func normalizeNumeric(f *excelize.File, sheet string, col, row int, v string) string {
if v == "" {
return v
}
fv, err := strconv.ParseFloat(v, 64)
if err != nil {
return v
}
out := strconv.FormatFloat(fv, 'f', -1, 64)
if out == v {
return v
}
axis, err := excelize.CoordinatesToCellName(col+1, row+1)
if err != nil {
return v
}
// OOXML omits the t attribute on numeric cells, and excelize has no map
// entry for an empty t, so a plain number reports CellTypeUnset rather than
// CellTypeNumber. Unset is only reachable here for a cell that exists and
// parsed as a float — an absent cell is "" and returned above — so both
// values mean "numeric". Shared strings report CellTypeSharedString and are
// left alone, which is what protects "01063476", "6.00" and "NAN".
if t, err := f.GetCellType(sheet, axis); err == nil &&
(t == excelize.CellTypeNumber || t == excelize.CellTypeUnset) {
return out
}
return v
}
// buildCRFixups maps a shared string's line-ending-normalised form back to its
// raw form, for the strings that contain a carriage return.
//
// Go's encoding/xml performs the line-ending normalisation the XML 1.0 spec
// mandates (CRLF and lone CR both become LF), so excelize returns "a\nb" where
// calamine — which reads the raw bytes — returns "a\r\nb". That difference
// reaches the database: in one 2016 file it affects 2,233 TEN_CUMTHI values,
// which populate the ten_cum_thi column.
//
// The trick is that character references are exempt from that normalisation, so
// rewriting literal CR bytes to &#13; before decoding round-trips them intact.
//
// Returns nil when the file has no CR at all, which is the common case.
func buildCRFixups(path string) map[string]string {
zr, err := zip.OpenReader(path)
if err != nil {
return nil
}
defer zr.Close()
var entry *zip.File
for _, zf := range zr.File {
if zf.Name == "xl/sharedStrings.xml" {
entry = zf
break
}
}
if entry == nil {
return nil
}
rc, err := entry.Open()
if err != nil {
return nil
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil || !bytes.ContainsRune(data, '\r') {
return nil
}
data = bytes.ReplaceAll(data, []byte{'\r'}, []byte("&#13;"))
fixups := make(map[string]string)
dec := xml.NewDecoder(bytes.NewReader(data))
var cur bytes.Buffer
inSI, inT := false, false
for {
tok, err := dec.Token()
if err != nil {
break
}
switch t := tok.(type) {
case xml.StartElement:
switch t.Name.Local {
case "si":
inSI, cur = true, bytes.Buffer{}
case "t":
inT = true
}
case xml.CharData:
if inSI && inT {
cur.Write(t)
}
case xml.EndElement:
switch t.Name.Local {
case "t":
inT = false
case "si":
if inSI {
raw := cur.String()
if norm := normalizeEOL(raw); norm != raw {
fixups[norm] = raw
}
}
inSI = false
}
}
}
if len(fixups) == 0 {
return nil
}
return fixups
}
// normalizeEOL applies XML 1.0 line-ending normalisation: CRLF and lone CR
// both collapse to LF. This is what encoding/xml does to the raw bytes, so it
// reproduces the text excelize hands back.
func normalizeEOL(s string) string {
if !bytes.ContainsRune([]byte(s), '\r') {
return s
}
var b bytes.Buffer
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\r' {
if i+1 < len(s) && s[i+1] == '\n' {
continue // CRLF: the LF is emitted on the next pass
}
b.WriteByte('\n') // lone CR
continue
}
b.WriteByte(s[i])
}
return b.String()
}
+144
View File
@@ -0,0 +1,144 @@
// Package schema is the single source of truth for the SQL shape of every
// dataset — a direct port of parser/src/schema.rs.
//
// All four datasets (2016, 2017, 2017-old, 2017-old2) write into the same
// 22-column student table. Columns a dataset has no data for bind NULL.
//
// Column provenance:
//
// ten_cum_thi, gioi_tinh, tieng_duc, tieng_nhat -> 2016 only
// khtn, khxh, gdcd, tieng_nga -> 2017 datasets only
// everything else -> both
//
// Before this consolidation the DDL, the INSERT and the subject regexes were
// duplicated across four TOML configs, which is how the 2016 and 2017 schemas
// drifted apart. The configs now carry only per-dataset parse rules.
package schema
import "regexp"
// DDL is executed verbatim after the output database is (re)created.
//
// idx_ten_cum_thi is partial, so it holds zero entries on the three 2017
// datasets — where the column is always NULL — while staying useful for the
// 2016 cluster-grouping queries. Partial indexes are SQLite-specific.
//
// Byte-identical to parser/src/schema.rs:26-54; TestDDLMatchesRust enforces it.
const DDL = `
CREATE TABLE student (
so_bao_danh TEXT PRIMARY KEY,
ho_ten TEXT NOT NULL,
ho_ten_ascii TEXT NOT NULL,
ngay_sinh TEXT,
ten_cum_thi TEXT,
gioi_tinh TEXT,
toan REAL,
ngu_van REAL,
vat_ly REAL,
hoa_hoc REAL,
sinh_hoc REAL,
khtn REAL,
lich_su REAL,
dia_ly REAL,
gdcd REAL,
khxh REAL,
tieng_anh REAL,
tieng_phap REAL,
tieng_nga REAL,
tieng_duc REAL,
tieng_nhat REAL,
tieng_trung REAL
);
CREATE INDEX idx_ho_ten ON student(ho_ten);
CREATE INDEX idx_ho_ten_ascii ON student(ho_ten_ascii);
CREATE INDEX idx_ten_cum_thi ON student(ten_cum_thi) WHERE ten_cum_thi IS NOT NULL;
`
// IdentityFields are the identity columns, in INSERT parameter order.
var IdentityFields = []string{
"so_bao_danh",
"ho_ten",
"ho_ten_ascii",
"ngay_sinh",
"ten_cum_thi",
"gioi_tinh",
}
// ScoreFields are the subject columns, in INSERT parameter order. Bound NULL
// when a row has no score for that subject.
var ScoreFields = []string{
"toan",
"ngu_van",
"vat_ly",
"hoa_hoc",
"sinh_hoc",
"khtn",
"lich_su",
"dia_ly",
"gdcd",
"khxh",
"tieng_anh",
"tieng_phap",
"tieng_nga",
"tieng_duc",
"tieng_nhat",
"tieng_trung",
}
// ParamCount is the total bound parameters per row.
const ParamCount = 22
// InsertSQL is a positional INSERT matching IdentityFields then ScoreFields.
//
// OR REPLACE is a behavioural contract, not an optimisation: a repeated SBD
// overwrites the earlier row rather than aborting the transaction, so the last
// file to supply a duplicate wins.
const InsertSQL = `
INSERT OR REPLACE INTO student
(so_bao_danh, ho_ten, ho_ten_ascii, ngay_sinh, ten_cum_thi, gioi_tinh,
toan, ngu_van, vat_ly, hoa_hoc, sinh_hoc, khtn,
lich_su, dia_ly, gdcd, khxh,
tieng_anh, tieng_phap, tieng_nga, tieng_duc, tieng_nhat, tieng_trung)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
// scorePatternSources holds the regex per subject, applied to the DIEM_THI cell
// text. Copied verbatim from parser/src/schema.rs:126-143 — the literals contain
// Vietnamese text and must never be retyped.
//
// Every pattern runs against every dataset. A subject absent from a given exam
// year simply never matches and stays NULL: 2016 files contain no "KHTN:" or
// "Tiếng Nga:" tokens, and 2017 files contain no "Tiếng Đức:" or "Tiếng Nhật:".
//
// Go's regexp and Rust's regex crate are both RE2, and these patterns use no
// backreferences, lookaround or Unicode classes, so they port with zero risk.
var scorePatternSources = map[string]string{
"toan": `Toán:\s*(\d+(?:\.\d+)?)`,
"ngu_van": `Ngữ văn:\s*(\d+(?:\.\d+)?)`,
"vat_ly": `Vật lí:\s*(\d+(?:\.\d+)?)`,
"hoa_hoc": `Hóa học:\s*(\d+(?:\.\d+)?)`,
"sinh_hoc": `Sinh học:\s*(\d+(?:\.\d+)?)`,
"khtn": `KHTN:\s*(\d+(?:\.\d+)?)`,
"lich_su": `Lịch sử:\s*(\d+(?:\.\d+)?)`,
"dia_ly": `Địa lí:\s*(\d+(?:\.\d+)?)`,
"gdcd": `GDCD:\s*(\d+(?:\.\d+)?)`,
"khxh": `KHXH:\s*(\d+(?:\.\d+)?)`,
"tieng_anh": `Tiếng Anh:\s*(\d+(?:\.\d+)?)`,
"tieng_phap": `Tiếng Pháp:\s*(\d+(?:\.\d+)?)`,
"tieng_nga": `Tiếng Nga:\s*(\d+(?:\.\d+)?)`,
"tieng_duc": `Tiếng Đức:\s*(\d+(?:\.\d+)?)`,
"tieng_nhat": `Tiếng Nhật:\s*(\d+(?:\.\d+)?)`,
"tieng_trung": `Tiếng Trung:\s*(\d+(?:\.\d+)?)`,
}
// ScorePatterns holds the compiled subject regexes, compiled once at init.
// Rust compiles them once per run in CompiledPatterns::new; a package-level map
// is the equivalent for a single-threaded CLI.
var ScorePatterns = func() map[string]*regexp.Regexp {
out := make(map[string]*regexp.Regexp, len(scorePatternSources))
for field, src := range scorePatternSources {
out[field] = regexp.MustCompile(src)
}
return out
}()
+150
View File
@@ -0,0 +1,150 @@
package schema
import (
"strings"
"testing"
)
// Ports the four tests in parser/src/schema.rs:149-213. Their purpose is to stop
// the DDL, the INSERT column list and the field-order constants from drifting
// apart — a drift that silently lands values in the wrong columns.
// TestInsertMatchesFieldOrder ports insert_matches_field_order (schema.rs:156).
func TestInsertMatchesFieldOrder(t *testing.T) {
if ParamCount != 22 {
t.Errorf("ParamCount = %d, want 22", ParamCount)
}
if got := strings.Count(InsertSQL, "?"); got != ParamCount {
t.Errorf("INSERT placeholders = %d, want %d", got, ParamCount)
}
open := strings.Index(InsertSQL, "(")
closeIdx := strings.Index(InsertSQL, ")")
if open < 0 || closeIdx < 0 {
t.Fatal("INSERT must contain a column list")
}
var listed []string
for _, c := range strings.Split(InsertSQL[open+1:closeIdx], ",") {
if c = strings.TrimSpace(c); c != "" {
listed = append(listed, c)
}
}
want := append(append([]string{}, IdentityFields...), ScoreFields...)
if len(listed) != len(want) {
t.Fatalf("INSERT lists %d columns, want %d", len(listed), len(want))
}
for i := range want {
if listed[i] != want[i] {
t.Errorf("column %d: INSERT has %q, field order has %q", i, listed[i], want[i])
}
}
}
// TestScorePatternsCoverScoreFields ports score_patterns_cover_score_fields
// (schema.rs:183).
func TestScorePatternsCoverScoreFields(t *testing.T) {
if len(ScorePatterns) != len(ScoreFields) {
t.Fatalf("%d patterns for %d score columns", len(ScorePatterns), len(ScoreFields))
}
inFields := make(map[string]bool, len(ScoreFields))
for _, f := range ScoreFields {
inFields[f] = true
}
for field := range ScorePatterns {
if !inFields[field] {
t.Errorf("pattern %q has no column", field)
}
}
for _, field := range ScoreFields {
if _, ok := ScorePatterns[field]; !ok {
t.Errorf("column %q has no pattern", field)
}
}
}
// TestDDLColumnsMatchInsert ports ddl_columns_match_insert (schema.rs:201).
func TestDDLColumnsMatchInsert(t *testing.T) {
for _, field := range append(append([]string{}, IdentityFields...), ScoreFields...) {
if !strings.Contains(DDL, field) {
t.Errorf("DDL missing column %q", field)
}
}
}
// TestScorePatternsCompile ports score_patterns_compile (schema.rs:208).
// Compilation happens in the package initialiser, so reaching this point already
// proves it; the explicit checks guard against an empty or partial table.
func TestScorePatternsCompile(t *testing.T) {
for field, re := range ScorePatterns {
if re == nil {
t.Errorf("pattern %q is nil", field)
}
}
}
// TestDDLMatchesRust asserts the DDL is byte-identical to parser/src/schema.rs.
// Anything less and the two parsers can produce structurally different databases
// while every row-level check still passes.
func TestDDLMatchesRust(t *testing.T) {
const want = `
CREATE TABLE student (
so_bao_danh TEXT PRIMARY KEY,
ho_ten TEXT NOT NULL,
ho_ten_ascii TEXT NOT NULL,
ngay_sinh TEXT,
ten_cum_thi TEXT,
gioi_tinh TEXT,
toan REAL,
ngu_van REAL,
vat_ly REAL,
hoa_hoc REAL,
sinh_hoc REAL,
khtn REAL,
lich_su REAL,
dia_ly REAL,
gdcd REAL,
khxh REAL,
tieng_anh REAL,
tieng_phap REAL,
tieng_nga REAL,
tieng_duc REAL,
tieng_nhat REAL,
tieng_trung REAL
);
CREATE INDEX idx_ho_ten ON student(ho_ten);
CREATE INDEX idx_ho_ten_ascii ON student(ho_ten_ascii);
CREATE INDEX idx_ten_cum_thi ON student(ten_cum_thi) WHERE ten_cum_thi IS NOT NULL;
`
if DDL != want {
t.Errorf("DDL diverges from parser/src/schema.rs:26-54\n--- got ---\n%s\n--- want ---\n%s", DDL, want)
}
}
// TestScorePatternsMatchScores exercises each pattern against the shape the
// DIEM_THI cell actually carries, including the wide runs of spaces seen in the
// real corpus.
func TestScorePatternsMatchScores(t *testing.T) {
const cell = "Toán: 8.50 Ngữ văn: 7.00 Tiếng Đức: 9 KHXH: 5.58 "
cases := map[string]string{
"toan": "8.50",
"ngu_van": "7.00",
"tieng_duc": "9",
"khxh": "5.58",
"tieng_nhat": "", // absent from the cell -> no match
}
for field, want := range cases {
re, ok := ScorePatterns[field]
if !ok {
t.Fatalf("no pattern for %q", field)
}
m := re.FindStringSubmatch(cell)
got := ""
if m != nil {
got = m[1]
}
if got != want {
t.Errorf("%s: matched %q, want %q", field, got, want)
}
}
}
+25
View File
@@ -0,0 +1,25 @@
// Package sqlitedb registers the SQLite driver the parser writes with and names
// it in one place.
//
// modernc.org/sqlite is a pure-Go SQLite, chosen so the whole module stays
// cgo-free — grate, excelize and yaml.v3 are pure Go too, so CI can compile with
// CGO_ENABLED=0 and no C toolchain.
//
// It is a machine-transpiled SQLite rather than the upstream C amalgamation that
// Rust's rusqlite --bundled vendors (libsqlite3-sys 0.30.1). Two things make that
// acceptable: this parser uses only plain SQL — no CTEs, window functions,
// triggers or extensions — and the differential gate compares a full-table
// SHA-256 plus PRAGMA table_info/index_list against live Rust output.
//
// Verified on linux/arm64 with v1.56.0 (SQLite 3.53.3): the full DDL including
// the partial idx_ten_cum_thi index, INSERT OR REPLACE, and VACUUM.
//
// Fallback if the differential gate ever implicates the driver: mattn/go-sqlite3
// is upstream C at the cost of cgo.
package sqlitedb
// Registers "sqlite" with database/sql.
import _ "modernc.org/sqlite"
// DriverName is the database/sql driver name to pass to sql.Open.
const DriverName = "sqlite"
@@ -0,0 +1,65 @@
package transform_test
import (
"database/sql"
"os"
"testing"
_ "github.com/tiennm99/thptqg/parser/internal/sqlitedb"
"github.com/tiennm99/thptqg/parser/internal/transform"
)
// TestToAsciiAgainstRustOutput cross-checks ToAscii against Rust on real data.
//
// A Rust-built database is its own oracle: every row carries ho_ten alongside
// the ho_ten_ascii that Rust derived from it, so the whole table is a
// name -> expected-slug corpus far broader than the 20 hand-picked unit cases.
//
// Point it at a Rust-built database:
//
// GO_PARSER_RUST_DB=/tmp/rust-2016.db go test ./internal/transform/
//
// Skips when unset, so the default suite stays hermetic.
func TestToAsciiAgainstRustOutput(t *testing.T) {
path := os.Getenv("GO_PARSER_RUST_DB")
if path == "" {
t.Skip("GO_PARSER_RUST_DB not set; skipping cross-check against Rust output")
}
if _, err := os.Stat(path); err != nil {
t.Skipf("GO_PARSER_RUST_DB=%s not readable: %v", path, err)
}
db, err := sql.Open("sqlite", "file:"+path+"?mode=ro")
if err != nil {
t.Fatalf("open %s: %v", path, err)
}
defer db.Close()
rows, err := db.Query("SELECT ho_ten, ho_ten_ascii FROM student")
if err != nil {
t.Fatalf("query: %v", err)
}
defer rows.Close()
var checked, bad int
for rows.Next() {
var name, rustAscii string
if err := rows.Scan(&name, &rustAscii); err != nil {
t.Fatalf("scan: %v", err)
}
checked++
if got := transform.ToAscii(name); got != rustAscii {
bad++
if bad <= 5 {
t.Errorf("ToAscii(%q)\n rust = %q\n go = %q", name, rustAscii, got)
}
}
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate: %v", err)
}
if checked == 0 {
t.Fatal("database contained no rows")
}
t.Logf("compared %d real names, %d mismatches", checked, bad)
}
+223
View File
@@ -0,0 +1,223 @@
// Package transform performs row transformation: ASCII normalisation, score
// regex parsing, and validation — a port of parser/src/transform.rs.
//
// ToAscii replicates build-lib.js toAscii exactly:
//
// str.normalize("NFD").replace(/[̀-ͯ]/g,"").replace(/đ/gi,"d").toLowerCase()
package transform
import (
"errors"
"math"
"strconv"
"strings"
"golang.org/x/text/unicode/norm"
"github.com/tiennm99/thptqg/parser/internal/config"
"github.com/tiennm99/thptqg/parser/internal/reader"
"github.com/tiennm99/thptqg/parser/internal/schema"
)
// ToAscii normalises a Vietnamese name to an ASCII slug.
//
// 1. NFD decompose (splits base + combining diacritics)
// 2. Drop combining marks in U+0300..U+036F
// 3. Replace đ/Đ with d (NFD does not decompose them)
// 4. Lowercase
//
// Step 2 filters a LITERAL CODEPOINT RANGE, not a Unicode category. The inline
// comment at transform.rs:53 says "Unicode category M", but the code at :56 is
// the specification and it checks '\u{0300}'..='\u{036f}'. unicode.Is(unicode.Mn, r)
// is strictly broader and would strip marks Rust keeps, silently changing
// ho_ten_ascii — the column the site's accent-insensitive search runs on.
//
// Step order matters and mirrors transform.rs:54-63: the đ/Đ replacement happens
// before lowercasing.
func ToAscii(s string) string {
if s == "" {
return ""
}
decomposed := norm.NFD.String(s)
var b strings.Builder
b.Grow(len(decomposed))
for _, r := range decomposed {
if r >= 0x0300 && r <= 0x036F {
continue // combining mark, in the range Rust drops
}
switch r {
case 'đ', 'Đ':
b.WriteByte('d')
default:
b.WriteRune(r)
}
}
return strings.ToLower(b.String())
}
// ParsedRow is one row ready for insertion.
type ParsedRow struct {
SoBaoDanh string
HoTen string
HoTenAscii string
NgaySinh *string
// TenCumThi is 2016 only: examination cluster name (TEN_CUMTHI column).
TenCumThi *string
// GioiTinh is 2016 only: gender, normalised to "Nam"/"Nữ" or nil.
GioiTinh *string
// Scores maps subject field -> value. Absent subjects are simply missing and
// bind NULL.
Scores map[string]float64
}
// SkipReason says why a row was skipped, or SkipNone when it passed.
//
// The distinction is load-bearing for the printed counters, and the two
// non-blank reasons are counted as source rows while BlankRow is not — but note
// that split lives in the CALLER, not here. parser/src/main.rs has two call
// sites with opposite outcomes for BlankRow: at :135-137 it returns before the
// counter at :140, while at :151 it matches Err(BlankRow) => {} and falls
// through to transform and insert. The build loop must reproduce both.
type SkipReason int
const (
SkipNone SkipReason = iota
// SkipBlankRow: row is fully blank (2017-old2 only, checked before the
// source-row counter).
SkipBlankRow
// SkipEmptyField: so_bao_danh or ho_ten empty/missing.
SkipEmptyField
// SkipNonNumericSbd: so_bao_danh contains non-digit characters
// (2017-old / 2017-old2 guard).
SkipNonNumericSbd
)
func (s SkipReason) String() string {
switch s {
case SkipNone:
return "none"
case SkipBlankRow:
return "blank_row"
case SkipEmptyField:
return "empty_field"
case SkipNonNumericSbd:
return "non_numeric_sbd"
}
return "unknown"
}
// ValidateRow checks a row against the dataset's validation rules.
//
// Signature mirrors transform.rs:101-107, taking stripBlankRows and allBlank
// explicitly; a shorter signature could not express both blank-row paths.
func ValidateRow(hoTen, soBaoDanh string, cfg *config.ValidationCfg, stripBlankRows, allBlank bool) SkipReason {
// 2017-old2: skip fully blank rows BEFORE counting source rows.
if stripBlankRows && allBlank {
return SkipBlankRow
}
if cfg.RequireNonemptySbd && soBaoDanh == "" {
return SkipEmptyField
}
if cfg.RequireNonemptyName && hoTen == "" {
return SkipEmptyField
}
if cfg.RequireNumericSbd && !allASCIIDigits(soBaoDanh) {
return SkipNonNumericSbd
}
return SkipNone
}
// allASCIIDigits mirrors Rust's chars().all(|c| c.is_ascii_digit()).
//
// Deliberately not strconv.Atoi: Atoi accepts a leading sign, so "+123" would
// pass a check Rust rejects. Empty input returns true, matching Rust's all() on
// an empty iterator — the empty case is caught earlier by RequireNonemptySbd.
func allASCIIDigits(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
// ParseScores extracts subject scores from a DIEM_THI cell.
//
// Every one of the 16 patterns runs against every dataset; a subject absent from
// a given exam year never matches and stays NULL. Matching is unanchored
// first-match, like Rust's Regex::captures.
func ParseScores(diemThi string) map[string]float64 {
out := make(map[string]float64)
if diemThi == "" {
return out
}
for field, re := range schema.ScorePatterns {
m := re.FindStringSubmatch(diemThi)
if m == nil {
continue
}
v, err := strconv.ParseFloat(m[1], 64)
if err != nil {
continue
}
// Unreachable given the pattern shape, but kept for parity with
// transform.rs:136 (is_finite).
if math.IsInf(v, 0) || math.IsNaN(v) {
continue
}
out[field] = v
}
return out
}
// ErrNoColumns is returned when the fixed-column path is used on a config that
// has no columns: mapping. Rust panics here via .expect() (transform.rs:162);
// returning an error is the Go-idiomatic equivalent and is unreachable in
// practice, since only the non-2016 path calls this.
var ErrNoColumns = errors.New("transform: config has no columns mapping")
// TransformRow extracts one row into a ParsedRow using fixed column indices,
// the 2017-family path. 2016 uses runtime format detection instead.
func TransformRow(raw []reader.Cell, cfg *config.DatasetConfig) (*ParsedRow, error) {
cols := cfg.Columns
if cols == nil {
return nil, ErrNoColumns
}
// Trimmed accessor, mirroring the closure at transform.rs:163-167. Out-of-range
// indices yield "" rather than an error, matching unwrap_or_default().
get := func(idx int) string {
if idx < 0 || idx >= len(raw) {
return ""
}
return strings.TrimSpace(raw[idx].Str)
}
hoTen := get(cols.HoTen)
ngaySinh := get(cols.NgaySinh)
soBaoDanh := get(cols.SoBaoDanh)
// diem_thi is read WITHOUT trimming (transform.rs:172-175), unlike the three
// fields above. Harmless because the score patterns are unanchored, but it is
// the shipped behaviour — do not "tidy" it.
diemThi := ""
if cols.DiemThi >= 0 && cols.DiemThi < len(raw) {
diemThi = raw[cols.DiemThi].Str
}
var ngaySinhOpt *string
if ngaySinh != "" {
ngaySinhOpt = &ngaySinh
}
return &ParsedRow{
SoBaoDanh: soBaoDanh,
HoTen: hoTen,
HoTenAscii: ToAscii(hoTen),
NgaySinh: ngaySinhOpt,
TenCumThi: nil,
GioiTinh: nil,
Scores: ParseScores(diemThi),
}, nil
}
+283
View File
@@ -0,0 +1,283 @@
package transform
import (
"testing"
"github.com/tiennm99/thptqg/parser/internal/config"
"github.com/tiennm99/thptqg/parser/internal/reader"
)
// Ports every test in parser/src/transform.rs's test module (:201-409) — 29 in
// total, not the 20 in the :213-315 range, which covers only ToAscii. The nine
// outside that range are the ParseScores and ValidateRow cases, i.e. exactly the
// behaviours this package's traps concern.
// --- ToAscii: the 20 cases at transform.rs:213-315 ---
func TestToAscii(t *testing.T) {
cases := []struct{ name, in, want string }{
{"plain_latin", "Nguyen Van A", "nguyen van a"},
{"nguyen_thi_hoa", "Nguyễn Thị Hoa", "nguyen thi hoa"},
{"tran_van_duc", "Trần Văn Đức", "tran van duc"},
{"le_thi_my_duyen", "Lê Thị Mỹ Duyên", "le thi my duyen"},
{"pham_thi_lan", "Phạm Thị Lan", "pham thi lan"},
{"bui_thi_thu", "Bùi Thị Thu", "bui thi thu"},
{"hoang_van_truong", "Hoàng Văn Trường", "hoang van truong"},
{"do_thi_ngan", "Đỗ Thị Ngân", "do thi ngan"},
{"nguyen_van_khanh", "Nguyễn Văn Khánh", "nguyen van khanh"},
{"trinh_thi_bich_ngoc", "Trịnh Thị Bích Ngọc", "trinh thi bich ngoc"},
{"vu_thi_dieu", "Vũ Thị Diệu", "vu thi dieu"},
{"nguyen_thi_tuong_vi", "Nguyễn Thị Tường Vi", "nguyen thi tuong vi"},
{"lowercase_d_stroke", "đặng thị hằng", "dang thi hang"},
{"uppercase_d_stroke", "ĐẶNG THỊ HẰNG", "dang thi hang"},
{"mixed_case", "NGUYỄN VĂN AN", "nguyen van an"},
{"tran_thi_kim_anh", "Trần Thị Kim Anh", "tran thi kim anh"},
{"nguyen_thi_phuong_thao", "Nguyễn Thị Phương Thảo", "nguyen thi phuong thao"},
{"le_van_long", "Lê Văn Long", "le van long"},
{"vo_thi_xuan_mai", "Võ Thị Xuân Mai", "vo thi xuan mai"},
{"empty_string", "", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := ToAscii(c.in); got != c.want {
t.Errorf("ToAscii(%q) = %q, want %q", c.in, got, c.want)
}
})
}
}
// TestToAsciiUsesLiteralRangeNotUnicodeMn guards the highest-value trap in this
// package. transform.rs:56 filters the literal range U+0300..U+036F; the inline
// comment at :53 calls it "Unicode category M", but the code is the spec.
// unicode.Mn is strictly broader, so using it would strip marks Rust keeps.
// U+0654 (ARABIC HAMZA ABOVE) is in Mn but outside the range: Rust keeps it.
func TestToAsciiUsesLiteralRangeNotUnicodeMn(t *testing.T) {
const in = "aٔb"
if got := ToAscii(in); got != in {
t.Errorf("ToAscii(%q) = %q — a combining mark outside U+0300..U+036F must survive; "+
"stripping it means unicode.Mn was used instead of the literal range", in, got)
}
// And a mark inside the range must be stripped.
if got := ToAscii("áb"); got != "ab" {
t.Errorf("ToAscii(\"a\\u0301b\") = %q, want \"ab\"", got)
}
}
// TestToAsciiDStrokeIndependentOfNFD proves the đ/Đ replacement is a separate
// step: NFD does not decompose them, so relying on the mark filter alone loses
// the letter entirely.
func TestToAsciiDStrokeIndependentOfNFD(t *testing.T) {
for _, c := range []struct{ in, want string }{
{"đ", "d"}, {"Đ", "d"}, {"đĐ", "dd"},
} {
if got := ToAscii(c.in); got != c.want {
t.Errorf("ToAscii(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// --- ParseScores: transform.rs:323, :332, :342 ---
func TestParseScoresSingle(t *testing.T) {
s := ParseScores("Toán: 8.5")
if v, ok := s["toan"]; !ok || v != 8.5 {
t.Errorf("toan = %v (present=%v), want 8.5", v, ok)
}
if _, ok := s["ngu_van"]; ok {
t.Error("ngu_van should be absent")
}
}
func TestParseScoresMultiple(t *testing.T) {
s := ParseScores("Toán: 7.25 Ngữ văn: 6.0 Vật lí: 9")
for field, want := range map[string]float64{"toan": 7.25, "ngu_van": 6.0, "vat_ly": 9.0} {
if v, ok := s[field]; !ok || v != want {
t.Errorf("%s = %v (present=%v), want %v", field, v, ok, want)
}
}
}
func TestParseScoresEmptyCell(t *testing.T) {
if s := ParseScores(""); len(s) != 0 {
t.Errorf("ParseScores(\"\") = %v, want empty", s)
}
}
// TestParseScoresRealCellShape uses the wide space runs seen in the corpus.
func TestParseScoresRealCellShape(t *testing.T) {
const cell = "Toán: 4.60 Ngữ văn: 5.50 Lịch sử: 4.50 "
s := ParseScores(cell)
if len(s) != 3 {
t.Fatalf("matched %d subjects, want 3: %v", len(s), s)
}
if s["toan"] != 4.60 || s["ngu_van"] != 5.50 || s["lich_su"] != 4.50 {
t.Errorf("got %v", s)
}
}
// --- ValidateRow: transform.rs:359, :365, :374, :383, :393, :400 ---
func defaultValidation() *config.ValidationCfg {
return &config.ValidationCfg{
RequireNumericSbd: false,
RequireNonemptyName: true,
RequireNonemptySbd: true,
}
}
func TestValidateOK(t *testing.T) {
if r := ValidateRow("Nguyen Van A", "12345678", defaultValidation(), false, false); r != SkipNone {
t.Errorf("got %v, want SkipNone", r)
}
}
func TestValidateEmptySbd(t *testing.T) {
if r := ValidateRow("Nguyen Van A", "", defaultValidation(), false, false); r != SkipEmptyField {
t.Errorf("got %v, want SkipEmptyField", r)
}
}
func TestValidateEmptyName(t *testing.T) {
if r := ValidateRow("", "12345678", defaultValidation(), false, false); r != SkipEmptyField {
t.Errorf("got %v, want SkipEmptyField", r)
}
}
func TestValidateNonNumericSbdRejected(t *testing.T) {
v := defaultValidation()
v.RequireNumericSbd = true
if r := ValidateRow("Nguyen Van A", "12AB5678", v, false, false); r != SkipNonNumericSbd {
t.Errorf("got %v, want SkipNonNumericSbd", r)
}
}
func TestValidateNumericSbdAccepted(t *testing.T) {
v := defaultValidation()
v.RequireNumericSbd = true
if r := ValidateRow("Nguyen Van A", "12345678", v, false, false); r != SkipNone {
t.Errorf("got %v, want SkipNone", r)
}
}
func TestValidateBlankRowSkipped(t *testing.T) {
if r := ValidateRow("", "", defaultValidation(), true, true); r != SkipBlankRow {
t.Errorf("got %v, want SkipBlankRow", r)
}
}
// TestValidateNumericSbdIsDigitScanNotAtoi: Rust uses chars().all(is_ascii_digit),
// which strconv.Atoi does not reproduce — Atoi accepts a leading sign, and would
// wrongly admit "+123".
func TestValidateNumericSbdIsDigitScanNotAtoi(t *testing.T) {
v := defaultValidation()
v.RequireNumericSbd = true
for _, sbd := range []string{"+123", "-123", "12 3", "1.0", "ABC123", "123"} {
if r := ValidateRow("Nguyen Van A", sbd, v, false, false); r != SkipNonNumericSbd {
t.Errorf("ValidateRow(sbd=%q) = %v, want SkipNonNumericSbd", sbd, r)
}
}
}
// TestValidateBlankRowOnlyWhenStripEnabled: with strip_blank_rows false, an
// all-blank row falls through to the empty-field checks instead (transform.rs:109).
func TestValidateBlankRowOnlyWhenStripEnabled(t *testing.T) {
if r := ValidateRow("", "", defaultValidation(), false, true); r != SkipEmptyField {
t.Errorf("got %v, want SkipEmptyField when strip_blank_rows is off", r)
}
}
// --- TransformRow ---
func fixedColumnCfg() *config.DatasetConfig {
return &config.DatasetConfig{
Columns: &config.ColumnMap{HoTen: 0, NgaySinh: 1, SoBaoDanh: 2, DiemThi: 3},
Validation: *defaultValidation(),
}
}
func cells(vals ...string) []reader.Cell {
out := make([]reader.Cell, len(vals))
for i, v := range vals {
out[i] = reader.Cell{Str: v, IsEmpty: v == ""}
}
return out
}
func TestTransformRow(t *testing.T) {
row := cells("Nguyễn Văn Đức", "04/04/1999", "51002167", "Toán: 8.5 Ngữ văn: 7")
got, err := TransformRow(row, fixedColumnCfg())
if err != nil {
t.Fatalf("TransformRow: %v", err)
}
if got.HoTen != "Nguyễn Văn Đức" || got.HoTenAscii != "nguyen van duc" {
t.Errorf("ho_ten=%q ascii=%q", got.HoTen, got.HoTenAscii)
}
if got.SoBaoDanh != "51002167" {
t.Errorf("so_bao_danh = %q", got.SoBaoDanh)
}
if got.NgaySinh == nil || *got.NgaySinh != "04/04/1999" {
t.Errorf("ngay_sinh = %v", got.NgaySinh)
}
// 2016-only columns are never populated on the fixed-column path.
if got.TenCumThi != nil || got.GioiTinh != nil {
t.Error("ten_cum_thi and gioi_tinh must stay nil on the 2017 path")
}
if got.Scores["toan"] != 8.5 || got.Scores["ngu_van"] != 7 {
t.Errorf("scores = %v", got.Scores)
}
}
// TestTransformRowEmptyNgaySinhBecomesNil ports transform.rs:179-183.
func TestTransformRowEmptyNgaySinhBecomesNil(t *testing.T) {
got, err := TransformRow(cells("A", "", "1", ""), fixedColumnCfg())
if err != nil {
t.Fatalf("TransformRow: %v", err)
}
if got.NgaySinh != nil {
t.Errorf("empty ngay_sinh should be nil, got %q", *got.NgaySinh)
}
}
// TestTransformRowShortRowYieldsEmptyFields ports the unwrap_or_default()
// behaviour at transform.rs:163-176: a row shorter than the configured indices
// yields empty strings rather than an error.
func TestTransformRowShortRowYieldsEmptyFields(t *testing.T) {
got, err := TransformRow(cells("OnlyName"), fixedColumnCfg())
if err != nil {
t.Fatalf("TransformRow: %v", err)
}
if got.HoTen != "OnlyName" || got.SoBaoDanh != "" || got.NgaySinh != nil {
t.Errorf("got ho_ten=%q sbd=%q ngay_sinh=%v", got.HoTen, got.SoBaoDanh, got.NgaySinh)
}
}
// TestTransformRowDiemThiIsNotTrimmed pins an asymmetry that is easy to
// "tidy away": ho_ten, ngay_sinh and so_bao_danh are trimmed through the closure
// at transform.rs:164-168, but diem_thi is read raw at :172-175.
func TestTransformRowDiemThiIsNotTrimmed(t *testing.T) {
row := cells(" A ", " 01/01/2000 ", " 123 ", " Toán: 5 ")
got, err := TransformRow(row, fixedColumnCfg())
if err != nil {
t.Fatalf("TransformRow: %v", err)
}
if got.HoTen != "A" || got.SoBaoDanh != "123" {
t.Errorf("trimmed fields wrong: ho_ten=%q sbd=%q", got.HoTen, got.SoBaoDanh)
}
if got.NgaySinh == nil || *got.NgaySinh != "01/01/2000" {
t.Errorf("ngay_sinh = %v, want trimmed", got.NgaySinh)
}
// Untrimmed diem_thi still parses — the regexes are unanchored.
if got.Scores["toan"] != 5 {
t.Errorf("scores = %v", got.Scores)
}
}
// TestTransformRowRequiresColumns: the fixed-column path is only reachable when
// the config has a columns: mapping. Rust panics via .expect() (transform.rs:162);
// Go returns an error instead.
func TestTransformRowRequiresColumns(t *testing.T) {
cfg := &config.DatasetConfig{Validation: *defaultValidation()}
if _, err := TransformRow(cells("A", "B", "C", "D"), cfg); err == nil {
t.Fatal("TransformRow without a columns: mapping must return an error")
}
}
+177
View File
@@ -0,0 +1,177 @@
// Package writer handles SQLite output: DDL setup, INSERT OR REPLACE, VACUUM
// and the stats block — a port of parser/src/writer.rs.
//
// Every dataset writes the same canonical table (internal/schema), so there is
// exactly one insert path. Columns a dataset carries no data for bind NULL.
//
// The stats lines are reproduced verbatim because they are the operator-facing
// output of the build, and docs/deployment-guide.md points at the per-file row
// counts for troubleshooting.
package writer
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"github.com/tiennm99/thptqg/parser/internal/schema"
"github.com/tiennm99/thptqg/parser/internal/sqlitedb"
"github.com/tiennm99/thptqg/parser/internal/transform"
)
// OpenDB deletes any existing database at dbPath, recreates it, and executes the
// canonical DDL.
//
// Deleting the file rather than issuing DROP TABLE mirrors build-lib.js:54 via
// writer.rs:24-30. A consequence worth knowing: a concurrent reader sees the file
// vanish mid-rebuild rather than a transactional swap.
func OpenDB(dbPath string) (*sql.DB, error) {
if _, err := os.Stat(dbPath); err == nil {
if err := os.Remove(dbPath); err != nil {
return nil, fmt.Errorf("remove existing db %s: %w", dbPath, err)
}
}
if parent := filepath.Dir(dbPath); parent != "" && parent != "." {
if err := os.MkdirAll(parent, 0o755); err != nil {
return nil, fmt.Errorf("create %s: %w", parent, err)
}
}
db, err := sql.Open(sqlitedb.DriverName, dbPath)
if err != nil {
return nil, fmt.Errorf("open db %s: %w", dbPath, err)
}
if _, err := db.Exec(schema.DDL); err != nil {
db.Close()
return nil, fmt.Errorf("execute DDL: %w", err)
}
return db, nil
}
// Inserter wraps a prepared INSERT statement.
//
// Rust calls conn.execute(INSERT_SQL, ...) per row (writer.rs:73), re-preparing
// each time. Preparing once is a performance choice, not a parity requirement —
// the SQL and its bindings are identical either way.
type Inserter struct{ stmt *sql.Stmt }
// Prepare compiles the canonical INSERT against tx.
func Prepare(tx *sql.Tx) (*Inserter, error) {
stmt, err := tx.Prepare(schema.InsertSQL)
if err != nil {
return nil, fmt.Errorf("prepare insert: %w", err)
}
return &Inserter{stmt: stmt}, nil
}
// Close releases the prepared statement.
func (i *Inserter) Close() error { return i.stmt.Close() }
// Insert binds one parsed row and executes the INSERT.
//
// Parameter order is IdentityFields then ScoreFields. Subjects absent from
// row.Scores — and the two identity columns only the 2016 layouts populate —
// bind NULL.
func (i *Inserter) Insert(row *transform.ParsedRow) error {
args := make([]any, 0, schema.ParamCount)
args = append(args,
row.SoBaoDanh,
row.HoTen,
row.HoTenAscii,
nullableString(row.NgaySinh),
nullableString(row.TenCumThi),
nullableString(row.GioiTinh),
)
for _, field := range schema.ScoreFields {
if v, ok := row.Scores[field]; ok {
args = append(args, v)
} else {
args = append(args, nil)
}
}
if len(args) != schema.ParamCount {
return fmt.Errorf("built %d params, want %d", len(args), schema.ParamCount)
}
if _, err := i.stmt.Exec(args...); err != nil {
return err
}
return nil
}
func nullableString(s *string) any {
if s == nil {
return nil
}
return *s
}
// Stats carries the counters the build loop accumulates.
type Stats struct {
SourceRows uint64
Skipped uint64
Errors uint64
}
// Finish runs VACUUM and prints the stats block.
//
// VACUUM must run AFTER the transaction commits — SQLite refuses it inside one.
//
// The wording branches on datasetLabel, which Rust derives from the input
// directory's basename (main.rs:98-101). That makes the output depend on a
// filesystem path rather than on config; it is reproduced here for parity, and
// the caller passes the label explicitly so tests are not at the mercy of a
// temp-directory name.
func Finish(db *sql.DB, dbPath string, st Stats, datasetLabel string, isOld2 bool) error {
if _, err := db.Exec("VACUUM"); err != nil {
return fmt.Errorf("vacuum: %w", err)
}
var dbCount int64
if err := db.QueryRow("SELECT COUNT(*) FROM student").Scan(&dbCount); err != nil {
return fmt.Errorf("count rows: %w", err)
}
insertable := st.SourceRows - st.Skipped
fmt.Println()
if isOld2 {
fmt.Printf("Source non-blank data rows: %d\n", st.SourceRows)
fmt.Printf(" skipped (empty/non-numeric SBD): %d\n", st.Skipped)
} else {
fmt.Printf("Source data rows (post-header): %d\n", st.SourceRows)
if containsOld(datasetLabel) {
fmt.Printf(" skipped (empty/non-numeric SBD): %d\n", st.Skipped)
} else {
fmt.Printf(" skipped (empty/invalid): %d\n", st.Skipped)
}
}
fmt.Printf(" insertable: %d\n", insertable)
fmt.Printf(" insert errors: %d\n", st.Errors)
fmt.Printf("DB rows (distinct SBD): %d\n", dbCount)
if !containsOld(datasetLabel) && st.Errors == 0 {
gap := int64(insertable) - dbCount
if gap == 0 {
fmt.Println("Audit: OK — every source row made it in.")
} else {
fmt.Printf("Audit: %d row(s) collapsed (duplicate SBDs overwriting).\n", gap)
}
}
var size int64
if fi, err := os.Stat(dbPath); err == nil {
size = fi.Size()
}
fmt.Printf("Size: %.1f MB\n", float64(size)/1024.0/1024.0)
return nil
}
func containsOld(label string) bool {
for i := 0; i+3 <= len(label); i++ {
if label[i:i+3] == "old" {
return true
}
}
return false
}
Binary file not shown.
+192
View File
@@ -0,0 +1,192 @@
# Canonical cell-dump SHA-256 per input file, produced by the Rust/calamine
# ground truth (parser/examples/dump_cells.rs). The Go reader must reproduce
# each hash exactly. Hashes only - the dumps themselves are real student PII
# and are never committed, per parser/tests/fixtures/README.md.
# FROZEN. These hashes were produced by the Rust/calamine parser, which has
# since been removed, so they cannot be regenerated. They remain a real
# regression guard: any change to the Go reader that alters a single cell of any
# of the 299 input files fails the suite. If the inputs themselves ever change,
# the affected rows must be re-derived deliberately and reviewed, not refreshed
# in bulk.
data/2016/023718c7d3cf7ace3a7116fabb12bd9cdhyduoccantho-1468920829104.xlsx fc3afd8da9ed28b0fc177192fab7b19a06535cb45d86841835d8e8828fc61c1c
data/2016/08cdeb3636dcb2adaef829d62968274atravinh-1468902443859.xlsx 71f0e61918c63b95f08a16d502f5e9f2054e3c830bd83c725f1285bb1aabead1
data/2016/0b583c9875443e65ffa449d5ca76fae8ninhbinh-1468899778557.xlsx b0c2cfb9ab419442f151be5a014439f7f477fa7b31a9ae3f37b83073276bdb59
data/2016/15c85d901a73dd49f2bae71aadcdbb5cninhthuan-1468920071763.xlsx f27ceabc10484c0124c7d03f8343248df05719486af81ea2fb387d09a2d11b31
data/2016/167116af6c8a096c2a9307edfa115f77dhcnthucpham-tphcm-1468910578187.xls 9962f437ca047cafb3add0c9eb07f96cf4168d026b7d5947ecd2dc915117ef11
data/2016/17843bccde10f362c2b9600bc49cd3f8backan-1468933016076.xlsx ffdabc85670ef7d5522bb33254d6053682b96deb248406e171bd066749c51cae
data/2016/178814c33c8b85a77d0fc295fa6d2be1hagiang-1468932847230.xlsx 843d83e11b78862598eb77b1a5448f7b09f0a7f1842687b4f0174e69ae4fff8b
data/2016/18055d17e46854ecc6c8be585bcb6e5cbaclieu-1468934144150.xlsx ec257a18650045ec476ebbbdbfed48760b4c51b5aee73fe6456729d7cd8ea365
data/2016/1c66db0265bf45df237b37a7bcaeaef3hanoi-1468932790746.xlsx aeb84a0588ce445b852a9328f8a233444a5e5ace6ea0cdff695d96a85cf0015e
data/2016/2004a8d225fd87524e324d2d519e87a4daclac-1468899960505.xlsx 455f5c3f17397139e50b71329e936ffc1c76a39dc244ee72f49289b3eeb5a47c
data/2016/20db5eaace927596f1806f151649ee6edhtantrao-1468901296040.xlsx b25aa4ab69339c095c20d291614fdcd6569f210b33cdebae955e54945e647010
data/2016/216ee4e385dbc236767be899b068bf98dhtaynguyen-1468940490240.xlsx 88346d3fa7087055f4c602bda8456581d721c9f9f2d4e0a18dbc511c9c8288fe
data/2016/2208903e1fa12220d100f93e40d5ff3bquangtri-1468933440097.xlsx 49241f1af4fd0eb84e6a2943f3c07d1c0f7bd71c9a8ea407b5adcff194bcbcbd
data/2016/22e8617886412abd90ae3d33cc6d7db4dhyduocthaibinh-1468941020145.xlsx 9ec94a6c47932bccbf001c431c0d4c480a861cb9aa3f845d9b6e86d4f2688889
data/2016/238adc19e80c0daef089a307af23edcethanhhoa-1468933304999.xlsx 121b6ed30c60a2979db512f55fd2f8e65194c7323b5bf495d8175a256eb8eb2b
data/2016/2b33640b0dd154490f2809d96be81797dhdalat-1468902130616.xlsx 201b953d4a19c0a0441693d43be8f37212b1309084428dc6d1367fb2904a92f6
data/2016/2fa6d9a308bd11bc09f02d787eef2c15lamdong-1468933821082.xlsx 323b5f6551d35d57a995ef04c5d0d569433775fe6e2d685afc88d43442e3d215
data/2016/357c6aaf59aeaa6c11c3e83c595d38cfdhbachkhoa-tphcm-1468939678829.xlsx 873fa6455a85783e4fe54b420c6cbda91803e0fa36356af859236092019f0083
data/2016/388fcdfdac18ef6eee6125a2a015714ddhdongthap-1468934545359.xlsx 6db5b9fa0acd509c1b07bd6a03ae07df19487073a3d18495df3e185e85711189
data/2016/3c0e56abd8c5334f4e1ff7df3ac23a15dienbien-1468920113820.xlsx cfbadef763402bb8ac1e08b398cb38bebc184a2de7e760c44c2e31ce54349a87
data/2016/3daa6ee33fdafadc3c112c18bff18c2ddhthainguyen-1468940268065.xlsx a7d7e20b00b912fdd4658fa73c1fd13efc4a748e01d9bd41ce25d91222e6b29b
data/2016/412e62265f35ca0078a0d94af5d1ed8abinhdinh-1468899884192.xlsx 06d144fca25ec641e5d8bec34df77b69fcea1946120d050d7679bf282a77b1bb
data/2016/43213f61074068d9177685eb6c3f2d3bdhbachkhoa-dhdanang-1468938494437.xlsx 381a59addee12fd6efc7951282578d177101fce5a07be8621b1143f1c99a0e29
data/2016/4334ea476a2b8196d54ba40a341831echvkythuatquansu-1468920342994.xlsx 3705579c6f432e2a8aa053e7f2564f79aad49fde836ad3934d1f9f39375150a4
data/2016/487c7287c1c9735722b31d877942becddhcongnghieptphcm-1468983373303.xlsx 2c50ae0fc1992ad8b61b7d52ad02f32159bd649f0426ae00d17583b53427b05c
data/2016/4a3fa95a83fe9b3b2fafab309323fd22dhthuyloi-1468901840919.xlsx e75239bd4e311ad36e57d42ffb4c2649ce8e599f1ae0bad505957edd69b09a80
data/2016/4ad416ccf014bd5d01f415238566b210khanhhoa-1468933789968.xlsx 7a4451cba4a76acc50362cb94aefee1ff9cb04e6e2461daddc383ff1601c80da
data/2016/4b98fdd7300b86a94636e4d15394fc19sonla-1468899319124.xlsx 961407526e9d79d0e2dfcf370eaed7ff7f64d14835f88481ac17361acbea996c
data/2016/4c2fd846ed2a0670ba6d1200d1d68e1dspkythuatvinhlong-1468902640438.xlsx 88b634010f5888ed6e954807c31712125a280f432219dc652d790d01ef7c10d1
data/2016/4e0ccee19eb64513d23a6dff96ff7f9cvinhphuc-1468919874814.xlsx 52199086d32401b35629d6c4930b024161b0d6d979f4aebc1bef26a391d4b235
data/2016/4e9bb192d56c9fa92b3e0ec13d8236c8dhxaydungmientrung-1468940978346.xlsx dd3d9e4ce4e0c385e753a56f16ce03e7de535a3700e2c62dfe3dce201c73f6f9
data/2016/505a876dfd1ea321291e29192aebe95ehoabinh-1468983220125.xlsx b17f8a592f67c1ed8408b145a2241b4acb6088825463708b5f38749fedfd21be
data/2016/514877c1cfebdc9c2251f651b5f02c7edhspkythuattphcm-1468906568655.xlsx 86cbefa0051faad10505f5d2f5dc4c4690c2c9ccd475fa06713754da9edcc627
data/2016/51bb2f9b6e9f565c2d705606fd56958bdhgiaothongvantai-1468939107463.xlsx b2199701dc1126ee509581dd6fd739d9a913ea6a232a55d59a14fced8ab82cc3
data/2016/538c9676b70f2509573245d58fba93bcdongnai-1468938241935.xlsx 8ff1d5c71cf63b688ecfac852244b24cb6a4321eccf28fe056139f1527f2fb33
data/2016/53e5e20ebd74d84741513b703fbd13bfdhnonglam-dhthainguyen-1468939023637.xlsx b3ac4319785a8f33065f4684b947598733050a916ae9cabaa5684023d42f9163
data/2016/5465cca59495dcd9a48f689e2d38b85ephutho-1468902409682.xlsx 44873acca3ede545505c531b921605fc7b8372c32bff3886572ca072966c898e
data/2016/54bbcf865c137d58cf62af15733bd376dhsupham-dhdanang-1468938526558.xlsx 7e846a06d99ff5b71dbeb3db833d748c7675a17901e3d41bac84a9023e16601d
data/2016/575df8a4df74e87abb8f55d807324374dongthap-1468933902699.xlsx db44859e1fce523fb86907b4928c84f92036e696bdf14a337b1f25f69664389f
data/2016/5aede4951e7af8c1e1056c747a553386hvnongnghiepvn-1468900610577.xlsx 76e75a3d59c5e850f62191dfa3ce65b6d1347b4eaa4f4870c687e94aea5087fd
data/2016/5b16d7156a03bb7ed0618796bae87bc3cantho-1468933954714.xlsx 6d9b5f906fd7b5a66390e42d51f06a3445724e92f4a67cb116ab74359d8693e3
data/2016/5ca241aa1de8d25a3e6ff6db75234025dhcantho-baclieu-1468907071460.xls 290c7e658c3b218befd5b111ad89d8c03f5e5dc1a1815e730da8bc1308660b6c
data/2016/5fb3a1b66aff077ee3da482be08cd391thainguyen-1468902797020.xlsx 6df75f210b0dc269ceb4ddbbe323640cc32d75157096942ff4eb7565157d4d7a
data/2016/5fd13ae5287cbf0149bd733b507e0608dhkhxhnv-dhqgtphcm-1468920630193.xlsx 311b90c29f089ffc7bfdc7e5b9c7fdcefaa803bb6d785189cebb6b170b61ee1e
data/2016/6375fd13557bee08d20f1f3e46dac130dhnhatrang-coso1-1468901350425.xlsx 05dc9a62f844c3a73f0a781066ba9fc3f3bf5faa970cb1b3f0f0f0487769488e
data/2016/64d2635efd30a0d0db4f7c30b9735261dhvinh-1468940230027.xlsx 0e2fc0f487f3963cd063bcb3fac067d2af1ef5a73e5d3def3523e1ecc30851e2
data/2016/64e44466ffeff9a587260795644805d0dhkhtunhien-dhqgtphcm-1468921015146.xlsx 4f67d25acd11182b60d8c938ee49102fb30e8c96c85e6ccecd260fca0f5d53aa
data/2016/6960b6fa493e1365a183af461e945389thaibinh-1468920019273.xlsx 6f10581918d96d82a99eab4c2729672836e091265c4518996f4aad5687038f3a
data/2016/698b930ab7697a0672bbc39168024c9cgialai-1468933753098.xlsx 3be46dfad3d9d2c81557e1bc4103622bb7d9e9ba4fa18b25d2a4b355bf617264
data/2016/6badc7cfa258b59ad67e2b64f713b95fthuathienhue-1468983258438.xlsx 1dccf8266a62a50911046837d01cd07f6085d43dc1c3cc953a35bf033c4991b3
data/2016/70bf8cd2b6e87523219e55149a8524c5dhquynhon-1468938878328.xlsx 874dfbb664c72a3b075be56adc386dba2ac9aea4be2bb7a11d250b67d67b964d
data/2016/74145979ee30186bf4e40ee3e6a3ac74bentre-1468934010040.xlsx 6db50bd7918d17ab04bd4a8a30859719bb1f3e7c5aa6e3bbe8d4f49d4c2bcf49
data/2016/7418e83ff9d07b146216a3beafa927bbdhtravinh-1468902716174.xlsx eadf00e204586e628a12390d335627c65cec48cb0e6ed53f5cc383bac73de402
data/2016/7435bb054e8e4ec34c173af1b6ad8402kiengiang-1468900147395.xlsx 271fa0d823b0d07f32c0fb9b885513e465eeb4880e8fbcc72eedd20759c7d11d
data/2016/74e6cd0dc78a3fb7dcf56ae57283b9b9dhtaybac-1468940302052.xlsx 9b0a007cb2f55d5c2a629d1c9bfa7f16547e3811f3d5c256bd1377b2013e7856
data/2016/76f19098881e9c79f66ac4f1b42a8a55dhsuphamhanoi-1468901144203.xlsx 1a955af97d97f57d9dc20b5c406ca146753760e03050c02c74bef4d62a39ec21
data/2016/778ab074ed6a95d209fa901fd5d68843dhhungvuong-1468901964127.xlsx 0add20e99589c83ec61e21582a965692148d899b4df0182c4a4d6d2ce7696109
data/2016/7afd5d7fdff7c170a6342cd8f9474b22langson-1468932984587.xlsx 8f4014d1d2bf4e02b3136d6790297af8352f8148dfecace502c4cbaa59b6ca7d
data/2016/7b5d74564ab9f6e0e137f6d161fab6f0dhxaydung-1468940561682.xlsx b95d40c1b9497224dd8d59f6e7aeea31a618deba14e43e57539cf32ae748e61d
data/2016/7cfc91bd6a1f5ae9d7917e920f469b90haiduong-1468899640007.xlsx 5abbd89e8130078fe42ecee222327add52e4dbd362c2f96576d9e6ed7c441c19
data/2016/7e0d0b6fb981d407cffdb79d724e8aecbacninh-1468919984998.xlsx 3f32fd1af47a2b1f92175bcc55d95c48be1de1d9544e06f440ac36a8491b1a14
data/2016/7f12b90225e91f9d00aac3bcdf9974e1nghean-1468920043121.xlsx a4c5a99a9faa75e36b809d1bfa4b7caab706bde26649256ed1248f429e59abe6
data/2016/7f1cbe7f35d1f539ca817a0ab8bd00c2dhcantho-haugiang-1468907167921.xls 434417e4765c5b225570c4a19b655c5ac957707209af398f7277a0a9ee90f2b6
data/2016/7f4362ef567cdf74495368e0fc11072avinhlong-1468934067981.xlsx de063663b5f31f525a8a07958d436c8e10805d9ec7b27f9bff2b4a2c7ae559f0
data/2016/82338a4a93ea1e0cde26e0061066ca8adhcongnghiephanoi-1468920154757.xlsx fed3686286093866bfe03b67d7f72ab7c3aaa1a5ce92d503d1ca24582addee31
data/2016/86a692bebf9b0b7c58adb9876b9befd5dhnganhangtphcm-1468902244566.xlsx 5a2b2e5e63bb8c12f0e16b802b51fac470650fc050acd9e846275e82a5d33fbc
data/2016/87ffaf3197ba20f0919f98a0b3f33914dhkhoahoc-dhhue-1468938729472.xlsx 1b46f645bb5c491ba9e9e490f36f1692b055e2d29878dfff1fa4db94fabad72f
data/2016/8916da7b3c4d58cffc1dd29dc7a8aabfdhkinhte-dhhue-1468938560205.xlsx 332aa93e333430021755667c10795749db3ec3f7c7d3afc4d70a48fbd00bc820
data/2016/8af8f1b29b6a40e26ccb4a2e19e8eededhkythuatcongnghiep-dhthainguyen-1468901919568.xlsx 94347288ba444f19a160868649528dc29633f4eb660aad74ea9799ee79935432
data/2016/8f948c2a5ab49a3c5fd03b6ea04887a7dhkinhtequocdanfix-1468902083782.xlsx f8e4c450509b176716429055e2ed72dd340af7ad5a5704db2d0c60818deae548
data/2016/919276a0348634459f8937dfcd6c7129dhhue-1468938792115.xlsx cb91ae55927a40d0eef9ef1bc2bd23a758528de6b36496505dba75e1cccdb849
data/2016/92045214159cb0335b2836aa1c4bb25ahungyen-1468899732002.xlsx 74536aad02570a17c83933c46d10ce9d2300a5e61a37fa2a67e5d99cffb17632
data/2016/964c4368131f6be6058fabb1caefba1chaugiang-1468934247324.xlsx 1df076df14fcb51daffa600dca05064be61269fe19f33dc14d03c8f43d8ee1ca
data/2016/96c95c85c5549a06f2e92ffcacf8ff21hvtaichinh-1468934470924.xlsx 9a7a3841700b27e4265eef8def9addf17406d08c4f007462cda9335f5369c4ff
data/2016/97aa741a4c2b1276559eb021779daa32yenbai-1468933116055.xlsx aa79b6e84e95f58704b715b701449507dbd079f9bee9900f20e6c90baf299952
data/2016/9ba098a7913ad837a66e173b3fc41fd7dhngoaingu-dhdanang-1468902750424.xlsx 8864a16b969ea34dc1f7b8762b8039e17df1599bfea7ca5914dc518983fd36fe
data/2016/9c730c43bf4309171528c3853ed53b19quangbinh-1468933376032.xlsx b637ecd24a240e4a73f291cc4c9faef42379c04d40d1952cf84e54e51eb8cdf1
data/2016/9d78f6dce7965fb4b7a337279920d4b1dhtonducthang-1468902168623.xlsx 1c4f32eb3ed9aa831d2abe41998f5c157ae5ca3d9735b87bb101913870f4c4cc
data/2016/9e0e564ae801e347bd7649da8b89ad90dhluathn-1468920377978.xlsx 1a1b4b0bed838f2c6043d789dae4ab7f96a904b886cd5990a98f7be3b960caf5
data/2016/a11e707e6ed5fe4d29a8e32380d7558cdhsphanoi-2-1468902006284.xlsx 9b8060368d09cf89c1e88001a642d1aec413e4b3f8646c8360a0459d3704dfd3
data/2016/a31b3446bae5a17c517348d8c38c5b7equangninh-1468919920197.xlsx 8b284c18aab150349d49fe596cb96a0a838e8c5618eb1f2d7dd5f0053430c71d
data/2016/a3aa106104f114c6426deb2d5e5da5fdlaichau-1468932954079.xlsx 0697ae494d6a17e188138e2c9f3d412a1bbd8a462e2eb82060ffff25e9982506
data/2016/a8ff80d9478747e36d21ec95cc9ecb8fsoctrang-1468902686409.xlsx 3fb1d35e7647d0581f08d2f56b4d7ccd5fab1ab125bdcb3b83c64c65d540a53b
data/2016/a995de3f7a7055cf10b2f002f0e194f7dhsptphcm-1468920792291.xlsx 9f25b647f9d4d714772ffcb1cba40fa1b338500bd08e39352704de6a9810a8df
data/2016/ab2f259ef55cb08b3a436d3f15d42cc7dhsupham-dhhue-1468934431828.xlsx 96f40bdf397bdf98e596701bfed957d18483ec0affb7e945f6e8b69453b7015c
data/2016/af6828f837a80e03d77a65f3d310ec60dh-tai-chinh-marketing-1468924711596.xlsx 297b71a7760cfff612a290f31082f22647c0d7a8528dbe052356885fc620dc5e
data/2016/b2552e20c9ff4b237244dfbf2e278a47dhthuongmai-1468902876264.xlsx 8c7f9d58fb5ffdfafb1431e9fd51347d338ccba14f547aa26917a2692a515982
data/2016/b2a739584a8861502573d1c6372fd6e9dhnonglamtphcm-1468939555326.xlsx d8c8b6e10d3a44f8a429d2a718af55c853bd508dcb79d13522f1e40d9d6e96e7
data/2016/b34c777942ca8a4de91f23a35cce1c6bdhbachkhoahn-1468901491486.xlsx b5135f12ac53b3f513b46078a89b6a2663089e6bb3ad97d48a13312b5ac69ca6
data/2016/b35a3f26a162bec3740e2da5639327afdhcantho-1468907131146.xls d114bf1c070bb165bd45a64ec79495c764805ee989c740ed95fad06eabd69ec4
data/2016/b42fd58ead4c54279d7df5f53160a851dhdanang-1468920187203.xlsx 9ce9d1b6f494866f6a39e68babe27b3a9562cd1515477f5f32a8e2a8a57bf13e
data/2016/b7a0c1ecce7c8445b3a1021091a5e654bacgiang-1468983153932.xlsx a53266ee75a8d49fe5a017125e198a1368a9bc9259dabe8cdf1cad11551dd439
data/2016/bacd1b2133b37d0ea9fbf46445265036dhtiengiang-1468940391597.xlsx bc90ae7bf5611ae0bb3c321e5185afcb530e1331c5a920a40b85b30b47eb1006
data/2016/bd857add0cfe92c1c247469ff2104527dh-hong-duc-1468900427119.xlsx 96b62a2954d2c1541699d491ea931c240a9b06660d5c27a3cd9055433306aaef
data/2016/bf259a908ff8a8dcc067a761204459f4daknong-1468934173803.xlsx 2f8fb2dbcd9a06acfd91becba0f728bf56e64f5c9984b40eac060834ea619b44
data/2016/c174631d90303276ac63ff38b31157bbdhmodiachat-1468900697349.xlsx c5c4435cfe1e6feede1936fd11e5cd3ff5758b4c4d522df03facf7522a9e7194
data/2016/c2cfaaf287fb1e7120894e545771b027dhkhoahoc-dhthainguyen-1468900365545.xlsx 9a35e56e631ba5d1056fb149ea01a41b4e62ff075937fa735666d192b1714da2
data/2016/c5c28959fcd0b4e39dd9fd538db5448adhgtvttphcm-1468939150821.xlsx 17a032a01a9ebe9f577af8fa4666e8700e9ab4e97166703cf6a7a0ff9dea4e9b
data/2016/c5ecc58a731fd1a54b7e6cc3bc99449ddhlamnghiep-1468939300790.xlsx 8f5f7bf26bafb3a8dd6988ab9858dbf03a14d200a23ed5299a8a5411c84d7f1b
data/2016/c6ef16bc9f75ce0fd16c1229afad4e71dhhanghai-1468906457479.xlsx 8e43d5e1782e366344c8d615666541931319475e02df0be29ae290c7ab2abda1
data/2016/cc57dc986acb525086b86e85df54949dkontum-1468933632113.xlsx 2397d3b0fca3888ff34d4166a9d3da70dcd6986e6405bd556471d46fff0ab551
data/2016/cdc912664d7217c75eb9b67c6ef3797adhkiengiang-1468901222446.xlsx 460eb7e1ce79dde123fe53a18034c5617f0616ad1abce6dedfcd0e4a2957a28d
data/2016/d035f470c13adbf5785797c6ccdcb204laocai-1468919805337.xlsx d23348d36bf6fa886d50e30507d4eada114a58f52cb6e615c119980d7753b5ad
data/2016/d5ecd2574b5ab36c62dffad435262cb8tuyenquang-1468902296807.xlsx 3acb7e4ba9a3c210906b9a32372db747ba2d95d25d276ba3ba45046b941dbbef
data/2016/d652d24e8bd4fe6a48d634b116f37c27dhluattphcm-1468939398928.xlsx f519010d9b2631457c9b7784bb77f07979907321199b98b3f97cb45e5b20c2ff
data/2016/d75282bff0a4d8cd1421c3fb74f789c3dhsaigon-1468900867561.xlsx ee7734bac44228a58a11b85d490cbd5da27d7a6e07b22c1eb9921a123b3e6acd
data/2016/ddb631f833af894200ef10cd408d2ef0caobang-1468932877769.xlsx 2bbda20bab1190f42b44d0eeabcf2be7f2203d4e5f8a2ad3d80365770a8e2d9e
data/2016/deca197a916a8f633f6894b4e13c3ca4dhhaiphong-1468901178368.xlsx 221fe731a4b2de02b3952572baf48d66544fddf87dfdd7851b740325c0864d24
data/2016/dfa63c5b68a8f638379ca0e9c5a1c556namdinh-1468933234061.xlsx 446cd9626fe197f6509efb01ca5b4cb26730907b5e114c4b9c07399a489b45d1
data/2016/e693bc9de67acc916032c6a3b2fc9eedhanam-1468933146526.xlsx b1ef9283b500e965205281d3875eff011be1d418312d187831925860b1ddab74
data/2016/e80dc028d53fa49ee247391a0653dee5quangngai-1468933592400.xlsx 9c6ba937e8ad772a3782baf9c40ef781121c27ffdeb4519170cbf2b81545a146
data/2016/ec765c32190773f5a18cd8202a5eaf0dquangnam-1468933501467.xlsx a566488b611ca04c705f385c47b871792ce75f7c1ca793d0528220352d6b49e1
data/2016/ec84067268f47d523b1e80b493248377dhngoaithuong-1468920480753.xlsx 5d892f08ec818be3ceb343529c9f0b35e0af0aeb43a229446dccaa87c6d4c846
data/2016/ee09c723da4e86cdbe26213413450af2dhsupham-dhthainguyen-1468939061872.xlsx aefac40009116615131c926775e6625eeebc615cd2ff4c7c9af399dd2faab982
data/2016/f0aa5a19c2c8c1fde8211341f0e98e26dhkinhtetphcm-1468939229043.xlsx 5ce36388e304cbba01c513f11f54b940f4299061e2e8acfa4c4219ee536a01f2
data/2016/f2846894bd5533e02b122a1e3b4333f6hvnganhang-1468939472850.xlsx 142b39b4f77c1d7a648c2397165b315acdd48ae6d354d914dd674a46b3d7160c
data/2016/f85f04bb2bbd84457b83ac846728a2a6dhspkythuathungyen-1468920667091.xlsx f8cc571e94720652f1507576ff48616d5331a4ed1403cb95cdc30124a03c3a68
data/2016/fc511852659150f6305f6f624445300ddhangiang-1468940189321.xlsx 4e16ea6a122e5bc0c62c7e6e55d9ede3a41242dd3e034d92a450bf29e8fff197
data/2016/ff877788e43bd84f0119dae026996892dhkinhteluat-dhqgtphcm-1468939860894.xlsx 8c9e7b097d0796750995268aa36a9fa00112c0989b513023ef0a53456afd7051
data/2017/an-giang.xls 5bde5f6447b9e27540f4f72d9b371b6c94376aeb97af65f4ec5eede43ba9f25c
data/2017/bac-giang.xls 9cd5c6d858062a1fb30c477b699be467139f183745e66d134746e043d73579a4
data/2017/bac-kan.xls 9642c00d14c54102ce8dc38408676863a87652aee89651113f4ad3e0a25bd1fa
data/2017/bac-lieu.xls e364dd65fa068d40e9a390aa3322e1676171af797c5d1f98949277cadab5369e
data/2017/bac-ninh.xls 539ed3cb1a2df358ab169e2163face76c949aac2eca0d2acb7ceb51e97811bc5
data/2017/ba-ria-vung-tau.xls 78dc6d67bbf3ffcb7e4407a6d950e28e39366ed62617691c9a9db610a5079134
data/2017/ben-tre.xls b4cce0c5a443062a5e7aef95785fc56f421c21d5ad22f993cbc30ec28e078f4f
data/2017/binh-dinh.xls fa1a39cb5134a8159e71dabea41c9ef2a3ec263642f19bed54ddd17e5a5e3d98
data/2017/binh-duong.xls ced0d7469186d9e5fbbb8c4323a4bb6a589abb8ef23f01d8d39f947a575af4ba
data/2017/binh-phuoc.xls d1ecf9b66ea294eec24ac0f703c614a2011b7d693aeb08c719cfcb73cae7ca47
data/2017/binh-thuan.xls 3cb0f85889ada81cd257f51ae0b0b6414d2ad8f81d7f94f4b1b38010600243c6
data/2017/ca-mau.xls c4bba31f7728f7825c20b3c0b708ef977dcf6f56f0dd5c14d9342cea0467e505
data/2017/can-tho.xls b5ba59ff7b87c31859686adb98da8ef2e95de84404daff2ac3df1e00b748b57d
data/2017/cao-bang.xls 9b0a497ed16840a22af34f7e75e316b00dd60490d6d7f8a2f012507c95fad3e7
data/2017/dak-lak.xls aae3e074d91afd98d89cc9d07bc1f8debe4559bd78c409cc5f50f2670a2c337a
data/2017/dak-nong.xls f7e30b4cdfe951086aeb1c169e7aad88a17bf9fe07aa33f85654e309899f6beb
data/2017/da-nang.xls 80963ff2f56d2b6bf331546d08184e325ad0ec7432ba948c0f74845f440e87c8
data/2017/dien-bien.xls 6bcb1969ce81ccb94c6ee625456530b9661ee255c00eb598dc195611a46205c8
data/2017/dong-nai.xls af248453ea27e879da1b8cfa0f2cd345aa068f93c884d1bc98862db0ca2f9faa
data/2017/dong-thap.xls 186a0c4ac6bf838cc496c4604a7e8979712051c71a53622a9ec72af70491e4f7
data/2017/gia-lai.xls 3a1b64c8362cad028ee53f4fd18b0923315d919b117df9a2fd83d2f46544db98
data/2017/ha-giang.xls a3248393c759a41de424e3ed994d2b9298cb39fda3c6eb9a68f48025c5a2e072
data/2017/hai-duong.xls 6634715f5a0cbac6a8d2d18be0df74e0abd328641721692224758c730fd86e51
data/2017/hai-phong.xls 7a8a210986de8c562a9128dccfaed1c6fc6c32df0c5e2e8948bee2f73777ba99
data/2017/ha-nam.xls f725e212f976af00e68edd6552721e56b30d5fa7d786f548dfc34721ec8b4426
data/2017/ha-noi.xls f0f4bc9216e421acf655a40844a4dcb74e871b188cf6d455982ee6e408170ef7
data/2017/ha-tinh.xls b0f5bcd8beff7cfe411b7bd88f9e5b9c5a9a0578a55cf18bb003284aca12e57f
data/2017/hau-giang.xls 9e8bccd0738bac3d68640768a8d9abc3e7a6a4e97e8c5968039530cf313cafb9
data/2017/hoa-binh.xls 51b1e910bfc47404b411cb2ab1ff14b522d8fe54d66a1d50479350273c7be1b5
data/2017/ho-chi-minh.xls c4e2f920a5e58fa62bd0913d994d44079e472b955adfc504b734c6773c485f64
data/2017/hung-yen.xls 833584c8ea9337734feb37e068bb06232293108d50b18d88f731d30a576cc29a
data/2017/khanh-hoa.xls 644ab06abca93d7b966205604313a65d50f3a0a6fb8be509cadb55e0d55fe12e
data/2017/kien-giang.xls df452e618544a9b7932d918d26dd2a0f2149f92599609c15a9ac373291cfc440
data/2017/kon-tum.xls 7d26ff078adaf51dcf4333b074f8a444d328d8083835bd55b1034c1a63e4eb17
data/2017/lai-chau.xls b37cd06914cad19bc0e523d0b1cfe87db692e26dfab327f5f9a7686b2b14dae9
data/2017/lam-dong.xls 59cc802a6c4d622f1bb6233f6b9b1a94f342a6eae1c389235383db1767f34720
data/2017/lang-son.xls ae376e75bf615483b6cb3a37daa1cea6a7eec4cf3fdd1186801f56d074eb4ce7
data/2017/lao-cai.xls 7596c99851c20ff60b95347bc9fae78014873b4c3e1f1f7144c48d9f285f7962
data/2017/long-an.xls e52d8e8a4592258e87c5cda04e0907541f60e7e7ef4cb21e9171b65283e0b87a
data/2017/nam-dinh.xls 80dcda8b058dcf1a7fdcd7e34d126c44c31b2d4987b52eeae790bf6c38c58e40
data/2017/nghe-an.xls 3f32421eb6ee8bc21fcedd4cc1043df1c9c0381abfaa1006c85c3ca6d6209543
data/2017/ninh-binh.xls dc3d08a86cdd145e870486773d95a1d896a003b5400ba6ff8456a5512b10b2b8
data/2017/ninh-thuan.xls d69b0ed7192e85cff31ad5bca8942fe3c92322268d1ea707e945f9991bd78115
data/2017/phu-tho.xls e352a51109a2373c1efbb6726d262fc490bcbf9229ae90bd473d14a4b6eebcc0
data/2017/phu-yen.xls 6871aedd90d2c6c87ad6409164a9b26f6f89f97cdd7a1dffbbe4e6879f2348c6
data/2017/quang-binh.xls 6c5e9b3c6780a5b70a02dd4b4854861ba148613361c048bfc001706e5bb77a61
data/2017/quang-nam.xls 859258cb8c4b7806101039e4e2484e56971f2640a65720a108a0933245bd3157
data/2017/quang-ngai.xls 834988a6484fab64bc59fe35a6813038663054553d269d16b7d8200593f468e8
data/2017/quang-ninh.xls 1b1c0b1ea1d6aadd8bce5faef6b5fdc5181daaa9bd44df6acbf06c9021a0ade9
data/2017/quang-tri.xls 35e401061962c6d80593d684f179e1f82b05f884a6a470d1bc17b2d31151e05f
data/2017/soc-trang.xls 668a0edcb624faa954c424616ea6c35d6f06b9e9fc62524723dea01af09490c0
data/2017/son-la.xls 96bcfeb35e5bc23ccd7dc6a62a533e21dabeb3145473ecc5a039d970019a1129
data/2017/tay-ninh.xls aedf4ccab43e7a1fa985721de0d6053988ad471355c0ccb0b8f9c19af02423e2
data/2017/thai-binh.xls b20f66cdeb975a64eed7322b616bd291fe34e08e5fbedad3d729d21da0eba627
data/2017/thai-nguyen.xls f0b93db9aab966d393b8a3323204ba7f147ddd0a92746355b81df09bc58267b4
data/2017/thanh-hoa.xls 3f803ae67dac24317fa45dac60f36e39dc222ff84d23a0a3dde7751cdcb17646
data/2017/thua-thien-hue.xls 71e220ee4ea0287657fa379c587ca93f71f96b4dacc48cef074f8c223e940daa
data/2017/tien-giang.xls 2e061f8724e1ea8b997d7e6700ac89a60921771a57dffa7297f4728715811406
data/2017/tra-vinh.xls 59507a1d237ac5e29c1033d42b08da6ac9a7505a5045b791e512e91493102ae7
data/2017/tuyen-quang.xls 053d293e4acd9dfa6ce9532a047cc64b4d2edee1ff13b387723586c1fae6312a
data/2017/vinh-long.xls 6e7584a3047b0ba7487dbb8c71f5b594db220f11b747149d41d31ea97e1e4eab
data/2017/vinh-phuc.xls 9753e2d864584e76aef1e690e4e2de98b28240fbba810e614eab9926668d66d2
data/2017/yen-bai.xls fe6824a1254116bc6ed372e0a1fd635a816a09e010b7efde9659baca2e143138
1 # Canonical cell-dump SHA-256 per input file, produced by the Rust/calamine
2 # ground truth (parser/examples/dump_cells.rs). The Go reader must reproduce
3 # each hash exactly. Hashes only - the dumps themselves are real student PII
4 # and are never committed, per parser/tests/fixtures/README.md.
5 # FROZEN. These hashes were produced by the Rust/calamine parser, which has
6 # since been removed, so they cannot be regenerated. They remain a real
7 # regression guard: any change to the Go reader that alters a single cell of any
8 # of the 299 input files fails the suite. If the inputs themselves ever change,
9 # the affected rows must be re-derived deliberately and reviewed, not refreshed
10 # in bulk.
11 data/2016/023718c7d3cf7ace3a7116fabb12bd9cdhyduoccantho-1468920829104.xlsx fc3afd8da9ed28b0fc177192fab7b19a06535cb45d86841835d8e8828fc61c1c
12 data/2016/08cdeb3636dcb2adaef829d62968274atravinh-1468902443859.xlsx 71f0e61918c63b95f08a16d502f5e9f2054e3c830bd83c725f1285bb1aabead1
13 data/2016/0b583c9875443e65ffa449d5ca76fae8ninhbinh-1468899778557.xlsx b0c2cfb9ab419442f151be5a014439f7f477fa7b31a9ae3f37b83073276bdb59
14 data/2016/15c85d901a73dd49f2bae71aadcdbb5cninhthuan-1468920071763.xlsx f27ceabc10484c0124c7d03f8343248df05719486af81ea2fb387d09a2d11b31
15 data/2016/167116af6c8a096c2a9307edfa115f77dhcnthucpham-tphcm-1468910578187.xls 9962f437ca047cafb3add0c9eb07f96cf4168d026b7d5947ecd2dc915117ef11
16 data/2016/17843bccde10f362c2b9600bc49cd3f8backan-1468933016076.xlsx ffdabc85670ef7d5522bb33254d6053682b96deb248406e171bd066749c51cae
17 data/2016/178814c33c8b85a77d0fc295fa6d2be1hagiang-1468932847230.xlsx 843d83e11b78862598eb77b1a5448f7b09f0a7f1842687b4f0174e69ae4fff8b
18 data/2016/18055d17e46854ecc6c8be585bcb6e5cbaclieu-1468934144150.xlsx ec257a18650045ec476ebbbdbfed48760b4c51b5aee73fe6456729d7cd8ea365
19 data/2016/1c66db0265bf45df237b37a7bcaeaef3hanoi-1468932790746.xlsx aeb84a0588ce445b852a9328f8a233444a5e5ace6ea0cdff695d96a85cf0015e
20 data/2016/2004a8d225fd87524e324d2d519e87a4daclac-1468899960505.xlsx 455f5c3f17397139e50b71329e936ffc1c76a39dc244ee72f49289b3eeb5a47c
21 data/2016/20db5eaace927596f1806f151649ee6edhtantrao-1468901296040.xlsx b25aa4ab69339c095c20d291614fdcd6569f210b33cdebae955e54945e647010
22 data/2016/216ee4e385dbc236767be899b068bf98dhtaynguyen-1468940490240.xlsx 88346d3fa7087055f4c602bda8456581d721c9f9f2d4e0a18dbc511c9c8288fe
23 data/2016/2208903e1fa12220d100f93e40d5ff3bquangtri-1468933440097.xlsx 49241f1af4fd0eb84e6a2943f3c07d1c0f7bd71c9a8ea407b5adcff194bcbcbd
24 data/2016/22e8617886412abd90ae3d33cc6d7db4dhyduocthaibinh-1468941020145.xlsx 9ec94a6c47932bccbf001c431c0d4c480a861cb9aa3f845d9b6e86d4f2688889
25 data/2016/238adc19e80c0daef089a307af23edcethanhhoa-1468933304999.xlsx 121b6ed30c60a2979db512f55fd2f8e65194c7323b5bf495d8175a256eb8eb2b
26 data/2016/2b33640b0dd154490f2809d96be81797dhdalat-1468902130616.xlsx 201b953d4a19c0a0441693d43be8f37212b1309084428dc6d1367fb2904a92f6
27 data/2016/2fa6d9a308bd11bc09f02d787eef2c15lamdong-1468933821082.xlsx 323b5f6551d35d57a995ef04c5d0d569433775fe6e2d685afc88d43442e3d215
28 data/2016/357c6aaf59aeaa6c11c3e83c595d38cfdhbachkhoa-tphcm-1468939678829.xlsx 873fa6455a85783e4fe54b420c6cbda91803e0fa36356af859236092019f0083
29 data/2016/388fcdfdac18ef6eee6125a2a015714ddhdongthap-1468934545359.xlsx 6db5b9fa0acd509c1b07bd6a03ae07df19487073a3d18495df3e185e85711189
30 data/2016/3c0e56abd8c5334f4e1ff7df3ac23a15dienbien-1468920113820.xlsx cfbadef763402bb8ac1e08b398cb38bebc184a2de7e760c44c2e31ce54349a87
31 data/2016/3daa6ee33fdafadc3c112c18bff18c2ddhthainguyen-1468940268065.xlsx a7d7e20b00b912fdd4658fa73c1fd13efc4a748e01d9bd41ce25d91222e6b29b
32 data/2016/412e62265f35ca0078a0d94af5d1ed8abinhdinh-1468899884192.xlsx 06d144fca25ec641e5d8bec34df77b69fcea1946120d050d7679bf282a77b1bb
33 data/2016/43213f61074068d9177685eb6c3f2d3bdhbachkhoa-dhdanang-1468938494437.xlsx 381a59addee12fd6efc7951282578d177101fce5a07be8621b1143f1c99a0e29
34 data/2016/4334ea476a2b8196d54ba40a341831echvkythuatquansu-1468920342994.xlsx 3705579c6f432e2a8aa053e7f2564f79aad49fde836ad3934d1f9f39375150a4
35 data/2016/487c7287c1c9735722b31d877942becddhcongnghieptphcm-1468983373303.xlsx 2c50ae0fc1992ad8b61b7d52ad02f32159bd649f0426ae00d17583b53427b05c
36 data/2016/4a3fa95a83fe9b3b2fafab309323fd22dhthuyloi-1468901840919.xlsx e75239bd4e311ad36e57d42ffb4c2649ce8e599f1ae0bad505957edd69b09a80
37 data/2016/4ad416ccf014bd5d01f415238566b210khanhhoa-1468933789968.xlsx 7a4451cba4a76acc50362cb94aefee1ff9cb04e6e2461daddc383ff1601c80da
38 data/2016/4b98fdd7300b86a94636e4d15394fc19sonla-1468899319124.xlsx 961407526e9d79d0e2dfcf370eaed7ff7f64d14835f88481ac17361acbea996c
39 data/2016/4c2fd846ed2a0670ba6d1200d1d68e1dspkythuatvinhlong-1468902640438.xlsx 88b634010f5888ed6e954807c31712125a280f432219dc652d790d01ef7c10d1
40 data/2016/4e0ccee19eb64513d23a6dff96ff7f9cvinhphuc-1468919874814.xlsx 52199086d32401b35629d6c4930b024161b0d6d979f4aebc1bef26a391d4b235
41 data/2016/4e9bb192d56c9fa92b3e0ec13d8236c8dhxaydungmientrung-1468940978346.xlsx dd3d9e4ce4e0c385e753a56f16ce03e7de535a3700e2c62dfe3dce201c73f6f9
42 data/2016/505a876dfd1ea321291e29192aebe95ehoabinh-1468983220125.xlsx b17f8a592f67c1ed8408b145a2241b4acb6088825463708b5f38749fedfd21be
43 data/2016/514877c1cfebdc9c2251f651b5f02c7edhspkythuattphcm-1468906568655.xlsx 86cbefa0051faad10505f5d2f5dc4c4690c2c9ccd475fa06713754da9edcc627
44 data/2016/51bb2f9b6e9f565c2d705606fd56958bdhgiaothongvantai-1468939107463.xlsx b2199701dc1126ee509581dd6fd739d9a913ea6a232a55d59a14fced8ab82cc3
45 data/2016/538c9676b70f2509573245d58fba93bcdongnai-1468938241935.xlsx 8ff1d5c71cf63b688ecfac852244b24cb6a4321eccf28fe056139f1527f2fb33
46 data/2016/53e5e20ebd74d84741513b703fbd13bfdhnonglam-dhthainguyen-1468939023637.xlsx b3ac4319785a8f33065f4684b947598733050a916ae9cabaa5684023d42f9163
47 data/2016/5465cca59495dcd9a48f689e2d38b85ephutho-1468902409682.xlsx 44873acca3ede545505c531b921605fc7b8372c32bff3886572ca072966c898e
48 data/2016/54bbcf865c137d58cf62af15733bd376dhsupham-dhdanang-1468938526558.xlsx 7e846a06d99ff5b71dbeb3db833d748c7675a17901e3d41bac84a9023e16601d
49 data/2016/575df8a4df74e87abb8f55d807324374dongthap-1468933902699.xlsx db44859e1fce523fb86907b4928c84f92036e696bdf14a337b1f25f69664389f
50 data/2016/5aede4951e7af8c1e1056c747a553386hvnongnghiepvn-1468900610577.xlsx 76e75a3d59c5e850f62191dfa3ce65b6d1347b4eaa4f4870c687e94aea5087fd
51 data/2016/5b16d7156a03bb7ed0618796bae87bc3cantho-1468933954714.xlsx 6d9b5f906fd7b5a66390e42d51f06a3445724e92f4a67cb116ab74359d8693e3
52 data/2016/5ca241aa1de8d25a3e6ff6db75234025dhcantho-baclieu-1468907071460.xls 290c7e658c3b218befd5b111ad89d8c03f5e5dc1a1815e730da8bc1308660b6c
53 data/2016/5fb3a1b66aff077ee3da482be08cd391thainguyen-1468902797020.xlsx 6df75f210b0dc269ceb4ddbbe323640cc32d75157096942ff4eb7565157d4d7a
54 data/2016/5fd13ae5287cbf0149bd733b507e0608dhkhxhnv-dhqgtphcm-1468920630193.xlsx 311b90c29f089ffc7bfdc7e5b9c7fdcefaa803bb6d785189cebb6b170b61ee1e
55 data/2016/6375fd13557bee08d20f1f3e46dac130dhnhatrang-coso1-1468901350425.xlsx 05dc9a62f844c3a73f0a781066ba9fc3f3bf5faa970cb1b3f0f0f0487769488e
56 data/2016/64d2635efd30a0d0db4f7c30b9735261dhvinh-1468940230027.xlsx 0e2fc0f487f3963cd063bcb3fac067d2af1ef5a73e5d3def3523e1ecc30851e2
57 data/2016/64e44466ffeff9a587260795644805d0dhkhtunhien-dhqgtphcm-1468921015146.xlsx 4f67d25acd11182b60d8c938ee49102fb30e8c96c85e6ccecd260fca0f5d53aa
58 data/2016/6960b6fa493e1365a183af461e945389thaibinh-1468920019273.xlsx 6f10581918d96d82a99eab4c2729672836e091265c4518996f4aad5687038f3a
59 data/2016/698b930ab7697a0672bbc39168024c9cgialai-1468933753098.xlsx 3be46dfad3d9d2c81557e1bc4103622bb7d9e9ba4fa18b25d2a4b355bf617264
60 data/2016/6badc7cfa258b59ad67e2b64f713b95fthuathienhue-1468983258438.xlsx 1dccf8266a62a50911046837d01cd07f6085d43dc1c3cc953a35bf033c4991b3
61 data/2016/70bf8cd2b6e87523219e55149a8524c5dhquynhon-1468938878328.xlsx 874dfbb664c72a3b075be56adc386dba2ac9aea4be2bb7a11d250b67d67b964d
62 data/2016/74145979ee30186bf4e40ee3e6a3ac74bentre-1468934010040.xlsx 6db50bd7918d17ab04bd4a8a30859719bb1f3e7c5aa6e3bbe8d4f49d4c2bcf49
63 data/2016/7418e83ff9d07b146216a3beafa927bbdhtravinh-1468902716174.xlsx eadf00e204586e628a12390d335627c65cec48cb0e6ed53f5cc383bac73de402
64 data/2016/7435bb054e8e4ec34c173af1b6ad8402kiengiang-1468900147395.xlsx 271fa0d823b0d07f32c0fb9b885513e465eeb4880e8fbcc72eedd20759c7d11d
65 data/2016/74e6cd0dc78a3fb7dcf56ae57283b9b9dhtaybac-1468940302052.xlsx 9b0a007cb2f55d5c2a629d1c9bfa7f16547e3811f3d5c256bd1377b2013e7856
66 data/2016/76f19098881e9c79f66ac4f1b42a8a55dhsuphamhanoi-1468901144203.xlsx 1a955af97d97f57d9dc20b5c406ca146753760e03050c02c74bef4d62a39ec21
67 data/2016/778ab074ed6a95d209fa901fd5d68843dhhungvuong-1468901964127.xlsx 0add20e99589c83ec61e21582a965692148d899b4df0182c4a4d6d2ce7696109
68 data/2016/7afd5d7fdff7c170a6342cd8f9474b22langson-1468932984587.xlsx 8f4014d1d2bf4e02b3136d6790297af8352f8148dfecace502c4cbaa59b6ca7d
69 data/2016/7b5d74564ab9f6e0e137f6d161fab6f0dhxaydung-1468940561682.xlsx b95d40c1b9497224dd8d59f6e7aeea31a618deba14e43e57539cf32ae748e61d
70 data/2016/7cfc91bd6a1f5ae9d7917e920f469b90haiduong-1468899640007.xlsx 5abbd89e8130078fe42ecee222327add52e4dbd362c2f96576d9e6ed7c441c19
71 data/2016/7e0d0b6fb981d407cffdb79d724e8aecbacninh-1468919984998.xlsx 3f32fd1af47a2b1f92175bcc55d95c48be1de1d9544e06f440ac36a8491b1a14
72 data/2016/7f12b90225e91f9d00aac3bcdf9974e1nghean-1468920043121.xlsx a4c5a99a9faa75e36b809d1bfa4b7caab706bde26649256ed1248f429e59abe6
73 data/2016/7f1cbe7f35d1f539ca817a0ab8bd00c2dhcantho-haugiang-1468907167921.xls 434417e4765c5b225570c4a19b655c5ac957707209af398f7277a0a9ee90f2b6
74 data/2016/7f4362ef567cdf74495368e0fc11072avinhlong-1468934067981.xlsx de063663b5f31f525a8a07958d436c8e10805d9ec7b27f9bff2b4a2c7ae559f0
75 data/2016/82338a4a93ea1e0cde26e0061066ca8adhcongnghiephanoi-1468920154757.xlsx fed3686286093866bfe03b67d7f72ab7c3aaa1a5ce92d503d1ca24582addee31
76 data/2016/86a692bebf9b0b7c58adb9876b9befd5dhnganhangtphcm-1468902244566.xlsx 5a2b2e5e63bb8c12f0e16b802b51fac470650fc050acd9e846275e82a5d33fbc
77 data/2016/87ffaf3197ba20f0919f98a0b3f33914dhkhoahoc-dhhue-1468938729472.xlsx 1b46f645bb5c491ba9e9e490f36f1692b055e2d29878dfff1fa4db94fabad72f
78 data/2016/8916da7b3c4d58cffc1dd29dc7a8aabfdhkinhte-dhhue-1468938560205.xlsx 332aa93e333430021755667c10795749db3ec3f7c7d3afc4d70a48fbd00bc820
79 data/2016/8af8f1b29b6a40e26ccb4a2e19e8eededhkythuatcongnghiep-dhthainguyen-1468901919568.xlsx 94347288ba444f19a160868649528dc29633f4eb660aad74ea9799ee79935432
80 data/2016/8f948c2a5ab49a3c5fd03b6ea04887a7dhkinhtequocdanfix-1468902083782.xlsx f8e4c450509b176716429055e2ed72dd340af7ad5a5704db2d0c60818deae548
81 data/2016/919276a0348634459f8937dfcd6c7129dhhue-1468938792115.xlsx cb91ae55927a40d0eef9ef1bc2bd23a758528de6b36496505dba75e1cccdb849
82 data/2016/92045214159cb0335b2836aa1c4bb25ahungyen-1468899732002.xlsx 74536aad02570a17c83933c46d10ce9d2300a5e61a37fa2a67e5d99cffb17632
83 data/2016/964c4368131f6be6058fabb1caefba1chaugiang-1468934247324.xlsx 1df076df14fcb51daffa600dca05064be61269fe19f33dc14d03c8f43d8ee1ca
84 data/2016/96c95c85c5549a06f2e92ffcacf8ff21hvtaichinh-1468934470924.xlsx 9a7a3841700b27e4265eef8def9addf17406d08c4f007462cda9335f5369c4ff
85 data/2016/97aa741a4c2b1276559eb021779daa32yenbai-1468933116055.xlsx aa79b6e84e95f58704b715b701449507dbd079f9bee9900f20e6c90baf299952
86 data/2016/9ba098a7913ad837a66e173b3fc41fd7dhngoaingu-dhdanang-1468902750424.xlsx 8864a16b969ea34dc1f7b8762b8039e17df1599bfea7ca5914dc518983fd36fe
87 data/2016/9c730c43bf4309171528c3853ed53b19quangbinh-1468933376032.xlsx b637ecd24a240e4a73f291cc4c9faef42379c04d40d1952cf84e54e51eb8cdf1
88 data/2016/9d78f6dce7965fb4b7a337279920d4b1dhtonducthang-1468902168623.xlsx 1c4f32eb3ed9aa831d2abe41998f5c157ae5ca3d9735b87bb101913870f4c4cc
89 data/2016/9e0e564ae801e347bd7649da8b89ad90dhluathn-1468920377978.xlsx 1a1b4b0bed838f2c6043d789dae4ab7f96a904b886cd5990a98f7be3b960caf5
90 data/2016/a11e707e6ed5fe4d29a8e32380d7558cdhsphanoi-2-1468902006284.xlsx 9b8060368d09cf89c1e88001a642d1aec413e4b3f8646c8360a0459d3704dfd3
91 data/2016/a31b3446bae5a17c517348d8c38c5b7equangninh-1468919920197.xlsx 8b284c18aab150349d49fe596cb96a0a838e8c5618eb1f2d7dd5f0053430c71d
92 data/2016/a3aa106104f114c6426deb2d5e5da5fdlaichau-1468932954079.xlsx 0697ae494d6a17e188138e2c9f3d412a1bbd8a462e2eb82060ffff25e9982506
93 data/2016/a8ff80d9478747e36d21ec95cc9ecb8fsoctrang-1468902686409.xlsx 3fb1d35e7647d0581f08d2f56b4d7ccd5fab1ab125bdcb3b83c64c65d540a53b
94 data/2016/a995de3f7a7055cf10b2f002f0e194f7dhsptphcm-1468920792291.xlsx 9f25b647f9d4d714772ffcb1cba40fa1b338500bd08e39352704de6a9810a8df
95 data/2016/ab2f259ef55cb08b3a436d3f15d42cc7dhsupham-dhhue-1468934431828.xlsx 96f40bdf397bdf98e596701bfed957d18483ec0affb7e945f6e8b69453b7015c
96 data/2016/af6828f837a80e03d77a65f3d310ec60dh-tai-chinh-marketing-1468924711596.xlsx 297b71a7760cfff612a290f31082f22647c0d7a8528dbe052356885fc620dc5e
97 data/2016/b2552e20c9ff4b237244dfbf2e278a47dhthuongmai-1468902876264.xlsx 8c7f9d58fb5ffdfafb1431e9fd51347d338ccba14f547aa26917a2692a515982
98 data/2016/b2a739584a8861502573d1c6372fd6e9dhnonglamtphcm-1468939555326.xlsx d8c8b6e10d3a44f8a429d2a718af55c853bd508dcb79d13522f1e40d9d6e96e7
99 data/2016/b34c777942ca8a4de91f23a35cce1c6bdhbachkhoahn-1468901491486.xlsx b5135f12ac53b3f513b46078a89b6a2663089e6bb3ad97d48a13312b5ac69ca6
100 data/2016/b35a3f26a162bec3740e2da5639327afdhcantho-1468907131146.xls d114bf1c070bb165bd45a64ec79495c764805ee989c740ed95fad06eabd69ec4
101 data/2016/b42fd58ead4c54279d7df5f53160a851dhdanang-1468920187203.xlsx 9ce9d1b6f494866f6a39e68babe27b3a9562cd1515477f5f32a8e2a8a57bf13e
102 data/2016/b7a0c1ecce7c8445b3a1021091a5e654bacgiang-1468983153932.xlsx a53266ee75a8d49fe5a017125e198a1368a9bc9259dabe8cdf1cad11551dd439
103 data/2016/bacd1b2133b37d0ea9fbf46445265036dhtiengiang-1468940391597.xlsx bc90ae7bf5611ae0bb3c321e5185afcb530e1331c5a920a40b85b30b47eb1006
104 data/2016/bd857add0cfe92c1c247469ff2104527dh-hong-duc-1468900427119.xlsx 96b62a2954d2c1541699d491ea931c240a9b06660d5c27a3cd9055433306aaef
105 data/2016/bf259a908ff8a8dcc067a761204459f4daknong-1468934173803.xlsx 2f8fb2dbcd9a06acfd91becba0f728bf56e64f5c9984b40eac060834ea619b44
106 data/2016/c174631d90303276ac63ff38b31157bbdhmodiachat-1468900697349.xlsx c5c4435cfe1e6feede1936fd11e5cd3ff5758b4c4d522df03facf7522a9e7194
107 data/2016/c2cfaaf287fb1e7120894e545771b027dhkhoahoc-dhthainguyen-1468900365545.xlsx 9a35e56e631ba5d1056fb149ea01a41b4e62ff075937fa735666d192b1714da2
108 data/2016/c5c28959fcd0b4e39dd9fd538db5448adhgtvttphcm-1468939150821.xlsx 17a032a01a9ebe9f577af8fa4666e8700e9ab4e97166703cf6a7a0ff9dea4e9b
109 data/2016/c5ecc58a731fd1a54b7e6cc3bc99449ddhlamnghiep-1468939300790.xlsx 8f5f7bf26bafb3a8dd6988ab9858dbf03a14d200a23ed5299a8a5411c84d7f1b
110 data/2016/c6ef16bc9f75ce0fd16c1229afad4e71dhhanghai-1468906457479.xlsx 8e43d5e1782e366344c8d615666541931319475e02df0be29ae290c7ab2abda1
111 data/2016/cc57dc986acb525086b86e85df54949dkontum-1468933632113.xlsx 2397d3b0fca3888ff34d4166a9d3da70dcd6986e6405bd556471d46fff0ab551
112 data/2016/cdc912664d7217c75eb9b67c6ef3797adhkiengiang-1468901222446.xlsx 460eb7e1ce79dde123fe53a18034c5617f0616ad1abce6dedfcd0e4a2957a28d
113 data/2016/d035f470c13adbf5785797c6ccdcb204laocai-1468919805337.xlsx d23348d36bf6fa886d50e30507d4eada114a58f52cb6e615c119980d7753b5ad
114 data/2016/d5ecd2574b5ab36c62dffad435262cb8tuyenquang-1468902296807.xlsx 3acb7e4ba9a3c210906b9a32372db747ba2d95d25d276ba3ba45046b941dbbef
115 data/2016/d652d24e8bd4fe6a48d634b116f37c27dhluattphcm-1468939398928.xlsx f519010d9b2631457c9b7784bb77f07979907321199b98b3f97cb45e5b20c2ff
116 data/2016/d75282bff0a4d8cd1421c3fb74f789c3dhsaigon-1468900867561.xlsx ee7734bac44228a58a11b85d490cbd5da27d7a6e07b22c1eb9921a123b3e6acd
117 data/2016/ddb631f833af894200ef10cd408d2ef0caobang-1468932877769.xlsx 2bbda20bab1190f42b44d0eeabcf2be7f2203d4e5f8a2ad3d80365770a8e2d9e
118 data/2016/deca197a916a8f633f6894b4e13c3ca4dhhaiphong-1468901178368.xlsx 221fe731a4b2de02b3952572baf48d66544fddf87dfdd7851b740325c0864d24
119 data/2016/dfa63c5b68a8f638379ca0e9c5a1c556namdinh-1468933234061.xlsx 446cd9626fe197f6509efb01ca5b4cb26730907b5e114c4b9c07399a489b45d1
120 data/2016/e693bc9de67acc916032c6a3b2fc9eedhanam-1468933146526.xlsx b1ef9283b500e965205281d3875eff011be1d418312d187831925860b1ddab74
121 data/2016/e80dc028d53fa49ee247391a0653dee5quangngai-1468933592400.xlsx 9c6ba937e8ad772a3782baf9c40ef781121c27ffdeb4519170cbf2b81545a146
122 data/2016/ec765c32190773f5a18cd8202a5eaf0dquangnam-1468933501467.xlsx a566488b611ca04c705f385c47b871792ce75f7c1ca793d0528220352d6b49e1
123 data/2016/ec84067268f47d523b1e80b493248377dhngoaithuong-1468920480753.xlsx 5d892f08ec818be3ceb343529c9f0b35e0af0aeb43a229446dccaa87c6d4c846
124 data/2016/ee09c723da4e86cdbe26213413450af2dhsupham-dhthainguyen-1468939061872.xlsx aefac40009116615131c926775e6625eeebc615cd2ff4c7c9af399dd2faab982
125 data/2016/f0aa5a19c2c8c1fde8211341f0e98e26dhkinhtetphcm-1468939229043.xlsx 5ce36388e304cbba01c513f11f54b940f4299061e2e8acfa4c4219ee536a01f2
126 data/2016/f2846894bd5533e02b122a1e3b4333f6hvnganhang-1468939472850.xlsx 142b39b4f77c1d7a648c2397165b315acdd48ae6d354d914dd674a46b3d7160c
127 data/2016/f85f04bb2bbd84457b83ac846728a2a6dhspkythuathungyen-1468920667091.xlsx f8cc571e94720652f1507576ff48616d5331a4ed1403cb95cdc30124a03c3a68
128 data/2016/fc511852659150f6305f6f624445300ddhangiang-1468940189321.xlsx 4e16ea6a122e5bc0c62c7e6e55d9ede3a41242dd3e034d92a450bf29e8fff197
129 data/2016/ff877788e43bd84f0119dae026996892dhkinhteluat-dhqgtphcm-1468939860894.xlsx 8c9e7b097d0796750995268aa36a9fa00112c0989b513023ef0a53456afd7051
130 data/2017/an-giang.xls 5bde5f6447b9e27540f4f72d9b371b6c94376aeb97af65f4ec5eede43ba9f25c
131 data/2017/bac-giang.xls 9cd5c6d858062a1fb30c477b699be467139f183745e66d134746e043d73579a4
132 data/2017/bac-kan.xls 9642c00d14c54102ce8dc38408676863a87652aee89651113f4ad3e0a25bd1fa
133 data/2017/bac-lieu.xls e364dd65fa068d40e9a390aa3322e1676171af797c5d1f98949277cadab5369e
134 data/2017/bac-ninh.xls 539ed3cb1a2df358ab169e2163face76c949aac2eca0d2acb7ceb51e97811bc5
135 data/2017/ba-ria-vung-tau.xls 78dc6d67bbf3ffcb7e4407a6d950e28e39366ed62617691c9a9db610a5079134
136 data/2017/ben-tre.xls b4cce0c5a443062a5e7aef95785fc56f421c21d5ad22f993cbc30ec28e078f4f
137 data/2017/binh-dinh.xls fa1a39cb5134a8159e71dabea41c9ef2a3ec263642f19bed54ddd17e5a5e3d98
138 data/2017/binh-duong.xls ced0d7469186d9e5fbbb8c4323a4bb6a589abb8ef23f01d8d39f947a575af4ba
139 data/2017/binh-phuoc.xls d1ecf9b66ea294eec24ac0f703c614a2011b7d693aeb08c719cfcb73cae7ca47
140 data/2017/binh-thuan.xls 3cb0f85889ada81cd257f51ae0b0b6414d2ad8f81d7f94f4b1b38010600243c6
141 data/2017/ca-mau.xls c4bba31f7728f7825c20b3c0b708ef977dcf6f56f0dd5c14d9342cea0467e505
142 data/2017/can-tho.xls b5ba59ff7b87c31859686adb98da8ef2e95de84404daff2ac3df1e00b748b57d
143 data/2017/cao-bang.xls 9b0a497ed16840a22af34f7e75e316b00dd60490d6d7f8a2f012507c95fad3e7
144 data/2017/dak-lak.xls aae3e074d91afd98d89cc9d07bc1f8debe4559bd78c409cc5f50f2670a2c337a
145 data/2017/dak-nong.xls f7e30b4cdfe951086aeb1c169e7aad88a17bf9fe07aa33f85654e309899f6beb
146 data/2017/da-nang.xls 80963ff2f56d2b6bf331546d08184e325ad0ec7432ba948c0f74845f440e87c8
147 data/2017/dien-bien.xls 6bcb1969ce81ccb94c6ee625456530b9661ee255c00eb598dc195611a46205c8
148 data/2017/dong-nai.xls af248453ea27e879da1b8cfa0f2cd345aa068f93c884d1bc98862db0ca2f9faa
149 data/2017/dong-thap.xls 186a0c4ac6bf838cc496c4604a7e8979712051c71a53622a9ec72af70491e4f7
150 data/2017/gia-lai.xls 3a1b64c8362cad028ee53f4fd18b0923315d919b117df9a2fd83d2f46544db98
151 data/2017/ha-giang.xls a3248393c759a41de424e3ed994d2b9298cb39fda3c6eb9a68f48025c5a2e072
152 data/2017/hai-duong.xls 6634715f5a0cbac6a8d2d18be0df74e0abd328641721692224758c730fd86e51
153 data/2017/hai-phong.xls 7a8a210986de8c562a9128dccfaed1c6fc6c32df0c5e2e8948bee2f73777ba99
154 data/2017/ha-nam.xls f725e212f976af00e68edd6552721e56b30d5fa7d786f548dfc34721ec8b4426
155 data/2017/ha-noi.xls f0f4bc9216e421acf655a40844a4dcb74e871b188cf6d455982ee6e408170ef7
156 data/2017/ha-tinh.xls b0f5bcd8beff7cfe411b7bd88f9e5b9c5a9a0578a55cf18bb003284aca12e57f
157 data/2017/hau-giang.xls 9e8bccd0738bac3d68640768a8d9abc3e7a6a4e97e8c5968039530cf313cafb9
158 data/2017/hoa-binh.xls 51b1e910bfc47404b411cb2ab1ff14b522d8fe54d66a1d50479350273c7be1b5
159 data/2017/ho-chi-minh.xls c4e2f920a5e58fa62bd0913d994d44079e472b955adfc504b734c6773c485f64
160 data/2017/hung-yen.xls 833584c8ea9337734feb37e068bb06232293108d50b18d88f731d30a576cc29a
161 data/2017/khanh-hoa.xls 644ab06abca93d7b966205604313a65d50f3a0a6fb8be509cadb55e0d55fe12e
162 data/2017/kien-giang.xls df452e618544a9b7932d918d26dd2a0f2149f92599609c15a9ac373291cfc440
163 data/2017/kon-tum.xls 7d26ff078adaf51dcf4333b074f8a444d328d8083835bd55b1034c1a63e4eb17
164 data/2017/lai-chau.xls b37cd06914cad19bc0e523d0b1cfe87db692e26dfab327f5f9a7686b2b14dae9
165 data/2017/lam-dong.xls 59cc802a6c4d622f1bb6233f6b9b1a94f342a6eae1c389235383db1767f34720
166 data/2017/lang-son.xls ae376e75bf615483b6cb3a37daa1cea6a7eec4cf3fdd1186801f56d074eb4ce7
167 data/2017/lao-cai.xls 7596c99851c20ff60b95347bc9fae78014873b4c3e1f1f7144c48d9f285f7962
168 data/2017/long-an.xls e52d8e8a4592258e87c5cda04e0907541f60e7e7ef4cb21e9171b65283e0b87a
169 data/2017/nam-dinh.xls 80dcda8b058dcf1a7fdcd7e34d126c44c31b2d4987b52eeae790bf6c38c58e40
170 data/2017/nghe-an.xls 3f32421eb6ee8bc21fcedd4cc1043df1c9c0381abfaa1006c85c3ca6d6209543
171 data/2017/ninh-binh.xls dc3d08a86cdd145e870486773d95a1d896a003b5400ba6ff8456a5512b10b2b8
172 data/2017/ninh-thuan.xls d69b0ed7192e85cff31ad5bca8942fe3c92322268d1ea707e945f9991bd78115
173 data/2017/phu-tho.xls e352a51109a2373c1efbb6726d262fc490bcbf9229ae90bd473d14a4b6eebcc0
174 data/2017/phu-yen.xls 6871aedd90d2c6c87ad6409164a9b26f6f89f97cdd7a1dffbbe4e6879f2348c6
175 data/2017/quang-binh.xls 6c5e9b3c6780a5b70a02dd4b4854861ba148613361c048bfc001706e5bb77a61
176 data/2017/quang-nam.xls 859258cb8c4b7806101039e4e2484e56971f2640a65720a108a0933245bd3157
177 data/2017/quang-ngai.xls 834988a6484fab64bc59fe35a6813038663054553d269d16b7d8200593f468e8
178 data/2017/quang-ninh.xls 1b1c0b1ea1d6aadd8bce5faef6b5fdc5181daaa9bd44df6acbf06c9021a0ade9
179 data/2017/quang-tri.xls 35e401061962c6d80593d684f179e1f82b05f884a6a470d1bc17b2d31151e05f
180 data/2017/soc-trang.xls 668a0edcb624faa954c424616ea6c35d6f06b9e9fc62524723dea01af09490c0
181 data/2017/son-la.xls 96bcfeb35e5bc23ccd7dc6a62a533e21dabeb3145473ecc5a039d970019a1129
182 data/2017/tay-ninh.xls aedf4ccab43e7a1fa985721de0d6053988ad471355c0ccb0b8f9c19af02423e2
183 data/2017/thai-binh.xls b20f66cdeb975a64eed7322b616bd291fe34e08e5fbedad3d729d21da0eba627
184 data/2017/thai-nguyen.xls f0b93db9aab966d393b8a3323204ba7f147ddd0a92746355b81df09bc58267b4
185 data/2017/thanh-hoa.xls 3f803ae67dac24317fa45dac60f36e39dc222ff84d23a0a3dde7751cdcb17646
186 data/2017/thua-thien-hue.xls 71e220ee4ea0287657fa379c587ca93f71f96b4dacc48cef074f8c223e940daa
187 data/2017/tien-giang.xls 2e061f8724e1ea8b997d7e6700ac89a60921771a57dffa7297f4728715811406
188 data/2017/tra-vinh.xls 59507a1d237ac5e29c1033d42b08da6ac9a7505a5045b791e512e91493102ae7
189 data/2017/tuyen-quang.xls 053d293e4acd9dfa6ce9532a047cc64b4d2edee1ff13b387723586c1fae6312a
190 data/2017/vinh-long.xls 6e7584a3047b0ba7487dbb8c71f5b594db220f11b747149d41d31ea97e1e4eab
191 data/2017/vinh-phuc.xls 9753e2d864584e76aef1e690e4e2de98b28240fbba810e614eab9926668d66d2
192 data/2017/yen-bai.xls fe6824a1254116bc6ed372e0a1fd635a816a09e010b7efde9659baca2e143138