Initial commit — MarineMaintenance v1.0
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>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user