From 89eb4a45d11836e2a05fce51239b5765b6c16cc6 Mon Sep 17 00:00:00 2001 From: Wolfgang Sanyer Date: Sat, 13 Jun 2026 16:35:24 -0400 Subject: [PATCH] [VENDOR][FIX] Persist the vendor-to-discovered-license link (issue 27) Slice 2's resolution recorded only the vendor's input eVP license and dropped the board-confirmed License_Number it discovered on a name match. So a name-matched vendor (whose eVP license was blank or out-of-state) could not be linked to its real license from the persisted output, blocking slice 3's reconciliation. Capture the decoded License_Number as Resolution.discovered_license and write it into resolution-report.json; the acquire loop now decodes the record before recording the resolution. Regression test added first, then the fix. --- docs/design-nclbgc.md | 2 +- src/vendorscope/nclbgc_client.py | 41 ++++++++++++++++++++++---------- tests/test_nclbgc_client.py | 20 ++++++++++++++++ 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/docs/design-nclbgc.md b/docs/design-nclbgc.md index 3d7c168..66d10e2 100644 --- a/docs/design-nclbgc.md +++ b/docs/design-nclbgc.md @@ -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//` (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//` (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-.csv` (no red columns) and `nclbgc-license-contacts-.csv` (`row_key` + the red columns) — plus `audit//` and `profile/`. ### 4.2 The license-details schema (twelve columns, per the NCLBGC data dictionary) diff --git a/src/vendorscope/nclbgc_client.py b/src/vendorscope/nclbgc_client.py index 3fc189a..e5c6c35 100644 --- a/src/vendorscope/nclbgc_client.py +++ b/src/vendorscope/nclbgc_client.py @@ -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) @@ -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) @@ -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( diff --git a/tests/test_nclbgc_client.py b/tests/test_nclbgc_client.py index 96dadda..c4523fd 100644 --- a/tests/test_nclbgc_client.py +++ b/tests/test_nclbgc_client.py @@ -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)