| import json
|
| import math
|
| import os
|
| import tempfile
|
| from datetime import datetime
|
|
|
| import pandas as pd
|
|
|
| from catalog_resolver import resolve_items
|
| from config import call_llm, llm_available
|
| from data import SAMPLE_CATALOG
|
| from parser_fallback import parse_po_fallback
|
| from po_normalizer import normalize_po_text
|
| from prompts import DELIVERY_SYSTEM, BESTSELLER_SYSTEM, build_parse_po_system
|
|
|
|
|
| def _extract_json_from_llm(raw: str) -> dict | None:
|
| json_str = raw.strip()
|
| if "```" in json_str:
|
| json_str = "\n".join(
|
| line for line in json_str.split("\n")
|
| if not line.strip().startswith("```")
|
| )
|
| try:
|
| return json.loads(json_str)
|
| except json.JSONDecodeError:
|
| start, end = raw.find("{"), raw.rfind("}") + 1
|
| if start != -1 and end > start:
|
| try:
|
| return json.loads(raw[start:end])
|
| except json.JSONDecodeError:
|
| return None
|
| return None
|
|
|
|
|
| def _validate_po_data(data: dict) -> dict:
|
| """Stage 3a — enforce structured schema before catalog anchoring."""
|
| items = data.get("items") or []
|
| clean = []
|
| for it in items:
|
| if not isinstance(it, dict):
|
| continue
|
| qty = it.get("quantity", 0)
|
| try:
|
| qty = int(qty)
|
| except (TypeError, ValueError):
|
| qty = 0
|
| product = str(it.get("product", "")).strip()
|
| if product and qty > 0:
|
| clean.append({
|
| "product": product,
|
| "quantity": qty,
|
| "notes": str(it.get("notes", "")).strip(),
|
| })
|
| return {"items": clean, "store_notes": str(data.get("store_notes", "")).strip()}
|
|
|
|
|
| def parse_po(store_name: str, po_text: str, all_pos: dict) -> tuple:
|
| if not po_text.strip():
|
| return pd.DataFrame(columns=["Product", "Qty", "Notes"]), "Please paste a PO first.", all_pos
|
|
|
| used_fallback = False
|
| data = None
|
|
|
|
|
| normalized, slang_fixes = normalize_po_text(po_text)
|
|
|
|
|
| raw = call_llm(
|
| f"Store: {store_name}\n\nPO text:\n{normalized}",
|
| system=build_parse_po_system(SAMPLE_CATALOG),
|
| )
|
| if not raw.startswith("[LLM Error"):
|
| data = _extract_json_from_llm(raw)
|
|
|
| if data is None:
|
| data = parse_po_fallback(normalized)
|
| used_fallback = True
|
| if not data.get("items"):
|
| err = raw[:300] if raw.startswith("[LLM Error") else "No line items detected."
|
| return (
|
| pd.DataFrame(columns=["Product", "Qty", "Notes"]),
|
| f"Could not parse PO. {err}",
|
| all_pos,
|
| )
|
|
|
| data = _validate_po_data(data)
|
|
|
|
|
| raw_items = data.get("items", [])
|
| items = resolve_items(raw_items, SAMPLE_CATALOG)
|
| catalog_hits = sum(
|
| 1 for before, after in zip(raw_items, items)
|
| if before.get("product", "").strip().lower() != after.get("product", "").strip().lower()
|
| )
|
| store_notes = data.get("store_notes", "")
|
| rows = [
|
| {
|
| "Product": it.get("product", ""),
|
| "Qty": it.get("quantity", 0),
|
| "Notes": it.get("notes", ""),
|
| }
|
| for it in items
|
| ]
|
| df = pd.DataFrame(rows) if rows else pd.DataFrame(columns=["Product", "Qty", "Notes"])
|
|
|
| all_pos[store_name] = {
|
| "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
| "items": rows,
|
| "store_notes": store_notes,
|
| }
|
|
|
| total_qty = sum(r["Qty"] for r in rows)
|
| engine = "offline rules" if used_fallback else "Qwen 2.5-7B"
|
| pipeline = "normalize → extract JSON → catalog anchor"
|
| status = (
|
| f"Parsed {len(rows)} items ({total_qty} stickers) from {store_name} | "
|
| f"Pipeline: {pipeline} | Engine: {engine}"
|
| )
|
| if slang_fixes:
|
| status += f" | Slang normalized: {', '.join(slang_fixes[:4])}"
|
| if len(slang_fixes) > 4:
|
| status += f" +{len(slang_fixes) - 4} more"
|
| if catalog_hits:
|
| status += f" | Catalog matched: {catalog_hits} name(s)"
|
| if store_notes:
|
| status += f" | Notes: {store_notes}"
|
| return df, status, all_pos
|
|
|
|
|
| def save_po(store_name: str, edited_table, all_pos: dict) -> tuple:
|
| if edited_table is None or (isinstance(edited_table, pd.DataFrame) and edited_table.empty):
|
| return all_pos, "Nothing to save."
|
| if isinstance(edited_table, pd.DataFrame):
|
| rows = edited_table.to_dict("records")
|
| else:
|
| rows = [{"Product": r[0], "Qty": r[1], "Notes": r[2]} for r in edited_table]
|
| all_pos[store_name] = all_pos.get(
|
| store_name,
|
| {"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), "store_notes": ""},
|
| )
|
| all_pos[store_name]["items"] = rows
|
| return all_pos, f"Saved {len(rows)} items for {store_name}."
|
|
|
|
|
| def build_demand_table(all_pos: dict, stock_df) -> tuple:
|
| if isinstance(stock_df, list):
|
| stock_df = pd.DataFrame(stock_df, columns=["Product", "In Stock"])
|
|
|
| demand: dict[str, int] = {}
|
| stores_with_po: list[str] = []
|
| for store, po_data in all_pos.items():
|
| stores_with_po.append(store)
|
| for item in po_data.get("items", []):
|
| qty = item["Qty"] if isinstance(item["Qty"], (int, float)) else 0
|
| demand[item["Product"]] = demand.get(item["Product"], 0) + int(qty)
|
|
|
| if not demand:
|
| return (
|
| pd.DataFrame(columns=["Product", "Total Demand", "In Stock", "Shortage"]),
|
| "No POs parsed yet. Go to the PO Intake tab first.",
|
| )
|
|
|
| stock_map = {}
|
| if stock_df is not None and not stock_df.empty:
|
| stock_map = {row["Product"]: int(row["In Stock"]) for _, row in stock_df.iterrows()}
|
|
|
| rows = [
|
| {
|
| "Product": p,
|
| "Total Demand": d,
|
| "In Stock": stock_map.get(p, 0),
|
| "Shortage": max(0, d - stock_map.get(p, 0)),
|
| }
|
| for p, d in sorted(demand.items())
|
| ]
|
| df = pd.DataFrame(rows)
|
| shortage_total = sum(r["Shortage"] for r in rows)
|
| summary = (
|
| f"POs from {len(stores_with_po)} stores | "
|
| f"{len(rows)} products | "
|
| f"Shortage: {shortage_total} stickers to produce"
|
| )
|
| return df, summary
|
|
|
|
|
| def calculate_printing(demand_df) -> tuple:
|
| if demand_df is None or (isinstance(demand_df, pd.DataFrame) and demand_df.empty):
|
| return (
|
| pd.DataFrame(columns=["Product", "Qty to Print", "A3 Sheets (8 per sheet)"]),
|
| "No shortage data. Check the Demand tab first.",
|
| None,
|
| )
|
| if isinstance(demand_df, list):
|
| demand_df = pd.DataFrame(demand_df, columns=["Product", "Total Demand", "In Stock", "Shortage"])
|
|
|
| rows: list[dict] = []
|
| total_sheets = 0
|
| for _, row in demand_df.iterrows():
|
| shortage = int(row.get("Shortage", 0))
|
| if shortage > 0:
|
| sheets = math.ceil(shortage / 8)
|
| total_sheets += sheets
|
| rows.append({
|
| "Product": row["Product"],
|
| "Qty to Print": shortage,
|
| "A3 Sheets (8 per sheet)": sheets,
|
| })
|
|
|
| if not rows:
|
| return (
|
| pd.DataFrame(columns=["Product", "Qty to Print", "A3 Sheets (8 per sheet)"]),
|
| "No printing needed -- stock covers all demand!",
|
| None,
|
| )
|
|
|
| df = pd.DataFrame(rows)
|
| path = os.path.join(tempfile.gettempdir(), f"print_order_{datetime.now():%Y%m%d_%H%M%S}.csv")
|
| df.to_csv(path, index=False)
|
|
|
| total_qty = sum(r["Qty to Print"] for r in rows)
|
| summary = f"Print {total_qty} stickers across {len(rows)} varieties | Total A3 sheets: {total_sheets}"
|
| return df, summary, path
|
|
|
|
|
| def _delivery_doc_fallback(store_name: str, items: list, language: str) -> str:
|
| lang = "id" if language.lower().startswith("indo") else "en"
|
| date_str = datetime.now().strftime("%d %B %Y")
|
| lines = items if lang == "en" else items
|
| header = (
|
| f"PAPERAIN STUDIO — DELIVERY NOTE\n{'=' * 40}\n"
|
| f"{'Store' if lang == 'en' else 'Toko'}: {store_name}\n"
|
| f"{'Date' if lang == 'en' else 'Tanggal'}: {date_str}\n\n"
|
| f"{'Items' if lang == 'en' else 'Daftar barang'}:\n"
|
| )
|
| body = ""
|
| total = 0
|
| for i, it in enumerate([x for x in items if x.get("Qty", 0) > 0], 1):
|
| qty = int(it.get("Qty", 0))
|
| total += qty
|
| body += f" {i}. {it['Product']} — {qty} pcs\n"
|
| footer = (
|
| f"\n{'Total stickers' if lang == 'en' else 'Total stiker'}: {total} pcs\n\n"
|
| + (
|
| "Please verify receipt and contact us if anything is missing.\n— Paperain Studio, Yogyakarta"
|
| if lang == "en"
|
| else "Mohon periksa barang saat diterima. Hubungi kami jika ada yang kurang.\n— Paperain Studio, Yogyakarta"
|
| )
|
| )
|
| return header + body + footer
|
|
|
|
|
| def generate_delivery_doc(store_name: str, all_pos: dict, language: str) -> str:
|
| if store_name not in all_pos or not all_pos[store_name].get("items"):
|
| return f"No PO data for {store_name}. Parse their PO first in the PO Intake tab."
|
|
|
| items = all_pos[store_name]["items"]
|
| items_text = "\n".join(
|
| f"- {it['Product']}: {it['Qty']} pcs"
|
| for it in items if it.get("Qty", 0) > 0
|
| )
|
| total = sum(it.get("Qty", 0) for it in items)
|
|
|
| prompt = (
|
| f"Store: {store_name}\n"
|
| f"Date: {datetime.now():%B %d, %Y}\n"
|
| f"Language: {language}\n"
|
| f"Total items: {total}\n\n"
|
| f"Items to deliver:\n{items_text}"
|
| )
|
| raw = call_llm(prompt, system=DELIVERY_SYSTEM)
|
| if raw.startswith("[LLM Error"):
|
| return _delivery_doc_fallback(store_name, items, language)
|
| return raw
|
|
|
|
|
| def generate_bestseller_report(all_pos: dict, language: str) -> str:
|
| if not all_pos:
|
| return "No PO data yet. Parse some store POs first to generate recommendations."
|
|
|
| demand: dict[str, int] = {}
|
| store_demand: dict[str, dict[str, int]] = {}
|
| for store, po_data in all_pos.items():
|
| store_demand[store] = {}
|
| for item in po_data.get("items", []):
|
| qty = int(item.get("Qty", 0)) if isinstance(item.get("Qty", 0), (int, float)) else 0
|
| demand[item["Product"]] = demand.get(item["Product"], 0) + qty
|
| store_demand[store][item["Product"]] = qty
|
|
|
| if not demand:
|
| return "No order data to analyze."
|
|
|
| sales_text = "Aggregated demand across all stores:\n"
|
| for product, total in sorted(demand.items(), key=lambda x: x[1], reverse=True):
|
| stores = [
|
| f"{s} ({store_demand[s].get(product, 0)})"
|
| for s in store_demand if store_demand[s].get(product, 0) > 0
|
| ]
|
| sales_text += f"- {product}: {total} total ({', '.join(stores)})\n"
|
|
|
| raw = call_llm(f"Language: {language}\n\n{sales_text}", system=BESTSELLER_SYSTEM)
|
| if raw.startswith("[LLM Error"):
|
| return _bestseller_fallback(demand, store_demand, language)
|
| return raw
|
|
|
|
|
| def _bestseller_fallback(demand: dict, store_demand: dict, language: str) -> str:
|
| lang = "id" if language.lower().startswith("indo") else "en"
|
| top = sorted(demand.items(), key=lambda x: x[1], reverse=True)[:8]
|
| title = "Piper's Best Seller Report" if lang == "en" else "Laporan Best Seller dari Piper"
|
| lines = [f"{title}\n{'=' * 36}\n"]
|
| for rank, (product, total) in enumerate(top, 1):
|
| stores = [s for s, d in store_demand.items() if d.get(product, 0) > 0]
|
| if lang == "en":
|
| lines.append(f"{rank}. {product} — {total} ordered across {len(stores)} store(s)")
|
| else:
|
| lines.append(f"{rank}. {product} — {total} dipesan dari {len(stores)} toko")
|
| lines.append(
|
| "\nThese designs sell consistently — great for restock and new partner onboarding."
|
| if lang == "en"
|
| else "\nDesain ini laris — cocok untuk restock dan rekomendasi ke toko baru."
|
| )
|
| return "\n".join(lines)
|
|
|
|
|
| def run_full_workflow(all_pos: dict, stock_df) -> tuple:
|
| """Demand + print in one click (uses existing PO state)."""
|
| demand_df, demand_summary = build_demand_table(all_pos, stock_df)
|
| print_df, print_summary, print_csv = calculate_printing(demand_df)
|
| combined = f"{demand_summary}\n\n{print_summary}"
|
| return demand_df, combined, print_df, print_summary, print_csv
|
|
|
|
|
| def format_po_log(all_pos: dict) -> str:
|
| if not all_pos:
|
| return "*No POs parsed yet.*"
|
| md = ""
|
| for store, data in all_pos.items():
|
| md += f"### {store} ({data.get('timestamp', '')})\n"
|
| for it in data.get("items", []):
|
| md += f"- {it.get('Product', '')} x{it.get('Qty', 0)}\n"
|
| if data.get("store_notes"):
|
| md += f"\n*Notes: {data['store_notes']}*\n"
|
| md += "\n---\n\n"
|
| return md
|
|
|
|
|
| def export_all_pos_csv(all_pos: dict):
|
| if not all_pos:
|
| return None
|
| rows = []
|
| for store, data in all_pos.items():
|
| for item in data.get("items", []):
|
| rows.append({
|
| "Store": store,
|
| "Product": item.get("Product", ""),
|
| "Qty": item.get("Qty", 0),
|
| "Notes": item.get("Notes", ""),
|
| "Parsed At": data.get("timestamp", ""),
|
| })
|
| if not rows:
|
| return None
|
| df = pd.DataFrame(rows)
|
| path = os.path.join(tempfile.gettempdir(), f"all_pos_{datetime.now():%Y%m%d_%H%M%S}.csv")
|
| df.to_csv(path, index=False)
|
| return path
|
|
|