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,636 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user