Packing_List / app.py
vishakhad30's picture
Upload 30 files
802473d verified
Raw History Blame Contribute Delete
10.5 kB
import os
import re
import tempfile
from pathlib import Path
import gradio as gr
import pandas as pd
from pypdf import PdfReader
from parsers.systemax import parse as parse_systemax
from parsers.home_depot import parse as parse_home_depot
from parsers.staples_com import parse as parse_staples_com
from parsers.staples_advantage import parse as parse_staples_advantage
from parsers.walmart import parse as parse_walmart
from parsers.lowes import parse as parse_lowes
from parsers.quill import parse as parse_quill
from parsers.msc_industrial import parse as parse_msc_industrial
from parsers.lowes_rona import parse as parse_lowes_rona
from parsers.united_stationers import parse as parse_united_stationers
from parsers.bh_photo_video import parse as parse_bh_photo_video
HEADERS = ["Trading Partner", "PO Number", "Ship Via", "Order Date", "Source PDF"]
def normalize(text):
text = text.replace("\x00", " ")
text = text.replace("\r", "\n")
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n[ \t]+", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def read_pdf(path):
reader = PdfReader(path)
pages = []
for page in reader.pages:
try:
pages.append(page.extract_text() or "")
except Exception:
pages.append("")
return "\n\n".join(pages)
def identify_vendor(filename):
n = re.sub(r"[^a-z0-9]", "", Path(filename).stem.lower())
# More specific names must be checked before generic substrings
# (e.g. "lowesrona" before "lowes").
if "systemax" in n:
return "SYSTEMAX"
if "homedepot" in n:
return "HOME DEPOT"
if "staplesadvantage" in n:
return "STAPLES ADVANTAGE"
if "staplescom" in n:
return "STAPLES.COM"
if "walmartcomdirecttocustomer" in n or "walmart" in n:
return "WALMART"
if "lowesrona" in n or "ronaps" in n or ("rona" in n and "lowes" in n):
return "LOWE'S / RONA"
if "lowessos" in n or "lowes" in n:
return "LOWE'S"
if "quillcom" in n or "quill" in n:
return "QUILL"
if "mscindustrial" in n or n.startswith("msc"):
return "MSC INDUSTRIAL SUPPLY"
if "unitedstationers" in n or "essendant" in n:
return "UNITED STATIONERS"
if "bandhphotovideo" in n or "bhphotovideo" in n:
return "B&H PHOTO-VIDEO"
return "UNKNOWN"
PARSERS = {
"SYSTEMAX": parse_systemax,
"HOME DEPOT": parse_home_depot,
"STAPLES.COM": parse_staples_com,
"STAPLES ADVANTAGE": parse_staples_advantage,
"WALMART": parse_walmart,
"LOWE'S": parse_lowes,
"QUILL": parse_quill,
"MSC INDUSTRIAL SUPPLY": parse_msc_industrial,
"LOWE'S / RONA": parse_lowes_rona,
"UNITED STATIONERS": parse_united_stationers,
"B&H PHOTO-VIDEO": parse_bh_photo_video,
}
def process_one(path):
filename = Path(path).name
vendor = identify_vendor(filename)
if vendor == "UNKNOWN":
return [], f"⚠️ UNKNOWN VENDOR: {filename}"
raw = read_pdf(path)
text = normalize(raw)
if not text:
return [], f"⚠️ NO TEXT EXTRACTED: {filename}"
rows = PARSERS[vendor](text)
clean = []
for row in rows:
row = {
"Trading Partner": vendor,
"PO Number": str(row.get("PO Number", "")).strip(),
"Ship Via": str(row.get("Ship Via", "")).strip(),
"Order Date": str(row.get("Order Date", "")).strip(),
"Source PDF": filename,
}
# Never allow obvious non-PO text into the PO column.
po = row["PO Number"]
if po and (
len(po) > 40
or "direct inquiries" in po.lower()
or "customer service" in po.lower()
or po.lower() in {
"services", "service", "customer", "sales", "date",
"location", "salesperson", "release", "order",
}
):
row["PO Number"] = ""
# Reject dates mistakenly captured as Ship Via.
ship = row["Ship Via"]
if ship and re.fullmatch(r"\d{1,2}/\d{1,2}/\d{2,4}", ship):
if not row["Order Date"]:
row["Order Date"] = ship
row["Ship Via"] = ""
if any(row[h] for h in HEADERS[:-1]):
clean.append(row)
return clean, f"✅ {vendor}: {len(clean)} record(s)"
def process_files(files):
if not files:
return "Please upload one or more PDF files.", None
all_rows = []
messages = []
for f in files:
path = getattr(f, "name", f)
try:
rows, message = process_one(path)
all_rows.extend(rows)
messages.append(message)
except Exception as e:
messages.append(f"❌ {Path(path).name}: {type(e).__name__}: {e}")
df = pd.DataFrame(all_rows, columns=HEADERS)
# Keep leading zeros by storing PO values as strings.
if not df.empty:
df["PO Number"] = df["PO Number"].astype(str)
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx")
tmp.close()
df.to_excel(tmp.name, index=False, engine="openpyxl")
summary = "\n".join(messages)
summary += f"\n\nTOTAL RECORDS: {len(df)}"
return summary, tmp.name
THEME = gr.themes.Soft(
primary_hue="rose",
secondary_hue="teal",
neutral_hue="stone",
font=[gr.themes.GoogleFont("Nunito"), "ui-sans-serif", "system-ui", "sans-serif"],
font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace"],
).set(
body_background_fill="#f7f3ef",
body_background_fill_dark="#f7f3ef",
block_background_fill="#ffffff",
block_border_width="0px",
block_radius="18px",
block_shadow="0 8px 28px rgba(120, 90, 90, 0.08)",
button_primary_background_fill="#e86b7a",
button_primary_background_fill_hover="#d95a6a",
button_primary_text_color="#ffffff",
button_large_radius="14px",
input_radius="14px",
)
APP_CSS = """
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@500;600;700;800&display=swap');
.gradio-container {
max-width: 1100px !important;
margin: 0 auto !important;
padding: 0.85rem 1rem 1rem !important;
font-family: 'Nunito', ui-sans-serif, system-ui, sans-serif !important;
}
.main-wrap {
display: flex;
flex-direction: column;
gap: 0.7rem !important;
}
.hero-card {
background: linear-gradient(135deg, #fff7f5 0%, #ffffff 55%, #f3fbf8 100%);
border: 1px solid rgba(232, 107, 122, 0.18);
border-radius: 18px;
padding: 0.75rem 1.1rem !important;
box-shadow: 0 10px 30px rgba(180, 110, 110, 0.08);
}
.hero-card h1 {
font-size: 1.4rem !important;
font-weight: 800 !important;
letter-spacing: -0.02em;
color: #3d2c2e !important;
margin: 0 !important;
}
.hero-card p {
display: none !important;
}
.panel {
background: #ffffff;
border-radius: 16px;
padding: 0.65rem 0.75rem 0.55rem !important;
box-shadow: 0 8px 24px rgba(120, 90, 90, 0.07);
border: 1px solid rgba(80, 60, 60, 0.05);
}
.panel .label-wrap span,
.panel label span {
font-weight: 700 !important;
color: #5c4548 !important;
}
button.primary,
.primary {
min-height: 42px !important;
font-weight: 800 !important;
letter-spacing: 0.02em !important;
box-shadow: 0 8px 18px rgba(232, 107, 122, 0.28) !important;
}
/* Force true 50/50 side-by-side layout */
#main-split {
display: grid !important;
grid-template-columns: 1fr 1fr !important;
gap: 0.75rem !important;
align-items: stretch !important;
}
#main-split > div {
min-width: 0 !important;
width: 100% !important;
max-width: none !important;
flex: unset !important;
}
#upload-col {
height: 100% !important;
}
#upload-col .wrap,
#upload-col [data-testid="file"] {
height: 100% !important;
}
#upload-col .file-preview-holder,
#upload-col .file-preview,
#upload-col ul,
#upload-col .scroll-hide {
max-height: 260px !important;
overflow-y: auto !important;
}
#side-col {
display: flex !important;
flex-direction: column !important;
gap: 0.7rem !important;
height: 100% !important;
min-height: 340px !important;
}
#side-col > div {
flex: 1 1 0 !important;
min-height: 160px !important;
}
#status-box textarea {
min-height: 140px !important;
height: 140px !important;
max-height: 180px !important;
overflow-y: auto !important;
resize: none !important;
}
#download-box,
#download-box .wrap,
#download-box [data-testid="file"] {
min-height: 140px !important;
height: 140px !important;
}
@media (max-width: 760px) {
#main-split {
grid-template-columns: 1fr !important;
}
}
footer { display: none !important; }
"""
with gr.Blocks(title="Packing List PDF Extractor") as demo:
with gr.Column(elem_classes=["main-wrap"]):
gr.Markdown("# Packing List PDF Extractor", elem_classes=["hero-card"])
process = gr.Button("Process PDFs", variant="primary", size="lg")
with gr.Row(elem_id="main-split"):
with gr.Column(elem_id="upload-col", elem_classes=["panel"]):
files = gr.File(
label="Upload PDFs",
file_count="multiple",
file_types=[".pdf"],
type="filepath",
height=340,
)
with gr.Column(elem_id="side-col"):
with gr.Column(elem_classes=["panel"]):
status = gr.Textbox(
label="Status",
lines=7,
max_lines=9,
elem_id="status-box",
)
with gr.Column(elem_classes=["panel"]):
download = gr.File(
label="Excel download",
height=140,
elem_id="download-box",
)
clear = gr.Button("Clear form", variant="secondary", size="lg")
def clear_form():
return None, "", None
process.click(
fn=process_files,
inputs=files,
outputs=[status, download],
)
clear.click(
fn=clear_form,
inputs=None,
outputs=[files, status, download],
)
if __name__ == "__main__":
# HF Spaces needs 0.0.0.0; locally bind to localhost so the URL is usable.
on_spaces = bool(os.getenv("SPACE_ID") or os.getenv("SYSTEM") == "spaces")
demo.launch(
server_name="0.0.0.0" if on_spaces else "127.0.0.1",
server_port=7860,
ssr_mode=False,
theme=THEME,
css=APP_CSS,
)