#!/usr/bin/env python3 """Read-only structural audit for Confirmation Report 2026.xlsx.""" from __future__ import annotations 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.xlsx" ) OWNER_MD = PROJECT_ROOT / "业主账户.md" USAGE_MD = PROJECT_ROOT / "使用记录.md" VALID_PURCHASED_ROOM_TYPES = { "RM1", "RM2", "RM3", "RM4", "UG1", "UG2", "SU1", "SU2", "SU6", "SU3", "AC2", } def scalar(value: Any) -> Any: if isinstance(value, (datetime, date)): return value.date().isoformat() if isinstance(value, datetime) else 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 date_value(value: Any) -> date | None: if isinstance(value, datetime): return value.date() if isinstance(value, date): return value if isinstance(value, str): candidate = value.strip() for parser in ( lambda item: date.fromisoformat(item), lambda item: datetime.strptime(item, "%d-%b-%y").date(), lambda item: datetime.strptime(item, "%d-%b-%Y").date(), ): try: return parser(candidate) except ValueError: continue return None def room_numbers(value: Any) -> list[str]: return list(dict.fromkeys(re.findall(r"\b\d{4}\b", text(value)))) def room_key(value: Any) -> tuple[str, ...]: return tuple(sorted(room_numbers(value))) def normalized_name(value: Any) -> str: return re.sub(r"[^A-Z0-9]+", "", text(value).upper()) def markdown_cells(line: str) -> list[str] | None: value = line.strip() if not value.startswith("|") or not value.endswith("|"): return None return [cell.strip() for cell in value[1:-1].split("|")] def parse_owner_markdown() -> dict[str, dict[str, str]]: owners: dict[str, dict[str, str]] = {} for line_no, line in enumerate(OWNER_MD.read_text(encoding="utf-8").splitlines(), 1): cells = markdown_cells(line) if not cells or len(cells) != 5 or not re.fullmatch(r"\d{4}", cells[1]): continue owners[cells[1]] = { "line": str(line_no), "name": cells[0], "room_type": cells[2], "unit_no": cells[3], "member_no": cells[4], } return owners def parse_usage_markdown() -> dict[tuple[str, ...], list[dict[str, str]]]: sections: dict[tuple[str, ...], list[dict[str, str]]] = {} current_key: tuple[str, ...] | None = None for line_no, line in enumerate(USAGE_MD.read_text(encoding="utf-8").splitlines(), 1): heading = re.match(r"^## Room No\. (.+)$", line) if heading: current_key = room_key(heading.group(1)) sections[current_key] = [] continue cells = markdown_cells(line) if current_key is None or not cells or len(cells) != 9: continue if not re.match(r"^\d", cells[0]): continue sections[current_key].append( { "line": str(line_no), "confirmation": cells[0], "check_in": cells[1], "check_out": cells[2], "night": cells[3], "room": cells[4], "use": cells[5], "balance": cells[6], "room_type": cells[7], "remark": cells[8], } ) return sections def normalize_room_type(value: Any) -> str: return re.sub(r"\r?\n", "
", text(value)) def normalize_header(value: Any) -> str: """Normalize a spreadsheet header for resilient column lookup.""" return re.sub(r"[^a-z0-9]+", "", text(value).lower()) def audit(workbook_path: Path) -> dict[str, Any]: formulas = load_workbook(workbook_path, data_only=False, read_only=False) values = load_workbook(workbook_path, data_only=True, read_only=False) master_formula = formulas["Total2024-2026"] master_value = values["Total2024-2026"] # The newest workbook has an additional descriptive room-type column and # places Unit/Member/Remaining at G/H/I. Resolve the six authoritative # fields by header rather than assuming the old positional layout. master_header_map: dict[str, int] = {} for column in range(1, master_value.max_column + 1): header = normalize_header(master_value.cell(2, column).value) if header: master_header_map.setdefault(header, column) def master_column(header: str, fallback: int) -> int: return master_header_map.get(normalize_header(header), fallback) master_columns = { "no": master_column("No", 1), "transfer_date": master_column("Transfer Date", 2), "name": master_column("Name", 3), "room_no": master_column("Room No.", 4), "room_type": master_column("Room Type", 5), "unit_no": master_column("Unit No.", 6), "member_no": master_column("Member No.", 7), "remaining": master_column("Remaining stay privileges", 8), } owners: list[dict[str, Any]] = [] for row in range(4, master_value.max_row + 1): room_no = text(master_value.cell(row, master_columns["room_no"]).value) if not re.fullmatch(r"\d{4}", room_no): continue owners.append( { "row": row, "no": number(master_value.cell(row, master_columns["no"]).value), "transfer_date": scalar( master_value.cell(row, master_columns["transfer_date"]).value ), "name": text(master_value.cell(row, master_columns["name"]).value), "room_no": room_no, "room_type": text( master_value.cell(row, master_columns["room_type"]).value ), "unit_no": text( master_value.cell(row, master_columns["unit_no"]).value ), "member_no": text( master_value.cell(row, master_columns["member_no"]).value ), "remaining": number( master_value.cell(row, master_columns["remaining"]).value ), } ) owners_by_room = {owner["room_no"]: owner for owner in owners} owner_rooms_by_name: dict[str, list[str]] = defaultdict(list) for owner in owners: owner_rooms_by_name[normalized_name(owner["name"])].append(owner["room_no"]) owner_required_field_issues = [ { "row": owner["row"], "room_no": owner["room_no"], "missing": [ field for field in ( "no", "transfer_date", "name", "room_no", "room_type", "unit_no", "member_no", "remaining", ) if owner[field] in (None, "") ], } for owner in owners if any( owner[field] in (None, "") for field in ( "no", "transfer_date", "name", "room_no", "room_type", "unit_no", "member_no", "remaining", ) ) ] owner_no_counts = Counter(owner["no"] for owner in owners if owner["no"] is not None) owner_duplicate_nos = { value: count for value, count in owner_no_counts.items() if count > 1 } owner_invalid_transfer_dates = [ { "row": owner["row"], "room_no": owner["room_no"], "transfer_date": owner["transfer_date"], } for owner in owners if date_value(owner["transfer_date"]) is None ] owner_invalid_room_types = [ { "row": owner["row"], "room_no": owner["room_no"], "room_type": owner["room_type"], } for owner in owners if owner["room_type"] not in VALID_PURCHASED_ROOM_TYPES ] owner_remaining_out_of_range = [ { "row": owner["row"], "room_no": owner["room_no"], "remaining": owner["remaining"], } for owner in owners if owner["remaining"] is not None and not 0 <= owner["remaining"] <= 15 ] expected_headers = [ "No.", "Confirmation No.", "Check -IN", "Check-OUT", "Night", "Room", "Total", "Use", "Balance", "Room Type", "Remark", ] sheets: list[dict[str, Any]] = [] all_usage: list[dict[str, Any]] = [] header_issues: list[dict[str, Any]] = [] arithmetic_issues: list[dict[str, Any]] = [] chain_issues: list[dict[str, Any]] = [] initial_total_issues: list[dict[str, Any]] = [] room_identity_mismatches: list[dict[str, Any]] = [] usage_required_field_issues: list[dict[str, Any]] = [] date_night_issues: list[dict[str, Any]] = [] cross_year_issues: list[dict[str, Any]] = [] numeric_domain_issues: list[dict[str, Any]] = [] orphan_data_rows: list[dict[str, Any]] = [] chronological_order_issues: list[dict[str, Any]] = [] sheet_rooms_missing_from_master: list[dict[str, Any]] = [] template_sheets: list[str] = [] for title in values.sheetnames[1:]: value_sheet = values[title] formula_sheet = formulas[title] # A workbook may contain a blank non-room template (currently named # ``FROM``). It is not an owner's usage tab and must not contribute # to usage counts, orphan rows, or balance checks. if not room_numbers(title): template_sheets.append(title) continue headers = [text(value_sheet.cell(3, column).value).rstrip(" ") for column in range(1, 12)] normalized_headers = [header.replace("Check-OUT ", "Check-OUT").strip() for header in headers] if normalized_headers != expected_headers: header_issues.append({"sheet": title, "headers": headers}) title_rooms = room_numbers(title) header_rooms = room_numbers(value_sheet.cell(2, 4).value) rooms = title_rooms or header_rooms room_resolution = "sheet title and Room No. header agree" if set(title_rooms) != set(header_rooms): name_matches = owner_rooms_by_name.get( normalized_name(value_sheet.cell(2, 2).value), [] ) rooms = title_rooms or header_rooms room_resolution = "sheet title used; account metadata comes from master" room_identity_mismatches.append( { "sheet": title, "owner_name": text(value_sheet.cell(2, 2).value), "title_rooms": title_rooms, "header_rooms": header_rooms, "master_name_matches": name_matches, "resolved_rooms": rooms, "resolution": room_resolution, } ) missing_master_rooms = [room for room in rooms if room not in owners_by_room] if missing_master_rooms: sheet_rooms_missing_from_master.append( {"sheet": title, "missing_rooms": missing_master_rooms} ) usages: list[dict[str, Any]] = [] previous_balance: int | None = None previous_check_in: date | None = None for row in range(4, value_sheet.max_row + 1): confirmation = text(value_sheet.cell(row, 2).value) if not confirmation: # E/G/H/I contain prefilled formulas on template rows. Only # inspect user-entered columns when looking for an orphan row. populated_columns = [ column for column in (3, 4, 6, 10, 11) if text(value_sheet.cell(row, column).value) ] if populated_columns: orphan_data_rows.append( { "sheet": title, "row": row, "populated_columns": populated_columns, "no": number(value_sheet.cell(row, 1).value), "check_in": scalar(value_sheet.cell(row, 3).value), "check_out": scalar(value_sheet.cell(row, 4).value), "night": number(value_sheet.cell(row, 5).value), "room": number(value_sheet.cell(row, 6).value), "total": number(value_sheet.cell(row, 7).value), "use": number(value_sheet.cell(row, 8).value), "balance": number(value_sheet.cell(row, 9).value), "room_type": normalize_room_type( value_sheet.cell(row, 10).value ), "remark": text(value_sheet.cell(row, 11).value), } ) continue item = { "sheet": title, "row": row, "section_rooms": rooms, "no": number(value_sheet.cell(row, 1).value), "confirmation": confirmation, "check_in": scalar(value_sheet.cell(row, 3).value), "check_out": scalar(value_sheet.cell(row, 4).value), "night": number(value_sheet.cell(row, 5).value), "room": number(value_sheet.cell(row, 6).value), "total": number(value_sheet.cell(row, 7).value), "use": number(value_sheet.cell(row, 8).value), "balance": number(value_sheet.cell(row, 9).value), "room_type": normalize_room_type(value_sheet.cell(row, 10).value), "remark": text(value_sheet.cell(row, 11).value), "night_formula": text(formula_sheet.cell(row, 5).value), "total_formula": text(formula_sheet.cell(row, 7).value), "use_formula": text(formula_sheet.cell(row, 8).value), "balance_formula": text(formula_sheet.cell(row, 9).value), } missing_fields = [ field for field in ( "confirmation", "check_in", "check_out", "night", "room", "total", "use", "balance", "room_type", "remark", ) if item[field] in (None, "") ] if missing_fields: usage_required_field_issues.append( {"sheet": title, "row": row, "missing": missing_fields} ) check_in_date = date_value(value_sheet.cell(row, 3).value) check_out_date = date_value(value_sheet.cell(row, 4).value) if ( check_in_date is None or check_out_date is None or check_out_date <= check_in_date or item["night"] != (check_out_date - check_in_date).days ): date_night_issues.append( { "sheet": title, "row": row, "check_in": item["check_in"], "check_out": item["check_out"], "night": item["night"], } ) if ( check_in_date is not None and check_out_date is not None and check_in_date.year != check_out_date.year ): cross_year_issues.append( { "sheet": title, "row": row, "check_in": item["check_in"], "check_out": item["check_out"], } ) if any( item[field] is None or item[field] < minimum for field, minimum in ( ("night", 1), ("room", 1), ("total", 0), ("use", 0), ("balance", 0), ) ): numeric_domain_issues.append( { "sheet": title, "row": row, "night": item["night"], "room": item["room"], "total": item["total"], "use": item["use"], "balance": item["balance"], } ) if ( previous_check_in is not None and check_in_date is not None and check_in_date < previous_check_in ): chronological_order_issues.append( { "sheet": title, "row": row, "previous_check_in": previous_check_in.isoformat(), "actual_check_in": check_in_date.isoformat(), } ) if check_in_date is not None: previous_check_in = check_in_date if ( item["total"] is None or item["use"] is None or item["balance"] is None or item["total"] - item["use"] != item["balance"] ): arithmetic_issues.append(item) if previous_balance is not None and item["total"] != previous_balance: chain_issues.append( { "sheet": title, "row": row, "expected_total": previous_balance, "actual_total": item["total"], } ) previous_balance = item["balance"] usages.append(item) all_usage.append(item) expected_initial = len(rooms) * 15 if usages and usages[0]["total"] != expected_initial: initial_total_issues.append( { "sheet": title, "rooms": rooms, "expected": expected_initial, "actual": usages[0]["total"], } ) final_balance = usages[-1]["balance"] if usages else None master_remaining_sum = sum( owners_by_room[room]["remaining"] or 0 for room in rooms if room in owners_by_room ) sheets.append( { "title": title, "owner_name": text(value_sheet.cell(2, 2).value), "room_no_raw": text(value_sheet.cell(2, 4).value), "title_rooms": title_rooms, "header_rooms": header_rooms, "rooms": rooms, "room_resolution": room_resolution, "purchased_room_type_raw": text(value_sheet.cell(2, 6).value), "transfer_on_raw": scalar(value_sheet.cell(2, 10).value), "usage_count": len(usages), "initial_total": usages[0]["total"] if usages else None, "final_balance": final_balance, "master_remaining_sum": master_remaining_sum, "master_remaining_matches_final": final_balance == master_remaining_sum, } ) confirmations: dict[str, list[dict[str, Any]]] = defaultdict(list) for item in all_usage: confirmations[item["confirmation"]].append( {"sheet": item["sheet"], "row": item["row"]} ) duplicate_confirmations = { key: locations for key, locations in confirmations.items() if len(locations) > 1 } duplicate_confirmation_details = { confirmation: [ { "sheet": item["sheet"], "row": item["row"], "section_rooms": item["section_rooms"], "check_in": item["check_in"], "check_out": item["check_out"], "night": item["night"], "room": item["room"], "total": item["total"], "use": item["use"], "balance": item["balance"], "room_type": item["room_type"], "remark": item["remark"], } for item in items ] for confirmation, items in ( (confirmation, [ item for item in all_usage if item["confirmation"] == confirmation ]) for confirmation in duplicate_confirmations ) } duplicate_confirmation_date_mismatches = { confirmation: details for confirmation, details in duplicate_confirmation_details.items() if len({(item["check_in"], item["check_out"]) for item in details}) > 1 } usage_check_in_dates = [ parsed for item in all_usage if (parsed := date_value(item["check_in"])) is not None ] owner_md = parse_owner_markdown() owner_md_differences: list[dict[str, Any]] = [] for owner in owners: markdown = owner_md.get(owner["room_no"]) if markdown is None: owner_md_differences.append( {"room_no": owner["room_no"], "reason": "missing in markdown"} ) continue differences = {} for excel_key, md_key in [ ("name", "name"), ("room_type", "room_type"), ("unit_no", "unit_no"), ("member_no", "member_no"), ]: if text(owner[excel_key]) != text(markdown[md_key]): differences[excel_key] = { "excel": owner[excel_key], "markdown": markdown[md_key], } if differences: owner_md_differences.append( {"room_no": owner["room_no"], "differences": differences} ) usage_md = parse_usage_markdown() workbook_by_key: dict[tuple[str, ...], list[dict[str, Any]]] = { tuple(sorted(sheet["rooms"])): [ item for item in all_usage if item["sheet"] == sheet["title"] ] for sheet in sheets } usage_md_count_differences: list[dict[str, Any]] = [] usage_md_core_differences: list[dict[str, Any]] = [] for key, workbook_rows in workbook_by_key.items(): markdown_rows = usage_md.get(key, []) if len(workbook_rows) != len(markdown_rows): usage_md_count_differences.append( { "rooms": list(key), "excel": len(workbook_rows), "markdown": len(markdown_rows), } ) continue for index, (excel, markdown) in enumerate(zip(workbook_rows, markdown_rows), 1): excel_core = [ excel["confirmation"], text(excel["check_in"]), text(excel["check_out"]), text(excel["night"]), text(excel["room"]), text(excel["use"]), text(excel["balance"]), ] markdown_core = [ markdown["confirmation"], markdown["check_in"], markdown["check_out"], markdown["night"], markdown["room"], markdown["use"], markdown["balance"], ] if excel_core != markdown_core: usage_md_core_differences.append( { "rooms": list(key), "index": index, "excel": excel_core, "markdown": markdown_core, } ) workbook_keys = set(workbook_by_key) markdown_keys = set(usage_md) for key in sorted(markdown_keys - workbook_keys): usage_md_count_differences.append( { "rooms": list(key), "excel": 0, "markdown": len(usage_md[key]), } ) transfer_dates = [owner["transfer_date"] for owner in owners] remaining_values = [owner["remaining"] for owner in owners] single_sheets = [sheet for sheet in sheets if len(sheet["rooms"]) == 1] grouped_sheets = [sheet for sheet in sheets if len(sheet["rooms"]) > 1] nonempty_sheets = [sheet for sheet in sheets if sheet["usage_count"] > 0] empty_sheets = [sheet for sheet in sheets if sheet["usage_count"] == 0] return { "mode": "read-only", "workbook": str(workbook_path), "sheet_count": len(values.sheetnames), "master_columns": master_columns, "master": { "record_count": len(owners), "unique_room_count": len(owners_by_room), "missing_transfer_dates": sum(value in (None, "") for value in transfer_dates), "missing_remaining": sum(value is None for value in remaining_values), "remaining_sum": sum(value or 0 for value in remaining_values), "implied_used_from_388_x_15": len(owners) * 15 - sum(value or 0 for value in remaining_values), "remaining_distribution": dict(sorted(Counter(remaining_values).items(), key=lambda item: (item[0] is None, item[0]))), "room_type_distribution": dict(sorted(Counter(owner["room_type"] for owner in owners).items())), "duplicate_member_numbers": { member: [owner["room_no"] for owner in owners if owner["member_no"] == member] for member, count in Counter(owner["member_no"] for owner in owners).items() if count > 1 }, "duplicate_unit_numbers": { unit: [owner["room_no"] for owner in owners if owner["unit_no"] == unit] for unit, count in Counter(owner["unit_no"] for owner in owners).items() if count > 1 }, "required_field_issues": owner_required_field_issues, "duplicate_no_values": owner_duplicate_nos, "invalid_transfer_dates": owner_invalid_transfer_dates, "invalid_room_types": owner_invalid_room_types, "remaining_out_of_range": owner_remaining_out_of_range, "markdown_record_count": len(owner_md), "markdown_differences": owner_md_differences, }, "usage": { "sub_sheet_count": len(sheets), "template_sheets": template_sheets, "nonempty_sub_sheet_count": len(nonempty_sheets), "empty_sub_sheet_count": len(empty_sheets), "empty_sub_sheets": [sheet["title"] for sheet in empty_sheets], "record_count": len(all_usage), "single_room_sheet_count": len(single_sheets), "grouped_room_sheet_count": len(grouped_sheets), "room_count_distribution": dict(sorted(Counter(item["room"] for item in all_usage).items())), "confirmation_length_distribution": dict( sorted(Counter(len(item["confirmation"]) for item in all_usage).items()) ), "check_in_year_distribution": dict( sorted(Counter(value.year for value in usage_check_in_dates).items()) ), "check_in_date_range": { "minimum": min(usage_check_in_dates).isoformat() if usage_check_in_dates else None, "maximum": max(usage_check_in_dates).isoformat() if usage_check_in_dates else None, }, "used_room_type_distribution": dict( sorted(Counter(item["room_type"] for item in all_usage).items()) ), "canonical_used_room_type_count": sum( item["room_type"] in VALID_PURCHASED_ROOM_TYPES for item in all_usage ), "noncanonical_used_room_type_rows": [ { "sheet": item["sheet"], "row": item["row"], "confirmation": item["confirmation"], "room_type": item["room_type"], } for item in all_usage if item["room_type"] not in VALID_PURCHASED_ROOM_TYPES ], "duplicate_confirmations": duplicate_confirmations, "duplicate_confirmation_details": duplicate_confirmation_details, "duplicate_confirmation_date_mismatches": duplicate_confirmation_date_mismatches, "non_digit_confirmations": [ { "sheet": item["sheet"], "row": item["row"], "confirmation": item["confirmation"], } for item in all_usage if not item["confirmation"].isdigit() ], "header_issues": header_issues, "arithmetic_issues": arithmetic_issues, "chain_issues": chain_issues, "initial_total_issues": initial_total_issues, "room_identity_mismatches": room_identity_mismatches, "sheet_rooms_missing_from_master": sheet_rooms_missing_from_master, "required_field_issues": usage_required_field_issues, "date_night_issues": date_night_issues, "cross_year_issues": cross_year_issues, "numeric_domain_issues": numeric_domain_issues, "orphan_data_rows": orphan_data_rows, "chronological_order_issues": chronological_order_issues, "use_sum": sum(item["use"] or 0 for item in all_usage), "single_sheet_master_balance_mismatches": [ sheet for sheet in single_sheets if sheet["usage_count"] > 0 and not sheet["master_remaining_matches_final"] ], "empty_sheet_master_balance_results": empty_sheets, "grouped_sheet_master_balance_results": grouped_sheets, "room_gt_one_rows": [ { "sheet": item["sheet"], "row": item["row"], "confirmation": item["confirmation"], "check_in": item["check_in"], "check_out": item["check_out"], "night": item["night"], "room": item["room"], "total": item["total"], "use": item["use"], "balance": item["balance"], "room_type": item["room_type"], "remark": item["remark"], } for item in all_usage if (item["room"] or 0) > 1 ], "manual_use_rows": [ { "sheet": item["sheet"], "row": item["row"], "confirmation": item["confirmation"], "night": item["night"], "room": item["room"], "use": item["use"], "use_formula": item["use_formula"], } for item in all_usage if not item["use_formula"].startswith("=") ], "markdown_section_count": len(usage_md), "markdown_record_count": sum(len(rows) for rows in usage_md.values()), "markdown_count_differences": usage_md_count_differences, "markdown_core_differences": usage_md_core_differences, }, "sheets": sheets, } def main() -> None: path = Path(sys.argv[1]).expanduser() if len(sys.argv) > 1 else DEFAULT_WORKBOOK if not path.is_file(): raise SystemExit(f"Workbook not found: {path}") print(json.dumps(audit(path), ensure_ascii=False, indent=2, default=str)) if __name__ == "__main__": main()