GabrielGD commited on
Commit
73c59ce
·
verified ·
1 Parent(s): 373dd6e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +272 -336
app.py CHANGED
@@ -12,25 +12,13 @@ from reportlab.lib import colors
12
  from reportlab.lib.pagesizes import letter
13
 
14
  # ==========================================
15
- # 1. CONFIGURAÇÕES E CONSTANTES
16
  # ==========================================
17
- st.set_page_config(page_title="SGP - Gestão de Embalagem v13.1", layout="wide", page_icon="🏭")
18
 
19
- # Configurações de Negócio
20
  PESO_IDEAL_MIN = 970
21
  PESO_IDEAL_MAX = 1080
22
  PESO_LIMITE_SEGURANCA = 1200
23
- DENSIDADE_ACO = 7.85e-6
24
-
25
- # Tolerâncias e Margens
26
- TOLERANCIA_DIMENSAO = 0.1 # mm (para match exato)
27
- TOLERANCIA_BUSCA_GEO = 5.0 # mm (para busca por similaridade)
28
- MARGEM_FITA = 1.10 # 10% de margem para fita
29
-
30
- # Dimensões de Insumos
31
- MADEIRA_H = 6.60
32
- MADEIRA_W = 48.20
33
- MADEIRAS_PADRAO = [370, 400, 500]
34
 
35
  FITAS = [
36
  {"nome": "Fita 1.20", "tamanho": 1.20}, {"nome": "Fita 1.40", "tamanho": 1.40},
@@ -41,233 +29,196 @@ FITAS = [
41
  {"nome": "Fita 3.50", "tamanho": 3.50}, {"nome": "Fita 4.00", "tamanho": 4.00}
42
  ]
43
 
 
 
44
  DB_FILE = 'catalogo.json'
45
 
46
- # ==========================================
47
- # 2. GERENCIAMENTO DE DADOS
48
- # ==========================================
49
  @st.cache_data
50
- def load_db() -> dict:
51
- if not os.path.exists(DB_FILE):
52
- return {"IT13": {}, "IT14": {}}
53
- try:
54
- with open(DB_FILE, 'r') as f:
55
- return json.load(f)
56
- except json.JSONDecodeError:
57
- st.error("Erro: O arquivo catalogo.json está corrompido.")
58
- return {"IT13": {}, "IT14": {}}
59
- except Exception as e:
60
- st.error(f"Erro desconhecido ao carregar DB: {e}")
61
- return {"IT13": {}, "IT14": {}}
62
-
63
- def save_db(new_db: dict):
64
  try:
65
- with open(DB_FILE, 'w') as f:
66
- json.dump(new_db, f, indent=4)
67
- st.cache_data.clear()
68
- except Exception as e:
69
- st.error(f"Erro ao salvar DB: {e}")
 
70
 
71
  DB = load_db()
72
 
73
  # ==========================================
74
- # 3. MOTOR DE CÁLCULO (REFATORADO)
75
  # ==========================================
76
 
77
  class PackageCalculator:
78
- def _parse_layout(self, layout_str: str) -> list:
79
  try:
80
  return [int(x) for x in layout_str.split('-') if x.strip().isdigit()]
81
  except:
82
  return []
83
 
84
- def _validar_inputs(self, l: float, a: float, e: float, c: float, tipo: str) -> list:
85
- """Valida inputs e retorna lista de erros."""
86
- erros = []
87
- if l <= 0 or e <= 0 or c <= 0:
88
- erros.append("Dimensões devem ser positivas")
89
- if tipo != "Redondo" and a <= 0:
90
- erros.append("Altura deve ser positiva para perfis não-redondos")
91
- if tipo == "Redondo" and e >= l/2:
92
- erros.append("Espessura não pode ser maior ou igual ao raio")
93
- return erros
94
-
95
- def _get_physical_props(self, tipo: str, l: float, a: float, e: float, c: float, aba: float = 0) -> tuple:
96
- """Calcula Área da Seção e Peso Unitário de forma centralizada."""
97
- if tipo == "Redondo":
98
- area = math.pi * ((l/2)**2 - (l/2-e)**2)
99
- elif tipo == "Enrijecido":
100
- area = (l + 2*a + 2*aba - 4*e) * e
101
- else: # Quadrado/Retangular
102
- area = (l + 2*a - 2*e) * e
103
-
104
- peso_unitario = area * c * DENSIDADE_ACO
105
- return area, peso_unitario
106
-
107
- def _find_smart_neighbor(self, tipo: str, l: float, a: float, e: float):
108
- """Busca inteligente com tolerâncias definidas."""
109
  best_item = None
110
- min_diff_thickness = float('inf')
111
- min_dist_geo = float('inf')
112
  target_db = DB["IT13"] if tipo == "Redondo" else DB["IT14"]
113
 
114
  for key, item in target_db.items():
115
  try:
116
  parts = [float(x) for x in key.split('_')]
117
  if tipo == "Redondo":
118
- db_l, db_e = parts[0], parts[1]
119
- # Prioridade 1: Mesmo Diâmetro (Tolerância definida)
120
- if abs(l - db_l) < TOLERANCIA_DIMENSAO:
121
- diff_e = abs(e - db_e)
122
- if diff_e < min_diff_thickness:
123
- min_diff_thickness = diff_e
124
- best_item = item
125
- # Prioridade 2: Geometria próxima
126
- elif min_diff_thickness == float('inf'):
127
- dist = math.sqrt((l - db_l)**2 + (e - db_e)**2)
128
- if dist < min_dist_geo and dist < TOLERANCIA_BUSCA_GEO:
129
- min_dist_geo = dist
130
  best_item = item
131
  else:
132
- db_l, db_a, db_e = parts[0], parts[1], parts[2]
133
- is_same_dims = (abs(l - db_l) < TOLERANCIA_DIMENSAO and abs(a - db_a) < TOLERANCIA_DIMENSAO) or \
134
- (abs(l - db_a) < TOLERANCIA_DIMENSAO and abs(a - db_l) < TOLERANCIA_DIMENSAO)
135
- if is_same_dims:
136
- diff_e = abs(e - db_e)
137
- if diff_e < min_diff_thickness:
138
- min_diff_thickness = diff_e
139
- best_item = item
140
- elif min_diff_thickness == float('inf'):
141
- dist = math.sqrt((l - db_l)**2 + (a - db_a)**2)
142
- if dist < min_dist_geo and dist < (TOLERANCIA_BUSCA_GEO * 2): # Tolerância maior para retangulos
143
- min_dist_geo = dist
144
  best_item = item
145
- except:
146
- continue
147
  return best_item
148
 
149
- def _apply_giga_rules(self, is_galv: bool, tipo: str, e: float):
150
- if is_galv and tipo != "Redondo":
151
- if e <= 1.25:
152
- return 10, "Regra GI/GA (<=1.25mm)"
153
- elif 1.55 <= e <= 1.95:
154
- return 8, "Regra GI/GA (1.55-1.95mm)"
155
- return 0, ""
156
-
157
- def _resolve_layout(self, item, tipo, peso_unit):
158
- """Define a geometria do pacote."""
159
- layout = None
160
- is_sext = False
161
- gl, ga = 0, 0
162
- num_pecas = 0
163
- layout_str = ""
164
 
165
- if item:
166
- num_pecas = item['qtde']
167
-
168
- if item.get('sextavado') and 'padrao' in item:
169
- layout = self._parse_layout(item.get('padrao', ''))
170
- is_sext, gl, ga = True, 0, len(layout)
171
- layout_str = item.get('padrao', '')
172
-
173
- elif tipo == "Redondo":
174
- # Conversão Grade -> Ninho
175
- base_width = item.get('ml', 0)
176
- if base_width == 0: base_width = int(math.sqrt(num_pecas))
177
- layout = []
178
- rem = num_pecas
179
- curr_w = base_width
180
- while rem > 0:
181
- take = min(rem, curr_w)
182
- layout.append(take)
183
- rem -= take
184
- curr_w = base_width - 1 if curr_w == base_width else base_width
185
- is_sext, gl, ga = True, 0, len(layout)
186
- layout_str = "-".join(map(str, layout))
187
-
188
- else:
189
- is_sext, gl, ga = False, item.get('ml', 0), item.get('ma', 0)
190
-
191
- else:
192
- # Matemático
193
- peso_alvo = 1000
194
- num_pecas = int(peso_alvo / peso_unit) if peso_unit > 0 else 1
195
-
196
- if tipo == "Redondo":
197
- side = int(math.sqrt(num_pecas/3))
198
- base = side + 2
199
- layout = []
200
- curr = 0
201
- for i in range(50):
202
- cols = base if i%2==0 else base-1
203
- layout.append(cols); curr+=cols
204
- if curr>=num_pecas: num_pecas=curr; break
205
- is_sext, gl, ga = True, 0, len(layout)
206
- layout_str = "-".join(map(str, layout))
207
- else:
208
- is_sext, gl, ga = False, int(math.sqrt(num_pecas)), 0
209
- if gl > 0: ga = math.ceil(num_pecas/gl)
210
- else: gl, ga = 1, 1
211
-
212
- return num_pecas, layout, is_sext, gl, ga, layout_str
213
 
214
  def calculate(self, modo, meta, l, a, e, c, tipo, acabamento, aba=0):
215
- # 1. Validação
216
- if erros := self._validar_inputs(l, a, e, c, tipo):
217
- return {"erro": f"Entrada Inválida: {', '.join(erros)}"}
218
-
219
- # 2. Normalização e Chaves
220
  l, a, e = float(l), float(a), float(e)
221
  is_galv = (acabamento == "Galvanizado (GI/GA)")
222
- wood_h = MADEIRA_H if is_galv else 0.0
223
-
 
224
  key_red = f"{l:.2f}_{e:.2f}"
225
  key_met = f"{l:.2f}_{a:.2f}_{e:.2f}"
226
  key_met_inv = f"{a:.2f}_{l:.2f}_{e:.2f}"
227
-
228
  item = None
229
  if tipo == "Redondo":
230
  item = DB["IT13"].get(key_red)
231
  else:
232
  item = DB["IT14"].get(key_met) or DB["IT14"].get(key_met_inv)
233
 
234
- # 3. Propriedades Físicas
235
- area, peso_unit = self._get_physical_props(tipo, l, a, e, c, aba)
 
 
 
 
 
 
236
 
237
- # 4. Decisão de Estrutura (Fluxo Corrigido)
238
- forced_gl, rule_origem = self._apply_giga_rules(is_galv, tipo, e)
239
-
 
240
  origem = ""
241
-
242
- if forced_gl > 0:
243
- # Prioridade Máxima: Regra GI/GA
244
- origem = rule_origem
245
- gl = forced_gl
246
- target_weight = 1000
247
- total_pcs_pkg = int(target_weight / peso_unit) if peso_unit > 0 else 1
248
- ga = math.ceil(total_pcs_pkg / gl)
249
- num_pecas = gl * ga
250
- is_sext = False
251
- layout = None
252
- layout_str = ""
253
- else:
254
- # Fluxo Padrão
 
 
 
 
255
  if item:
256
  origem = "Catálogo (Norma)"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  else:
258
- # Tenta encontrar vizinho
259
- item = self._find_smart_neighbor(tipo, l, a, e)
260
- if item:
 
261
  origem = "Engenharia (Similar)"
 
 
 
 
 
 
 
 
 
262
  else:
263
- origem = "Engenharia (Cálculo Matemático)"
264
-
265
- # Resolve layout com o item encontrado (seja exato ou vizinho) ou None (para matemático)
266
- num_pecas, layout, is_sext, gl, ga, layout_str = self._resolve_layout(item, tipo, peso_unit)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
267
 
268
  peso_pct = num_pecas * peso_unit
269
 
270
- # 5. Totais
271
  if modo == "Toneladas":
272
  total_pcs = int((meta * 1000) / peso_unit) if peso_unit > 0 else 0
273
  else:
@@ -276,8 +227,7 @@ class PackageCalculator:
276
  qtd_pcts = total_pcs // num_pecas if num_pecas > 0 else 0
277
  sobra = total_pcs % num_pecas
278
 
279
- # 6. Construção dos Objetos
280
- main_pkg = self._build_pkg_obj(l, a, e, c, tipo, aba, num_pecas, peso_pct, origem, is_sext, layout, gl, ga, False, is_galv, wood_h, layout_str)
281
 
282
  sobra_pkg = None
283
  if sobra > 0:
@@ -288,17 +238,17 @@ class PackageCalculator:
288
  cols = base if k%2==0 else base-1
289
  take = min(rem, cols); s_layout.append(take); rem-=take
290
  if rem<=0: break
291
- sobra_pkg = self._build_pkg_obj(l, a, e, c, tipo, aba, sobra, sobra*peso_unit, "Sobra", True, s_layout, 0, len(s_layout), True, is_galv, wood_h, "Sobra")
292
  else:
293
  s_ga = math.ceil(sobra / gl) if gl>0 else 1
294
- sobra_pkg = self._build_pkg_obj(l, a, e, c, tipo, aba, sobra, sobra*peso_unit, "Sobra", False, None, gl, s_ga, True, is_galv, wood_h, "")
295
 
296
  return {
297
- "main": main_pkg, "sobra": sobra_pkg,
298
  "total_pcts": qtd_pcts, "peso_total": (qtd_pcts * peso_pct) + (sobra_pkg['peso'] if sobra_pkg else 0)
299
  }
300
 
301
- def _build_pkg_obj(self, l, a, e, c, tipo, aba, num, peso, orig, sext, layout, gl, ga, is_rem, is_galv, wood_h, layout_str):
302
  info = {
303
  "l": l, "a": a, "e": e, "c": c, "tipo": tipo, "aba": aba,
304
  "num": num, "peso": peso, "origem": orig,
@@ -307,66 +257,56 @@ class PackageCalculator:
307
  "layout_str": layout_str
308
  }
309
 
310
- # Dimensões e Madeiras
311
  if sext:
312
  rows = layout or []
313
  max_c = max(rows) if rows else 1
314
  info["larg_total"] = max_c * l
315
  h_geo = l + (len(rows) - 1) * l * math.sin(math.radians(60))
316
  if is_galv:
317
- camadas_mad = max(0, len(rows) - 1)
318
- info["alt_total"] = h_geo + (camadas_mad * wood_h)
319
- info["qtd_madeiras"] = camadas_mad * 5
320
  else:
321
- info["alt_total"] = h_geo
322
- info["qtd_madeiras"] = 0
323
  else:
 
324
  info["larg_total"] = gl * l
325
  if tipo == "Enrijecido": info["larg_total"] += aba
326
 
327
- h_base = ga * a
328
- # Correção Redondo em Grade (Encaixe)
329
- if tipo == "Redondo":
330
- h_base = l + (ga - 1) * l * math.sin(math.radians(60))
331
 
332
  if is_galv:
333
- camadas_mad = max(0, ga - 1)
334
- info["alt_total"] = h_base + (camadas_mad * wood_h)
335
- info["qtd_madeiras"] = camadas_mad * 5
336
  else:
337
- info["alt_total"] = h_base
338
- info["qtd_madeiras"] = 0
339
 
340
- # Escolha Madeira Real (apenas se galv)
341
  if is_galv:
342
- for m in MADEIRAS_PADRAO:
343
- if m >= info["larg_total"]:
344
- info["madeira_real"] = m
345
- break
346
- else:
347
- info["madeira_real"] = MADEIRAS_PADRAO[-1]
348
  else:
349
- info["madeira_real"] = 0
350
 
351
- # Fitas
352
- perim = (info["larg_total"] + info["alt_total"]) * 2 / 1000
353
- for f in FITAS:
354
- if f["tamanho"] >= perim * MARGEM_FITA:
355
- info["fita_nome"] = f["nome"]
356
- info["fita_tam"] = f["tamanho"]
357
- break
358
- else:
359
- info["fita_nome"], info["fita_tam"] = "Sob Medida", perim * MARGEM_FITA
360
 
361
- # Fita Meio
362
- info["fita_meio_nome"], info["fita_meio_tam"] = "N/A", 0
 
 
 
 
 
 
363
  if not is_galv:
364
- p_meio = (info["larg_total"] + (info["alt_total"]/2)) * 2 / 1000
365
- for f in FITAS:
366
- if f["tamanho"] >= p_meio * MARGEM_FITA:
367
- info["fita_meio_nome"] = f["nome"]
368
- info["fita_meio_tam"] = f["tamanho"]
369
- break
370
  info["desc_fitas"] = "4 Fitas Totais + 1 Fita Meio"
371
  else:
372
  info["desc_fitas"] = "5 Fitas Totais (Pacote Completo)"
@@ -374,21 +314,22 @@ class PackageCalculator:
374
  return info
375
 
376
  # ==========================================
377
- # 4. VISUALIZAÇÃO E GRÁFICOS
378
  # ==========================================
379
 
380
  def plot_cross_section(info):
381
  fig, ax = plt.subplots(figsize=(10, 8))
382
  l, a, e = info['l'], info['a'], info['e']
383
- wood_h = info['wood_h']
384
  is_galv = info['is_galv']
385
- wood_len = info.get("madeira_real", 0)
386
 
387
  fc = 'lightblue' if not info['is_rem'] else 'lightcoral'
388
  ec = 'black'
389
  total_w_pkg = info['larg_total']
390
 
391
  if info['sext']:
 
392
  rows = info['layout']
393
  radius = l / 2
394
  dy = l * 0.866
@@ -398,48 +339,50 @@ def plot_cross_section(info):
398
  y_base = radius + i * dy + (i * wood_h)
399
  if is_galv and i > 0:
400
  wy = y_base - radius - wood_h
401
- wood_x = (total_w_pkg - wood_len) / 2
402
- ax.add_patch(patches.Rectangle((wood_x, wy), wood_len, wood_h, fc='peru', ec='brown'))
403
  for k in range(count):
404
  cx = x0 + k * l + radius
405
  ax.add_patch(patches.Circle((cx, y_base), radius, fc=fc, ec=ec))
406
  ax.add_patch(patches.Circle((cx, y_base), radius-e, fc='white', ec=ec, lw=0.5))
407
  else:
 
408
  gl, ga = info['gl'], info['ga']
409
  drawn = 0
410
- radius = l / 2
411
- dy = l * 0.866
412
  for i in range(ga):
 
413
  if info['tipo'] == "Redondo":
414
- y = radius + i * dy + (i * wood_h)
 
 
415
  else:
416
  y = i * (a + wood_h)
417
- if is_galv and i > 0:
418
- wy = y - radius - wood_h if info['tipo'] == "Redondo" else y - wood_h
419
- wood_x = (total_w_pkg - wood_len) / 2
420
- ax.add_patch(patches.Rectangle((wood_x, wy), wood_len, wood_h, fc='peru', ec='brown'))
421
- tube_y = y + wood_h if info['tipo'] != "Redondo" else y
422
- else:
423
- tube_y = y
424
 
425
  for j in range(gl):
426
  if drawn >= info['num']: break
 
 
427
  if info['tipo'] == "Redondo":
428
- if i % 2 == 0: x = j * l
429
- else: x = j * l + radius
430
- cx = x + radius
431
- cy = y
432
- ax.add_patch(patches.Circle((cx, cy), radius, fc=fc, ec=ec))
433
- ax.add_patch(patches.Circle((cx, cy), radius-e, fc='white', ec=ec, lw=0.5))
434
  else:
435
- x = j * l
436
  if info['tipo'] == "Enrijecido":
437
- ax.add_patch(patches.Rectangle((x, tube_y), l, a, fc='lightcyan', ec=ec))
438
- ax.add_patch(patches.Rectangle((x+e, tube_y+a/2), info['aba'], e, fc='gray'))
439
  else:
440
- ax.add_patch(patches.Rectangle((x, tube_y), l, a, fc=fc, ec=ec))
441
  inset = min(l, a)*0.1
442
- ax.add_patch(patches.Rectangle((x+inset, tube_y+inset), l-2*inset, a-2*inset, fc='white', ec=ec, lw=0.5))
443
  drawn += 1
444
 
445
  L_tot, A_tot = info['larg_total'], info['alt_total']
@@ -449,13 +392,13 @@ def plot_cross_section(info):
449
  ax.add_patch(patches.Rectangle((-2, -2), L_tot+4, A_tot+4, fill=False, ec='red', ls='--', lw=1.5, label='Limite'))
450
 
451
  if not info['sext']:
452
- ax.text(L_tot/2, A_tot+margin*0.1, f"BASE: {info['gl']} PEÇAS", ha='center', va='bottom', color='blue', fontweight='bold', bbox=dict(facecolor='white', ec='blue', boxstyle='round,pad=0.2'))
453
- ax.text(-margin*0.1, A_tot/2, f"ALTURA: {info['ga']} CAMADAS", ha='right', va='center', color='blue', fontweight='bold', rotation=90, bbox=dict(facecolor='white', ec='blue', boxstyle='round,pad=0.2'))
454
  else:
455
  txt = info.get('layout_str') or f"Sextavado ({len(info['layout'])} camadas)"
456
- ax.text(L_tot/2, A_tot+margin*0.1, txt, ha='center', va='bottom', color='green', fontweight='bold', bbox=dict(facecolor='white', ec='green', boxstyle='round,pad=0.2'))
457
 
458
- ax.text(L_tot/2, -margin/2, f"L: {L_tot:.1f} mm", ha='center', va='center', fontweight='bold')
459
  ax.text(-margin/2, A_tot/2, f"A: {A_tot:.1f} mm", ha='center', va='center', fontweight='bold', rotation=90)
460
 
461
  if is_galv:
@@ -479,24 +422,29 @@ def plot_side_view(info):
479
 
480
  if is_galv:
481
  layers = info['ga'] if not info['sext'] else len(info['layout'])
482
- pos_wood = [0.1, 0.3, 0.5, 0.7, 0.9]
483
  for i in range(1, layers):
484
- if info['sext'] or info['tipo'] == "Redondo":
485
- rad = info['l']/2
486
- y_wood = (rad + i*info['l']*0.866 + i*wood_h) - rad - wood_h
 
487
  else:
488
- y_wood = i*(info['a'] + wood_h) - wood_h
489
-
490
- for p in pos_wood:
491
- ax.add_patch(patches.Rectangle((c*p - wood_w/2, y_wood), wood_w, wood_h, fc='peru', ec='brown'))
 
 
 
 
492
 
493
- pos_fita = [0.1, 0.3, 0.5, 0.7, 0.9]
494
- for idx, p in enumerate(pos_fita):
495
  x = c * p
496
  if not is_galv and idx == 2:
497
- ax.plot([x, x], [0, h/2], color='orange', lw=3, ls='--')
498
  else:
499
- ax.plot([x, x], [0, h], color='purple', lw=3)
500
 
501
  ax.text(c/2, -h*0.2, f"Comp: {c:.0f} mm", ha='center', va='top', fontweight='bold')
502
  ax.set_xlim(-c*0.05, c*1.05); ax.set_ylim(-h*0.3, h*1.1); ax.axis('off')
@@ -518,10 +466,10 @@ def generate_pdf(res):
518
  story.append(Paragraph(f"Ficha: {m['tipo']} {m['l']}x{m['a']} #{m['e']}", styles['Title']))
519
  story.append(Spacer(1, 12))
520
  acab = "Galvanizado (Com Madeira)" if m['is_galv'] else "Preto (Normal)"
521
- fita = f"4x {m['fita_nome']} ({m['fita_tam']}m)"
522
- if m['fita_meio_tam'] > 0: fita += f" + 1x {m['fita_meio_nome']} ({m['fita_meio_tam']}m)"
523
- else: fita = f"5x {m['fita_nome']} ({m['fita_tam']}m)"
524
- wood = f"{m['qtd_madeiras']} pçs (48.2x6.6mm) | Ref: {m.get('madeira_real', 'N/A')}mm" if m['is_galv'] else "N/A"
525
  data = [["Item", f"{m['l']}x{m['a']} #{m['e']}mm"], ["Acabamento", acab], ["Produção", f"{res['total_pcts']} pct + {1 if res['sobra'] else 0} sobra"], ["Peso Total", f"{res['peso_total']:.2f} kg"], ["Cintas", fita], ["Madeiras", wood]]
526
  t = Table(data, colWidths=[150, 300])
527
  t.setStyle(TableStyle([('GRID', (0,0), (-1,-1), 1, colors.black), ('BACKGROUND', (0,0), (0,-1), colors.lightgrey)]))
@@ -543,62 +491,51 @@ def generate_csv(res):
543
  m = res['main']
544
  writer.writerow(["Tipo", "L", "A", "E", "Comp", "Acabamento", "Qtd Pacotes", "Pecas/Pct", "Peso Total", "Fita Principal", "Fita Meio", "Qtd Madeira", "Tam Madeira"])
545
  acab = "GI/GA" if m['is_galv'] else "Normal"
546
- writer.writerow([m['tipo'], m['l'], m['a'], m['e'], m['c'], acab, res['total_pcts'], m['num'], f"{res['peso_total']:.2f}", m['fita_nome'], m['fita_meio_nome'], m['qtd_madeiras'], m.get('madeira_real', 0)])
547
  return output.getvalue()
548
 
549
- # ==========================================
550
- # INTERFACE
551
- # ==========================================
552
-
553
- aba_calc, aba_admin = st.tabs(["🏭 Calculadora", "⚙️ Admin"])
554
-
555
- with aba_calc:
556
- with st.sidebar:
557
- st.header("Parâmetros")
558
- tipo = st.selectbox("Perfil", ["Quadrado/Retangular", "Redondo", "U/Enrijecido"])
559
- acabamento = st.radio("Acabamento", ["Preto (Normal)", "Galvanizado (GI/GA)"])
560
- c1, c2 = st.columns(2)
561
- l = c1.number_input("L/Diam", value=50.0)
562
- a = c2.number_input("Altura", value=50.0, disabled=(tipo=="Redondo"))
563
- c3, c4 = st.columns(2)
564
- e = c3.number_input("Espessura", value=2.00)
565
- c = c4.number_input("Comp", value=6000)
566
- aba = st.number_input("Aba (U)", value=0.0, disabled=(tipo!="U/Enrijecido"))
567
- st.markdown("---")
568
- meta = st.number_input("Meta", value=1000)
569
- modo = st.selectbox("Unidade", ["Peças", "Toneladas"])
570
- btn = st.button("CALCULAR", type="primary")
571
-
572
- if btn:
573
- calc = PackageCalculator()
574
- t_map = "Enrijecido" if tipo == "U/Enrijecido" else tipo
575
- if tipo == "Quadrado/Retangular": t_map = "Quadrado/Retangular"
576
- res = calc.calculate(modo, meta, l, a, e, c, t_map, acabamento, aba)
577
-
578
- if "erro" in res:
579
- st.error(res["erro"])
580
- else:
581
- m = res['main']
582
- if "Engenharia" in m['origem']: st.warning(f"⚠️ {m['origem']}")
583
- else: st.success(f"✅ {m['origem']}")
584
-
585
- k1, k2, k3, k4 = st.columns(4)
586
- k1.metric("Pacotes", res['total_pcts'])
587
- k2.metric("Pçs/Pct", m['num'])
588
- k3.metric("Peso Total", f"{res['peso_total']:.0f}kg")
589
- k4.metric("Fita", f"{m['fita_tam']}m")
590
-
591
- t1, t2 = st.tabs(["Visual", "Downloads"])
592
- with t1:
593
- c_g, c_s = st.columns([1,1])
594
- with c_g: st.pyplot(plot_cross_section(m))
595
- with c_s: st.pyplot(plot_side_view(m))
596
- with t2:
597
- st.download_button("📄 PDF", generate_pdf(res), "relatorio.pdf", "application/pdf")
598
- st.download_button("📊 CSV", generate_csv(res), "dados.csv", "text/csv")
599
-
600
- with aba_admin:
601
- st.info("Edição de Catálogo JSON")
602
  col_a, col_b = st.columns(2)
603
  with col_a:
604
  adm_tipo = st.selectbox("Norma", ["IT14", "IT13"])
@@ -610,7 +547,6 @@ with aba_admin:
610
  adm_peso = st.number_input("Peso", value=0.0)
611
  adm_ml = st.number_input("Colunas", value=0)
612
  adm_ma = st.number_input("Linhas", value=0)
613
-
614
  if st.button("Salvar"):
615
  new = {"ml": int(adm_ml), "ma": int(adm_ma), "qtde": int(adm_qtde), "peso": float(adm_peso)}
616
  key = f"{adm_l:.2f}_{adm_e:.2f}" if adm_tipo == "IT13" else f"{adm_l:.2f}_{adm_a:.2f}_{adm_e:.2f}"
 
12
  from reportlab.lib.pagesizes import letter
13
 
14
  # ==========================================
15
+ # CONFIGURAÇÃO GERAL
16
  # ==========================================
17
+ st.set_page_config(page_title="SGP - Gestão de Embalagem v12.1", layout="wide", page_icon="🏭")
18
 
 
19
  PESO_IDEAL_MIN = 970
20
  PESO_IDEAL_MAX = 1080
21
  PESO_LIMITE_SEGURANCA = 1200
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  FITAS = [
24
  {"nome": "Fita 1.20", "tamanho": 1.20}, {"nome": "Fita 1.40", "tamanho": 1.40},
 
29
  {"nome": "Fita 3.50", "tamanho": 3.50}, {"nome": "Fita 4.00", "tamanho": 4.00}
30
  ]
31
 
32
+ MADEIRAS_PADRAO = [370, 400, 500, 600, 800, 1000]
33
+
34
  DB_FILE = 'catalogo.json'
35
 
 
 
 
36
  @st.cache_data
37
+ def load_db():
38
+ if not os.path.exists(DB_FILE): return {"IT13": {}, "IT14": {}}
 
 
 
 
 
 
 
 
 
 
 
 
39
  try:
40
+ with open(DB_FILE, 'r') as f: return json.load(f)
41
+ except: return {"IT13": {}, "IT14": {}}
42
+
43
+ def save_db(new_db):
44
+ with open(DB_FILE, 'w') as f: json.dump(new_db, f, indent=4)
45
+ st.cache_data.clear()
46
 
47
  DB = load_db()
48
 
49
  # ==========================================
50
+ # LÓGICA DE CÁLCULO
51
  # ==========================================
52
 
53
  class PackageCalculator:
54
+ def _parse_layout(self, layout_str):
55
  try:
56
  return [int(x) for x in layout_str.split('-') if x.strip().isdigit()]
57
  except:
58
  return []
59
 
60
+ def _find_smart_neighbor(self, tipo, l, a, e):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  best_item = None
62
+ min_diff = float('inf')
 
63
  target_db = DB["IT13"] if tipo == "Redondo" else DB["IT14"]
64
 
65
  for key, item in target_db.items():
66
  try:
67
  parts = [float(x) for x in key.split('_')]
68
  if tipo == "Redondo":
69
+ # Mesmo diametro, espessura diferente
70
+ if abs(l - parts[0]) < 0.1:
71
+ diff = abs(e - parts[1])
72
+ if diff < min_diff:
73
+ min_diff = diff
 
 
 
 
 
 
 
74
  best_item = item
75
  else:
76
+ # Mesma geometria
77
+ if (abs(l - parts[0]) < 0.1 and abs(a - parts[1]) < 0.1) or \
78
+ (abs(l - parts[1]) < 0.1 and abs(a - parts[0]) < 0.1):
79
+ diff = abs(e - parts[2])
80
+ if diff < min_diff:
81
+ min_diff = diff
 
 
 
 
 
 
82
  best_item = item
83
+ except: continue
 
84
  return best_item
85
 
86
+ def _escolher_fita(self, perimetro_m):
87
+ perimetro_com_margem = perimetro_m * 1.10
88
+ for f in FITAS:
89
+ if f["tamanho"] >= perimetro_com_margem:
90
+ return f["nome"], f["tamanho"], perimetro_com_margem
91
+ return "Sob Medida", perimetro_com_margem, perimetro_com_margem
 
 
 
 
 
 
 
 
 
92
 
93
+ def _escolher_madeira(self, largura_pacote_mm):
94
+ for m in MADEIRAS_PADRAO:
95
+ if m >= largura_pacote_mm: return m
96
+ return MADEIRAS_PADRAO[-1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  def calculate(self, modo, meta, l, a, e, c, tipo, acabamento, aba=0):
 
 
 
 
 
99
  l, a, e = float(l), float(a), float(e)
100
  is_galv = (acabamento == "Galvanizado (GI/GA)")
101
+ wood_h = 6.60 if is_galv else 0.0
102
+ wood_w = 48.20
103
+
104
  key_red = f"{l:.2f}_{e:.2f}"
105
  key_met = f"{l:.2f}_{a:.2f}_{e:.2f}"
106
  key_met_inv = f"{a:.2f}_{l:.2f}_{e:.2f}"
107
+
108
  item = None
109
  if tipo == "Redondo":
110
  item = DB["IT13"].get(key_red)
111
  else:
112
  item = DB["IT14"].get(key_met) or DB["IT14"].get(key_met_inv)
113
 
114
+ densidade = 7.85e-6
115
+ if tipo == "Redondo":
116
+ area = math.pi * ((l/2)**2 - (l/2-e)**2)
117
+ elif tipo == "Enrijecido":
118
+ area = (l + 2*a + 2*aba - 4*e) * e
119
+ else:
120
+ area = (l + 2*a - 2*e) * e
121
+ peso_unit = area * c * densidade
122
 
123
+ override_rule = False
124
+ gl, ga = 0, 0
125
+ is_sext = False
126
+ layout = None
127
  origem = ""
128
+ layout_str = ""
129
+
130
+ # Regra GI/GA
131
+ if is_galv and tipo != "Redondo":
132
+ forced_gl = 0
133
+ if e <= 1.25: forced_gl = 10; origem = "Regra GI/GA (<=1.25mm)"
134
+ elif 1.55 <= e <= 1.95: forced_gl = 8; origem = "Regra GI/GA (1.55-1.95mm)"
135
+
136
+ if forced_gl > 0:
137
+ override_rule = True
138
+ gl = forced_gl
139
+ target_weight = 1000
140
+ total_pcs_pkg = int(target_weight / peso_unit) if peso_unit > 0 else 1
141
+ ga = math.ceil(total_pcs_pkg / gl)
142
+ num_pecas = gl * ga
143
+ is_sext = False
144
+
145
+ if not override_rule:
146
  if item:
147
  origem = "Catálogo (Norma)"
148
+ num_pecas = item['qtde']
149
+
150
+ # --- CORREÇÃO CRÍTICA AQUI ---
151
+ # Verifica se é um Redondo que se comporta como GRADE (Retangular)
152
+ # Ex: 25.40 -> ml=15, ma=16, total=240. 15*16 = 240. Isso é grade.
153
+ is_grid_match = False
154
+ if item.get('ml') and item.get('ma'):
155
+ if item['ml'] * item['ma'] == item['qtde']:
156
+ is_grid_match = True
157
+
158
+ if item.get('sextavado') and 'padrao' in item:
159
+ # Sextavado Explicito
160
+ layout = self._parse_layout(item.get('padrao', ''))
161
+ is_sext, gl, ga = True, 0, len(layout)
162
+ layout_str = item.get('padrao', '')
163
+ elif tipo == "Redondo" and not is_grid_match:
164
+ # Redondo Sem Grade Definida -> Força Ninho
165
+ base_width = item.get('ml', 0)
166
+ if base_width == 0: base_width = int(math.sqrt(num_pecas))
167
+ layout = []
168
+ rem = num_pecas
169
+ curr_w = base_width
170
+ while rem > 0:
171
+ take = min(rem, curr_w)
172
+ layout.append(take)
173
+ rem -= take
174
+ curr_w = base_width - 1 if curr_w == base_width else base_width
175
+ is_sext, gl, ga = True, 0, len(layout)
176
+ layout_str = "-".join(map(str, layout))
177
+ else:
178
+ # É Metalon ou Redondo com Grade Definida (240 pçs = 15x16)
179
+ layout = None
180
+ is_sext = False
181
+ gl, ga = item.get('ml', 0), item.get('ma', 0)
182
+
183
  else:
184
+ # Fallback Engenharia
185
+ # Tenta vizinho primeiro
186
+ neighbor = self._find_smart_neighbor(tipo, l, a, e)
187
+ if neighbor:
188
  origem = "Engenharia (Similar)"
189
+ # Copia estrutura do vizinho
190
+ if neighbor.get('sextavado'):
191
+ layout = self._parse_layout(neighbor.get('padrao', ''))
192
+ is_sext, gl, ga = True, 0, len(layout)
193
+ num_pecas = sum(layout)
194
+ else:
195
+ is_sext = False
196
+ gl, ga = neighbor.get('ml', 0), neighbor.get('ma', 0)
197
+ num_pecas = gl * ga
198
  else:
199
+ # Calculo Puro
200
+ origem = "Engenharia (Cálculo)"
201
+ peso_alvo = 1000
202
+ num_pecas = int(peso_alvo / peso_unit) if peso_unit > 0 else 1
203
+
204
+ if tipo == "Redondo":
205
+ # Redondo calculado sempre sugere Ninho para segurança
206
+ side = int(math.sqrt(num_pecas/3))
207
+ base = side + 2
208
+ layout = []
209
+ curr = 0
210
+ for i in range(50):
211
+ cols = base if i%2==0 else base-1
212
+ layout.append(cols); curr+=cols
213
+ if curr>=num_pecas: num_pecas=curr; break
214
+ is_sext, gl, ga = True, 0, len(layout)
215
+ else:
216
+ is_sext = False
217
+ gl = int(math.sqrt(num_pecas))
218
+ ga = math.ceil(num_pecas/gl) if gl>0 else 1
219
 
220
  peso_pct = num_pecas * peso_unit
221
 
 
222
  if modo == "Toneladas":
223
  total_pcs = int((meta * 1000) / peso_unit) if peso_unit > 0 else 0
224
  else:
 
227
  qtd_pcts = total_pcs // num_pecas if num_pecas > 0 else 0
228
  sobra = total_pcs % num_pecas
229
 
230
+ main = self._build_pkg(l, a, e, c, tipo, aba, num_pecas, peso_pct, origem, is_sext, layout, gl, ga, False, is_galv, wood_h, wood_w, layout_str)
 
231
 
232
  sobra_pkg = None
233
  if sobra > 0:
 
238
  cols = base if k%2==0 else base-1
239
  take = min(rem, cols); s_layout.append(take); rem-=take
240
  if rem<=0: break
241
+ sobra_pkg = self._build_pkg(l, a, e, c, tipo, aba, sobra, sobra*peso_unit, "Sobra", True, s_layout, 0, len(s_layout), True, is_galv, wood_h, "Sobra")
242
  else:
243
  s_ga = math.ceil(sobra / gl) if gl>0 else 1
244
+ sobra_pkg = self._build_pkg(l, a, e, c, tipo, aba, sobra, sobra*peso_unit, "Sobra", False, None, gl, s_ga, True, is_galv, wood_h, "")
245
 
246
  return {
247
+ "main": main, "sobra": sobra_pkg,
248
  "total_pcts": qtd_pcts, "peso_total": (qtd_pcts * peso_pct) + (sobra_pkg['peso'] if sobra_pkg else 0)
249
  }
250
 
251
+ def _build_pkg(self, l, a, e, c, tipo, aba, num, peso, orig, sext, layout, gl, ga, is_rem, is_galv, wood_h, layout_str):
252
  info = {
253
  "l": l, "a": a, "e": e, "c": c, "tipo": tipo, "aba": aba,
254
  "num": num, "peso": peso, "origem": orig,
 
257
  "layout_str": layout_str
258
  }
259
 
260
+ qtd_madeiras = 0
261
  if sext:
262
  rows = layout or []
263
  max_c = max(rows) if rows else 1
264
  info["larg_total"] = max_c * l
265
  h_geo = l + (len(rows) - 1) * l * math.sin(math.radians(60))
266
  if is_galv:
267
+ camadas_madeira = max(0, len(rows) - 1)
268
+ h_wood = camadas_madeira * wood_h
269
+ qtd_madeiras = camadas_madeira * 5
270
  else:
271
+ h_wood = 0
272
+ info["alt_total"] = h_geo + h_wood
273
  else:
274
+ # Grade (Retangular)
275
  info["larg_total"] = gl * l
276
  if tipo == "Enrijecido": info["larg_total"] += aba
277
 
278
+ # Altura: Se for Redondo em Grade, ele fica um sobre o outro (altura = N * diam)
279
+ # Pois "Embalagem Retangular" sugere alinhamento
280
+ h_base = ga * a if tipo != "Redondo" else ga * l
 
281
 
282
  if is_galv:
283
+ camadas_madeira = max(0, ga - 1)
284
+ h_wood = camadas_madeira * wood_h
285
+ qtd_madeiras = camadas_madeira * 5
286
  else:
287
+ h_wood = 0
288
+ info["alt_total"] = h_base + h_wood
289
 
 
290
  if is_galv:
291
+ info["madeira_comprimento_real"] = self._escolher_madeira(info["larg_total"])
 
 
 
 
 
292
  else:
293
+ info["madeira_comprimento_real"] = 0
294
 
295
+ info["qtd_madeiras"] = qtd_madeiras
 
 
 
 
 
 
 
 
296
 
297
+ perim_total = (info["larg_total"] + info["alt_total"]) * 2 / 1000
298
+ nome_fita, tam_fita, real_fita = self._escolher_fita(perim_total)
299
+ info["fita_nome"] = nome_fita
300
+ info["fita_tamanho"] = tam_fita
301
+
302
+ info["fita_meio_nome"] = "N/A"
303
+ info["fita_meio_tamanho"] = 0
304
+
305
  if not is_galv:
306
+ perim_meio = (info["larg_total"] + (info["alt_total"] / 2)) * 2 / 1000
307
+ n_meio, t_meio, r_meio = self._escolher_fita(perim_meio)
308
+ info["fita_meio_nome"] = n_meio
309
+ info["fita_meio_tamanho"] = t_meio
 
 
310
  info["desc_fitas"] = "4 Fitas Totais + 1 Fita Meio"
311
  else:
312
  info["desc_fitas"] = "5 Fitas Totais (Pacote Completo)"
 
314
  return info
315
 
316
  # ==========================================
317
+ # VISUALIZAÇÃO
318
  # ==========================================
319
 
320
  def plot_cross_section(info):
321
  fig, ax = plt.subplots(figsize=(10, 8))
322
  l, a, e = info['l'], info['a'], info['e']
323
+ wood_h, wood_w = info['wood_h'], info['wood_w']
324
  is_galv = info['is_galv']
325
+ wood_len = info.get("madeira_comprimento_real", 0)
326
 
327
  fc = 'lightblue' if not info['is_rem'] else 'lightcoral'
328
  ec = 'black'
329
  total_w_pkg = info['larg_total']
330
 
331
  if info['sext']:
332
+ # SEXTAVADO
333
  rows = info['layout']
334
  radius = l / 2
335
  dy = l * 0.866
 
339
  y_base = radius + i * dy + (i * wood_h)
340
  if is_galv and i > 0:
341
  wy = y_base - radius - wood_h
342
+ wood_x_start = (total_w_pkg - wood_len) / 2
343
+ ax.add_patch(patches.Rectangle((wood_x_start, wy), wood_len, wood_h, fc='peru', ec='brown'))
344
  for k in range(count):
345
  cx = x0 + k * l + radius
346
  ax.add_patch(patches.Circle((cx, y_base), radius, fc=fc, ec=ec))
347
  ax.add_patch(patches.Circle((cx, y_base), radius-e, fc='white', ec=ec, lw=0.5))
348
  else:
349
+ # GRADE RETANGULAR (Corrigido para alinhar Redondo)
350
  gl, ga = info['gl'], info['ga']
351
  drawn = 0
352
+
 
353
  for i in range(ga):
354
+ # Cálculo de Y (Baseado na camada)
355
  if info['tipo'] == "Redondo":
356
+ y = i * (l + wood_h)
357
+ # Centro do círculo será y + raio
358
+ cy = y + (l/2)
359
  else:
360
  y = i * (a + wood_h)
361
+ cy = y # Metalon desenha do canto
362
+
363
+ # Madeira
364
+ if is_galv and i > 0:
365
+ wy = y - wood_h
366
+ wood_x_start = (total_w_pkg - wood_len) / 2
367
+ ax.add_patch(patches.Rectangle((wood_x_start, wy), wood_len, wood_h, fc='peru', ec='brown'))
368
 
369
  for j in range(gl):
370
  if drawn >= info['num']: break
371
+
372
+ x = j * l
373
  if info['tipo'] == "Redondo":
374
+ # Redondo em Grade -> Um sobre o outro (alinhado)
375
+ cx = x + (l/2)
376
+ ax.add_patch(patches.Circle((cx, cy), l/2, fc=fc, ec=ec))
377
+ ax.add_patch(patches.Circle((cx, cy), (l/2)-e, fc='white', ec=ec, lw=0.5))
 
 
378
  else:
 
379
  if info['tipo'] == "Enrijecido":
380
+ ax.add_patch(patches.Rectangle((x, y), l, a, fc='lightcyan', ec=ec))
381
+ ax.add_patch(patches.Rectangle((x+e, y+a/2), info['aba'], e, fc='gray'))
382
  else:
383
+ ax.add_patch(patches.Rectangle((x, y), l, a, fc=fc, ec=ec))
384
  inset = min(l, a)*0.1
385
+ ax.add_patch(patches.Rectangle((x+inset, y+inset), l-2*inset, a-2*inset, fc='white', ec=ec, lw=0.5))
386
  drawn += 1
387
 
388
  L_tot, A_tot = info['larg_total'], info['alt_total']
 
392
  ax.add_patch(patches.Rectangle((-2, -2), L_tot+4, A_tot+4, fill=False, ec='red', ls='--', lw=1.5, label='Limite'))
393
 
394
  if not info['sext']:
395
+ ax.text(L_tot/2, A_tot + margin*0.1, f"BASE: {info['gl']} PEÇAS", ha='center', va='bottom', fontweight='bold', color='blue', fontsize=12, bbox=dict(facecolor='white', edgecolor='blue', boxstyle='round,pad=0.3'))
396
+ ax.text(-margin*0.1, A_tot/2, f"ALTURA: {info['ga']} CAMADAS", ha='right', va='center', fontweight='bold', color='blue', fontsize=12, rotation=90, bbox=dict(facecolor='white', edgecolor='blue', boxstyle='round,pad=0.3'))
397
  else:
398
  txt = info.get('layout_str') or f"Sextavado ({len(info['layout'])} camadas)"
399
+ ax.text(L_tot/2, A_tot + margin*0.1, txt, ha='center', va='bottom', color='green', fontweight='bold', bbox=dict(facecolor='white', ec='green', boxstyle='round,pad=0.2'))
400
 
401
+ ax.text(L_tot/2, -margin/2, f"L Tubos: {L_tot:.1f} mm", ha='center', va='center', fontweight='bold')
402
  ax.text(-margin/2, A_tot/2, f"A: {A_tot:.1f} mm", ha='center', va='center', fontweight='bold', rotation=90)
403
 
404
  if is_galv:
 
422
 
423
  if is_galv:
424
  layers = info['ga'] if not info['sext'] else len(info['layout'])
425
+ positions_wood = [0.1, 0.3, 0.5, 0.7, 0.9]
426
  for i in range(1, layers):
427
+ if info['sext']:
428
+ radius = info['l'] / 2
429
+ dy = info['l'] * 0.866
430
+ y_wood = (radius + i * dy + (i * wood_h)) - radius - wood_h
431
  else:
432
+ # Grade
433
+ y_wood = i * (info['a'] + wood_h) - wood_h
434
+ if info['tipo'] == "Redondo": # Redondo grade tem altura D
435
+ y_wood = i * (info['l'] + wood_h) - wood_h
436
+
437
+ for p in positions_wood:
438
+ wx = (c * p) - (wood_w / 2)
439
+ ax.add_patch(patches.Rectangle((wx, y_wood), wood_w, wood_h, fc='peru', ec='brown'))
440
 
441
+ positions_fita = [0.1, 0.3, 0.5, 0.7, 0.9]
442
+ for idx, p in enumerate(positions_fita):
443
  x = c * p
444
  if not is_galv and idx == 2:
445
+ ax.plot([x, x], [0, h/2], color='orange', lw=3, ls='--')
446
  else:
447
+ ax.plot([x, x], [0, h], color='purple', lw=3)
448
 
449
  ax.text(c/2, -h*0.2, f"Comp: {c:.0f} mm", ha='center', va='top', fontweight='bold')
450
  ax.set_xlim(-c*0.05, c*1.05); ax.set_ylim(-h*0.3, h*1.1); ax.axis('off')
 
466
  story.append(Paragraph(f"Ficha: {m['tipo']} {m['l']}x{m['a']} #{m['e']}", styles['Title']))
467
  story.append(Spacer(1, 12))
468
  acab = "Galvanizado (Com Madeira)" if m['is_galv'] else "Preto (Normal)"
469
+ fita = f"4x {m['fita_nome']} ({m['fita_tamanho']}m)"
470
+ if m['fita_meio_tamanho'] > 0: fita += f" + 1x {m['fita_meio_nome']} ({m['fita_meio_tamanho']}m)"
471
+ else: fita = f"5x {m['fita_nome']} ({m['fita_tamanho']}m)"
472
+ wood = f"{m['qtd_madeiras']} pçs (48.2x6.6mm) | Ref: {m.get('madeira_comprimento_real', 'N/A')}mm" if m['is_galv'] else "N/A"
473
  data = [["Item", f"{m['l']}x{m['a']} #{m['e']}mm"], ["Acabamento", acab], ["Produção", f"{res['total_pcts']} pct + {1 if res['sobra'] else 0} sobra"], ["Peso Total", f"{res['peso_total']:.2f} kg"], ["Cintas", fita], ["Madeiras", wood]]
474
  t = Table(data, colWidths=[150, 300])
475
  t.setStyle(TableStyle([('GRID', (0,0), (-1,-1), 1, colors.black), ('BACKGROUND', (0,0), (0,-1), colors.lightgrey)]))
 
491
  m = res['main']
492
  writer.writerow(["Tipo", "L", "A", "E", "Comp", "Acabamento", "Qtd Pacotes", "Pecas/Pct", "Peso Total", "Fita Principal", "Fita Meio", "Qtd Madeira", "Tam Madeira"])
493
  acab = "GI/GA" if m['is_galv'] else "Normal"
494
+ writer.writerow([m['tipo'], m['l'], m['a'], m['e'], m['c'], acab, res['total_pcts'], m['num'], f"{res['peso_total']:.2f}", m['fita_nome'], m['fita_meio_nome'], m['qtd_madeiras'], m.get('madeira_comprimento_real', 0)])
495
  return output.getvalue()
496
 
497
+ st.title("🏭 Calculadora Formadoras v12.1")
498
+ with st.sidebar:
499
+ st.header("Parâmetros")
500
+ tipo = st.selectbox("Perfil", ["Quadrado/Retangular", "Redondo", "U/Enrijecido"])
501
+ acabamento = st.radio("Acabamento", ["Preto (Normal)", "Galvanizado (GI/GA)"])
502
+ c1, c2 = st.columns(2)
503
+ l = c1.number_input("L/Diam", value=50.0)
504
+ a = c2.number_input("Altura", value=50.0, disabled=(tipo=="Redondo"))
505
+ c3, c4 = st.columns(2)
506
+ e = c3.number_input("Espessura", value=2.00)
507
+ c = c4.number_input("Comp", value=6000)
508
+ aba = st.number_input("Aba (U)", value=0.0, disabled=(tipo!="U/Enrijecido"))
509
+ meta = st.number_input("Meta", value=1000)
510
+ modo = st.selectbox("Unidade", ["Peças", "Toneladas"])
511
+ btn = st.button("CALCULAR", type="primary")
512
+
513
+ if btn:
514
+ calc = PackageCalculator()
515
+ t_map = "Enrijecido" if tipo == "U/Enrijecido" else tipo
516
+ if tipo == "Quadrado/Retangular": t_map = "Quadrado/Retangular"
517
+ res = calc.calculate(modo, meta, l, a, e, c, t_map, acabamento, aba)
518
+ m = res['main']
519
+
520
+ if "Engenharia" in m['origem']: st.warning(f"⚠️ {m['origem']}")
521
+ else: st.success(f"✅ {m['origem']}")
522
+
523
+ k1, k2, k3, k4 = st.columns(4)
524
+ k1.metric("Pacotes", res['total_pcts'])
525
+ k2.metric("Pçs/Pct", m['num'])
526
+ k3.metric("Peso Total", f"{res['peso_total']:.0f}kg")
527
+ k4.metric("Fita", f"{m['fita_tamanho']}m")
528
+
529
+ t1, t2 = st.tabs(["Visual", "Downloads"])
530
+ with t1:
531
+ c_g, c_s = st.columns([1,1])
532
+ with c_g: st.pyplot(plot_cross_section(m))
533
+ with c_s: st.pyplot(plot_side_view(m))
534
+ with t2:
535
+ st.download_button("📄 PDF", generate_pdf(res), "relatorio.pdf", "application/pdf")
536
+ st.download_button("📊 CSV", generate_csv(res), "dados.csv", "text/csv")
537
+
538
+ with st.expander("Admin"):
 
 
 
 
 
 
 
 
 
 
 
539
  col_a, col_b = st.columns(2)
540
  with col_a:
541
  adm_tipo = st.selectbox("Norma", ["IT14", "IT13"])
 
547
  adm_peso = st.number_input("Peso", value=0.0)
548
  adm_ml = st.number_input("Colunas", value=0)
549
  adm_ma = st.number_input("Linhas", value=0)
 
550
  if st.button("Salvar"):
551
  new = {"ml": int(adm_ml), "ma": int(adm_ma), "qtde": int(adm_qtde), "peso": float(adm_peso)}
552
  key = f"{adm_l:.2f}_{adm_e:.2f}" if adm_tipo == "IT13" else f"{adm_l:.2f}_{adm_a:.2f}_{adm_e:.2f}"