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 # Stage 1: normalize Indonesian/English code-mixed slang normalized, slang_fixes = normalize_po_text(po_text) # Stage 2: Qwen extracts strict JSON (temperature=0.1 in config) 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) # Stage 3b: anchor product names to official catalog (fuzzy match) 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