#!/usr/bin/env python3 """Prepare a deterministic, read-only legacy import batch from the latest workbook. This script never connects to PostgreSQL. It keeps source Use/Balance and raw room-type text, filters rows without Confirmation, and writes auditable JSON artifacts for the later database importer. """ from __future__ import annotations import hashlib import json import re import sys from collections import Counter, defaultdict from datetime import date, datetime from pathlib import Path from typing import Any from openpyxl import load_workbook PROJECT_ROOT = Path(__file__).resolve().parents[2] DEFAULT_WORKBOOK = Path( "/Users/chillishark/Desktop/Condo公寓/Confirmation Report 2026 สิทธิ์ 15 วันคอนโดเท่านั้น(2).xlsx" ) DEFAULT_OUTPUT = PROJECT_ROOT / ".planning" / "data_import_audit" / "import_batch_2026_07_31" MASTER_SHEET = "Total2024-2026" ROOM_TYPES = { "RM1", "RM2", "RM3", "RM4", "UG1", "UG2", "SU1", "SU2", "SU6", "SU3", "AC2", } def scalar(value: Any) -> Any: if isinstance(value, datetime): return value.date().isoformat() if isinstance(value, date): return value.isoformat() if isinstance(value, float) and value.is_integer(): return int(value) return value def text(value: Any) -> str: value = scalar(value) return "" if value is None else str(value).strip() def number(value: Any) -> int | None: value = scalar(value) if isinstance(value, int): return value if isinstance(value, str) and re.fullmatch(r"-?\d+", value.strip()): return int(value.strip()) return None def parsed_date(value: Any) -> date | None: value = scalar(value) if isinstance(value, date): return value if isinstance(value, str): for fmt in ("%Y-%m-%d", "%d-%b-%y", "%d-%b-%Y"): try: return datetime.strptime(value.strip(), fmt).date() except ValueError: pass return None def normalize_header(value: Any) -> str: return re.sub(r"[^a-z0-9]+", "", text(value).lower()) def normalize_room_type(value: Any) -> str: return re.sub(r"\r?\n", "
", text(value)) def room_numbers(value: Any) -> list[str]: return list(dict.fromkeys(re.findall(r"\b\d{4}\b", text(value)))) def canonical_room_type(raw: str) -> str | None: """Only exact standard codes are canonicalized; legacy text is not guessed.""" return raw if raw in ROOM_TYPES else None def write_json(path: Path, value: Any) -> None: path.write_text( json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) def prepare(workbook_path: Path, output_dir: Path) -> dict[str, Any]: workbook_bytes = workbook_path.read_bytes() source_sha256 = hashlib.sha256(workbook_bytes).hexdigest() workbook = load_workbook(workbook_path, data_only=True, read_only=False) if MASTER_SHEET not in workbook.sheetnames: raise ValueError(f"Missing master sheet: {MASTER_SHEET}") master = workbook[MASTER_SHEET] header_map: dict[str, int] = {} for column in range(1, master.max_column + 1): header = normalize_header(master.cell(2, column).value) if header: header_map.setdefault(header, column) def column(header: str, fallback: int) -> int: return header_map.get(normalize_header(header), fallback) columns = { "no": column("No", 1), "transfer_date": column("Transfer Date", 2), "name": column("Name", 3), "room_no": column("Room No.", 4), "room_type": column("Room Type", 5), "unit_no": column("Unit No.", 6), "member_no": column("Member No.", 7), "remaining": column("Remaining stay privileges", 8), } owners: list[dict[str, Any]] = [] owner_by_room: dict[str, dict[str, Any]] = {} owner_issues: list[dict[str, Any]] = [] for row in range(4, master.max_row + 1): room_no = text(master.cell(row, columns["room_no"]).value) if not re.fullmatch(r"\d{4}", room_no): continue owner = { "account_no": number(master.cell(row, columns["no"]).value), "transfer_date": scalar(master.cell(row, columns["transfer_date"]).value), "name": text(master.cell(row, columns["name"]).value), "room_no": room_no, "purchased_room_type_code": text(master.cell(row, columns["room_type"]).value), "unit_no": text(master.cell(row, columns["unit_no"]).value), "member_no": text(master.cell(row, columns["member_no"]).value), "reported_remaining": number(master.cell(row, columns["remaining"]).value), "source_sheet": MASTER_SHEET, "source_row": row, } missing = [ key for key in ( "account_no", "transfer_date", "name", "room_no", "purchased_room_type_code", "unit_no", "member_no", "reported_remaining", ) if owner[key] in (None, "") ] if missing: owner_issues.append({"source_row": row, "room_no": room_no, "missing": missing}) if owner["purchased_room_type_code"] not in ROOM_TYPES: owner_issues.append( { "source_row": row, "room_no": room_no, "invalid_purchased_room_type": owner["purchased_room_type_code"], } ) if owner["reported_remaining"] is not None and not 0 <= owner["reported_remaining"] <= 15: owner_issues.append( { "source_row": row, "room_no": room_no, "remaining_out_of_range": owner["reported_remaining"], } ) owners.append(owner) owner_by_room[room_no] = owner usage: list[dict[str, Any]] = [] rejected: list[dict[str, Any]] = [] sheet_diagnostics: list[dict[str, Any]] = [] arithmetic_issues: list[dict[str, Any]] = [] chain_issues: list[dict[str, Any]] = [] required_issues: list[dict[str, Any]] = [] date_issues: list[dict[str, Any]] = [] identity_issues: list[dict[str, Any]] = [] previous_by_sheet: dict[str, dict[str, Any]] = {} duplicate_confirmation_locations: dict[str, list[dict[str, Any]]] = defaultdict(list) expected_headers = [ "No.", "Confirmation No.", "Check -IN", "Check-OUT", "Night", "Room", "Total", "Use", "Balance", "Room Type", "Remark", ] for title in workbook.sheetnames: if title == MASTER_SHEET: continue sheet = workbook[title] title_rooms = room_numbers(title) if not title_rooms: sheet_diagnostics.append({"sheet": title, "kind": "template", "usage_count": 0}) continue header_values = [text(sheet.cell(3, column).value).rstrip(" ") for column in range(1, 12)] normalized_headers = [header.replace("Check-OUT ", "Check-OUT").strip() for header in header_values] header_issue = normalized_headers != expected_headers header_rooms = room_numbers(sheet.cell(2, 4).value) if set(title_rooms) != set(header_rooms): identity_issues.append( { "sheet": title, "title_rooms": title_rooms, "header_rooms": header_rooms, "resolution": "sheet title is authoritative", } ) accepted_count = 0 sheet_rows: list[dict[str, Any]] = [] for row in range(4, sheet.max_row + 1): raw = { "no": number(sheet.cell(row, 1).value), "confirmation_no": text(sheet.cell(row, 2).value), "check_in": scalar(sheet.cell(row, 3).value), "check_out": scalar(sheet.cell(row, 4).value), "night": number(sheet.cell(row, 5).value), "room": number(sheet.cell(row, 6).value), "total": number(sheet.cell(row, 7).value), "use": number(sheet.cell(row, 8).value), "balance": number(sheet.cell(row, 9).value), "raw_used_room_type": normalize_room_type(sheet.cell(row, 10).value), "remark": text(sheet.cell(row, 11).value), } if not raw["confirmation_no"]: populated_columns = [ column for column in (3, 4, 6, 10, 11) if text(sheet.cell(row, column).value) ] if populated_columns: rejected.append( { "reason_code": "missing_confirmation", "reason": "Confirmation No. is empty; clear/exclude this row", "source_sheet": title, "source_row": row, "populated_columns": populated_columns, **raw, } ) continue missing = [ key for key in ( "confirmation_no", "check_in", "check_out", "night", "room", "total", "use", "balance", "raw_used_room_type", "remark", ) if raw[key] in (None, "") ] if missing: required_issues.append({"source_sheet": title, "source_row": row, "missing": missing}) check_in = parsed_date(raw["check_in"]) check_out = parsed_date(raw["check_out"]) if ( check_in is None or check_out is None or check_out <= check_in or raw["night"] != (check_out - check_in).days ): date_issues.append({"source_sheet": title, "source_row": row, **raw}) if raw["total"] is None or raw["use"] is None or raw["balance"] is None: arithmetic_issues.append({"source_sheet": title, "source_row": row, **raw}) elif raw["total"] - raw["use"] != raw["balance"]: arithmetic_issues.append({"source_sheet": title, "source_row": row, **raw}) previous = previous_by_sheet.get(title) if previous is not None and raw["total"] != previous["balance"]: chain_issues.append( { "source_sheet": title, "source_row": row, "expected_total": previous["balance"], "actual_total": raw["total"], } ) previous_by_sheet[title] = raw period_year = check_in.year if check_in else None record = { "owner_room_no": title_rooms[0] if len(title_rooms) == 1 else None, "confirmation_no": raw["confirmation_no"], "check_in": raw["check_in"], "check_out": raw["check_out"], "night": raw["night"], "room": raw["room"], "total": raw["total"], "use": raw["use"], "balance": raw["balance"], "raw_used_room_type": raw["raw_used_room_type"], "canonical_used_room_type_code": canonical_room_type(raw["raw_used_room_type"]), "remark": raw["remark"], "source_sheet": title, "source_row": row, "source_sequence": raw["no"], "period_year": period_year, "applied_multiplier": None, "rule_version": "legacy-source", } usage.append(record) sheet_rows.append(record) accepted_count += 1 duplicate_confirmation_locations[raw["confirmation_no"]].append( {"source_sheet": title, "source_row": row} ) sheet_diagnostics.append( { "sheet": title, "kind": "room_usage", "title_rooms": title_rooms, "header_rooms": header_rooms, "header_issue": header_issue, "usage_count": accepted_count, "rejected_count": sum( 1 for row in rejected if row["source_sheet"] == title ), "final_balance": sheet_rows[-1]["balance"] if sheet_rows else None, } ) duplicate_confirmations = { confirmation: locations for confirmation, locations in duplicate_confirmation_locations.items() if len(locations) > 1 } master_remaining_sum = sum(owner["reported_remaining"] or 0 for owner in owners) usage_sum = sum(record["use"] or 0 for record in usage) empty_usage_sheets = [ item["sheet"] for item in sheet_diagnostics if item["kind"] == "room_usage" and item["usage_count"] == 0 ] noncanonical = [ record for record in usage if record["canonical_used_room_type_code"] is None ] manifest = { "mode": "local-import-preflight", "source_workbook": str(workbook_path), "source_sha256": source_sha256, "master_sheet": MASTER_SHEET, "rules": { "latest_workbook_only": True, "missing_confirmation": "clear_or_exclude", "usage_owner": "sheet_title", "owner_metadata": "master_sheet", "history_values": "preserve_source_use_balance_total_room", "legacy_applied_multiplier": None, "legacy_rule_version": "legacy-source", "new_usage_room_type": "standard_code_only", "database_write_performed": False, }, "counts": { "owner_count": len(owners), "unique_owner_room_count": len(owner_by_room), "accepted_usage_count": len(usage), "rejected_row_count": len(rejected), "rejected_missing_confirmation_count": sum( item["reason_code"] == "missing_confirmation" for item in rejected ), "usage_use_sum": usage_sum, "master_remaining_sum": master_remaining_sum, "implied_used": len(owners) * 15 - master_remaining_sum, "canonical_used_room_type_count": len(usage) - len(noncanonical), "noncanonical_used_room_type_count": len(noncanonical), "duplicate_confirmation_group_count": len(duplicate_confirmations), }, "checks": { "owner_issues": owner_issues, "required_usage_issues": required_issues, "date_night_issues": date_issues, "arithmetic_issues": arithmetic_issues, "balance_chain_issues": chain_issues, "room_gt_one_count": sum((record["room"] or 0) > 1 for record in usage), "missing_owner_rooms": sorted( { record["owner_room_no"] for record in usage if record["owner_room_no"] not in owner_by_room } ), "empty_usage_sheets": empty_usage_sheets, }, "duplicate_confirmations": duplicate_confirmations, "year_counts": dict(sorted(Counter(record["period_year"] for record in usage).items())), "raw_used_room_type_counts": dict( sorted(Counter(record["raw_used_room_type"] for record in usage).items()) ), } output_dir.mkdir(parents=True, exist_ok=True) write_json(output_dir / "owners.json", owners) write_json(output_dir / "usage_legacy.json", usage) write_json(output_dir / "rejected_rows.json", rejected) write_json(output_dir / "sheet_diagnostics.json", sheet_diagnostics) write_json(output_dir / "identity_issues.json", identity_issues) write_json(output_dir / "manifest.json", manifest) return manifest def main() -> None: workbook_path = Path(sys.argv[1]).expanduser() if len(sys.argv) > 1 else DEFAULT_WORKBOOK output_dir = Path(sys.argv[2]).expanduser() if len(sys.argv) > 2 else DEFAULT_OUTPUT if not workbook_path.is_file(): raise SystemExit(f"Workbook not found: {workbook_path}") manifest = prepare(workbook_path, output_dir) print(json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True)) if __name__ == "__main__": main()