Usman / src /streamlit_app.py
Waqasjan123's picture
Fix: profile load defaults dim_adjustment to 0mm instead of 10mm
69b4a32 verified
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
@st.cache_data(show_spinner="Loading data...")
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;">&#9632;</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 &nbsp;&#x2022;&nbsp; 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 &nbsp;&mdash;&nbsp; Materials &bull; Engineering &bull; Costing</div>
</div>
<div style="font-family:'JetBrains Mono',monospace; font-size:11px; color:#94a3b8; text-align:right; padding-bottom:4px;">
v3.0 &nbsp;|&nbsp; 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.")