vishakhad30's picture
Add Gradio Invoice Reconciler for Hugging Face Spaces.
abd8f87
Raw History Blame Contribute Delete
8.85 kB
"""Hugging Face Spaces / local Gradio entrypoint."""
from __future__ import annotations
import sys
import tempfile
from decimal import Decimal
from io import BytesIO
from pathlib import Path
from typing import Any, Optional
import gradio as gr
import pandas as pd
ROOT = Path(__file__).resolve().parent
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from invoice_reconciler.mapping import FieldMapping, records_from_table, suggest_mapping
from invoice_reconciler.models import MatchStatus, ReconciliationResult
from invoice_reconciler.parsers import parse_file
from invoice_reconciler.parsers.base import ParsedTable
from invoice_reconciler.reconciliation import reconcile
from invoice_reconciler.reporting import export_excel_report
def _save_upload(file_obj) -> tuple[Path, str]:
"""Persist a Gradio upload to a temp path; return path and original name."""
if file_obj is None:
raise ValueError("No file uploaded")
# gradio File: .name is temp path; .orig_name may exist on NamedString
if isinstance(file_obj, str):
path = Path(file_obj)
return path, path.name
name = getattr(file_obj, "orig_name", None) or Path(str(file_obj)).name
src = Path(str(file_obj))
suffix = Path(name).suffix or src.suffix
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
tmp.write(src.read_bytes())
tmp.close()
return Path(tmp.name), name
def _fmt_money(v: Decimal | None) -> str:
if v is None:
return ""
return f"{v:.2f}"
def _preview_df(table: ParsedTable | None, limit: int = 15) -> pd.DataFrame:
if table is None or not table.rows:
return pd.DataFrame()
return pd.DataFrame(table.preview(limit))
def _exceptions_df(result: ReconciliationResult) -> pd.DataFrame:
rows = []
for e in result.exceptions:
rows.append(
{
"Invoice Number": e.invoice_number,
"Status": e.status.value,
"Source Amount": _fmt_money(e.source_amount),
"QuickBooks Amount": _fmt_money(e.quickbooks_amount),
"Difference": _fmt_money(e.difference),
"Source Page/Row": e.source_location,
"QuickBooks Page/Row": e.quickbooks_location,
"Reason": e.reason,
}
)
return pd.DataFrame(rows)
def _summary_md(result: ReconciliationResult) -> str:
s = result.summary
return f"""### Summary Dashboard
| Metric | Count |
|---|---:|
| Total Source Invoices | {s.total_source} |
| Total QuickBooks Invoices | {s.total_quickbooks} |
| Matched | {s.matched} |
| Amount Mismatch | {s.amount_mismatch} |
| Missing from QuickBooks | {s.missing_from_quickbooks} |
| Extra in QuickBooks | {s.extra_in_quickbooks} |
| Duplicates | {s.duplicates} |
| Needs Review | {s.needs_review} |
"""
def parse_uploads(
source_file, qb_file
) -> tuple[
Optional[ParsedTable],
Optional[ParsedTable],
gr.Dropdown,
gr.Dropdown,
gr.Dropdown,
gr.Dropdown,
pd.DataFrame,
pd.DataFrame,
str,
]:
if source_file is None or qb_file is None:
empty = gr.Dropdown(choices=[], value=None)
return (
None,
None,
empty,
empty,
empty,
empty,
pd.DataFrame(),
pd.DataFrame(),
"Upload **both** Source of Truth and QuickBooks files, then click **Parse & detect fields**.",
)
src_path, src_name = _save_upload(source_file)
qb_path, qb_name = _save_upload(qb_file)
source_table = parse_file(src_path, original_name=src_name)
qb_table = parse_file(qb_path, original_name=qb_name)
src_sug = suggest_mapping(source_table.columns)
qb_sug = suggest_mapping(qb_table.columns)
notes = [
f"Parsed **{src_name}**: {len(source_table.rows)} rows, columns={source_table.columns}",
f"Parsed **{qb_name}**: {len(qb_table.rows)} rows, columns={qb_table.columns}",
]
if source_table.ocr_required or source_table.ocr_used:
notes.append("Source PDF may need / used OCR β€” confirm fields carefully.")
if qb_table.ocr_required or qb_table.ocr_used:
notes.append("QuickBooks PDF may need / used OCR β€” confirm fields carefully.")
notes.append("Confirm Invoice Number and Gross Amount β€” do **not** assume the first money column.")
return (
source_table,
qb_table,
gr.Dropdown(choices=source_table.columns, value=src_sug.invoice_column, interactive=True),
gr.Dropdown(choices=source_table.columns, value=src_sug.amount_column, interactive=True),
gr.Dropdown(choices=qb_table.columns, value=qb_sug.invoice_column, interactive=True),
gr.Dropdown(choices=qb_table.columns, value=qb_sug.amount_column, interactive=True),
_preview_df(source_table),
_preview_df(qb_table),
"\n\n".join(notes),
)
def run_reconciliation(
source_table: ParsedTable | None,
qb_table: ParsedTable | None,
src_inv: str | None,
src_amt: str | None,
qb_inv: str | None,
qb_amt: str | None,
) -> tuple[str, pd.DataFrame, Optional[str], Optional[ReconciliationResult]]:
if source_table is None or qb_table is None:
return "Parse both files first.", pd.DataFrame(), None, None
if not all([src_inv, src_amt, qb_inv, qb_amt]):
return "Select Invoice Number and Gross Amount columns for both files.", pd.DataFrame(), None, None
source_records = records_from_table(
source_table, FieldMapping(invoice_column=src_inv, amount_column=src_amt)
)
qb_records = records_from_table(
qb_table, FieldMapping(invoice_column=qb_inv, amount_column=qb_amt)
)
result = reconcile(source_records, qb_records)
buf = BytesIO()
export_excel_report(result, buf)
out_path = Path(tempfile.gettempdir()) / "reconciliation_report.xlsx"
out_path.write_bytes(buf.getvalue())
return _summary_md(result), _exceptions_df(result), str(out_path), result
def build_demo() -> gr.Blocks:
with gr.Blocks(title="Invoice Reconciler") as demo:
gr.Markdown(
"""
# Invoice Reconciler
Source of truth ↔ QuickBooks Β· **parsers Β· engine Β· UI** stay separate
"""
)
source_state = gr.State(None)
qb_state = gr.State(None)
result_state = gr.State(None)
with gr.Row():
source_file = gr.File(
label="1. Upload Source of Truth (PDF / XLSX / XLS / CSV)",
file_types=[".pdf", ".xlsx", ".xls", ".csv"],
)
qb_file = gr.File(
label="2. Upload QuickBooks (PDF / XLSX / XLS / CSV)",
file_types=[".pdf", ".xlsx", ".xls", ".csv"],
)
parse_btn = gr.Button("Parse & detect fields", variant="secondary")
status = gr.Markdown()
gr.Markdown("### 3. Review detected fields")
with gr.Row():
with gr.Column():
gr.Markdown("**Source of Truth mapping**")
src_inv = gr.Dropdown(label="Invoice Number β†’", choices=[], interactive=True)
src_amt = gr.Dropdown(
label="Gross Amount β†’",
choices=[],
interactive=True,
info="Do not assume the first monetary column is the amount.",
)
src_preview = gr.Dataframe(label="Source preview", interactive=False)
with gr.Column():
gr.Markdown("**QuickBooks mapping**")
qb_inv = gr.Dropdown(label="Invoice Number β†’", choices=[], interactive=True)
qb_amt = gr.Dropdown(label="Gross Amount β†’", choices=[], interactive=True)
qb_preview = gr.Dataframe(label="QuickBooks preview", interactive=False)
run_btn = gr.Button("4. Run Reconciliation", variant="primary")
summary = gr.Markdown("### 5. Summary Dashboard\n_Run reconciliation to see counts._")
exceptions = gr.Dataframe(label="6. Detailed Exceptions", interactive=False)
report = gr.File(label="7. Export Report")
parse_btn.click(
fn=parse_uploads,
inputs=[source_file, qb_file],
outputs=[
source_state,
qb_state,
src_inv,
src_amt,
qb_inv,
qb_amt,
src_preview,
qb_preview,
status,
],
)
run_btn.click(
fn=run_reconciliation,
inputs=[source_state, qb_state, src_inv, src_amt, qb_inv, qb_amt],
outputs=[summary, exceptions, report, result_state],
)
return demo
demo = build_demo()
if __name__ == "__main__":
demo.launch()