838 lines
32 KiB
Python
838 lines
32 KiB
Python
#!/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", "<br>", 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()
|