Update src/streamlit_app.py
Browse files- src/streamlit_app.py +274 -475
src/streamlit_app.py
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
import os
|
| 2 |
# --- Fix: ensure HOME is writable before Streamlit initializes ---
|
| 3 |
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
_home = os.environ.get("HOME", "")
|
| 6 |
if _home in ("", "/", None):
|
|
@@ -32,45 +38,10 @@ import pandas as pd
|
|
| 32 |
from PIL import Image
|
| 33 |
from huggingface_hub import login
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
#
|
| 38 |
-
|
| 39 |
-
def _update_flat_field(field_name: str, widget_key: str):
|
| 40 |
-
"""Update a top-level field in edited_data."""
|
| 41 |
-
current_hash = st.session_state.get("current_file_hash")
|
| 42 |
-
if not current_hash or current_hash not in st.session_state.batch_results:
|
| 43 |
-
return
|
| 44 |
-
new_value = st.session_state[widget_key]
|
| 45 |
-
st.session_state.batch_results[current_hash]["edited_data"][field_name] = new_value
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def _update_nested_field(field_path: str, widget_key: str):
|
| 49 |
-
"""Update a nested field like 'Sender.Name' or 'Bank Details.bank_name'."""
|
| 50 |
-
current_hash = st.session_state.get("current_file_hash")
|
| 51 |
-
if not current_hash or current_hash not in st.session_state.batch_results:
|
| 52 |
-
return
|
| 53 |
-
new_value = st.session_state[widget_key]
|
| 54 |
-
data_ref = st.session_state.batch_results[current_hash]["edited_data"]
|
| 55 |
-
|
| 56 |
-
keys = field_path.split(".")
|
| 57 |
-
d = data_ref
|
| 58 |
-
for k in keys[:-1]:
|
| 59 |
-
if k not in d or not isinstance(d[k], dict):
|
| 60 |
-
d[k] = {}
|
| 61 |
-
d = d[k]
|
| 62 |
-
d[keys[-1]] = new_value
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
def _update_line_items(widget_key: str):
|
| 66 |
-
"""Auto-update line items when data_editor changes."""
|
| 67 |
-
current_hash = st.session_state.get("current_file_hash")
|
| 68 |
-
if not current_hash or current_hash not in st.session_state.batch_results:
|
| 69 |
-
return
|
| 70 |
-
df = st.session_state[widget_key]
|
| 71 |
-
records = df.to_dict('records')
|
| 72 |
-
st.session_state.batch_results[current_hash]["edited_data"]["Itemized Data"] = records
|
| 73 |
-
|
| 74 |
|
| 75 |
# ---------------------------
|
| 76 |
# UI: main
|
|
@@ -236,44 +207,25 @@ def run_inference_on_image(image: Image.Image, processor, model, device, decoder
|
|
| 236 |
# ---------------------------
|
| 237 |
def map_prediction_to_ui(pred):
|
| 238 |
import json, re
|
| 239 |
-
from
|
| 240 |
|
| 241 |
-
# --- parse raw string payloads that embed JSON ---
|
| 242 |
def safe_json_load(s):
|
| 243 |
if s is None:
|
| 244 |
return None
|
| 245 |
if isinstance(s, (dict, list)):
|
| 246 |
return s
|
| 247 |
if isinstance(s, str):
|
| 248 |
-
s = s.strip()
|
| 249 |
-
if s == "":
|
| 250 |
-
return None
|
| 251 |
try:
|
| 252 |
return json.loads(s)
|
| 253 |
except Exception:
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
if not stack:
|
| 261 |
-
start = i
|
| 262 |
-
stack.append("{")
|
| 263 |
-
elif ch == "}":
|
| 264 |
-
if stack:
|
| 265 |
-
stack.pop()
|
| 266 |
-
if not stack and start is not None:
|
| 267 |
-
subs.append(s[start:i+1])
|
| 268 |
-
start = None
|
| 269 |
-
for sub in subs:
|
| 270 |
-
try:
|
| 271 |
-
return json.loads(sub)
|
| 272 |
-
except Exception:
|
| 273 |
-
continue
|
| 274 |
return None
|
| 275 |
|
| 276 |
-
# --- normalize numeric strings like "1,800.00" -> float ---
|
| 277 |
def clean_number(x):
|
| 278 |
if x is None:
|
| 279 |
return 0.0
|
|
@@ -282,7 +234,6 @@ def map_prediction_to_ui(pred):
|
|
| 282 |
s = str(x).strip()
|
| 283 |
if s == "":
|
| 284 |
return 0.0
|
| 285 |
-
# remove commas and non-number chars except dot and minus
|
| 286 |
s = re.sub(r"[,\s]", "", s)
|
| 287 |
s = re.sub(r"[^\d\.\-]", "", s)
|
| 288 |
if s in ("", ".", "-", "-."):
|
|
@@ -292,69 +243,28 @@ def map_prediction_to_ui(pred):
|
|
| 292 |
except Exception:
|
| 293 |
return 0.0
|
| 294 |
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
collect_lists_of_dicts(v, out_lists)
|
| 317 |
-
elif isinstance(obj, list):
|
| 318 |
-
for it in obj:
|
| 319 |
-
if isinstance(it, list) and it and isinstance(it[0], dict):
|
| 320 |
-
out_lists.append(it)
|
| 321 |
-
else:
|
| 322 |
-
collect_lists_of_dicts(it, out_lists)
|
| 323 |
-
|
| 324 |
-
# --- map item dict -> UI item row using the keys you specified in example ---
|
| 325 |
-
def map_item_dict(it):
|
| 326 |
-
if not isinstance(it, dict):
|
| 327 |
-
return None
|
| 328 |
-
# lowered keys mapping
|
| 329 |
-
lower = {str(k).strip().lower(): v for k, v in it.items()}
|
| 330 |
-
desc = (lower.get("descriptions") or lower.get("description") or lower.get("desc") or lower.get("item") or "")
|
| 331 |
-
qty = lower.get("quantity") or lower.get("qty") or lower.get("count") or ""
|
| 332 |
-
unit_price = lower.get("unit_price") or lower.get("price") or ""
|
| 333 |
-
amount = lower.get("amount") or lower.get("line_total") or lower.get("line total") or lower.get("total") or ""
|
| 334 |
-
tax = lower.get("tax") or lower.get("tax_amount") or ""
|
| 335 |
-
line_total = lower.get("line_total") or lower.get("line_total".lower()) or lower.get("line total") or amount
|
| 336 |
-
|
| 337 |
-
return {
|
| 338 |
-
"Description": str(desc).strip(),
|
| 339 |
-
"Quantity": float(clean_number(qty)),
|
| 340 |
-
"Unit Price": float(clean_number(unit_price)),
|
| 341 |
-
"Amount": float(clean_number(amount)),
|
| 342 |
-
"Tax": float(clean_number(tax)),
|
| 343 |
-
"Line Total": float(clean_number(line_total))
|
| 344 |
-
}
|
| 345 |
-
|
| 346 |
-
# ----------------- Start mapping -----------------
|
| 347 |
-
# Try parse if pred is a JSON-like string
|
| 348 |
-
parsed = safe_json_load(pred) if isinstance(pred, str) else pred
|
| 349 |
-
if parsed is None and isinstance(pred, str):
|
| 350 |
-
# not parseable -> fallback to empty UI
|
| 351 |
-
parsed = None
|
| 352 |
-
|
| 353 |
-
if parsed is None and not isinstance(pred, dict):
|
| 354 |
-
# nothing we can map
|
| 355 |
-
parsed = pred # will still allow collect_keys if it's dict; else produce empty ui
|
| 356 |
|
| 357 |
-
# create empty UI template
|
| 358 |
ui = {
|
| 359 |
"Invoice Number": "",
|
| 360 |
"Invoice Date": "",
|
|
@@ -374,122 +284,138 @@ def map_prediction_to_ui(pred):
|
|
| 374 |
"Itemized Data": []
|
| 375 |
}
|
| 376 |
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
list_candidates = [] # list of list-of-dicts found
|
| 380 |
-
if isinstance(parsed, dict):
|
| 381 |
-
collect_keys(parsed, key_map)
|
| 382 |
-
collect_lists_of_dicts(parsed, list_candidates)
|
| 383 |
-
elif isinstance(pred, dict):
|
| 384 |
-
# if parsing failed but original pred is dict, use that
|
| 385 |
-
collect_keys(pred, key_map)
|
| 386 |
-
collect_lists_of_dicts(pred, list_candidates)
|
| 387 |
-
|
| 388 |
-
# Helper to pick first non-empty value from candidate keys
|
| 389 |
-
def pick_first(*candidate_keys):
|
| 390 |
-
for k in candidate_keys:
|
| 391 |
-
lk = k.strip().lower()
|
| 392 |
-
if lk in key_map:
|
| 393 |
-
# pick first non-empty
|
| 394 |
-
for v in key_map[lk]:
|
| 395 |
-
if v is None:
|
| 396 |
-
continue
|
| 397 |
-
# return primitive or string immediately; if dict/list, return as-is
|
| 398 |
-
if isinstance(v, (dict, list)):
|
| 399 |
-
return v
|
| 400 |
-
s = str(v).strip()
|
| 401 |
-
if s != "":
|
| 402 |
-
return s
|
| 403 |
-
return None
|
| 404 |
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
ui["Sender Name"] = pick_first("sender_name", "sender") or ""
|
| 410 |
-
ui["Sender Address"] = pick_first("sender_addr", "sender_address", "sender addr") or ""
|
| 411 |
-
ui["Recipient Name"] = pick_first("rcpt_name", "recipient_name", "recipient", "rcpt") or ""
|
| 412 |
-
ui["Recipient Address"] = pick_first("rcpt_addr", "recipient_address", "recipient addr") or ""
|
| 413 |
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
|
| 426 |
-
# summary / totals
|
| 427 |
-
ui["Subtotal"] = clean_number(pick_first("subtotal", "sub_total", "sub total") or 0.0)
|
| 428 |
-
ui["Tax Percentage"] = clean_number(pick_first("tax_rate", "tax_percentage", "tax pct", "tax percentage") or 0.0)
|
| 429 |
-
ui["Total Tax"] = clean_number(pick_first("tax_amount", "tax", "total_tax") or 0.0)
|
| 430 |
-
ui["Total Amount"] = clean_number(pick_first("total_amount", "grand_total", "total", "amount") or 0.0)
|
| 431 |
-
ui["Currency"] = (pick_first("currency") or "").strip()
|
| 432 |
-
|
| 433 |
-
# Item extraction:
|
| 434 |
-
items_rows = []
|
| 435 |
-
|
| 436 |
-
# 1) If we found an explicit list-of-dicts candidate, use the first one that looks like invoice items
|
| 437 |
-
def list_looks_like_items(lst):
|
| 438 |
-
if not isinstance(lst, list) or not lst:
|
| 439 |
-
return False
|
| 440 |
-
if not isinstance(lst[0], dict):
|
| 441 |
-
return False
|
| 442 |
-
# check if any expected item key present in first element
|
| 443 |
-
expected = {"descriptions", "description", "desc", "item", "quantity", "qty", "amount", "unit_price", "line_total", "line_total".lower(), "line_total"}
|
| 444 |
-
keys0 = {str(k).strip().lower() for k in lst[0].keys()}
|
| 445 |
-
return bool(expected.intersection(keys0))
|
| 446 |
-
|
| 447 |
-
for cand in list_candidates:
|
| 448 |
-
if list_looks_like_items(cand):
|
| 449 |
-
for it in cand:
|
| 450 |
-
row = map_item_dict(it)
|
| 451 |
-
if row is not None:
|
| 452 |
-
items_rows.append(row)
|
| 453 |
-
# prefer first plausible list
|
| 454 |
-
if items_rows:
|
| 455 |
-
break
|
| 456 |
-
|
| 457 |
-
# 2) If no list-of-dicts found, try to find a single dict anywhere that looks like an item (e.g., 'items': {...} as dict)
|
| 458 |
-
if not items_rows:
|
| 459 |
-
# search key_map values for dicts that have item-like keys
|
| 460 |
-
for k, vals in key_map.items():
|
| 461 |
-
for v in vals:
|
| 462 |
-
if isinstance(v, dict):
|
| 463 |
-
# does this dict have an item-like key?
|
| 464 |
-
lower_keys = {str(x).strip().lower() for x in v.keys()}
|
| 465 |
-
if lower_keys.intersection({"descriptions", "description", "desc", "amount", "line_total", "quantity", "qty", "unit_price"}):
|
| 466 |
-
row = map_item_dict(v)
|
| 467 |
-
if row is not None:
|
| 468 |
-
items_rows.append(row)
|
| 469 |
-
# we don't break because there might be multiple item-like dicts at different keys,
|
| 470 |
-
# but continue scanning to collect all.
|
| 471 |
-
# 3) Last resort: if key_map contains 'descriptions' or 'amount' as scalar but no dict, build a single-item row
|
| 472 |
-
if not items_rows:
|
| 473 |
-
desc = pick_first("descriptions", "description")
|
| 474 |
-
amt = pick_first("amount", "line_total")
|
| 475 |
-
qty = pick_first("quantity", "qty")
|
| 476 |
-
unit_price = pick_first("unit_price", "price")
|
| 477 |
-
if desc or amt or qty or unit_price:
|
| 478 |
-
items_rows.append({
|
| 479 |
-
"Description": str(desc or ""),
|
| 480 |
-
"Quantity": float(clean_number(qty)),
|
| 481 |
-
"Unit Price": float(clean_number(unit_price)),
|
| 482 |
-
"Amount": float(clean_number(amt)),
|
| 483 |
-
"Tax": float(clean_number(pick_first("tax", "tax_amount") or 0.0)),
|
| 484 |
-
"Line Total": float(clean_number(amt or 0.0))
|
| 485 |
-
})
|
| 486 |
-
|
| 487 |
-
ui["Itemized Data"] = items_rows
|
| 488 |
-
|
| 489 |
-
# Also set Sender/Recipient convenience fields
|
| 490 |
ui["Sender"] = {"Name": ui["Sender Name"], "Address": ui["Sender Address"]}
|
| 491 |
ui["Recipient"] = {"Name": ui["Recipient Name"], "Address": ui["Recipient Address"]}
|
| 492 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
return ui
|
| 494 |
|
| 495 |
# ---------------------------
|
|
@@ -499,53 +425,35 @@ def flatten_invoice_to_rows(invoice_data) -> list:
|
|
| 499 |
"""
|
| 500 |
Converts nested invoice data into a flat list of rows (one per line item),
|
| 501 |
with invoice-level and sender/recipient/bank fields repeated in each row.
|
| 502 |
-
Ensures all expected bank fields are present, even if empty.
|
| 503 |
"""
|
| 504 |
-
# Define all expected bank fields (must match UI keys)
|
| 505 |
-
EXPECTED_BANK_FIELDS = [
|
| 506 |
-
"bank_name",
|
| 507 |
-
"bank_account_number",
|
| 508 |
-
"bank_acc_name",
|
| 509 |
-
"bank_iban",
|
| 510 |
-
"bank_swift",
|
| 511 |
-
"bank_routing",
|
| 512 |
-
"bank_branch"
|
| 513 |
-
]
|
| 514 |
-
|
| 515 |
rows = []
|
| 516 |
line_items = invoice_data.get("Itemized Data", [])
|
| 517 |
|
| 518 |
-
# Prepare base invoice info
|
| 519 |
-
base_row = {
|
| 520 |
-
"Invoice Number": invoice_data.get("Invoice Number", ""),
|
| 521 |
-
"Invoice Date": invoice_data.get("Invoice Date", ""),
|
| 522 |
-
"Due Date": invoice_data.get("Due Date", ""),
|
| 523 |
-
"Currency": invoice_data.get("Currency", ""),
|
| 524 |
-
"Subtotal": invoice_data.get("Subtotal", 0.0),
|
| 525 |
-
"Tax Percentage": invoice_data.get("Tax Percentage", 0.0),
|
| 526 |
-
"Total Tax": invoice_data.get("Total Tax", 0.0),
|
| 527 |
-
"Total Amount": invoice_data.get("Total Amount", 0.0),
|
| 528 |
-
"Sender Name": invoice_data.get("Sender", {}).get("Name", ""),
|
| 529 |
-
"Sender Address": invoice_data.get("Sender", {}).get("Address", ""),
|
| 530 |
-
"Recipient Name": invoice_data.get("Recipient", {}).get("Name", ""),
|
| 531 |
-
"Recipient Address": invoice_data.get("Recipient", {}).get("Address", ""),
|
| 532 |
-
}
|
| 533 |
-
|
| 534 |
-
# Initialize all expected bank fields to empty string
|
| 535 |
-
bank_row = {field: "" for field in EXPECTED_BANK_FIELDS}
|
| 536 |
-
|
| 537 |
-
# Override with actual values if present
|
| 538 |
-
bank_details = invoice_data.get("Bank Details", {}) or {}
|
| 539 |
-
for k, v in bank_details.items():
|
| 540 |
-
key_name = k if str(k).startswith("bank_") else f"bank_{k}"
|
| 541 |
-
if key_name in bank_row:
|
| 542 |
-
bank_row[key_name] = str(v).strip() if v is not None else ""
|
| 543 |
-
|
| 544 |
-
base_row.update(bank_row)
|
| 545 |
-
|
| 546 |
if not line_items:
|
| 547 |
# If no line items, create one row with invoice info only
|
| 548 |
-
row =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
row.update({
|
| 550 |
"Item Description": "",
|
| 551 |
"Item Quantity": 0,
|
|
@@ -559,7 +467,29 @@ def flatten_invoice_to_rows(invoice_data) -> list:
|
|
| 559 |
|
| 560 |
# For each line item, create a row with all invoice context
|
| 561 |
for item in line_items:
|
| 562 |
-
row =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
row.update({
|
| 564 |
"Item Description": item.get("Description", ""),
|
| 565 |
"Item Quantity": item.get("Quantity", 0),
|
|
@@ -568,6 +498,7 @@ def flatten_invoice_to_rows(invoice_data) -> list:
|
|
| 568 |
"Item Tax": item.get("Tax", 0.0),
|
| 569 |
"Item Line Total": item.get("Line Total", item.get("Amount", 0.0)),
|
| 570 |
})
|
|
|
|
| 571 |
rows.append(row)
|
| 572 |
|
| 573 |
return rows
|
|
@@ -678,15 +609,15 @@ if not st.session_state.is_processing_batch and len(st.session_state.batch_resul
|
|
| 678 |
# RESULTS VIEW — Show selector + editable form
|
| 679 |
# ---------------------------
|
| 680 |
elif len(st.session_state.batch_results) > 0:
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
if st.button("📦 Download All Results (Excel)", key="download_all"):
|
| 685 |
-
|
| 686 |
all_rows = []
|
| 687 |
for file_hash, result in st.session_state.batch_results.items():
|
| 688 |
rows = flatten_invoice_to_rows(result["edited_data"])
|
| 689 |
-
|
| 690 |
for r in rows:
|
| 691 |
r["Source File"] = result.get("file_name", file_hash)
|
| 692 |
all_rows.extend(rows)
|
|
@@ -696,13 +627,13 @@ elif len(st.session_state.batch_results) > 0:
|
|
| 696 |
else:
|
| 697 |
full_df = pd.DataFrame(all_rows)
|
| 698 |
|
| 699 |
-
|
| 700 |
cols = list(full_df.columns)
|
| 701 |
if "Source File" in cols:
|
| 702 |
cols = ["Source File"] + [c for c in cols if c != "Source File"]
|
| 703 |
full_df = full_df[cols]
|
| 704 |
|
| 705 |
-
|
| 706 |
buffer = BytesIO()
|
| 707 |
dl_filename = "all_extracted_invoices.xlsx"
|
| 708 |
tried_xlsx = False
|
|
@@ -723,7 +654,7 @@ elif len(st.session_state.batch_results) > 0:
|
|
| 723 |
dl_filename = "all_extracted_invoices.csv"
|
| 724 |
mime = "text/csv"
|
| 725 |
|
| 726 |
-
|
| 727 |
import base64
|
| 728 |
import streamlit.components.v1 as components
|
| 729 |
b64 = base64.b64encode(file_bytes).decode()
|
|
@@ -765,7 +696,8 @@ elif len(st.session_state.batch_results) > 0:
|
|
| 765 |
# Get current file data
|
| 766 |
current = st.session_state.batch_results[selected_hash]
|
| 767 |
image = current["image"]
|
| 768 |
-
|
|
|
|
| 769 |
|
| 770 |
# Layout
|
| 771 |
left_col, right_col = st.columns([1, 1])
|
|
@@ -802,7 +734,6 @@ elif len(st.session_state.batch_results) > 0:
|
|
| 802 |
st.rerun()
|
| 803 |
except Exception as e:
|
| 804 |
st.error(f"Re-run failed: {e}")
|
| 805 |
-
|
| 806 |
tabs = st.tabs(["Invoice Details", "Sender/Recipient info", "Bank Details", "Line Items"])
|
| 807 |
|
| 808 |
st.markdown(
|
|
@@ -831,225 +762,88 @@ elif len(st.session_state.batch_results) > 0:
|
|
| 831 |
# ---------- Invoice Details ----------
|
| 832 |
with tabs[0]:
|
| 833 |
with st.container():
|
| 834 |
-
|
| 835 |
-
st.text_input(
|
| 836 |
-
|
| 837 |
-
value=data.get('Invoice Number', ''),
|
| 838 |
-
key=widget_key,
|
| 839 |
-
on_change=_update_flat_field,
|
| 840 |
-
args=("Invoice Number", widget_key)
|
| 841 |
-
)
|
| 842 |
-
|
| 843 |
-
widget_key = f"invoice_date_text_{selected_hash}"
|
| 844 |
-
st.text_input(
|
| 845 |
-
"Invoice Date",
|
| 846 |
-
value=str(data.get('Invoice Date', '')).strip(),
|
| 847 |
-
key=widget_key,
|
| 848 |
-
on_change=_update_flat_field,
|
| 849 |
-
args=("Invoice Date", widget_key)
|
| 850 |
-
)
|
| 851 |
-
|
| 852 |
-
widget_key = f"due_date_text_{selected_hash}"
|
| 853 |
-
st.text_input(
|
| 854 |
-
"Due Date",
|
| 855 |
-
value=str(data.get('Due Date', '')).strip(),
|
| 856 |
-
key=widget_key,
|
| 857 |
-
on_change=_update_flat_field,
|
| 858 |
-
args=("Due Date", widget_key)
|
| 859 |
-
)
|
| 860 |
-
|
| 861 |
curr_options = ['USD', 'EUR', 'GBP', 'INR', 'Other']
|
| 862 |
-
curr_value =
|
| 863 |
curr_index = curr_options.index(curr_value) if curr_value in curr_options else (len(curr_options) - 1)
|
| 864 |
-
new_curr = st.selectbox(
|
| 865 |
-
"Currency",
|
| 866 |
-
options=curr_options,
|
| 867 |
-
index=curr_index,
|
| 868 |
-
key=f"currency_select_{selected_hash}",
|
| 869 |
-
on_change=_update_flat_field,
|
| 870 |
-
args=("Currency", f"currency_select_{selected_hash}")
|
| 871 |
-
)
|
| 872 |
if new_curr == 'Other':
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
args=("Currency", widget_key)
|
| 880 |
-
)
|
| 881 |
-
|
| 882 |
-
def safe_number_input(label, value, key, field_name):
|
| 883 |
-
try:
|
| 884 |
-
v = float(value)
|
| 885 |
-
except Exception:
|
| 886 |
-
v = 0.0
|
| 887 |
-
st.number_input(
|
| 888 |
-
label,
|
| 889 |
-
value=v,
|
| 890 |
-
key=key,
|
| 891 |
-
on_change=_update_flat_field,
|
| 892 |
-
args=(field_name, key)
|
| 893 |
-
)
|
| 894 |
-
|
| 895 |
-
safe_number_input("Subtotal", data.get('Subtotal', 0.0), f"subtotal_{selected_hash}", "Subtotal")
|
| 896 |
-
safe_number_input("Tax Percentage", data.get('Tax Percentage', 0.0), f"tax_pct_{selected_hash}", "Tax Percentage")
|
| 897 |
-
safe_number_input("Total Tax", data.get('Total Tax', 0.0), f"total_tax_{selected_hash}", "Total Tax")
|
| 898 |
-
safe_number_input("Total Amount", data.get('Total Amount', 0.0), f"total_amount_{selected_hash}", "Total Amount")
|
| 899 |
|
| 900 |
# ---------- Sender / Recipient ----------
|
| 901 |
with tabs[1]:
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
if 'Recipient' not in data:
|
| 905 |
-
data['Recipient'] = {'Name': '', 'Address': ''}
|
| 906 |
-
|
| 907 |
-
sender_info = data['Sender']
|
| 908 |
-
recipient_info = data['Recipient']
|
| 909 |
-
|
| 910 |
with st.container():
|
| 911 |
-
|
| 912 |
-
st.
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
key=widget_key,
|
| 916 |
-
on_change=_update_nested_field,
|
| 917 |
-
args=("Sender.Name", widget_key)
|
| 918 |
-
)
|
| 919 |
-
|
| 920 |
-
widget_key = f"sender_address_{selected_hash}"
|
| 921 |
-
st.text_area(
|
| 922 |
-
"Sender Address*",
|
| 923 |
-
value=sender_info.get('Address', ''),
|
| 924 |
-
key=widget_key,
|
| 925 |
-
on_change=_update_nested_field,
|
| 926 |
-
args=("Sender.Address", widget_key)
|
| 927 |
-
)
|
| 928 |
-
|
| 929 |
-
widget_key = f"recipient_name_{selected_hash}"
|
| 930 |
-
st.text_input(
|
| 931 |
-
"Recipient Name*",
|
| 932 |
-
value=recipient_info.get('Name', ''),
|
| 933 |
-
key=widget_key,
|
| 934 |
-
on_change=_update_nested_field,
|
| 935 |
-
args=("Recipient.Name", widget_key)
|
| 936 |
-
)
|
| 937 |
-
|
| 938 |
-
widget_key = f"recipient_address_{selected_hash}"
|
| 939 |
-
st.text_area(
|
| 940 |
-
"Recipient Address*",
|
| 941 |
-
value=recipient_info.get('Address', ''),
|
| 942 |
-
key=widget_key,
|
| 943 |
-
on_change=_update_nested_field,
|
| 944 |
-
args=("Recipient.Address", widget_key)
|
| 945 |
-
)
|
| 946 |
-
|
| 947 |
if st.button("⇄ Swap", help="Swap sender and recipient information", key=f"swap_{selected_hash}"):
|
| 948 |
-
|
| 949 |
-
st.session_state.batch_results[selected_hash]["edited_data"] = data
|
| 950 |
st.rerun()
|
| 951 |
|
| 952 |
# ---------- Bank Details ----------
|
| 953 |
with tabs[2]:
|
| 954 |
-
bank_info =
|
| 955 |
with st.container():
|
| 956 |
-
|
| 957 |
-
st.text_input(
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
widget_key = f"bank_account_{selected_hash}"
|
| 966 |
-
st.text_input(
|
| 967 |
-
"Account Number",
|
| 968 |
-
value=bank_info.get('bank_account_number', '') or bank_info.get('bank_acc_no',''),
|
| 969 |
-
key=widget_key,
|
| 970 |
-
on_change=_update_nested_field,
|
| 971 |
-
args=("Bank Details.bank_account_number", widget_key)
|
| 972 |
-
)
|
| 973 |
-
|
| 974 |
-
widget_key = f"bank_acc_name_{selected_hash}"
|
| 975 |
-
st.text_input(
|
| 976 |
-
"Bank Account Name",
|
| 977 |
-
value=bank_info.get('bank_acc_name', '') or bank_info.get('bank_acc_name', ''),
|
| 978 |
-
key=widget_key,
|
| 979 |
-
on_change=_update_nested_field,
|
| 980 |
-
args=("Bank Details.bank_acc_name", widget_key)
|
| 981 |
-
)
|
| 982 |
-
|
| 983 |
-
widget_key = f"iban_{selected_hash}"
|
| 984 |
-
st.text_input(
|
| 985 |
-
"IBAN",
|
| 986 |
-
value=bank_info.get('bank_iban', ''),
|
| 987 |
-
key=widget_key,
|
| 988 |
-
on_change=_update_nested_field,
|
| 989 |
-
args=("Bank Details.bank_iban", widget_key)
|
| 990 |
-
)
|
| 991 |
-
|
| 992 |
-
widget_key = f"swift_code_{selected_hash}"
|
| 993 |
-
st.text_input(
|
| 994 |
-
"SWIFT Code",
|
| 995 |
-
value=bank_info.get('bank_swift', ''),
|
| 996 |
-
key=widget_key,
|
| 997 |
-
on_change=_update_nested_field,
|
| 998 |
-
args=("Bank Details.bank_swift", widget_key)
|
| 999 |
-
)
|
| 1000 |
-
|
| 1001 |
-
widget_key = f"routing_{selected_hash}"
|
| 1002 |
-
st.text_input(
|
| 1003 |
-
"Routing Number",
|
| 1004 |
-
value=bank_info.get('bank_routing', ''),
|
| 1005 |
-
key=widget_key,
|
| 1006 |
-
on_change=_update_nested_field,
|
| 1007 |
-
args=("Bank Details.bank_routing", widget_key)
|
| 1008 |
-
)
|
| 1009 |
-
|
| 1010 |
-
widget_key = f"branch_{selected_hash}"
|
| 1011 |
-
st.text_input(
|
| 1012 |
-
"Branch",
|
| 1013 |
-
value=bank_info.get('bank_branch', ''),
|
| 1014 |
-
key=widget_key,
|
| 1015 |
-
on_change=_update_nested_field,
|
| 1016 |
-
args=("Bank Details.bank_branch", widget_key)
|
| 1017 |
-
)
|
| 1018 |
|
| 1019 |
# ---------- Line Items ----------
|
| 1020 |
with tabs[3]:
|
| 1021 |
editor_key = f"item_editor_{selected_hash}"
|
| 1022 |
-
|
| 1023 |
-
item_rows = data.get('Itemized Data', [])
|
| 1024 |
df = pd.DataFrame(item_rows)
|
| 1025 |
for col in ["Description", "Quantity", "Unit Price", "Amount", "Tax", "Line Total"]:
|
| 1026 |
if col not in df.columns:
|
| 1027 |
df[col] = ""
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
st.data_editor(
|
| 1031 |
df,
|
| 1032 |
num_rows="dynamic",
|
| 1033 |
key=editor_key,
|
| 1034 |
use_container_width=True,
|
| 1035 |
-
on_change=_update_line_items,
|
| 1036 |
-
args=(editor_key,)
|
| 1037 |
)
|
| 1038 |
-
|
| 1039 |
-
if len(df) == 0:
|
| 1040 |
st.info("No line items found in the invoice.")
|
| 1041 |
|
| 1042 |
-
# Save button (per file)
|
| 1043 |
-
if st.button("💾 Save Edits for This File
|
| 1044 |
-
#
|
| 1045 |
-
|
|
|
|
|
|
|
|
|
|
| 1046 |
|
| 1047 |
# Download buttons (per file)
|
| 1048 |
st.markdown("---")
|
| 1049 |
col_a, col_b, col_c = st.columns([1, 1, 1])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1050 |
with col_b:
|
| 1051 |
# ✅ Flatten entire invoice into rows (one per line item)
|
| 1052 |
-
rows = flatten_invoice_to_rows(
|
| 1053 |
full_df = pd.DataFrame(rows)
|
| 1054 |
|
| 1055 |
# Optional: Reorder columns for better readability
|
|
@@ -1076,6 +870,11 @@ elif len(st.session_state.batch_results) > 0:
|
|
| 1076 |
mime="text/csv",
|
| 1077 |
key=f"dl_csv_{selected_hash}"
|
| 1078 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1079 |
|
| 1080 |
# ---------------------------
|
| 1081 |
# PROCESSING STATE — Show progress
|
|
|
|
| 1 |
import os
|
| 2 |
# --- Fix: ensure HOME is writable before Streamlit initializes ---
|
| 3 |
from pathlib import Path
|
| 4 |
+
def safe_number_input(label, value, key):
|
| 5 |
+
try:
|
| 6 |
+
v = float(value)
|
| 7 |
+
except Exception:
|
| 8 |
+
v = 0.0
|
| 9 |
+
return st.number_input(label, value=v, key=key)
|
| 10 |
|
| 11 |
_home = os.environ.get("HOME", "")
|
| 12 |
if _home in ("", "/", None):
|
|
|
|
| 38 |
from PIL import Image
|
| 39 |
from huggingface_hub import login
|
| 40 |
|
| 41 |
+
# 👇 ADD CALLBACK FUNCTION AT TOP LEVEL
|
| 42 |
+
def trigger_rerun():
|
| 43 |
+
# Forces immediate rerun after any edit — ensures edited_df is fresh
|
| 44 |
+
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
# ---------------------------
|
| 47 |
# UI: main
|
|
|
|
| 207 |
# ---------------------------
|
| 208 |
def map_prediction_to_ui(pred):
|
| 209 |
import json, re
|
| 210 |
+
from datetime import datetime
|
| 211 |
|
|
|
|
| 212 |
def safe_json_load(s):
|
| 213 |
if s is None:
|
| 214 |
return None
|
| 215 |
if isinstance(s, (dict, list)):
|
| 216 |
return s
|
| 217 |
if isinstance(s, str):
|
|
|
|
|
|
|
|
|
|
| 218 |
try:
|
| 219 |
return json.loads(s)
|
| 220 |
except Exception:
|
| 221 |
+
try:
|
| 222 |
+
t = s.strip()
|
| 223 |
+
t = t.replace("\\'", "'").replace('\"{', '{').replace('}\"', '}')
|
| 224 |
+
return json.loads(t)
|
| 225 |
+
except Exception:
|
| 226 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
return None
|
| 228 |
|
|
|
|
| 229 |
def clean_number(x):
|
| 230 |
if x is None:
|
| 231 |
return 0.0
|
|
|
|
| 234 |
s = str(x).strip()
|
| 235 |
if s == "":
|
| 236 |
return 0.0
|
|
|
|
| 237 |
s = re.sub(r"[,\s]", "", s)
|
| 238 |
s = re.sub(r"[^\d\.\-]", "", s)
|
| 239 |
if s in ("", ".", "-", "-."):
|
|
|
|
| 243 |
except Exception:
|
| 244 |
return 0.0
|
| 245 |
|
| 246 |
+
def parse_date(s):
|
| 247 |
+
if not s:
|
| 248 |
+
return ""
|
| 249 |
+
s = str(s).strip()
|
| 250 |
+
for fmt in ("%Y-%m-%d", "%d-%m-%Y", "%d/%m/%Y", "%m/%d/%Y", "%d.%m.%Y"):
|
| 251 |
+
try:
|
| 252 |
+
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
|
| 253 |
+
except Exception:
|
| 254 |
+
pass
|
| 255 |
+
m = re.match(r"^(\d{1,2})/(\d{1,2})/(\d{4})$", s)
|
| 256 |
+
if m:
|
| 257 |
+
a, b, y = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
| 258 |
+
if a > 12:
|
| 259 |
+
d, mo = a, b
|
| 260 |
+
else:
|
| 261 |
+
mo, d = a, b
|
| 262 |
+
try:
|
| 263 |
+
return datetime(year=y, month=mo, day=d).strftime("%Y-%m-%d")
|
| 264 |
+
except Exception:
|
| 265 |
+
return s
|
| 266 |
+
return s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
|
|
|
| 268 |
ui = {
|
| 269 |
"Invoice Number": "",
|
| 270 |
"Invoice Date": "",
|
|
|
|
| 284 |
"Itemized Data": []
|
| 285 |
}
|
| 286 |
|
| 287 |
+
if pred is None:
|
| 288 |
+
return ui
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
|
| 290 |
+
if isinstance(pred, str):
|
| 291 |
+
parsed = safe_json_load(pred)
|
| 292 |
+
if parsed is not None:
|
| 293 |
+
pred = parsed
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
+
gt = None
|
| 296 |
+
if isinstance(pred, dict):
|
| 297 |
+
if "gt_parse" in pred:
|
| 298 |
+
gp = pred["gt_parse"]
|
| 299 |
+
gp_parsed = safe_json_load(gp)
|
| 300 |
+
gt = gp_parsed if gp_parsed is not None else (gp if isinstance(gp, dict) else {})
|
| 301 |
+
else:
|
| 302 |
+
gt = pred
|
| 303 |
+
else:
|
| 304 |
+
return ui
|
| 305 |
+
|
| 306 |
+
header = gt.get("header") or {}
|
| 307 |
+
items = gt.get("items") or []
|
| 308 |
+
summary = gt.get("summary") or {}
|
| 309 |
+
|
| 310 |
+
ui["Invoice Number"] = header.get("invoice_no") or header.get("invoice_number") or ui["Invoice Number"]
|
| 311 |
+
ui["Invoice Date"] = str(header.get("invoice_date") or header.get("inv_date") or "")
|
| 312 |
+
ui["Due Date"] = str(header.get("due_date") or header.get("due") or "")
|
| 313 |
+
|
| 314 |
+
ui["Sender Name"] = header.get("sender_name") or header.get("seller_name") or header.get("from_name") or ui["Sender Name"]
|
| 315 |
+
ui["Sender Address"] = header.get("sender_addr") or header.get("sender_address") or header.get("seller_addr") or ui["Sender Address"]
|
| 316 |
+
ui["Recipient Name"] = header.get("rcpt_name") or header.get("recipient_name") or header.get("to_name") or ui["Recipient Name"]
|
| 317 |
+
ui["Recipient Address"] = header.get("rcpt_addr") or header.get("rcpt_address") or header.get("recipient_address") or ui["Recipient Address"]
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
ui["Sender"] = {"Name": ui["Sender Name"], "Address": ui["Sender Address"]}
|
| 320 |
ui["Recipient"] = {"Name": ui["Recipient Name"], "Address": ui["Recipient Address"]}
|
| 321 |
|
| 322 |
+
bank = {}
|
| 323 |
+
if header.get("bank_name"):
|
| 324 |
+
bank["bank_name"] = str(header.get("bank_name")).strip()
|
| 325 |
+
if header.get("bank_acc_no"):
|
| 326 |
+
bank["bank_account_number"] = str(header.get("bank_acc_no")).strip()
|
| 327 |
+
if header.get("bank_account_number"):
|
| 328 |
+
bank["bank_account_number"] = bank.get("bank_account_number") or str(header.get("bank_account_number")).strip()
|
| 329 |
+
if header.get("bank_iban"):
|
| 330 |
+
bank["bank_iban"] = str(header.get("bank_iban")).strip()
|
| 331 |
+
if header.get("bank_routing"):
|
| 332 |
+
bank["bank_routing"] = str(header.get("bank_routing")).strip()
|
| 333 |
+
if header.get("bank_swift"):
|
| 334 |
+
bank["bank_swift"] = str(header.get("bank_swift")).strip()
|
| 335 |
+
if header.get("bank_branch"):
|
| 336 |
+
bank["bank_branch"] = str(header.get("bank_branch")).strip()
|
| 337 |
+
if header.get("bank_acc_name"):
|
| 338 |
+
bank["bank_acc_name"] = str(header.get("bank_acc_name")).strip()
|
| 339 |
+
hb = header.get("bank")
|
| 340 |
+
if isinstance(hb, dict):
|
| 341 |
+
for k, v in hb.items():
|
| 342 |
+
if not v:
|
| 343 |
+
continue
|
| 344 |
+
lk = k.lower()
|
| 345 |
+
if "iban" in lk:
|
| 346 |
+
bank["bank_iban"] = bank.get("bank_iban") or str(v).strip()
|
| 347 |
+
elif "swift" in lk:
|
| 348 |
+
bank["bank_swift"] = bank.get("bank_swift") or str(v).strip()
|
| 349 |
+
elif "acc" in lk or "account" in lk:
|
| 350 |
+
bank["bank_account_number"] = bank.get("bank_account_number") or str(v).strip()
|
| 351 |
+
elif "name" in lk and "bank" in lk:
|
| 352 |
+
bank["bank_name"] = bank.get("bank_name") or str(v).strip()
|
| 353 |
+
elif "branch" in lk:
|
| 354 |
+
bank["bank_branch"] = bank.get("bank_branch") or str(v).strip()
|
| 355 |
+
elif "acc_name" in lk or "account_name" in lk:
|
| 356 |
+
bank["bank_acc_name"] = bank.get("bank_acc_name") or str(v).strip()
|
| 357 |
+
|
| 358 |
+
ui["Bank Details"] = bank
|
| 359 |
+
|
| 360 |
+
ui["Subtotal"] = clean_number(summary.get("subtotal") or summary.get("sub_total") or summary.get("subTotal"))
|
| 361 |
+
ui["Tax Percentage"] = clean_number(summary.get("tax_rate") or summary.get("taxRate") or summary.get("tax_percentage"))
|
| 362 |
+
ui["Total Tax"] = clean_number(summary.get("tax_amount") or summary.get("tax") or summary.get("taxAmount"))
|
| 363 |
+
ui["Total Amount"] = clean_number(summary.get("total_amount") or summary.get("grand_total") or summary.get("total") or summary.get("amount_total"))
|
| 364 |
+
ui["Currency"] = summary.get("currency") or header.get("currency") or ui["Currency"] or ""
|
| 365 |
+
|
| 366 |
+
normalized_items = []
|
| 367 |
+
|
| 368 |
+
if isinstance(items, str):
|
| 369 |
+
parsed_items = safe_json_load(items)
|
| 370 |
+
if parsed_items is not None:
|
| 371 |
+
items = parsed_items
|
| 372 |
+
|
| 373 |
+
if isinstance(items, dict):
|
| 374 |
+
if any(isinstance(v, list) for v in items.values()):
|
| 375 |
+
list_cols = {k: v for k, v in items.items() if isinstance(v, list)}
|
| 376 |
+
max_len = max((len(v) for v in list_cols.values()), default=0)
|
| 377 |
+
for i in range(max_len):
|
| 378 |
+
row = {}
|
| 379 |
+
for k, v in items.items():
|
| 380 |
+
if isinstance(v, list):
|
| 381 |
+
row[k] = v[i] if i < len(v) else ""
|
| 382 |
+
else:
|
| 383 |
+
row[k] = v
|
| 384 |
+
normalized_items.append(row)
|
| 385 |
+
else:
|
| 386 |
+
normalized_items.append(items)
|
| 387 |
+
elif isinstance(items, list):
|
| 388 |
+
normalized_items = items
|
| 389 |
+
else:
|
| 390 |
+
normalized_items = []
|
| 391 |
+
|
| 392 |
+
item_rows = []
|
| 393 |
+
for it in normalized_items:
|
| 394 |
+
if not isinstance(it, dict):
|
| 395 |
+
item_rows.append({"Description": str(it), "Quantity": 1, "Unit Price": 0.0, "Amount": 0.0, "Tax": 0.0, "Line Total": 0.0})
|
| 396 |
+
continue
|
| 397 |
+
desc = it.get("descriptions") or it.get("description") or it.get("desc") or it.get("item") or it.get("name") or ""
|
| 398 |
+
qty = it.get("quantity") or it.get("qty") or it.get("Quantity") or ""
|
| 399 |
+
unit = it.get("unit_price") or it.get("unitPrice") or it.get("price") or ""
|
| 400 |
+
amt = it.get("amount") or it.get("Line_total") or it.get("line_total") or it.get("total") or ""
|
| 401 |
+
|
| 402 |
+
# Extract item-level tax if available under common keys
|
| 403 |
+
tax_val = it.get("tax") or it.get("tax_amount") or it.get("line_tax") or it.get("item_tax") or it.get("taxAmount") or ""
|
| 404 |
+
|
| 405 |
+
# Extract explicit line total if present; otherwise fall back to amount
|
| 406 |
+
line_total_val = it.get("Line_total") or it.get("line_total") or it.get("lineTotal") or amt
|
| 407 |
+
|
| 408 |
+
item_rows.append({
|
| 409 |
+
"Description": str(desc).strip(),
|
| 410 |
+
"Quantity": float(clean_number(qty)),
|
| 411 |
+
"Unit Price": float(clean_number(unit)),
|
| 412 |
+
"Amount": float(clean_number(amt)),
|
| 413 |
+
"Tax": float(clean_number(tax_val)),
|
| 414 |
+
"Line Total": float(clean_number(line_total_val))
|
| 415 |
+
})
|
| 416 |
+
|
| 417 |
+
ui["Itemized Data"] = item_rows
|
| 418 |
+
|
| 419 |
return ui
|
| 420 |
|
| 421 |
# ---------------------------
|
|
|
|
| 425 |
"""
|
| 426 |
Converts nested invoice data into a flat list of rows (one per line item),
|
| 427 |
with invoice-level and sender/recipient/bank fields repeated in each row.
|
|
|
|
| 428 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
rows = []
|
| 430 |
line_items = invoice_data.get("Itemized Data", [])
|
| 431 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
if not line_items:
|
| 433 |
# If no line items, create one row with invoice info only
|
| 434 |
+
row = {
|
| 435 |
+
"Invoice Number": invoice_data.get("Invoice Number", ""),
|
| 436 |
+
"Invoice Date": invoice_data.get("Invoice Date", ""),
|
| 437 |
+
"Due Date": invoice_data.get("Due Date", ""),
|
| 438 |
+
"Currency": invoice_data.get("Currency", ""),
|
| 439 |
+
"Subtotal": invoice_data.get("Subtotal", 0.0),
|
| 440 |
+
"Tax Percentage": invoice_data.get("Tax Percentage", 0.0),
|
| 441 |
+
"Total Tax": invoice_data.get("Total Tax", 0.0),
|
| 442 |
+
"Total Amount": invoice_data.get("Total Amount", 0.0),
|
| 443 |
+
"Sender Name": invoice_data.get("Sender", {}).get("Name", ""),
|
| 444 |
+
"Sender Address": invoice_data.get("Sender", {}).get("Address", ""),
|
| 445 |
+
"Recipient Name": invoice_data.get("Recipient", {}).get("Name", ""),
|
| 446 |
+
"Recipient Address": invoice_data.get("Recipient", {}).get("Address", ""),
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
# Flatten bank details
|
| 450 |
+
bank = invoice_data.get("Bank Details", {})
|
| 451 |
+
for k, v in bank.items():
|
| 452 |
+
# Avoid double-prefixing if key already contains 'bank_'
|
| 453 |
+
key_name = k if str(k).startswith("bank_") else f"bank_{k}"
|
| 454 |
+
row[key_name] = v
|
| 455 |
+
|
| 456 |
+
# Add empty line item fields
|
| 457 |
row.update({
|
| 458 |
"Item Description": "",
|
| 459 |
"Item Quantity": 0,
|
|
|
|
| 467 |
|
| 468 |
# For each line item, create a row with all invoice context
|
| 469 |
for item in line_items:
|
| 470 |
+
row = {
|
| 471 |
+
"Invoice Number": invoice_data.get("Invoice Number", ""),
|
| 472 |
+
"Invoice Date": invoice_data.get("Invoice Date", ""),
|
| 473 |
+
"Due Date": invoice_data.get("Due Date", ""),
|
| 474 |
+
"Currency": invoice_data.get("Currency", ""),
|
| 475 |
+
"Subtotal": invoice_data.get("Subtotal", 0.0),
|
| 476 |
+
"Tax Percentage": invoice_data.get("Tax Percentage", 0.0),
|
| 477 |
+
"Total Tax": invoice_data.get("Total Tax", 0.0),
|
| 478 |
+
"Total Amount": invoice_data.get("Total Amount", 0.0),
|
| 479 |
+
"Sender Name": invoice_data.get("Sender", {}).get("Name", ""),
|
| 480 |
+
"Sender Address": invoice_data.get("Sender", {}).get("Address", ""),
|
| 481 |
+
"Recipient Name": invoice_data.get("Recipient", {}).get("Name", ""),
|
| 482 |
+
"Recipient Address": invoice_data.get("Recipient", {}).get("Address", ""),
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
# Flatten bank details
|
| 486 |
+
bank = invoice_data.get("Bank Details", {})
|
| 487 |
+
for k, v in bank.items():
|
| 488 |
+
# Avoid double-prefixing if key already contains 'bank_'
|
| 489 |
+
key_name = k if str(k).startswith("bank_") else f"bank_{k}"
|
| 490 |
+
row[key_name] = v
|
| 491 |
+
|
| 492 |
+
# Add line item fields
|
| 493 |
row.update({
|
| 494 |
"Item Description": item.get("Description", ""),
|
| 495 |
"Item Quantity": item.get("Quantity", 0),
|
|
|
|
| 498 |
"Item Tax": item.get("Tax", 0.0),
|
| 499 |
"Item Line Total": item.get("Line Total", item.get("Amount", 0.0)),
|
| 500 |
})
|
| 501 |
+
|
| 502 |
rows.append(row)
|
| 503 |
|
| 504 |
return rows
|
|
|
|
| 609 |
# RESULTS VIEW — Show selector + editable form
|
| 610 |
# ---------------------------
|
| 611 |
elif len(st.session_state.batch_results) > 0:
|
| 612 |
+
# ---------------------------
|
| 613 |
+
# Global Download All — produce a single Excel file (concatenated rows) and trigger direct download
|
| 614 |
+
# ---------------------------
|
| 615 |
if st.button("📦 Download All Results (Excel)", key="download_all"):
|
| 616 |
+
# Collect rows from all invoices and concatenate into one DataFrame
|
| 617 |
all_rows = []
|
| 618 |
for file_hash, result in st.session_state.batch_results.items():
|
| 619 |
rows = flatten_invoice_to_rows(result["edited_data"])
|
| 620 |
+
# Annotate rows with source file name so user can identify which invoice each row came from
|
| 621 |
for r in rows:
|
| 622 |
r["Source File"] = result.get("file_name", file_hash)
|
| 623 |
all_rows.extend(rows)
|
|
|
|
| 627 |
else:
|
| 628 |
full_df = pd.DataFrame(all_rows)
|
| 629 |
|
| 630 |
+
# Reorder columns to put Source File first
|
| 631 |
cols = list(full_df.columns)
|
| 632 |
if "Source File" in cols:
|
| 633 |
cols = ["Source File"] + [c for c in cols if c != "Source File"]
|
| 634 |
full_df = full_df[cols]
|
| 635 |
|
| 636 |
+
# Try to write XLSX (preferred). If engine not available, fall back to CSV.
|
| 637 |
buffer = BytesIO()
|
| 638 |
dl_filename = "all_extracted_invoices.xlsx"
|
| 639 |
tried_xlsx = False
|
|
|
|
| 654 |
dl_filename = "all_extracted_invoices.csv"
|
| 655 |
mime = "text/csv"
|
| 656 |
|
| 657 |
+
# Trigger immediate download via a data URI and small HTML snippet
|
| 658 |
import base64
|
| 659 |
import streamlit.components.v1 as components
|
| 660 |
b64 = base64.b64encode(file_bytes).decode()
|
|
|
|
| 696 |
# Get current file data
|
| 697 |
current = st.session_state.batch_results[selected_hash]
|
| 698 |
image = current["image"]
|
| 699 |
+
import copy
|
| 700 |
+
form_data = copy.deepcopy(current["edited_data"])
|
| 701 |
|
| 702 |
# Layout
|
| 703 |
left_col, right_col = st.columns([1, 1])
|
|
|
|
| 734 |
st.rerun()
|
| 735 |
except Exception as e:
|
| 736 |
st.error(f"Re-run failed: {e}")
|
|
|
|
| 737 |
tabs = st.tabs(["Invoice Details", "Sender/Recipient info", "Bank Details", "Line Items"])
|
| 738 |
|
| 739 |
st.markdown(
|
|
|
|
| 762 |
# ---------- Invoice Details ----------
|
| 763 |
with tabs[0]:
|
| 764 |
with st.container():
|
| 765 |
+
form_data['Invoice Number'] = st.text_input("Invoice Number", value=form_data.get('Invoice Number', ''), key=f"invoice_number_{selected_hash}")
|
| 766 |
+
form_data['Invoice Date'] = st.text_input("Invoice Date", value=str(form_data.get('Invoice Date', '')).strip(), key=f"invoice_date_text_{selected_hash}")
|
| 767 |
+
form_data['Due Date'] = st.text_input("Due Date", value=str(form_data.get('Due Date', '')).strip(), key=f"due_date_text_{selected_hash}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
curr_options = ['USD', 'EUR', 'GBP', 'INR', 'Other']
|
| 769 |
+
curr_value = form_data.get('Currency', 'USD')
|
| 770 |
curr_index = curr_options.index(curr_value) if curr_value in curr_options else (len(curr_options) - 1)
|
| 771 |
+
new_curr = st.selectbox("Currency", options=curr_options, index=curr_index, key=f"currency_select_{selected_hash}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 772 |
if new_curr == 'Other':
|
| 773 |
+
new_curr = st.text_input("Specify Currency", value=form_data.get('Currency', ''), key=f"custom_currency_{selected_hash}")
|
| 774 |
+
form_data['Currency'] = new_curr
|
| 775 |
+
form_data['Subtotal'] = safe_number_input("Subtotal", form_data.get('Subtotal', 0.0), f"subtotal_{selected_hash}")
|
| 776 |
+
form_data['Tax Percentage'] = safe_number_input("Tax Percentage", form_data.get('Tax Percentage', 0.0), f"tax_pct_{selected_hash}")
|
| 777 |
+
form_data['Total Tax'] = safe_number_input("Total Tax", form_data.get('Total Tax', 0.0), f"total_tax_{selected_hash}")
|
| 778 |
+
form_data['Total Amount'] = safe_number_input("Total Amount", form_data.get('Total Amount', 0.0), f"total_amount_{selected_hash}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 779 |
|
| 780 |
# ---------- Sender / Recipient ----------
|
| 781 |
with tabs[1]:
|
| 782 |
+
sender_info = form_data.get('Sender', {'Name': '', 'Address': ''})
|
| 783 |
+
recipient_info = form_data.get('Recipient', {'Name': '', 'Address': ''})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 784 |
with st.container():
|
| 785 |
+
sender_info['Name'] = st.text_input("Sender Name*", value=sender_info.get('Name', ''), key=f"sender_name_{selected_hash}")
|
| 786 |
+
sender_info['Address'] = st.text_area("Sender Address*", value=sender_info.get('Address', ''), key=f"sender_address_{selected_hash}")
|
| 787 |
+
recipient_info['Name'] = st.text_input("Recipient Name*", value=recipient_info.get('Name', ''), key=f"recipient_name_{selected_hash}")
|
| 788 |
+
recipient_info['Address'] = st.text_area("Recipient Address*", value=recipient_info.get('Address', ''), key=f"recipient_address_{selected_hash}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 789 |
if st.button("⇄ Swap", help="Swap sender and recipient information", key=f"swap_{selected_hash}"):
|
| 790 |
+
form_data['Sender'], form_data['Recipient'] = form_data['Recipient'], form_data['Sender']
|
|
|
|
| 791 |
st.rerun()
|
| 792 |
|
| 793 |
# ---------- Bank Details ----------
|
| 794 |
with tabs[2]:
|
| 795 |
+
bank_info = form_data.get('Bank Details', {}) or {}
|
| 796 |
with st.container():
|
| 797 |
+
bank_info['bank_name'] = st.text_input("Bank Name", value=bank_info.get('bank_name', ''), key=f"bank_name_{selected_hash}")
|
| 798 |
+
bank_info['bank_account_number'] = st.text_input("Account Number", value=bank_info.get('bank_account_number', '') or bank_info.get('bank_acc_no',''), key=f"bank_account_{selected_hash}")
|
| 799 |
+
bank_info['bank_acc_name'] = st.text_input("Bank Account Name", value=bank_info.get('bank_acc_name', '') or bank_info.get('bank_acc_name', ''), key=f"bank_acc_name_{selected_hash}")
|
| 800 |
+
bank_info['bank_iban'] = st.text_input("IBAN", value=bank_info.get('bank_iban', ''), key=f"iban_{selected_hash}")
|
| 801 |
+
bank_info['bank_swift'] = st.text_input("SWIFT Code", value=bank_info.get('bank_swift', ''), key=f"swift_code_{selected_hash}")
|
| 802 |
+
bank_info['bank_routing'] = st.text_input("Routing Number", value=bank_info.get('bank_routing', ''), key=f"routing_{selected_hash}")
|
| 803 |
+
bank_info['bank_branch'] = st.text_input("Branch", value=bank_info.get('bank_branch', ''), key=f"branch_{selected_hash}")
|
| 804 |
+
form_data['Bank Details'] = bank_info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 805 |
|
| 806 |
# ---------- Line Items ----------
|
| 807 |
with tabs[3]:
|
| 808 |
editor_key = f"item_editor_{selected_hash}"
|
| 809 |
+
item_rows = form_data.get('Itemized Data', [])
|
|
|
|
| 810 |
df = pd.DataFrame(item_rows)
|
| 811 |
for col in ["Description", "Quantity", "Unit Price", "Amount", "Tax", "Line Total"]:
|
| 812 |
if col not in df.columns:
|
| 813 |
df[col] = ""
|
| 814 |
+
st.write("✏️ Edit line items below. Press Enter or click outside a cell to confirm each edit.")
|
| 815 |
+
edited_df = st.data_editor(
|
|
|
|
| 816 |
df,
|
| 817 |
num_rows="dynamic",
|
| 818 |
key=editor_key,
|
| 819 |
use_container_width=True,
|
|
|
|
|
|
|
| 820 |
)
|
| 821 |
+
if len(edited_df) == 0:
|
|
|
|
| 822 |
st.info("No line items found in the invoice.")
|
| 823 |
|
| 824 |
+
# Save button (per file)
|
| 825 |
+
if st.button("💾 Save Edits for This File", key=f"save_{selected_hash}"):
|
| 826 |
+
# Update line items from data editor
|
| 827 |
+
form_data['Itemized Data'] = edited_df.to_dict('records')
|
| 828 |
+
# Update session state with complete form data
|
| 829 |
+
st.session_state.batch_results[selected_hash]["edited_data"] = form_data
|
| 830 |
+
st.success(f"✅ Edits saved for {current['file_name']}")
|
| 831 |
|
| 832 |
# Download buttons (per file)
|
| 833 |
st.markdown("---")
|
| 834 |
col_a, col_b, col_c = st.columns([1, 1, 1])
|
| 835 |
+
#with col_a:
|
| 836 |
+
#jsonl_str = json.dumps(data, ensure_ascii=False, indent=2)
|
| 837 |
+
#st.download_button(
|
| 838 |
+
# "📥 Download JSON",
|
| 839 |
+
#jsonl_str.encode("utf-8"),
|
| 840 |
+
#file_name=f"{Path(current['file_name']).stem}_extracted.json",
|
| 841 |
+
#mime="application/json",
|
| 842 |
+
#key=f"dl_json_{selected_hash}"
|
| 843 |
+
#)
|
| 844 |
with col_b:
|
| 845 |
# ✅ Flatten entire invoice into rows (one per line item)
|
| 846 |
+
rows = flatten_invoice_to_rows(form_data)
|
| 847 |
full_df = pd.DataFrame(rows)
|
| 848 |
|
| 849 |
# Optional: Reorder columns for better readability
|
|
|
|
| 870 |
mime="text/csv",
|
| 871 |
key=f"dl_csv_{selected_hash}"
|
| 872 |
)
|
| 873 |
+
# Global Download All — produce a single Excel file (concatenated rows) and trigger direct download
|
| 874 |
+
|
| 875 |
+
|
| 876 |
+
# ---------------------------
|
| 877 |
+
# PROCESSING STATE
|
| 878 |
|
| 879 |
# ---------------------------
|
| 880 |
# PROCESSING STATE — Show progress
|