NHVAS_Quote_Generator / invoice.py
Shami96's picture
Update invoice.py
1adc50b verified
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