Spaces:
Sleeping
Sleeping
| import copy | |
| import streamlit as st | |
| import pandas as pd | |
| import json | |
| import plotly.graph_objects as go | |
| import plotly.express as px | |
| from datetime import datetime | |
| from typing import List, Dict | |
| from pathlib import Path | |
| from models import ( | |
| FluteProfile, PaperGrade, FactoryConfig, LayerInput, CartonSpecification, | |
| GRIP_ALLOWANCE_MM, SCORE_ALLOWANCE_LENGTH_MM, SCORE_ALLOWANCE_WIDTH_MM, | |
| SCORE_ALLOWANCE_DEPTH_MM | |
| ) | |
| from calculations import CorruLabEngine | |
| from data_loader import ( | |
| load_all_data, | |
| load_box_profiles, | |
| save_box_profile, | |
| delete_box_profile, | |
| load_sheet_sizes, | |
| save_sheet_size, | |
| delete_sheet_size | |
| ) | |
| from config import DEV_MODE | |
| from auth import require_auth, logout_button | |
| # Version-compatible rerun function (works with old and new Streamlit) | |
| def safe_rerun(): | |
| """Rerun the app - compatible with both old and new Streamlit versions.""" | |
| if hasattr(st, 'rerun'): | |
| st.rerun() | |
| else: | |
| st.experimental_rerun() | |
| # --- CONFIG & SETUP --- | |
| st.set_page_config(page_title="NRP - New Royal Printing", page_icon="📦", layout="wide", initial_sidebar_state="expanded") | |
| # --- AUTHENTICATION --- | |
| require_auth() | |
| logout_button() | |
| # Get the directory where app.py is located (works on local + Hugging Face) | |
| BASE_DIR = Path(__file__).parent | |
| # C2: Session persistence via localStorage (save key dimensions on change) | |
| import streamlit.components.v1 as components | |
| def _inject_session_persistence(): | |
| """Inject JS to save/restore key fields to/from localStorage.""" | |
| components.html(""" | |
| <script> | |
| const KEYS = ['length_input','width_input','height_input','ply_type','customer_name','order_qty']; | |
| // Restore on load | |
| const parent = window.parent; | |
| if (parent && parent.document) { | |
| KEYS.forEach(k => { | |
| const v = localStorage.getItem('nrp_' + k); | |
| if (v) { | |
| // Set into Streamlit's session by dispatching to query params or session | |
| // This is a lightweight hint - actual restoration is done via session_state defaults | |
| } | |
| }); | |
| } | |
| // Save on unload | |
| window.addEventListener('beforeunload', () => { | |
| const inputs = parent.document.querySelectorAll('[data-testid="stNumberInput"] input, [data-testid="stTextInput"] input'); | |
| inputs.forEach(inp => { | |
| const label = inp.closest('[data-testid]')?.querySelector('label')?.textContent || ''; | |
| KEYS.forEach(k => { | |
| if (label.toLowerCase().includes(k.replace('_input','').replace('_',' '))) { | |
| localStorage.setItem('nrp_' + k, inp.value); | |
| } | |
| }); | |
| }); | |
| }); | |
| </script> | |
| """, height=0) | |
| _inject_session_persistence() | |
| # --- LOAD DATA --- | |
| # Data loading now handled by data_loader.py | |
| # Automatically switches between local (DEV_MODE=true) and HuggingFace (DEV_MODE=false) | |
| # Cached to prevent memory issues on HuggingFace Spaces | |
| def get_initial_data(): | |
| """Load and cache initial data to reduce memory usage.""" | |
| return load_all_data() | |
| try: | |
| _PAPER_DB, _FACTORY_CONFIG, _FLUTE_PROFILES = get_initial_data() | |
| except Exception as e: | |
| st.error(f"Critical Error Loading Data: {e}") | |
| st.stop() | |
| # C1 Fix: Deep-copy config per session to prevent cross-user mutation | |
| if '_session_factory_config' not in st.session_state: | |
| st.session_state['_session_factory_config'] = copy.deepcopy(_FACTORY_CONFIG) | |
| if '_session_flute_profiles' not in st.session_state: | |
| st.session_state['_session_flute_profiles'] = copy.deepcopy(_FLUTE_PROFILES) | |
| PAPER_DB = _PAPER_DB # Read-only, safe to share | |
| FACTORY_CONFIG = st.session_state['_session_factory_config'] | |
| FLUTE_PROFILES = st.session_state['_session_flute_profiles'] | |
| # --- STYLING --- | |
| st.markdown(""" | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;800&family=Barlow:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> | |
| <style> | |
| /* ========== CSS VARIABLES ========== */ | |
| :root { | |
| --navy: #0f172a; | |
| --navy-light: #1e293b; | |
| --amber: #d97706; | |
| --amber-text: #b45309; | |
| --amber-light: #f59e0b; | |
| --amber-pale: #fef3c7; | |
| --steel: #475569; | |
| --mist: #94a3b8; | |
| --bg: #f5f7fa; | |
| --bg-card: #ffffff; | |
| --border: #e2e8f0; | |
| --success: #059669; | |
| --danger: #dc2626; | |
| --radius: 8px; | |
| --radius-lg: 12px; | |
| --shadow-sm: 0 1px 3px rgba(0,0,0,0.08); | |
| --shadow-md: 0 4px 12px rgba(0,0,0,0.10); | |
| --shadow-lg: 0 8px 24px rgba(0,0,0,0.12); | |
| } | |
| /* ========== GLOBAL TYPOGRAPHY ========== */ | |
| html, body, [class*="css"] { | |
| font-family: 'Barlow', sans-serif !important; | |
| } | |
| h1, h2, h3, .main-header { | |
| font-family: 'Barlow Condensed', sans-serif !important; | |
| } | |
| /* ========== HIDE NUMBER INPUT STEPPERS ========== */ | |
| button[kind="secondaryFormSubmit"], | |
| button[data-testid="stNumberInputStepUp"], | |
| button[data-testid="stNumberInputStepDown"], | |
| [data-testid="stNumberInput"] button, | |
| [data-testid="stNumberInput"] div[data-testid="StyledLinkIconContainer"], | |
| div[data-baseweb="input"] button { | |
| display: none !important; | |
| visibility: hidden !important; | |
| width: 0 !important; height: 0 !important; | |
| padding: 0 !important; margin: 0 !important; | |
| } | |
| [data-testid="stNumberInput"] > div > div:nth-child(2), | |
| [data-testid="stNumberInput"] > div > div > div:nth-child(2) { display: none !important; } | |
| input::-webkit-outer-spin-button, | |
| input::-webkit-inner-spin-button { -webkit-appearance: none !important; margin: 0 !important; } | |
| input[type=number] { -moz-appearance: textfield !important; } | |
| /* ========== MAIN BACKGROUND ========== */ | |
| .stApp { background: var(--bg) !important; } | |
| [data-testid="stAppViewContainer"] { background: var(--bg) !important; } | |
| /* ========== SIDEBAR ========== */ | |
| [data-testid="stSidebar"] { | |
| background: var(--navy) !important; | |
| border-right: 3px solid var(--amber) !important; | |
| } | |
| [data-testid="stSidebar"] * { color: #cbd5e1 !important; } | |
| [data-testid="stSidebar"] h1, [data-testid="stSidebar"] h2, | |
| [data-testid="stSidebar"] h3, [data-testid="stSidebar"] strong { | |
| color: #f8fafc !important; | |
| font-family: 'Barlow Condensed', sans-serif !important; | |
| } | |
| [data-testid="stSidebar"] .stCaption { color: var(--mist) !important; } | |
| [data-testid="stSidebar"] hr { border-color: #334155 !important; background: #334155 !important; } | |
| /* Sidebar inputs */ | |
| [data-testid="stSidebar"] [data-testid="stNumberInput"] input, | |
| [data-testid="stSidebar"] [data-testid="stTextInput"] input, | |
| [data-testid="stSidebar"] [data-testid="stSelectbox"] > div > div { | |
| background: #1e293b !important; | |
| border: 1px solid #334155 !important; | |
| color: #f1f5f9 !important; | |
| border-radius: var(--radius) !important; | |
| } | |
| [data-testid="stSidebar"] label { color: #94a3b8 !important; font-size: 12px !important; letter-spacing: 0.5px; } | |
| /* Sidebar expanders */ | |
| [data-testid="stSidebar"] [data-testid="stExpander"] { | |
| background: #1e293b !important; | |
| border: 1px solid #334155 !important; | |
| border-radius: var(--radius) !important; | |
| margin-bottom: 8px !important; | |
| } | |
| [data-testid="stSidebar"] [data-testid="stExpander"] summary { | |
| color: #e2e8f0 !important; | |
| font-weight: 600 !important; | |
| } | |
| /* ========== TABS ========== */ | |
| .stTabs [data-baseweb="tab-list"] { | |
| gap: 2px; | |
| background: var(--navy) !important; | |
| padding: 6px 8px !important; | |
| border-radius: var(--radius-lg) !important; | |
| margin-bottom: 20px !important; | |
| } | |
| .stTabs [data-baseweb="tab"] { | |
| height: 44px !important; | |
| background: transparent !important; | |
| border-radius: var(--radius) !important; | |
| color: var(--mist) !important; | |
| font-family: 'Barlow Condensed', sans-serif !important; | |
| font-weight: 600 !important; | |
| font-size: 15px !important; | |
| letter-spacing: 0.5px !important; | |
| transition: all 0.2s ease !important; | |
| border: none !important; | |
| padding: 0 18px !important; | |
| } | |
| .stTabs [data-baseweb="tab"]:hover { | |
| background: rgba(217, 119, 6, 0.15) !important; | |
| color: var(--amber-text) !important; | |
| } | |
| .stTabs [aria-selected="true"] { | |
| background: var(--amber) !important; | |
| color: white !important; | |
| box-shadow: 0 2px 8px rgba(217,119,6,0.4) !important; | |
| border: none !important; | |
| } | |
| /* ========== INPUTS ========== */ | |
| [data-testid="stNumberInput"] input, | |
| [data-testid="stTextInput"] input, | |
| [data-testid="stTextArea"] textarea { | |
| border: 1.5px solid var(--border) !important; | |
| border-radius: var(--radius) !important; | |
| padding: 10px 14px !important; | |
| font-size: 15px !important; | |
| font-weight: 500 !important; | |
| font-family: 'Barlow', sans-serif !important; | |
| background: white !important; | |
| transition: border-color 0.2s ease, box-shadow 0.2s ease !important; | |
| min-height: 42px !important; | |
| } | |
| [data-testid="stNumberInput"] input:focus, | |
| [data-testid="stTextInput"] input:focus, | |
| [data-testid="stTextArea"] textarea:focus { | |
| border-color: var(--amber) !important; | |
| box-shadow: 0 0 0 3px rgba(217,119,6,0.12) !important; | |
| outline: none !important; | |
| } | |
| [data-testid="stSelectbox"] > div > div { | |
| border-radius: var(--radius) !important; | |
| border: 1.5px solid var(--border) !important; | |
| background: white !important; | |
| } | |
| [data-testid="stSelectbox"] > div > div:focus-within { | |
| border-color: var(--amber) !important; | |
| box-shadow: 0 0 0 3px rgba(217,119,6,0.12) !important; | |
| } | |
| /* ========== COLUMN SPACING ========== */ | |
| div[data-testid="column"] { padding: 0px 5px !important; } | |
| /* ========== METRIC CARDS ========== */ | |
| .metric-card { | |
| background: var(--navy); | |
| color: white; | |
| padding: 20px 24px; | |
| border-radius: var(--radius-lg); | |
| border-left: 4px solid var(--amber); | |
| box-shadow: var(--shadow-md); | |
| margin-bottom: 12px; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .metric-card::before { | |
| content: ''; | |
| position: absolute; | |
| top: -20px; right: -20px; | |
| width: 80px; height: 80px; | |
| background: rgba(217,119,6,0.08); | |
| border-radius: 50%; | |
| } | |
| .metric-title { | |
| color: var(--mist); | |
| font-size: 0.75rem; | |
| text-transform: uppercase; | |
| letter-spacing: 1.5px; | |
| font-weight: 600; | |
| font-family: 'Barlow Condensed', sans-serif; | |
| } | |
| .metric-value { | |
| color: var(--amber-text); | |
| font-size: 2rem; | |
| font-weight: 700; | |
| margin-top: 6px; | |
| font-family: 'JetBrains Mono', monospace; | |
| line-height: 1; | |
| } | |
| .metric-unit { | |
| color: #64748b; | |
| font-size: 0.85rem; | |
| margin-top: 4px; | |
| } | |
| /* Success/Warning variants */ | |
| .metric-card.success { border-left-color: var(--success); } | |
| .metric-card.success .metric-value { color: #34d399; } | |
| .metric-card.danger { border-left-color: var(--danger); } | |
| .metric-card.danger .metric-value { color: #f87171; } | |
| /* ========== HEADER ========== */ | |
| .main-header { | |
| font-size: 2.6rem !important; | |
| font-weight: 800 !important; | |
| font-family: 'Barlow Condensed', sans-serif !important; | |
| color: var(--navy) !important; | |
| letter-spacing: -0.5px !important; | |
| margin-bottom: 4px !important; | |
| line-height: 1.1 !important; | |
| } | |
| .main-header span { color: var(--amber-text); } | |
| .sub-header { | |
| color: var(--steel); | |
| font-size: 0.95rem; | |
| font-weight: 400; | |
| margin-bottom: 24px; | |
| } | |
| /* ========== SECTION DIVIDERS ========== */ | |
| hr { | |
| margin: 20px 0; | |
| border: none; | |
| height: 1px; | |
| background: linear-gradient(90deg, var(--amber) 0%, transparent 100%); | |
| opacity: 0.3; | |
| } | |
| /* ========== EXPANDERS ========== */ | |
| [data-testid="stExpander"] { | |
| border: 1px solid var(--border) !important; | |
| border-radius: var(--radius-lg) !important; | |
| background: var(--bg-card) !important; | |
| box-shadow: var(--shadow-sm) !important; | |
| margin-bottom: 10px !important; | |
| } | |
| [data-testid="stExpander"] summary { | |
| font-family: 'Barlow Condensed', sans-serif !important; | |
| font-weight: 700 !important; | |
| font-size: 16px !important; | |
| letter-spacing: 0.3px !important; | |
| color: var(--navy) !important; | |
| } | |
| /* ========== BUTTONS ========== */ | |
| [data-testid="stButton"] > button { | |
| border-radius: var(--radius) !important; | |
| font-family: 'Barlow Condensed', sans-serif !important; | |
| font-weight: 700 !important; | |
| font-size: 15px !important; | |
| letter-spacing: 0.5px !important; | |
| transition: all 0.2s ease !important; | |
| } | |
| [data-testid="stButton"] > button[kind="primary"] { | |
| background: var(--amber) !important; | |
| border: none !important; | |
| color: white !important; | |
| box-shadow: 0 2px 8px rgba(217,119,6,0.35) !important; | |
| } | |
| [data-testid="stButton"] > button[kind="primary"]:hover { | |
| background: var(--amber-light) !important; | |
| box-shadow: 0 4px 12px rgba(217,119,6,0.5) !important; | |
| transform: translateY(-1px) !important; | |
| } | |
| [data-testid="stButton"] > button[kind="secondary"] { | |
| background: transparent !important; | |
| border: 1.5px solid var(--border) !important; | |
| color: var(--navy) !important; | |
| } | |
| [data-testid="stButton"] > button[kind="secondary"]:hover { | |
| border-color: var(--amber) !important; | |
| color: var(--amber-text) !important; | |
| background: var(--amber-pale) !important; | |
| } | |
| /* ========== DATA TABLES ========== */ | |
| [data-testid="stDataFrame"] table { | |
| font-family: 'Barlow', sans-serif !important; | |
| font-size: 14px !important; | |
| } | |
| [data-testid="stDataFrame"] th { | |
| background: var(--navy) !important; | |
| color: white !important; | |
| font-family: 'Barlow Condensed', sans-serif !important; | |
| font-weight: 700 !important; | |
| font-size: 13px !important; | |
| letter-spacing: 0.5px !important; | |
| text-transform: uppercase !important; | |
| } | |
| [data-testid="stDataFrame"] tr:hover { background: var(--amber-pale) !important; } | |
| /* ========== SUCCESS / ERROR MESSAGES ========== */ | |
| [data-testid="stSuccess"] { | |
| border-left: 4px solid var(--success) !important; | |
| border-radius: var(--radius) !important; | |
| } | |
| [data-testid="stError"] { | |
| border-left: 4px solid var(--danger) !important; | |
| border-radius: var(--radius) !important; | |
| } | |
| [data-testid="stWarning"] { | |
| border-left: 4px solid var(--amber) !important; | |
| border-radius: var(--radius) !important; | |
| } | |
| [data-testid="stInfo"] { | |
| border-left: 4px solid #3b82f6 !important; | |
| border-radius: var(--radius) !important; | |
| } | |
| /* ========== LAYER SECTION CARDS ========== */ | |
| .layer-card { | |
| background: white; | |
| border: 1px solid var(--border); | |
| border-left: 3px solid var(--amber); | |
| border-radius: var(--radius-lg); | |
| padding: 16px 20px; | |
| margin-bottom: 12px; | |
| box-shadow: var(--shadow-sm); | |
| } | |
| .layer-title { | |
| font-family: 'Barlow Condensed', sans-serif; | |
| font-weight: 700; | |
| font-size: 15px; | |
| color: var(--navy); | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| margin-bottom: 10px; | |
| } | |
| /* ========== RESULT HIGHLIGHTS ========== */ | |
| .result-highlight { | |
| background: var(--amber-pale); | |
| border: 1px solid #fcd34d; | |
| border-radius: var(--radius); | |
| padding: 12px 16px; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 14px; | |
| color: #92400e; | |
| } | |
| /* ========== ELEMENT SPACING ========== */ | |
| .element-container { margin-bottom: 6px; } | |
| /* ========== SCROLLBAR ========== */ | |
| ::-webkit-scrollbar { width: 6px; height: 6px; } | |
| ::-webkit-scrollbar-track { background: var(--border); border-radius: 3px; } | |
| ::-webkit-scrollbar-thumb { background: var(--mist); border-radius: 3px; } | |
| ::-webkit-scrollbar-thumb:hover { background: var(--amber); } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # --- HELPERS --- | |
| def to_mm(val, unit): | |
| return val * 25.4 if unit == "inch" else val | |
| def from_mm(val_mm, unit): | |
| return val_mm / 25.4 if unit == "inch" else val_mm | |
| def unit_label(label, unit): | |
| return f"{label} ({unit})" | |
| # Unit Switch Handler | |
| def update_units(): | |
| # Called AFTER the 'unit' state has changed. | |
| new_unit = st.session_state.unit | |
| prev_unit = st.session_state.get('prev_unit', 'mm') | |
| if prev_unit != new_unit: | |
| factor = 1.0 | |
| if prev_unit == 'mm' and new_unit == 'inch': | |
| factor = 1/25.4 | |
| elif prev_unit == 'inch' and new_unit == 'mm': | |
| factor = 25.4 | |
| if 'length_input' in st.session_state: | |
| st.session_state.length_input *= factor | |
| if 'width_input' in st.session_state: | |
| st.session_state.width_input *= factor | |
| if 'height_input' in st.session_state: | |
| st.session_state.height_input *= factor | |
| st.session_state.prev_unit = new_unit | |
| if 'prev_unit' not in st.session_state: | |
| st.session_state.prev_unit = "mm" | |
| # --- SIDEBAR --- | |
| with st.sidebar: | |
| st.markdown(""" | |
| <div style="padding: 12px 0 8px 0;"> | |
| <div style="font-family:'Barlow Condensed',sans-serif; font-size:22px; font-weight:800; | |
| color:#f8fafc; letter-spacing:0.5px; line-height:1.2;"> | |
| NRP <span style="color:#d97706;">■</span> New Royal Printing | |
| </div> | |
| <div style="font-family:'Barlow',sans-serif; font-size:11px; font-weight:600; | |
| color:#64748b; letter-spacing:2px; text-transform:uppercase; margin-top:2px;"> | |
| MRP System • Corrugated Division | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.divider() | |
| # ============================================================================ | |
| # AI QUICK-LOAD | |
| # ============================================================================ | |
| with st.expander("🤖 AI Quick-Load", expanded=False): | |
| st.markdown("**1. Copy this prompt to ChatGPT/Claude:**") | |
| ai_prompt = """I am designing a corrugated box. Please extract the specifications and output ONLY a raw JSON strictly following this schema. Do not output anything else. | |
| Fields: | |
| - length_mm, width_mm, height_mm (integers) | |
| - ply_type (string, exactly one of: "3 Ply (Single Wall)", "5 Ply (Double Wall)", "2+1 (Bleach Card)", "4+1 (Bleach Card)", "3 Ply Padded Sheet", "5 Ply Padded Sheet") | |
| - order_qty (integer) | |
| - cust_name (string) | |
| - layers (array of objects for each paper layer, from top to bottom) | |
| - paper_code (string, generic like "KLB", "TL", "HSF") | |
| - gsm (integer) | |
| - rate (number) | |
| Example JSON: | |
| { | |
| "length_mm": 400, "width_mm": 300, "height_mm": 300, | |
| "ply_type": "3 Ply (Single Wall)", | |
| "order_qty": 5000, | |
| "cust_name": "ABC Corp", | |
| "layers": [ | |
| {"paper_code": "KLB", "gsm": 150}, | |
| {"paper_code": "HSF", "gsm": 125} | |
| ] | |
| } | |
| Only output the variables I provide in my text below. My text: Let's make a 500x350x250 mm box, 5 ply, 3000 qty.""" | |
| st.code(ai_prompt, language="text") | |
| st.markdown("**2. Paste the generated JSON here:**") | |
| json_input = st.text_area("JSON Input", height=150, placeholder="{\n \"length_mm\": 500,\n ...\n}", label_visibility="collapsed") | |
| if st.button("⚡ Apply AI JSON", type="primary", width="stretch"): | |
| if json_input.strip(): | |
| try: | |
| jdata = json.loads(json_input) | |
| # Dimensions | |
| if 'length_mm' in jdata: | |
| st.session_state['length_input'] = jdata['length_mm'] / 25.4 if st.session_state.get('unit') == 'inch' else float(jdata['length_mm']) | |
| if 'width_mm' in jdata: | |
| st.session_state['width_input'] = jdata['width_mm'] / 25.4 if st.session_state.get('unit') == 'inch' else float(jdata['width_mm']) | |
| if 'height_mm' in jdata: | |
| st.session_state['height_input'] = jdata['height_mm'] / 25.4 if st.session_state.get('unit') == 'inch' else float(jdata['height_mm']) | |
| # Ply type | |
| if 'ply_type' in jdata: | |
| st.session_state['ply_type'] = jdata['ply_type'] | |
| # Job details | |
| if 'order_qty' in jdata: | |
| st.session_state['order_qty'] = int(jdata['order_qty']) | |
| if 'cust_name' in jdata: | |
| st.session_state['cust_name'] = str(jdata['cust_name']) | |
| if 'quote_ref' in jdata: | |
| st.session_state['quote_ref'] = str(jdata['quote_ref']) | |
| # Per-layer fields (only what's provided) | |
| for idx, layer in enumerate(jdata.get('layers', [])): | |
| p_sel_key = f'p_{idx}' | |
| p_code = layer.get('paper_code') | |
| p_gsm = layer.get('gsm') | |
| if p_code and p_gsm is not None: | |
| # Find closest match or create a dynamic string | |
| match = next((p for p in PAPER_DB if str(p.code).upper() == str(p_code).upper() and abs(p.gsm - float(p_gsm)) < 1), None) | |
| if match: | |
| st.session_state[p_sel_key] = f"{match.code} {match.gsm}GSM" | |
| else: | |
| # If no exact match, use the provided code and GSM, format it, and remove trailing .0 if present | |
| st.session_state[p_sel_key] = f"{p_code} {float(p_gsm)}GSM".replace(".0GSM", "GSM") | |
| if 'gsm' in layer: st.session_state[f'g_{idx}'] = float(layer['gsm']) | |
| if 'rate' in layer: st.session_state[f'rt_{idx}'] = float(layer['rate']) | |
| if 'rct_cd' in layer: st.session_state[f'rcd_{idx}'] = float(layer['rct_cd']) | |
| if 'rct_md' in layer: st.session_state[f'rmd_{idx}'] = float(layer['rct_md']) | |
| if 'burst_kpa' in layer: st.session_state[f'bst_{idx}'] = float(layer['burst_kpa']) | |
| # Set unified deckle from first reel layer's deckle_mm | |
| if 'deckle_mm' in layer and not layer.get('is_sheet', False): | |
| if 'unified_dk_custom' not in st.session_state: | |
| dk_val = float(layer['deckle_mm']) | |
| unit_val = st.session_state.get('unit', 'inch') | |
| st.session_state['unified_deckle_sel'] = "📝 Custom Size..." | |
| st.session_state['unified_dk_custom'] = dk_val / 25.4 if unit_val == 'inch' else dk_val | |
| st.success("✅ Calculator fields updated!") | |
| safe_rerun() | |
| except Exception as e: | |
| st.error(f"Invalid JSON: {e}") | |
| else: | |
| st.warning("Please paste JSON first.") | |
| st.divider() | |
| # ============================================================================ | |
| # BOX PROFILES SECTION | |
| # ============================================================================ | |
| st.subheader("📦 Box Profiles") | |
| # Load saved profiles (session-cached; invalidated after save/delete) | |
| if st.session_state.get('_profiles_dirty', True): | |
| st.session_state['_cached_profiles'] = load_box_profiles() | |
| st.session_state['_profiles_dirty'] = False | |
| saved_profiles = st.session_state.get('_cached_profiles', []) | |
| # Filter out or fix empty names for the dropdown | |
| def _get_display_name(p): | |
| name = p.get('name', '').strip() | |
| if not name: | |
| name = f"[Unnamed] ({p.get('id', '?')})" | |
| return name | |
| profile_names = ["-- New Profile --"] + [_get_display_name(p) for p in saved_profiles] | |
| # Profile dropdown | |
| selected_profile_name = st.selectbox( | |
| "Load Saved Profile", | |
| profile_names, | |
| key="profile_selector", | |
| help="Select a saved profile to load all its settings" | |
| ) | |
| # Load profile into session state if selected | |
| # Load profile into session state if selected | |
| if selected_profile_name != "-- New Profile --": | |
| selected_profile = next((p for p in saved_profiles if p.get('name') == selected_profile_name), None) | |
| if selected_profile and st.session_state.get('loaded_profile_id') != selected_profile.get('id'): | |
| st.session_state['loaded_profile'] = selected_profile | |
| st.session_state['loaded_profile_id'] = selected_profile.get('id') | |
| # --- CRITICAL RESTORE: PLY TYPE --- | |
| # Must be set BEFORE widgets render | |
| if 'ply_type' in selected_profile: | |
| st.session_state['ply_type'] = selected_profile['ply_type'] | |
| # --- CRITICAL RESTORE: UNITS --- | |
| if 'units' in selected_profile: | |
| st.session_state['unit'] = selected_profile['units'] | |
| # --- CRITICAL RESTORE: DIMENSION TYPE + ADJUSTMENT --- | |
| st.session_state['dim_type'] = selected_profile.get('dimension_type', 'Outside') | |
| st.session_state['dim_adj_mode'] = selected_profile.get('dim_adj_mode', 'Manual') | |
| _adj_stored = selected_profile.get('dim_adjustment_mm', 0.0) | |
| _cur_unit = st.session_state.get('unit', 'mm') | |
| if _cur_unit == 'inch': | |
| st.session_state['dim_adjustment'] = round(_adj_stored / 25.4, 2) | |
| else: | |
| st.session_state['dim_adjustment'] = _adj_stored | |
| # --- CRITICAL RESTORE: JOB DETAILS --- | |
| if 'job_details' in selected_profile: | |
| jd = selected_profile['job_details'] | |
| st.session_state['cust_name'] = jd.get('cust_name', 'New Client') | |
| st.session_state['quote_ref'] = jd.get('quote_ref', 'Q-001') | |
| st.session_state['order_qty'] = jd.get('order_qty', 5000) | |
| # --- CRITICAL RESTORE: FACTORY SETTINGS --- | |
| if 'factory_settings' in selected_profile: | |
| fs = selected_profile['factory_settings'] | |
| # Wastage & Efficiency | |
| FACTORY_CONFIG.wastage_process_pct = fs.get('wastage_process_pct', 5.0) | |
| FACTORY_CONFIG.process_efficiency_pct = fs.get('process_efficiency_pct', 85.0) | |
| FACTORY_CONFIG.ect_conversion_factor = fs.get('ect_conversion_factor', 0.85) | |
| # Costs | |
| FACTORY_CONFIG.cost_conversion_per_kg = fs.get('cost_conversion_per_kg', FACTORY_CONFIG.cost_conversion_per_kg) | |
| FACTORY_CONFIG.cost_fixed_setup = fs.get('cost_fixed_setup', FACTORY_CONFIG.cost_fixed_setup) | |
| FACTORY_CONFIG.margin_pct = fs.get('margin_pct', FACTORY_CONFIG.margin_pct) | |
| FACTORY_CONFIG.cost_printing_per_1000 = fs.get('cost_printing_per_1000', FACTORY_CONFIG.cost_printing_per_1000) | |
| FACTORY_CONFIG.cost_printing_plate = fs.get('cost_printing_plate', FACTORY_CONFIG.cost_printing_plate) | |
| FACTORY_CONFIG.cost_uv_per_1000 = fs.get('cost_uv_per_1000', FACTORY_CONFIG.cost_uv_per_1000) | |
| FACTORY_CONFIG.cost_lamination_per_1000 = fs.get('cost_lamination_per_1000', FACTORY_CONFIG.cost_lamination_per_1000) | |
| FACTORY_CONFIG.cost_die_cutting_per_1000 = fs.get('cost_die_cutting_per_1000', FACTORY_CONFIG.cost_die_cutting_per_1000) | |
| FACTORY_CONFIG.cost_die_frame = fs.get('cost_die_frame', FACTORY_CONFIG.cost_die_frame) | |
| FACTORY_CONFIG.cost_paper_corrugation_per_1000 = fs.get('cost_paper_corrugation_per_1000', FACTORY_CONFIG.cost_paper_corrugation_per_1000) | |
| FACTORY_CONFIG.cost_flute_lamination_per_1000 = fs.get('cost_flute_lamination_per_1000', FACTORY_CONFIG.cost_flute_lamination_per_1000) | |
| FACTORY_CONFIG.cost_folder_gluer_per_1000 = fs.get('cost_folder_gluer_per_1000', FACTORY_CONFIG.cost_folder_gluer_per_1000) | |
| # Glue Flap: only set from profile if explicitly saved | |
| if 'stitch_allowance_mm' in fs: | |
| FACTORY_CONFIG.stitch_allowance_mm = fs['stitch_allowance_mm'] | |
| st.session_state['stitch_input_main'] = round(fs['stitch_allowance_mm'] / 25.4, 2) | |
| else: | |
| # No saved glue flap → remove session key so default kicks in | |
| st.session_state.pop('stitch_input_main', None) | |
| # Grip Allowance | |
| if 'grip_allowance_mm' in fs: | |
| FACTORY_CONFIG.grip_allowance_mm = fs['grip_allowance_mm'] | |
| # Reels | |
| if 'available_reel_sizes' in fs: | |
| FACTORY_CONFIG.available_reel_sizes = fs['available_reel_sizes'] | |
| # Flute Factors | |
| if 'flute_factors' in fs: | |
| for fp in FLUTE_PROFILES: | |
| if fp.name in fs['flute_factors']: | |
| fp.factor = fs['flute_factors'][fp.name] | |
| safe_rerun() | |
| # Save Profile Section | |
| st.markdown(""" | |
| <style> | |
| .save-profile-container { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| padding: 16px; | |
| border-radius: 12px; | |
| margin: 10px 0; | |
| } | |
| .save-btn { | |
| background: white !important; | |
| color: #667eea !important; | |
| font-weight: 600 !important; | |
| border-radius: 8px !important; | |
| </style> | |
| """, unsafe_allow_html=True) | |
| with st.container(): | |
| profile_name_input = st.text_input( | |
| "Profile Name", | |
| value=selected_profile.get('name', '') if selected_profile_name != "-- New Profile --" else '', | |
| placeholder="e.g. Apple Carton 15x12", | |
| key="save_profile_name", | |
| help="Enter a name for this box configuration" | |
| ) | |
| # Simple, clean save button in the sidebar | |
| if st.button("💾 Save Profile", width="stretch", type="primary"): | |
| if profile_name_input.strip(): | |
| st.session_state['trigger_save'] = True | |
| # If they are saving a profile with the EXACT same name as what's loaded, assume overwrite to avoid cloning spam | |
| # Otherwise, it creates a new profile. Advanced management is in the Profile Manager page. | |
| if selected_profile_name != "-- New Profile --" and profile_name_input.strip() == selected_profile.get('name', ''): | |
| st.session_state['save_mode'] = 'overwrite' | |
| else: | |
| st.session_state['save_mode'] = 'new' | |
| else: | |
| st.warning("Please enter a profile name before saving.") | |
| st.caption("📝 Advanced editing, deleting, and import/export available on the **Profile Manager** page.") | |
| st.divider() | |
| st.subheader("📏 Units") | |
| unit_system = st.radio("Display Units", ["mm", "inch"], horizontal=True, key="unit", on_change=update_units) # Global Unit State | |
| st.divider() | |
| st.subheader("📝 Job Details") | |
| if 'cust_name' not in st.session_state: | |
| st.session_state['cust_name'] = "New Client" | |
| if 'quote_ref' not in st.session_state: | |
| st.session_state['quote_ref'] = f"Q-{datetime.now().strftime('%Y%m%d')}" | |
| cust_name = st.text_input("Customer", key="cust_name") | |
| quote_ref = st.text_input("Quote Ref", key="quote_ref") | |
| order_qty = st.number_input("Order Quantity", min_value=1, value=st.session_state.get('order_qty', 5000), key="order_qty") | |
| st.divider() | |
| with st.expander("🏭 Factory Settings", expanded=False): | |
| st.caption("These settings affect wastage and strength calculations.") | |
| st.markdown("**📉 Wastage**") | |
| FACTORY_CONFIG.wastage_process_pct = st.number_input( | |
| "Process Waste (%)", | |
| value=FACTORY_CONFIG.wastage_process_pct, | |
| min_value=0.0, max_value=20.0, step=0.5, | |
| help="Running waste from corrugator" | |
| ) | |
| st.markdown("**💪 Strength Factors**") | |
| FACTORY_CONFIG.ect_conversion_factor = st.number_input( | |
| "ECT Factor", | |
| value=FACTORY_CONFIG.ect_conversion_factor, | |
| min_value=0.5, max_value=1.2, step=0.01, | |
| help="Lab-to-real ECT reduction (0.80-0.90)" | |
| ) | |
| FACTORY_CONFIG.process_efficiency_pct = st.number_input( | |
| "BCT Efficiency (%)", | |
| value=FACTORY_CONFIG.process_efficiency_pct, | |
| min_value=50.0, max_value=100.0, step=1.0, | |
| help="BCT process efficiency" | |
| ) | |
| st.markdown("**🔧 Machine Settings**") | |
| FACTORY_CONFIG.grip_allowance_mm = st.number_input( | |
| "Grip Allowance (mm)", | |
| value=FACTORY_CONFIG.grip_allowance_mm, | |
| min_value=0.0, max_value=100.0, step=1.0, | |
| help="Deckle width lost to machine grip. Default 50.4mm (~2\"). Varies by machine." | |
| ) | |
| st.markdown("**🌊 Flute Takeup**") | |
| for fp in FLUTE_PROFILES: | |
| fp.factor = st.number_input( | |
| f"{fp.name} Factor", | |
| value=fp.factor, | |
| key=f"f_{fp.name}", | |
| step=0.01, | |
| min_value=1.0, max_value=2.0, | |
| help=f"Paper consumed per meter" | |
| ) | |
| with st.expander("💸 Process Costs (Rs)", expanded=False): | |
| st.caption("Per-unit costs for value-added processes. Rates are per 1000 boxes.") | |
| FACTORY_CONFIG.cost_printing_per_1000 = st.number_input("🖨️ Printing / 1000", value=FACTORY_CONFIG.cost_printing_per_1000) | |
| FACTORY_CONFIG.cost_printing_plate = st.number_input("🖨️ Print Plate (Fixed)", value=FACTORY_CONFIG.cost_printing_plate) | |
| FACTORY_CONFIG.cost_uv_per_1000 = st.number_input("✨ UV Coating / 1000", value=FACTORY_CONFIG.cost_uv_per_1000) | |
| FACTORY_CONFIG.cost_lamination_per_1000 = st.number_input("📄 Lamination / 1000", value=FACTORY_CONFIG.cost_lamination_per_1000) | |
| FACTORY_CONFIG.cost_die_cutting_per_1000 = st.number_input("✂️ Die Cutting / 1000", value=FACTORY_CONFIG.cost_die_cutting_per_1000) | |
| FACTORY_CONFIG.cost_die_frame = st.number_input("✂️ Die Frame (Fixed)", value=FACTORY_CONFIG.cost_die_frame) | |
| FACTORY_CONFIG.cost_paper_corrugation_per_1000 = st.number_input("🏭 Paper Corrugation / 1000", value=FACTORY_CONFIG.cost_paper_corrugation_per_1000) | |
| FACTORY_CONFIG.cost_flute_lamination_per_1000 = st.number_input("🥪 Flute Lamination / 1000", value=FACTORY_CONFIG.cost_flute_lamination_per_1000) | |
| FACTORY_CONFIG.cost_folder_gluer_per_1000 = st.number_input("📦 Folder Gluer / 1000", value=FACTORY_CONFIG.cost_folder_gluer_per_1000) | |
| # --- MAIN LAYOUT --- | |
| st.markdown(""" | |
| <div style="display:flex; align-items:flex-end; justify-content:space-between; margin-bottom:20px; padding-bottom:16px; | |
| border-bottom:2px solid #e2e8f0;"> | |
| <div> | |
| <div class='main-header'>New Royal <span>Printing</span></div> | |
| <div class='sub-header'>Corrugated Box MRP System — Materials • Engineering • Costing</div> | |
| </div> | |
| <div style="font-family:'JetBrains Mono',monospace; font-size:11px; color:#94a3b8; text-align:right; padding-bottom:4px;"> | |
| v3.0 | DEV MODE: {dev} | |
| </div> | |
| </div> | |
| """.format(dev="ON" if DEV_MODE else "OFF"), unsafe_allow_html=True) | |
| tabs = st.tabs(["THE LAB", "ENGINEERING", "PRODUCTION", "FINANCIALS", "REPORTS"]) | |
| # --- HELPERS --- | |
| def plot_deckle_visualization(deckle_mm, sheet_width_mm, sheet_length_mm, ups, layer_name, utilization_pct): | |
| """ | |
| Creates a 2D visual representation of Deckle Utilization. | |
| X-axis = Deckle width (how many sheets fit across the reel) | |
| Y-axis = Sheet length (the full sheet dimension) | |
| """ | |
| fig = go.Figure() | |
| # Dimensions | |
| used_width = ups * sheet_width_mm | |
| trim_width = deckle_mm - used_width | |
| # Scale for display (keep aspect ratio reasonable) | |
| scale_y = min(1.0, 400 / sheet_length_mm) if sheet_length_mm > 0 else 1.0 | |
| display_height = sheet_length_mm * scale_y | |
| # Draw "Ups" (Good Sheets) | |
| for i in range(ups): | |
| x0 = i * sheet_width_mm | |
| x1 = x0 + sheet_width_mm | |
| fig.add_shape( | |
| type="rect", | |
| x0=x0, y0=0, x1=x1, y1=display_height, | |
| line=dict(color="#1d4ed8", width=2), | |
| fillcolor="rgba(37, 99, 235, 0.6)", # F6: Blue instead of green (colorblind-safe) | |
| ) | |
| # Annotation for sheet | |
| fig.add_annotation( | |
| x=x0 + (sheet_width_mm/2), | |
| y=display_height/2, | |
| text=f"<b>Sheet {i+1}</b><br>{sheet_width_mm:.0f} × {sheet_length_mm:.0f}mm", | |
| showarrow=False, | |
| font=dict(color="white", size=11) | |
| ) | |
| # Draw Trim (Wastage) | |
| if trim_width > 1: # Only show if more than 1mm | |
| x0_trim = used_width | |
| x1_trim = deckle_mm | |
| fig.add_shape( | |
| type="rect", | |
| x0=x0_trim, y0=0, x1=x1_trim, y1=display_height, | |
| line=dict(color="#c2410c", width=2), | |
| fillcolor="rgba(249, 115, 22, 0.8)", # F6: Orange instead of red (colorblind-safe) | |
| ) | |
| # Annotation for trim | |
| fig.add_annotation( | |
| x=x0_trim + (trim_width/2), | |
| y=display_height/2, | |
| text=f"<b>TRIM</b><br>{trim_width:.0f}mm<br>({100-utilization_pct:.1f}%)", | |
| showarrow=False, | |
| font=dict(color="white", size=10) | |
| ) | |
| # Add dimension arrows/labels | |
| # Deckle dimension (top) | |
| fig.add_annotation( | |
| x=deckle_mm/2, y=display_height + 20, | |
| text=f"<b>Reel Deckle: {deckle_mm}mm</b>", | |
| showarrow=False, font=dict(size=12, color="#333") | |
| ) | |
| # Sheet width dimension (bottom) | |
| fig.add_annotation( | |
| x=sheet_width_mm/2, y=-25, | |
| text=f"W+H = {sheet_width_mm:.0f}mm", | |
| showarrow=False, font=dict(size=10, color="#666") | |
| ) | |
| # Config | |
| fig.update_layout( | |
| title=dict( | |
| text=f"<b>{layer_name}</b> | Utilization: <span style='color:{'#2563eb' if utilization_pct > 95 else '#d97706' if utilization_pct > 85 else '#f97316'}'>{utilization_pct:.1f}%</span>", | |
| font=dict(size=14) | |
| ), | |
| xaxis=dict( | |
| range=[-10, deckle_mm * 1.05], | |
| title="← Deckle Width (mm) →", | |
| showgrid=True, gridcolor='rgba(0,0,0,0.1)', | |
| dtick=200 | |
| ), | |
| yaxis=dict( | |
| range=[-40, display_height + 40], | |
| title="Sheet Length", | |
| showgrid=True, gridcolor='rgba(0,0,0,0.1)', | |
| showticklabels=False | |
| ), | |
| height=280, | |
| margin=dict(l=60, r=20, t=50, b=50), | |
| plot_bgcolor='rgba(248,248,248,1)', | |
| ) | |
| return fig | |
| # --- TAB 1: THE LAB --- | |
| with tabs[0]: | |
| # ======================================================================== | |
| # LOAD PROFILE DATA INTO FORM (before widgets render) | |
| # ======================================================================== | |
| loaded_profile = st.session_state.get('loaded_profile') | |
| if loaded_profile: | |
| # Populate dimensions | |
| dims = loaded_profile.get('dimensions', {}) | |
| if 'length_input' not in st.session_state or st.session_state.get('_profile_applied') != loaded_profile.get('id'): | |
| # Convert from mm to current unit system | |
| if unit_system == "inch": | |
| st.session_state['length_input'] = dims.get('length_mm', 400) / 25.4 | |
| st.session_state['width_input'] = dims.get('width_mm', 300) / 25.4 | |
| st.session_state['height_input'] = dims.get('height_mm', 300) / 25.4 | |
| else: | |
| st.session_state['length_input'] = dims.get('length_mm', 400) | |
| st.session_state['width_input'] = dims.get('width_mm', 300) | |
| st.session_state['height_input'] = dims.get('height_mm', 300) | |
| # Populate Process selections | |
| procs = loaded_profile.get('processes', {}) | |
| st.session_state['chk_print'] = procs.get('printing', False) | |
| st.session_state['chk_uv'] = procs.get('uv_coating', False) | |
| st.session_state['chk_lam'] = procs.get('lamination', False) | |
| st.session_state['chk_die'] = procs.get('die_cutting', False) | |
| st.session_state['chk_corrugation'] = procs.get('paper_corrugation', False) | |
| st.session_state['chk_flute_lam'] = procs.get('flute_lamination', False) | |
| st.session_state['chk_folder_gluer'] = procs.get('folder_gluer', False) | |
| # Helper for consistent naming within the loop | |
| def format_paper_opt(p): | |
| supp = getattr(p, 'supplier', None) | |
| return f"{p.code} {p.gsm}GSM ({supp})" if supp else f"{p.code} {p.gsm}GSM" | |
| # Populate layer data | |
| layers = loaded_profile.get('layers', []) | |
| for i, layer in enumerate(layers): | |
| p_code = layer.get('paper_code') | |
| p_gsm = layer.get('gsm', 125) | |
| if p_code and p_gsm is not None: | |
| # Try to find a match in DB | |
| match = next((p for p in PAPER_DB if str(p.code).upper() == str(p_code).upper() and abs(p.gsm - float(p_gsm)) < 1), None) | |
| if match: | |
| st.session_state[f'p_{i}'] = format_paper_opt(match) | |
| else: | |
| st.session_state[f'p_{i}'] = f"{p_code} {float(p_gsm)}GSM".replace(".0GSM", "GSM") | |
| st.session_state[f'g_{i}'] = float(p_gsm) | |
| st.session_state[f'rt_{i}'] = layer.get('rate', 180) | |
| st.session_state[f'rcd_{i}'] = layer.get('rct_cd', 20) | |
| st.session_state[f'rmd_{i}'] = layer.get('rct_md', 20) | |
| st.session_state[f'bst_{i}'] = layer.get('burst_kpa', 350) | |
| st.session_state[f'mst_{i}'] = layer.get('moisture_pct', 7.5) | |
| # Per-layer caliper (restore from profile) | |
| if 'caliper_mm' in layer and layer['caliper_mm'] is not None: | |
| st.session_state[f'cal_{i}'] = layer['caliper_mm'] | |
| # Deckle / Sheet Size | |
| if layer.get('is_sheet', False): | |
| # Sheet Dims (for 2+1) | |
| if 'sheet_dims' in layer: | |
| st.session_state[f'sheet_sel_{i}'] = "📋 Custom Size (Use Once)" | |
| st.session_state[f'sheet_unit_{i}'] = "mm" | |
| st.session_state[f'sheet_w_{i}'] = float(layer['sheet_dims'][0]) | |
| st.session_state[f'sheet_l_{i}'] = float(layer['sheet_dims'][1]) | |
| st.session_state[f'use_sheet_{i}'] = True | |
| # Reel layer deckle (per-layer): restore from profile or backward compat unified | |
| if not layer.get('is_sheet', False): | |
| _layer_dk_mm = layer.get('deckle_mm', 0) | |
| # Backward compat: if per-layer deckle is 0, use top-level unified deckle | |
| if not _layer_dk_mm: | |
| _layer_dk_mm = loaded_profile.get('deckle_mm', 0) | |
| if _layer_dk_mm and _layer_dk_mm > 0: | |
| _layer_dk_in = _layer_dk_mm / 25.4 | |
| # Check if it matches a DB deckle | |
| _dk_match = False | |
| for _p in PAPER_DB: | |
| _all_dk = _p.deckles if _p.deckles else ([_p.deckle] if _p.deckle else []) | |
| for _dk in _all_dk: | |
| if _dk and abs(int(_dk) - int(round(_layer_dk_in))) < 1: | |
| _dk_match = True | |
| break | |
| if _dk_match: | |
| break | |
| if not _dk_match: | |
| st.session_state[f'deckle_sel_{i}'] = "📝 Custom Size..." | |
| if unit_system == "inch": | |
| st.session_state[f'dk_custom_{i}'] = _layer_dk_in | |
| else: | |
| st.session_state[f'dk_custom_{i}'] = float(_layer_dk_mm) | |
| # Flute profile | |
| if layer.get('flute_profile'): | |
| st.session_state[f'fp_{i}'] = layer.get('flute_profile') | |
| # Mark profile as applied to prevent re-applying on every rerun | |
| st.session_state['_profile_applied'] = loaded_profile.get('id') | |
| st.success(f"✅ Loaded profile: **{loaded_profile.get('name')}**") | |
| with st.container(): | |
| st.markdown("### 1. Carton Specification") | |
| # Split into two rows for better spacing | |
| r1_c1, r1_c2, r1_c3 = st.columns(3) | |
| with r1_c1: | |
| # KEY ADDED to ensure state persistence | |
| ply_type = st.selectbox("Board Construction", [ | |
| "3 Ply (Single Wall)", | |
| "5 Ply (Double Wall)", | |
| "2+1 (Bleach Card)", | |
| "4+1 (Bleach Card)", | |
| "3 Ply Padded Sheet", | |
| "5 Ply Padded Sheet" | |
| ], key="ply_type") | |
| with r1_c2: | |
| box_style = st.selectbox("Box Style", ["RSC", "HSC", "Full Overlap", "Die-Cut"], | |
| key="box_style", | |
| help="RSC=Regular Slotted, HSC=Half Slotted, Full Overlap=flaps overlap fully") | |
| is_pad_type = "Padded Sheet" in ply_type | |
| r2_c1, r2_c2, r2_c3 = st.columns(3) | |
| # Smart Defaults based on Unit - only set if not already in session_state | |
| def_l = st.session_state.get('length_input', 15.75 if unit_system == "inch" else 400.0) | |
| def_w = st.session_state.get('width_input', 11.81 if unit_system == "inch" else 300.0) | |
| def_h = st.session_state.get('height_input', 11.81 if unit_system == "inch" else 300.0) | |
| # Initialize session state if not set (avoids widget conflict) | |
| if 'length_input' not in st.session_state: | |
| st.session_state['length_input'] = def_l | |
| if 'width_input' not in st.session_state: | |
| st.session_state['width_input'] = def_w | |
| if 'height_input' not in st.session_state: | |
| st.session_state['height_input'] = def_h | |
| _min_dim = 0.1 if unit_system == "Inches" else 1.0 | |
| with r2_c1: l_input = st.number_input(unit_label("Length", unit_system), min_value=_min_dim, key="length_input") | |
| with r2_c2: w_input = st.number_input(unit_label("Width", unit_system), min_value=_min_dim, key="width_input") | |
| # Disable/hide height if pad type | |
| if is_pad_type: | |
| with r2_c3: h_input = st.number_input(unit_label("Height (N/A for Pads)", unit_system), value=0.0, disabled=True, key="height_input_pad") | |
| h_input = 0.0 | |
| else: | |
| with r2_c3: h_input = st.number_input(unit_label("Height", unit_system), min_value=_min_dim, key="height_input") | |
| # Convert inputs to MM for internal logic | |
| l_mm_raw = to_mm(l_input, unit_system) | |
| w_mm_raw = to_mm(w_input, unit_system) | |
| h_mm_raw = to_mm(h_input, unit_system) | |
| # --- Inside/Outside Dimension Toggle + Auto/Manual Adjustment --- | |
| if not is_pad_type: | |
| _dc1, _dc2, _dc3 = st.columns([1, 1, 1]) | |
| with _dc1: | |
| dim_type = st.radio("Dimension Type", ["Outside", "Inside"], | |
| key="dim_type", horizontal=True, | |
| help="Outside = manufacturing size. Inside = product cavity; adjustment added.") | |
| with _dc2: | |
| adj_mode = st.radio("Adjustment", ["Manual", "Auto (system)"], | |
| key="dim_adj_mode", horizontal=True, | |
| help="Manual = you set the value. Auto = system calculates from board caliper.") | |
| with _dc3: | |
| if adj_mode == "Manual": | |
| _adj_default = 0.0 | |
| if 'dim_adjustment' not in st.session_state: | |
| st.session_state['dim_adjustment'] = _adj_default | |
| dim_adj_val = st.number_input( | |
| unit_label("Adjustment", unit_system), | |
| min_value=0.0, step=1.0 if unit_system == "mm" else 0.05, | |
| format="%.1f" if unit_system == "mm" else "%.2f", | |
| key="dim_adjustment", | |
| help="Manual adjustment value. Set > 0 to apply.") | |
| dim_adj_mm = to_mm(dim_adj_val, unit_system) | |
| else: | |
| # Auto: will be recalculated after layers are defined. | |
| # For now use a placeholder that gets updated. | |
| # We store auto caliper in session state once layers are built. | |
| _auto_cal = st.session_state.get('_auto_board_caliper_mm', 10.0) | |
| dim_adj_mm = _auto_cal | |
| dim_adj_val = from_mm(_auto_cal, unit_system) | |
| st.metric("Auto Adjustment", f"{dim_adj_val:.1f} {unit_system}", | |
| help="System-calculated board caliper from paper layers") | |
| # Apply adjustment based on Inside/Outside | |
| if dim_type == "Inside": | |
| l_mm = l_mm_raw + dim_adj_mm | |
| w_mm = w_mm_raw + dim_adj_mm | |
| h_mm = h_mm_raw + dim_adj_mm | |
| if dim_adj_mm > 0: | |
| _adj_l = from_mm(l_mm, unit_system) | |
| _adj_w = from_mm(w_mm, unit_system) | |
| _adj_h = from_mm(h_mm, unit_system) | |
| st.markdown( | |
| f'<p style="color: #16a34a; font-weight: bold; margin:0;">' | |
| f'Manufacturing dims: L={_adj_l:.1f}, W={_adj_w:.1f}, H={_adj_h:.1f} {unit_system} ' | |
| f'(+{dim_adj_val:.1f} {unit_system} added)</p>', | |
| unsafe_allow_html=True) | |
| else: | |
| l_mm = l_mm_raw | |
| w_mm = w_mm_raw | |
| h_mm = h_mm_raw | |
| if dim_adj_mm > 0: | |
| _ins_l = from_mm(l_mm_raw - dim_adj_mm, unit_system) | |
| _ins_w = from_mm(w_mm_raw - dim_adj_mm, unit_system) | |
| _ins_h = from_mm(h_mm_raw - dim_adj_mm, unit_system) | |
| st.markdown( | |
| f'<p style="color: #dc2626; font-weight: bold; margin:0;">' | |
| f'Inside cavity: L={_ins_l:.1f}, W={_ins_w:.1f}, H={_ins_h:.1f} {unit_system} ' | |
| f'(-{dim_adj_val:.1f} {unit_system} deducted)</p>', | |
| unsafe_allow_html=True) | |
| else: | |
| dim_type = "Outside" | |
| dim_adj_mm = 0.0 | |
| adj_mode = "Manual" | |
| l_mm = l_mm_raw | |
| w_mm = w_mm_raw | |
| h_mm = h_mm_raw | |
| st.markdown("#### Process Selection") | |
| # 2 Rows of Checkboxes for 7 options | |
| cp1, cp2, cp3, cp4 = st.columns(4) | |
| has_print = cp1.checkbox("Printing", key="chk_print") | |
| has_uv = cp2.checkbox("UV Coating", key="chk_uv") | |
| has_lam = cp3.checkbox("Lamination", key="chk_lam") | |
| has_die = cp4.checkbox("Die Cutting", key="chk_die") | |
| cp5, cp6, cp7, cp8 = st.columns(4) | |
| has_corrugation = cp5.checkbox("Paper Corrugation", key="chk_corrugation") | |
| has_flute_lam = cp6.checkbox("Flute Lamination", key="chk_flute_lam") | |
| has_folder_gluer = cp7.checkbox("Folder Gluer", key="chk_folder_gluer") | |
| # CA4: Multi-color printing cost | |
| num_colors = 0 | |
| if has_print: | |
| num_colors = cp8.selectbox("Colors", [1, 2, 3, 4, 5, 6], key="num_colors", | |
| help="Number of printing colors (+25% per extra color)") | |
| st.divider() | |
| with st.container(): | |
| st.markdown("### 2. Paper Composition & Deckle") | |
| # Define Layer Structure | |
| if ply_type == "3 Ply (Single Wall)": | |
| layer_defs = [("Top Liner", "Liner"), ("Flute", "Flute"), ("Bottom Liner", "Liner")] | |
| elif ply_type == "5 Ply (Double Wall)": | |
| layer_defs = [("Top Liner", "Liner"), ("Flute 1", "Flute"), ("Middle Liner", "Liner"), ("Flute 2", "Flute"), ("Bottom Liner", "Liner")] | |
| elif ply_type == "2+1 (Bleach Card)": | |
| layer_defs = [("Top Liner (Bleach Card)", "Liner"), ("Flute", "Flute"), ("Bottom Liner", "Liner")] | |
| elif ply_type == "4+1 (Bleach Card)": | |
| layer_defs = [("Top Liner (Bleach Card)", "Liner"), ("Flute 1", "Flute"), ("Middle Liner", "Liner"), ("Flute 2", "Flute"), ("Bottom Liner", "Liner")] | |
| elif ply_type == "3 Ply Padded Sheet": | |
| layer_defs = [("Top Liner", "Liner"), ("Flute", "Flute"), ("Bottom Liner", "Liner")] | |
| elif ply_type == "5 Ply Padded Sheet": | |
| layer_defs = [("Top Liner", "Liner"), ("Flute 1", "Flute"), ("Middle Liner", "Liner"), ("Flute 2", "Flute"), ("Bottom Liner", "Liner")] | |
| else: | |
| layer_defs = [] | |
| selected_layers = [] | |
| # Load sheet sizes once outside the layer loop (avoid repeated HF API calls) | |
| _saved_sheets_cache = load_sheet_sizes() | |
| # ================================================================ | |
| # UNIFIED DECKLE SELECTION (for all reel layers) | |
| # Padded Sheets use sheet-only logic, so no deckle section. | |
| # Glue flap is in running direction only (not deckle). | |
| # ================================================================ | |
| unified_deckle_val_mm = 0 # default (will be set below for reel types) | |
| stitch_mm = FACTORY_CONFIG.stitch_allowance_mm # default for padded sheets | |
| req_width = 0 # deckle requirement per blank (set below for non-pad types) | |
| deckle_options = [] # pre-computed deckle options (set below) | |
| _deckle_opt_map = {} | |
| _deckle_default_idx = 0 | |
| if not is_pad_type: | |
| # ── GLUE FLAP ALLOWANCE (running/machine direction only) ── | |
| st.markdown("#### Glue Flap Allowance") | |
| _gf_c1, _gf_c2 = st.columns([1, 2]) | |
| with _gf_c1: | |
| if unit_system == "mm": | |
| if 'stitch_input_main' not in st.session_state: | |
| st.session_state['stitch_input_main'] = FACTORY_CONFIG.stitch_allowance_mm | |
| glue_flap_val = st.number_input( | |
| "Glue Flap (mm)", | |
| min_value=0.0, max_value=60.0, | |
| step=1.0, format="%.1f", | |
| key="stitch_input_main", | |
| help="Glue flap in the running (machine) direction. Does NOT affect deckle.") | |
| stitch_mm = round(glue_flap_val, 1) | |
| else: | |
| if 'stitch_input_main' not in st.session_state: | |
| st.session_state['stitch_input_main'] = round(FACTORY_CONFIG.stitch_allowance_mm / 25.4, 2) | |
| glue_flap_val = st.number_input( | |
| "Glue Flap (inches)", | |
| min_value=0.0, max_value=2.50, | |
| step=0.05, format="%.2f", | |
| key="stitch_input_main", | |
| help="Glue flap in the running (machine) direction. Does NOT affect deckle.") | |
| stitch_mm = round(glue_flap_val * 25.4, 1) | |
| FACTORY_CONFIG.stitch_allowance_mm = stitch_mm | |
| st.caption(f"= **{stitch_mm:.1f} mm** ({stitch_mm/25.4:.2f}\")") | |
| with _gf_c2: | |
| _running_len = 2 * l_mm + 2 * w_mm + stitch_mm | |
| _deckle_w = w_mm + h_mm | |
| st.info( | |
| f"Glue flap is in the **running direction** only:\n\n" | |
| f"**Sheet length** = 2L + 2W + Glue Flap = {_running_len:.0f} mm\n\n" | |
| f"**Deckle width** = W + H = {_deckle_w:.0f} mm (no glue flap)") | |
| st.divider() | |
| # Deckle requirement per blank (no glue flap — it's running direction) | |
| req_width = w_mm + h_mm | |
| if req_width > 0: | |
| # Gather ALL unique deckles from paper DB (supports list or single value) | |
| available_deckles_in = set() | |
| for p in PAPER_DB: | |
| if p.deckles: | |
| for d in p.deckles: | |
| if d and d > 0: | |
| available_deckles_in.add(int(d)) | |
| elif p.deckle and p.deckle > 0: | |
| available_deckles_in.add(int(p.deckle)) | |
| # Calculate waste for each deckle (with 1" grip allowance) | |
| deckle_options_data = [] | |
| # Map from option display string → deckle mm value (for robust lookup) | |
| _deckle_opt_map = {} | |
| for deckle_in in available_deckles_in: | |
| full_deckle_mm = deckle_in * 25.4 | |
| effective_mm = full_deckle_mm - FACTORY_CONFIG.grip_allowance_mm | |
| if effective_mm < req_width: | |
| continue # Too small for even 1 up after grip | |
| ups = int(effective_mm / req_width) | |
| if ups < 1: | |
| ups = 1 | |
| used_width = ups * req_width | |
| trim_mm = effective_mm - used_width | |
| grip_mm = FACTORY_CONFIG.grip_allowance_mm | |
| total_waste_mm = grip_mm + trim_mm | |
| waste_pct = (total_waste_mm / full_deckle_mm) * 100 | |
| deckle_options_data.append({ | |
| 'in': deckle_in, | |
| 'mm': full_deckle_mm, | |
| 'effective_mm': effective_mm, | |
| 'ups': ups, | |
| 'trim_mm': trim_mm, | |
| 'grip_mm': grip_mm, | |
| 'total_waste_mm': total_waste_mm, | |
| 'waste_pct': waste_pct, | |
| }) | |
| # Sort: most ups first, then least waste | |
| deckle_options_data.sort(key=lambda x: (-x['ups'], x['waste_pct'])) | |
| # Build options list with robust mapping (reused per-layer below) | |
| deckle_options = ["📝 Custom Size..."] | |
| if not deckle_options_data: | |
| deckle_options = ["❌ Box too large for any saved deckles", "📝 Custom Size..."] | |
| for opt in deckle_options_data: | |
| label = ( | |
| f"{opt['mm']:.0f}mm ({opt['in']}\") — " | |
| f"Usable: {opt['effective_mm']:.0f}mm | " | |
| f"{opt['ups']} ups, {opt['waste_pct']:.1f}% waste" | |
| ) | |
| deckle_options.append(label) | |
| _deckle_opt_map[label] = opt['mm'] # robust: map label → value | |
| # Default to best option (most ups, least waste = index 1) | |
| _deckle_default_idx = 1 if len(deckle_options) > 1 and "❌" not in deckle_options[0] else 0 | |
| st.caption(f"Req. deckle per blank: W({w_mm:.0f}) + H({h_mm:.0f}) = **{req_width:.0f}mm** — Grip: {FACTORY_CONFIG.grip_allowance_mm:.0f}mm") | |
| st.divider() | |
| for i, (name, l_type) in enumerate(layer_defs): | |
| with st.expander(f"**{name}**", expanded=True): | |
| # Flute Profile Selection (if applicable) | |
| if l_type == "Flute": | |
| f_name = st.selectbox("Flute Profile", [f.name for f in FLUTE_PROFILES], key=f"fp_{i}") | |
| flute_prof = next(f for f in FLUTE_PROFILES if f.name == f_name) | |
| else: | |
| flute_prof = None | |
| # Helper for consistent naming | |
| def format_paper_opt(p): | |
| supp = getattr(p, 'supplier', None) | |
| return f"{p.code} {p.gsm}GSM ({supp})" if supp else f"{p.code} {p.gsm}GSM" | |
| # Paper Selection | |
| paper_opts = [format_paper_opt(p) for p in PAPER_DB] | |
| # Check session state for a pre-loaded value | |
| st_key = f"p_{i}" | |
| if st_key in st.session_state: | |
| pre_val = st.session_state[st_key] | |
| if pre_val not in paper_opts: | |
| # Append the missing option so Streamlit doesn't crash on index lookup | |
| paper_opts.append(pre_val) | |
| # Smart Defaults | |
| sel_idx = 0 | |
| if "2+1" in ply_type and i == 0: | |
| try: sel_idx = next(idx for idx, opt in enumerate(paper_opts) if opt.startswith("BC")) | |
| except: sel_idx = 0 | |
| elif i == 1: | |
| # Normally index 1, bounds check just in case | |
| sel_idx = min(1, len(paper_opts) - 1) | |
| sel_paper_str = st.selectbox("Paper Grade", paper_opts, index=sel_idx, key=st_key) | |
| # Safe paper resolution | |
| sel_paper = next((p for p in PAPER_DB if format_paper_opt(p) == sel_paper_str), None) | |
| if not sel_paper: | |
| st.info(f"💡 **Note:** Paper '{sel_paper_str}' from your profile is not in the Paper DB. Using its saved properties.") | |
| # Dummy object so we don't crash when rendering number_inputs below. | |
| # The actual user values from the JSON are safe in session state and will take precedence in UI. | |
| sel_paper = copy.deepcopy(PAPER_DB[0]) | |
| try: | |
| code, gsm_str = sel_paper_str.split(" ", 1) | |
| sel_paper.code = code | |
| sel_paper.gsm = int(float(gsm_str.split("GSM")[0])) | |
| except: | |
| pass | |
| # Primary Inputs - Clean 3-column layout | |
| col1, col2, col3 = st.columns(3) | |
| # Logic for 2+1/4+1 Top Liner (Bleach Card) -> Sheet Input | |
| is_bleach_card_top = (("2+1" in ply_type or "4+1" in ply_type) and i == 0) | |
| with col1: | |
| gsm = st.number_input("GSM", value=sel_paper.gsm, key=f"g_{i}", help="Grams per Square Meter") | |
| with col2: | |
| rate = st.number_input("Rate (per kg)", value=sel_paper.rate, key=f"rt_{i}") | |
| # Local unit toggle for sheet/deckle sizes (independent of global setting) | |
| sheet_unit = st.radio("Input Unit", ["inch", "mm"], horizontal=True, key=f'sheet_unit_{i}') | |
| # Conversion helpers | |
| def to_display(val_in, unit): | |
| return val_in * 25.4 if unit == "mm" else val_in | |
| def from_display(val, unit): | |
| return val / 25.4 if unit == "mm" else val | |
| unit_suffix = "mm" if sheet_unit == "mm" else "in" | |
| with col3: | |
| if is_bleach_card_top: | |
| # SHEET SIZE SELECTOR (ENHANCED) | |
| # Calculate box flat dimensions for waste comparison | |
| if is_pad_type: | |
| flat_w_mm = w_mm | |
| flat_l_mm = l_mm | |
| else: | |
| flat_w_mm = w_mm + h_mm | |
| flat_l_mm = 2*l_mm + 2*w_mm + FACTORY_CONFIG.stitch_allowance_mm | |
| # Use pre-loaded sheet sizes (hoisted above loop) | |
| saved_sheets = _saved_sheets_cache | |
| # Compare all sheets | |
| sheet_comparison = CorruLabEngine.compare_all_sheets(flat_w_mm, flat_l_mm, saved_sheets) if saved_sheets else [] | |
| # Filter out sheets where box doesn't fit (ups == 0) | |
| valid_sheets = [sh for sh in sheet_comparison if sh['ups'] > 0] | |
| invalid_sheets = [sh for sh in sheet_comparison if sh['ups'] == 0] | |
| # Find best sheet (only from valid ones) | |
| best_sheet = None | |
| if valid_sheets: | |
| valid_sheets_sorted = sorted(valid_sheets, key=lambda x: x['waste_pct']) | |
| best_sheet = valid_sheets_sorted[0] | |
| # Create options for selectbox | |
| sheet_options = ["➕ Add New Sheet...", "📋 Custom Size (Use Once)"] | |
| if best_sheet: | |
| gsm_label = f" {best_sheet.get('gsm', '')}g" if best_sheet.get('gsm') else "" | |
| w_disp = to_display(best_sheet['width_in'], sheet_unit) | |
| l_disp = to_display(best_sheet['length_in'], sheet_unit) | |
| sheet_options.insert(0, f"✅ Auto: {w_disp:.1f}x{l_disp:.1f}{unit_suffix}{gsm_label} ({best_sheet['waste_pct']}%)") | |
| for sh in valid_sheets: | |
| if best_sheet and sh['id'] == best_sheet['id']: | |
| continue | |
| gsm_label = f" {sh.get('gsm', '')}g" if sh.get('gsm') else "" | |
| w_disp = to_display(sh['width_in'], sheet_unit) | |
| l_disp = to_display(sh['length_in'], sheet_unit) | |
| sheet_options.append(f"{w_disp:.1f}x{l_disp:.1f}{unit_suffix}{gsm_label} ({sh['waste_pct']}%)") | |
| selected_sheet_opt = st.selectbox("Sheet Size", sheet_options, key=f'sheet_sel_{i}') | |
| # Show error if there are sheets that don't fit | |
| if invalid_sheets and not valid_sheets: | |
| st.error("⚠️ Box too large! No saved sheets can fit this box. Use 'Custom Size' or 'Add New Sheet'.") | |
| elif invalid_sheets: | |
| st.warning(f"ℹ️ {len(invalid_sheets)} sheet(s) too small for this box size") | |
| # Determine selected sheet values | |
| if "Auto:" in selected_sheet_opt and best_sheet: | |
| sh_w_in = best_sheet['width_in'] | |
| sh_l_in = best_sheet['length_in'] | |
| elif "Add New Sheet" in selected_sheet_opt: | |
| # ADD NEW SHEET FORM | |
| st.markdown("**Add New Sheet to Templates**") | |
| anc1, anc2, anc3 = st.columns(3) | |
| with anc1: | |
| new_w = st.number_input(f"Width ({unit_suffix})", value=30.0, min_value=1.0, key=f'new_sheet_w_{i}') | |
| with anc2: | |
| new_l = st.number_input(f"Length ({unit_suffix})", value=34.0, min_value=1.0, key=f'new_sheet_l_{i}') | |
| with anc3: | |
| new_gsm = st.number_input("GSM", value=220, min_value=100, max_value=400, step=10, key=f'new_sheet_gsm_{i}') | |
| # Convert to inches for storage | |
| new_w_in = from_display(new_w, sheet_unit) | |
| new_l_in = from_display(new_l, sheet_unit) | |
| # Preview | |
| preview_ups = CorruLabEngine._calculate_sheet_ups(new_w_in*25.4, new_l_in*25.4, flat_w_mm, flat_l_mm) | |
| if preview_ups > 0: | |
| preview_sheet_area = new_w_in * new_l_in * 25.4 * 25.4 | |
| preview_box_area = flat_w_mm * flat_l_mm | |
| preview_waste = ((preview_sheet_area - preview_ups * preview_box_area) / preview_sheet_area) * 100 | |
| st.success(f"✅ Fits! Ups: {preview_ups} | Waste: {preview_waste:.1f}%") | |
| else: | |
| st.error("❌ Box too large for this sheet size!") | |
| if st.button("💾 Save Sheet", key=f'save_new_sheet_{i}'): | |
| new_sheet = { | |
| "name": f"{new_w_in:.1f}x{new_l_in:.1f}", | |
| "width_in": new_w_in, | |
| "length_in": new_l_in, | |
| "gsm": new_gsm | |
| } | |
| success, msg = save_sheet_size(new_sheet) | |
| if success: | |
| st.success(msg) | |
| st.rerun() | |
| else: | |
| st.error(msg) | |
| # Use default for now | |
| sh_w_in = new_w_in | |
| sh_l_in = new_l_in | |
| elif "Custom Size" in selected_sheet_opt: | |
| # CUSTOM ENTRY (USE ONCE - NO SAVE) | |
| st.caption(f"Custom Sheet Size ({unit_suffix}) - Use Once") | |
| scust1, scust2 = st.columns(2) | |
| with scust1: | |
| cust_w = st.number_input("W", value=25.0 if sheet_unit == "inch" else 635.0, min_value=1.0, key=f'sheet_w_{i}') | |
| with scust2: | |
| cust_l = st.number_input("L", value=36.0 if sheet_unit == "inch" else 914.0, min_value=1.0, key=f'sheet_l_{i}') | |
| sh_w_in = from_display(cust_w, sheet_unit) | |
| sh_l_in = from_display(cust_l, sheet_unit) | |
| # Calculate waste for custom size | |
| custom_ups = CorruLabEngine._calculate_sheet_ups(sh_w_in*25.4, sh_l_in*25.4, flat_w_mm, flat_l_mm) | |
| if custom_ups > 0: | |
| custom_sheet_area = sh_w_in * sh_l_in * 25.4 * 25.4 | |
| custom_box_area = flat_w_mm * flat_l_mm | |
| custom_waste = ((custom_sheet_area - custom_ups * custom_box_area) / custom_sheet_area) * 100 | |
| st.caption(f"✅ Ups: **{custom_ups}** | Waste: **{custom_waste:.1f}%**") | |
| else: | |
| st.error("❌ Box too large for this sheet!") | |
| else: | |
| # Find selected sheet from comparison | |
| for sh in valid_sheets: | |
| w_disp = to_display(sh['width_in'], sheet_unit) | |
| l_disp = to_display(sh['length_in'], sheet_unit) | |
| if f"{w_disp:.1f}x{l_disp:.1f}" in selected_sheet_opt: | |
| sh_w_in = sh['width_in'] | |
| sh_l_in = sh['length_in'] | |
| break | |
| else: | |
| # Fallback | |
| sh_w_in = 25.0 | |
| sh_l_in = 36.0 | |
| # SHEET MANAGEMENT: Compare & Delete | |
| if sheet_comparison: | |
| if st.checkbox("🔍 Manage Sheets", key=f'show_comp_{i}'): | |
| st.markdown("**All Saved Sheets**") | |
| for sh in sheet_comparison: | |
| w_disp = to_display(sh['width_in'], sheet_unit) | |
| l_disp = to_display(sh['length_in'], sheet_unit) | |
| gsm_label = f" {sh.get('gsm', 0)}g" if sh.get('gsm') else "" | |
| status = f"✅ {sh['ups']} ups, {sh['waste_pct']}% waste" if sh['ups'] > 0 else "❌ Box too large" | |
| mcol1, mcol2 = st.columns([4, 1]) | |
| with mcol1: | |
| st.text(f"{w_disp:.1f}x{l_disp:.1f}{unit_suffix}{gsm_label} - {status}") | |
| with mcol2: | |
| if st.button("🗑️", key=f"del_{sh['id']}_{i}", help="Delete this sheet"): | |
| success, msg = delete_sheet_size(sh['id']) | |
| if success: | |
| st.rerun() | |
| else: | |
| st.error(msg) | |
| # Store mm for internal model | |
| sheet_dims_mm = [sh_w_in * 25.4, sh_l_in * 25.4] | |
| deckle_val_mm = 0 | |
| else: | |
| # REEL LAYER — per-layer deckle selection | |
| sheet_dims_mm = None | |
| if req_width > 0 and len(deckle_options) > 0: | |
| _dk_key = f"deckle_sel_{i}" | |
| selected_dk_opt = st.selectbox( | |
| "Reel Deckle", | |
| deckle_options, | |
| index=_deckle_default_idx, | |
| key=_dk_key, | |
| help="Reel deckle for this layer. Grip allowance auto-deducted." | |
| ) | |
| if "Custom Size" in selected_dk_opt: | |
| dk_cust = st.number_input( | |
| unit_label("Custom Deckle", unit_system), | |
| value=47.0 if unit_system == "inch" else 1200.0, | |
| step=1.0, | |
| key=f"dk_custom_{i}" | |
| ) | |
| deckle_val_mm = to_mm(dk_cust, unit_system) | |
| elif "❌" in selected_dk_opt: | |
| deckle_val_mm = 0 | |
| else: | |
| deckle_val_mm = _deckle_opt_map.get(selected_dk_opt, 0) | |
| if deckle_val_mm > 0: | |
| _gp = FACTORY_CONFIG.grip_allowance_mm | |
| _eff = deckle_val_mm - _gp | |
| st.caption(f"Full: **{deckle_val_mm:.0f}mm** → Grip: {_gp:.0f}mm → Usable: **{_eff:.0f}mm**") | |
| else: | |
| deckle_val_mm = 0 | |
| # Technical Specs - 4-column layout | |
| st.markdown("**Technical Specifications**") | |
| tc1, tc2, tc3, tc4 = st.columns(4) | |
| with tc1: | |
| rct_cd = st.number_input("RCT-CD (kgf)", value=sel_paper.rct_cd_kgf, key=f"rcd_{i}", help="Ring Crush Test CD (kgf)") | |
| with tc2: | |
| rct_md = st.number_input("RCT-MD (kgf)", value=sel_paper.rct_md_kgf, key=f"rmd_{i}", help="Ring Crush Test MD (kgf)") | |
| with tc3: | |
| burst = st.number_input("Burst (kPa)", value=sel_paper.bursting_strength_kpa, key=f"bst_{i}") | |
| with tc4: | |
| moist = st.number_input("Moisture %", value=sel_paper.moisture_pct, key=f"mst_{i}") | |
| # Per-layer caliper (editable, defaults from paper_db or GSM-based estimate) | |
| _est_caliper = sel_paper.estimated_caliper_mm | |
| _pt = sel_paper.paper_type or "Test" | |
| if l_type == "Flute" and flute_prof: | |
| _layer_cal_display = f"Flute height: **{flute_prof.height_mm:.1f} mm** ({flute_prof.name})" | |
| st.caption(_layer_cal_display) | |
| else: | |
| _cal_c1, _cal_c2 = st.columns([1, 2]) | |
| with _cal_c1: | |
| caliper_val = st.number_input( | |
| "Caliper (mm)", value=round(_est_caliper, 3), | |
| min_value=0.05, max_value=2.0, step=0.01, format="%.3f", | |
| key=f"cal_{i}", | |
| help=f"Board thickness. Auto: {gsm}GSM x {_pt} factor = {_est_caliper:.3f}mm. Override with lab-measured value.") | |
| with _cal_c2: | |
| _factor = {"Kraft": 0.0015, "Test": 0.0017, "Recycled": 0.0017, | |
| "Semi-Chemical": 0.0016, "Bleach Card": 0.0014}.get(_pt, 0.0017) | |
| st.caption(f"{_pt} paper | Factor: {_factor} | Auto: {gsm} x {_factor} = {_est_caliper:.3f}mm") | |
| # Create Paper Object with overrides (preserve deckle and supplier from source) | |
| final_paper = PaperGrade(sel_paper.code, gsm, rate, rct_cd, rct_md, burst, | |
| sel_paper.ash_pct, moist, | |
| deckle=sel_paper.deckle, supplier=sel_paper.supplier, | |
| caliper_mm=caliper_val if l_type != "Flute" else sel_paper.caliper_mm, | |
| paper_type=_pt) | |
| selected_layers.append(LayerInput(l_type, final_paper, flute_prof, | |
| deckle_mm=int(deckle_val_mm) if deckle_val_mm else 0, | |
| is_sheet=is_bleach_card_top, | |
| sheet_dims=sheet_dims_mm)) | |
| # Calculate auto board caliper from layers (for Auto adjustment mode) | |
| _auto_cal_mm = 0.0 | |
| for _sl in selected_layers: | |
| if _sl.layer_type == "Flute" and _sl.flute_profile: | |
| _auto_cal_mm += _sl.flute_profile.height_mm | |
| else: | |
| _auto_cal_mm += _sl.paper.estimated_caliper_mm | |
| st.session_state['_auto_board_caliper_mm'] = round(_auto_cal_mm, 2) | |
| # If Auto mode, recalculate dims with actual board caliper | |
| if not is_pad_type and adj_mode == "Auto (system)": | |
| dim_adj_mm = _auto_cal_mm | |
| if dim_type == "Inside": | |
| l_mm = l_mm_raw + dim_adj_mm | |
| w_mm = w_mm_raw + dim_adj_mm | |
| h_mm = h_mm_raw + dim_adj_mm | |
| # Build Spec Object | |
| try: | |
| CARTON_SPEC = CartonSpecification(l_mm, w_mm, h_mm, selected_layers, ply_type, | |
| stitch_allowance_mm=FACTORY_CONFIG.stitch_allowance_mm, | |
| has_printing=has_print, has_uv=has_uv, | |
| has_lamination=has_lam, has_die_cutting=has_die, | |
| has_paper_corrugation=has_corrugation, | |
| has_flute_lamination=has_flute_lam, | |
| has_folder_gluer=has_folder_gluer, | |
| is_pad=is_pad_type, | |
| box_style=box_style, | |
| num_colors=num_colors) | |
| except ValueError as e: | |
| st.error(f"Invalid dimensions: {e}") | |
| st.stop() | |
| # ======================================================================== | |
| # SAVE PROFILE HANDLER | |
| # ======================================================================== | |
| if st.session_state.get('trigger_save'): | |
| st.session_state['trigger_save'] = False | |
| # Collect all layer data | |
| layers_data = [] | |
| for i, (name, l_type) in enumerate(layer_defs): | |
| layer = selected_layers[i] | |
| layer_info = { | |
| "name": name, | |
| "type": l_type, | |
| "paper_code": layer.paper.code, | |
| "gsm": layer.paper.gsm, | |
| "rate": layer.paper.rate, | |
| "rct_cd": layer.paper.rct_cd_kgf, | |
| "rct_md": layer.paper.rct_md_kgf, | |
| "burst_kpa": layer.paper.bursting_strength_kpa, | |
| "moisture_pct": layer.paper.moisture_pct, | |
| "deckle_mm": layer.deckle_mm if not layer.is_sheet else 0, | |
| "caliper_mm": layer.paper.caliper_mm, | |
| "paper_type": layer.paper.paper_type | |
| } | |
| # Save Sheet Data | |
| if layer.is_sheet: | |
| layer_info['is_sheet'] = True | |
| layer_info['sheet_dims'] = layer.sheet_dims | |
| # Add flute profile if applicable | |
| if layer.flute_profile: | |
| layer_info["flute_profile"] = layer.flute_profile.name | |
| layers_data.append(layer_info) | |
| # Build profile data | |
| profile_data = { | |
| "name": st.session_state.get('save_profile_name', 'Unnamed'), | |
| "ply_type": ply_type, | |
| "deckle_mm": 0, # Per-layer deckle now (backward compat: 0 means check layers) | |
| "dimensions": { | |
| "length_mm": l_mm_raw, | |
| "width_mm": w_mm_raw, | |
| "height_mm": h_mm_raw | |
| }, | |
| "dimension_type": st.session_state.get('dim_type', 'Outside'), | |
| "dim_adj_mode": st.session_state.get('dim_adj_mode', 'Manual'), | |
| "dim_adjustment_mm": dim_adj_mm, | |
| "layers": layers_data, | |
| "processes": { | |
| "printing": has_print, | |
| "uv_coating": has_uv, | |
| "lamination": has_lam, | |
| "die_cutting": has_die, | |
| "paper_corrugation": has_corrugation, | |
| "flute_lamination": has_flute_lam, | |
| "folder_gluer": has_folder_gluer | |
| }, | |
| # --- NEW FIELDS --- | |
| "units": st.session_state.unit, | |
| "job_details": { | |
| "cust_name": cust_name, | |
| "quote_ref": quote_ref, | |
| "order_qty": order_qty | |
| }, | |
| "factory_settings": { | |
| "wastage_process_pct": FACTORY_CONFIG.wastage_process_pct, | |
| "ect_conversion_factor": FACTORY_CONFIG.ect_conversion_factor, | |
| "process_efficiency_pct": FACTORY_CONFIG.process_efficiency_pct, | |
| "cost_conversion_per_kg": FACTORY_CONFIG.cost_conversion_per_kg, | |
| "cost_fixed_setup": FACTORY_CONFIG.cost_fixed_setup, | |
| "cost_printing_per_1000": FACTORY_CONFIG.cost_printing_per_1000, | |
| "cost_printing_plate": FACTORY_CONFIG.cost_printing_plate, | |
| "cost_uv_per_1000": FACTORY_CONFIG.cost_uv_per_1000, | |
| "cost_lamination_per_1000": FACTORY_CONFIG.cost_lamination_per_1000, | |
| "cost_die_cutting_per_1000": FACTORY_CONFIG.cost_die_cutting_per_1000, | |
| "cost_die_frame": FACTORY_CONFIG.cost_die_frame, | |
| "cost_paper_corrugation_per_1000": FACTORY_CONFIG.cost_paper_corrugation_per_1000, | |
| "cost_flute_lamination_per_1000": FACTORY_CONFIG.cost_flute_lamination_per_1000, | |
| "cost_folder_gluer_per_1000": FACTORY_CONFIG.cost_folder_gluer_per_1000, | |
| "margin_pct": FACTORY_CONFIG.margin_pct, | |
| "stitch_allowance_mm": FACTORY_CONFIG.stitch_allowance_mm, | |
| "grip_allowance_mm": FACTORY_CONFIG.grip_allowance_mm, | |
| "flute_factors": {fp.name: fp.factor for fp in FLUTE_PROFILES} | |
| } | |
| } | |
| # Save profile | |
| save_mode = st.session_state.get('save_mode', 'new') | |
| target_id = None | |
| if save_mode == 'overwrite': | |
| target_id = st.session_state.get('loaded_profile_id') | |
| if not target_id: | |
| st.error("❌ No profile loaded to overwrite.") | |
| st.stop() | |
| success, message = save_box_profile(profile_data, target_id) | |
| if success: | |
| st.success(f"✅ {message}") | |
| st.balloons() | |
| st.session_state['loaded_profile_id'] = profile_data.get('id', target_id) | |
| st.session_state['_profiles_dirty'] = True # Invalidate profile cache | |
| else: | |
| st.error(f"❌ {message}") | |
| # --- TAB 2: ENGINEERING --- | |
| with tabs[1]: | |
| st.markdown("### 🔬 Physics-Based Analysis") | |
| # Calculate Results | |
| ect_res = CorruLabEngine.calculate_ect_rct_method(CARTON_SPEC, FACTORY_CONFIG) | |
| bct_res = CorruLabEngine.calculate_bct_mckee(CARTON_SPEC, ect_res['ect_value'], FACTORY_CONFIG) | |
| burst_res = CorruLabEngine.calculate_box_burst(CARTON_SPEC) | |
| # Safety Factor Slider (Outside columns for full width) | |
| safety_factor = st.slider("Stacking Safety Factor (SF)", min_value=1.0, max_value=6.0, value=5.0, step=0.5, | |
| help="3=Short Term, 5=Long Term/High Humidity") | |
| safe_load_val = bct_res['bct_kg'] / safety_factor | |
| # --- KEY METRICS ROW (Using Native st.metric) --- | |
| col1, col2, col3 = st.columns(3) | |
| col1.metric("Edge Crush Test (ECT)", f"{ect_res['ect_value']:.2f} kN/m") | |
| col2.metric("Safe Stacking Load", f"{safe_load_val:.0f} kg", delta=f"Collapse @ {bct_res['bct_kg']:.0f} kg", delta_color="off") | |
| col3.metric("Bursting Strength", f"{burst_res['burst_kpa']:.0f} kPa") | |
| # BCT Confidence Band | |
| if 'bct_low' in bct_res and 'bct_high' in bct_res: | |
| st.caption(f"BCT Confidence Band: **{bct_res['bct_low']:.0f} kg** to **{bct_res['bct_high']:.0f} kg** (+-30%)") | |
| # Aspect Ratio Warning | |
| if bct_res.get("aspect_ratio_warning"): | |
| st.warning("Extreme box geometry detected (aspect ratio > 5:1). BCT prediction accuracy may be reduced. Consider redesigning dimensions for better structural performance.") | |
| # --- CALCULATION DETAILS --- | |
| with st.expander("📖 ECT Calculation Details"): | |
| st.markdown(ect_res['explanation']) | |
| with st.expander("📖 BCT (Stacking Load) Calculation Details"): | |
| st.markdown(bct_res['explanation']) | |
| with st.expander("📖 Burst Strength Calculation Details"): | |
| st.markdown(burst_res['explanation']) | |
| # CA3: Score-adjusted internal dimensions | |
| if not CARTON_SPEC.is_pad: | |
| with st.expander("📐 Board Caliper & Score-Adjusted Dimensions", expanded=False): | |
| # Calculate board caliper from paper layers | |
| _liner_cals = [] | |
| _flute_cals = [] | |
| _total_caliper = 0.0 | |
| for _lay in CARTON_SPEC.layers: | |
| if _lay.layer_type == "Flute" and _lay.flute_profile: | |
| _fc = _lay.flute_profile.height_mm | |
| _flute_cals.append(f"{_lay.flute_profile.name}: {_fc:.1f}mm") | |
| _total_caliper += _fc | |
| else: | |
| _lc = _lay.paper.estimated_caliper_mm | |
| _pt = _lay.paper.paper_type or "Test" | |
| _factor = { | |
| "Kraft": 0.0015, "Test": 0.0017, "Recycled": 0.0017, | |
| "Semi-Chemical": 0.0016, "Bleach Card": 0.0014 | |
| }.get(_pt, 0.0017) | |
| _liner_cals.append(f"{_lay.paper.code} {_lay.paper.gsm}GSM: {_lc:.3f}mm ({_pt} x {_factor})") | |
| _total_caliper += _lc | |
| _liner_str = ", ".join(_liner_cals) if _liner_cals else "None" | |
| _flute_str = ", ".join(_flute_cals) if _flute_cals else "None" | |
| st.info( | |
| f"**Board caliper: {_total_caliper:.2f}mm**\n\n" | |
| f"Liners: {_liner_str}\n\n" | |
| f"Flutes: {_flute_str}\n\n" | |
| f"Score deductions (RSC): L-{SCORE_ALLOWANCE_LENGTH_MM:.2f}mm, " | |
| f"W-{SCORE_ALLOWANCE_WIDTH_MM:.2f}mm, H-{SCORE_ALLOWANCE_DEPTH_MM:.2f}mm") | |
| # Outside dimensions (what calculations use) | |
| st.markdown("**Manufacturing (Outside) Dimensions:**") | |
| oc1, oc2, oc3 = st.columns(3) | |
| oc1.metric("Length", f"{CARTON_SPEC.length_mm:.1f} mm") | |
| oc2.metric("Width", f"{CARTON_SPEC.width_mm:.1f} mm") | |
| oc3.metric("Height", f"{CARTON_SPEC.height_mm:.1f} mm") | |
| # Score-adjusted internal dimensions | |
| st.markdown("**Score-Adjusted Internal Dimensions:**") | |
| score_dims = CARTON_SPEC.score_adjusted_dimensions | |
| sc1, sc2, sc3 = st.columns(3) | |
| sc1.metric("Internal Length", f"{score_dims['length_mm']:.1f} mm", | |
| delta=f"-{SCORE_ALLOWANCE_LENGTH_MM:.2f} mm", delta_color="off") | |
| sc2.metric("Internal Width", f"{score_dims['width_mm']:.1f} mm", | |
| delta=f"-{SCORE_ALLOWANCE_WIDTH_MM:.2f} mm", delta_color="off") | |
| sc3.metric("Internal Height", f"{score_dims['height_mm']:.1f} mm", | |
| delta=f"-{SCORE_ALLOWANCE_DEPTH_MM:.2f} mm", delta_color="off") | |
| st.divider() | |
| # --- LIFECYCLE SIMULATION --- | |
| st.markdown("#### 📉 Lifecycle Simulation (Strength vs Time)") | |
| humidity = st.slider("Storage Humidity (%)", 20, 95, 60, help="Higher humidity accelerates strength degradation.") | |
| # CA2: Check if majority of layers are recycled paper | |
| _recycled_count = sum( | |
| 1 for l in CARTON_SPEC.layers | |
| if getattr(l.paper, 'paper_type', None) in ('Test', 'Recycled', None) | |
| ) | |
| _is_recycled = _recycled_count > len(CARTON_SPEC.layers) / 2 | |
| # Calculate degradation | |
| lifecycle_df = CorruLabEngine.predict_lifecycle(safe_load_val, humidity, is_recycled=_is_recycled) | |
| # Plot | |
| fig_life = px.line(lifecycle_df, x='Days', y='Safe Load (kg)', title=f"Strength Decay @ {humidity}% RH") | |
| fig_life.add_hline(y=lifecycle_df['Target'][0], line_dash="dash", line_color="red", annotation_text="Safety Threshold (60%)") | |
| fig_life.update_layout(height=400, hovermode="x unified") | |
| st.plotly_chart(fig_life, width="stretch") | |
| # --- TAB 3: PRODUCTION --- | |
| with tabs[2]: | |
| st.markdown("### 🏭 Production Planning & Wastage") | |
| # 1. Wastage Calculation (Moved to top for Dashboard access) | |
| wastage_df = CorruLabEngine.calculate_wastage_per_layer(CARTON_SPEC, FACTORY_CONFIG) | |
| # Metrics - Sheet Area (Full Rectangle - factory buys full sheet) | |
| # Glue flap cut-outs are waste, tracked separately | |
| flap_height_mm = min(l_mm, w_mm) / 2.0 # RSC standard flap | |
| sheet_area_m2 = ((2*l_mm + 2*w_mm + CARTON_SPEC.stitch_allowance_mm) * (w_mm + h_mm)) / 1_000_000.0 | |
| stitch_cutout_area_m2 = (CARTON_SPEC.stitch_allowance_mm * 2 * flap_height_mm) / 1_000_000.0 | |
| # Calculate Total Weight (Net of Box * Qty) - Approximate | |
| # Better: sum of layer gsm | |
| total_gsm = sum(l.paper.gsm * (l.flute_profile.factor if l.flute_profile else 1.0) for l in CARTON_SPEC.layers) | |
| net_weight_box_kg = total_gsm * sheet_area_m2 / 1000.0 | |
| total_batch_weight_ton = (net_weight_box_kg * order_qty) / 1000.0 | |
| # Run Length (Linear Meters of Board) | |
| # Assuming board width is fixed by Deckle? No, Corrugator runs full width. | |
| # The Cut Length is the running direction. | |
| # Linear Meters = (Sheet Length / 1000) * Qty / Ups | |
| try: | |
| avg_ups = wastage_df['ups'].mean() | |
| except: | |
| avg_ups = 1 | |
| running_cut_length_m = (2*l_mm + 2*w_mm + CARTON_SPEC.stitch_allowance_mm) / 1000.0 | |
| total_linear_meters = (running_cut_length_m * order_qty) / max(1, avg_ups) | |
| # Estimated Time (at 150m/min avg) | |
| est_time_min = total_linear_meters / 150.0 | |
| # Dashboard Grid | |
| m1, m2, m3, m4 = st.columns(4) | |
| with m1: | |
| st.metric("Total Batch Weight", f"{total_batch_weight_ton:.2f} tons", help="Net Paper Weight") | |
| with m2: | |
| st.metric("Linear Meters", f"{total_linear_meters:,.0f} m", help="Total board length at corrugator") | |
| with m3: | |
| st.metric("Est. Run Time", f"{est_time_min:.0f} mins", help="Based on 150m/min avg speed") | |
| with m4: | |
| st.metric("Avg. Ups", f"{avg_ups:.1f}", help="Average Ups across layers") | |
| # --- WASTAGE BREAKDOWN (NEW TRANSPARENCY SECTION) --- | |
| st.divider() | |
| st.markdown("#### 📊 Wastage Breakdown") | |
| # Calculate detailed wastage | |
| total_net_kg = net_weight_box_kg * order_qty | |
| # Get gross from breakdown - using same corrected sheet area | |
| sheet_area = sheet_area_m2 # Already calculated above with stitch cut-out correction | |
| total_process_waste_kg = 0.0 | |
| total_trim_waste_kg = 0.0 | |
| total_gross_kg = 0.0 | |
| for i, layer in enumerate(CARTON_SPEC.layers): | |
| takeup = layer.flute_profile.factor if layer.flute_profile else 1.0 | |
| weight_net_kg = (layer.paper.gsm * takeup * sheet_area / 1000.0) * order_qty | |
| row = wastage_df.loc[wastage_df['layer_idx'] == i].iloc[0] | |
| # Process waste | |
| w_after_process = weight_net_kg * (1 + FACTORY_CONFIG.wastage_process_pct/100.0) | |
| process_waste = w_after_process - weight_net_kg | |
| total_process_waste_kg += process_waste | |
| # Trim waste | |
| if row.get('is_sheet', False): | |
| # SHEET LOGIC: Use trim_pct from wastage_df (calculated in calculations.py) | |
| trim_pct_val = row['trim_pct'] / 100.0 | |
| if trim_pct_val < 1: | |
| w_gross = w_after_process / (1 - trim_pct_val) | |
| else: | |
| w_gross = w_after_process # 100% waste edge case | |
| deckle_util = 1 - trim_pct_val # For consistency | |
| elif row['deckle_mm'] > 0 and row['ups'] > 0: | |
| # REEL LOGIC | |
| deckle_util = (row['ups'] * (w_mm + h_mm)) / row['deckle_mm'] | |
| if deckle_util > 0: | |
| w_gross = w_after_process / deckle_util | |
| else: | |
| w_gross = w_after_process | |
| else: | |
| w_gross = w_after_process | |
| deckle_util = 1.0 | |
| trim_waste = w_gross - w_after_process | |
| total_trim_waste_kg += trim_waste | |
| total_gross_kg += w_gross | |
| # Calculate stitch cut-out waste | |
| stitch_cutout_waste_kg = stitch_cutout_area_m2 * total_gsm / 1000.0 * order_qty | |
| total_waste_kg = total_process_waste_kg + total_trim_waste_kg + stitch_cutout_waste_kg | |
| waste_pct = (total_waste_kg / total_net_kg) * 100 if total_net_kg > 0 else 0 | |
| # Display breakdown | |
| waste_col1, waste_col2 = st.columns([2, 1]) | |
| with waste_col1: | |
| st.markdown(f""" | |
| | Component | Formula | Amount | | |
| |-----------|---------|--------| | |
| | **Paper Purchased** | GSM × Takeup × FullArea × Qty | **{total_net_kg:,.0f} kg** | | |
| | **- Glue Flap Cut-outs** | Paper cut above/below glue flap | -{stitch_cutout_waste_kg:,.0f} kg | | |
| | **+ Process Waste** | Paper × {FACTORY_CONFIG.wastage_process_pct}% | +{total_process_waste_kg:,.0f} kg | | |
| | **+ Trim Waste** | (100% - Deckle Utilization) | +{total_trim_waste_kg:,.0f} kg | | |
| | **= In Box** | Finished product weight | **{total_net_kg - stitch_cutout_waste_kg:,.0f} kg** | | |
| """) | |
| with waste_col2: | |
| # Visual gauge | |
| st.metric("Total Waste", f"{total_waste_kg:,.0f} kg", f"{waste_pct:.1f}%", delta_color="inverse") | |
| st.caption(f"Glue Flap: {stitch_cutout_waste_kg:.0f} kg | Process: {total_process_waste_kg:.0f} kg | Trim: {total_trim_waste_kg:.0f} kg") | |
| st.info(f""" | |
| **💡 How Waste is Calculated:** | |
| 1. **Glue Flap Cut-outs**: Flap areas above/below glue flap ({CARTON_SPEC.stitch_allowance_mm}mm x 2 x {flap_height_mm}mm) are cut and discarded | |
| 2. **Process Waste ({FACTORY_CONFIG.wastage_process_pct}%)**: Corrugator running waste, edge tears, startup/stop losses | |
| 3. **Trim Waste**: When deckle size doesn't divide evenly by sheet width (W+H = {w_mm+h_mm}mm) | |
| """) | |
| st.divider() | |
| # Display 2D Visualization for each unique layer | |
| st.markdown("#### Deckle / Sheet Utilization Visualizer") | |
| # Loop through unique layers in wastage_df | |
| for idx, row in wastage_df.iterrows(): | |
| l_name = f"{row['layer_type']} Layer {row['layer_idx']+1} ({row['paper']})" | |
| ups = int(row['ups']) | |
| is_sheet_layer = row.get('is_sheet', False) | |
| if is_sheet_layer: | |
| # SHEET VISUALIZATION (Enhanced) | |
| sheet_dims_str = row.get('sheet_dims', '0x0') | |
| try: | |
| s_w, s_l = [float(x) for x in sheet_dims_str.split('x')] | |
| except: | |
| s_w, s_l = 635.0, 914.0 # Default 25x36 in mm | |
| # Box flat dimensions | |
| flat_l = 2*l_mm + 2*w_mm + CARTON_SPEC.stitch_allowance_mm | |
| flat_w = w_mm + h_mm | |
| utilization = 100 - float(row['trim_pct']) | |
| waste_pct = float(row['trim_pct']) | |
| # Calculate box positions on sheet | |
| ups_normal = int(s_w // flat_w) * int(s_l // flat_l) | |
| ups_rotated = int(s_w // flat_l) * int(s_l // flat_w) | |
| if ups_rotated > ups_normal: | |
| box_w, box_l = flat_l, flat_w # Rotated | |
| cols = int(s_w // box_w) | |
| rows_count = int(s_l // box_l) | |
| orientation = "Rotated" | |
| else: | |
| box_w, box_l = flat_w, flat_l # Normal | |
| cols = int(s_w // box_w) | |
| rows_count = int(s_l // box_l) | |
| orientation = "Normal" | |
| actual_ups = cols * rows_count | |
| # Handle case where box is TOO BIG for sheet | |
| if actual_ups == 0: | |
| st.error(f""" | |
| ❌ **{l_name}: Box too large for sheet!** | |
| - Sheet Size: **{s_w:.0f} × {s_l:.0f}mm** | |
| - Box Flat Size: **{flat_w:.0f} × {flat_l:.0f}mm** | |
| 👉 **Solution:** Choose a larger sheet size or reduce box dimensions. | |
| """) | |
| continue # Skip to next layer | |
| # Create Sheet Visualization (only when boxes fit) | |
| fig_sheet = go.Figure() | |
| # Draw Sheet Background (WASTE AREA - Red) | |
| fig_sheet.add_shape( | |
| type="rect", x0=0, y0=0, x1=s_w, y1=s_l, | |
| line=dict(color="#c0392b", width=3), | |
| fillcolor="rgba(249, 115, 22, 0.3)" # F6: Orange for waste (colorblind-safe) | |
| ) | |
| # Draw boxes on sheet (USABLE AREA - Green) | |
| box_count = 0 | |
| for r in range(rows_count): | |
| for c in range(cols): | |
| x0 = c * box_w | |
| y0 = r * box_l | |
| fig_sheet.add_shape( | |
| type="rect", x0=x0, y0=y0, x1=x0+box_w, y1=y0+box_l, | |
| line=dict(color="#1d4ed8", width=2), | |
| fillcolor="rgba(37, 99, 235, 0.85)" # F6: Blue (colorblind-safe) | |
| ) | |
| box_count += 1 | |
| if box_count <= 4: # Only label first few to avoid clutter | |
| fig_sheet.add_annotation( | |
| x=x0 + box_w/2, y=y0 + box_l/2, | |
| text=f"<b>Box {box_count}</b><br>{box_w:.0f}×{box_l:.0f}mm", | |
| showarrow=False, | |
| font=dict(color="white", size=10) | |
| ) | |
| # Add waste area label | |
| used_w = cols * box_w | |
| used_l = rows_count * box_l | |
| waste_area = (s_w * s_l) - (used_w * used_l) | |
| if s_w - used_w > 20: # Right waste strip | |
| fig_sheet.add_annotation( | |
| x=used_w + (s_w - used_w)/2, y=s_l/2, | |
| text=f"<b>WASTE</b><br>{s_w - used_w:.0f}mm", | |
| showarrow=False, | |
| font=dict(color="white", size=10) | |
| ) | |
| if s_l - used_l > 20: # Top waste strip | |
| fig_sheet.add_annotation( | |
| x=s_w/2, y=used_l + (s_l - used_l)/2, | |
| text=f"<b>WASTE</b><br>{s_l - used_l:.0f}mm", | |
| showarrow=False, | |
| font=dict(color="white", size=10) | |
| ) | |
| # Calculate weight and cost | |
| layer = CARTON_SPEC.layers[int(row['layer_idx'])] | |
| sheet_area_m2 = (s_w * s_l) / 1_000_000.0 | |
| sheet_weight_kg = sheet_area_m2 * layer.paper.gsm / 1000.0 | |
| sheets_needed = (order_qty / actual_ups) if actual_ups > 0 else order_qty | |
| total_weight_kg = sheet_weight_kg * sheets_needed | |
| total_cost = total_weight_kg * layer.paper.rate | |
| # Layout | |
| fig_sheet.update_layout( | |
| title=dict( | |
| text=f"<b>📄 {l_name}</b><br><span style='font-size:12px'>Sheet: {s_w:.0f}×{s_l:.0f}mm | Orientation: {orientation} | Ups: {actual_ups} | Waste: {waste_pct:.1f}%</span>", | |
| font=dict(size=14) | |
| ), | |
| xaxis=dict(title="Width (mm)", range=[-20, s_w*1.1], showgrid=True, gridcolor='rgba(0,0,0,0.1)'), | |
| yaxis=dict(title="Length (mm)", range=[-20, s_l*1.1], scaleanchor="x", showgrid=True, gridcolor='rgba(0,0,0,0.1)'), | |
| height=450, | |
| margin=dict(l=50, r=20, t=80, b=50), | |
| plot_bgcolor='white' | |
| ) | |
| # Display chart with info panel | |
| viz_col1, viz_col2 = st.columns([3, 1]) | |
| with viz_col1: | |
| st.plotly_chart(fig_sheet, width="stretch") | |
| with viz_col2: | |
| st.markdown("##### 📊 Sheet Stats") | |
| st.metric("Ups per Sheet", f"{actual_ups}") | |
| st.metric("Sheets Needed", f"{sheets_needed:.0f}") | |
| st.metric("Total Weight", f"{total_weight_kg:.1f} kg") | |
| st.metric("Material Cost", f"Rs {total_cost:,.0f}") | |
| if waste_pct > 15: | |
| st.error(f"⚠️ High waste: {waste_pct:.1f}%") | |
| elif waste_pct > 8: | |
| st.warning(f"🔶 Moderate waste: {waste_pct:.1f}%") | |
| else: | |
| st.success(f"✅ Low waste: {waste_pct:.1f}%") | |
| else: | |
| # DECKLE VISUALIZATION (existing logic) | |
| deckle = float(row['deckle_mm']) | |
| if deckle > 0 and ups > 0: | |
| used_w = deckle - float(row['trim_mm']) | |
| sheet_w = used_w / ups | |
| sheet_length = 2*l_mm + 2*w_mm + CARTON_SPEC.stitch_allowance_mm | |
| utilization = 100 - float(row['trim_pct']) | |
| fig_viz = plot_deckle_visualization(deckle, sheet_w, sheet_length, ups, l_name, utilization) | |
| st.plotly_chart(fig_viz, width="stretch") | |
| else: | |
| st.warning(f"⚠️ {l_name}: No valid deckle data to visualize") | |
| # Summary Table (Cleaned up) | |
| st.markdown("#### 📋 Layer Summary") | |
| # Create a cleaner display dataframe | |
| display_df = wastage_df.copy() | |
| display_df['Layer'] = display_df.apply(lambda r: f"{r['layer_type']} {r['layer_idx']+1}", axis=1) | |
| display_df['Paper'] = display_df['paper'] | |
| display_df['Source'] = display_df.apply(lambda r: f"Sheet ({r['sheet_dims']})" if r.get('is_sheet') else f"Reel ({r['deckle_mm']:.0f}mm)", axis=1) | |
| display_df['Ups'] = display_df['ups'] | |
| display_df['Waste %'] = display_df['trim_pct'].apply(lambda x: f"{x:.1f}%") | |
| display_df['Utilization'] = display_df['trim_pct'].apply(lambda x: f"{100-x:.1f}%") | |
| # Select only clean columns | |
| st.dataframe( | |
| display_df[['Layer', 'Paper', 'Source', 'Ups', 'Waste %', 'Utilization']], | |
| width="stretch", | |
| hide_index=True | |
| ) | |
| st.divider() | |
| st.markdown("### 🏗️ Engineering Visualizers") | |
| vis_col1, vis_col2 = st.columns([1, 1]) | |
| # --- ENHANCED 2D NET VISUALIZER --- | |
| if CARTON_SPEC.is_pad: | |
| st.info("ℹ️ **Padded Sheet:** Flat board only. No 2D cutting net or 3D folded visualization applicable.") | |
| else: | |
| # Dimensions | |
| L_net = l_mm | |
| W_net = w_mm | |
| H_net = h_mm | |
| S_net = CARTON_SPEC.stitch_allowance_mm | |
| # Helper for formatting units | |
| use_inch = st.session_state.get('unit', 'mm') == 'inch' | |
| def fmt_dim(val_mm): | |
| if use_inch: | |
| return f"{val_mm/25.4:.2f} in" | |
| return f"{val_mm:.0f} mm" | |
| def fmt_val(val_mm): # Just number for text labels with space constraint | |
| if use_inch: | |
| return f"{val_mm/25.4:.2f}" | |
| return f"{val_mm:.0f}" | |
| # X Coordinates (Length Direction) | |
| # Flap - Length - Width - Length - Width | |
| x_coords = [0, S_net, S_net+L_net, S_net+L_net+W_net, S_net+2*L_net+W_net, S_net+2*L_net+2*W_net] | |
| total_net_w = x_coords[-1] | |
| # Y Coordinates (Height Direction) | |
| # Flap - Height - Flap | |
| flap_h = min(L_net, W_net) / 2.0 # RSC flap is half of smaller dimension (L or W) | |
| y_coords = [0, flap_h, flap_h+H_net, 2*flap_h+H_net] | |
| total_net_h = y_coords[-1] | |
| fig_2d = go.Figure() | |
| # --- TECHNICAL DIELINE LOGIC --- | |
| # Green = Cut Lines (Outline) | |
| # Red Dashed = Crease Lines (Internal Folds) | |
| cut_lines_x = [] | |
| cut_lines_y = [] | |
| crease_lines_x = [] | |
| crease_lines_y = [] | |
| # Slot Gap (e.g., 5mm) to separate flap edges | |
| slot_gap = 5.0 | |
| # --- 1. Outline (Cut Lines) --- | |
| # We trace the outer boundary: | |
| # Start Top-Left of Stitch Tab -> Top Edge of Flaps -> Right Edge -> Bottom Edge -> Stitch Tab | |
| # Stitch Tab: (0, flap)->(S, flap) [Top] ? No, Stitch usually has angled cut. | |
| # Let's keep smooth rect for now. | |
| # Stitch: x=0..S, y=flap..flap+H | |
| # Top Edge Path: | |
| # 1. Stitch Top: (0, flap) -> (S, flap) | |
| # 2. Panel 1 Top Flap: (S + gap/2, 0) -> (S+L - gap/2, 0) | |
| # 3. Panel 2 Top Flap: (S+L + gap/2, 0) -> (S+L+W - gap/2, 0) | |
| # ... | |
| # Actually, simpler to draw "Per Panel" outlines. | |
| # Cut Lines = Top of Flaps + Bottom of Flaps + Side Edges of Flaps + Outer Edges of End Panels. | |
| # Crease Lines = Flap Hinge (Horizontal) + Panel Hinge (Vertical). | |
| # Stitch Tab | |
| # Cut: Left (0, flap->flap+H), Top (0,flap->S,flap), Bottom (0,flap+H->S,flap+H). | |
| # Hinge(Red): Right (S, flap->flap+H) attached to Panel 1. | |
| # Cut Lines (Green) | |
| # Stitch Left | |
| fig_2d.add_trace(go.Scatter(x=[0, 0], y=[flap_h, flap_h+H_net], mode='lines', line=dict(color='green', width=2), showlegend=False)) | |
| # Stitch Top/Bot (Angled usually, but straight here) | |
| fig_2d.add_trace(go.Scatter(x=[0, S_net], y=[flap_h, flap_h], mode='lines', line=dict(color='green', width=2), showlegend=False)) | |
| fig_2d.add_trace(go.Scatter(x=[0, S_net], y=[flap_h+H_net, flap_h+H_net], mode='lines', line=dict(color='green', width=2), showlegend=False)) | |
| # Panels Loop | |
| panels = [ | |
| (S_net, x_coords[2]), # Panel 1 (L) | |
| (x_coords[2], x_coords[3]), # Panel 2 (W) | |
| (x_coords[3], x_coords[4]), # Panel 3 (L) | |
| (x_coords[4], x_coords[5]) # Panel 4 (W) | |
| ] | |
| for idx, (x_start, x_end) in enumerate(panels): | |
| # Vertical Hinge (Start) - Red Dashed | |
| # If it's the first panel, it's the Stitch Hinge. | |
| # If it's later, it's shared with prev panel. | |
| fig_2d.add_trace(go.Scatter(x=[x_start, x_start], y=[flap_h, flap_h+H_net], mode='lines', line=dict(color='red', width=1, dash='dash'), showlegend=False)) | |
| # Horizontal Hinges (Top & Bottom) - Red Dashed | |
| # Flap Fold Lines | |
| fig_2d.add_trace(go.Scatter(x=[x_start, x_end], y=[flap_h, flap_h], mode='lines', line=dict(color='red', width=1, dash='dash'), showlegend=False)) | |
| fig_2d.add_trace(go.Scatter(x=[x_start, x_end], y=[flap_h+H_net, flap_h+H_net], mode='lines', line=dict(color='red', width=1, dash='dash'), showlegend=False)) | |
| # --- Flaps (Green Cuts) --- | |
| # Top Flap | |
| # Left Edge (Cut) - with gap | |
| # But adjacent flaps share a slot. | |
| # So "x_start" -> "x_start + slot_gap/2" is void? | |
| # Standard: Standard slot is cut centered on the crease line. | |
| # So Flap Left Edge starts at x_start + slot/2. | |
| # Top Flap Path: | |
| # (x_start + gap/2, flap) -> (x_start + gap/2, 0) | |
| # (x_start + gap/2, 0) -> (x_end - gap/2, 0) | |
| # (x_end - gap/2, 0) -> (x_end - gap/2, flap) | |
| gap_offset = slot_gap / 2.0 | |
| t_x = [x_start + gap_offset, x_start + gap_offset, x_end - gap_offset, x_end - gap_offset] | |
| t_y = [flap_h, 0, 0, flap_h] | |
| fig_2d.add_trace(go.Scatter(x=t_x, y=t_y, mode='lines', line=dict(color='green', width=2), showlegend=False)) | |
| # Bottom Flap Path | |
| b_x = [x_start + gap_offset, x_start + gap_offset, x_end - gap_offset, x_end - gap_offset] | |
| b_y = [flap_h + H_net, total_net_h, total_net_h, flap_h + H_net] | |
| fig_2d.add_trace(go.Scatter(x=b_x, y=b_y, mode='lines', line=dict(color='green', width=2), showlegend=False)) | |
| # Final Vertical Line (End of Panel 4) - Green Cut | |
| last_x = x_coords[5] | |
| fig_2d.add_trace(go.Scatter(x=[last_x, last_x], y=[flap_h, flap_h+H_net], mode='lines', line=dict(color='green', width=2), showlegend=False)) | |
| # --- DIMENSIONS --- | |
| # Add Arrow Annotations for L, W, H | |
| # 1. Length (Panel 1) - Inside Panel | |
| p1_center = (panels[0][0] + panels[0][1]) / 2 | |
| # Better: Use 'shapes' for arrows or just text? | |
| # Let's simple text. | |
| fig_2d.add_annotation(x=p1_center, y=flap_h + H_net/2, text=f"L={fmt_val(L_net)}", showarrow=False, font=dict(size=12, color="black")) | |
| # 2. Width (Panel 2) | |
| p2_center = (panels[1][0] + panels[1][1]) / 2 | |
| fig_2d.add_annotation(x=p2_center, y=flap_h + H_net/2, text=f"W={fmt_val(W_net)}", showarrow=False, font=dict(size=12, color="black")) | |
| # 3. Flap Height (W/2) | |
| # Vertical Arrow on Flap 2 | |
| fig_2d.add_annotation(x=p2_center, y=flap_h/2, text=f"Flap={fmt_val(flap_h)}", showarrow=False, font=dict(size=10, color="gray")) | |
| # 4. Total Height (H) | |
| # Arrow on side | |
| # x= -20 | |
| # y= flap -> flap+H | |
| fig_2d.add_annotation( | |
| x=-30, y=flap_h + H_net/2, | |
| text=f"H={fmt_val(H_net)}", showarrow=False, textangle=-90 | |
| ) | |
| # Vertical Line for H dimension | |
| fig_2d.add_shape(type="line", x0=-10, y0=flap_h, x1=-10, y1=flap_h+H_net, line=dict(color="black", width=1)) | |
| fig_2d.add_shape(type="line", x0=-15, y0=flap_h, x1=-5, y1=flap_h, line=dict(color="black", width=1)) # Tick | |
| fig_2d.add_shape(type="line", x0=-15, y0=flap_h+H_net, x1=-5, y1=flap_h+H_net, line=dict(color="black", width=1)) # Tick | |
| # Styling | |
| padding_x = total_net_w * 0.1 | |
| padding_y = total_net_h * 0.1 | |
| fig_2d.update_layout( | |
| title=f"2D Production Net ({fmt_val(total_net_w)} x {fmt_val(total_net_h)} {unit_label( '', unit_system).split('(')[1][:-1]})", | |
| # Hacky split to get 'mm' or 'inch'. Better: Use my 'unit_label' or just 'unit'. | |
| # Actually I defined 'unit_label' helper earlier? | |
| # Let's use simpler: 'mm' if unit=='mm' else 'inch' | |
| # But wait, local scope issues? No. | |
| xaxis=dict(showgrid=False, showticklabels=False, visible=False, range=[-padding_x, total_net_w + padding_x]), | |
| yaxis=dict(showgrid=False, showticklabels=False, visible=False, scaleanchor="x", scaleratio=1, range=[total_net_h + padding_y, -padding_y]), | |
| height=350, | |
| margin=dict(l=10, r=10, t=30, b=10), | |
| plot_bgcolor='white', | |
| ) | |
| # --- SIMPLIFIED 3D VISUALIZATION --- | |
| st.markdown("#### 🧊 3D Box Visualization") | |
| # Simple toggle between flat net and folded box | |
| view_mode = st.radio("View Mode", ["📄 Flat Net", "📦 Folded Box"], horizontal=True, help="Toggle between flat production sheet and assembled box") | |
| import numpy as np | |
| # Color scheme | |
| COLOR_GLUE_FLAP = "#2962FF" # Blue for glue flap | |
| COLOR_PANEL_L = "#C19A6B" # Brown for Length panels (1, 3) | |
| COLOR_PANEL_W = "#A67C52" # Slightly different brown for Width panels (2, 4) | |
| COLOR_FLAP = "#D4A574" # Lighter brown for flaps | |
| fig_3d = go.Figure() | |
| # Helper: Create quad mesh | |
| def add_quad(corners, color, name): | |
| """Add a quadrilateral mesh from 4 corner points [(x,y,z), ...]""" | |
| x = [c[0] for c in corners] | |
| y = [c[1] for c in corners] | |
| z = [c[2] for c in corners] | |
| fig_3d.add_trace(go.Mesh3d( | |
| x=x, y=y, z=z, | |
| i=[0, 0], j=[1, 2], k=[2, 3], | |
| color=color, opacity=0.95, name=name, | |
| flatshading=True, | |
| lighting=dict(ambient=0.7, diffuse=0.8, roughness=0.3, specular=0.2) | |
| )) | |
| # Wireframe | |
| wx = x + [x[0]] | |
| wy = y + [y[0]] | |
| wz = z + [z[0]] | |
| fig_3d.add_trace(go.Scatter3d( | |
| x=wx, y=wy, z=wz, mode='lines', | |
| line=dict(color='#333', width=2), showlegend=False | |
| )) | |
| unit_label = "in" if use_inch else "mm" | |
| if view_mode == "📄 Flat Net": | |
| # === FLAT NET VIEW === | |
| # Lay the entire net flat on XY plane (Z=0) | |
| # Same layout as 2D: Stitch | P1(L) | P2(W) | P3(L) | P4(W) | |
| # Y direction: Bottom Flap | Main Body (H) | Top Flap | |
| z = 0 # Flat on table | |
| # Panel X positions (same as 2D) | |
| stitch_x = (0, S_net) | |
| p1_x = (S_net, S_net + L_net) | |
| p2_x = (S_net + L_net, S_net + L_net + W_net) | |
| p3_x = (S_net + L_net + W_net, S_net + 2*L_net + W_net) | |
| p4_x = (S_net + 2*L_net + W_net, total_net_w) | |
| # Y positions | |
| bot_flap_y = (0, flap_h) | |
| main_y = (flap_h, flap_h + H_net) | |
| top_flap_y = (flap_h + H_net, total_net_h) | |
| # Stitch Tab (main body only, no flaps) | |
| add_quad([ | |
| (stitch_x[0], main_y[0], z), (stitch_x[1], main_y[0], z), | |
| (stitch_x[1], main_y[1], z), (stitch_x[0], main_y[1], z) | |
| ], COLOR_GLUE_FLAP, "Glue Flap") | |
| # Panel 1 (L) + Flaps | |
| add_quad([(p1_x[0], main_y[0], z), (p1_x[1], main_y[0], z), (p1_x[1], main_y[1], z), (p1_x[0], main_y[1], z)], COLOR_PANEL_L, "Panel 1 (L)") | |
| add_quad([(p1_x[0], bot_flap_y[0], z), (p1_x[1], bot_flap_y[0], z), (p1_x[1], bot_flap_y[1], z), (p1_x[0], bot_flap_y[1], z)], COLOR_FLAP, "P1 Bot Flap") | |
| add_quad([(p1_x[0], top_flap_y[0], z), (p1_x[1], top_flap_y[0], z), (p1_x[1], top_flap_y[1], z), (p1_x[0], top_flap_y[1], z)], COLOR_FLAP, "P1 Top Flap") | |
| # Panel 2 (W) + Flaps | |
| add_quad([(p2_x[0], main_y[0], z), (p2_x[1], main_y[0], z), (p2_x[1], main_y[1], z), (p2_x[0], main_y[1], z)], COLOR_PANEL_W, "Panel 2 (W)") | |
| add_quad([(p2_x[0], bot_flap_y[0], z), (p2_x[1], bot_flap_y[0], z), (p2_x[1], bot_flap_y[1], z), (p2_x[0], bot_flap_y[1], z)], COLOR_FLAP, "P2 Bot Flap") | |
| add_quad([(p2_x[0], top_flap_y[0], z), (p2_x[1], top_flap_y[0], z), (p2_x[1], top_flap_y[1], z), (p2_x[0], top_flap_y[1], z)], COLOR_FLAP, "P2 Top Flap") | |
| # Panel 3 (L) + Flaps | |
| add_quad([(p3_x[0], main_y[0], z), (p3_x[1], main_y[0], z), (p3_x[1], main_y[1], z), (p3_x[0], main_y[1], z)], COLOR_PANEL_L, "Panel 3 (L)") | |
| add_quad([(p3_x[0], bot_flap_y[0], z), (p3_x[1], bot_flap_y[0], z), (p3_x[1], bot_flap_y[1], z), (p3_x[0], bot_flap_y[1], z)], COLOR_FLAP, "P3 Bot Flap") | |
| add_quad([(p3_x[0], top_flap_y[0], z), (p3_x[1], top_flap_y[0], z), (p3_x[1], top_flap_y[1], z), (p3_x[0], top_flap_y[1], z)], COLOR_FLAP, "P3 Top Flap") | |
| # Panel 4 (W) + Flaps | |
| add_quad([(p4_x[0], main_y[0], z), (p4_x[1], main_y[0], z), (p4_x[1], main_y[1], z), (p4_x[0], main_y[1], z)], COLOR_PANEL_W, "Panel 4 (W)") | |
| add_quad([(p4_x[0], bot_flap_y[0], z), (p4_x[1], bot_flap_y[0], z), (p4_x[1], bot_flap_y[1], z), (p4_x[0], bot_flap_y[1], z)], COLOR_FLAP, "P4 Bot Flap") | |
| add_quad([(p4_x[0], top_flap_y[0], z), (p4_x[1], top_flap_y[0], z), (p4_x[1], top_flap_y[1], z), (p4_x[0], top_flap_y[1], z)], COLOR_FLAP, "P4 Top Flap") | |
| # Dimension labels | |
| fig_3d.add_trace(go.Scatter3d( | |
| x=[total_net_w / 2], y=[total_net_h + 20], z=[5], | |
| mode='text', text=[f"Sheet: {fmt_val(total_net_w)} × {fmt_val(total_net_h)} {unit_label}"], | |
| textfont=dict(size=12, color='#1976D2'), showlegend=False | |
| )) | |
| fig_3d.add_trace(go.Scatter3d( | |
| x=[(p1_x[0] + p1_x[1]) / 2], y=[flap_h + H_net / 2], z=[5], | |
| mode='text', text=[f"L={fmt_val(L_net)}"], | |
| textfont=dict(size=11, color='#333'), showlegend=False | |
| )) | |
| fig_3d.add_trace(go.Scatter3d( | |
| x=[(p2_x[0] + p2_x[1]) / 2], y=[flap_h + H_net / 2], z=[5], | |
| mode='text', text=[f"W={fmt_val(W_net)}"], | |
| textfont=dict(size=11, color='#333'), showlegend=False | |
| )) | |
| fig_3d.add_trace(go.Scatter3d( | |
| x=[(p2_x[0] + p2_x[1]) / 2], y=[flap_h / 2], z=[5], | |
| mode='text', text=[f"Flap={fmt_val(flap_h)}"], | |
| textfont=dict(size=10, color='#666'), showlegend=False | |
| )) | |
| # Center the view on the net | |
| center_x = total_net_w / 2 | |
| center_y = total_net_h / 2 | |
| fig_3d.update_layout( | |
| title=f"3D Flat Net ({fmt_val(total_net_w)} × {fmt_val(total_net_h)} {unit_label})", | |
| scene=dict( | |
| xaxis=dict(title=f'X ({unit_label})', range=[-total_net_w*0.1, total_net_w * 1.15]), | |
| yaxis=dict(title=f'Y ({unit_label})', range=[-total_net_h*0.1, total_net_h * 1.2]), | |
| zaxis=dict(title='Z', range=[0, 30], showticklabels=False), | |
| aspectmode='auto', # Auto fit to container | |
| camera=dict( | |
| eye=dict(x=0.0, y=0.0, z=2.5), # Looking straight down, auto mode handles zoom | |
| center=dict(x=0, y=0, z=0), | |
| up=dict(x=0, y=1, z=0) | |
| ) | |
| ), | |
| margin=dict(l=0, r=0, t=40, b=0), | |
| height=500 | |
| ) | |
| else: | |
| # === FOLDED BOX VIEW === | |
| # Show assembled RSC box | |
| # Coordinate system: X=Width, Y=Height, Z=Length (depth) | |
| # Box dimensions | |
| W = W_net # Width (X) | |
| H = H_net # Height (Y) | |
| L = L_net # Length/Depth (Z) | |
| # Panel 2 - Front face (Z=0) | |
| add_quad([(0, 0, 0), (W, 0, 0), (W, H, 0), (0, H, 0)], COLOR_PANEL_W, "Front (P2)") | |
| # Panel 4 - Back face (Z=L) | |
| add_quad([(0, 0, L), (W, 0, L), (W, H, L), (0, H, L)], COLOR_PANEL_W, "Back (P4)") | |
| # Panel 1 - Left face (X=0) | |
| add_quad([(0, 0, 0), (0, 0, L), (0, H, L), (0, H, 0)], COLOR_PANEL_L, "Left (P1)") | |
| # Panel 3 - Right face (X=W) | |
| add_quad([(W, 0, 0), (W, 0, L), (W, H, L), (W, H, 0)], COLOR_PANEL_L, "Right (P3)") | |
| # Bottom flaps (folded in, Y=0) | |
| # Minor flaps (from P2 and P4) - partial overlap | |
| add_quad([(0, 0, 0), (W, 0, 0), (W, 0, flap_h), (0, 0, flap_h)], COLOR_FLAP, "P2 Bot Flap") | |
| add_quad([(0, 0, L-flap_h), (W, 0, L-flap_h), (W, 0, L), (0, 0, L)], COLOR_FLAP, "P4 Bot Flap") | |
| # Major flaps (from P1 and P3) - cover the opening | |
| add_quad([(0, 0, flap_h), (0, 0, L-flap_h), (flap_h, 0, L-flap_h), (flap_h, 0, flap_h)], COLOR_FLAP, "P1 Bot Flap") | |
| add_quad([(W-flap_h, 0, flap_h), (W-flap_h, 0, L-flap_h), (W, 0, L-flap_h), (W, 0, flap_h)], COLOR_FLAP, "P3 Bot Flap") | |
| # Top flaps (folded in, Y=H) | |
| add_quad([(0, H, 0), (W, H, 0), (W, H, flap_h), (0, H, flap_h)], COLOR_FLAP, "P2 Top Flap") | |
| add_quad([(0, H, L-flap_h), (W, H, L-flap_h), (W, H, L), (0, H, L)], COLOR_FLAP, "P4 Top Flap") | |
| add_quad([(0, H, flap_h), (0, H, L-flap_h), (flap_h, H, L-flap_h), (flap_h, H, flap_h)], COLOR_FLAP, "P1 Top Flap") | |
| add_quad([(W-flap_h, H, flap_h), (W-flap_h, H, L-flap_h), (W, H, L-flap_h), (W, H, flap_h)], COLOR_FLAP, "P3 Top Flap") | |
| # Stitch tab (attached to back of P1, slightly offset) | |
| add_quad([(-2, 0, L-2), (-2, 0, L), (-2, H, L), (-2, H, L-2)], COLOR_GLUE_FLAP, "Glue Flap") | |
| # Dimension labels | |
| fig_3d.add_trace(go.Scatter3d( | |
| x=[W/2], y=[-30], z=[L/2], | |
| mode='text', text=[f"Box: {fmt_val(L)}×{fmt_val(W)}×{fmt_val(H)} {unit_label}"], | |
| textfont=dict(size=12, color='#333'), showlegend=False | |
| )) | |
| # Width label | |
| fig_3d.add_trace(go.Scatter3d( | |
| x=[W/2], y=[H/2], z=[-15], | |
| mode='text', text=[f"W={fmt_val(W)}"], | |
| textfont=dict(size=11, color='#333'), showlegend=False | |
| )) | |
| # Height label | |
| fig_3d.add_trace(go.Scatter3d( | |
| x=[-20], y=[H/2], z=[L/2], | |
| mode='text', text=[f"H={fmt_val(H)}"], | |
| textfont=dict(size=11, color='#333'), showlegend=False | |
| )) | |
| # Length label | |
| fig_3d.add_trace(go.Scatter3d( | |
| x=[W+15], y=[H/2], z=[L/2], | |
| mode='text', text=[f"L={fmt_val(L)}"], | |
| textfont=dict(size=11, color='#333'), showlegend=False | |
| )) | |
| max_dim = max(W, H, L) * 1.5 | |
| fig_3d.update_layout( | |
| title=f"3D Folded Box ({fmt_val(L)}×{fmt_val(W)}×{fmt_val(H)} {unit_label})", | |
| scene=dict( | |
| xaxis=dict(title=f'Width ({unit_label})', range=[-50, max_dim]), | |
| yaxis=dict(title=f'Height ({unit_label})', range=[-50, max_dim]), | |
| zaxis=dict(title=f'Length ({unit_label})', range=[-20, max_dim]), | |
| aspectmode='data', | |
| camera=dict(eye=dict(x=1.5, y=1.2, z=1.5), up=dict(x=0, y=1, z=0)) | |
| ), | |
| margin=dict(l=0, r=0, t=40, b=0), | |
| height=500 | |
| ) | |
| with vis_col1: st.plotly_chart(fig_2d, width="stretch") | |
| with vis_col2: st.plotly_chart(fig_3d, width="stretch") | |
| # --- TAB 4: FINANCIALS --- | |
| with tabs[3]: | |
| st.markdown("### 💰 Financial Analysis & Costing") | |
| # order_qty is now global in sidebar | |
| cost_res = CorruLabEngine.calculate_granular_cost(CARTON_SPEC, FACTORY_CONFIG, wastage_df, order_qty) | |
| # === SECTION 1: KEY METRICS DASHBOARD === | |
| st.markdown("#### 📊 Order Summary") | |
| # Use tiered price for financial summary | |
| _tiered_price = cost_res.get('tiered_price', cost_res['price']) | |
| _tier_adj = cost_res.get('tier_adjustment_pct', 0) | |
| _tier_name = cost_res.get('tier_name', 'Standard') | |
| total_revenue = _tiered_price * order_qty | |
| total_cost = cost_res['factory_cost'] * order_qty | |
| total_profit = total_revenue - total_cost | |
| profit_margin_pct = (total_profit / total_revenue) * 100 if total_revenue > 0 else 0 | |
| metric_cols = st.columns(5) | |
| with metric_cols[0]: | |
| st.metric("Order Quantity", f"{order_qty:,} boxes", | |
| help="Number of boxes in this order (set in sidebar)") | |
| with metric_cols[1]: | |
| _tier_label = f" ({_tier_name})" if _tier_adj != 0 else "" | |
| st.metric("Price / Box", f"Rs {_tiered_price:.2f}", | |
| delta=f"{_tier_adj:+.0f}%{_tier_label}" if _tier_adj != 0 else None, | |
| delta_color="normal" if _tier_adj < 0 else "inverse", | |
| help=f"Base Rs {cost_res['price']:.2f} + tier adjustment") | |
| with metric_cols[2]: | |
| st.metric("Total Revenue", f"Rs {total_revenue:,.0f}", | |
| help=f"Price × Quantity = Rs {_tiered_price:.2f} × {order_qty:,}") | |
| with metric_cols[3]: | |
| st.metric("Total Profit", f"Rs {total_profit:,.0f}", f"{profit_margin_pct:.1f}%", | |
| help=f"Revenue - Cost = Rs {total_revenue:,.0f} - Rs {total_cost:,.0f}") | |
| with metric_cols[4]: | |
| st.metric("Cost / Box", f"Rs {cost_res['factory_cost']:.2f}", | |
| help="Sum of: Paper + Waste + Conversion + Value-Add + Setup") | |
| # F2: Quantity breaks table | |
| from calculations import QUANTITY_TIERS | |
| with st.expander("📊 Quantity Tier Pricing", expanded=False): | |
| tier_data = [] | |
| for low, high, adj, name in QUANTITY_TIERS: | |
| hi_str = f"{high:,.0f}" if high != float('inf') else "+" | |
| qty_range = f"{low:,} - {hi_str}" if high != float('inf') else f"{low:,}+" | |
| adj_price = cost_res['price'] * (1 + adj / 100.0) | |
| is_current = (name == _tier_name) | |
| tier_data.append({ | |
| "Tier": name, | |
| "Qty Range": qty_range, | |
| "Adjustment": f"{adj:+.0f}%", | |
| "Price/Box": f"Rs {adj_price:.2f}", | |
| "Current": "**>>>**" if is_current else "" | |
| }) | |
| st.table(pd.DataFrame(tier_data)) | |
| st.divider() | |
| # === FORMULA REFERENCE === | |
| with st.expander("📐 How Each Cost is Calculated (Click to Expand)", expanded=False): | |
| st.markdown(f""" | |
| | Cost Component | Formula | Your Values | | |
| |----------------|---------|-------------| | |
| | **Paper (Net)** | GSM × Takeup × Area × Rate ÷ 1000 | Area = {sheet_area_m2*1e6:,.0f} mm² | | |
| | **Waste** | Paper × (Process% + Trim%) | Process = {FACTORY_CONFIG.wastage_process_pct}% | | |
| | **Conversion** | Weight × Rate/kg | {cost_res['weight_per_box']*1000:.0f}g × Rs {FACTORY_CONFIG.cost_conversion_per_kg}/kg | | |
| | **Setup** | Fixed Cost ÷ Order Qty | Rs {FACTORY_CONFIG.cost_fixed_setup:,.0f} ÷ {order_qty} = Rs {cost_res['setup_cost']:.2f} | | |
| | **Margin** | Factory Cost × {FACTORY_CONFIG.margin_pct}% | Rs {cost_res['factory_cost']:.2f} × {FACTORY_CONFIG.margin_pct}% | | |
| **Value-Add Costs** (only if enabled): | |
| - Printing: Rs {FACTORY_CONFIG.cost_printing_per_1000}/1000 + Plate cost/qty | |
| - UV: Rs {FACTORY_CONFIG.cost_uv_per_1000}/1000 | |
| - Lamination: Rs {FACTORY_CONFIG.cost_lamination_per_1000}/1000 | |
| - Die-cutting: Rs {FACTORY_CONFIG.cost_die_cutting_per_1000}/1000 + Frame cost/qty | |
| """) | |
| st.divider() | |
| # === SECTION 2: COST BREAKDOWN === | |
| fin_col1, fin_col2 = st.columns([1, 1]) | |
| with fin_col1: | |
| st.markdown("#### 📈 Cost Breakdown (Per Box)") | |
| cost_data = [ | |
| {"Component": "Paper (Net)", "Cost": cost_res['paper_cost_net'], "Category": "Material"}, | |
| {"Component": "Conversion Cost", "Cost": cost_res.get('conversion_cost', 0), "Category": "Processing"}, | |
| {"Component": "Waste", "Cost": cost_res['trim_and_process_waste_cost'], "Category": "Waste"}, | |
| {"Component": "Printing", "Cost": cost_res['print_cost'], "Category": "Value Add"}, | |
| {"Component": "UV/Lam/Die", "Cost": cost_res['uv_cost'] + cost_res['lam_cost'] + cost_res['die_cost'], "Category": "Value Add"}, | |
| {"Component": "Corrugation/Lam/Gluer", "Cost": cost_res['paper_corrugation_cost'] + cost_res['flute_lamination_cost'] + cost_res['folder_gluer_cost'], "Category": "Processing"}, | |
| {"Component": "Setup", "Cost": cost_res['setup_cost'], "Category": "Fixed"}, | |
| ] | |
| df_cost = pd.DataFrame(cost_data) | |
| # Filter out zero-cost items | |
| df_cost = df_cost[df_cost['Cost'] > 0.001] | |
| # Pie Chart | |
| fig_pie = px.pie(df_cost, values='Cost', names='Component', | |
| title='Cost Distribution', | |
| color='Category', | |
| color_discrete_map={ | |
| "Material": "#2E86AB", "Waste": "#E94F37", | |
| "Processing": "#F39237", "Value Add": "#96E6A1", "Fixed": "#DDA0DD" | |
| }) | |
| fig_pie.update_traces(textposition='inside', textinfo='percent+label') | |
| st.plotly_chart(fig_pie, width="stretch") | |
| with fin_col2: | |
| st.markdown("#### 📉 Cost Waterfall") | |
| # Waterfall chart - only non-zero items | |
| waterfall_data = [ | |
| {"x": "Paper", "y": cost_res['paper_cost_net'], "type": "relative"}, | |
| ] | |
| # Add conversion cost if present | |
| conv_cost = cost_res.get('conversion_cost', 0) | |
| if conv_cost > 0: | |
| waterfall_data.append({"x": "Conversion", "y": conv_cost, "type": "relative"}) | |
| waterfall_data.append({"x": "Waste", "y": cost_res['trim_and_process_waste_cost'], "type": "relative"}) | |
| # Add value-add only if enabled | |
| value_add_cost = cost_res['print_cost'] + cost_res['uv_cost'] + cost_res['lam_cost'] + cost_res['die_cost'] | |
| if value_add_cost > 0: | |
| waterfall_data.append({"x": "Value Add", "y": value_add_cost, "type": "relative"}) | |
| processing_cost = cost_res['paper_corrugation_cost'] + cost_res['flute_lamination_cost'] + cost_res['folder_gluer_cost'] | |
| if processing_cost > 0: | |
| waterfall_data.append({"x": "Processing", "y": processing_cost, "type": "relative"}) | |
| waterfall_data.extend([ | |
| {"x": "Setup", "y": cost_res['setup_cost'], "type": "relative"}, | |
| {"x": "Factory Cost", "y": cost_res['factory_cost'], "type": "total"}, | |
| {"x": f"+Margin ({FACTORY_CONFIG.margin_pct}%)", "y": cost_res['price'] - cost_res['factory_cost'], "type": "relative"}, | |
| {"x": "Price", "y": cost_res['price'], "type": "total"}, | |
| ]) | |
| fig_waterfall = go.Figure(go.Waterfall( | |
| x=[d['x'] for d in waterfall_data], | |
| y=[d['y'] for d in waterfall_data], | |
| measure=[d['type'] for d in waterfall_data], | |
| text=[f"Rs {d['y']:.2f}" for d in waterfall_data], | |
| textposition="outside", | |
| connector=dict(line=dict(color="rgb(63, 63, 63)")), | |
| increasing=dict(marker=dict(color="#E94F37")), | |
| decreasing=dict(marker=dict(color="#2E86AB")), | |
| totals=dict(marker=dict(color="#45B7D1")) | |
| )) | |
| fig_waterfall.update_layout(title="Cost Build-up to Price", showlegend=False, height=400) | |
| st.plotly_chart(fig_waterfall, width="stretch") | |
| st.divider() | |
| # === SECTION 3: DETAILED COST TABLE === | |
| st.markdown("#### 📋 Cost Summary") | |
| detail_col1, detail_col2 = st.columns([2, 1]) | |
| with detail_col1: | |
| # Add per-box and order-total columns | |
| df_cost['Per Box (Rs)'] = df_cost['Cost'] | |
| df_cost['Order Total (Rs)'] = df_cost['Cost'] * order_qty | |
| df_cost['% of Cost'] = (df_cost['Cost'] / cost_res['factory_cost'] * 100) | |
| st.dataframe( | |
| df_cost[['Component', 'Category', 'Per Box (Rs)', 'Order Total (Rs)', '% of Cost']].style.format({ | |
| "Per Box (Rs)": "{:.2f}", | |
| "Order Total (Rs)": "{:,.0f}", | |
| "% of Cost": "{:.1f}%" | |
| }).background_gradient(subset=['% of Cost'], cmap='Reds'), | |
| width="stretch", hide_index=True | |
| ) | |
| with detail_col2: | |
| st.markdown("##### 💵 Profitability Summary") | |
| st.markdown(f""" | |
| | Metric | Per Box | Order Total | | |
| |--------|---------|-------------| | |
| | **Revenue** | Rs {_tiered_price:.2f} | Rs {total_revenue:,.0f} | | |
| | **Cost** | Rs {cost_res['factory_cost']:.2f} | Rs {total_cost:,.0f} | | |
| | **Profit** | Rs {_tiered_price - cost_res['factory_cost']:.2f} | Rs {total_profit:,.0f} | | |
| | **Margin** | {profit_margin_pct:.1f}% | {profit_margin_pct:.1f}% | | |
| """) | |
| st.divider() | |
| # === SECTION 4: QUOTATION SUMMARY === | |
| st.markdown("#### 📝 Quotation Summary") | |
| quote_col1, quote_col2 = st.columns([1, 1]) | |
| with quote_col1: | |
| st.markdown(f""" | |
| **📦 Product:** {ply_type} {box_style} Box | |
| **📏 Dimensions:** {l_mm} × {w_mm} × {h_mm} mm | |
| **⚖️ Weight:** {cost_res['weight_per_box']*1000:.0f} g per box | |
| **🔢 Quantity:** {order_qty:,} boxes | |
| """) | |
| with quote_col2: | |
| st.markdown(f""" | |
| <div style='background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| padding: 20px; border-radius: 10px; color: white; text-align: center;'> | |
| <h3 style='margin:0; color: white;'>QUOTED PRICE</h3> | |
| <h1 style='margin: 10px 0; color: white;'>Rs {_tiered_price:.2f} / box</h1> | |
| <p style='margin:0; opacity: 0.9;'>Order Total: Rs {total_revenue:,.0f}</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # --- TAB 5: REPORTS --- | |
| with tabs[4]: | |
| st.markdown("### 📄 Job Card & Quote") | |
| st.markdown(f""" | |
| <div style='border:1px solid #ccc; padding:20px; background:white;'> | |
| <h2>JOB CARD: {quote_ref}</h2> | |
| <p><b>Client:</b> {cust_name} | <b>Date:</b> {datetime.now().strftime('%d-%b-%Y')}</p> | |
| <hr> | |
| <h4>1. Production Specs</h4> | |
| <ul> | |
| <li><b>Size:</b> {l_mm} x {w_mm} x {h_mm} mm</li> | |
| <li><b>Board:</b> {ply_type}</li> | |
| <li><b>Net Weight (Finished Boxes):</b> {cost_res['weight_per_box']*order_qty/1000.0:.2f} Tons ({order_qty} Boxes)</li> | |
| <li><b>Score Lines:</b> Glue Flap({CARTON_SPEC.stitch_allowance_mm:.1f}) | {l_mm} | {w_mm} | {l_mm} | {w_mm}</li> | |
| </ul> | |
| <h4>2. Material List & Deckles</h4> | |
| <table width='100%' border='1' cellspacing='0' cellpadding='5'> | |
| <tr><th>Layer</th><th>Grade</th><th>GSM</th><th>Deckle (mm)</th><th>Burst (kPa)</th><th>RCT-CD (kgf)</th></tr> | |
| {''.join([f"<tr><td>{l.layer_type}</td><td>{l.paper.code}</td><td>{l.paper.gsm}</td><td>{l.deckle_mm}</td><td>{l.paper.bursting_strength_kpa}</td><td>{l.paper.rct_cd_kgf:.1f}</td></tr>" for l in CARTON_SPEC.layers])} | |
| </table> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # F1: PDF Export | |
| def _generate_pdf(): | |
| from fpdf import FPDF | |
| pdf = FPDF() | |
| pdf.add_page() | |
| pdf.set_font("Helvetica", "B", 16) | |
| pdf.cell(0, 10, f"JOB CARD: {quote_ref}", new_x="LMARGIN", new_y="NEXT") | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.cell(0, 8, f"Client: {cust_name} | Date: {datetime.now().strftime('%d-%b-%Y')}", new_x="LMARGIN", new_y="NEXT") | |
| pdf.ln(5) | |
| pdf.set_font("Helvetica", "B", 12) | |
| pdf.cell(0, 8, "1. Production Specs", new_x="LMARGIN", new_y="NEXT") | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.cell(0, 7, f"Size: {l_mm} x {w_mm} x {h_mm} mm | Board: {ply_type} | Style: {box_style}", new_x="LMARGIN", new_y="NEXT") | |
| pdf.cell(0, 7, f"Net Weight: {cost_res['weight_per_box']*order_qty/1000.0:.2f} Tons ({order_qty} Boxes)", new_x="LMARGIN", new_y="NEXT") | |
| pdf.ln(5) | |
| pdf.set_font("Helvetica", "B", 12) | |
| pdf.cell(0, 8, "2. Material List", new_x="LMARGIN", new_y="NEXT") | |
| pdf.set_font("Helvetica", "B", 9) | |
| col_widths = [30, 25, 20, 30, 30, 30] | |
| headers = ["Layer", "Grade", "GSM", "Deckle (mm)", "Burst (kPa)", "RCT-CD (kgf)"] | |
| for w, h in zip(col_widths, headers): | |
| pdf.cell(w, 7, h, border=1) | |
| pdf.ln() | |
| pdf.set_font("Helvetica", "", 9) | |
| for l in CARTON_SPEC.layers: | |
| vals = [l.layer_type, l.paper.code, str(l.paper.gsm), str(l.deckle_mm), | |
| str(l.paper.bursting_strength_kpa), f"{l.paper.rct_cd_kgf:.1f}"] | |
| for w, v in zip(col_widths, vals): | |
| pdf.cell(w, 7, v, border=1) | |
| pdf.ln() | |
| pdf.ln(5) | |
| pdf.set_font("Helvetica", "B", 12) | |
| pdf.cell(0, 8, "3. Costing Summary", new_x="LMARGIN", new_y="NEXT") | |
| pdf.set_font("Helvetica", "", 10) | |
| pdf.cell(0, 7, f"Factory Cost: Rs {cost_res['factory_cost']:.2f}/box", new_x="LMARGIN", new_y="NEXT") | |
| pdf.cell(0, 7, f"Price: Rs {_tiered_price:.2f}/box ({_tier_name})", new_x="LMARGIN", new_y="NEXT") | |
| pdf.cell(0, 7, f"Order Total: Rs {total_revenue:,.0f}", new_x="LMARGIN", new_y="NEXT") | |
| return bytes(pdf.output()) | |
| _pdf_c1, _pdf_c2 = st.columns(2) | |
| with _pdf_c1: | |
| st.download_button( | |
| "Download Job Card PDF", | |
| data=_generate_pdf(), | |
| file_name=f"JobCard_{quote_ref}_{datetime.now().strftime('%Y%m%d')}.pdf", | |
| mime="application/pdf" | |
| ) | |
| # F3: Data Backup | |
| import io, zipfile | |
| def _generate_backup_zip(): | |
| buf = io.BytesIO() | |
| data_dir = BASE_DIR / "data" | |
| with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: | |
| for fname in ["paper_db.json", "factory_settings.json", "box_profiles.json", "sheet_sizes.json"]: | |
| fpath = data_dir / fname | |
| if fpath.exists(): | |
| zf.write(fpath, fname) | |
| buf.seek(0) | |
| return buf.getvalue() | |
| with _pdf_c2: | |
| st.download_button( | |
| "Download Data Backup (ZIP)", | |
| data=_generate_backup_zip(), | |
| file_name=f"NRP_backup_{datetime.now().strftime('%Y%m%d')}.zip", | |
| mime="application/zip" | |
| ) | |
| st.markdown("### 📦 Material Requisition (Store Demand)") | |
| # Enhanced Per-Layer Breakdown | |
| st.markdown("#### Layer-by-Layer Calculation") | |
| layer_breakdown_data = [] | |
| # Sheet area - full rectangle (stitch cut-out is waste, not subtracted) | |
| flap_h_calc = min(l_mm, w_mm) / 2.0 | |
| sheet_area_calc = ((2*l_mm + 2*w_mm + CARTON_SPEC.stitch_allowance_mm) * (w_mm + h_mm)) / 1_000_000.0 | |
| for i, layer in enumerate(CARTON_SPEC.layers): | |
| takeup = layer.flute_profile.factor if layer.flute_profile else 1.0 | |
| weight_net_per_box = (layer.paper.gsm * takeup * sheet_area_calc / 1000.0) | |
| weight_net_total = weight_net_per_box * order_qty | |
| row = wastage_df.loc[wastage_df['layer_idx'] == i].iloc[0] | |
| # Process waste | |
| w_after_process = weight_net_total * (1 + FACTORY_CONFIG.wastage_process_pct/100.0) | |
| # Trim waste | |
| if row.get('is_sheet', False): | |
| # SHEET LOGIC: Use trim_pct from wastage_df | |
| trim_pct_val = row['trim_pct'] / 100.0 | |
| if trim_pct_val < 1: | |
| w_gross = w_after_process / (1 - trim_pct_val) | |
| else: | |
| w_gross = w_after_process | |
| deckle_util_pct = (1 - trim_pct_val) * 100 | |
| elif row['deckle_mm'] > 0 and row['ups'] > 0: | |
| # REEL LOGIC | |
| deckle_util = (row['ups'] * (w_mm + h_mm)) / row['deckle_mm'] | |
| deckle_util_pct = deckle_util * 100 | |
| w_gross = w_after_process / deckle_util if deckle_util > 0 else w_after_process | |
| else: | |
| deckle_util_pct = 100.0 | |
| w_gross = w_after_process | |
| layer_breakdown_data.append({ | |
| "Layer": f"{layer.layer_type} ({layer.paper.code})", | |
| "GSM": layer.paper.gsm, | |
| "Takeup": takeup, | |
| "Net (kg)": weight_net_total, | |
| f"+Process ({FACTORY_CONFIG.wastage_process_pct}%)": w_after_process - weight_net_total, | |
| "Deckle Util (%)": deckle_util_pct, | |
| "+Trim (kg)": w_gross - w_after_process, | |
| "Gross (kg)": w_gross | |
| }) | |
| df_layers = pd.DataFrame(layer_breakdown_data) | |
| st.dataframe( | |
| df_layers.style.format({ | |
| "Net (kg)": "{:.1f}", | |
| f"+Process ({FACTORY_CONFIG.wastage_process_pct}%)": "{:+.1f}", | |
| "Deckle Util (%)": "{:.1f}%", | |
| "+Trim (kg)": "{:+.1f}", | |
| "Gross (kg)": "{:.1f}", | |
| "Takeup": "{:.2f}" | |
| }), | |
| width="stretch" | |
| ) | |
| # Summary row | |
| total_net = df_layers["Net (kg)"].sum() | |
| total_process = df_layers[f"+Process ({FACTORY_CONFIG.wastage_process_pct}%)"].sum() | |
| total_trim = df_layers["+Trim (kg)"].sum() | |
| total_gross = df_layers["Gross (kg)"].sum() | |
| st.markdown(f""" | |
| **Summary:** | |
| | Net Weight | + Process Waste | + Trim Waste | = Gross Requirement | | |
| |------------|-----------------|--------------|---------------------| | |
| | {total_net:,.0f} kg | +{total_process:,.0f} kg | +{total_trim:,.0f} kg | **{total_gross:,.0f} kg** | | |
| """) | |
| st.info(f""" | |
| **📐 Formula Applied:** | |
| `Gross = (Net × 1.{int(FACTORY_CONFIG.wastage_process_pct):02d}) ÷ Deckle_Utilization` | |
| Where Deckle_Utilization = (Ups × SheetWidth) ÷ ReelSize | |
| """) | |
| st.divider() | |
| # F4: Profile Comparison | |
| st.markdown("### 🔍 Profile Comparison") | |
| _all_profiles = load_box_profiles() | |
| if len(_all_profiles) >= 2: | |
| _profile_names = [p.get('name', f"Profile {i}") for i, p in enumerate(_all_profiles)] | |
| _cmp_c1, _cmp_c2 = st.columns(2) | |
| with _cmp_c1: | |
| _cmp_a_idx = st.selectbox("Profile A", range(len(_profile_names)), | |
| format_func=lambda x: _profile_names[x], | |
| key="cmp_profile_a") | |
| with _cmp_c2: | |
| _cmp_b_idx = st.selectbox("Profile B", range(len(_profile_names)), | |
| format_func=lambda x: _profile_names[x], | |
| index=min(1, len(_profile_names)-1), | |
| key="cmp_profile_b") | |
| _pa = _all_profiles[_cmp_a_idx] | |
| _pb = _all_profiles[_cmp_b_idx] | |
| _cmp_data = { | |
| "Property": ["Name", "Ply Type", "Dimensions (L×W×H)", "Layers", "Processes"], | |
| "Profile A": [ | |
| _pa.get('name', ''), | |
| _pa.get('ply_type', ''), | |
| f"{_pa.get('dimensions', {}).get('length_mm', '')} × {_pa.get('dimensions', {}).get('width_mm', '')} × {_pa.get('dimensions', {}).get('height_mm', '')} mm", | |
| ', '.join([f"{l.get('paper_code', '')}-{l.get('gsm', '')}" for l in _pa.get('layers', [])]), | |
| ', '.join([k for k, v in _pa.get('processes', {}).items() if v]) | |
| ], | |
| "Profile B": [ | |
| _pb.get('name', ''), | |
| _pb.get('ply_type', ''), | |
| f"{_pb.get('dimensions', {}).get('length_mm', '')} × {_pb.get('dimensions', {}).get('width_mm', '')} × {_pb.get('dimensions', {}).get('height_mm', '')} mm", | |
| ', '.join([f"{l.get('paper_code', '')}-{l.get('gsm', '')}" for l in _pb.get('layers', [])]), | |
| ', '.join([k for k, v in _pb.get('processes', {}).items() if v]) | |
| ] | |
| } | |
| st.table(pd.DataFrame(_cmp_data)) | |
| else: | |
| st.caption("Save at least 2 profiles to enable comparison.") |