199 lines
5.9 KiB
Python
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": [],
|
|
}
|