67a0e674ca
Marine maintenance management: work orders with photos, ISM/SWP procedures, MSDS, inventory, RFQ/purchases, vessel history, bilingual PDF reports. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
283 lines
15 KiB
Python
283 lines
15 KiB
Python
"""swp_generator.py — PDF SWP con espaciado compacto y traducción Ollama"""
|
|
import os, json, urllib.request
|
|
from io import BytesIO
|
|
from reportlab.lib.pagesizes import letter
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.units import inch
|
|
from reportlab.lib.styles import ParagraphStyle
|
|
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
|
from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table,
|
|
TableStyle, HRFlowable, Image as RLImage, KeepTogether)
|
|
from reportlab.platypus import Flowable
|
|
from PIL import Image as PILImage
|
|
|
|
NAVY = colors.HexColor('#0a1628')
|
|
CYAN = colors.HexColor('#00b4d8')
|
|
WARN = colors.HexColor('#f4a261')
|
|
LIGHT = colors.HexColor('#eef2f7')
|
|
GRAY = colors.HexColor('#8a9bb0')
|
|
WHITE = colors.white
|
|
RED = colors.HexColor('#e63946')
|
|
GREEN = colors.HexColor('#2ec4b6')
|
|
ORANGE= colors.HexColor('#e76f51')
|
|
BLUE = colors.HexColor('#0077b6')
|
|
PURPLE= colors.HexColor('#7b2d8b')
|
|
|
|
CATEGORY_LABELS_ES = {
|
|
'electrical':'Trabajo Eléctrico','mechanical':'Mecánico / Motor',
|
|
'chemical':'Químicos / Pinturas','confined':'Espacio Confinado',
|
|
'height':'Trabajos en Altura','welding':'Soldadura / Calor',
|
|
'hull':'Casco / Buceo','other':'Otro',
|
|
}
|
|
CATEGORY_LABELS_EN = {
|
|
'electrical':'Electrical Work','mechanical':'Mechanical / Engine',
|
|
'chemical':'Chemicals / Paints','confined':'Confined Space',
|
|
'height':'Working at Height','welding':'Welding / Heat Work',
|
|
'hull':'Hull / Diving','other':'Other',
|
|
}
|
|
LABELS = {
|
|
'es': dict(title='PROCEDIMIENTO DE TRABAJO SEGURO',active='ACTIVO',
|
|
category='Categoría',approved_by='Aprobado por',status='Estado',
|
|
purpose='1. Propósito y Alcance',hazards='2. Riesgos Identificados',
|
|
ppe='3. EPP Requerido',tools='4. Herramientas y Materiales',
|
|
steps='5. Pasos del Procedimiento',emergency='6. Medidas de Emergencia',
|
|
refs='7. Referencias y Normativa',version_ctrl='Control de Versiones',
|
|
version='Versión',reason='Motivo del cambio',diff='Diferencias',
|
|
created_by='Creado por',effective='Vigente desde',
|
|
footer='Documento controlado — no válido si se imprime sin sello de aprobación',
|
|
purpose_lbl='Propósito',scope_lbl='Alcance'),
|
|
'en': dict(title='SAFE WORK PROCEDURE',active='ACTIVE',
|
|
category='Category',approved_by='Approved by',status='Status',
|
|
purpose='1. Purpose and Scope',hazards='2. Identified Hazards',
|
|
ppe='3. Required PPE',tools='4. Tools and Materials',
|
|
steps='5. Procedure Steps',emergency='6. Emergency Measures',
|
|
refs='7. References and Standards',version_ctrl='Version Control',
|
|
version='Version',reason='Reason for change',diff='Differences',
|
|
created_by='Created by',effective='Effective date',
|
|
footer='Controlled document — not valid if printed without approval stamp',
|
|
purpose_lbl='Purpose',scope_lbl='Scope'),
|
|
}
|
|
|
|
def mk(name, **kw):
|
|
d = dict(fontName='Helvetica',fontSize=9,textColor=NAVY,leading=11,spaceAfter=0)
|
|
d.update(kw); return ParagraphStyle(name, **d)
|
|
|
|
class SHdr(Flowable):
|
|
def __init__(self, text, color=NAVY):
|
|
Flowable.__init__(self); self.text=text; self._c=color; self.height=16
|
|
def wrap(self,aw,ah): self._w=aw; return aw,self.height
|
|
def draw(self):
|
|
self.canv.setFillColor(self._c)
|
|
self.canv.rect(0,0,self._w,self.height,fill=1,stroke=0)
|
|
self.canv.setFillColor(WHITE)
|
|
self.canv.setFont('Helvetica-Bold',8)
|
|
self.canv.drawString(8,4,self.text.upper())
|
|
|
|
def num_table(items, W):
|
|
if not items: return Paragraph('—', mk('b',textColor=GRAY))
|
|
rows=[[Paragraph(f'<b>{i+1}</b>',mk('n',fontSize=8,textColor=CYAN,alignment=TA_CENTER)),
|
|
Paragraph(str(item),mk('it',fontSize=8,leading=10))] for i,item in enumerate(items)]
|
|
t=Table(rows,colWidths=[0.28*inch,W-0.28*inch])
|
|
t.setStyle(TableStyle([
|
|
('VALIGN',(0,0),(-1,-1),'TOP'),
|
|
('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),2),
|
|
('LEFTPADDING',(0,0),(-1,-1),3),('RIGHTPADDING',(0,0),(-1,-1),3),
|
|
('ROWBACKGROUNDS',(0,0),(-1,-1),[WHITE,LIGHT]),
|
|
])); return t
|
|
|
|
def tag_table(items, W):
|
|
if not items: return Paragraph('—', mk('b',textColor=GRAY))
|
|
cells=[Paragraph(f'• {item}',mk('tg',fontSize=8,leading=10)) for item in items]
|
|
rows=[cells[i:i+3] for i in range(0,len(cells),3)]
|
|
while len(rows[-1])<3: rows[-1].append(Paragraph('',mk('tg')))
|
|
t=Table(rows,colWidths=[W/3]*3)
|
|
t.setStyle(TableStyle([
|
|
('BACKGROUND',(0,0),(-1,-1),LIGHT),
|
|
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
|
|
('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3),
|
|
('LEFTPADDING',(0,0),(-1,-1),6),('VALIGN',(0,0),(-1,-1),'MIDDLE'),
|
|
])); return t
|
|
|
|
def translate_text(text, target='en'):
|
|
if not text or not text.strip(): return text
|
|
try:
|
|
direction = 'Spanish to English' if target=='en' else 'English to Spanish'
|
|
prompt = (f"Translate from {direction}. Technical safety/marine terminology. "
|
|
f"Return ONLY the translated text:\n\n{text}")
|
|
payload = json.dumps({"model":"llama3.1:8b","prompt":prompt,
|
|
"stream":False,"options":{"temperature":0.1}}).encode('utf-8')
|
|
req = urllib.request.Request("http://localhost:11434/api/generate",
|
|
data=payload,headers={"Content-Type":"application/json"},method="POST")
|
|
with urllib.request.urlopen(req,timeout=25) as resp:
|
|
data = json.loads(resp.read().decode('utf-8'))
|
|
result = data.get('response',text).strip()
|
|
for p in ['Here is the translated text:','Translation:','Translated text:']:
|
|
if result.lower().startswith(p.lower()): result=result[len(p):].strip()
|
|
return result
|
|
except: return text
|
|
|
|
def translate_list(lst, target='en'):
|
|
return [translate_text(i,target) for i in lst]
|
|
|
|
def resize_image(path,mw,mh):
|
|
try:
|
|
with PILImage.open(path) as img: iw,ih=img.size
|
|
r=min(mw/iw,mh/ih); return iw*r,ih*r
|
|
except: return mw,mh
|
|
|
|
def section(hdr_flowable, content_flowable):
|
|
"""Compact section: header + 2pt gap + content + 5pt after"""
|
|
return [KeepTogether([hdr_flowable, Spacer(1,2), content_flowable]), Spacer(1,5)]
|
|
|
|
def generate_swp_pdf(swp, version, logo_path, json_module, lang='es'):
|
|
L = LABELS.get(lang, LABELS['es'])
|
|
cat_map = CATEGORY_LABELS_EN if lang=='en' else CATEGORY_LABELS_ES
|
|
|
|
def parse(field):
|
|
try: return json_module.loads(version.get(field) or '[]')
|
|
except: return []
|
|
|
|
hazards = parse('hazards'); ppe=parse('ppe')
|
|
tools = parse('tools'); steps=parse('steps')
|
|
refs = parse('ref_standards')
|
|
purpose = version.get('purpose') or ''
|
|
scope = version.get('scope') or ''
|
|
emergency = version.get('emergency') or ''
|
|
|
|
if lang == 'en':
|
|
purpose = translate_text(purpose,'en')
|
|
scope = translate_text(scope,'en')
|
|
emergency = translate_text(emergency,'en')
|
|
hazards = translate_list(hazards,'en')
|
|
ppe = translate_list(ppe,'en')
|
|
tools = translate_list(tools,'en')
|
|
steps = translate_list(steps,'en')
|
|
|
|
buf = BytesIO()
|
|
W = 7.5*inch
|
|
doc = SimpleDocTemplate(buf, pagesize=letter,
|
|
leftMargin=0.65*inch, rightMargin=0.65*inch,
|
|
topMargin=0.6*inch, bottomMargin=0.6*inch)
|
|
story = []
|
|
|
|
# ── HEADER ───────────────────────────────────────────────────────────────
|
|
story.append(HRFlowable(width=W,thickness=3,color=CYAN,spaceAfter=4))
|
|
if logo_path and os.path.exists(logo_path):
|
|
try:
|
|
lw,lh = resize_image(logo_path,1.4*inch,0.55*inch)
|
|
logo_cell = RLImage(logo_path,width=lw,height=lh)
|
|
except: logo_cell = Paragraph(f"<b>{swp.get('company_name','')}</b>",mk('co',fontSize=12,textColor=NAVY))
|
|
else:
|
|
logo_cell = Paragraph(f"<b>{swp.get('company_name','')}</b>",mk('co',fontSize=12,textColor=NAVY))
|
|
|
|
right_txt = (f'<font size="7" color="#8a9bb0">{L["title"]}</font><br/>'
|
|
f'<font size="16" color="#00b4d8"><b>{swp["code"]}</b></font><br/>'
|
|
f'<font size="7" color="#8a9bb0">{version["version"]} · {L["effective"]}: {version.get("effective_date") or "—"}</font>')
|
|
hdr = Table([[logo_cell, Paragraph(right_txt,mk('rh',fontSize=8,alignment=TA_RIGHT,leading=13))]],
|
|
colWidths=[W*0.55,W*0.45])
|
|
hdr.setStyle(TableStyle([('VALIGN',(0,0),(-1,-1),'TOP')]))
|
|
story.append(hdr)
|
|
if swp.get('company_info'):
|
|
story.append(Paragraph(swp['company_info'],mk('ci',fontSize=7,textColor=GRAY)))
|
|
story.append(HRFlowable(width=W,thickness=1,color=LIGHT,spaceAfter=4))
|
|
|
|
# ── META ─────────────────────────────────────────────────────────────────
|
|
meta = Table([
|
|
[Paragraph(f'<b>{swp.get("title","")}</b>',mk('t',fontSize=10,fontName='Helvetica-Bold')),
|
|
Paragraph(cat_map.get(swp.get('category','other'),''),mk('c',fontSize=8)),
|
|
Paragraph(version.get('approved_by') or '—',mk('a',fontSize=8)),
|
|
Paragraph(L['active'],mk('s',fontSize=8,textColor=GREEN,fontName='Helvetica-Bold'))],
|
|
[Paragraph(swp.get('code',''),mk('lc',fontSize=7,textColor=GRAY)),
|
|
Paragraph(L['category'],mk('lcat',fontSize=7,textColor=GRAY)),
|
|
Paragraph(L['approved_by'],mk('lab',fontSize=7,textColor=GRAY)),
|
|
Paragraph(L['status'],mk('lst',fontSize=7,textColor=GRAY))],
|
|
],colWidths=[W*0.38,W*0.25,W*0.25,W*0.12])
|
|
meta.setStyle(TableStyle([
|
|
('BACKGROUND',(0,1),(-1,1),LIGHT),
|
|
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
|
|
('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3),
|
|
('LEFTPADDING',(0,0),(-1,-1),6),('RIGHTPADDING',(0,0),(-1,-1),6),
|
|
]))
|
|
story.append(meta)
|
|
story.append(Spacer(1,4))
|
|
|
|
# ── PURPOSE & SCOPE ──────────────────────────────────────────────────────
|
|
if purpose or scope:
|
|
rows=[]
|
|
if purpose: rows.append([Paragraph(f'<b>{L["purpose_lbl"]}</b>',mk('pl',fontSize=7,textColor=GRAY)),
|
|
Paragraph(purpose,mk('pv',fontSize=8,leading=11))])
|
|
if scope: rows.append([Paragraph(f'<b>{L["scope_lbl"]}</b>',mk('sl',fontSize=7,textColor=GRAY)),
|
|
Paragraph(scope,mk('sv',fontSize=8,leading=11))])
|
|
pt=Table(rows,colWidths=[0.85*inch,W-0.85*inch])
|
|
pt.setStyle(TableStyle([
|
|
('BACKGROUND',(0,0),(0,-1),LIGHT),
|
|
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
|
|
('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3),
|
|
('LEFTPADDING',(0,0),(-1,-1),6),('VALIGN',(0,0),(-1,-1),'TOP'),
|
|
]))
|
|
story += section(SHdr(L['purpose']), pt)
|
|
|
|
# ── HAZARDS ──────────────────────────────────────────────────────────────
|
|
if hazards:
|
|
story += section(SHdr(L['hazards'],RED), num_table(hazards,W))
|
|
|
|
# ── PPE ──────────────────────────────────────────────────────────────────
|
|
if ppe:
|
|
story += section(SHdr(L['ppe'],ORANGE), tag_table(ppe,W))
|
|
|
|
# ── TOOLS & MATERIALS ────────────────────────────────────────────────────
|
|
if tools:
|
|
story += section(SHdr(L['tools'],PURPLE), tag_table(tools,W))
|
|
|
|
# ── STEPS ────────────────────────────────────────────────────────────────
|
|
if steps:
|
|
story += section(SHdr(L['steps'],BLUE), num_table(steps,W))
|
|
|
|
# ── EMERGENCY ────────────────────────────────────────────────────────────
|
|
if emergency:
|
|
et=Table([[Paragraph(emergency,mk('em',fontSize=8,leading=11))]],colWidths=[W])
|
|
et.setStyle(TableStyle([
|
|
('BACKGROUND',(0,0),(-1,-1),colors.HexColor('#fff8e8')),
|
|
('BOX',(0,0),(-1,-1),1,WARN),
|
|
('TOPPADDING',(0,0),(-1,-1),4),('BOTTOMPADDING',(0,0),(-1,-1),4),
|
|
('LEFTPADDING',(0,0),(-1,-1),8),('RIGHTPADDING',(0,0),(-1,-1),8),
|
|
]))
|
|
story += section(SHdr(L['emergency'],WARN), et)
|
|
|
|
# ── REFERENCES ───────────────────────────────────────────────────────────
|
|
if refs:
|
|
story += section(SHdr(L['refs']), tag_table(refs,W))
|
|
|
|
# ── VERSION CONTROL ──────────────────────────────────────────────────────
|
|
story.append(HRFlowable(width=W,thickness=1,color=LIGHT,spaceAfter=3))
|
|
vt=Table([
|
|
[Paragraph(f'<b>{L["version"]}</b>',mk('vh',fontSize=7,textColor=GRAY)),
|
|
Paragraph(f'<b>{L["reason"]}</b>',mk('vh',fontSize=7,textColor=GRAY)),
|
|
Paragraph(f'<b>{L["diff"]}</b>',mk('vh',fontSize=7,textColor=GRAY)),
|
|
Paragraph(f'<b>{L["created_by"]}</b>',mk('vh',fontSize=7,textColor=GRAY)),
|
|
Paragraph(f'<b>{L["effective"]}</b>',mk('vh',fontSize=7,textColor=GRAY))],
|
|
[Paragraph(f"<b>{version['version']}</b>",mk('vv',fontSize=8,textColor=CYAN,fontName='Helvetica-Bold')),
|
|
Paragraph(version.get('change_reason') or '—',mk('vr',fontSize=8)),
|
|
Paragraph(version.get('diff_summary') or '—',mk('vd',fontSize=8)),
|
|
Paragraph(version.get('created_by') or '—',mk('vc',fontSize=8)),
|
|
Paragraph(version.get('effective_date') or '—',mk('ve',fontSize=8))],
|
|
],colWidths=[W*0.1,W*0.22,W*0.28,W*0.22,W*0.18])
|
|
vt.setStyle(TableStyle([
|
|
('BACKGROUND',(0,0),(-1,0),LIGHT),
|
|
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
|
|
('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3),
|
|
('LEFTPADDING',(0,0),(-1,-1),6),('VALIGN',(0,0),(-1,-1),'TOP'),
|
|
]))
|
|
story += section(SHdr(L['version_ctrl']), vt)
|
|
|
|
# ── FOOTER ───────────────────────────────────────────────────────────────
|
|
story.append(Spacer(1,6))
|
|
story.append(HRFlowable(width=W,thickness=1,color=LIGHT,spaceAfter=2))
|
|
story.append(Paragraph(
|
|
f"{swp.get('company_name','')} | {swp['code']} {version['version']} | {L['footer']}",
|
|
mk('ft',fontSize=7,textColor=GRAY,alignment=TA_CENTER)))
|
|
|
|
doc.build(story)
|
|
buf.seek(0)
|
|
return buf
|