| 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 |
|
|