Files
wyndham-Condon/backend/scripts/audit-confirmation-workbook.py
2026-08-02 13:33:13 +08:00

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()