import streamlit as st import json import math import matplotlib.pyplot as plt import matplotlib.patches as patches import io import csv import os from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image as RLImage, Table, TableStyle, PageBreak from reportlab.lib.styles import getSampleStyleSheet from reportlab.lib import colors from reportlab.lib.pagesizes import letter # ========================================== # CONFIGURAÇÃO GERAL # ========================================== st.set_page_config(page_title="SGP - Gestão de Embalagem v21.0", layout="wide", page_icon="🏭") FITAS = [ {"nome": "Fita 1.20", "tamanho": 1.20}, {"nome": "Fita 1.40", "tamanho": 1.40}, {"nome": "Fita 1.50", "tamanho": 1.50}, {"nome": "Fita 1.60", "tamanho": 1.60}, {"nome": "Fita 1.70", "tamanho": 1.70}, {"nome": "Fita 1.80", "tamanho": 1.80}, {"nome": "Fita 1.85", "tamanho": 1.85}, {"nome": "Fita 1.90", "tamanho": 1.90}, {"nome": "Fita 2.50", "tamanho": 2.50}, {"nome": "Fita 3.00", "tamanho": 3.00}, {"nome": "Fita 3.50", "tamanho": 3.50}, {"nome": "Fita 4.00", "tamanho": 4.00} ] MADEIRAS_PADRAO = [370, 400, 500] DB_FILE = 'catalogo.json' DENSIDADE_ACO = 7.85e-6 @st.cache_data def load_db(): if not os.path.exists(DB_FILE): return {"IT13": {}, "IT14": {}} try: with open(DB_FILE, 'r') as f: return json.load(f) except: return {"IT13": {}, "IT14": {}} def save_db(new_db): with open(DB_FILE, 'w') as f: json.dump(new_db, f, indent=4) st.cache_data.clear() DB = load_db() # ========================================== # LÓGICA DE CÁLCULO # ========================================== class PackageCalculator: def _parse_layout(self, layout_str): try: return [int(x) for x in layout_str.split('-') if x.strip().isdigit()] except: return [] def _escolher_fita(self, perimetro_m): p_margem = perimetro_m * 1.10 for f in FITAS: if f["tamanho"] >= p_margem: return f["nome"], f["tamanho"] return "Sob Medida", round(p_margem, 2) def _escolher_madeira(self, w): for m in MADEIRAS_PADRAO: if m >= w: return m return MADEIRAS_PADRAO[-1] def _buscar_no_banco(self, tipo, l, a, e): target_db = DB["IT13"] if tipo == "Redondo" else DB["IT14"] melhor_vizinho = None menor_distancia = float('inf') for key, item in target_db.items(): try: parts = [float(x) for x in key.split('_')] if tipo == "Redondo": db_l, db_e = parts[0], parts[1] if math.isclose(l, db_l, abs_tol=0.1) and math.isclose(e, db_e, abs_tol=0.05): return item, None if math.isclose(l, db_l, abs_tol=0.1): dist = abs(e - db_e) if dist < menor_distancia: menor_distancia, melhor_vizinho = dist, item else: db_l, db_a, db_e = parts[0], parts[1], parts[2] match_n = math.isclose(l, db_l, abs_tol=0.1) and math.isclose(a, db_a, abs_tol=0.1) match_i = math.isclose(l, db_a, abs_tol=0.1) and math.isclose(a, db_l, abs_tol=0.1) if (match_n or match_i) and math.isclose(e, db_e, abs_tol=0.05): return item, None if (match_n or match_i): dist = abs(e - db_e) if dist < menor_distancia: menor_distancia, melhor_vizinho = dist, item except: continue return None, melhor_vizinho def simulate_wood_scenarios(self, l, a, e, c, tipo, peso_unit): scenarios = {} for madeira_size in MADEIRAS_PADRAO: if tipo == "Redondo": base = int(madeira_size / l) else: base = int(madeira_size / l) if base < 1: scenarios[madeira_size] = None continue qtd_alvo = int(1000 / peso_unit) if peso_unit > 0 else 1 if tipo == "Redondo": layout = [] curr = 0 while curr < qtd_alvo: take = base if len(layout)%2==0 else base-1 if (qtd_alvo - curr) < take: take = qtd_alvo - curr layout.append(take); curr += take pkg = self._build_pkg(l, a, e, c, tipo, 0, curr, curr*peso_unit, f"Simulação {madeira_size}", True, layout, 0, 0, False, True, 6.60) pkg['madeira_real'] = madeira_size else: ga = math.ceil(qtd_alvo / base) num = base * ga pkg = self._build_pkg(l, a, e, c, tipo, 0, num, num*peso_unit, f"Simulação {madeira_size}", False, None, base, ga, False, True, 6.60) pkg['madeira_real'] = madeira_size scenarios[madeira_size] = pkg return scenarios def calculate(self, modo, meta, l, a, e, c, tipo, acabamento, aba=0): l, a, e = float(l), float(a), float(e) is_galv = (acabamento == "Galvanizado (GI/GA)") wood_h = 6.60 if is_galv else 0.0 item_exato, item_vizinho = self._buscar_no_banco(tipo, l, a, e) if tipo == "Redondo": area = math.pi * ((l/2)**2 - (l/2-e)**2) elif tipo == "Enrijecido": area = (l + 2*a + 2*aba - 4*e) * e else: area = (l + 2*a - 2*e) * e peso_unit = area * c * DENSIDADE_ACO # --- CENÁRIO 1: NORMA --- pkg_norma = None item_ref = item_exato or item_vizinho if item_ref: origem_ref = "Catálogo (Exato)" if item_exato else "Catálogo (Vizinho)" num_ref = item_ref['qtde'] if item_ref.get('sextavado'): layout_n = self._parse_layout(item_ref['padrao']) pkg_norma = self._build_pkg(l, a, e, c, tipo, aba, num_ref, num_ref*peso_unit, origem_ref, True, layout_n, 0, 0, False, is_galv, wood_h) elif tipo == "Redondo": gl, ga = item_ref.get('ml', 0), item_ref.get('ma', 0) pkg_norma = self._build_pkg(l, a, e, c, tipo, aba, num_ref, num_ref*peso_unit, origem_ref, False, None, gl, ga, False, is_galv, wood_h) else: gl, ga = item_ref.get('ml', 0), item_ref.get('ma', 0) pkg_norma = self._build_pkg(l, a, e, c, tipo, aba, num_ref, num_ref*peso_unit, origem_ref, False, None, gl, ga, False, is_galv, wood_h) # --- CENÁRIO 2: IDEAL --- base_ideal = 0 override_base = False target_pecas = pkg_norma['num'] if pkg_norma else int(1000/peso_unit) if target_pecas == 0: target_pecas = 1 if is_galv and tipo != "Redondo": if e <= 1.25: base_ideal, override_base = 10, True elif 1.55 <= e <= 1.95: base_ideal, override_base = 8, True if is_galv and not override_base: base_ideal = int(500 / l) elif not override_base: if pkg_norma and pkg_norma['gl'] > 0: base_ideal = pkg_norma['gl'] else: base_ideal = int(math.sqrt(target_pecas)) if base_ideal < 1: base_ideal = 1 if tipo == "Redondo": layout_ideal, curr = [], 0 while curr < target_pecas: take = base_ideal if len(layout_ideal)%2==0 else base_ideal-1 if (target_pecas - curr) < take: take = target_pecas - curr layout_ideal.append(take); curr += take pkg_ideal = self._build_pkg(l, a, e, c, tipo, aba, curr, curr*peso_unit, "Ideal (Físico)", True, layout_ideal, 0, 0, False, is_galv, wood_h) else: gl_ideal = base_ideal ga_ideal = math.ceil(target_pecas / gl_ideal) num_ideal = gl_ideal * ga_ideal pkg_ideal = self._build_pkg(l, a, e, c, tipo, aba, num_ideal, num_ideal*peso_unit, "Ideal (Físico)", False, None, gl_ideal, ga_ideal, False, is_galv, wood_h) # --- SIMULAÇÕES EXTRAS (Se Galv) --- simulations = {} if is_galv: simulations = self.simulate_wood_scenarios(l, a, e, c, tipo, peso_unit) # Totais main_ref = pkg_ideal if modo == "Toneladas": total_pcs = int((meta*1000)/peso_unit) if peso_unit>0 else 0 else: total_pcs = int(meta) qtd_pcts = total_pcs // main_ref['num'] if main_ref['num'] > 0 else 0 sobra = total_pcs % main_ref['num'] if main_ref['num'] > 0 else 0 sobra_pkg = None if sobra > 0: if pkg_ideal['sext']: rem, s_lay = sobra, [] b = pkg_ideal['layout'][0] if pkg_ideal['layout'] else 5 while rem > 0: tk = min(rem, b if len(s_lay)%2==0 else b-1) s_lay.append(tk); rem -= tk sobra_pkg = self._build_pkg(l, a, e, c, tipo, aba, sobra, sobra*peso_unit, "Sobra", True, s_lay, 0, 0, True, is_galv, wood_h) else: s_ga = math.ceil(sobra/pkg_ideal['gl']) sobra_pkg = self._build_pkg(l, a, e, c, tipo, aba, sobra, sobra*peso_unit, "Sobra", False, None, pkg_ideal['gl'], s_ga, True, is_galv, wood_h) return { "norma": pkg_norma, "ideal": pkg_ideal, "sobra": sobra_pkg, "simulations": simulations, "total_pcts": qtd_pcts, "peso_total": (qtd_pcts * main_ref['peso']) + (sobra_pkg['peso'] if sobra_pkg else 0) } def _build_pkg(self, l, a, e, c, tipo, aba, num, peso, orig, sext, layout, gl, ga, is_rem, is_galv, wood_h): info = {"l":l, "a":a, "e":e, "c":c, "tipo":tipo, "aba":aba, "num":num, "peso":peso, "origem":orig, "sext":sext, "layout":layout, "gl":gl, "ga":ga, "is_rem":is_rem, "is_galv":is_galv, "wood_h":wood_h, "wood_w": 48.20} if sext: r = layout or [] mc = max(r) if r else 1 info["larg_total"] = mc * l info["alt_total"] = l + (len(r)-1)*l*math.sin(math.radians(60)) if is_galv: cm = max(0, len(r)-1) info["alt_total"] += cm * wood_h info["qtd_madeiras"] = cm * 5 else: info["qtd_madeiras"] = 0 info["layout_str"] = "-".join(map(str, r)) if r else "" else: info["larg_total"] = gl*l + (aba if tipo=="Enrijecido" else 0) h_base = (ga * l) if tipo=="Redondo" else (ga * a) if is_galv: cm = max(0, ga-1) info["alt_total"] = h_base + cm*wood_h info["qtd_madeiras"] = cm * 5 else: info["alt_total"] = h_base info["qtd_madeiras"] = 0 info["layout_str"] = f"{gl} x {ga}" info["madeira_real"] = self._escolher_madeira(info["larg_total"]) if is_galv else 0 pt = (info["larg_total"] + info["alt_total"])*2/1000 info["fita_nome"], info["fita_tam"] = self._escolher_fita(pt) info["fita_meio_nome"], info["fita_meio_tam"] = "N/A", 0 if not is_galv: pm = (info["larg_total"] + info["alt_total"]/2)*2/1000 info["fita_meio_nome"], info["fita_meio_tam"] = self._escolher_fita(pm) info["desc_fitas"] = "4 Fitas + 1 Meio" else: info["desc_fitas"] = "5 Fitas Totais" return info # ========================================== # VISUALIZAÇÃO # ========================================== def plot_cross_section(info): fig, ax = plt.subplots(figsize=(6, 5)) l, a, e = info['l'], info['a'], info['e'] wood_h = info['wood_h'] is_galv = info['is_galv'] wood_len = info.get("madeira_real", 0) total_w = info['larg_total'] fc = 'lightblue' if not info['is_rem'] else 'lightcoral' ec = 'black' if info['sext']: rows = info['layout'] rad = l/2; dy = l*0.866 for i, c in enumerate(rows): x0 = (total_w - c*l)/2 y = rad + i*dy + i*wood_h if is_galv and i>0: wx = (total_w - wood_len)/2 ax.add_patch(patches.Rectangle((wx, y-rad-wood_h), wood_len, wood_h, fc='peru', ec='brown')) for k in range(c): cx = x0 + k*l + rad ax.add_patch(patches.Circle((cx, y), rad, fc=fc, ec=ec)) ax.add_patch(patches.Circle((cx, y), rad-e, fc='white', ec=ec, lw=0.5)) else: gl, ga = info['gl'], info['ga'] drawn = 0 rad = l/2; dy = l*0.866 for i in range(ga): if info['tipo']=="Redondo": y = i*(l+wood_h); cy = y + l/2 else: y = i*(a+wood_h); tube_y = y if is_galv and i>0: wy = y-wood_h wx = (total_w - wood_len)/2 ax.add_patch(patches.Rectangle((wx, wy), wood_len, wood_h, fc='peru', ec='brown')) for j in range(gl): if drawn >= info['num']: break x = j*l if info['tipo']=="Redondo": cx = x + rad ax.add_patch(patches.Circle((cx, cy), rad, fc=fc, ec=ec)) ax.add_patch(patches.Circle((cx, cy), rad-e, fc='white', ec=ec, lw=0.5)) else: ax.add_patch(patches.Rectangle((x, tube_y), l, a, fc=fc, ec=ec)) ax.add_patch(patches.Rectangle((x+e, tube_y+e), l-2*e, a-2*e, fc='white', ec=ec, lw=0.5)) drawn+=1 margin = max(total_w, info['alt_total']) * 0.15 ax.add_patch(patches.Rectangle((-2, -2), total_w+4, info['alt_total']+4, fill=False, ec='red', ls='--', lw=1)) if not info['sext']: ax.text(total_w/2, info['alt_total']+margin*0.2, f"BASE: {info['gl']}", ha='center', color='blue', weight='bold', fontsize=10) ax.text(-margin*0.2, info['alt_total']/2, f"ALT: {info['ga']}", ha='right', va='center', color='blue', weight='bold', rotation=90, fontsize=10) if is_galv and wood_len < total_w: ax.text(total_w/2, info['alt_total']/2, "MADEIRA CURTA!", ha='center', color='red', weight='bold', fontsize=14, rotation=45) ax.text(total_w/2, -margin/2, f"L: {total_w:.1f}mm", ha='center') ax.text(-margin/2, info['alt_total']/2, f"A: {info['alt_total']:.1f}mm", ha='center', rotation=90) if is_galv: ax.text(total_w/2, -margin, f"Madeira: {wood_len}mm", ha='center', color='brown', weight='bold') ax.set_xlim(-margin, total_w+margin); ax.set_ylim(-margin, info['alt_total']+margin) ax.set_aspect('equal'); ax.axis('off') plt.title(f"{info['origem']} | {info['num']} Pçs", fontsize=10) return fig def plot_side_view(info): fig, ax = plt.subplots(figsize=(8, 2)) c, h = info['c'], info['alt_total'] ax.add_patch(patches.Rectangle((0,0), c, h, fc='lightgray', ec='black')) if info['is_galv']: lays = info['ga'] if not info['sext'] else len(info['layout']) for i in range(1, lays): if info['sext']: y = i*(info['l']*0.866 + info['wood_h']) - info['wood_h'] else: y = i*(info['l']+info['wood_h']) - info['wood_h'] if info['tipo']=="Redondo" else i*(info['a']+info['wood_h']) - info['wood_h'] for p in [0.1, 0.3, 0.5, 0.7, 0.9]: ax.add_patch(patches.Rectangle((c*p-24, y), 48, info['wood_h'], fc='peru')) for idx, p in enumerate([0.1, 0.3, 0.5, 0.7, 0.9]): col = 'orange' if (not info['is_galv'] and idx==2) else 'purple' ls = '--' if col=='orange' else '-' to_h = h/2 if col=='orange' else h ax.plot([c*p, c*p], [0, to_h], color=col, lw=2, ls=ls) ax.set_xlim(-c*0.05, c*1.05); ax.set_ylim(0, h*1.2); ax.axis('off') plt.title("Vista Lateral", fontsize=8) return fig def fig_to_img(fig, w=400, h=300): buf = io.BytesIO() fig.savefig(buf, format='png', dpi=100, bbox_inches='tight') buf.seek(0); plt.close(fig) return RLImage(buf, width=w, height=h, kind='proportional') def generate_pdf(res): buffer = io.BytesIO() doc = SimpleDocTemplate(buffer, pagesize=letter) styles = getSampleStyleSheet() story = [] m = res['ideal'] story.append(Paragraph(f"Ficha Técnica: {m['tipo']} {m['l']}x{m['a']} #{m['e']}", styles['Title'])) story.append(Spacer(1, 12)) acab = "Galvanizado (Com Madeira)" if m['is_galv'] else "Preto (Normal)" fita = f"4x {m['fita_nome']} ({m['fita_tam']}m)" if m['fita_meio_tam'] > 0: fita += f" + 1x {m['fita_meio_nome']} ({m['fita_meio_tam']}m)" else: fita = f"5x {m['fita_nome']} ({m['fita_tam']}m)" wood = f"{m['qtd_madeiras']} pçs (Ref: {m.get('madeira_real', 'N/A')}mm)" if m['is_galv'] else "N/A" data = [ ["Item", f"{m['l']}x{m['a']} #{m['e']}mm"], ["Acabamento", acab], ["Pacotes", f"{res['total_pcts']} (Padrão) + {1 if res['sobra'] else 0} (Sobra)"], ["Peso Padrão", f"{m['peso']:.2f} kg"], ["Cinta", fita], ["Madeiras", wood] ] t = Table(data, colWidths=[150, 300]) t.setStyle(TableStyle([('GRID', (0,0), (-1,-1), 1, colors.black), ('BACKGROUND', (0,0), (0,-1), colors.lightgrey)])) story.append(t); story.append(Spacer(1, 20)) story.append(Paragraph("Visualização Pacote Ideal (Produção)", styles['Heading2'])) story.append(fig_to_img(plot_cross_section(m))) story.append(fig_to_img(plot_side_view(m), h=150)) # ADICIONADO: SIMULAÇÃO DE MADEIRAS NO PDF if m['is_galv'] and res.get('simulations'): story.append(PageBreak()) story.append(Paragraph("Simulação de Cenários de Madeira", styles['Heading2'])) sim_data = [] headers = [] imgs = [] for mad_size, scen in res['simulations'].items(): headers.append(f"Madeira {mad_size}mm") if scen: img = fig_to_img(plot_cross_section(scen), w=180, h=150) imgs.append(img) else: imgs.append(Paragraph("Não Compatível", styles['Normal'])) sim_data = [headers, imgs] t_sim = Table(sim_data, colWidths=[190, 190, 190]) t_sim.setStyle(TableStyle([('GRID', (0,0), (-1,-1), 1, colors.black), ('ALIGN', (0,0), (-1,-1), 'CENTER')])) story.append(t_sim) if res['norma']: story.append(PageBreak()) story.append(Paragraph("Comparativo: O que diz a Norma (Tabela)", styles['Heading2'])) story.append(fig_to_img(plot_cross_section(res['norma']))) if res['sobra']: story.append(PageBreak()) story.append(Paragraph(f"Pacote de Sobra ({res['sobra']['num']} pçs)", styles['Heading2'])) story.append(fig_to_img(plot_cross_section(res['sobra']))) doc.build(story) buffer.seek(0) return buffer def generate_csv(res): output = io.StringIO() writer = csv.writer(output) m = res['ideal'] writer.writerow(["Tipo", "L", "A", "E", "Acab", "Qtd Pcts", "Pecas/Pct", "Peso", "Fita", "Madeira"]) acab = "GI/GA" if m['is_galv'] else "Normal" writer.writerow([m['tipo'], m['l'], m['a'], m['e'], acab, res['total_pcts'], m['num'], m['peso'], m['fita_nome'], m.get('madeira_real', 0)]) return output.getvalue() # ========================================== # INTERFACE # ========================================== st.title("🏭 SGP - Gestão de Embalagem v21.0") with st.sidebar: st.header("Parâmetros") tipo = st.selectbox("Perfil", ["Quadrado/Retangular", "Redondo", "U/Enrijecido"]) acabamento = st.radio("Acabamento", ["Preto (Normal)", "Galvanizado (GI/GA)"]) c1, c2 = st.columns(2) l = c1.number_input("L/Diam", value=50.0) a = c2.number_input("Altura", value=50.0, disabled=(tipo=="Redondo")) c3, c4 = st.columns(2) e = c3.number_input("Espessura", value=2.00) c = c4.number_input("Comp", value=6000) aba = st.number_input("Aba (U)", value=0.0, disabled=(tipo!="U/Enrijecido")) meta = st.number_input("Meta", value=1000) modo = st.selectbox("Unidade", ["Peças", "Toneladas"]) btn = st.button("CALCULAR", type="primary") if btn: calc = PackageCalculator() t_map = "Enrijecido" if tipo == "U/Enrijecido" else tipo if tipo == "Quadrado/Retangular": t_map = "Quadrado/Retangular" res = calc.calculate(modo, meta, l, a, e, c, t_map, acabamento, aba) if res['norma']: st.info("ℹ️ Encontrado na Norma. A seguir, o cálculo Ideal (Físico).") # 2. Painel Principal (Recomendado) st.subheader("✅ Configuração Recomendada") m = res['ideal'] k1, k2, k3, k4 = st.columns(4) k1.metric("Pacotes", res['total_pcts']) k2.metric("Pçs/Pct", m['num']) k3.metric("Peso", f"{m['peso']:.0f}kg") k4.metric("Madeira", f"{m.get('madeira_real', 0)}mm" if m['is_galv'] else "N/A") c_main, c_side = st.columns([1,1]) with c_main: st.pyplot(plot_cross_section(m)) with c_side: st.pyplot(plot_side_view(m)) if res['sobra']: st.warning(f"Sobra: {res['sobra']['num']} pçs"); st.pyplot(plot_cross_section(res['sobra'])) # 3. SIMULADOR DE MADEIRAS (Mostra no site e agora no PDF) if m['is_galv']: st.markdown("---") st.subheader("🪵 Simulador por Tamanho de Madeira") cols = st.columns(3) sims = res['simulations'] for i, mad_size in enumerate(MADEIRAS_PADRAO): with cols[i]: st.markdown(f"### Madeira {mad_size}mm") scenario = sims.get(mad_size) if scenario: st.caption(f"{scenario['num']} Pçs | {scenario['peso']:.0f}kg | {scenario['gl']}x{scenario['ga']}") st.pyplot(plot_cross_section(scenario)) else: st.error("❌ Não cabe") # 4. Downloads st.markdown("---") c1, c2 = st.columns(2) c1.download_button("📄 PDF Relatório Completo", generate_pdf(res), "relatorio.pdf", "application/pdf") c2.download_button("📊 CSV Dados", generate_csv(res), "dados.csv", "text/csv")