Files
wyndham-ARR/booking_ingestion/md_fixture.py
2026-07-29 16:38:05 +08:00

199 lines
5.9 KiB
Python

"""Deterministic parser for the approved booking-room Markdown fixture."""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass
from pathlib import Path
PROCESSOR_NAME = "booking-md-fixture-importer"
PROCESSOR_VERSION = "1.0.0"
RESULT_SCHEMA_VERSION = "1.0"
_WORKSHEET_RE = re.compile(r"^## 工作表:(.*\S)\s*$")
_ROW_RE = re.compile(
r"^\|\s*第\s*(\d+)\s*行\s*"
r"\|\s*([^|]+?)\s*"
r"\|\s*([^|]+?)\s*"
r"\|\s*(\d+)\s*\|\s*$"
)
_SOURCE_FILE_RE = re.compile(r"^来源文件:`([^`]+)`\s*$")
_MAPPING_FILE_RE = re.compile(r"^房型映射文件:`([^`]+)`\s*$")
@dataclass(frozen=True)
class FixtureRow:
worksheet: str
row_no: int
group_code_raw: str
type_of_room_raw: str
no_of_rooms: int
@property
def group_code_key(self) -> str:
return self.group_code_raw.strip().upper()
def canonical_source(self) -> dict[str, object]:
return {
"worksheet": self.worksheet,
"row_no": self.row_no,
"group_code_raw": self.group_code_raw,
"group_code_key": self.group_code_key,
"type_of_room_raw": self.type_of_room_raw,
"no_of_rooms": self.no_of_rooms,
}
def sha256(self) -> str:
return hashlib.sha256(
canonical_json_bytes(self.canonical_source())
).hexdigest()
@dataclass(frozen=True)
class FixtureDocument:
source_file: str
mapping_file: str
rows: tuple[FixtureRow, ...]
@property
def worksheet_count(self) -> int:
return len({row.worksheet for row in self.rows})
@property
def distinct_group_code_count(self) -> int:
return len({row.group_code_key for row in self.rows})
def canonical_json_bytes(value: object) -> bytes:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
def parse_fixture_markdown(text: str) -> FixtureDocument:
source_file = ""
mapping_file = ""
worksheet: str | None = None
rows: list[FixtureRow] = []
coordinates: set[tuple[str, int]] = set()
for physical_line_no, raw_line in enumerate(text.splitlines(), start=1):
line = raw_line.strip()
source_match = _SOURCE_FILE_RE.fullmatch(line)
if source_match:
source_file = source_match.group(1).strip()
continue
mapping_match = _MAPPING_FILE_RE.fullmatch(line)
if mapping_match:
mapping_file = mapping_match.group(1).strip()
continue
worksheet_match = _WORKSHEET_RE.fullmatch(line)
if worksheet_match:
worksheet = worksheet_match.group(1).strip()
if not worksheet:
raise ValueError(
f"empty worksheet at Markdown line {physical_line_no}"
)
continue
row_match = _ROW_RE.fullmatch(line)
if not row_match:
continue
if worksheet is None:
raise ValueError(
f"data row before worksheet at Markdown line {physical_line_no}"
)
row = FixtureRow(
worksheet=worksheet,
row_no=int(row_match.group(1)),
group_code_raw=row_match.group(2).strip(),
type_of_room_raw=row_match.group(3).strip(),
no_of_rooms=int(row_match.group(4)),
)
if not row.group_code_key:
raise ValueError(
f"empty Group Code at Markdown line {physical_line_no}"
)
if not row.type_of_room_raw:
raise ValueError(
f"empty type of room at Markdown line {physical_line_no}"
)
if row.no_of_rooms <= 0:
raise ValueError(
f"invalid room quantity at Markdown line {physical_line_no}"
)
coordinate = (row.worksheet, row.row_no)
if coordinate in coordinates:
raise ValueError(
"duplicate worksheet/row coordinate "
f"{row.worksheet!r}/{row.row_no}"
)
coordinates.add(coordinate)
rows.append(row)
if not source_file:
raise ValueError("fixture source file metadata is missing")
if not mapping_file:
raise ValueError("fixture mapping file metadata is missing")
if not rows:
raise ValueError("fixture contains no data rows")
return FixtureDocument(
source_file=source_file,
mapping_file=mapping_file,
rows=tuple(rows),
)
def parse_fixture_file(path: Path) -> FixtureDocument:
return parse_fixture_markdown(path.read_text(encoding="utf-8"))
def rule_set_sha256(contract_path: Path) -> str:
digest = hashlib.sha256()
digest.update(contract_path.read_bytes())
digest.update(b"\0")
digest.update(Path(__file__).read_bytes())
return digest.hexdigest()
def build_fixture_parse_result(
row: FixtureRow,
*,
rule_sha256: str,
) -> dict[str, object]:
source_sha256 = row.sha256()
return {
"result_schema_version": RESULT_SCHEMA_VERSION,
"status": "accepted",
"source_row": {
"worksheet": row.worksheet,
"row_no": row.row_no,
"sha256": source_sha256,
},
"processor_version": PROCESSOR_VERSION,
"rule_set_sha256": rule_sha256,
"group_code_raw": row.group_code_raw,
"group_code_key": row.group_code_key,
"type_of_room_raw": row.type_of_room_raw,
"no_of_rooms": row.no_of_rooms,
"room_items": [
{
"item_no": 1,
"room_type_raw": row.type_of_room_raw,
"room_type_code": row.type_of_room_raw,
"quantity": row.no_of_rooms,
"unit_price": None,
"currency_code": None,
"price_token_raw": None,
"source_fragment": row.type_of_room_raw,
}
],
"warnings": [],
"errors": [],
}