"""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": [], }