Spaces:
Sleeping
Sleeping
| import os, re, json, datetime, subprocess, sys, random, unicodedata, shutil | |
| from typing import Dict, Any, Optional, Tuple | |
| from openpyxl import load_workbook | |
| from openpyxl.worksheet.worksheet import Worksheet | |
| from openpyxl.drawing.image import Image as XLImage # FIX: needed for logo | |
| from openpyxl.worksheet.page import PageMargins | |
| from openpyxl.utils import get_column_letter | |
| # top of file | |
| from openpyxl.styles import Font | |
| from openpyxl.styles import Border, Side, PatternFill, Alignment, Font | |
| _THIN = Side(style="thin", color="000000") | |
| _BORDER_ALL = Border(left=_THIN, right=_THIN, top=_THIN, bottom=_THIN) | |
| _GRAY = "FFBFBFBF" # slightly darker than hairline | |
| _GREEN = "FF6FB643" # your brand | |
| _WHITE = "FFFFFFFF" | |
| # Tune these to the template's designed page | |
| PRINT_COLS = 11 # A..K typically | |
| PRINT_ROWS = 70 # enough to include footer band | |
| # ---------- initial paths for logo ---------- | |
| ASSETS = os.environ.get("INVOICE_ASSETS", "assets") | |
| LOGO_PATH = os.path.join(ASSETS, "logo.png") | |
| def logo_data_uri() -> str: | |
| try: | |
| import base64 | |
| with open(LOGO_PATH, "rb") as f: | |
| data = base64.b64encode(f.read()).decode("ascii") | |
| return f"data:image/png;base64,{data}" | |
| except Exception: | |
| return "" | |
| # ---------- optional libs ---------- | |
| docx_available = False | |
| pdf_available = False | |
| win32_available = False | |
| try: | |
| import docx | |
| docx_available = True | |
| except Exception: | |
| pass | |
| try: | |
| import PyPDF2 | |
| pdf_available = True | |
| except Exception: | |
| pass | |
| try: | |
| import win32com.client # Windows only | |
| win32_available = True | |
| except Exception: | |
| pass | |
| # ---------- paths ---------- | |
| OUT_DIR = os.environ.get("INVOICE_OUT", "outputs") | |
| os.makedirs(OUT_DIR, exist_ok=True) | |
| TEMPLATE_JC = os.path.join(ASSETS, "JC Auditing Invoice Template.xlsx") | |
| TEMPLATE_SPRINGY = os.path.join(ASSETS, "Springy Invoice Template.xlsx") | |
| INPUTS_DOCX = os.path.join(ASSETS, "Invoice Inputs.docx") | |
| # ---------- excel helpers ---------- | |
| def _border_range(ws, r1, c1, r2, c2): | |
| for r in range(r1, r2 + 1): | |
| for c in range(c1, c2 + 1): | |
| ws.cell(r, c).border = _BORDER_ALL | |
| def _ensure_table_grid(ws, header_row, col, rows_visible=12): | |
| """Force visible borders for the item table so LO prints them.""" | |
| c_first = col.get("item", 1) | |
| c_last = col.get("price", max(col.values())) | |
| start = header_row + 1 | |
| end = start + rows_visible | |
| # Give every cell a very light fill so LO doesn't drop empty borders | |
| for r in range(start, end + 1): | |
| for c in range(c_first, c_last + 1): | |
| ws.cell(r, c).fill = PatternFill("solid", fgColor=_WHITE) | |
| _border_range(ws, start, c_first, end, c_last) | |
| # Make header line a bit darker so it matches your template | |
| for c in range(c_first, c_last + 1): | |
| ws.cell(header_row, c).border = Border(bottom=Side(style="medium", color=_GRAY)) | |
| def _footer_band(ws, row_top, col_first=1, col_last=10): | |
| """Draw the green footer bar as cells (no shapes).""" | |
| # band 2 rows high | |
| for r in (row_top, row_top + 1): | |
| for c in range(col_first, col_last + 1): | |
| ws.cell(r, c).fill = PatternFill("solid", fgColor=_GREEN) | |
| ws.cell(r, c).border = Border(top=_THIN, bottom=_THIN) | |
| # centered white text in first row | |
| msg = "0417 664 190 | P.O. BOX 14, O’Halloran Hill, SA 5158 | www.springyconsultingservices.com" | |
| mid_col = (col_first + col_last) // 2 | |
| ws.merge_cells(start_row=row_top, start_column=col_first, end_row=row_top, end_column=col_last) | |
| cell = ws.cell(row_top, col_first) | |
| cell.value = msg | |
| cell.font = Font(bold=True, color=_WHITE) | |
| cell.alignment = Alignment(horizontal="center", vertical="center") | |
| reportlab_available = False | |
| try: | |
| from reportlab.lib.pagesizes import A4 | |
| from reportlab.pdfgen import canvas | |
| from reportlab.lib.units import mm | |
| from reportlab.lib import colors | |
| reportlab_available = True | |
| except Exception: | |
| pass | |
| def _render_pdf_reportlab(payload: Dict[str, Any], pdf_path: str) -> bool: | |
| if not reportlab_available: | |
| return False | |
| W, H = A4 | |
| margin = 15 * mm | |
| c = canvas.Canvas(pdf_path, pagesize=A4) | |
| # Header with logo (if exists) | |
| y = H - margin | |
| if os.path.exists(LOGO_PATH): | |
| try: | |
| c.drawImage(LOGO_PATH, margin, y - 50, width=W - 2*margin, height=40, preserveAspectRatio=True, mask='auto') | |
| y -= 60 | |
| except Exception: | |
| # Fallback to text header | |
| c.setFont("Helvetica-Bold", 20) | |
| c.setFillColor(colors.HexColor("#6FB643")) | |
| c.drawString(margin, y, "SPRINGY CONSULTING SERVICES") | |
| y -= 15 | |
| c.setFont("Helvetica-Bold", 10) | |
| c.drawString(margin, y, "HEAVY VEHICLE AUDITING & COMPLIANCE") | |
| y -= 25 | |
| else: | |
| # Text header | |
| c.setFont("Helvetica-Bold", 20) | |
| c.setFillColor(colors.HexColor("#6FB643")) | |
| c.drawString(margin, y, "SPRINGY CONSULTING SERVICES") | |
| y -= 15 | |
| c.setFont("Helvetica-Bold", 10) | |
| c.drawString(margin, y, "HEAVY VEHICLE AUDITING & COMPLIANCE") | |
| y -= 25 | |
| # Tax Invoice title | |
| c.setFillColor(colors.black) | |
| c.setFont("Helvetica-Bold", 16) | |
| c.drawString(margin, y, "Tax Invoice") | |
| y -= 25 | |
| # Invoice details (right aligned) | |
| c.setFont("Helvetica", 10) | |
| meta_lines = [ | |
| f"Invoice Date: {payload.get('invoice_date','')}", | |
| f"Invoice #: {payload.get('invoice_number','')}", | |
| "ABN: 646 382 464 92", | |
| ] | |
| for line in meta_lines: | |
| c.drawRightString(W - margin, y, line) | |
| y -= 12 | |
| y -= 10 | |
| # Get invoice type from payload | |
| inv_type = payload.get('inv_type', 'springy') # Default to springy if not specified | |
| # Customer block - behavior changes based on invoice type | |
| c.setFont("Helvetica-Bold", 10) | |
| c.drawString(margin, y, f"Customer: {payload.get('customer','Customer')}") | |
| y -= 12 | |
| # For Springy invoices - show customer details under Customer | |
| if inv_type == "springy": | |
| c.setFont("Helvetica", 9) | |
| for line in [payload.get("address",""), payload.get("email",""), payload.get("phone",""), payload.get("audit_date","")]: | |
| if line: | |
| c.drawString(margin, y, str(line)) | |
| y -= 11 | |
| y -= 15 | |
| # Table header | |
| c.setFont("Helvetica-Bold", 10) | |
| cols = [("Item", margin), ("Description", margin + 50), ("Qty/Hours", margin + 300), ("Unit Price", margin + 380), ("Price", margin + 460)] | |
| # Draw header background | |
| c.setFillColor(colors.lightgrey) | |
| c.rect(margin, y-15, W-2*margin, 15, fill=1, stroke=1) | |
| c.setFillColor(colors.black) | |
| for title, x in cols: | |
| c.drawString(x + 3, y - 12, title) | |
| y -= 20 | |
| # First table row - main item | |
| c.setFont("Helvetica", 10) | |
| modules = payload.get("modules", 1) | |
| unit = float(payload.get("unit_price", 0.0)) | |
| # Draw table row border | |
| c.rect(margin, y-15, W-2*margin, 15, fill=0, stroke=1) | |
| c.drawString(margin + 15, y - 12, str(modules)) | |
| c.drawString(margin + 53, y - 12, payload.get("audit_type","NHVR Audit")) | |
| c.drawString(margin + 315, y - 12, "1") | |
| c.drawRightString(margin + 430, y - 12, f"{unit:.2f}") | |
| c.drawRightString(margin + 510, y - 12, f"{unit:.2f}") | |
| y -= 20 | |
| # For Third Party invoices - show customer details in table rows | |
| if inv_type == "third_party": | |
| customer_details = [ | |
| f"{payload.get('customer','')} NHVR audit {payload.get('audit_date','')}".strip(), | |
| payload.get("address",""), | |
| payload.get("email",""), | |
| payload.get("phone","") | |
| ] | |
| for detail in customer_details: | |
| if detail: | |
| c.rect(margin, y-15, W-2*margin, 15, fill=0, stroke=1) | |
| c.setFont("Helvetica", 9) | |
| c.drawString(margin + 53, y - 12, detail) | |
| y -= 15 | |
| # Add empty rows to match Excel table structure (show complete table) | |
| empty_rows = 8 - (4 if inv_type == "third_party" else 0) # Adjust based on customer detail rows | |
| for i in range(empty_rows): | |
| c.rect(margin, y-15, W-2*margin, 15, fill=0, stroke=1) | |
| y -= 15 | |
| # Third-party admin fee (if applicable) | |
| admin = float(payload.get("admin_fee", 0.0) or 0.0) | |
| if admin != 0: | |
| c.rect(margin, y-15, W-2*margin, 15, fill=0, stroke=1) | |
| c.setFont("Helvetica", 10) | |
| c.drawString(margin + 53, y - 12, "JC Auditing administration fee") | |
| c.drawString(margin + 315, y - 12, "1") | |
| c.drawRightString(margin + 510, y - 12, f"{admin:.2f}") | |
| y -= 20 | |
| y -= 20 | |
| # Payment information section (left side) | |
| payment_y = y - 80 | |
| c.setFont("Helvetica-Bold", 10) | |
| c.drawString(margin, payment_y, "All payments can be made by direct deposit to the following") | |
| payment_y -= 20 | |
| c.drawString(margin, payment_y, "NAB") | |
| payment_y -= 12 | |
| c.setFont("Helvetica", 10) | |
| c.drawString(margin, payment_y, "BSB 085 005") | |
| payment_y -= 12 | |
| c.drawString(margin, payment_y, "Account 898 164 211") | |
| payment_y -= 20 | |
| c.drawString(margin, payment_y, "Invoice due in 14 days") | |
| payment_y -= 15 | |
| c.drawString(margin, payment_y, "contact@springyconsultingservices.com") | |
| # Totals box (right side) | |
| box_x = W - margin - 160 | |
| box_w = 160 | |
| box_y = y - 80 | |
| c.rect(box_x, box_y - 60, box_w, 60, stroke=1, fill=0) | |
| rows = [ | |
| ("Invoice Subtotal", float(payload.get("subtotal", 0.0))), | |
| ("Tax Rate", "10%"), | |
| ("GST", float(payload.get("gst", 0.0))), | |
| ("Total", float(payload.get("total", 0.0))), | |
| ] | |
| ty = box_y - 15 | |
| c.setFont("Helvetica", 9) | |
| for label, val in rows: | |
| c.drawString(box_x + 6, ty, label) | |
| if isinstance(val, (int, float)): | |
| c.drawRightString(box_x + box_w - 6, ty, f"{val:.2f}") | |
| else: | |
| c.drawRightString(box_x + box_w - 6, ty, str(val)) | |
| ty -= 14 | |
| # Thank you message | |
| c.setFont("Helvetica-Bold", 12) | |
| c.drawString(margin, payment_y - 25, "Thank you for your Business") | |
| # Footer band | |
| footer_y = 25 * mm | |
| c.setFillColor(colors.HexColor("#6FB643")) | |
| c.rect(0, footer_y - 8, W, 16, fill=1, stroke=0) | |
| c.setFillColor(colors.white) | |
| c.setFont("Helvetica-Bold", 10) | |
| c.drawCentredString(W/2, footer_y - 3, "0417 664 190 | P.O. BOX 14, O'Halloran Hill, SA 5158 | www.springyconsultingservices.com") | |
| c.showPage() | |
| c.save() | |
| return os.path.exists(pdf_path) | |
| def _replace_token(ws, token: str, value: str): | |
| tok = str(token).strip().lower() | |
| for row in ws.iter_rows(): | |
| for c in row: | |
| v = str(c.value).strip().lower() if c.value is not None else "" | |
| if v == tok: | |
| c.value = value | |
| def _place_logo(ws): | |
| """Embed the header logo across the invoice header band if logo.png exists.""" | |
| if os.path.exists(LOGO_PATH): | |
| img = XLImage(LOGO_PATH) | |
| img.height = 140 # larger | |
| img.width = 1100 # span full header | |
| ws.add_image(img, "A1") # assumes header row starts at row 1 | |
| def _nz(v): return "" if v is None else v | |
| def _named_cell(ws: Worksheet, name: str): | |
| try: | |
| dn = ws.parent.defined_names[name] | |
| except KeyError: | |
| return None | |
| for title, ref in dn.destinations: | |
| if title == ws.title: | |
| return ws[ref].cells[0] | |
| return None | |
| def _set_named(ws: Worksheet, name: str, value) -> bool: | |
| c = _named_cell(ws, name) | |
| if c is None: return False | |
| c.value = value | |
| return True | |
| def _find_row_exact(ws: Worksheet, label: str) -> Optional[int]: | |
| target = str(label).strip().lower() | |
| for r in ws.iter_rows(min_row=1, max_row=ws.max_row): | |
| for c in r: | |
| if str(c.value).strip().lower() == target: | |
| return c.row | |
| return None | |
| def _find_cell(ws: Worksheet, needle: str): | |
| patt = needle.lower() | |
| for row in ws.iter_rows(): | |
| for cell in row: | |
| v = str(cell.value).strip().lower() if cell.value is not None else "" | |
| if patt == v or patt in v: return cell | |
| return None | |
| def _header_map(ws: Worksheet) -> Tuple[int, dict]: | |
| hdr = _find_cell(ws, "Description") or _find_cell(ws, "Item") | |
| if not hdr: | |
| raise RuntimeError("Table header not found (need 'Item'/'Description').") | |
| row = hdr.row | |
| cols = {} | |
| for j in range(1, ws.max_column + 1): | |
| txt = str(ws.cell(row=row, column=j).value or "").strip().lower() | |
| if txt == "item": cols["item"] = j | |
| if "description" in txt: cols["desc"] = j | |
| if "qty" in txt or "hours" in txt: cols["qty"] = j | |
| if "unit price" in txt: cols["unit"] = j | |
| if txt == "price" or "price" in txt: cols["price"] = j | |
| return row, cols | |
| def _clear_data(ws: Worksheet, start_row: int, col_first: int, col_last: int): | |
| stop = _find_row_exact(ws, "Invoice Subtotal") or ws.max_row | |
| for r in range(start_row, max(start_row, stop)): | |
| for c in range(col_first, col_last + 1): | |
| ws.cell(row=r, column=c).value = None | |
| # clear named TP notes if present | |
| for nm in ["TP_NOTE1","TP_NOTE2","TP_NOTE3","TP_NOTE4"]: | |
| c = _named_cell(ws, nm) | |
| if c: c.value = None | |
| def _apply_print_setup(ws: Worksheet): | |
| # Respect template Print_Area if present | |
| try: | |
| dn = ws.parent.defined_names.get('_xlnm.Print_Area') or ws.parent.defined_names.get('Print_Area') | |
| if dn: | |
| for title, ref in dn.destinations: | |
| if title == ws.title: | |
| ws.print_area = ref | |
| break | |
| except Exception: | |
| pass | |
| # If not set by template, cover the whole layout explicitly | |
| if not ws.print_area: | |
| ws.print_area = "A1:L110" # <<< match the “L110” used above | |
| # Fit the whole area on a single A4 page | |
| ws.sheet_properties.pageSetUpPr.fitToPage = True | |
| ps = ws.page_setup | |
| ps.orientation = "portrait" | |
| ps.paperSize = ws.PAPERSIZE_A4 | |
| ps.fitToWidth = 1 | |
| ps.fitToHeight = 1 | |
| ps.scale = None | |
| # Clean output | |
| ws.print_options.gridLines = False | |
| ws.sheet_view.showGridLines = False | |
| ws.page_margins = PageMargins(left=0.3, right=0.3, top=0.5, bottom=0.5, header=0.25, footer=0.25) | |
| # ---------- date / numbering ---------- | |
| def today_str() -> str: | |
| if sys.platform == "win32": | |
| return datetime.date.today().strftime("%#d/%#m/%Y") | |
| return datetime.date.today().strftime("%-d/%-m/%Y") | |
| def new_invoice_code(inv_type: str) -> str: | |
| prefix = "SPR" if inv_type == "springy" else "TP" | |
| return f"{prefix}#{random.randint(10000, 99999)}" | |
| # ---------- filenames ---------- | |
| def _safe_filename(s: str) -> str: | |
| s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode() | |
| s = re.sub(r"[^\w\s.-]", "", s) | |
| s = re.sub(r"\s+", " ", s).strip() | |
| return s | |
| def _friendly_pdf_name(inv_type: str, customer: str, dt: datetime.date) -> str: | |
| date_token = dt.strftime("%d%m%Y") # DDMMYYYY | |
| head = "Springy Invoice " | |
| mid = "JCAuditing " if inv_type == "third_party" else "" | |
| tail = " NHVR Audit report " | |
| return _safe_filename(f"{head}{mid}{customer}{tail}{date_token}") + ".pdf" | |
| # ---------- readers ---------- | |
| def read_pdf(path: str) -> str: | |
| if not pdf_available: return "" | |
| try: | |
| out = [] | |
| with open(path, "rb") as f: | |
| r = PyPDF2.PdfReader(f) | |
| for p in r.pages: | |
| out.append(p.extract_text() or "") | |
| return "\n".join(out) | |
| except Exception: | |
| return "" | |
| def read_docx_plain(path: str) -> str: | |
| if not docx_available: | |
| return "" # consider logging: "python-docx not installed" | |
| try: | |
| d = docx.Document(path) | |
| parts = [] | |
| # Body paragraphs | |
| parts += [p.text.strip() for p in d.paragraphs if p.text and p.text.strip()] | |
| # Tables: join first two cells as "Label: Value" to satisfy your regex | |
| for tbl in d.tables: | |
| for row in tbl.rows: | |
| cells = [c.text.strip() for c in row.cells] | |
| cells = [c for c in cells if c] # drop empties | |
| if not cells: | |
| continue | |
| if len(cells) >= 2: | |
| parts.append(f"{cells[0]}: {cells[1]}") | |
| else: | |
| parts.append(cells[0]) | |
| # Headers and footers (often hold contact blocks) | |
| for s in d.sections: | |
| parts += [p.text.strip() for p in s.header.paragraphs if p.text and p.text.strip()] | |
| parts += [p.text.strip() for p in s.footer.paragraphs if p.text and p.text.strip()] | |
| # Normalize spacing so ^|\n anchors work | |
| text = "\n".join(parts) | |
| text = re.sub(r"[ \t]+", " ", text) | |
| text = re.sub(r"\r?\n[ \t]*", "\n", text) | |
| return text | |
| except Exception: | |
| return "" | |
| def read_report(file_obj) -> str: | |
| if file_obj is None: return "" | |
| path = getattr(file_obj, "name", None) or (file_obj if isinstance(file_obj, str) else None) | |
| if not path: return "" | |
| ext = os.path.splitext(path)[1].lower() | |
| if ext == ".pdf": return read_pdf(path) | |
| if ext == ".docx": return read_docx_plain(path) | |
| return "" | |
| # ---------- parsing (Page 1 tables) ---------- | |
| LAB = { | |
| "date": r"(?:^|\n)\s*date\s*of\s*audit\s*[:\-]?\s*(.+)", | |
| "name": r"(?:^|\n)\s*operator\s*name(?:\s*\(.*?legal\s*entity.*?\))?\s*[:\-]?\s*(.+)", | |
| "addr": r"(?:^|\n)\s*operator\s*business\s*address\s*[:\-]?\s*(.+)", | |
| "email": r"(?:^|\n)\s*email\s*address\s*[:\-]?\s*([^\s]+@[^\s]+)", | |
| "phone": r"(?:^|\n)\s*operator\s*telephone\s*number\s*[:\-]?\s*([\d\+\s\-()]+)", | |
| } | |
| _EMAIL_RE = r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" | |
| _PHONE_RE = r"\+?\d[\d\s\-()]{7,}" | |
| def _first(pat: str, text: str) -> Optional[str]: | |
| m = re.search(pat, text, re.I) | |
| if m: | |
| return (m.group(1) or "").strip() | |
| return None | |
| def parse_audit_date(t: str) -> Optional[str]: | |
| v = _first(LAB["date"], t) | |
| if v: return v | |
| m = re.search(r"\b(\d{1,2}\s*(?:st|nd|rd|th)?\s+[A-Za-z]+\.?\s+\d{4})\b", t) | |
| if m: return m.group(1) | |
| m = re.search(r"\b(\d{1,2}/\d{1,2}/\d{2,4})\b", t) | |
| return m.group(1) if m else None | |
| def parse_operator_name(t: str) -> Optional[str]: | |
| v = _first(LAB["name"], t) | |
| if v: return v | |
| v = _first(r"(?:^|\n)\s*(?:operator|customer|client|company\s*name)\s*[:\-]?\s*(.+)", t) | |
| return v | |
| def parse_operator_address(t: str) -> Optional[str]: | |
| v = _first(LAB["addr"], t) | |
| if v: return v | |
| v = _first(r"(?:^|\n)\s*address\s*[:\-]?\s*(.+)", t) | |
| return v | |
| def parse_operator_email(t: str) -> Optional[str]: | |
| v = _first(LAB["email"], t) | |
| if v: return v | |
| m = re.search(_EMAIL_RE, t) | |
| return m.group(0) if m else None | |
| def parse_operator_phone(t: str) -> Optional[str]: | |
| v = _first(LAB["phone"], t) | |
| if v: return v | |
| m = re.search(_PHONE_RE, t) | |
| return m.group(0) if m else None | |
| def parse_audit_type(t: str) -> str: | |
| choices = [ | |
| "NHVR Maintenance Audit","NHVR Mass Audit","NHVR Fatigue Audit", | |
| "NHVR Maintenance & Mass Audit","NHVR Maintenance & Fatigue Audit", | |
| "NHVR Maintenance, Mass & Fatigue Audit","NHVR Mass & Fatigue Audit", | |
| "Accreditation Manual NHVR & WA Main Roads","Policy & Procedure Manual NHVR", | |
| "Policy & Procedure Manual WA Main roads","Compliance","Consulting", | |
| "WA Maintenance, Fatigue, Dimensions & Loading Audit", | |
| "WA Maintenance, Fatigue, Dimensions & Loading, Mass Audit", | |
| "Submission of NHVR audit summary report","Pre Trip Inspection Books", | |
| ] | |
| low = t.lower() | |
| for ch in choices: | |
| if ch.lower() in low: return ch | |
| m = re.search(r"NHVR\s+(Maintenance|Mass|Fatigue)", t, re.I) | |
| return f"NHVR {m.group(1).title()} Audit" if m else "NHVR Audit" | |
| def parse_modules(t: str) -> int: | |
| m = re.search(r"\b([1-4])\s+modules?\b", t, re.I) | |
| if m: return int(m.group(1)) | |
| at = parse_audit_type(t).lower() | |
| if "maintenance, mass & fatigue" in at: return 3 | |
| if "&" in at: | |
| parts = [p for p in re.split(r"&|,", at) if any(k in p for k in ["maintenance","mass","fatigue"])] | |
| return max(1, min(4, len(parts))) | |
| return 1 | |
| # ---------- pricing ---------- | |
| def load_pricing() -> Dict[int, float]: | |
| tiers = {1: 650.0, 2: 750.0, 3: 850.0, 4: 950.0} | |
| if os.path.exists(INPUTS_DOCX) and docx_available: | |
| try: | |
| txt = read_docx_plain(INPUTS_DOCX) | |
| for m in re.finditer(r"\b([1-4])\s*Modules?\D+\$?\s*([0-9]{3,4})", txt, re.I): | |
| tiers[int(m.group(1))] = float(m.group(2)) | |
| except Exception: | |
| pass | |
| return tiers | |
| def compute_totals(modules: int, inv_type: str, unit: float) -> Tuple[float,float,float,float]: | |
| admin = -100.0 if inv_type == "third_party" else 0.0 | |
| subtotal = unit + admin | |
| gst = round(subtotal * 0.10, 2) | |
| total = round(subtotal + gst, 2) | |
| return admin, subtotal, gst, total | |
| # ---------- excel writer ---------- | |
| def write_invoice_to_xlsx(template_path: str, out_path: str, payload: Dict[str, Any], inv_type: str): | |
| wb = load_workbook(template_path) | |
| ws = wb.active | |
| # ---- 1) Header fields: try named ranges (old behavior), else tokens (new templates) ---- | |
| if not _set_named(ws, "INVOICE_DATE", _nz(payload["invoice_date"])): | |
| c = _find_cell(ws, "Invoice Date"); c and ws.cell(c.row, c.column+1, _nz(payload["invoice_date"])) | |
| if not _set_named(ws, "INVOICE_NO", _nz(payload["invoice_number"])): | |
| c = _find_cell(ws, "Invoice #"); c and ws.cell(c.row, c.column+1, _nz(payload["invoice_number"])) | |
| if not _set_named(ws, "CUSTOMER", _nz(payload["customer"])): | |
| c = _find_cell(ws, "Customer"); c and ws.cell(c.row, c.column+1, _nz(payload["customer"])) | |
| _set_named(ws, "ABN_CELL", _nz(payload.get("abn", "646 382 464 92"))) | |
| if inv_type == "springy": | |
| _set_named(ws, "EMAIL_CELL", _nz(payload.get("email", ""))) | |
| _set_named(ws, "PHONE_CELL", _nz(payload.get("phone", ""))) | |
| _set_named(ws, "ADDRESS_CELL", _nz(payload.get("address", ""))) | |
| # token replacements (clean templates with placeholders) | |
| #_replace_token(ws, "CUSTOMER", _nz(payload["customer"])) | |
| _replace_token(ws, "ADDRESS_CELL", _nz(payload.get("address", ""))) | |
| _replace_token(ws, "EMAIL_CELL", _nz(payload.get("email", ""))) | |
| _replace_token(ws, "PHONE_CELL", _nz(payload.get("phone", ""))) | |
| _replace_token(ws, "INVOICE_DATE", _nz(payload["invoice_date"])) | |
| _replace_token(ws, "INVOICE_NO", _nz(payload["invoice_number"])) | |
| _replace_token(ws, "ABN_CELL", _nz(payload.get("abn", "646 382 464 92"))) | |
| # ---- 2) Table region (clear, then write primary line) ---- | |
| header_row, col = _header_map(ws) | |
| data_row = header_row + 1 | |
| c_first = col.get("item", 1) | |
| c_last = col.get("price", max(col.values())) | |
| _clear_data(ws, data_row, c_first, c_last) | |
| r = data_row | |
| ws.cell(row=r, column=c_first).value = payload["modules"] | |
| ws.cell(row=r, column=col["desc"]).value = payload["audit_type"] | |
| ws.cell(row=r, column=col["qty"]).value = 1 | |
| ws.cell(row=r, column=col["unit"]).value = payload["unit_price"] | |
| ws.cell(row=r, column=col["price"]).value= payload["unit_price"] | |
| _ensure_table_grid(ws, header_row, col, rows_visible=12) | |
| # Also expose as tokens for very simple templates (optional) | |
| _replace_token(ws, "AUDIT_TYPE", _nz(payload.get("audit_type", "NHVR Audit"))) | |
| _replace_token(ws, "MODULES", str(payload.get("modules", ""))) | |
| _replace_token(ws, "UNIT_PRICE", f"{payload.get('unit_price', 0):.2f}") | |
| # ---- 3) Third-party notes + admin fee ---- | |
| if inv_type == "third_party": | |
| notes = [ | |
| f"{payload['customer']} NHVR audit {payload.get('audit_date','')}".strip(), | |
| _nz(payload.get("address","")), | |
| _nz(payload.get("email","")), | |
| _nz(payload.get("phone","")), | |
| ] | |
| wrote_named = all(_set_named(ws, f"TP_NOTE{i+1}", notes[i]) for i in range(4)) | |
| if not wrote_named: | |
| for i, txt in enumerate(notes, start=1): | |
| ws.cell(row=data_row + i, column=col["desc"]).value = txt | |
| if payload.get("admin_fee", 0): | |
| row_fee = data_row + 6 | |
| ws.cell(row=row_fee, column=col["desc"]).value = "JC Auditing administration fee" | |
| ws.cell(row=row_fee, column=col["qty"]).value = 1 | |
| ws.cell(row=row_fee, column=col["price"]).value = payload["admin_fee"] | |
| _replace_token(ws, "ADMIN_FEE", f"{payload.get('admin_fee', 0):.2f}") | |
| # ---- 4) Totals ---- | |
| _set_named(ws, "SUBTOTAL_CELL", payload["subtotal"]) | |
| _set_named(ws, "GST_CELL", payload["gst"]) | |
| _set_named(ws, "TOTAL_CELL", payload["total"]) | |
| _replace_token(ws, "SUBTOTAL", f"{payload.get('subtotal', 0):.2f}") | |
| #_replace_token(ws, "GST", f"{payload.get('gst', 0):.2f}") | |
| #_replace_token(ws, "TOTAL", f"{payload.get('total', 0):.2f}") | |
| # ---- 5) Logo + print setup ---- | |
| _place_logo(ws) | |
| _apply_print_setup(ws) | |
| # --- FORCE USED RANGE TO INCLUDE FULL TEMPLATE (so LO doesn't crop) --- | |
| ws["L110"].value = "•" # any tiny char | |
| ws["L110"].font = Font(color="FFFFFF", size=1) # invisible in print | |
| # ---------------------------------------------------------------------- | |
| # draw a footer bar ~ at the bottom of page | |
| footer_row = (ws.max_row if ws.max_row > 60 else 60) # push low on page | |
| _footer_band(ws, footer_row, col_first=1, col_last=max(col.values())) | |
| # make sure it's inside the printed area | |
| ws.print_area = f"A1:{get_column_letter(max(col.values()))}{footer_row+1}" | |
| wb.save(out_path) | |
| return out_path | |
| # ---------- xlsx -> pdf ---------- | |
| def _soffice_path() -> Optional[str]: | |
| candidates = [ | |
| shutil.which("soffice"), | |
| "/Applications/LibreOffice.app/Contents/MacOS/soffice", | |
| "C:\\Program Files\\LibreOffice\\program\\soffice.exe", | |
| "C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe", | |
| ] | |
| return next((p for p in candidates if p and os.path.exists(p)), None) | |
| def _convert_with_libreoffice(xlsx_path: str, pdf_path: str) -> bool: | |
| soffice = _soffice_path() | |
| if not soffice: | |
| return False | |
| try: | |
| # calc_pdf_Export with defaults reliably keeps drawing layer | |
| cmd = [ | |
| soffice, "--headless", "--nologo", "--nofirststartwizard", | |
| "--convert-to", "pdf:calc_pdf_Export", | |
| "--outdir", os.path.dirname(pdf_path), | |
| os.path.abspath(xlsx_path), | |
| ] | |
| r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90) | |
| out_pdf = os.path.splitext(os.path.abspath(xlsx_path))[0] + ".pdf" | |
| if r.returncode == 0 and os.path.exists(out_pdf): | |
| os.replace(out_pdf, pdf_path) | |
| return True | |
| except Exception: | |
| pass | |
| return False | |
| def _convert_with_excel_win32(xlsx_path: str, pdf_path: str) -> bool: | |
| if not win32_available: return False | |
| try: | |
| excel = win32com.client.DispatchEx("Excel.Application") | |
| excel.Visible = False | |
| wb = excel.Workbooks.Open(os.path.abspath(xlsx_path)) | |
| for sh in wb.Worksheets: | |
| sh.PageSetup.Zoom = False | |
| sh.PageSetup.FitToPagesWide = 1 | |
| sh.PageSetup.FitToPagesTall = False | |
| wb.ExportAsFixedFormat(0, os.path.abspath(pdf_path)) | |
| wb.Close(False); excel.Quit() | |
| return os.path.exists(pdf_path) | |
| except Exception: | |
| try: excel.Quit() | |
| except Exception: pass | |
| return False | |
| def xlsx_to_pdf(xlsx_path: str, pdf_path: str) -> str: | |
| # Try native converters first | |
| if _convert_with_excel_win32(xlsx_path, pdf_path): return pdf_path | |
| if _convert_with_libreoffice(xlsx_path, pdf_path): return pdf_path | |
| # Fallback: draw a clean PDF with ReportLab | |
| if _render_pdf_reportlab(_last_payload_cache or {}, pdf_path): | |
| return pdf_path | |
| return "" # no PDF available | |
| # ---------- API ---------- | |
| _last_payload_cache: Dict[str, Any] = {} | |
| def do_parse(file_obj): | |
| t = read_report(file_obj) | |
| inv_type = "third_party" if re.search(r"\b(third[- ]?party|kick ?back|referral|jc auditing)\b", t, re.I) else "springy" | |
| modules = parse_modules(t) or 1 | |
| audit_type = parse_audit_type(t) | |
| audit_date = parse_audit_date(t) or "" | |
| name = parse_operator_name(t) or "" | |
| address = parse_operator_address(t) or "" | |
| email = parse_operator_email(t) or "" | |
| phone = parse_operator_phone(t) or "" | |
| meta = json.dumps({ | |
| "detected_type":inv_type, | |
| "modules":modules, | |
| "audit_type":audit_type, | |
| "audit_date":audit_date | |
| }, ensure_ascii=False) | |
| return meta, inv_type, modules, audit_type, audit_date, name, address, email, phone | |
| def do_generate(file_obj, inv_type, modules, audit_type, audit_date, name, address, email, phone): | |
| tiers = load_pricing() | |
| unit = tiers.get(int(modules or 1), 650.0) | |
| inv_num = new_invoice_code(inv_type) | |
| inv_dt = today_str() | |
| admin, subtotal, gst, total = compute_totals(int(modules or 1), inv_type, unit) | |
| # In do_generate function, add this line to the payload: | |
| payload = { | |
| "invoice_number": inv_num, | |
| "invoice_date": inv_dt, | |
| "customer": name or "Customer", | |
| "address": address or "", | |
| "email": email or "", | |
| "phone": phone or "", | |
| "audit_type": audit_type or "NHVR Audit", | |
| "modules": int(modules or 1), | |
| "unit_price": unit, | |
| "admin_fee": admin, | |
| "subtotal": subtotal, | |
| "gst": gst, | |
| "total": total, | |
| "tax_rate": "10%", | |
| "abn": "646 382 464 92", | |
| "audit_date": audit_date or "", | |
| "inv_type": inv_type, # <-- ADD THIS LINE | |
| } | |
| global _last_payload_cache | |
| _last_payload_cache = payload.copy() | |
| template = TEMPLATE_JC if inv_type == "third_party" else TEMPLATE_SPRINGY | |
| excel_out = os.path.join(OUT_DIR, f"{inv_num}.xlsx") | |
| write_invoice_to_xlsx(template, excel_out, payload, inv_type) | |
| pdf_filename = _friendly_pdf_name(inv_type, payload["customer"], datetime.date.today()) | |
| pdf_out = os.path.join(OUT_DIR, pdf_filename) | |
| produced = xlsx_to_pdf(excel_out, pdf_out) | |
| pdf_final = produced if produced and os.path.exists(produced) else None | |
| meta = json.dumps({ | |
| "invoice_number": inv_num, "invoice_date": inv_dt, "type": inv_type, | |
| "modules": modules, "subtotal": subtotal, "gst": gst, "total": total, | |
| "pdf": pdf_filename if pdf_final else "" | |
| }, ensure_ascii=False) | |
| return meta, excel_out, pdf_final | |