""" Xero ↔ Flash Purchases Reconciliation Accepts a Xero "Account Transactions" XLSX export, parses it, matches rows against Flash invoices for the same period, and returns a four-section reconciliation report. Matching is rule-based first (exact → near-match), with an optional LLM pass for remaining unmatched items. """ from datetime import date, timedelta from decimal import Decimal, InvalidOperation from typing import Optional import io import re import logging import json from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import selectinload from pydantic import BaseModel from database import get_db from models.user import User from models.invoice import Invoice, InvoiceStatus from models.supplier import Supplier from auth import get_current_user, require_cap logger = logging.getLogger(__name__) router = APIRouter() # ============ Pydantic Response Models ============ class ReconcileMatchedItem(BaseModel): date: str supplier: str ref: str amount: str # formatted £ flash_id: int | None = None match_source: str = "rule" # "rule" or "ai" class ReconcileDiscrepancyItem(BaseModel): date: str supplier: str ref: str flash_amount: str xero_amount: str difference: str differs: list[str] # which fields differ: "amount", "date", "ref" flash_id: int | None = None flash_date: str | None = None xero_date: str | None = None flash_ref: str | None = None xero_ref: str | None = None flash_supplier: str | None = None xero_description: str | None = None match_source: str = "rule" # "rule" or "ai" amount_insight: str | None = None # explanation for amount discrepancy class ReconcileUnmatchedFlash(BaseModel): date: str supplier: str ref: str net_stock: str flash_id: int class ReconcileUnmatchedXero(BaseModel): date: str description: str ref: str net: str is_expected_external: bool = False class ReconcileResponse(BaseModel): period_start: str period_end: str flash_total: str xero_total: str difference: str matched_count: int discrepancy_count: int flash_only_count: int xero_only_count: int non_stock_excluded_count: int non_stock_excluded_total: str matched: list[ReconcileMatchedItem] discrepancies: list[ReconcileDiscrepancyItem] flash_only: list[ReconcileUnmatchedFlash] xero_only: list[ReconcileUnmatchedXero] llm_matches_attempted: bool = False # ============ Supplier alias map for Xero description → Flash supplier ============ # Xero descriptions often look like "Supplier Name - food" or "Supplier - food crn" # This maps known Xero description variants to the Flash supplier name SUPPLIER_ALIASES = { "j hall & son (bakers) ltd": "Halls", "j hall & son bakers ltd": "Halls", "halls": "Halls", "r & d walker ltd": "R&D Walker", "r&d walker ltd": "R&D Walker", "r&d walker": "R&D Walker", "lambournes of stow-on-the-wold": "Lambournes", "lambournes": "Lambournes", "bramleys": "Bramleys", "cotswold coffee": "Cotswold Coffee", "brakes": "Brakes", "direct seafoods": "Direct Seafoods", } # Xero-only entries that are expected (not from Flash) — collapsible sub-section EXPECTED_EXTERNAL_PATTERNS = [ "tesco", "revenue jv", "journal", "internal", "petty", ] # ============ Helpers ============ def normalise_ref(ref: str | None) -> str: """Normalise a reference for comparison: uppercase, strip whitespace/leading #, collapse spaces.""" if not ref: return "" r = ref.strip().upper() r = r.lstrip("#") r = re.sub(r"\s+", " ", r).strip() return r def extract_supplier_from_xero_desc(description: str) -> str: """ Extract supplier name from Xero description. Xero format: "Supplier Name - food", "Supplier - food crn", etc. """ desc = description.strip() # Strip trailing " - food", " - food crn", " - beverage", etc. desc = re.sub(r"\s*-\s*(food|beverage|cleaning|sundry)(\s+crn)?\s*$", "", desc, flags=re.IGNORECASE) return desc.strip() def normalise_supplier(name: str) -> str: """Normalise supplier name to lowercase for comparison.""" return name.strip().lower() def resolve_supplier(xero_desc: str) -> str: """Resolve a Xero description to a canonical supplier name.""" extracted = extract_supplier_from_xero_desc(xero_desc) norm = normalise_supplier(extracted) if norm in SUPPLIER_ALIASES: return SUPPLIER_ALIASES[norm] return extracted def is_expected_external(xero_desc: str) -> bool: """Check if a Xero entry is expected to be outside Flash.""" desc_lower = xero_desc.lower() return any(pat in desc_lower for pat in EXPECTED_EXTERNAL_PATTERNS) def levenshtein(s1: str, s2: str) -> int: """Compute Levenshtein distance between two strings.""" if len(s1) < len(s2): return levenshtein(s2, s1) if len(s2) == 0: return len(s1) prev_row = list(range(len(s2) + 1)) for i, c1 in enumerate(s1): curr_row = [i + 1] for j, c2 in enumerate(s2): insertions = prev_row[j + 1] + 1 deletions = curr_row[j] + 1 substitutions = prev_row[j] + (c1 != c2) curr_row.append(min(insertions, deletions, substitutions)) prev_row = curr_row return prev_row[-1] def alphanumeric_only(s: str) -> str: """Strip non-alphanumeric characters for fuzzy ref comparison.""" return re.sub(r"[^A-Z0-9]", "", s.upper()) def amounts_match(a: Decimal, b: Decimal, tolerance: Decimal = Decimal("0.01")) -> bool: return abs(a - b) <= tolerance def fmt_money(d: Decimal) -> str: """Format a decimal as £X,XXX.XX""" return f"£{d:,.2f}" def parse_xero_date(val) -> date | None: """Parse a date from an openpyxl cell value.""" from datetime import datetime if isinstance(val, datetime): return val.date() if isinstance(val, date): return val if isinstance(val, str): val = val.strip() for fmt in ("%d %b %Y", "%d/%m/%Y", "%Y-%m-%d", "%d %B %Y"): try: return datetime.strptime(val, fmt).date() except ValueError: continue return None def parse_xero_amount(val) -> Decimal: """Parse a numeric cell to Decimal, returning 0 for None/empty.""" if val is None: return Decimal("0") if isinstance(val, (int, float)): return Decimal(str(val)).quantize(Decimal("0.01")) if isinstance(val, str): val = val.strip().replace(",", "").replace("£", "") if not val or val == "-": return Decimal("0") try: return Decimal(val).quantize(Decimal("0.01")) except InvalidOperation: return Decimal("0") return Decimal("0") # ============ XLSX Parsing ============ def parse_xero_xlsx(file_bytes: bytes) -> tuple[date, date, list[dict]]: """ Parse a Xero Account Transactions XLSX export. Returns (period_start, period_end, rows) where each row is: { "date": date, "description": str, "ref": str, "debit": Decimal, "credit": Decimal, "net": Decimal, # debit - credit } """ from openpyxl import load_workbook wb = load_workbook(filename=io.BytesIO(file_bytes), read_only=True, data_only=True) ws = wb.active # Extract period from header rows (typically row 3) period_start = None period_end = None for row_idx in range(1, 6): cell_val = ws.cell(row=row_idx, column=1).value if cell_val and isinstance(cell_val, str) and "period" in cell_val.lower(): # "For the period 1 March 2026 to 31 March 2026" match = re.search( r"(\d{1,2}\s+\w+\s+\d{4})\s+to\s+(\d{1,2}\s+\w+\s+\d{4})", cell_val, re.IGNORECASE ) if match: from datetime import datetime for fmt in ("%d %B %Y", "%d %b %Y"): try: period_start = datetime.strptime(match.group(1), fmt).date() period_end = datetime.strptime(match.group(2), fmt).date() break except ValueError: continue break if not period_start or not period_end: raise ValueError("Could not extract report period from XLSX header rows. Expected 'For the period DD Month YYYY to DD Month YYYY' in rows 1-5.") # Find header row (look for "Date" in column A) header_row = None for row_idx in range(1, 10): cell_val = ws.cell(row=row_idx, column=1).value if cell_val and isinstance(cell_val, str) and cell_val.strip().lower() == "date": header_row = row_idx break if not header_row: raise ValueError("Could not find column header row (expected 'Date' in column A within first 10 rows).") # Map column headers col_map = {} for col_idx in range(1, 20): val = ws.cell(row=header_row, column=col_idx).value if val and isinstance(val, str): col_map[val.strip().lower()] = col_idx required = ["date", "description", "debit", "credit"] for req in required: if req not in col_map: raise ValueError(f"Missing required column '{req}' in header row {header_row}. Found: {list(col_map.keys())}") ref_col = col_map.get("reference", col_map.get("ref")) source_col = col_map.get("source") # Parse data rows rows = [] for row_idx in range(header_row + 1, ws.max_row + 1): date_val = ws.cell(row=row_idx, column=col_map["date"]).value parsed_date = parse_xero_date(date_val) if not parsed_date: continue # Skip non-data rows (section headers, totals, blanks) description = ws.cell(row=row_idx, column=col_map["description"]).value or "" ref = "" if ref_col: ref = ws.cell(row=row_idx, column=ref_col).value or "" ref = str(ref).strip() source = "" if source_col: source = str(ws.cell(row=row_idx, column=source_col).value or "").strip() debit = parse_xero_amount(ws.cell(row=row_idx, column=col_map["debit"]).value) credit = parse_xero_amount(ws.cell(row=row_idx, column=col_map["credit"]).value) net = debit - credit # Credits (credit notes) become negative is_credit_note = "credit note" in source.lower() or credit > 0 rows.append({ "date": parsed_date, "description": str(description).strip(), "ref": ref, "source": source, "debit": debit, "credit": credit, "net": net, "is_credit_note": is_credit_note, }) wb.close() return period_start, period_end, rows def group_xero_by_ref(rows: list[dict]) -> list[dict]: """ Group Xero rows by reference (Xero sometimes splits an invoice across stock/non-stock lines with the same ref). Sum the nets. Rows with empty refs are NOT grouped — each stays separate. """ grouped = {} no_ref = [] for row in rows: norm = normalise_ref(row["ref"]) if not norm: no_ref.append({ "date": row["date"], "description": row["description"], "ref": row["ref"], "net": row["net"], "is_credit_note": row.get("is_credit_note", False), "raw_rows": [row], }) else: if norm not in grouped: grouped[norm] = { "date": row["date"], "description": row["description"], "ref": row["ref"], # keep original formatting from first row "net": Decimal("0"), "is_credit_note": row.get("is_credit_note", False), "raw_rows": [], } grouped[norm]["net"] += row["net"] grouped[norm]["raw_rows"].append(row) # If any row is a credit note, mark the group if row.get("is_credit_note"): grouped[norm]["is_credit_note"] = True return list(grouped.values()) + no_ref # ============ Flash Invoice Querying ============ async def get_flash_invoices( db: AsyncSession, kitchen_id: int, period_start: date, period_end: date, ) -> tuple[list[dict], int, Decimal]: """ Get Flash invoices for the period. Returns: - stock_invoices: list of dicts for matching - non_stock_count: count of excluded non-stock-only invoices - non_stock_total: total of excluded non-stock amounts Each stock invoice dict has: { "id": int, "date": date, "supplier_name": str, "ref": str (invoice_number), "net_stock": Decimal, "document_type": str, } """ from models.line_item import LineItem result = await db.execute( select(Invoice) .where( Invoice.kitchen_id == kitchen_id, Invoice.status == InvoiceStatus.CONFIRMED, ) .options(selectinload(Invoice.line_items)) .order_by(Invoice.invoice_date.desc().nullslast()) ) all_invoices = result.scalars().all() # Get supplier map supplier_result = await db.execute( select(Supplier).where(Supplier.kitchen_id == kitchen_id) ) suppliers_map = {s.id: s for s in supplier_result.scalars().all()} stock_invoices = [] non_stock_count = 0 non_stock_total = Decimal("0") for inv in all_invoices: inv_date = inv.invoice_date or inv.created_at.date() if not (period_start <= inv_date <= period_end): continue # Calculate net_stock (stock items only) net_stock = Decimal("0") net_non_stock = Decimal("0") if inv.line_items: for item in inv.line_items: item_net = item.amount or Decimal("0") if item.is_non_stock: net_non_stock += item_net else: net_stock += item_net # Credit notes: negate if positive if inv.document_type == "credit_note": if net_stock > 0: net_stock = -net_stock if net_non_stock > 0: net_non_stock = -net_non_stock # Get supplier name supplier_name = "" if inv.supplier_id and inv.supplier_id in suppliers_map: supplier_name = suppliers_map[inv.supplier_id].name elif inv.vendor_name: supplier_name = inv.vendor_name else: supplier_name = "Unknown" if net_stock == 0 and net_non_stock != 0: # Entirely non-stock — exclude from match pool non_stock_count += 1 non_stock_total += net_non_stock continue if net_stock == 0 and net_non_stock == 0: # Zero-value invoice — nothing to reconcile continue # Build line item summaries for insight generation line_summaries = [] if inv.line_items: for item in inv.line_items: line_summaries.append({ "description": item.description or "", "amount": str(item.amount or 0), "is_non_stock": item.is_non_stock, }) stock_invoices.append({ "id": inv.id, "date": inv_date, "supplier_name": supplier_name, "ref": inv.invoice_number or "", "net_stock": net_stock.quantize(Decimal("0.01")), "net_non_stock": net_non_stock.quantize(Decimal("0.01")), "document_type": inv.document_type or "invoice", "line_items": line_summaries, }) # Track non-stock portion of mixed invoices separately if net_non_stock != 0: non_stock_count += 1 non_stock_total += net_non_stock return stock_invoices, non_stock_count, non_stock_total.quantize(Decimal("0.01")) # ============ Build supplier alias map from DB ============ async def build_supplier_alias_map(db: AsyncSession, kitchen_id: int) -> dict[str, str]: """ Build a normalised-name → canonical-name map from the Supplier table aliases. Merges with the hardcoded SUPPLIER_ALIASES. """ alias_map = dict(SUPPLIER_ALIASES) # start with hardcoded result = await db.execute( select(Supplier).where(Supplier.kitchen_id == kitchen_id) ) suppliers = result.scalars().all() for s in suppliers: # Map canonical name alias_map[normalise_supplier(s.name)] = s.name # Map aliases if s.aliases: for alias in s.aliases: alias_map[normalise_supplier(alias)] = s.name return alias_map def resolve_supplier_with_map(xero_desc: str, alias_map: dict[str, str]) -> str: """Resolve a Xero description to a canonical supplier name using dynamic alias map.""" extracted = extract_supplier_from_xero_desc(xero_desc) norm = normalise_supplier(extracted) if norm in alias_map: return alias_map[norm] # Try partial match — check if any alias key is contained in the extracted name for alias_key, canonical in alias_map.items(): if alias_key in norm or norm in alias_key: return canonical return extracted def suppliers_match(flash_supplier: str, xero_supplier: str) -> bool: """Check if a Flash supplier and resolved Xero supplier match.""" return normalise_supplier(flash_supplier) == normalise_supplier(xero_supplier) # ============ Matching Engine ============ def run_matching( flash_items: list[dict], xero_items: list[dict], alias_map: dict[str, str], ) -> tuple[list, list, list[dict], list[dict]]: """ Three-pass matching: 1. Exact: same normalised ref + same date + amount within £0.01 2. Near-match: one field differs (flagged as discrepancy) 3. Leftovers: unmatched on each side Returns (matched, discrepancies, flash_unmatched, xero_unmatched) """ matched = [] discrepancies = [] flash_used = set() xero_used = set() # Resolve Xero suppliers up front for xi, xero in enumerate(xero_items): xero["_resolved_supplier"] = resolve_supplier_with_map(xero["description"], alias_map) xero["_norm_ref"] = normalise_ref(xero["ref"]) xero["_idx"] = xi for fi, flash in enumerate(flash_items): flash["_norm_ref"] = normalise_ref(flash["ref"]) flash["_idx"] = fi # ---- Pass 1: Exact match ---- for fi, flash in enumerate(flash_items): if fi in flash_used: continue if not flash["_norm_ref"]: continue # Can't exact-match without a ref for xi, xero in enumerate(xero_items): if xi in xero_used: continue if flash["_norm_ref"] == xero["_norm_ref"] and \ flash["date"] == xero["date"] and \ amounts_match(flash["net_stock"], xero["net"]): matched.append({ "date": flash["date"].isoformat(), "supplier": flash["supplier_name"], "ref": flash["ref"] or xero["ref"], "amount": fmt_money(flash["net_stock"]), "flash_id": flash["id"], }) flash_used.add(fi) xero_used.add(xi) break # ---- Pass 2: Near-match (discrepancies) ---- for fi, flash in enumerate(flash_items): if fi in flash_used: continue best_match = None best_score = 0 # higher = more confident for xi, xero in enumerate(xero_items): if xi in xero_used: continue differs = [] score = 0 f_ref = flash["_norm_ref"] x_ref = xero["_norm_ref"] f_supplier = flash["supplier_name"] x_supplier = xero["_resolved_supplier"] same_supplier = suppliers_match(f_supplier, x_supplier) # 2a: Same ref, same date, amount differs if f_ref and x_ref and f_ref == x_ref and flash["date"] == xero["date"]: if not amounts_match(flash["net_stock"], xero["net"]): differs.append("amount") score = 10 # 2b: Same ref, amount matches, date differs ≤ 3 days elif f_ref and x_ref and f_ref == x_ref and \ amounts_match(flash["net_stock"], xero["net"]): date_diff = abs((flash["date"] - xero["date"]).days) if date_diff <= 3: differs.append("date") score = 9 # 2c: Same supplier, amount matches (within £0.01), date within 3 days, refs differ # This catches cases where refs are completely different # (e.g. Cotswold Coffee 126621 vs 145323, R&D Walker Nº4 vs 106891) elif same_supplier and amounts_match(flash["net_stock"], xero["net"], Decimal("0.01")): date_diff = abs((flash["date"] - xero["date"]).days) if date_diff == 0: differs.append("ref") score = 8 elif date_diff <= 3: differs.extend(["ref", "date"]) score = 7 # 2d: Same supplier, same ref, both amount and date differ slightly elif f_ref and x_ref and f_ref == x_ref and same_supplier: date_diff = abs((flash["date"] - xero["date"]).days) amt_diff = abs(flash["net_stock"] - xero["net"]) if date_diff <= 3 and amt_diff <= Decimal("5.00"): differs.extend(["amount", "date"]) score = 5 # 2e: Credit note matching — same supplier, same amount, # but refs differ completely (Xero uses CN prefix, Flash uses supplier ref) elif same_supplier and amounts_match(flash["net_stock"], xero["net"]) and \ (flash["document_type"] == "credit_note" or xero.get("is_credit_note", False)): date_diff = abs((flash["date"] - xero["date"]).days) if date_diff <= 5: differs.append("ref") if date_diff > 0: differs.append("date") score = 8 # High confidence — credit notes with matching supplier+amount # 2f: Same supplier, date within 3 days, amounts close but not exact elif same_supplier: date_diff = abs((flash["date"] - xero["date"]).days) amt_diff = abs(flash["net_stock"] - xero["net"]) if date_diff <= 3 and amt_diff <= Decimal("5.00") and amt_diff > Decimal("0.01"): differs_list = ["amount"] if date_diff > 0: differs_list.append("date") if f_ref != x_ref: differs_list.append("ref") differs.extend(differs_list) score = 4 if score > best_score: best_score = score best_match = (xi, xero, differs) if best_match: xi, xero, differs = best_match f_net = flash["net_stock"] x_net = xero["net"] diff_val = f_net - x_net amt_diff_abs = abs(float(diff_val)) # Safety check: reject any near-match where amount differs by more than £5 # AND refs are different — these are almost certainly different invoices f_ref_norm = normalise_ref(flash["ref"]) x_ref_norm = normalise_ref(xero["ref"]) refs_differ = f_ref_norm != x_ref_norm if refs_differ and amt_diff_abs > 5.0: logger.warning( f"REJECTED false discrepancy: Flash {flash['supplier_name']} " f"{flash['ref']}={f_net} vs Xero {xero['ref']}={x_net} " f"diff={amt_diff_abs:.2f} score={best_score}" ) continue # Skip — leave both as unmatched # Add "amount" to differs if amounts don't actually match if amt_diff_abs > 0.01 and "amount" not in differs: differs.append("amount") logger.info( f"Discrepancy: Flash {flash['supplier_name']} {flash['ref']}={f_net} " f"vs Xero {xero['ref']}={x_net} differs={differs} score={best_score}" ) discrepancies.append({ "date": flash["date"].isoformat(), "supplier": flash["supplier_name"], "ref": flash["ref"] or xero["ref"], "flash_amount": fmt_money(f_net), "xero_amount": fmt_money(x_net), "difference": fmt_money(diff_val), "differs": differs, "flash_id": flash["id"], "flash_date": flash["date"].isoformat(), "xero_date": xero["date"].isoformat(), "flash_ref": flash["ref"], "xero_ref": xero["ref"], "flash_supplier": flash["supplier_name"], "xero_description": xero["description"], }) flash_used.add(fi) xero_used.add(xi) # ---- Leftovers ---- flash_unmatched = [f for fi, f in enumerate(flash_items) if fi not in flash_used] xero_unmatched = [x for xi, x in enumerate(xero_items) if xi not in xero_used] return matched, discrepancies, flash_unmatched, xero_unmatched # ============ Amount Discrepancy Insights ============ def check_non_stock_explains_diff(flash: dict, diff_val: Decimal) -> str | None: """ Check if the amount discrepancy is explained by non-stock items. If Xero includes the full invoice (stock + non-stock) but Flash only shows stock, the difference should equal the non-stock total. """ net_non_stock = flash.get("net_non_stock", Decimal("0")) if net_non_stock == 0: return None # diff_val = flash_stock - xero_net (negative when Xero is higher) # If Xero has full invoice, diff = -net_non_stock if amounts_match(abs(diff_val), abs(net_non_stock), Decimal("0.02")): non_stock_items = [ li for li in flash.get("line_items", []) if li.get("is_non_stock") ] item_names = ", ".join( li["description"][:40] for li in non_stock_items if li.get("description") ) return ( f"Non-stock items account for the difference " f"({fmt_money(abs(net_non_stock))}). " f"Xero likely includes full invoice total. " f"Non-stock: {item_names}" if item_names else f"Non-stock items account for the difference " f"({fmt_money(abs(net_non_stock))}). " f"Xero likely includes full invoice total." ) # Check if non-stock is a partial explanation (diff is larger but non-stock is a chunk) if abs(net_non_stock) > Decimal("1.00") and abs(diff_val) > abs(net_non_stock): remainder = abs(diff_val) - abs(net_non_stock) return ( f"Non-stock items total {fmt_money(abs(net_non_stock))} " f"but difference is {fmt_money(abs(diff_val))} — " f"non-stock explains part, {fmt_money(remainder)} remains unexplained." ) return None def check_line_item_combinations(flash: dict, diff_val: Decimal) -> str | None: """ Check if any single line item or small combination matches the difference. This catches cases where a specific item was excluded/included differently. """ line_items = flash.get("line_items", []) if not line_items: return None target = abs(diff_val) # Check single items for li in line_items: amt = abs(Decimal(li["amount"])) if amounts_match(amt, target, Decimal("0.02")) and amt > Decimal("0.50"): desc = li["description"][:50] if li["description"] else "unnamed item" ns = " (non-stock)" if li.get("is_non_stock") else "" return f'Single line item matches difference: "{desc}"{ns} = {fmt_money(amt)}' return None async def generate_llm_insight( db: AsyncSession, kitchen_id: int, flash: dict, xero_net: Decimal, diff_val: Decimal, ) -> str | None: """Use LLM to analyse line items and suggest cause of amount variance.""" from services.llm_service import call_llm line_items = flash.get("line_items", []) if not line_items: return None # Build concise line item list li_summary = [] for li in line_items: li_summary.append({ "description": li["description"][:60] if li["description"] else "—", "amount": li["amount"], "non_stock": li["is_non_stock"], }) system_msg = ( "You are a kitchen accounts assistant. Flash is a food stock/GP system that " "tracks invoices — it separates stock (food) items from non-stock items " "(chemicals, packaging, equipment, etc). Xero is the accounting system that " "records the full invoice total posted to the food purchases account. " "An amount discrepancy means Flash stock total differs from the Xero net. " "Common causes: non-stock items not split out in Xero, line items missing, " "rounding, or Xero posting error." ) user_msg = ( f"Invoice: {flash['supplier_name']} ref {flash['ref']} dated {flash['date']}\n" f"Flash stock total: {fmt_money(flash['net_stock'])}\n" f"Xero net: {fmt_money(xero_net)}\n" f"Difference: {fmt_money(diff_val)} (Flash - Xero)\n\n" f"Flash line items:\n{json.dumps(li_summary, indent=2)}\n\n" f"Can you identify which line items or combination likely accounts for " f"the {fmt_money(abs(diff_val))} difference? " f"Reply in ONE short sentence (max 120 chars). " f"If unclear, say 'Unable to determine cause'." ) result = await call_llm( db=db, kitchen_id=kitchen_id, feature="reconciliation_insight", messages=[{"role": "user", "content": user_msg}], system_message=system_msg, ) if result["status"] not in ("success", "cached"): return None text = result.get("result", "") if isinstance(text, dict): text = str(text) text = text.strip().strip('"').strip("'") if text and len(text) < 200: return text return text[:200] + "..." if text else None async def generate_amount_insights( db: AsyncSession, kitchen_id: int, discrepancies: list[dict], flash_lookup: dict[int, dict], ) -> list[dict]: """ For each amount discrepancy, try to explain the variance: 1. Code check: does non-stock total match the difference? 2. Code check: does a single line item match the difference? 3. LLM fallback: send line items for analysis """ for disc in discrepancies: if "amount" not in disc.get("differs", []): continue flash_id = disc.get("flash_id") if not flash_id or flash_id not in flash_lookup: continue flash = flash_lookup[flash_id] # Parse the difference back to Decimal diff_str = disc["difference"].replace("£", "").replace(",", "") try: diff_val = Decimal(diff_str) except InvalidOperation: continue # 1. Non-stock check insight = check_non_stock_explains_diff(flash, diff_val) if insight: disc["amount_insight"] = insight continue # 2. Single line item check insight = check_line_item_combinations(flash, diff_val) if insight: disc["amount_insight"] = insight continue # 3. LLM fallback xero_str = disc["xero_amount"].replace("£", "").replace(",", "") try: xero_net = Decimal(xero_str) except InvalidOperation: continue insight = await generate_llm_insight(db, kitchen_id, flash, xero_net, diff_val) if insight: disc["amount_insight"] = f"🤖 {insight}" return discrepancies # ============ LLM Fallback Matching ============ async def llm_match_remaining( db: AsyncSession, kitchen_id: int, flash_unmatched: list[dict], xero_unmatched: list[dict], ) -> tuple[list, list, list[dict], list[dict]]: """ Use LLM to attempt matching remaining unmatched items. Returns (new_discrepancies, new_exact, remaining_flash, remaining_xero) """ from services.llm_service import call_llm if not flash_unmatched or not xero_unmatched: return [], [], flash_unmatched, xero_unmatched # Build concise representations flash_summary = [] for i, f in enumerate(flash_unmatched): flash_summary.append({ "idx": i, "date": f["date"].isoformat(), "supplier": f["supplier_name"], "ref": f["ref"], "amount": str(f["net_stock"]), }) xero_summary = [] for i, x in enumerate(xero_unmatched): xero_summary.append({ "idx": i, "date": x["date"].isoformat(), "description": x["description"], "ref": x["ref"], "amount": str(x["net"]), }) system_msg = """You are a bookkeeping reconciliation assistant. You are given two lists of unmatched invoice entries — one from Flash (the kitchen invoice system) and one from Xero (the accounting system). Your job is to identify probable matches between the two lists. These are entries that likely represent the same real-world invoice but have data discrepancies (different reference numbers, slightly different amounts, date offsets, supplier name variants, etc). For each probable match, explain which fields differ and why you think they are the same invoice. IMPORTANT: Only suggest matches you are reasonably confident about. It is better to leave items unmatched than to create false matches. Consider supplier names, dates, amounts, and reference numbers holistically.""" user_msg = f"""Here are the unmatched Flash invoices: {json.dumps(flash_summary, indent=2)} Here are the unmatched Xero entries: {json.dumps(xero_summary, indent=2)} Return a JSON array of matches. Each match should be: {{ "flash_idx": , "xero_idx": , "confidence": "high" or "medium", "differs": ["field1", "field2"], "reasoning": "brief explanation" }} Only include matches with high or medium confidence. Return an empty array [] if no good matches found.""" result = await call_llm( db=db, kitchen_id=kitchen_id, feature="reconciliation_matching", messages=[{"role": "user", "content": user_msg}], system_message=system_msg, ) if result["status"] not in ("success", "cached"): logger.info(f"LLM reconciliation matching unavailable: {result['status']}") return [], [], flash_unmatched, xero_unmatched # Parse LLM response new_discrepancies = [] llm_text = result.get("result", "") if isinstance(llm_text, dict): llm_text = json.dumps(llm_text) if not llm_text: return [], [], flash_unmatched, xero_unmatched try: # Extract JSON from response (may be wrapped in markdown code block) json_match = re.search(r"\[.*\]", str(llm_text), re.DOTALL) if not json_match: return [], [], flash_unmatched, xero_unmatched matches = json.loads(json_match.group()) except (json.JSONDecodeError, AttributeError): logger.warning("Failed to parse LLM reconciliation response") return [], [], flash_unmatched, xero_unmatched flash_matched = set() xero_matched = set() for m in matches: fi = m.get("flash_idx") xi = m.get("xero_idx") confidence = m.get("confidence", "medium") if fi is None or xi is None: continue if fi >= len(flash_unmatched) or xi >= len(xero_unmatched): continue if fi in flash_matched or xi in xero_matched: continue flash = flash_unmatched[fi] xero = xero_unmatched[xi] # Safety check: reject LLM matches where refs differ AND amount diff > £5 f_ref_norm = normalise_ref(flash["ref"]) x_ref_norm = normalise_ref(xero["ref"]) amt_diff_abs = abs(float(flash["net_stock"] - xero["net"])) if f_ref_norm != x_ref_norm and amt_diff_abs > 5.0: logger.warning( f"REJECTED LLM false match: Flash {flash['supplier_name']} " f"{flash['ref']}={flash['net_stock']} vs Xero {xero['ref']}={xero['net']} " f"diff={amt_diff_abs:.2f}" ) continue differs = m.get("differs", []) if not differs: differs = ["unknown"] diff_val = flash["net_stock"] - xero["net"] new_discrepancies.append({ "date": flash["date"].isoformat(), "supplier": flash["supplier_name"], "ref": flash["ref"] or xero["ref"], "flash_amount": fmt_money(flash["net_stock"]), "xero_amount": fmt_money(xero["net"]), "difference": fmt_money(diff_val), "differs": differs, "flash_id": flash["id"], "flash_date": flash["date"].isoformat(), "xero_date": xero["date"].isoformat(), "flash_ref": flash["ref"], "xero_ref": xero["ref"], "match_source": "ai", }) flash_matched.add(fi) xero_matched.add(xi) remaining_flash = [f for i, f in enumerate(flash_unmatched) if i not in flash_matched] remaining_xero = [x for i, x in enumerate(xero_unmatched) if i not in xero_matched] return new_discrepancies, [], remaining_flash, remaining_xero # ============ Main Endpoint ============ @router.post("/purchases/reconcile", response_model=ReconcileResponse) async def reconcile_purchases( file: UploadFile = File(...), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """ Upload a Xero Account Transactions XLSX and reconcile against Flash invoices. """ # Validate file type if not file.filename or not file.filename.lower().endswith(".xlsx"): raise HTTPException(status_code=400, detail="Please upload an XLSX file") # Read file into memory (no persistence) file_bytes = await file.read() if len(file_bytes) > 10 * 1024 * 1024: # 10MB limit raise HTTPException(status_code=400, detail="File too large (max 10MB)") # Parse XLSX try: period_start, period_end, xero_rows = parse_xero_xlsx(file_bytes) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"XLSX parsing failed: {e}") raise HTTPException(status_code=400, detail=f"Failed to parse XLSX: {str(e)}") # Group Xero rows by reference xero_items = group_xero_by_ref(xero_rows) # Get Flash invoices for the period flash_items, non_stock_count, non_stock_total = await get_flash_invoices( db, current_user.kitchen_id, period_start, period_end ) # Build supplier alias map from DB alias_map = await build_supplier_alias_map(db, current_user.kitchen_id) # Run rule-based matching matched, discrepancies, flash_unmatched, xero_unmatched = run_matching( flash_items, xero_items, alias_map ) # LLM fallback for remaining unmatched llm_attempted = False if flash_unmatched and xero_unmatched: llm_disc, llm_exact, flash_unmatched, xero_unmatched = await llm_match_remaining( db, current_user.kitchen_id, flash_unmatched, xero_unmatched ) if llm_disc or llm_exact: llm_attempted = True discrepancies.extend(llm_disc) matched.extend(llm_exact) # Generate insights for amount discrepancies flash_lookup = {f["id"]: f for f in flash_items} discrepancies = await generate_amount_insights( db, current_user.kitchen_id, discrepancies, flash_lookup ) # Calculate totals flash_total = sum(f["net_stock"] for f in flash_items) xero_total = sum(x["net"] for x in xero_items) difference = flash_total - xero_total # Build response matched_response = [ReconcileMatchedItem(**m) for m in matched] discrepancy_response = [ReconcileDiscrepancyItem(**d) for d in discrepancies] flash_only_response = [ ReconcileUnmatchedFlash( date=f["date"].isoformat(), supplier=f["supplier_name"], ref=f["ref"], net_stock=fmt_money(f["net_stock"]), flash_id=f["id"], ) for f in flash_unmatched ] xero_only_response = [ ReconcileUnmatchedXero( date=x["date"].isoformat(), description=x["description"], ref=x["ref"], net=fmt_money(x["net"]), is_expected_external=is_expected_external(x["description"]), ) for x in xero_unmatched ] return ReconcileResponse( period_start=period_start.isoformat(), period_end=period_end.isoformat(), flash_total=fmt_money(flash_total), xero_total=fmt_money(xero_total), difference=fmt_money(difference), matched_count=len(matched_response), discrepancy_count=len(discrepancy_response), flash_only_count=len(flash_only_response), xero_only_count=len(xero_only_response), non_stock_excluded_count=non_stock_count, non_stock_excluded_total=fmt_money(non_stock_total), matched=matched_response, discrepancies=discrepancy_response, flash_only=flash_only_response, xero_only=xero_only_response, llm_matches_attempted=llm_attempted, )