Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design-nclbgc.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ The fixture sanitizer (`tools/make_nclbgc_fixture.py`) and the six fixtures alre

### 4.1 Zones (mirrors 4.1)

- `data/raw/nclbgc/<run-id>/` (gitignored): the verbatim fragments (search / detail / qualifiers / public-matters HTML), `nclbgc-licenses.json` (the mechanically decoded license records, field names as the parser assigns them, values untouched), `acquire-manifest.json` (checksums, timestamp, count, client version), and `resolution-report.json` (the per-vendor match status — the analog of slice 1's drift report).
- `data/raw/nclbgc/<run-id>/` (gitignored): the verbatim fragments (search / detail / qualifiers / public-matters HTML), `nclbgc-licenses.json` (the mechanically decoded license records, field names as the parser assigns them, values untouched), `acquire-manifest.json` (checksums, timestamp, count, client version), and `resolution-report.json` (the per-vendor match status **and the discovered board license** — the `vendor → license` link slice 3 reconciles, issue #27; the analog of slice 1's drift report).
- `data/processed/` (gitignored): the deliverable pair — `nclbgc-license-master-<YYYYMMDD>.csv` (no red columns) and `nclbgc-license-contacts-<YYYYMMDD>.csv` (`row_key` + the red columns) — plus `audit/<run-id>/` and `profile/`.

### 4.2 The license-details schema (twelve columns, per the NCLBGC data dictionary)
Expand Down
41 changes: 28 additions & 13 deletions src/vendorscope/nclbgc_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,19 @@

@dataclass(frozen=True, slots=True)
class Resolution:
"""How one slice-1 vendor resolved against the board."""
"""How one slice-1 vendor resolved against the board.

``license_number`` is the vendor's input eVP value; ``discovered_license`` is
the board-confirmed ``License_Number`` from the matched record (empty when
unresolved) — the link slice 3 reconciles into the vendor master (issue #27).
"""

index: int
license_number: str
name: str
status: str # matched-by-license | matched-by-name | unresolved
key: str | None
discovered_license: str


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -112,17 +118,21 @@ def acquire(
)
tag = f"{index:0{width}d}"
(raw_dir / f"search-{tag}.html").write_text(search_html, encoding="utf-8")
resolutions.append(Resolution(index, license_number, name, status, key))
if key is None:
continue
# raw-first: freeze each fragment verbatim before decoding
detail = _fetch(client, config.NCLBGC_DETAIL_PATH, key)
qualifiers = _fetch(client, config.NCLBGC_QUALIFIERS_PATH, key)
matters = _fetch(client, config.NCLBGC_MATTERS_PATH, key)
(raw_dir / f"detail-{tag}.html").write_bytes(detail.content)
(raw_dir / f"qualifiers-{tag}.html").write_bytes(qualifiers.content)
(raw_dir / f"matters-{tag}.html").write_bytes(matters.content)
records.append(nclbgc_parse.decode_record(detail.text, qualifiers.text))
discovered_license = ""
if key is not None:
# raw-first: freeze each fragment verbatim before decoding
detail = _fetch(client, config.NCLBGC_DETAIL_PATH, key)
qualifiers = _fetch(client, config.NCLBGC_QUALIFIERS_PATH, key)
matters = _fetch(client, config.NCLBGC_MATTERS_PATH, key)
(raw_dir / f"detail-{tag}.html").write_bytes(detail.content)
(raw_dir / f"qualifiers-{tag}.html").write_bytes(qualifiers.content)
(raw_dir / f"matters-{tag}.html").write_bytes(matters.content)
record = nclbgc_parse.decode_record(detail.text, qualifiers.text)
records.append(record)
discovered_license = record["License_Number"] # the board link (issue #27)
resolutions.append(
Resolution(index, license_number, name, status, key, discovered_license)
)

json_bytes = json.dumps(records, indent=1, ensure_ascii=False).encode("utf-8")
(raw_dir / "nclbgc-licenses.json").write_bytes(json_bytes)
Expand All @@ -138,7 +148,12 @@ def acquire(
json.dumps(manifest, indent=1).encode("utf-8")
)
report = [
{"index": r.index, "license_number": r.license_number, "status": r.status}
{
"index": r.index,
"status": r.status,
"license_number": r.license_number, # the vendor's input eVP value
"discovered_license": r.discovered_license, # the board link (issue #27)
}
for r in resolutions
]
(raw_dir / "resolution-report.json").write_bytes(
Expand Down
20 changes: 20 additions & 0 deletions tests/test_nclbgc_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,26 @@ def test_flagged_license_goes_straight_to_name(tmp_path: Path) -> None:
assert wv.status == "matched-by-name"


@pytest.mark.contract
def test_resolution_carries_discovered_license(tmp_path: Path) -> None:
# issue #27: the vendor -> discovered-license linkage must be persisted, so
# slice 3 can reconcile a name-matched vendor (whose eVP license was blank or
# wrong) to the board-confirmed license. Every matched vendor carries the
# decoded License_Number; an unresolved vendor carries "".
result = _acquire(tmp_path)
assert [r.discovered_license for r in result.resolutions] == [
"L.68764",
"L.68764",
"L.68764",
"",
]
report = json.loads(
(result.raw_dir / "resolution-report.json").read_text(encoding="utf-8")
)
name_match = next(e for e in report if e["status"] == "matched-by-name")
assert name_match["discovered_license"] == "L.68764"


@pytest.mark.contract
def test_raw_frozen_and_reparsable(tmp_path: Path) -> None:
result = _acquire(tmp_path)
Expand Down