Files
MarineMaintenance/report_generator.py
T
alro65 67a0e674ca 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>
2026-05-05 01:54:20 -04:00

637 lines
32 KiB
Python

"""
report_generator.py - Marine Maintenance Pro
Genera reporte PDF con traduccion automatica via Claude API
"""
import os, re, 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_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
# Colors
NAVY = colors.HexColor('#0a1628')
CYAN = colors.HexColor('#00b4d8')
LIGHT_BG = colors.HexColor('#f0f4f8')
GRAY = colors.HexColor('#8a9bb0')
WHITE = colors.white
WARN = colors.HexColor('#f4a261')
SUCCESS = colors.HexColor('#2ec4b6')
STATUS_COLORS = {'open':CYAN,'in_progress':WARN,'completed':SUCCESS,'cancelled':GRAY}
# UI Labels
T = {
'es': {
'work_order':'ORDEN DE TRABAJO','vessel_data':'Datos de la Embarcacion',
'vessel':'Embarcacion','registration':'Matricula','type':'Tipo','year':'Ano',
'make_model':'Marca / Modelo','engine_hours':'Horas Motor','owner':'Propietario',
'captain':'Capitan','status':'Estado','start_date':'Fecha Inicio',
'end_date':'Fecha Cierre','technician':'Tecnico','system':'Sistema',
'scope':'Scope / Alcance','description':'Descripcion del Trabajo',
'root_cause':'Causa Tecnica de la Falla','repairs':'Reparaciones Realizadas',
'system':'Sistema',
'equipment_worked':'Equipos Trabajados','equip_name':'Equipo','serial':'N Serie',
'hrs':'Hrs','work_done':'Trabajo Realizado','parts':'Repuestos y Materiales',
'part':'Repuesto','desc':'Descripcion','qty':'Cant.','unit_price':'P. Unit.',
'total':'Total','costs':'Resumen de Costos','labor':'Mano de Obra',
'parts_cost':'Repuestos y Materiales','before':'ANTES','after':'DESPUES',
'evidence':'Evidencia Fotografica','signatures':'Firmas y Aprobacion',
'tech_sign':'Tecnico Responsable','client_sign':'Capitan / Propietario',
'generated':'Generado automaticamente por Marine Maintenance Pro',
'status_open':'Abierta','status_in_progress':'En Progreso',
'status_completed':'Completada','status_cancelled':'Cancelada',
},
'en': {
'work_order':'WORK ORDER','vessel_data':'Vessel Information',
'vessel':'Vessel','registration':'Registration','type':'Type','year':'Year',
'make_model':'Make / Model','engine_hours':'Engine Hours','owner':'Owner',
'captain':'Captain','status':'Status','start_date':'Start Date',
'end_date':'End Date','technician':'Technician','system':'System',
'scope':'Scope','description':'Work Description',
'root_cause':'Technical Root Cause','repairs':'Repairs Performed',
'system':'System',
'equipment_worked':'Equipment Worked On','equip_name':'Equipment','serial':'Serial No.',
'hrs':'Hrs','work_done':'Work Performed','parts':'Parts & Materials Used',
'part':'Part / Material','desc':'Description','qty':'Qty','unit_price':'Unit Price',
'total':'Total','costs':'Cost Summary','labor':'Labor',
'parts_cost':'Parts & Materials','before':'BEFORE','after':'AFTER',
'evidence':'Photo Evidence','signatures':'Signatures & Approval',
'tech_sign':'Responsible Technician','client_sign':'Captain / Owner',
'generated':'Automatically generated by Marine Maintenance Pro',
'status_open':'Open','status_in_progress':'In Progress',
'status_completed':'Completed','status_cancelled':'Cancelled',
'lump_sum_label':'Fixed Price (all inclusive)',
}
}
def t(lang, key):
return T.get(lang, T['es']).get(key, key)
def status_label(lang, status):
return t(lang, f'status_{status}')
# ── Auto-translate via Ollama ─────────────────────────────────────────────────
def translate_text(text, model='llama3.1:8b'):
"""Translate a single text string ES->EN via Ollama."""
if not text or not text.strip():
return text
try:
prompt = (
f"Translate this Spanish text to professional English. "
f"Keep marine and electrical technical terms accurate. "
f"Return ONLY the translated text, nothing else:\n\n{text}"
)
payload = json.dumps({
"model": model,
"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()
# Remove common Ollama preambles
for prefix in ['Here is the translated text:', 'Here is the translation:',
'Translation:', 'Translated text:', 'Here\'s the translation:']:
if result.lower().startswith(prefix.lower()):
result = result[len(prefix):].strip()
return result
except Exception as e:
print(f"[translate] Ollama error: {e}")
return text
def translate_content(texts_dict, target_lang='en'):
"""Translate ALL fields in a single Ollama call for speed."""
if target_lang != 'en':
return texts_dict
# Filter non-empty values
to_translate = {k: v for k, v in texts_dict.items() if v and str(v).strip()}
if not to_translate:
return texts_dict
try:
# Build numbered list for single batch translation
keys = list(to_translate.keys())
lines = '\n'.join(f"{i+1}. {to_translate[k]}" for i, k in enumerate(keys))
prompt = (
"Translate the following numbered items from Spanish to English. "
"Keep marine/nautical/technical terminology accurate. "
"Return ONLY the numbered list with translations, same format, nothing else:\n\n"
+ lines
)
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=60) as resp:
data = json.loads(resp.read().decode('utf-8'))
response = data.get('response', '').strip()
# Parse numbered response back
result = dict(texts_dict) # start with originals as fallback
for line in response.split('\n'):
line = line.strip()
if not line:
continue
# Match "1. text" or "1) text"
import re
m = re.match(r'^(\d+)[.)]\s*(.+)$', line)
if m:
idx = int(m.group(1)) - 1
if 0 <= idx < len(keys):
result[keys[idx]] = m.group(2).strip()
return result
except Exception as e:
print(f"[translate_batch] Ollama error: {e}")
return texts_dict # fallback: original text
# ── Styles ────────────────────────────────────────────────────────────────────
def make_styles():
s = {}
s['label'] = ParagraphStyle('label', fontName='Helvetica-Bold', fontSize=8,
textColor=GRAY, spaceAfter=2, leading=10)
s['value'] = ParagraphStyle('value', fontName='Helvetica', fontSize=9,
textColor=NAVY, spaceAfter=2, leading=12)
s['body'] = ParagraphStyle('body', fontName='Helvetica', fontSize=9,
textColor=NAVY, spaceAfter=3, leading=14,
leftIndent=0, rightIndent=0, borderPad=0)
s['small'] = ParagraphStyle('small', fontName='Helvetica', fontSize=8,
textColor=GRAY, spaceAfter=2, leading=10)
s['mono'] = ParagraphStyle('mono', fontName='Courier', fontSize=7,
textColor=NAVY, leading=10)
s['sign'] = ParagraphStyle('sign', fontName='Helvetica', fontSize=8,
textColor=GRAY, alignment=TA_CENTER)
s['photo'] = ParagraphStyle('photo', fontName='Helvetica-Bold', fontSize=8,
alignment=TA_CENTER, spaceAfter=2)
return s
class SectionHeader(Flowable):
def __init__(self, text, width=None):
Flowable.__init__(self)
self.text = text
self._fixed_width = width
self.height = 16
def wrap(self, availWidth, availHeight):
self._width = self._fixed_width if self._fixed_width else availWidth
return self._width, self.height
def draw(self):
self.canv.setFillColor(NAVY)
self.canv.rect(0, 0, self._width, self.height, fill=1, stroke=0)
self.canv.setFillColor(CYAN)
self.canv.rect(0, 0, 4, self.height, fill=1, stroke=0)
self.canv.setFillColor(WHITE)
self.canv.setFont('Helvetica-Bold', 9)
self.canv.drawString(12, 4, self.text.upper())
def resize_image(path, max_w, max_h):
try:
with PILImage.open(path) as img:
iw, ih = img.size
ratio = min(max_w/iw, max_h/ih)
return iw*ratio, ih*ratio
except:
return max_w, max_h
def text_block(paragraphs, W):
"""Single flowing text cell — no forced page breaks between paragraphs."""
# Join all paragraphs into one cell so text flows naturally
tbl = Table([[paragraphs]], colWidths=[W])
tbl.setStyle(TableStyle([
('LEFTPADDING', (0,0), (-1,-1), 10),
('RIGHTPADDING', (0,0), (-1,-1), 10),
('TOPPADDING',(0,0),(-1,-1),4),
('BOTTOMPADDING',(0,0),(-1,-1),4),
('VALIGN', (0,0), (-1,-1), 'TOP'),
('BOX', (0,0), (-1,-1), 0.5, colors.HexColor('#d0dae6')),
]))
return tbl
def section_block(header_flowable, content_flowables):
"""Header stays with content start, but content flows freely across pages.
Strategy: use KeepTogether ONLY for header + a tiny anchor spacer (not the
whole content block). This prevents orphan headers without forcing large
tables to jump pages looking for space.
"""
if not content_flowables:
return [header_flowable, Spacer(1,2)]
first = content_flowables[0]
rest = content_flowables[1:]
# If first content is a Table, we can't split KeepTogether with it safely
# Instead just keep header + 2pt spacer together (very small, almost never jumps)
# and let the table itself flow/split naturally
result = [KeepTogether([header_flowable, Spacer(1,2)]), first]
result.extend(rest)
return result
def generate_work_order_pdf(order, vessel, photos, parts_used, wo_equipment,
upload_folder, sig_folder,
company_name="Marine Maintenance Pro",
company_info="", company_logo=None, lang='es'):
# ── Translate content if EN ───────────────────────────────────────────────
if lang == 'en':
content_to_translate = {
'scope': order.get('scope') or '',
'description': order.get('description') or '',
'root_cause': order.get('root_cause') or '',
'repairs_done': order.get('repairs_done') or '',
'system_name': order.get('system_name') or '',
}
# Translate equipment names AND descriptions
for i, e in enumerate(wo_equipment):
content_to_translate[f'equip_name_{i}'] = e.get('equip_name') or ''
content_to_translate[f'equip_desc_{i}'] = e.get('description') or ''
# Translate photo captions
for i, p in enumerate(photos):
if p.get('caption'):
content_to_translate[f'photo_cap_{i}'] = p['caption']
translated = translate_content(content_to_translate, 'en')
order = dict(order)
order['scope'] = translated.get('scope', order.get('scope',''))
order['description'] = translated.get('description', order.get('description',''))
order['root_cause'] = translated.get('root_cause', order.get('root_cause',''))
order['repairs_done'] = translated.get('repairs_done', order.get('repairs_done',''))
order['system_name'] = translated.get('system_name', order.get('system_name',''))
wo_equipment = [dict(e) for e in wo_equipment]
for i, e in enumerate(wo_equipment):
e['equip_name'] = translated.get(f'equip_name_{i}', e.get('equip_name',''))
e['description'] = translated.get(f'equip_desc_{i}', e.get('description',''))
photos = [dict(p) for p in photos]
for i, p in enumerate(photos):
if p.get('caption'):
p['caption'] = translated.get(f'photo_cap_{i}', p['caption'])
# ── Build PDF ─────────────────────────────────────────────────────────────
buf = BytesIO()
W = 7.5 * inch
doc = SimpleDocTemplate(buf, pagesize=letter,
leftMargin=0.75*inch, rightMargin=0.75*inch,
topMargin=0.75*inch, bottomMargin=0.75*inch)
S = make_styles()
story = []
# ── HEADER ───────────────────────────────────────────────────────────────
story.append(HRFlowable(width=W, thickness=3, color=CYAN, spaceAfter=4))
if company_logo and os.path.exists(company_logo):
try:
lw, lh = resize_image(company_logo, 1.6*inch, 0.65*inch)
logo_cell = RLImage(company_logo, width=lw, height=lh)
except:
logo_cell = Paragraph(f'<b>{company_name}</b>',
ParagraphStyle('ch', fontName='Helvetica-Bold', fontSize=16, textColor=NAVY))
else:
logo_cell = Paragraph(f'<b>{company_name}</b>',
ParagraphStyle('ch', fontName='Helvetica-Bold', fontSize=16, textColor=NAVY, leading=20))
hdr = Table([[logo_cell,
Paragraph(f'<b>{t(lang,"work_order")}</b><br/>'
f'<font color="#00b4d8" size="18"><b>{order.get("order_number","")}</b></font>',
ParagraphStyle('on', fontName='Helvetica-Bold', fontSize=9,
textColor=GRAY, alignment=TA_RIGHT, leading=22))
]], colWidths=[W*0.6, W*0.4])
hdr.setStyle(TableStyle([('VALIGN',(0,0),(-1,-1),'TOP')]))
story.append(hdr)
if company_info:
story.append(Paragraph(company_info,
ParagraphStyle('ci', fontName='Helvetica', fontSize=8, textColor=GRAY, spaceAfter=2)))
story.append(HRFlowable(width=W, thickness=1, color=LIGHT_BG, spaceAfter=4))
# ── STATUS ROW ───────────────────────────────────────────────────────────
sk = order.get('status','open')
sc = STATUS_COLORS.get(sk, GRAY)
meta = Table([
[Paragraph(f'<b>{t(lang,"status")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"start_date")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"end_date")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"technician")}</b>',S['label'])],
[Paragraph(f'<font color="{sc.hexval()}"><b>{status_label(lang,sk)}</b></font>',S['value']),
Paragraph(str(order.get('start_date') or '—'),S['value']),
Paragraph(str(order.get('end_date') or '—'),S['value']),
Paragraph(str(order.get('technician') or '—'),S['value'])],
], colWidths=[W/4]*4)
meta.setStyle(TableStyle([
('BACKGROUND',(0,0),(-1,0),LIGHT_BG),
('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),10),('RIGHTPADDING',(0,0),(-1,-1),10),
]))
story.append(KeepTogether([meta]))
story.append(Spacer(1,5))
# ── VESSEL ───────────────────────────────────────────────────────────────
vt = Table([
[Paragraph(f'<b>{t(lang,"vessel")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"registration")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"type")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"year")}</b>',S['label'])],
[Paragraph(str(vessel.get('name') or '—'),S['value']),
Paragraph(str(vessel.get('registration') or '—'),S['value']),
Paragraph(str(vessel.get('vessel_type') or '—'),S['value']),
Paragraph(str(vessel.get('year') or '—'),S['value'])],
[Paragraph(f'<b>{t(lang,"make_model")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"engine_hours")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"captain")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"owner")}</b>',S['label'])],
[Paragraph(f'{vessel.get("make") or ""} {vessel.get("model") or ""}'.strip() or '—',S['value']),
Paragraph(f'{vessel.get("engine_hours") or 0} h',S['value']),
Paragraph(str(vessel.get('captain_name') or '—'),S['value']),
Paragraph(str(vessel.get('owner_name') or '—'),S['value'])],
], colWidths=[W/4]*4)
vt.setStyle(TableStyle([
('BACKGROUND',(0,0),(-1,0),LIGHT_BG),('BACKGROUND',(0,2),(-1,2),LIGHT_BG),
('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),10),('RIGHTPADDING',(0,0),(-1,-1),10),
]))
story += section_block(SectionHeader(f'{t(lang,"vessel_data")}'), [vt])
story.append(Spacer(1,5))
# ── SCOPE ────────────────────────────────────────────────────────────────
sys_name = order.get('system_name') or ''
scope = order.get('scope') or ''
if sys_name or scope:
rows = []
if sys_name:
rows.append([Paragraph(f'<b>{t(lang,"system")}</b>', S['label']),
Paragraph(sys_name, S['value'])])
if scope:
rows.append([Paragraph(f'<b>{t(lang,"scope")}</b>', S['label']),
Paragraph(scope, S['value'])])
st = Table(rows, colWidths=[W/4, W - W/4])
st.setStyle(TableStyle([
('BACKGROUND', (0,0), (0,-1), LIGHT_BG),
('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), 8),
('RIGHTPADDING', (0,0), (-1,-1), 8),
('VALIGN', (0,0), (-1,-1), 'TOP'),
]))
story += section_block(SectionHeader(f'{t(lang,"scope")}'), [st])
story.append(Spacer(1,4))
# ── DESCRIPTION ──────────────────────────────────────────────────────────
desc = order.get('description') or ''
if desc:
story += section_block(SectionHeader(f'{t(lang,"description")}'),
[text_block([Paragraph(desc.replace('\n', '<br/>'), S['body'])], W)])
story.append(Spacer(1,4))
# ── ROOT CAUSE ───────────────────────────────────────────────────────────
root_cause = order.get('root_cause') or ''
if root_cause:
story += section_block(SectionHeader(f'{t(lang,"root_cause")}'),
[text_block([Paragraph(root_cause.replace('\n', '<br/>'), S['body'])], W)])
story.append(Spacer(1,4))
# ── REPAIRS ──────────────────────────────────────────────────────────────
repairs = order.get('repairs_done') or ''
if repairs:
story += section_block(SectionHeader(f'{t(lang,"repairs")}'),
[text_block([Paragraph(repairs.replace('\n', '<br/>'), S['body'])], W)])
story.append(Spacer(1,4))
# ── EQUIPMENT WORKED ─────────────────────────────────────────────────────
if wo_equipment:
hdr_row = [
Paragraph(f'<b>{t(lang,"equip_name")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"serial")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"hrs")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"work_done")}</b>',S['label']),
]
rows = [hdr_row]
for e in wo_equipment:
name_str = str(e.get('equip_name') or '—')
brand = f'{e.get("make") or ""} {e.get("model") or ""}'.strip()
if brand:
name_str += f'<br/><font size="7" color="#8a9bb0">{brand}</font>'
rows.append([
Paragraph(name_str, S['value']),
Paragraph(str(e.get('serial_number') or '—'), S['mono']),
Paragraph(f'{e.get("labor_hours") or 0}h', S['value']),
Paragraph(str(e.get('description') or '—'), S['value']),
])
eqt = Table(rows, colWidths=[W*0.23, W*0.17, W*0.07, W*0.53])
eqt.setStyle(TableStyle([
('BACKGROUND',(0,0),(-1,0),NAVY),('TEXTCOLOR',(0,0),(-1,0),WHITE),
('ROWBACKGROUNDS',(0,1),(-1,-1),[WHITE,LIGHT_BG]),
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),2),
('LEFTPADDING',(0,0),(-1,-1),8),('RIGHTPADDING',(0,0),(-1,-1),8),
('VALIGN',(0,0),(-1,-1),'TOP'),
]))
story += section_block(SectionHeader(f'{t(lang,"equipment_worked")}'), [eqt])
story.append(Spacer(1,5))
# ── PARTS (only for labor_materials) ─────────────────────────────────────
billing = order.get('billing_type', 'labor_materials')
if billing == 'labor_materials':
parts_content = []
if parts_used:
hdr_row = [
Paragraph(f'<b>{t(lang,"part")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"desc")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"qty")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"unit_price")}</b>',S['label']),
Paragraph(f'<b>{t(lang,"total")}</b>',S['label']),
]
rows = [hdr_row]
for p in parts_used:
rows.append([
Paragraph(str(p.get('part_name') or '—'),S['value']),
Paragraph(str(p.get('description') or ''),S['small']),
Paragraph(str(p.get('quantity') or 0),S['value']),
Paragraph(f'${float(p.get("unit_cost") or 0):.2f}',S['value']),
Paragraph(f'${float(p.get("total_cost") or 0):.2f}',S['value']),
])
ptt = Table(rows, colWidths=[W*0.28,W*0.30,W*0.10,W*0.15,W*0.17])
ptt.setStyle(TableStyle([
('BACKGROUND',(0,0),(-1,0),NAVY),('TEXTCOLOR',(0,0),(-1,0),WHITE),
('ROWBACKGROUNDS',(0,1),(-1,-1),[WHITE,LIGHT_BG]),
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
('ALIGN',(2,0),(-1,-1),'RIGHT'),
('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),2),
('LEFTPADDING',(0,0),(-1,-1),8),('RIGHTPADDING',(0,0),(-1,-1),8),
('VALIGN',(0,0),(-1,-1),'TOP'),
]))
parts_content = [ptt]
else:
parts_content = [Paragraph('—', S['small'])]
story += section_block(SectionHeader(f'{t(lang,"parts")}'), parts_content)
story.append(Spacer(1,5))
# ── COSTS ────────────────────────────────────────────────────────────────
lh = float(order.get('labor_hours') or 0)
lr = float(order.get('labor_rate') or 0)
lc = lh * lr
pc = float(order.get('total_parts_cost') or 0) if billing == 'labor_materials' else 0
tot = lc + pc
# Build cost rows based on billing type
if billing == 'lump_sum':
cost_rows = [
[Paragraph(t(lang,'lump_sum_label') if lang=='en' else 'Precio fijo (todo incluido)', S['label']),
Paragraph(f'{lh} h', S['value']),
Paragraph(f'${tot:.2f}', S['value'])],
[Paragraph(f'<b>{t(lang,"total").upper()}</b>',
ParagraphStyle('tb',fontName='Helvetica-Bold',fontSize=10,textColor=WHITE)),
Paragraph('',S['value']),
Paragraph(f'<b>${tot:.2f}</b>',
ParagraphStyle('tv',fontName='Helvetica-Bold',fontSize=11,textColor=CYAN,alignment=TA_RIGHT))],
]
elif billing == 'labor_only':
cost_rows = [
[Paragraph(t(lang,'labor'), S['label']),
Paragraph(f'{lh} h x ${lr:.2f}/h', S['value']),
Paragraph(f'${lc:.2f}', S['value'])],
[Paragraph(f'<b>{t(lang,"total").upper()}</b>',
ParagraphStyle('tb',fontName='Helvetica-Bold',fontSize=10,textColor=WHITE)),
Paragraph('',S['value']),
Paragraph(f'<b>${lc:.2f}</b>',
ParagraphStyle('tv',fontName='Helvetica-Bold',fontSize=11,textColor=CYAN,alignment=TA_RIGHT))],
]
else: # labor_materials
cost_rows = [
[Paragraph(t(lang,'labor'),S['label']),
Paragraph(f'{lh} h x ${lr:.2f}/h',S['value']),
Paragraph(f'${lc:.2f}',S['value'])],
[Paragraph(t(lang,'parts_cost'),S['label']),
Paragraph('',S['value']),
Paragraph(f'${pc:.2f}',S['value'])],
[Paragraph(f'<b>{t(lang,"total").upper()}</b>',
ParagraphStyle('tb',fontName='Helvetica-Bold',fontSize=10,textColor=WHITE)),
Paragraph('',S['value']),
Paragraph(f'<b>${tot:.2f}</b>',
ParagraphStyle('tv',fontName='Helvetica-Bold',fontSize=11,textColor=CYAN,alignment=TA_RIGHT))],
]
ct = Table(cost_rows, colWidths=[W*0.42, W*0.33, W*0.25])
ct.setStyle(TableStyle([
('BACKGROUND',(0,0),(-1,-2),LIGHT_BG),('BACKGROUND',(0,-1),(-1,-1),NAVY),
('GRID',(0,0),(-1,-1),0.5,colors.HexColor('#d0dae6')),
('ALIGN',(2,0),(-1,-1),'RIGHT'),
('TOPPADDING',(0,0),(-1,-1),3),('BOTTOMPADDING',(0,0),(-1,-1),3),
('LEFTPADDING',(0,0),(-1,-1),10),('RIGHTPADDING',(0,0),(-1,-1),10),
]))
story += section_block(SectionHeader(f'{t(lang,"costs")}'), [ct])
story.append(Spacer(1,7))
# ── PHOTOS ───────────────────────────────────────────────────────────────
before_photos = [p for p in photos if p.get('photo_type')=='before']
after_photos = [p for p in photos if p.get('photo_type')=='after']
def photo_section(photo_list, label):
if not photo_list: return []
MAX_W = (W - 0.3*inch) / 2
MAX_H = 2.4 * inch
photo_tables = []
for row in [photo_list[i:i+2] for i in range(0,len(photo_list),2)]:
cells = []
for ph in row:
fp = os.path.join(upload_folder, ph['filename'])
if os.path.exists(fp):
try:
# Compress image to reduce PDF size before inserting
from io import BytesIO as _BytesIO
with PILImage.open(fp) as _img:
_img = _img.convert('RGB')
# Resize to max 1200px on longest side
_img.thumbnail((1200, 1200), PILImage.LANCZOS)
_buf = _BytesIO()
_img.save(_buf, format='JPEG', quality=72, optimize=True)
_buf.seek(0)
pw, ph_h = resize_image(fp, MAX_W, MAX_H)
img = RLImage(_buf, width=pw, height=ph_h)
cap = ph.get('caption') or ''
cell = [img]
if cap: cell += [Spacer(1,3), Paragraph(cap, S['photo'])]
cells.append(cell)
except: cells.append([Paragraph('[Error]', S['small'])])
else: cells.append([Paragraph('[Not found]', S['small'])])
while len(cells) < 2: cells.append([Paragraph('', S['small'])])
pt = Table([cells], colWidths=[W/2-0.1*inch, W/2-0.1*inch])
pt.setStyle(TableStyle([
('VALIGN',(0,0),(-1,-1),'TOP'),('ALIGN',(0,0),(-1,-1),'CENTER'),
('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),4),
]))
photo_tables.append(pt)
return section_block(SectionHeader(f'{t(lang,"evidence")}{label}'), photo_tables)
if before_photos or after_photos:
story += photo_section(before_photos, t(lang,'before'))
if after_photos: story.append(Spacer(1,3))
story += photo_section(after_photos, t(lang,'after'))
story.append(Spacer(1,7))
# ── SIGNATURES ───────────────────────────────────────────────────────────
story.append(HRFlowable(width=W, thickness=1, color=LIGHT_BG, spaceAfter=7))
def sig_cell(sig_filename, label, name):
content = []
if sig_filename and sig_folder:
fp = os.path.join(sig_folder, sig_filename)
if os.path.exists(fp):
try:
sw, sh = resize_image(fp, W/2-0.3*inch, 0.8*inch)
content.append(RLImage(fp, width=sw, height=sh))
except: pass
if not content:
content.append(Paragraph('_'*38, S['sign']))
content += [Spacer(1,3), Paragraph(f'{label}<br/>{name}', S['sign'])]
return content
tech_name = order.get('technician') or ''
client_name = vessel.get('captain_name') or vessel.get('owner_name') or ''
sig_tbl = Table([[
sig_cell(order.get('signature_tech'), t(lang,'tech_sign'), tech_name),
sig_cell(order.get('signature_client'), t(lang,'client_sign'), client_name),
]], colWidths=[W/2, W/2])
sig_tbl.setStyle(TableStyle([
('ALIGN',(0,0),(-1,-1),'CENTER'),('VALIGN',(0,0),(-1,-1),'BOTTOM'),
('TOPPADDING',(0,0),(-1,-1),2),('BOTTOMPADDING',(0,0),(-1,-1),2),
]))
story += section_block(SectionHeader(f'{t(lang,"signatures")}'), [sig_tbl])
# ── FOOTER ───────────────────────────────────────────────────────────────
story.append(Spacer(1,8))
story.append(HRFlowable(width=W, thickness=1, color=LIGHT_BG, spaceAfter=2))
story.append(Paragraph(
f'{company_name} | {order.get("order_number","")} | {t(lang,"generated")}',
ParagraphStyle('footer', fontName='Helvetica', fontSize=7,
textColor=GRAY, alignment=TA_CENTER)))
doc.build(story)
buf.seek(0)
return buf