Spaces:
Sleeping
Sleeping
| import io | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import tempfile | |
| import time | |
| import uuid | |
| import zipfile | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from datetime import datetime, timedelta | |
| from pathlib import Path | |
| from urllib.error import HTTPError, URLError | |
| from urllib.request import Request, urlopen | |
| from zoneinfo import ZoneInfo | |
| from openpyxl import load_workbook | |
| import policy_engine | |
| from openpyxl.cell.cell import MergedCell | |
| TEMPLATE_DIR = Path("templates") | |
| CALCULATOR_TEMPLATE_DIR = TEMPLATE_DIR / "Calculator" | |
| TEMPLATE_MANIFEST = Path(os.environ.get("TEMPLATE_MANIFEST", str(TEMPLATE_DIR / "template_manifest.json"))) | |
| def load_template_manifest() -> dict: | |
| try: | |
| data = json.loads(TEMPLATE_MANIFEST.read_text(encoding="utf-8")) | |
| except (OSError, json.JSONDecodeError): | |
| return {} | |
| return data if isinstance(data, dict) else {} | |
| def template_sort_key(path: Path) -> tuple: | |
| numbers = tuple(int(part) for part in re.findall(r"\d+", path.stem)) | |
| try: | |
| modified = path.stat().st_mtime | |
| except OSError: | |
| modified = 0 | |
| return numbers, modified, path.name.lower() | |
| def template_candidates(*filenames: str) -> list[Path]: | |
| paths = [] | |
| for filename in filenames: | |
| if not filename: | |
| continue | |
| candidate = Path(filename) | |
| if candidate.is_absolute(): | |
| paths.append(candidate) | |
| else: | |
| paths.extend([CALCULATOR_TEMPLATE_DIR / filename, TEMPLATE_DIR / filename]) | |
| return paths | |
| def latest_template(*patterns: str) -> Path | None: | |
| matches = [] | |
| for base_dir in (CALCULATOR_TEMPLATE_DIR, TEMPLATE_DIR): | |
| if not base_dir.exists(): | |
| continue | |
| for pattern in patterns: | |
| matches.extend(path for path in base_dir.glob(pattern) if path.is_file() and "Archive" not in path.parts) | |
| if not matches: | |
| return None | |
| return sorted(set(matches), key=template_sort_key)[-1] | |
| def resolve_template(name: str, filenames: list[str], patterns: list[str] | None = None) -> Path: | |
| manifest = load_template_manifest() | |
| override = manifest.get(name) | |
| if isinstance(override, str) and override.strip(): | |
| override_path = Path(override.strip()) | |
| if not override_path.is_absolute(): | |
| override_path = TEMPLATE_DIR / override_path | |
| if override_path.exists(): | |
| return override_path | |
| for path in template_candidates(*filenames): | |
| if path.exists(): | |
| return path | |
| discovered = latest_template(*(patterns or [])) | |
| if discovered: | |
| return discovered | |
| return template_candidates(filenames[0])[0] | |
| def resolve_latest_template(name: str, filenames: list[str], patterns: list[str]) -> Path: | |
| """Resolve templates that should automatically follow newly uploaded versions. | |
| A template_manifest.json override remains highest priority. Without an | |
| override, the newest matching versioned file is selected before falling back | |
| to explicit filenames. | |
| """ | |
| manifest = load_template_manifest() | |
| override = manifest.get(name) | |
| if isinstance(override, str) and override.strip(): | |
| override_path = Path(override.strip()) | |
| if not override_path.is_absolute(): | |
| override_path = TEMPLATE_DIR / override_path | |
| if override_path.exists(): | |
| return override_path | |
| discovered = latest_template(*patterns) | |
| if discovered: | |
| return discovered | |
| for path in template_candidates(*filenames): | |
| if path.exists(): | |
| return path | |
| return template_candidates(filenames[0])[0] | |
| def template_path_from_filename(filename: str) -> Path: | |
| candidate = Path(str(filename or "").strip()) | |
| if candidate.is_absolute(): | |
| return candidate | |
| calculator_path = CALCULATOR_TEMPLATE_DIR / candidate | |
| if calculator_path.exists(): | |
| return calculator_path | |
| template_path = TEMPLATE_DIR / candidate | |
| if template_path.exists(): | |
| return template_path | |
| return calculator_path | |
| def read_master_calculation_filenames(master_path: Path) -> dict[str, Path]: | |
| try: | |
| wb = load_workbook(master_path, data_only=True, keep_vba=True, read_only=True) | |
| ws = wb["Dashboard"] | |
| except Exception: | |
| return {} | |
| filenames = {} | |
| for row in range(1, min(ws.max_row, 120) + 1): | |
| for col in range(1, min(ws.max_column, 12) + 1): | |
| value = ws.cell(row=row, column=col).value | |
| if not isinstance(value, str) or "calculation file name" not in value.lower(): | |
| continue | |
| bank_col = col | |
| file_col = col + 1 | |
| for item_row in range(row + 1, min(ws.max_row, row + 40) + 1): | |
| bank = str(ws.cell(row=item_row, column=bank_col).value or "").strip() | |
| filename = str(ws.cell(row=item_row, column=file_col).value or "").strip() | |
| if bank and filename: | |
| filenames[bank] = template_path_from_filename(filename) | |
| return filenames | |
| return filenames | |
| def bank_template_path(bank: str, master_path: Path | None = None) -> Path: | |
| if master_path is not None: | |
| master_templates = read_master_calculation_filenames(master_path) | |
| aliases = { | |
| normalized_bank_label(name): path | |
| for name, path in master_templates.items() | |
| } | |
| master_template = aliases.get(normalized_bank_label(bank)) | |
| if master_template: | |
| return master_template | |
| return BANK_CONFIGS[bank]["template"] | |
| MASTER_TEMPLATE = resolve_latest_template( | |
| "Master", | |
| ["★EW_UMI_Calculator_v1.0.5.xlsm"], | |
| ["*EW_UMI_Calculator_v*.xlsm"], | |
| ) | |
| ASB_TEMPLATE = resolve_template("ASB", ["ASB UMI Calculator v2.90.xlsx"], ["ASB UMI Calculator v*.xlsx"]) | |
| BANK_CONFIGS = { | |
| "ANZ": {"block": ("B", "C", "D"), "template": resolve_template("ANZ", ["ANZ_Loan_Affordability_Calculator_v11_2_Windows.xlsm"], ["ANZ*Loan*Affordability*Calculator*.xlsm"]), "result_sheet": 0, "metric": "UMI", "value_cell": "O161", "dti_cell": "F161", "status_rule": {"type": "compare_cells", "left": ("master", "Dashboard", "AY37"), "op": "<=", "right": ("bank", 0, "O176")}}, | |
| "ASB": {"block": ("F", "G", "H"), "template": ASB_TEMPLATE, "result_sheet": "Calc", "metric": "UMI", "value_cell": "N20", "dti_cell": "N22", "status_rule": {"type": "threshold", "op": ">=", "value": 120}}, | |
| "BNZ": {"block": ("J", "K", "L"), "template": resolve_template("BNZ", ["BNZ Affordability Calculator v12.34.xlsx", "BNZ_Affordability_Calculator_Broker_Version_12_33.xlsx"], ["BNZ*Affordability*Calculator*.xlsx"]), "result_sheet": 0, "metric": "MBS", "value_cell": "D135", "si_cell": "H132", "dti_cell": "D141", "status_rule": {"type": "threshold", "op": ">=", "value": 100}}, | |
| "Westpac": {"block": ("N", "O", "P"), "template": resolve_template("Westpac", ["20251013 - Westpac Access Serviceability Calculator - October 2025.xlsm"], ["*Westpac*Access*Serviceability*Calculator*.xlsm"]), "result_sheet": 0, "metric": "UMI", "value_cell": "F14", "dti_cell": "D15", "status_rule": {"type": "threshold", "op": ">=", "value": 150}}, | |
| "Kiwibank": {"block": ("R", "S", "T"), "template": resolve_template("Kiwibank", ["Kiwibank_NSR v67 - Adviser - Kainga Ora.xlsx"], ["Kiwibank*NSR*.xlsx"]), "result_sheet": 0, "metric": "NSR", "value_cell": "I136", "dti_cell": "F170", "status_rule": {"type": "cell_threshold", "cell": "F160", "op": "<=", "value": 1}}, | |
| "BOC": {"block": ("V", "W", "X"), "template": resolve_template("BOC", ["Broker Calculator version 1.64.xlsm", "Broker Calculator version 1.62.xlsm"], ["Broker Calculator version*.xlsm"]), "result_sheet": 2, "metric": "UMI", "value_cell": "D46", "dti_cell": "D47", "status_rule": {"type": "threshold", "op": ">=", "value": 100}}, | |
| "Avanti": {"block": ("Z", "AA", "AB"), "template": resolve_template("Avanti", ["Avanti Finance Limited - Borrowing Capacity Calculator v1.11.xlsm"], ["Avanti*Borrowing*Capacity*Calculator*.xlsm"]), "result_sheet": "Borrowing Capacity", "metric": "UMI", "value_cell": "G15", "dti_cell": None, "status_rule": {"type": "threshold", "op": ">=", "value": 0}}, | |
| "Unity": {"block": ("AD", "AE", "AF"), "template": resolve_template("Unity", ["Unity-Loan-Serviceability-Calculator-18 June-2026.xlsx", "Unity-Loan-Serviceability-Calculator-24July-2025.xlsx"], ["Unity*Loan*Serviceability*Calculator*.xlsx"]), "result_sheet": 0, "metric": "NSR", "value_cell": "J98", "dti_cell": None, "status_cell": "J99"}, | |
| "Co-operative": {"block": ("AH", "AI", "AJ"), "template": resolve_template("Co-operative", ["Co_operative_Bank_Servicing_Calculator_v6_9_effective_25_08_2025_2.xlsx"], ["Co*operative*Servicing*Calculator*.xlsx"]), "result_sheet": 0, "metric": "UMI", "value_cell": "E101", "dti_cell": "J101", "status_rule": {"type": "threshold", "op": ">=", "value": 50}}, | |
| "Peppermoney": {"block": ("AL", "AM", "AN"), "template": resolve_template("Peppermoney", ["Peppermoney BSC v1.7a.xlsm"], ["Peppermoney*BSC*.xlsm"]), "result_sheet": 0, "metric": "NSR", "value_cell": "M75", "dti_cell": None, "status_cell": "O75", "status_rule": {"type": "threshold", "op": ">=", "value": 1}}, | |
| "SBS": {"block": ("AP", "AQ", "AR"), "template": resolve_template("SBS", ["SBS-NSR-Calculator_V16.7(Unprotect).xlsm", "SBS-NSR-Calculator_V16.7.xltm"], ["SBS*NSR*Calculator*.xlsm", "SBS*NSR*Calculator*.xltm"]), "result_sheet": "NSR Calculator", "metric": "NSR", "value_cell": "C149", "dti_cell": "I144", "status_rule": {"type": "threshold", "op": "<=", "value": 0.975}}, | |
| "TSB": {"block": ("AT", "AU", "AV"), "template": resolve_template("TSB", ["TSB Loan Affordability Calculator v6.8 - 09 10 25.xlsx"], ["TSB*Loan*Affordability*Calculator*.xlsx"]), "result_sheet": 0, "metric": "UMI", "value_cell": "D77", "dti_cell": None, "status_rule": {"type": "threshold", "op": ">=", "value": 0}}, | |
| } | |
| DATA_DIR = Path("/data") if Path("/data").exists() else Path("data") | |
| CLIENTS_DIR = DATA_DIR / "clients" | |
| JOBS_DIR = DATA_DIR / "jobs" | |
| RETENTION_DAYS = 7 | |
| NZ_TZ = ZoneInfo("Pacific/Auckland") | |
| ERROR_VALUES = {"#N/A", "#VALUE!", "#REF!", "#DIV/0!", "#NAME?", "#NUM!", "#NULL!"} | |
| DEFAULT_GOOGLE_SHEET_ID = "1XfWFOiuikbyLldmR7Fyz4cJiBTWHtlRFh2hpLt_mHdo" | |
| EXCLUDED_BANKS = {"Avanti", "TSB"} | |
| BANK_WORKERS = 2 | |
| TEST_RATE_CELLS = { | |
| "ANZ": ("LAC", "Q30"), | |
| "ASB": ("Calc", "F22"), | |
| "BNZ": ("Affordability Calculator", "G23"), | |
| "Westpac": ("Assess Serviceability calc", "J5"), | |
| "Kiwibank": ("Adviser HL Worksheet", "V7"), | |
| "BOC": ("Data 2021", "K1"), | |
| "Co-operative": ("Serviceability Assessment", "C7"), | |
| "Peppermoney": ("Residential", "O10"), | |
| "SBS": ("NSR Calculator", "S9"), | |
| "Unity": ("Calculator", "H13"), | |
| } | |
| GOOGLE_HEADERS = [ | |
| "record_id", | |
| "saved_at", | |
| "client_1", | |
| "client_2", | |
| "client_3", | |
| "client_label", | |
| "loan_amount", | |
| "property_value", | |
| "input_json", | |
| ] | |
| GOOGLE_SETTINGS_HEADERS = ["key", "updated_at", "settings_json"] | |
| LAST_GOOGLE_SAVE_STATUS = { | |
| "attempted": False, | |
| "saved": False, | |
| "message": "Google Sheet save has not run yet.", | |
| } | |
| FIELD_GROUPS = [ | |
| {"amount": "sal_1", "dependents": ["f1", "v1"]}, | |
| {"amount": "ovt_1", "dependents": ["f2", "v2"]}, | |
| {"amount": "biz_1", "dependents": ["f3", "v3"]}, | |
| {"amount": "gov_1", "dependents": ["f4"]}, | |
| {"amount": "brd_1", "dependents": ["f5", "v5"]}, | |
| {"amount": "rnt_1", "dependents": ["f6"]}, | |
| {"amount": "sal_a2", "dependents": ["f1_a2", "kiwi_a2"]}, | |
| {"amount": "ovt_a2", "dependents": ["f2_a2", "v2_a2"]}, | |
| {"amount": "biz_a2", "dependents": ["f3_a2", "v3_a2"]}, | |
| {"amount": "gov_a2", "dependents": ["f4_a2"]}, | |
| {"amount": "brd_a2", "dependents": ["f5_a2", "v5_a2"]}, | |
| {"amount": "rnt_a2", "dependents": ["f6_a2"]}, | |
| {"amount": "sal_a3", "dependents": ["f1_a3", "kiwi_a3"]}, | |
| {"amount": "ovt_a3", "dependents": ["f2_a3", "v2_a3"]}, | |
| {"amount": "biz_a3", "dependents": ["f3_a3", "v3_a3"]}, | |
| {"amount": "gov_a3", "dependents": ["f4_a3"]}, | |
| {"amount": "rnt_a3", "dependents": ["f6_a3"]}, | |
| ] | |
| LIABILITY_FIELD_GROUPS = [ | |
| {"amounts": ["m_b", "m_r_val"], "dependents": ["m_bk", "m_f", "m_rt", "m_tr"]}, | |
| {"amounts": ["p_b", "p_r"], "dependents": ["p_ln", "p_f", "p_t"]}, | |
| {"amounts": ["cc_l", "cc_b", "cc_rp"], "dependents": ["cc_bk"]}, | |
| {"amounts": ["sl_b1", "sl_r1", "sl_b2", "sl_r2", "sl_b3", "sl_r3_amt"], "dependents": ["sl_r3"]}, | |
| {"amounts": ["bn_l", "bn_b", "bn_r"], "dependents": ["bn_t_type", "bn_category", "bn_f"]}, | |
| ] | |
| REQUIRED_DASHBOARD_FORMULAS = { | |
| "AY209": "=SUBTOTAL(9,AY164:AY208)", | |
| } | |
| def setup_storage() -> None: | |
| CLIENTS_DIR.mkdir(parents=True, exist_ok=True) | |
| JOBS_DIR.mkdir(parents=True, exist_ok=True) | |
| def cleanup_old_jobs() -> None: | |
| setup_storage() | |
| cutoff = datetime.now() - timedelta(days=RETENTION_DAYS) | |
| for path in JOBS_DIR.iterdir(): | |
| if not path.is_dir(): | |
| continue | |
| try: | |
| if datetime.fromtimestamp(path.stat().st_mtime) < cutoff: | |
| shutil.rmtree(path, ignore_errors=True) | |
| except OSError: | |
| continue | |
| def parse_number(value): | |
| if value is None: | |
| return None | |
| if isinstance(value, (int, float)): | |
| return value | |
| text = str(value).replace(",", "").replace("$", "").strip() | |
| if text == "": | |
| return None | |
| try: | |
| number = float(text) | |
| return int(number) if number.is_integer() else number | |
| except ValueError: | |
| return value | |
| def parse_percent(value): | |
| if value is None: | |
| return None | |
| if isinstance(value, (int, float)): | |
| return value | |
| text = str(value).replace("%", "").strip() | |
| if text == "": | |
| return None | |
| try: | |
| return float(text) | |
| except ValueError: | |
| return value | |
| def is_blank_or_zero(value) -> bool: | |
| parsed = parse_number(value) | |
| return parsed is None or parsed == 0 | |
| def preprocess_inputs(inputs: dict) -> dict: | |
| cleaned = dict(inputs) | |
| percent_keys = {"v1", "kiwi_a2", "kiwi_a3"} | |
| for key, value in list(cleaned.items()): | |
| if key in percent_keys: | |
| cleaned[key] = parse_percent(value) | |
| elif isinstance(value, str) and any(ch.isdigit() for ch in value): | |
| cleaned[key] = parse_number(value) | |
| for group in FIELD_GROUPS: | |
| if is_blank_or_zero(cleaned.get(group["amount"])): | |
| cleaned.pop(group["amount"], None) | |
| for dependent in group["dependents"]: | |
| cleaned.pop(dependent, None) | |
| for group in LIABILITY_FIELD_GROUPS: | |
| if all(is_blank_or_zero(cleaned.get(amount)) for amount in group["amounts"]): | |
| for amount in group["amounts"]: | |
| cleaned.pop(amount, None) | |
| for dependent in group["dependents"]: | |
| cleaned.pop(dependent, None) | |
| return cleaned | |
| def client_key(name: str) -> str: | |
| safe = "".join(ch for ch in name.strip() if ch.isalnum() or ch in (" ", "-", "_")).strip() | |
| return safe or "Client" | |
| def nz_now() -> datetime: | |
| return datetime.now(NZ_TZ) | |
| def parse_saved_at(value: str) -> datetime | None: | |
| text = str(value or "").strip() | |
| if not text: | |
| return None | |
| try: | |
| if text.endswith("Z"): | |
| text = text[:-1] + "+00:00" | |
| parsed = datetime.fromisoformat(text) | |
| except ValueError: | |
| return None | |
| if parsed.tzinfo is None: | |
| parsed = parsed.replace(tzinfo=NZ_TZ) | |
| return parsed.astimezone(NZ_TZ) | |
| def saved_at_sort_key(value: str) -> str: | |
| parsed = parse_saved_at(value) | |
| return parsed.isoformat() if parsed else str(value or "") | |
| def format_saved_at(value: str) -> str: | |
| parsed = parse_saved_at(value) | |
| if not parsed: | |
| return str(value or "") | |
| return parsed.strftime("%Y-%m-%d %H:%M NZT") | |
| def google_sheet_id() -> str: | |
| return os.environ.get("GOOGLE_SHEET_ID", DEFAULT_GOOGLE_SHEET_ID).strip() | |
| def google_script_url() -> str: | |
| return os.environ.get("GOOGLE_SCRIPT_URL", "").strip() | |
| def google_script_token() -> str: | |
| return os.environ.get("GOOGLE_SCRIPT_TOKEN", "").strip() | |
| def google_script_enabled() -> bool: | |
| return bool(google_script_url() and google_script_token()) | |
| def google_service_account_info() -> dict | None: | |
| raw = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "").strip() | |
| if not raw: | |
| return None | |
| try: | |
| return json.loads(raw) | |
| except json.JSONDecodeError: | |
| return None | |
| def google_sheet_enabled() -> bool: | |
| return google_script_enabled() or bool(google_sheet_id() and google_service_account_info()) | |
| def google_sheet_config_status() -> dict: | |
| info = google_service_account_info() | |
| return { | |
| "sheet_id": google_sheet_id(), | |
| "script_url": google_script_url(), | |
| "has_script_token": bool(google_script_token()), | |
| "script_enabled": google_script_enabled(), | |
| "has_service_account_json": bool(info), | |
| "client_email": str((info or {}).get("client_email") or ""), | |
| "enabled": google_sheet_enabled(), | |
| } | |
| def last_google_save_status() -> dict: | |
| return dict(LAST_GOOGLE_SAVE_STATUS) | |
| def google_script_request(action: str, payload: dict | None = None) -> dict: | |
| if not google_script_enabled(): | |
| raise RuntimeError("GOOGLE_SCRIPT_URL or GOOGLE_SCRIPT_TOKEN is missing.") | |
| body = json.dumps( | |
| { | |
| "token": google_script_token(), | |
| "action": action, | |
| "payload": payload or {}, | |
| }, | |
| ensure_ascii=False, | |
| default=str, | |
| ).encode("utf-8") | |
| request = Request( | |
| google_script_url(), | |
| data=body, | |
| headers={"Content-Type": "application/json"}, | |
| method="POST", | |
| ) | |
| try: | |
| with urlopen(request, timeout=20) as response: | |
| raw = response.read().decode("utf-8") | |
| except HTTPError as exc: | |
| raw = exc.read().decode("utf-8", errors="replace") | |
| raise RuntimeError(f"Apps Script HTTP {exc.code}: {raw}") from exc | |
| except URLError as exc: | |
| raise RuntimeError(f"Apps Script connection failed: {exc.reason}") from exc | |
| try: | |
| data = json.loads(raw) | |
| except json.JSONDecodeError as exc: | |
| raise RuntimeError(f"Apps Script returned non-JSON response: {raw[:300]}") from exc | |
| if not data.get("ok"): | |
| raise RuntimeError(str(data.get("error") or "Apps Script request failed.")) | |
| return data | |
| def google_records_worksheet(): | |
| info = google_service_account_info() | |
| if not info: | |
| return None | |
| try: | |
| import gspread | |
| from google.oauth2.service_account import Credentials | |
| except ImportError: | |
| return None | |
| scopes = ["https://www.googleapis.com/auth/spreadsheets"] | |
| credentials = Credentials.from_service_account_info(info, scopes=scopes) | |
| client = gspread.authorize(credentials) | |
| spreadsheet = client.open_by_key(google_sheet_id()) | |
| try: | |
| worksheet = spreadsheet.worksheet("records") | |
| except gspread.WorksheetNotFound: | |
| worksheet = spreadsheet.add_worksheet(title="records", rows=1000, cols=len(GOOGLE_HEADERS)) | |
| existing_headers = worksheet.row_values(1) | |
| if existing_headers[: len(GOOGLE_HEADERS)] != GOOGLE_HEADERS: | |
| worksheet.update("A1:I1", [GOOGLE_HEADERS]) | |
| return worksheet | |
| def google_settings_worksheet(): | |
| info = google_service_account_info() | |
| if not info: | |
| return None | |
| try: | |
| import gspread | |
| from google.oauth2.service_account import Credentials | |
| except ImportError: | |
| return None | |
| scopes = ["https://www.googleapis.com/auth/spreadsheets"] | |
| credentials = Credentials.from_service_account_info(info, scopes=scopes) | |
| client = gspread.authorize(credentials) | |
| spreadsheet = client.open_by_key(google_sheet_id()) | |
| try: | |
| worksheet = spreadsheet.worksheet("rate") | |
| except gspread.WorksheetNotFound: | |
| worksheet = spreadsheet.add_worksheet(title="rate", rows=100, cols=len(GOOGLE_SETTINGS_HEADERS)) | |
| existing_headers = worksheet.row_values(1) | |
| if existing_headers[: len(GOOGLE_SETTINGS_HEADERS)] != GOOGLE_SETTINGS_HEADERS: | |
| worksheet.update("A1:C1", [GOOGLE_SETTINGS_HEADERS]) | |
| return worksheet | |
| def google_named_worksheet(title: str, rows: int, cols: int, headers: list[str]): | |
| info = google_service_account_info() | |
| if not info: | |
| return None | |
| try: | |
| import gspread | |
| from google.oauth2.service_account import Credentials | |
| except ImportError: | |
| return None | |
| scopes = ["https://www.googleapis.com/auth/spreadsheets"] | |
| credentials = Credentials.from_service_account_info(info, scopes=scopes) | |
| client = gspread.authorize(credentials) | |
| spreadsheet = client.open_by_key(google_sheet_id()) | |
| try: | |
| worksheet = spreadsheet.worksheet(title) | |
| except gspread.WorksheetNotFound: | |
| worksheet = spreadsheet.add_worksheet(title=title, rows=rows, cols=cols) | |
| existing_headers = worksheet.row_values(1) | |
| if existing_headers[: len(headers)] != headers: | |
| worksheet.update(f"A1:{chr(64 + len(headers))}1", [headers]) | |
| return worksheet | |
| def replace_google_sheet_rows(title: str, headers: list[str], rows: list[list]) -> None: | |
| worksheet = google_named_worksheet(title, max(len(rows) + 10, 100), len(headers), headers) | |
| if worksheet is None: | |
| return | |
| worksheet.clear() | |
| worksheet.update(f"A1:{chr(64 + len(headers))}1", [headers]) | |
| if rows: | |
| worksheet.update(f"A2:{chr(64 + len(headers))}{len(rows) + 1}", rows, value_input_option="USER_ENTERED") | |
| def save_readable_settings_to_google(settings: dict) -> None: | |
| updated_at = nz_now().strftime("%Y-%m-%d %H:%M NZT") | |
| replace_google_sheet_rows( | |
| "rate_matrix", | |
| ["Lender", "Source", "Product", "KO", "Offset", "Floating Rate", "Fixed Loan 1yr", "Fixed Loan 18 months", "Fixed Loan 2yr", "Fixed Loan 3yr", "updated_at"], | |
| [ | |
| [ | |
| row.get("Lender", ""), | |
| row.get("Source", ""), | |
| row.get("Product", ""), | |
| row.get("KO", ""), | |
| row.get("Offset", 0) or 0, | |
| row.get("Floating Rate", 0) or 0, | |
| row.get("Fixed Loan 1yr", 0) or 0, | |
| row.get("Fixed Loan 18 months", 0) or 0, | |
| row.get("Fixed Loan 2yr", 0) or 0, | |
| row.get("Fixed Loan 3yr", 0) or 0, | |
| updated_at, | |
| ] | |
| for row in settings.get("rate_matrix", []) | |
| ], | |
| ) | |
| replace_google_sheet_rows( | |
| "pepper_rates", | |
| ["Lender", "Product Class", "Meaning", "Floating Rate", "Fixed Loan 1yr", "Fixed Loan 18 months", "Fixed Loan 2yr", "Fixed Loan 3yr", "updated_at"], | |
| [ | |
| [ | |
| row.get("Lender", ""), | |
| row.get("Product Class", ""), | |
| row.get("Meaning", ""), | |
| row.get("Floating Rate", 0) or 0, | |
| row.get("Fixed Loan 1yr", 0) or 0, | |
| row.get("Fixed Loan 18 months", 0) or 0, | |
| row.get("Fixed Loan 2yr", 0) or 0, | |
| row.get("Fixed Loan 3yr", 0) or 0, | |
| updated_at, | |
| ] | |
| for row in settings.get("specialist_rates", []) | |
| ], | |
| ) | |
| replace_google_sheet_rows( | |
| "cashback", | |
| ["Lender", "Standard_Pct", "NewBuild_Pct", "Max_Limit", "FHL_Fixed", "FHL_Pct", "updated_at"], | |
| [ | |
| [bank, row.get("Standard_Pct", 0) or 0, row.get("NewBuild_Pct", 0) or 0, row.get("Max_Limit", 0) or 0, row.get("FHL_Fixed", 0) or 0, row.get("FHL_Pct", 0) or 0, updated_at] | |
| for bank, row in (settings.get("cashback") or {}).items() | |
| ], | |
| ) | |
| replace_google_sheet_rows( | |
| "lem", | |
| ["Lender", "80.01-85.00%", "85.01-90.00%", "90.01-95.00%", "updated_at"], | |
| [ | |
| [bank, row.get("80.01-85.00%", 0) or 0, row.get("85.01-90.00%", 0) or 0, row.get("90.01-95.00%", 0) or 0, updated_at] | |
| for bank, row in (settings.get("lem") or {}).items() | |
| ], | |
| ) | |
| replace_google_sheet_rows( | |
| "test_rate_cells", | |
| ["Lender", "Sheet", "Cell", "updated_at"], | |
| [[bank, value[0] if value else "", value[1] if len(value) > 1 else "", updated_at] for bank, value in (settings.get("test_rate_cells") or {}).items()], | |
| ) | |
| replace_google_sheet_rows( | |
| "lvr_policy", | |
| ["Lender", "Existing", "Investment", "New Build", "Apt", "Work Visa", "FHL", "updated_at"], | |
| [ | |
| [bank, row.get("Existing", ""), row.get("Investment", ""), row.get("New Build", ""), row.get("Apt", ""), row.get("Work Visa", ""), row.get("FHL", ""), updated_at] | |
| for bank, row in (settings.get("lvr_policy") or {}).items() | |
| ], | |
| ) | |
| def save_policy_registry_to_google(registry: dict | None) -> None: | |
| """Publish the normalized policy registry to editable row-based tabs.""" | |
| data = policy_engine.normalize_registry(registry) | |
| mappings = [ | |
| ("Policy Packages", policy_engine.PACKAGE_COLUMNS, data["packages"]), | |
| ("Policy Rules", policy_engine.RULE_COLUMNS, data["rules"]), | |
| ("Policy Conditions", policy_engine.CONDITION_COLUMNS, data["conditions"]), | |
| ("Policy Results", policy_engine.RESULT_COLUMNS, data["results"]), | |
| ("Required Evidence", policy_engine.EVIDENCE_COLUMNS, data["required_evidence"]), | |
| ("Workflow Escalation", policy_engine.WORKFLOW_COLUMNS, data["workflows"]), | |
| ("Policy Sources", policy_engine.SOURCE_COLUMNS, data["sources"]), | |
| ("Pending Changes", policy_engine.PENDING_COLUMNS, data["pending_changes"]), | |
| ] | |
| for title, headers, records in mappings: | |
| replace_google_sheet_rows(title, headers, [[row.get(column, "") for column in headers] for row in records]) | |
| def load_policy_registry_from_google() -> dict | None: | |
| """Load row-based policy tabs when service-account access is available.""" | |
| mappings = [ | |
| ("packages", "Policy Packages", policy_engine.PACKAGE_COLUMNS), | |
| ("rules", "Policy Rules", policy_engine.RULE_COLUMNS), | |
| ("conditions", "Policy Conditions", policy_engine.CONDITION_COLUMNS), | |
| ("results", "Policy Results", policy_engine.RESULT_COLUMNS), | |
| ("required_evidence", "Required Evidence", policy_engine.EVIDENCE_COLUMNS), | |
| ("workflows", "Workflow Escalation", policy_engine.WORKFLOW_COLUMNS), | |
| ("sources", "Policy Sources", policy_engine.SOURCE_COLUMNS), | |
| ("pending_changes", "Pending Changes", policy_engine.PENDING_COLUMNS), | |
| ] | |
| registry = {} | |
| found = False | |
| for key, title, headers in mappings: | |
| worksheet = google_named_worksheet(title, 100, len(headers), headers) | |
| if worksheet is None: | |
| return None | |
| rows = worksheet.get_all_records() | |
| registry[key] = [{column: row.get(column, "") for column in headers} for row in rows] | |
| found = found or bool(rows) | |
| return policy_engine.normalize_registry(registry) if found else None | |
| def apply_readable_settings_from_google(settings: dict) -> dict: | |
| def records(title: str, headers: list[str]) -> list[dict]: | |
| worksheet = google_named_worksheet(title, 100, len(headers), headers) | |
| if worksheet is None: | |
| return [] | |
| return worksheet.get_all_records() | |
| rate_rows = records("rate_matrix", ["Lender", "Source", "Product", "KO", "Offset", "Floating Rate", "Fixed Loan 1yr", "Fixed Loan 18 months", "Fixed Loan 2yr", "Fixed Loan 3yr", "updated_at"]) | |
| if rate_rows: | |
| settings["rate_matrix"] = [ | |
| {key: row.get(key, "") for key in ["Lender", "Source", "Product", "KO", "Offset", "Floating Rate", "Fixed Loan 1yr", "Fixed Loan 18 months", "Fixed Loan 2yr", "Fixed Loan 3yr"]} | |
| for row in rate_rows | |
| if str(row.get("Lender") or "").strip() and str(row.get("Product") or "").strip() | |
| ] | |
| pepper_rows = records("pepper_rates", ["Lender", "Product Class", "Meaning", "Floating Rate", "Fixed Loan 1yr", "Fixed Loan 18 months", "Fixed Loan 2yr", "Fixed Loan 3yr", "updated_at"]) | |
| if pepper_rows: | |
| settings["specialist_rates"] = [ | |
| {key: row.get(key, "") for key in ["Lender", "Product Class", "Meaning", "Floating Rate", "Fixed Loan 1yr", "Fixed Loan 18 months", "Fixed Loan 2yr", "Fixed Loan 3yr"]} | |
| for row in pepper_rows | |
| if str(row.get("Lender") or "").strip() and str(row.get("Product Class") or "").strip() | |
| ] | |
| cashback_rows = records("cashback", ["Lender", "Standard_Pct", "NewBuild_Pct", "Max_Limit", "FHL_Fixed", "FHL_Pct", "updated_at"]) | |
| if cashback_rows: | |
| settings["cashback"] = {row["Lender"]: {key: row.get(key, 0) for key in ["Standard_Pct", "NewBuild_Pct", "Max_Limit", "FHL_Fixed", "FHL_Pct"]} for row in cashback_rows if str(row.get("Lender") or "").strip()} | |
| lem_rows = records("lem", ["Lender", "80.01-85.00%", "85.01-90.00%", "90.01-95.00%", "updated_at"]) | |
| if lem_rows: | |
| settings["lem"] = {row["Lender"]: {key: row.get(key, 0) for key in ["80.01-85.00%", "85.01-90.00%", "90.01-95.00%"]} for row in lem_rows if str(row.get("Lender") or "").strip()} | |
| test_rows = records("test_rate_cells", ["Lender", "Sheet", "Cell", "updated_at"]) | |
| if test_rows: | |
| settings["test_rate_cells"] = {row["Lender"]: [row.get("Sheet", ""), row.get("Cell", "")] for row in test_rows if str(row.get("Lender") or "").strip()} | |
| lvr_rows = records("lvr_policy", ["Lender", "Existing", "Investment", "New Build", "Apt", "Work Visa", "FHL", "updated_at"]) | |
| if lvr_rows: | |
| settings["lvr_policy"] = {row["Lender"]: {key: row.get(key, "") for key in ["Existing", "Investment", "New Build", "Apt", "Work Visa", "FHL"]} for row in lvr_rows if str(row.get("Lender") or "").strip()} | |
| return settings | |
| def save_settings_to_google_sheet(settings: dict, key: str = "policy_settings") -> tuple[bool, str]: | |
| if google_script_enabled(): | |
| try: | |
| google_script_request("save_settings", {"key": key, "settings": settings}) | |
| try: | |
| if google_service_account_info(): | |
| save_policy_registry_to_google(settings.get("policy_registry")) | |
| except Exception: | |
| pass | |
| return True, "Saved to Google Sheet via Apps Script." | |
| except Exception as exc: | |
| script_error = str(exc) | |
| try: | |
| worksheet = google_settings_worksheet() | |
| if worksheet is None: | |
| return False, f"Apps Script failed ({script_error}); service account worksheet was not available." | |
| except Exception as exc: | |
| return False, f"Apps Script failed ({script_error}); service account failed ({exc})." | |
| else: | |
| worksheet = google_settings_worksheet() | |
| if worksheet is None: | |
| return False, "Google Apps Script is not configured and service account worksheet was not available." | |
| settings_json = json.dumps(settings, ensure_ascii=False, default=str) | |
| updated_at = nz_now().strftime("%Y-%m-%d %H:%M NZT") | |
| rows = worksheet.get_all_values() | |
| target_row = None | |
| for row_number, row in enumerate(rows[1:], start=2): | |
| if row and str(row[0]).strip() == key: | |
| target_row = row_number | |
| break | |
| values = [[key, updated_at, settings_json]] | |
| if target_row: | |
| worksheet.update(f"A{target_row}:C{target_row}", values) | |
| else: | |
| worksheet.append_row(values[0], value_input_option="USER_ENTERED") | |
| save_readable_settings_to_google(settings) | |
| save_policy_registry_to_google(settings.get("policy_registry")) | |
| return True, "Saved to Google Sheet via service account." | |
| def load_settings_from_google_sheet(key: str = "policy_settings") -> tuple[dict | None, str]: | |
| if google_script_enabled(): | |
| try: | |
| response = google_script_request("load_settings", {"key": key}) | |
| if response.get("found"): | |
| loaded = json.loads(response.get("settings_json") or "{}") | |
| try: | |
| row_registry = load_policy_registry_from_google() if google_service_account_info() else None | |
| if row_registry: | |
| loaded["policy_registry"] = row_registry | |
| except Exception: | |
| pass | |
| return loaded, "Loaded from Google Sheet rate tab via Apps Script." | |
| return None, "No settings row found via Apps Script." | |
| except Exception as exc: | |
| script_error = str(exc) | |
| try: | |
| worksheet = google_settings_worksheet() | |
| if worksheet is None: | |
| return None, f"Apps Script failed ({script_error}); service account worksheet was not available." | |
| except Exception as exc: | |
| return None, f"Apps Script failed ({script_error}); service account failed ({exc})." | |
| else: | |
| worksheet = google_settings_worksheet() | |
| if worksheet is None: | |
| return None, "Google Apps Script is not configured and service account worksheet was not available." | |
| for row in reversed(worksheet.get_all_records()): | |
| if str(row.get("key") or "").strip() != key: | |
| continue | |
| raw = str(row.get("settings_json") or "").strip() | |
| if not raw: | |
| continue | |
| loaded = apply_readable_settings_from_google(json.loads(raw)) | |
| row_registry = load_policy_registry_from_google() | |
| if row_registry: | |
| loaded["policy_registry"] = row_registry | |
| return loaded, "Loaded from Google Sheet via service account." | |
| return None, "No settings row found in Google Sheet rate tab." | |
| def google_row_to_summary(row: dict) -> dict | None: | |
| record_id = str(row.get("record_id") or "").strip() | |
| input_json = str(row.get("input_json") or "").strip() | |
| if not record_id or not input_json: | |
| return None | |
| loan_amount = parse_number(row.get("loan_amount")) | |
| property_value = parse_number(row.get("property_value")) | |
| return { | |
| "id": record_id, | |
| "label": str(row.get("client_label") or row.get("client_1") or "Client"), | |
| "saved_at": str(row.get("saved_at") or ""), | |
| "loan_amount": loan_amount if isinstance(loan_amount, (int, float)) else 0, | |
| "property_value": property_value if isinstance(property_value, (int, float)) else 0, | |
| "path": f"google:{record_id}", | |
| } | |
| def save_client_inputs_to_google(inputs: dict, saved_at: str) -> bool: | |
| if google_script_enabled(): | |
| summary = client_summary(inputs, saved_at) | |
| response = google_script_request( | |
| "save", | |
| { | |
| "record": { | |
| "record_id": f"{nz_now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}", | |
| "saved_at": saved_at, | |
| "client_1": str(inputs.get("c_name1") or ""), | |
| "client_2": str(inputs.get("c_name2") or ""), | |
| "client_3": str(inputs.get("c_name3") or ""), | |
| "client_label": summary["label"], | |
| "loan_amount": summary["loan_amount"], | |
| "property_value": summary["property_value"], | |
| "input_json": json.dumps(inputs, ensure_ascii=False, default=str), | |
| } | |
| }, | |
| ) | |
| return bool(response.get("saved", True)) | |
| worksheet = google_records_worksheet() | |
| if worksheet is None: | |
| return False | |
| summary = client_summary(inputs, saved_at) | |
| record_id = f"{nz_now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" | |
| row = [ | |
| record_id, | |
| saved_at, | |
| str(inputs.get("c_name1") or ""), | |
| str(inputs.get("c_name2") or ""), | |
| str(inputs.get("c_name3") or ""), | |
| summary["label"], | |
| summary["loan_amount"], | |
| summary["property_value"], | |
| json.dumps(inputs, ensure_ascii=False, default=str), | |
| ] | |
| worksheet.append_row(row, value_input_option="USER_ENTERED") | |
| return True | |
| def find_client_matches_in_google(name: str, limit: int = 20) -> list[dict]: | |
| query = str(name or "").strip().casefold() | |
| if not query: | |
| return [] | |
| if google_script_enabled(): | |
| response = google_script_request("search", {"query": name, "limit": limit}) | |
| matches = [] | |
| for row in response.get("records", []): | |
| summary = google_row_to_summary(row) | |
| if summary: | |
| matches.append(summary) | |
| matches.sort(key=lambda item: saved_at_sort_key(item.get("saved_at", "")), reverse=True) | |
| return matches[:limit] | |
| worksheet = google_records_worksheet() | |
| if worksheet is None: | |
| return [] | |
| matches = [] | |
| for row in worksheet.get_all_records(): | |
| names = [ | |
| str(row.get("client_1") or ""), | |
| str(row.get("client_2") or ""), | |
| str(row.get("client_3") or ""), | |
| str(row.get("client_label") or ""), | |
| ] | |
| if any(query in item.strip().casefold() for item in names): | |
| summary = google_row_to_summary(row) | |
| if summary: | |
| matches.append(summary) | |
| matches.sort(key=lambda item: saved_at_sort_key(item.get("saved_at", "")), reverse=True) | |
| return matches[:limit] | |
| def load_client_from_google_record(record_id: str) -> dict | None: | |
| if google_script_enabled(): | |
| response = google_script_request("load", {"record_id": record_id}) | |
| record = response.get("record") or {} | |
| input_json = str(record.get("input_json") or "").strip() | |
| if input_json: | |
| try: | |
| return json.loads(input_json) | |
| except json.JSONDecodeError: | |
| return None | |
| inputs = record.get("inputs") | |
| return inputs if isinstance(inputs, dict) else None | |
| worksheet = google_records_worksheet() | |
| if worksheet is None: | |
| return None | |
| for row in worksheet.get_all_records(): | |
| if str(row.get("record_id") or "").strip() == record_id: | |
| try: | |
| return json.loads(str(row.get("input_json") or "{}")) | |
| except json.JSONDecodeError: | |
| return None | |
| return None | |
| def save_client_inputs(inputs: dict) -> None: | |
| setup_storage() | |
| name = client_key(str(inputs.get("c_name1") or inputs.get("client_name") or "Client")) | |
| folder = CLIENTS_DIR / name | |
| folder.mkdir(parents=True, exist_ok=True) | |
| now = nz_now() | |
| payload = {"saved_at": now.isoformat(timespec="seconds"), "inputs": inputs} | |
| payload["summary"] = client_summary(inputs, payload["saved_at"]) | |
| timestamp = now.strftime("%Y%m%d_%H%M%S") | |
| (folder / "latest.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") | |
| (folder / f"{timestamp}.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") | |
| LAST_GOOGLE_SAVE_STATUS.update({ | |
| "attempted": True, | |
| "saved": False, | |
| "message": "", | |
| }) | |
| if not google_sheet_enabled(): | |
| config = google_sheet_config_status() | |
| if not config["script_url"]: | |
| message = "GOOGLE_SCRIPT_URL secret is missing." | |
| elif not config["has_script_token"]: | |
| message = "GOOGLE_SCRIPT_TOKEN secret is missing." | |
| elif not config["has_service_account_json"]: | |
| message = "Google Apps Script is not configured, and GOOGLE_SERVICE_ACCOUNT_JSON is missing or invalid." | |
| elif not config["sheet_id"]: | |
| message = "GOOGLE_SHEET_ID is missing." | |
| else: | |
| message = "Google Sheet is not configured." | |
| LAST_GOOGLE_SAVE_STATUS.update({"message": message}) | |
| return | |
| try: | |
| saved = save_client_inputs_to_google(inputs, payload["saved_at"]) | |
| LAST_GOOGLE_SAVE_STATUS.update({ | |
| "saved": bool(saved), | |
| "message": "Saved to Google Sheet." if saved else "Google Sheet worksheet was not available.", | |
| }) | |
| except Exception as exc: | |
| LAST_GOOGLE_SAVE_STATUS.update({ | |
| "saved": False, | |
| "message": f"{type(exc).__name__}: {exc}", | |
| }) | |
| def client_summary(inputs: dict, saved_at: str, path: Path | None = None) -> dict: | |
| names = [str(inputs.get(key) or "").strip() for key in ("c_name1", "c_name2", "c_name3")] | |
| names = [name for name in names if name] | |
| loan_amount = parse_number(inputs.get("loan_amt")) | |
| property_value = parse_number(inputs.get("prop_val")) | |
| return { | |
| "id": path.stem if path else "", | |
| "label": " / ".join(names) or str(inputs.get("client_name") or "Client"), | |
| "saved_at": saved_at, | |
| "loan_amount": loan_amount if isinstance(loan_amount, (int, float)) else 0, | |
| "property_value": property_value if isinstance(property_value, (int, float)) else 0, | |
| "path": str(path) if path else "", | |
| } | |
| def find_client_matches(name: str, limit: int = 20) -> list[dict]: | |
| setup_storage() | |
| query = str(name or "").strip().casefold() | |
| if not query: | |
| return [] | |
| try: | |
| google_matches = find_client_matches_in_google(name, limit) | |
| if google_matches: | |
| return google_matches | |
| except Exception: | |
| pass | |
| matches = [] | |
| for json_path in CLIENTS_DIR.glob("*/*.json"): | |
| if json_path.name == "latest.json": | |
| continue | |
| try: | |
| payload = json.loads(json_path.read_text(encoding="utf-8")) | |
| except (OSError, json.JSONDecodeError): | |
| continue | |
| inputs = payload.get("inputs", {}) | |
| names = [ | |
| str(inputs.get("c_name1") or ""), | |
| str(inputs.get("c_name2") or ""), | |
| str(inputs.get("c_name3") or ""), | |
| str(inputs.get("client_name") or ""), | |
| ] | |
| if any(query in item.strip().casefold() for item in names): | |
| summary = payload.get("summary") or client_summary(inputs, payload.get("saved_at", ""), json_path) | |
| summary["path"] = str(json_path) | |
| summary["id"] = json_path.stem | |
| matches.append(summary) | |
| matches.sort(key=lambda item: saved_at_sort_key(item.get("saved_at", "")), reverse=True) | |
| return matches[:limit] | |
| def load_client_by_path(path: str) -> dict | None: | |
| if str(path).startswith("google:"): | |
| try: | |
| return load_client_from_google_record(str(path).split(":", 1)[1]) | |
| except Exception: | |
| return None | |
| json_path = Path(path) | |
| if not json_path.exists() or not json_path.is_file(): | |
| return None | |
| try: | |
| payload = json.loads(json_path.read_text(encoding="utf-8")) | |
| except (OSError, json.JSONDecodeError): | |
| return None | |
| return payload.get("inputs", {}) | |
| def saved_client_record_count() -> int: | |
| setup_storage() | |
| return sum(1 for path in CLIENTS_DIR.glob("*/*.json") if path.name != "latest.json") | |
| def storage_status() -> dict: | |
| setup_storage() | |
| google_config = google_sheet_config_status() | |
| return { | |
| "path": str(DATA_DIR), | |
| "persistent": DATA_DIR == Path("/data"), | |
| "record_count": saved_client_record_count(), | |
| "google_sheet_enabled": google_config["enabled"], | |
| "google_sheet_id": google_config["sheet_id"], | |
| "google_script_url": google_config["script_url"], | |
| "google_script_enabled": google_config["script_enabled"], | |
| "google_has_script_token": google_config["has_script_token"], | |
| "google_client_email": google_config["client_email"], | |
| "google_has_service_account_json": google_config["has_service_account_json"], | |
| "last_google_save": last_google_save_status(), | |
| } | |
| def create_client_backup_zip() -> bytes: | |
| setup_storage() | |
| buffer = io.BytesIO() | |
| with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf: | |
| for json_path in CLIENTS_DIR.glob("*/*.json"): | |
| if json_path.is_file(): | |
| zf.write(json_path, arcname=str(json_path.relative_to(DATA_DIR))) | |
| return buffer.getvalue() | |
| def restore_client_backup_zip(data: bytes) -> int: | |
| setup_storage() | |
| restored = 0 | |
| with zipfile.ZipFile(io.BytesIO(data), "r") as zf: | |
| for info in zf.infolist(): | |
| path = Path(info.filename) | |
| if info.is_dir() or path.is_absolute() or ".." in path.parts: | |
| continue | |
| parts = path.parts | |
| if len(parts) >= 3 and parts[0] == "clients": | |
| relative = Path(*parts[1:]) | |
| else: | |
| relative = path | |
| if relative.suffix.lower() != ".json" or len(relative.parts) < 2: | |
| continue | |
| target = CLIENTS_DIR / relative | |
| target.parent.mkdir(parents=True, exist_ok=True) | |
| target.write_bytes(zf.read(info)) | |
| restored += 1 | |
| return restored | |
| def load_latest_client(name: str) -> dict | None: | |
| setup_storage() | |
| query = str(name or "").strip().casefold() | |
| if not query: | |
| return None | |
| try: | |
| google_matches = find_client_matches_in_google(name, 1) | |
| if google_matches: | |
| return load_client_from_google_record(google_matches[0]["id"]) | |
| except Exception: | |
| pass | |
| path = CLIENTS_DIR / client_key(name) / "latest.json" | |
| if path.exists(): | |
| payload = json.loads(path.read_text(encoding="utf-8")) | |
| return payload.get("inputs", {}) | |
| newest_match = None | |
| newest_mtime = 0.0 | |
| for latest_path in CLIENTS_DIR.glob("*/latest.json"): | |
| try: | |
| payload = json.loads(latest_path.read_text(encoding="utf-8")) | |
| except (OSError, json.JSONDecodeError): | |
| continue | |
| inputs = payload.get("inputs", {}) | |
| names = [ | |
| str(inputs.get("c_name1") or ""), | |
| str(inputs.get("c_name2") or ""), | |
| str(inputs.get("c_name3") or ""), | |
| str(inputs.get("client_name") or ""), | |
| ] | |
| if any(item.strip().casefold() == query for item in names): | |
| mtime = latest_path.stat().st_mtime | |
| if mtime >= newest_mtime: | |
| newest_match = inputs | |
| newest_mtime = mtime | |
| return newest_match | |
| def find_soffice() -> str: | |
| for name in ("soffice", "libreoffice"): | |
| path = shutil.which(name) | |
| if path: | |
| return path | |
| raise RuntimeError("LibreOffice/soffice was not found.") | |
| def recalc_with_libreoffice(source_file: Path, output_dir: Path, output_format: str = "xlsx") -> Path: | |
| soffice = find_soffice() | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| completed = subprocess.run( | |
| [ | |
| soffice, | |
| "--headless", | |
| "--convert-to", | |
| output_format, | |
| "--outdir", | |
| str(output_dir), | |
| str(source_file), | |
| ], | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| text=True, | |
| timeout=180, | |
| ) | |
| if completed.returncode != 0: | |
| raise RuntimeError(completed.stderr or completed.stdout) | |
| output_file = output_dir / source_file.with_suffix(f".{output_format}").name | |
| if not output_file.exists(): | |
| raise RuntimeError(f"LibreOffice did not create output file. {completed.stdout or completed.stderr}") | |
| return output_file | |
| def recalc_many_with_libreoffice(source_files: list[Path], output_dir: Path, output_format: str = "xlsx") -> dict[Path, Path]: | |
| if not source_files: | |
| return {} | |
| soffice = find_soffice() | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| completed = subprocess.run( | |
| [ | |
| soffice, | |
| "--headless", | |
| "--convert-to", | |
| output_format, | |
| "--outdir", | |
| str(output_dir), | |
| *[str(path) for path in source_files], | |
| ], | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| text=True, | |
| timeout=max(180, 60 * len(source_files)), | |
| ) | |
| if completed.returncode != 0: | |
| raise RuntimeError(completed.stderr or completed.stdout) | |
| outputs = {} | |
| for source_file in source_files: | |
| output_file = output_dir / source_file.with_suffix(f".{output_format}").name | |
| if output_file.exists(): | |
| outputs[source_file] = output_file | |
| missing = [path.name for path in source_files if path not in outputs] | |
| if missing: | |
| raise RuntimeError(f"LibreOffice did not create output files for: {', '.join(missing)}") | |
| return outputs | |
| def variable_to_dashboard_cell(master_path: Path) -> dict: | |
| wb = load_workbook(master_path, data_only=True, keep_vba=True) | |
| ws = wb["Mapping"] | |
| mapping = {} | |
| for row in range(2, ws.max_row + 1): | |
| key = ws[f"E{row}"].value | |
| cell = ws[f"F{row}"].value | |
| if key and cell: | |
| mapping[str(key)] = str(cell) | |
| return mapping | |
| MASTER_TEST_RATE_SCAN_COLUMNS = ("BN", "BO", "BP", "BQ") | |
| def normalized_bank_label(value) -> str: | |
| text = str(value or "").strip().lower() | |
| text = re.sub(r"[^a-z0-9]+", "", text) | |
| aliases = { | |
| "coop": "cooperative", | |
| "cooperative": "cooperative", | |
| "thecooperativebank": "cooperative", | |
| "pepper": "peppermoney", | |
| "peppermoney": "peppermoney", | |
| "bankofchina": "boc", | |
| } | |
| return aliases.get(text, text) | |
| def write_dashboard_test_rates(ws, test_rates: dict | None) -> int: | |
| if not isinstance(test_rates, dict) or not test_rates: | |
| return 0 | |
| rates_by_bank = {} | |
| for bank, rate in test_rates.items(): | |
| parsed_rate = parse_number(rate) | |
| if parsed_rate is None: | |
| continue | |
| rates_by_bank[normalized_bank_label(bank)] = parsed_rate | |
| if not rates_by_bank: | |
| return 0 | |
| written = 0 | |
| for row in range(1, ws.max_row + 1): | |
| for column in MASTER_TEST_RATE_SCAN_COLUMNS: | |
| cell = ws[f"{column}{row}"] | |
| bank_key = normalized_bank_label(cell.value) | |
| if bank_key not in rates_by_bank: | |
| continue | |
| target = ws.cell(row=row, column=cell.column + 1) | |
| target.value = rates_by_bank[bank_key] | |
| written += 1 | |
| return written | |
| def write_dashboard_inputs(master_path: Path, inputs: dict) -> None: | |
| wb = load_workbook(master_path, keep_vba=True) | |
| ws = wb["Dashboard"] | |
| mapping = variable_to_dashboard_cell(master_path) | |
| for key, value in inputs.items(): | |
| cell = mapping.get(key) | |
| if not cell or value is None or value == "": | |
| continue | |
| ws[cell] = value | |
| student_loan_repayments = [ | |
| ("sl_r1", "sl_f1"), | |
| ("sl_r2", "sl_f2"), | |
| ("sl_r3_amt", "sl_f3"), | |
| ] | |
| for amount_key, frequency_key in student_loan_repayments: | |
| cell = mapping.get(amount_key) | |
| if cell and inputs.get(amount_key) not in (None, ""): | |
| ws[cell] = monthlyize_engine(inputs.get(amount_key), inputs.get(frequency_key) or "Monthly") | |
| frequency_cell = mapping.get("sl_r3") | |
| if frequency_cell: | |
| ws[frequency_cell] = "Monthly" | |
| deposit_value = parse_number(inputs.get("dep_val")) | |
| if deposit_value is None or deposit_value == 0: | |
| property_value = parse_number(inputs.get("prop_val")) or 0 | |
| loan_amount = parse_number(inputs.get("loan_amt")) or 0 | |
| deposit_value = max(property_value - loan_amount, 0) if property_value and loan_amount else 0 | |
| ws["AY36"] = deposit_value | |
| for cell, formula in REQUIRED_DASHBOARD_FORMULAS.items(): | |
| ws[cell] = formula | |
| write_dashboard_test_rates(ws, inputs.get("_test_rates")) | |
| force_workbook_recalculation(wb) | |
| wb.save(master_path) | |
| def build_recalculated_master(job_dir: Path, inputs: dict, timestamp: str) -> tuple[Path, Path]: | |
| master_label = safe_filename_part(f"{MASTER_TEMPLATE.stem}_{client_names_label(inputs)}_{timestamp}") | |
| working_master = job_dir / f"{master_label}.xlsm" | |
| shutil.copy2(MASTER_TEMPLATE, working_master) | |
| write_dashboard_inputs(working_master, inputs) | |
| recalculated_master = recalc_with_libreoffice(working_master, job_dir / "master_recalculated", "xlsx") | |
| return working_master, recalculated_master | |
| def should_write_mapping_value(value) -> bool: | |
| if value is None: | |
| return False | |
| if isinstance(value, str) and value.strip() == "": | |
| return False | |
| return True | |
| def force_workbook_recalculation(workbook) -> None: | |
| try: | |
| workbook.calculation.fullCalcOnLoad = True | |
| workbook.calculation.forceFullCalc = True | |
| workbook.calculation.calcMode = "auto" | |
| except AttributeError: | |
| pass | |
| ASB_OPTIONAL_ZERO_CELLS = { | |
| # Optional income fields. ASB formulas often test for blank cells; writing 0 | |
| # makes lookup formulas run with blank type/frequency cells and can produce #N/A. | |
| "C36", "C39", "C42", "C45", "C50", "C53", | |
| "F32", "F36", "F39", "F42", "F45", "F50", "F53", | |
| "I32", "I36", "I39", "I45", "I50", "I53", | |
| # Optional liability/expense fields where the bank template treats blanks as absent. | |
| "F78", "F82", "F92", "F93", "F106", "F108", "F110", "F112", "F114", | |
| "F118", "F120", "F122", "F124", "F130", "F132", "F134", "F136", | |
| } | |
| ASB_OPTIONAL_ZERO_SHEET_CELLS = { | |
| ("LoanDetails", "B10"), ("LoanDetails", "C10"), ("LoanDetails", "D10"), ("LoanDetails", "E10"), | |
| ("LoanDetails", "F10"), ("LoanDetails", "H10"), ("LoanDetails", "I10"), | |
| ("LoanDetails", "B11"), ("LoanDetails", "C11"), ("LoanDetails", "D11"), ("LoanDetails", "E11"), | |
| ("LoanDetails", "F11"), ("LoanDetails", "H11"), ("LoanDetails", "I11"), | |
| ("LoanDetails", "B12"), ("LoanDetails", "C12"), ("LoanDetails", "D12"), ("LoanDetails", "E12"), | |
| ("LoanDetails", "F12"), ("LoanDetails", "G12"), | |
| ("Buy Now Pay Later", "B15"), ("Buy Now Pay Later", "C15"), ("Buy Now Pay Later", "D15"), | |
| } | |
| def should_write_bank_mapping_value(bank: str, sheet_name: str, target_cell: str, value) -> bool: | |
| if not should_write_mapping_value(value): | |
| return False | |
| if bank == "ASB" and parse_number(value) == 0: | |
| cell = str(target_cell).upper() | |
| sheet = str(sheet_name) | |
| if sheet == "Calc" and cell in ASB_OPTIONAL_ZERO_CELLS: | |
| return False | |
| if (sheet, cell) in ASB_OPTIONAL_ZERO_SHEET_CELLS: | |
| return False | |
| if bank == "Westpac" and str(sheet_name) == "Assess Serviceability calc" and str(target_cell).upper() == "I7": | |
| if parse_number(value) == 0: | |
| return False | |
| return True | |
| def patch_boc_formula_guards(workbook) -> None: | |
| guarded_pmt = '=IF(OR($E$5<=0,$E$9<=0,$B$20<=0),0,-PMT($B$20/100/12,$E$9*$E$7,$E$5,,0))' | |
| for sheet_name in [f"Loan {idx:02d}" for idx in range(1, 9)]: | |
| if sheet_name in workbook.sheetnames: | |
| workbook[sheet_name]["D20"] = guarded_pmt | |
| def clear_cells_if_amount_blank(sheet, amount_cell: str, cells: list[str]) -> None: | |
| if is_blank_or_zero(sheet[amount_cell].value): | |
| for cell in cells: | |
| if not isinstance(sheet[cell], MergedCell): | |
| sheet[cell] = None | |
| def clear_cells(sheet, cells: list[str]) -> None: | |
| for cell in cells: | |
| if not isinstance(sheet[cell], MergedCell): | |
| sheet[cell] = None | |
| def monthlyize_engine(amount, frequency: str) -> float: | |
| number = parse_number(amount) or 0 | |
| freq = str(frequency or "").strip().lower() | |
| if freq.startswith("week"): | |
| return number * 52 / 12 | |
| if freq.startswith("fort"): | |
| return number * 26 / 12 | |
| if freq.startswith("annual") or freq.startswith("year"): | |
| return number / 12 | |
| return number | |
| def asb_frequency_value(amount, frequency: str) -> tuple[float, str]: | |
| freq = str(frequency or "").strip() | |
| if freq in {"Weekly", "Fortnightly", "Monthly", "Quarterly"}: | |
| return parse_number(amount) or 0, freq | |
| return monthlyize_engine(amount, freq), "Monthly" | |
| def lender_cells(lender: str) -> tuple[str, str]: | |
| lender_name = str(lender or "").strip() | |
| if lender_name == "ASB": | |
| return "Yes", "" | |
| return "No", lender_name | |
| def has_item_value(item: dict, amount_keys: tuple[str, ...]) -> bool: | |
| return bool(str(item.get("lender") or item.get("issuer") or item.get("facility") or "").strip()) or any( | |
| parse_number(item.get(key)) not in (None, 0) for key in amount_keys | |
| ) | |
| def patch_asb_itemized_liabilities(workbook, inputs: dict) -> None: | |
| if "LoanDetails" in workbook.sheetnames: | |
| ws = workbook["LoanDetails"] | |
| for row in range(10, 51): | |
| for col in "BCDEFGHIJK": | |
| if not isinstance(ws[f"{col}{row}"], MergedCell): | |
| ws[f"{col}{row}"] = None | |
| loan_rows = [] | |
| for item in inputs.get("_mortgage_items") or []: | |
| if not isinstance(item, dict) or not has_item_value(item, ("balance", "repayment")): | |
| continue | |
| repayment, frequency = asb_frequency_value(item.get("repayment"), item.get("frequency")) | |
| asb_facility, other_lender = lender_cells(item.get("lender")) | |
| loan_rows.append({ | |
| "loan_type": "Home Loan", | |
| "revolving": "No", | |
| "asb_facility": asb_facility, | |
| "other_lender": other_lender, | |
| "balance": parse_number(item.get("balance")) or 0, | |
| "limit": None, | |
| "repayment": repayment, | |
| "frequency": frequency, | |
| "term": parse_number(item.get("term")) or None, | |
| }) | |
| for item in inputs.get("_personal_items") or []: | |
| if not isinstance(item, dict) or not has_item_value(item, ("balance", "repayment")): | |
| continue | |
| repayment, frequency = asb_frequency_value(item.get("repayment"), item.get("frequency")) | |
| asb_facility, other_lender = lender_cells(item.get("lender")) | |
| loan_rows.append({ | |
| "loan_type": "Personal Loan", | |
| "revolving": "No", | |
| "asb_facility": asb_facility, | |
| "other_lender": other_lender, | |
| "balance": parse_number(item.get("balance")) or 0, | |
| "limit": None, | |
| "repayment": repayment, | |
| "frequency": frequency, | |
| "term": parse_number(item.get("term")) or None, | |
| }) | |
| for item in inputs.get("_credit_items") or []: | |
| if not isinstance(item, dict) or not has_item_value(item, ("balance", "limit")): | |
| continue | |
| liability_type = str(item.get("type") or "Credit Card") | |
| asb_facility, other_lender = lender_cells(item.get("issuer")) | |
| if liability_type == "Overdraft / Revolving": | |
| loan_type = "Home Loan" | |
| revolving = "Yes" | |
| else: | |
| loan_type = "Credit Card" | |
| revolving = None | |
| loan_rows.append({ | |
| "loan_type": loan_type, | |
| "revolving": revolving, | |
| "asb_facility": asb_facility, | |
| "other_lender": other_lender, | |
| "balance": parse_number(item.get("balance")) or 0, | |
| "limit": parse_number(item.get("limit")) or 0, | |
| "repayment": None, | |
| "frequency": None, | |
| "term": None, | |
| }) | |
| for offset, item in enumerate(loan_rows[:41]): | |
| row = 10 + offset | |
| ws[f"B{row}"] = item["loan_type"] | |
| if item.get("revolving") is not None: | |
| ws[f"C{row}"] = item["revolving"] | |
| ws[f"D{row}"] = item["asb_facility"] | |
| ws[f"E{row}"] = item["other_lender"] | |
| if item.get("balance") not in (None, 0): | |
| ws[f"F{row}"] = item["balance"] | |
| if item.get("limit") not in (None, 0): | |
| ws[f"G{row}"] = item["limit"] | |
| if item.get("repayment") not in (None, 0): | |
| ws[f"H{row}"] = item["repayment"] | |
| if item.get("frequency"): | |
| ws[f"I{row}"] = item["frequency"] | |
| if item.get("term") not in (None, 0): | |
| ws[f"J{row}"] = item["term"] | |
| if "Buy Now Pay Later" in workbook.sheetnames: | |
| ws = workbook["Buy Now Pay Later"] | |
| for row in range(15, 56): | |
| for col in "BCDE": | |
| if not isinstance(ws[f"{col}{row}"], MergedCell): | |
| ws[f"{col}{row}"] = None | |
| for offset, item in enumerate((inputs.get("_bnpl_items") or [])[:41]): | |
| if not isinstance(item, dict) or not has_item_value(item, ("limit", "balance", "repayment")): | |
| continue | |
| row = 15 + offset | |
| ws[f"B{row}"] = item.get("facility") or None | |
| limit = parse_number(item.get("limit")) or 0 | |
| if limit: | |
| ws[f"C{row}"] = limit | |
| ws[f"D{row}"] = item.get("category") or "Household" | |
| def patch_westpac_blank_optional_rows(workbook) -> None: | |
| sheet_name = "Assess Serviceability calc" | |
| if sheet_name not in workbook.sheetnames: | |
| return | |
| ws = workbook[sheet_name] | |
| if is_blank_or_zero(ws["I7"].value): | |
| clear_cells(ws, ["I7"]) | |
| for row in list(range(3, 20)) + list(range(21, 24)) + list(range(25, 43)): | |
| clear_cells_if_amount_blank(ws, f"Q{row}", [f"Q{row}", f"R{row}", f"S{row}"]) | |
| if "Workings" not in workbook.sheetnames: | |
| return | |
| workings = workbook["Workings"] | |
| for row in list(range(166, 174)) + list(range(176, 184)): | |
| workings[f"H{row}"] = f'=IF(OR(E{row}="",E{row}=0,F{row}=""),0,E{row}*VLOOKUP(F{row},$B$21:$D$27,2,FALSE))' | |
| workings[f"I{row}"] = f'=IF(E{row}=0,0,IF($G{row}="Gross",$H{row},IFERROR($H{row}/VLOOKUP(H$174,$B$293:$G$24294,6),0)))' | |
| workings[f"J{row}"] = f'=IF(E{row}=0,0,IF($G{row}="Net",$H{row},IFERROR($H{row}*VLOOKUP(H$174,$F$293:$G$24294,2),0)))' | |
| workings[f"K{row}"] = f'=IF(OR(G{row}=0,G{row}=""),0,IFERROR(J{row}*VLOOKUP($B{row},$B$66:$C$76,2,FALSE)/12,0))' | |
| for row in range(186, 189): | |
| workings[f"H{row}"] = f'=IF(OR(E{row}="",E{row}=0,F{row}=""),0,E{row}*VLOOKUP(F{row},$B$21:$D$27,2,FALSE))' | |
| workings[f"I{row}"] = f'=IF(E{row}=0,0,H{row}/12)' | |
| workings[f"J{row}"] = f'=IF(E{row}=0,0,IFERROR(H{row}*VLOOKUP(B{row},B$66:C$75,2,FALSE),0))' | |
| workings[f"K{row}"] = f'=IF(E{row}=0,0,J{row}/12)' | |
| for row in list(range(191, 198)) + list(range(200, 206)) + list(range(208, 211)): | |
| workings[f"H{row}"] = f'=IF(OR(F{row}="",F{row}=0,G{row}=""),0,F{row}*VLOOKUP(G{row},$B$21:$D$27,2,FALSE)/12)' | |
| def patch_asb_blank_optional_rows(workbook) -> None: | |
| if "Calc" in workbook.sheetnames: | |
| ws = workbook["Calc"] | |
| for col in ("C", "F", "I", "L"): | |
| for start_row in (34, 37, 40, 43): | |
| clear_cells_if_amount_blank( | |
| ws, | |
| f"{col}{start_row + 2}", | |
| [f"{col}{start_row}", f"{col}{start_row + 1}", f"{col}{start_row + 2}"], | |
| ) | |
| if "LoanDetails" in workbook.sheetnames: | |
| ws = workbook["LoanDetails"] | |
| for row in (10, 11, 12): | |
| numeric_cells = [f"F{row}", f"G{row}", f"H{row}"] | |
| if all(is_blank_or_zero(ws[cell].value) for cell in numeric_cells): | |
| for col in "BCDEFGHIJKL": | |
| ws[f"{col}{row}"] = None | |
| if "Buy Now Pay Later" in workbook.sheetnames: | |
| ws = workbook["Buy Now Pay Later"] | |
| if is_blank_or_zero(ws["C15"].value): | |
| for cell in ("B15", "C15", "D15"): | |
| ws[cell] = None | |
| def patch_bnz_blank_optional_rows(workbook) -> None: | |
| sheet_name = "Affordability Calculator" | |
| if sheet_name not in workbook.sheetnames: | |
| return | |
| ws = workbook[sheet_name] | |
| for row in (32, 34, 40, 44, 46, 53, 59, 60): | |
| clear_cells_if_amount_blank(ws, f"G{row}", [f"B{row}", f"E{row}", f"G{row}", f"H{row}"]) | |
| for row in (80, 90): | |
| balance_cell = "B80" if row == 80 else "C90" | |
| if is_blank_or_zero(ws[balance_cell].value): | |
| for col in "BCDEFGIJK": | |
| cell = f"{col}{row}" | |
| if not isinstance(ws[cell], MergedCell): | |
| ws[cell] = None | |
| for row in range(99, 109): | |
| clear_cells_if_amount_blank(ws, f"F{row}", [f"B{row}", f"F{row}", f"G{row}"]) | |
| for row in range(109, 115): | |
| if is_blank_or_zero(ws[f"E{row}"].value): | |
| for col in "EFG": | |
| cell = f"{col}{row}" | |
| if not isinstance(ws[cell], MergedCell): | |
| ws[cell] = None | |
| def patch_sbs_blank_optional_rows(workbook) -> None: | |
| sheet_name = "NSR Calculator" | |
| if sheet_name not in workbook.sheetnames: | |
| return | |
| ws = workbook[sheet_name] | |
| for cell in ("M23", "R23"): | |
| if is_blank_or_zero(ws[cell].value): | |
| clear_cells(ws, [cell]) | |
| income_groups = [ | |
| ("D", "F"), | |
| ("I", "K"), | |
| ("N", "P"), | |
| ("S", None), | |
| ] | |
| for amount_col, ks_col in income_groups: | |
| for row in (27, 30, 33, 36, 39, 42, 45, 48, 50, 52): | |
| cells = [f"{amount_col}{row}"] | |
| if ks_col: | |
| cells.append(f"{ks_col}{row}") | |
| clear_cells_if_amount_blank(ws, f"{amount_col}{row}", cells) | |
| for row in (72, 77, 82, 88, 98, 107, 116, 125, 127): | |
| for col in ("C", "D", "I", "M", "N"): | |
| cell = f"{col}{row}" | |
| if cell in ws and is_blank_or_zero(ws[cell].value): | |
| clear_cells(ws, [cell]) | |
| def patch_unity_blank_optional_rows(workbook) -> None: | |
| sheet_name = "Calculator" | |
| if sheet_name not in workbook.sheetnames: | |
| return | |
| ws = workbook[sheet_name] | |
| if is_new_unity_layout(workbook): | |
| for row in (35, 36, 37, 38, 39, 40, 41): | |
| for amount_col, freq_col in (("G", "I"), ("H", "I"), ("J", "L"), ("K", "L")): | |
| clear_cells_if_amount_blank(ws, f"{amount_col}{row}", [f"{amount_col}{row}"]) | |
| for freq_col in ("I", "L"): | |
| if is_blank_or_zero(ws[f"{freq_col}{row}"].value): | |
| clear_cells(ws, [f"{freq_col}{row}"]) | |
| for row in range(58, 73): | |
| clear_cells_if_amount_blank(ws, f"H{row}", [f"H{row}", f"I{row}"]) | |
| for row in (85, 86, 87, 88): | |
| clear_cells_if_amount_blank(ws, f"H{row}", [f"H{row}"]) | |
| for row in (89, 90, 91, 92): | |
| clear_cells_if_amount_blank(ws, f"H{row}", [f"H{row}", f"I{row}", f"J{row}"]) | |
| return | |
| for income_col, helper_cols in (("H", ["H"]), ("I", ["I"])): | |
| clear_cells_if_amount_blank(ws, f"{income_col}34", [f"{income_col}34", f"{income_col}35", f"{income_col}36"]) | |
| for row in range(45, 50): | |
| clear_cells_if_amount_blank(ws, f"H{row}", [f"H{row}"]) | |
| for row in range(59, 74): | |
| clear_cells_if_amount_blank(ws, f"H{row}", [f"H{row}", f"I{row}"]) | |
| ws[f"J{row}"] = f'=IF(OR(H{row}="",H{row}=0,I{row}=""),0,H{row}*VLOOKUP(I{row},Lookups!$A$43:$B$47,2,0)/12)' | |
| for row in range(87, 95): | |
| clear_cells_if_amount_blank(ws, f"H{row}", [f"H{row}", f"I{row}"]) | |
| def is_new_unity_layout(workbook) -> bool: | |
| if "Calculator" not in workbook.sheetnames: | |
| return False | |
| ws = workbook["Calculator"] | |
| marker = str(ws["I101"].value or ws["J101"].value or "") | |
| status_formula = str(ws["J99"].value or "") | |
| return "2026" in marker or "J98" in status_formula or str(ws["C57"].value or "").strip().upper() == "LIVING EXPENSES" | |
| def apply_bank_mapping(recalculated_master: Path, bank: str, template_path: Path, output_path: Path, inputs: dict | None = None) -> int: | |
| sheet_col, cell_col, value_col = BANK_CONFIGS[bank]["block"] | |
| master = load_workbook(recalculated_master, data_only=True) | |
| dashboard = master["Dashboard"] | |
| target = load_workbook(template_path, keep_vba=template_path.suffix.lower() == ".xlsm") | |
| applied = 0 | |
| for row in range(26, dashboard.max_row + 1): | |
| sheet_name = dashboard[f"{sheet_col}{row}"].value | |
| target_cell = dashboard[f"{cell_col}{row}"].value | |
| value = dashboard[f"{value_col}{row}"].value | |
| if not sheet_name or not target_cell or str(target_cell).startswith("="): | |
| continue | |
| if not should_write_bank_mapping_value(bank, str(sheet_name), str(target_cell), value): | |
| continue | |
| if sheet_name not in target.sheetnames: | |
| continue | |
| try: | |
| target[str(sheet_name)][str(target_cell)] = value | |
| applied += 1 | |
| except Exception: | |
| continue | |
| if bank == "BOC": | |
| patch_boc_formula_guards(target) | |
| if bank == "Westpac": | |
| patch_westpac_blank_optional_rows(target) | |
| if bank == "ASB": | |
| patch_asb_itemized_liabilities(target, inputs or {}) | |
| patch_asb_blank_optional_rows(target) | |
| if bank == "BNZ": | |
| patch_bnz_blank_optional_rows(target) | |
| if bank == "SBS": | |
| patch_sbs_blank_optional_rows(target) | |
| if bank == "Unity": | |
| patch_unity_blank_optional_rows(target) | |
| force_workbook_recalculation(target) | |
| target.save(output_path) | |
| return applied | |
| def normalize_excel_value(value): | |
| if isinstance(value, str) and value.strip().upper() in ERROR_VALUES: | |
| return value.strip().upper() | |
| return value | |
| def get_sheet(workbook, sheet_ref): | |
| if isinstance(sheet_ref, int): | |
| return workbook.worksheets[sheet_ref] | |
| return workbook[str(sheet_ref)] | |
| def compare_values(left, op: str, right) -> bool: | |
| left_num = parse_number(left) | |
| right_num = parse_number(right) | |
| if not isinstance(left_num, (int, float)) or not isinstance(right_num, (int, float)): | |
| return False | |
| if op == ">=": | |
| return left_num >= right_num | |
| if op == "<=": | |
| return left_num <= right_num | |
| if op == ">": | |
| return left_num > right_num | |
| if op == "<": | |
| return left_num < right_num | |
| if op == "=": | |
| return left_num == right_num | |
| return False | |
| def resolve_rule_value(rule_ref, bank_wb, master_wb): | |
| source, sheet_ref, cell = rule_ref | |
| wb = master_wb if source == "master" else bank_wb | |
| return get_sheet(wb, sheet_ref)[cell].value | |
| def normalize_rate_percent(value): | |
| normalized = normalize_excel_value(value) | |
| number = parse_number(normalized) | |
| if isinstance(number, (int, float)): | |
| return number * 100 if 0 < number < 1 else number | |
| return normalized | |
| def read_test_rate(bank: str, bank_wb): | |
| sheet_name, cell = TEST_RATE_CELLS.get(bank, (None, None)) | |
| if not sheet_name or not cell: | |
| return None | |
| try: | |
| sheet = get_sheet(bank_wb, sheet_name) | |
| except Exception: | |
| return None | |
| return normalize_rate_percent(sheet[cell].value) | |
| def status_for_bank(bank: str, value, bank_wb, master_wb) -> str: | |
| config = BANK_CONFIGS[bank] | |
| status_cell = config.get("status_cell") | |
| if status_cell: | |
| status_value = get_sheet(bank_wb, config["result_sheet"])[status_cell].value | |
| text = str(status_value or "").strip().upper() | |
| if "PASS" in text: | |
| return "PASS" | |
| if "FAIL" in text: | |
| return "FAIL" | |
| rule = config.get("status_rule", {}) | |
| if rule.get("type") == "threshold": | |
| return "PASS" if compare_values(value, rule["op"], rule["value"]) else "FAIL" | |
| if rule.get("type") == "cell_threshold": | |
| cell_value = get_sheet(bank_wb, config["result_sheet"])[rule["cell"]].value | |
| return "PASS" if compare_values(cell_value, rule["op"], rule["value"]) else "FAIL" | |
| if rule.get("type") == "compare_cells": | |
| left = resolve_rule_value(rule["left"], bank_wb, master_wb) | |
| right = resolve_rule_value(rule["right"], bank_wb, master_wb) | |
| return "PASS" if compare_values(left, rule["op"], right) else "FAIL" | |
| return "FAIL" | |
| def policy_number(value) -> float | None: | |
| parsed = parse_number(value) | |
| return float(parsed) if isinstance(parsed, (int, float)) else None | |
| FREQ_TO_ANNUAL = {"Weekly": 52, "Fortnightly": 26, "Monthly": 12, "Annually": 1} | |
| def annualized_policy_amount(inputs: dict, amount_key: str, freq_key: str | None = None, *, default_annual: bool = False) -> float: | |
| amount = policy_number(inputs.get(amount_key)) or 0.0 | |
| if default_annual: | |
| return amount | |
| frequency = str(inputs.get(freq_key or "") or "Annually") | |
| return amount * FREQ_TO_ANNUAL.get(frequency, 1) | |
| def income_mix(inputs: dict) -> dict: | |
| applicant_fields = [ | |
| ("_1", "f1", "f2", "f4", "f5", "f6"), | |
| ("_a2", "f1_a2", "f2_a2", "f4_a2", "f5_a2", "f6_a2"), | |
| ("_a3", "f1_a3", "f2_a3", "f4_a3", "f5_a3", "f6_a3"), | |
| ] | |
| total = 0.0 | |
| government = 0.0 | |
| for suffix, salary_freq, overtime_freq, government_freq, boarder_freq, rental_freq in applicant_fields: | |
| total += annualized_policy_amount(inputs, f"sal{suffix}", salary_freq) | |
| total += annualized_policy_amount(inputs, f"ovt{suffix}", overtime_freq) | |
| total += annualized_policy_amount(inputs, f"biz{suffix}", default_annual=True) | |
| gov = annualized_policy_amount(inputs, f"gov{suffix}", government_freq) | |
| government += gov | |
| total += gov | |
| total += annualized_policy_amount(inputs, f"brd{suffix}", boarder_freq) | |
| total += annualized_policy_amount(inputs, f"rnt{suffix}", rental_freq) | |
| return { | |
| "total_annual_income": total, | |
| "government_annual_income": government, | |
| "government_is_majority": government > 0 and total > 0 and government / total >= 0.5, | |
| } | |
| def normalize_deal_type(value: str, legacy_property_type: str = "Existing") -> str: | |
| text = str(value or "").strip() | |
| if not text: | |
| return { | |
| "New Build": "Construction", | |
| "Apt": "Apartment", | |
| }.get(legacy_property_type, "Existing property") | |
| return { | |
| "Not selected yet - pre-approval": "Pre-approval - property not selected", | |
| "Purchase - Existing home": "Existing property", | |
| "Purchase - New build / turnkey": "Off-plan turnkey", | |
| "Completed new build - waiting for CCC": "Completed turnkey", | |
| "Completed new build - CCC issued": "Completed turnkey", | |
| "Completed new build - waiting CCC/title": "Completed turnkey", | |
| "Completed new build - CCC/title issued": "Completed turnkey", | |
| "Construction loan": "Construction", | |
| "Refinance - no extra borrowing": "Dollar-for-dollar refinance", | |
| "Refinance - extra borrowing or debt consolidation": "Refinance with cash-out / debt consolidation", | |
| "Labour-only construction": "Construction", | |
| }.get(text, text) | |
| def policy_context(inputs: dict) -> dict: | |
| loan_amount = policy_number(inputs.get("loan_amt") or inputs.get("loan_amount")) or 0.0 | |
| property_value = policy_number(inputs.get("prop_val") or inputs.get("property_value")) or 0.0 | |
| lvr = policy_number(inputs.get("lvr")) | |
| if lvr is None: | |
| lvr = loan_amount / property_value * 100 if property_value else 0.0 | |
| legacy_property_type = str(inputs.get("prop_type") or inputs.get("property_type") or "Existing") | |
| purpose = str(inputs.get("loan_purpose") or "").strip() | |
| if not purpose: | |
| purpose = "Investor" if legacy_property_type == "Investment" else "Owner Occupied" | |
| deal_type = normalize_deal_type(str(inputs.get("deal_type") or "").strip(), legacy_property_type) | |
| relationship = str(inputs.get("existing_bank_relationship") or "None").strip() | |
| existing_banks = set() | |
| if relationship in {"ASB", "BNZ"}: | |
| existing_banks.add(relationship) | |
| elif relationship == "Both": | |
| existing_banks.update({"ASB", "BNZ"}) | |
| mix = income_mix(inputs) | |
| return { | |
| "lvr": lvr, | |
| "purpose": purpose, | |
| "deal_type": deal_type, | |
| "existing_banks": existing_banks, | |
| "pre_approval": bool(inputs.get("pre_approval")), | |
| "kainga_ora": str(inputs.get("fhl_YN") or inputs.get("fhl") or "No").strip().lower() in {"yes", "y", "true", "1"}, | |
| **mix, | |
| } | |
| def set_policy_result( | |
| result: dict, | |
| *, | |
| status: str, | |
| metric: str, | |
| value, | |
| threshold: str, | |
| rule: str, | |
| note: str = "", | |
| detail: str = "", | |
| ) -> dict: | |
| result.update({ | |
| "status": status, | |
| "metric": metric, | |
| "value": value, | |
| "criteria_threshold": threshold, | |
| "criteria_rule": rule, | |
| "criteria_note": note, | |
| "criteria_detail": detail, | |
| }) | |
| return result | |
| def policy_value_label(metric: str, value) -> str: | |
| if value is None: | |
| return "not available" | |
| if metric in {"UMI", "MBS", "SI"}: | |
| return f"${value:,.0f}" if metric in {"UMI", "MBS"} else f"{value:,.0f} SI" | |
| return f"{value:.3f}" | |
| def threshold_result_detail(metric: str, value, op: str, threshold: float, *, label: str | None = None) -> str: | |
| name = label or metric | |
| if value is None: | |
| return f"{name} result was not available, so this servicing check could not be verified." | |
| passed = compare_values(value, op, threshold) | |
| threshold_label = policy_value_label(metric, threshold) | |
| value_label = policy_value_label(metric, value) | |
| is_money = metric in {"UMI", "MBS"} | |
| if op == ">=": | |
| if passed: | |
| buffer = value - threshold | |
| extra = f" with ${buffer:,.0f} buffer" if is_money and buffer > 0 else "" | |
| return f"Current {name} is {value_label}, meeting the required minimum {threshold_label}{extra}." | |
| shortfall = threshold - value | |
| gap = f"${shortfall:,.0f}" if is_money else f"{shortfall:.3f}" | |
| return f"Current {name} is {value_label}, which is {gap} below the required minimum {threshold_label}." | |
| if op == "<=": | |
| if passed: | |
| return f"Current {name} is {value_label}, within the allowed maximum {threshold_label}." | |
| excess = value - threshold | |
| gap = f"${excess:,.0f}" if is_money else f"{excess:.3f}" | |
| return f"Current {name} is {value_label}, which is {gap} above the allowed maximum {threshold_label}." | |
| if op == "<": | |
| if passed: | |
| return f"Current {name} is {value_label}, meeting the requirement to be below {threshold_label}." | |
| excess = value - threshold | |
| gap = f"${excess:,.0f}" if is_money else f"{excess:.3f}" | |
| return f"Current {name} is {value_label}, which is {gap} above the required level below {threshold_label}." | |
| if op == ">": | |
| if passed: | |
| return f"Current {name} is {value_label}, meeting the requirement to be above {threshold_label}." | |
| shortfall = threshold - value | |
| gap = f"${shortfall:,.0f}" if is_money else f"{shortfall:.3f}" | |
| return f"Current {name} is {value_label}, which is {gap} below the required level above {threshold_label}." | |
| if passed: | |
| return f"Current {name} is {value_label}, meeting the required value {threshold_label}." | |
| return f"Current {name} is {value_label}, but the required value is {threshold_label}." | |
| def minimum_result_detail(metric: str, value, minimum: float, *, label: str | None = None) -> str: | |
| return threshold_result_detail(metric, value, ">=", minimum, label=label) | |
| def generic_criteria_detail(bank: str, result: dict) -> str: | |
| config = BANK_CONFIGS.get(bank, {}) | |
| status = str(result.get("status") or "FAIL").upper() | |
| metric = result.get("metric", config.get("metric", "Result")) | |
| value = policy_number(result.get("value")) | |
| rule = config.get("status_rule", {}) | |
| if config.get("status_cell"): | |
| return ( | |
| f"The {bank} calculator returned {status}. " | |
| "This lender uses its own calculator status, so check the generated workbook if you need the detailed internal breakdown." | |
| ) | |
| if rule.get("type") == "threshold": | |
| return threshold_result_detail(metric, value, rule.get("op", ">="), float(rule.get("value", 0))) | |
| if rule.get("type") == "cell_threshold": | |
| check_value = policy_number(result.get("status_check_value")) | |
| check_metric = "calculator ratio" | |
| return threshold_result_detail(check_metric, check_value, rule.get("op", "<="), float(rule.get("value", 0))) | |
| if rule.get("type") == "compare_cells": | |
| left = policy_number(result.get("status_check_left")) | |
| right = policy_number(result.get("status_check_right")) | |
| if left is None or right is None: | |
| return f"The {bank} calculator could not provide enough values to explain the servicing result." | |
| if compare_values(left, rule.get("op", "<="), right): | |
| buffer = right - left | |
| return f"The {bank} calculator shows available servicing of ${right:,.0f} against required servicing of ${left:,.0f}, leaving ${buffer:,.0f} buffer." | |
| shortfall = left - right | |
| return f"The {bank} calculator shows required servicing of ${left:,.0f} but available servicing of ${right:,.0f}, leaving a ${shortfall:,.0f} shortfall." | |
| if status == "PASS": | |
| return f"The {bank} calculator result meets the servicing requirement." | |
| return f"The {bank} calculator result does not meet the servicing requirement." | |
| def evaluate_asb_criteria(result: dict, context: dict) -> dict: | |
| umi = policy_number(result.get("value")) | |
| dti = policy_number(result.get("dti")) | |
| lvr = context["lvr"] | |
| investor = context["purpose"] == "Investor" | |
| existing = "ASB" in context["existing_banks"] | |
| pre_approval = context["pre_approval"] | |
| kainga_ora = context["kainga_ora"] | |
| high_lvr = lvr > 80 | |
| dti_limit = 7 if investor else 6 | |
| high_dti = dti is not None and dti > dti_limit | |
| if kainga_ora and high_lvr: | |
| threshold = 750 if dti is not None and dti > 7 else 200 | |
| rule = f"ASB RBNZ-exempt / Kainga Ora LVR >80% and DTI {'>7' if dti is not None and dti > 7 else '<=7'}" | |
| return set_policy_result( | |
| result, | |
| status="PASS" if umi is not None and umi >= threshold else "FAIL", | |
| metric="UMI", | |
| value=umi, | |
| threshold=f">= ${threshold:,.0f}", | |
| rule=rule, | |
| note="ASB Home Happenings Update dated 13 July 2026: RBNZ high-LVR/DTI exemptions, including Kainga Ora First Home Loans, are unchanged.", | |
| detail=minimum_result_detail("UMI", umi, threshold), | |
| ) | |
| if investor: | |
| if lvr > 70: | |
| return set_policy_result( | |
| result, | |
| status="REVIEW", | |
| metric="UMI", | |
| value=umi, | |
| threshold="ASB Traffic Light confirmation required", | |
| rule="ASB Investor LVR >70%", | |
| note="The supplied text does not include the Investor high-LVR traffic-light table. Cashback eligibility is not a lending approval criterion.", | |
| detail=f"Investor LVR is {lvr:.2f}%. ASB investor lending above 70% LVR needs traffic-light availability confirmation before the result can be treated as a clear pass.", | |
| ) | |
| threshold = 120 if high_dti else 0 | |
| rule = f"ASB Investor {'High DTI > 7' if high_dti else 'Standard'}" | |
| else: | |
| threshold = 300 if high_lvr else (120 if high_dti else 0) | |
| if high_lvr and high_dti: | |
| rule = "ASB Owner Occupied High LVR & High DTI" | |
| elif high_lvr: | |
| rule = "ASB Owner Occupied High LVR > 80%" | |
| elif high_dti: | |
| rule = "ASB Owner Occupied High DTI > 6" | |
| else: | |
| rule = "ASB Owner Occupied Standard" | |
| if pre_approval and high_lvr and not existing: | |
| return set_policy_result( | |
| result, | |
| status="INELIGIBLE", | |
| metric="UMI", | |
| value=umi, | |
| threshold=f">= ${threshold:,.0f}", | |
| rule=rule, | |
| note="ASB Home Happenings Update dated 13 July 2026 moved >80% LVR pre-approvals for new owner-occupied customers to RED.", | |
| detail="This application is not eligible because it is a new-to-ASB owner-occupied high-LVR pre-approval. RBNZ-exempt / Kainga Ora scenarios are assessed under the separate exemption rule.", | |
| ) | |
| status = "PASS" if umi is not None and umi >= threshold else "FAIL" | |
| if threshold <= 0: | |
| detail = "No extra ASB UMI buffer applies to this standard scenario. The calculator result is treated as acceptable for this policy check." | |
| else: | |
| detail = minimum_result_detail("UMI", umi, threshold) | |
| return set_policy_result( | |
| result, | |
| status=status, | |
| metric="UMI", | |
| value=umi, | |
| threshold=f">= ${threshold:,.0f}", | |
| rule=rule, | |
| detail=detail, | |
| ) | |
| def evaluate_bnz_criteria(result: dict, context: dict) -> dict: | |
| mbs = policy_number(result.get("value")) | |
| si = policy_number(result.get("si")) | |
| dti = policy_number(result.get("dti")) | |
| lvr = context["lvr"] | |
| investor = context["purpose"] == "Investor" | |
| existing = "BNZ" in context["existing_banks"] | |
| pre_approval = context["pre_approval"] | |
| deal_type = context["deal_type"] | |
| metric = "MBS" | |
| value = mbs | |
| minimum = 100.0 | |
| dti_max = None | |
| rule = "BNZ Standard" | |
| note = "" | |
| max_lvr = 70 if investor else 80 | |
| existing_only = False | |
| live_only_for_all = False | |
| live_only_if_new = False | |
| if deal_type in {"Construction", "Labour-only construction"}: | |
| metric, value, minimum = "SI", si, 110.0 | |
| max_lvr = 80 if deal_type == "Labour-only construction" else 90 | |
| rule = f"BNZ {deal_type}" | |
| existing_only = True | |
| live_only_for_all = True | |
| elif deal_type == "Off-plan turnkey": | |
| max_lvr = 90 if investor else 95 | |
| rule = "BNZ Off-plan turnkey" | |
| live_only_for_all = True | |
| elif deal_type == "Completed turnkey": | |
| max_lvr = 90 if investor else 95 | |
| rule = "BNZ Completed turnkey" | |
| live_only_for_all = True | |
| note = "The completed property must receive CCC and settle within 90 days." | |
| elif deal_type == "Dollar-for-dollar refinance": | |
| max_lvr = 90 if investor else 95 | |
| rule = "BNZ RBNZ-exempt dollar-for-dollar refinance" | |
| live_only_for_all = True | |
| elif investor and lvr > 70: | |
| max_lvr = 90 | |
| minimum = 500.0 | |
| dti_max = 7.0 | |
| rule = "BNZ Investor high-LVR funding deal" | |
| existing_only = True | |
| live_only_for_all = True | |
| note = "RBNZ funding deal; existing BNZ live application only." | |
| elif not investor and lvr > 80: | |
| max_lvr = 95 | |
| rule = "BNZ Owner Occupied high-LVR funding deal" | |
| live_only_for_all = True | |
| note = "RBNZ funding deal; live application only." | |
| else: | |
| rule = f"BNZ {'Investor' if investor else 'Owner Occupied'} standard" | |
| live_only_if_new = True | |
| threshold = f">= {minimum:.0f} {'SI' if metric == 'SI' else 'MBS'}" | |
| if dti_max is not None: | |
| threshold += f" and DTI < {dti_max:g}" | |
| if lvr > max_lvr: | |
| return set_policy_result( | |
| result, | |
| status="INELIGIBLE", | |
| metric=metric, | |
| value=value, | |
| threshold=threshold, | |
| rule=rule, | |
| note=f"Maximum LVR for this scenario is {max_lvr:.0f}%.", | |
| detail=f"Current LVR is {lvr:.2f}%, which is above BNZ's allowed maximum of {max_lvr:.0f}% for this scenario.", | |
| ) | |
| if existing_only and not existing: | |
| return set_policy_result( | |
| result, | |
| status="INELIGIBLE", | |
| metric=metric, | |
| value=value, | |
| threshold=threshold, | |
| rule=rule, | |
| note="This scenario is not available to new-to-BNZ customers.", | |
| detail="This BNZ scenario requires an existing BNZ customer. Select BNZ or Both under Existing Bank Relationship only if the client meets BNZ's existing-customer definition.", | |
| ) | |
| if pre_approval and (live_only_for_all or (live_only_if_new and not existing)): | |
| return set_policy_result( | |
| result, | |
| status="INELIGIBLE", | |
| metric=metric, | |
| value=value, | |
| threshold=threshold, | |
| rule=rule, | |
| note="This scenario is available for live deals only.", | |
| detail="Pre-approval was selected, but BNZ only allows this scenario for a live deal.", | |
| ) | |
| numeric_pass = value is not None and value >= minimum | |
| detail_parts = [minimum_result_detail(metric, value, minimum)] | |
| if dti_max is not None: | |
| if dti is None: | |
| detail_parts.append(f"DTI result was not available, so the DTI < {dti_max:g} requirement could not be verified.") | |
| elif dti < dti_max: | |
| detail_parts.append(f"Current DTI is {dti:.2f}, meeting the DTI < {dti_max:g} requirement.") | |
| else: | |
| detail_parts.append(f"Current DTI is {dti:.2f}, which is above the required DTI < {dti_max:g}.") | |
| numeric_pass = numeric_pass and dti is not None and dti < dti_max | |
| status = "PASS" if numeric_pass else "FAIL" | |
| return set_policy_result( | |
| result, | |
| status=status, | |
| metric=metric, | |
| value=value, | |
| threshold=threshold, | |
| rule=rule, | |
| note=note, | |
| detail=" ".join(detail_parts), | |
| ) | |
| def evaluate_pepper_criteria(result: dict, context: dict) -> dict: | |
| nsr = policy_number(result.get("value")) | |
| government_majority = bool(context.get("government_is_majority")) | |
| government_income = context.get("government_annual_income") or 0.0 | |
| total_income = context.get("total_annual_income") or 0.0 | |
| threshold = 1.25 if government_majority else 1.00 | |
| rule = "Peppermoney NSR - Government allowance majority income" if government_majority else "Peppermoney NSR - All applications" | |
| threshold_text = f">= {threshold:.2f}" | |
| status = "PASS" if nsr is not None and nsr >= threshold else "FAIL" | |
| detail = threshold_result_detail("NSR", nsr, ">=", threshold) | |
| if government_majority: | |
| detail += ( | |
| f" Government benefit income appears to be the majority income " | |
| f"(${government_income:,.0f} of ${total_income:,.0f} annual gross income), so Pepper's higher NSR 1.25 rule applies." | |
| ) | |
| elif government_income > 0 and total_income > 0: | |
| detail += ( | |
| f" Government benefit income was included (${government_income:,.0f} of ${total_income:,.0f} annual gross income), " | |
| "but it does not appear to be the majority income, so the standard NSR 1.00 rule applies." | |
| ) | |
| else: | |
| detail += " No government-benefit majority income was identified, so the standard NSR 1.00 rule applies." | |
| return set_policy_result( | |
| result, | |
| status=status, | |
| metric="NSR", | |
| value=nsr, | |
| threshold=threshold_text, | |
| rule=rule, | |
| note="Interest-rate buffer eligibility is a separate manual check because it depends on transaction type, cash-out, Equifax score and RHI/default history.", | |
| detail=detail, | |
| ) | |
| def apply_servicing_criteria(bank: str, result: dict, inputs: dict) -> dict: | |
| context = policy_context(inputs) | |
| if bank == "ASB": | |
| return evaluate_asb_criteria(result, context) | |
| if bank == "BNZ": | |
| return evaluate_bnz_criteria(result, context) | |
| if bank == "Peppermoney": | |
| return evaluate_pepper_criteria(result, context) | |
| result.setdefault("criteria_threshold", "Calculator result") | |
| result.setdefault("criteria_rule", f"{bank} calculator") | |
| result.setdefault("criteria_note", "") | |
| result.setdefault("criteria_detail", generic_criteria_detail(bank, result)) | |
| return result | |
| def read_bank_results(bank: str, workbook_path: Path, recalculated_master: Path, policy_inputs: dict | None = None) -> dict: | |
| wb = load_workbook(workbook_path, data_only=True) | |
| master_wb = load_workbook(recalculated_master, data_only=True) | |
| config = BANK_CONFIGS[bank] | |
| sheet = get_sheet(wb, config["result_sheet"]) | |
| value = normalize_excel_value(sheet[config["value_cell"]].value) | |
| dti = None | |
| if config.get("dti_cell"): | |
| dti = normalize_excel_value(sheet[config["dti_cell"]].value) | |
| si = None | |
| if config.get("si_cell"): | |
| si = normalize_excel_value(sheet[config["si_cell"]].value) | |
| result = { | |
| "bank": bank, | |
| "metric": config["metric"], | |
| "value": value, | |
| "dti": dti, | |
| "si": si, | |
| "test_rate": read_test_rate(bank, wb), | |
| "status": status_for_bank(bank, value, wb, master_wb), | |
| "result_cells": {"value": f"{config['result_sheet']}!{config['value_cell']}", "dti": config.get("dti_cell"), "si": config.get("si_cell")}, | |
| } | |
| rule = config.get("status_rule", {}) | |
| if rule.get("type") == "cell_threshold": | |
| result["status_check_value"] = normalize_excel_value(sheet[rule["cell"]].value) | |
| elif rule.get("type") == "compare_cells": | |
| result["status_check_left"] = normalize_excel_value(resolve_rule_value(rule["left"], wb, master_wb)) | |
| result["status_check_right"] = normalize_excel_value(resolve_rule_value(rule["right"], wb, master_wb)) | |
| return apply_servicing_criteria(bank, result, policy_inputs or {}) | |
| def create_zip(files: list[Path]) -> bytes: | |
| buffer = io.BytesIO() | |
| with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as zf: | |
| for file_path in files: | |
| if file_path.exists(): | |
| zf.write(file_path, arcname=file_path.name) | |
| return buffer.getvalue() | |
| def safe_filename_part(value: str) -> str: | |
| text = str(value or "").strip() | |
| for char in '<>:"/\\|?*': | |
| text = text.replace(char, " ") | |
| return " ".join(text.split()) or "Client" | |
| def client_names_label(inputs: dict) -> str: | |
| names = [ | |
| str(inputs.get("c_name1") or "").strip(), | |
| str(inputs.get("c_name2") or "").strip(), | |
| str(inputs.get("c_name3") or "").strip(), | |
| ] | |
| names = [name for name in names if name] | |
| return safe_filename_part(" & ".join(names) if names else str(inputs.get("client_name") or "Client")) | |
| def bank_file_label(bank: str, inputs: dict, timestamp: str) -> str: | |
| return f"{safe_filename_part(bank)}_UMI Calculator_{client_names_label(inputs)}_{timestamp}" | |
| def package_file_label(inputs: dict, timestamp: str) -> str: | |
| loan_amount = int(parse_number(inputs.get("loan_amt")) or 0) | |
| return f"{client_names_label(inputs)}_Loan_{loan_amount:,.0f}_{timestamp}" | |
| def populate_bank_file(recalculated_master: Path, bank: str, inputs: dict, run_timestamp: str, generated_dir: Path) -> dict: | |
| config = BANK_CONFIGS[bank] | |
| source_template = bank_template_path(bank, recalculated_master) | |
| bank_label = bank_file_label(bank, inputs, run_timestamp) | |
| populated = generated_dir / f"{bank_label}_input{source_template.suffix}" | |
| shutil.copy2(source_template, populated) | |
| applied = apply_bank_mapping(recalculated_master, bank, populated, populated, inputs) | |
| return {"bank": bank, "source": populated, "applied": applied} | |
| def run_all_banks_engine( | |
| raw_inputs: dict, | |
| target_banks: list[str] | None = None, | |
| build_package: bool = True, | |
| save_inputs_record: bool = True, | |
| ) -> dict: | |
| total_started = time.perf_counter() | |
| timings = {} | |
| cleanup_old_jobs() | |
| setup_storage() | |
| inputs = preprocess_inputs(raw_inputs) | |
| if save_inputs_record: | |
| save_client_inputs(inputs) | |
| run_timestamp = nz_now().strftime("%Y%m%d_%H%M%S") | |
| job_id = f"{run_timestamp}_{uuid.uuid4().hex[:8]}" | |
| job_dir = JOBS_DIR / job_id | |
| generated_dir = job_dir / "generated" | |
| generated_dir.mkdir(parents=True, exist_ok=True) | |
| started = time.perf_counter() | |
| working_master, recalculated_master = build_recalculated_master(job_dir, inputs, run_timestamp) | |
| timings["Master recalc"] = time.perf_counter() - started | |
| client = client_key(str(inputs.get("c_name1") or "Client")) | |
| package_label = package_file_label(inputs, run_timestamp) | |
| target_banks = target_banks or list(BANK_CONFIGS) | |
| target_banks = [bank for bank in target_banks if bank not in EXCLUDED_BANKS] | |
| generated_files = [working_master] if build_package else [] | |
| results = [] | |
| pending = [] | |
| banks_to_prepare = [] | |
| for bank in target_banks: | |
| config = BANK_CONFIGS.get(bank) | |
| source_template = bank_template_path(bank, recalculated_master) if config else None | |
| if not config or not source_template or not source_template.exists(): | |
| missing_name = source_template.name if source_template else bank | |
| results.append({"bank": bank, "metric": "Result", "value": f"Template missing: {missing_name}", "dti": None, "status": "FAIL"}) | |
| continue | |
| banks_to_prepare.append(bank) | |
| started = time.perf_counter() | |
| with ThreadPoolExecutor(max_workers=BANK_WORKERS) as executor: | |
| future_to_bank = { | |
| executor.submit(populate_bank_file, recalculated_master, bank, inputs, run_timestamp, generated_dir): bank | |
| for bank in banks_to_prepare | |
| } | |
| for future in as_completed(future_to_bank): | |
| bank = future_to_bank[future] | |
| try: | |
| pending.append(future.result()) | |
| except Exception as exc: | |
| results.append({"bank": bank, "metric": "Result", "value": f"Prepare failed: {exc}", "dti": None, "status": "FAIL"}) | |
| timings["Bank file mapping"] = time.perf_counter() - started | |
| pending.sort(key=lambda item: target_banks.index(item["bank"]) if item["bank"] in target_banks else 999) | |
| started = time.perf_counter() | |
| try: | |
| converted = recalc_many_with_libreoffice([item["source"] for item in pending], generated_dir / "recalculated", "xlsx") | |
| except Exception as exc: | |
| converted = {} | |
| batch_error = exc | |
| else: | |
| batch_error = None | |
| timings["Bank LibreOffice recalc"] = time.perf_counter() - started | |
| started = time.perf_counter() | |
| for item in pending: | |
| bank = item["bank"] | |
| source_file = item["source"] | |
| try: | |
| if source_file in converted: | |
| recalculated = converted[source_file] | |
| else: | |
| if batch_error is not None: | |
| raise RuntimeError(batch_error) | |
| raise RuntimeError("Batch recalculation output missing") | |
| if build_package: | |
| final_file = generated_dir / f"{bank_file_label(bank, inputs, run_timestamp)}{recalculated.suffix}" | |
| shutil.copy2(recalculated, final_file) | |
| result = read_bank_results(bank, final_file, recalculated_master, inputs) | |
| if source_file.suffix.lower() == ".xlsm": | |
| download_file = generated_dir / f"{bank_file_label(bank, inputs, run_timestamp)}{source_file.suffix}" | |
| shutil.copy2(source_file, download_file) | |
| generated_files.append(download_file) | |
| else: | |
| generated_files.append(final_file) | |
| else: | |
| result = read_bank_results(bank, recalculated, recalculated_master, inputs) | |
| except Exception as exc: | |
| if build_package: | |
| generated_files.append(source_file) | |
| result = {"bank": bank, "metric": "Result", "value": f"Read failed: {exc}", "dti": None, "status": "FAIL"} | |
| result["mapping_rows_applied"] = item["applied"] | |
| results.append(result) | |
| timings["Result read"] = time.perf_counter() - started | |
| started = time.perf_counter() | |
| package_bytes = b"" | |
| package_path = None | |
| if build_package: | |
| package_bytes = create_zip(generated_files) | |
| package_path = job_dir / f"{package_label}.zip" | |
| package_path.write_bytes(package_bytes) | |
| timings["Package build"] = time.perf_counter() - started | |
| (job_dir / "input.json").write_text(json.dumps(inputs, indent=2, default=str), encoding="utf-8") | |
| (job_dir / "results.json").write_text(json.dumps(results, indent=2, default=str), encoding="utf-8") | |
| timings["Total"] = time.perf_counter() - total_started | |
| return { | |
| "job_id": job_id, | |
| "inputs": inputs, | |
| "results": results, | |
| "timings": timings, | |
| "package_bytes": package_bytes, | |
| "package_name": package_path.name if package_path else "", | |
| "package_path": str(package_path) if package_path else "", | |
| } | |
| def run_asb_engine(raw_inputs: dict) -> dict: | |
| return run_all_banks_engine(raw_inputs, ["ASB"]) | |