Files
clientflow_backend/scripts/audit_jasmin_converted_invoice_format.py

439 lines
19 KiB
Python
Executable File

#!/usr/bin/env python3
"""Audit Jasmin invoices created by converting quotations.
This script is diagnostic only. It does not modify ClientFlow or Jasmin data.
It compares local commercial_documents, remote Jasmin document details, and the
PDF returned by the Jasmin print endpoint for converted invoices.
Main goal: detect why invoices converted from quotations may have unexpected
PDF format/size/layout.
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import hashlib
import json
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from sqlalchemy import text
from app.db import engine
from app.jasmin_client import JasminClient
from app.commercial_service import update_commercial_document_details
A4_PORTRAIT_PT = (595.0, 842.0)
A4_LANDSCAPE_PT = (842.0, 595.0)
POINT_TO_MM = 25.4 / 72.0
def _s(value: Any) -> str:
return str(value or "").strip()
def _json_default(value: Any) -> str:
try:
return value.isoformat() # type: ignore[attr-defined]
except Exception:
return str(value)
def _payload(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if not value:
return {}
try:
return json.loads(value)
except Exception:
return {}
def _dig(data: Any, *path: str) -> Any:
cur = data
for key in path:
if isinstance(cur, dict):
cur = cur.get(key)
else:
return None
return cur
def _first(data: dict[str, Any], keys: Iterable[str]) -> Any:
for key in keys:
if data.get(key) not in (None, ""):
return data.get(key)
return None
def _money(value: Any) -> str:
if isinstance(value, dict):
for k in ("amount", "value", "baseAmount", "reportingAmount"):
if value.get(k) is not None:
return _s(value.get(k))
return _s(value)
def _selected_jasmin_fields(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
return {"raw_type": type(raw).__name__, "raw_preview": _s(raw)[:300]}
return {
"id": _first(raw, ["id", "key"]),
"naturalKey": raw.get("naturalKey"),
"documentNumber": raw.get("documentNumber"),
"documentType": _first(raw, ["documentType", "documentTypeKey"]),
"serie": _first(raw, ["serie", "serieKey"]),
"seriesNumber": _first(raw, ["seriesNumber", "number"]),
"company": _first(raw, ["company", "companyKey"]),
"documentDate": raw.get("documentDate"),
"postingDate": raw.get("postingDate"),
"currency": _first(raw, ["currency", "currencyKey"]),
"totalAmount": _money(raw.get("totalAmount")),
"payableAmount": _money(raw.get("payableAmount")),
"taxExclusiveAmount": _money(raw.get("taxExclusiveAmount")),
"buyerCustomerParty": _first(raw, ["buyerCustomerParty", "buyerCustomerPartyKey"]),
"buyerCustomerPartyName": raw.get("buyerCustomerPartyName"),
"paymentTerm": _first(raw, ["paymentTerm", "paymentTermKey"]),
"paymentMethod": _first(raw, ["paymentMethod", "paymentMethodKey"]),
"printLayout": _first(raw, ["printLayout", "report", "reportName", "reportKey", "documentReport", "printTemplate"]),
"schema_hint_keys": sorted([k for k in raw.keys() if re.search(r"print|report|layout|template|format|paper|serie|type", k, re.I)])[:50],
"line_count": len(raw.get("documentLines") or raw.get("lines") or []) if isinstance(raw.get("documentLines") or raw.get("lines") or [], list) else None,
}
def _extract_pdf_boxes(data: bytes) -> list[dict[str, Any]]:
# Works for the normal uncompressed page dictionaries returned by Jasmin.
# It is intentionally dependency-free for production servers.
latin = data.decode("latin-1", errors="ignore")
boxes: list[dict[str, Any]] = []
pattern = re.compile(
r"/(MediaBox|CropBox)\s*\[\s*([-+0-9.]+)\s+([-+0-9.]+)\s+([-+0-9.]+)\s+([-+0-9.]+)\s*\]",
re.I,
)
seen: set[tuple[str, float, float, float, float]] = set()
for m in pattern.finditer(latin):
kind = m.group(1)
x0, y0, x1, y1 = (float(m.group(i)) for i in range(2, 6))
key = (kind.lower(), x0, y0, x1, y1)
if key in seen:
continue
seen.add(key)
w = abs(x1 - x0)
h = abs(y1 - y0)
boxes.append({
"kind": kind,
"width_pt": round(w, 2),
"height_pt": round(h, 2),
"width_mm": round(w * POINT_TO_MM, 1),
"height_mm": round(h * POINT_TO_MM, 1),
"orientation": "landscape" if w > h else "portrait",
"looks_a4": _looks_a4(w, h),
})
return boxes
def _looks_a4(w: float, h: float, tolerance_pt: float = 20.0) -> bool:
pairs = [(w, h), (h, w)]
return any(abs(a - A4_PORTRAIT_PT[0]) <= tolerance_pt and abs(b - A4_PORTRAIT_PT[1]) <= tolerance_pt for a, b in pairs)
def _pdf_info(data: bytes, content_type: str) -> dict[str, Any]:
info = {
"content_type": content_type,
"bytes": len(data),
"sha256_12": hashlib.sha256(data).hexdigest()[:12] if data else "",
"is_pdf_header": data[:5] == b"%PDF-",
"header_preview": data[:80].decode("latin-1", errors="replace"),
"page_count_hint": None,
"boxes": [],
"format_warning": "",
}
if not data:
info["format_warning"] = "empty_response"
return info
if not info["is_pdf_header"]:
info["format_warning"] = "not_pdf_header"
return info
latin = data.decode("latin-1", errors="ignore")
# Count '/Type /Page' but not '/Type /Pages'.
page_count = len(re.findall(r"/Type\s*/Page(?!s)\b", latin))
info["page_count_hint"] = page_count or None
boxes = _extract_pdf_boxes(data)
info["boxes"] = boxes
if boxes and not any(bool(b.get("looks_a4")) for b in boxes if b.get("kind", "").lower() == "mediabox"):
info["format_warning"] = "non_a4_or_unexpected_mediabox"
elif len(data) < 15_000:
info["format_warning"] = "very_small_pdf"
elif len(data) > 2_500_000:
info["format_warning"] = "very_large_pdf"
return info
def _query_candidates(args: argparse.Namespace) -> list[dict[str, Any]]:
where = ["inv.document_kind = 'invoice'", "inv.system = 'jasmin'"]
params: dict[str, Any] = {"limit": args.limit}
if args.opportunity_id:
where.append("inv.opportunity_id = CAST(:opportunity_id AS UUID)")
params["opportunity_id"] = args.opportunity_id
if args.document:
where.append("(inv.document_number = :document OR inv.external_id = :document)")
params["document"] = args.document
if args.days and not args.document and not args.opportunity_id:
where.append("inv.created_at >= now() - (:days * INTERVAL '1 day')")
params["days"] = int(args.days)
if not args.include_all_invoices:
where.append("inv.parent_document_id IS NOT NULL")
where_sql = " AND ".join(where)
with engine.begin() as conn:
rows = conn.execute(text(f"""
SELECT
inv.id::text AS invoice_doc_id,
inv.opportunity_id::text AS opportunity_id,
inv.external_id AS invoice_external_id,
inv.document_number AS invoice_number,
inv.document_type AS invoice_type,
inv.serie AS invoice_serie,
inv.series_number AS invoice_series_number,
inv.status AS invoice_status,
inv.total_amount AS invoice_total,
inv.currency AS invoice_currency,
inv.payload AS invoice_payload,
inv.created_at AS invoice_created_at,
q.id::text AS quotation_doc_id,
q.external_id AS quotation_external_id,
q.document_number AS quotation_number,
q.document_type AS quotation_type,
q.serie AS quotation_serie,
q.total_amount AS quotation_total,
q.payload AS quotation_payload,
o.title AS opportunity_title,
o.customer_name AS opportunity_customer_name,
c.name AS fiscal_customer_name,
c.tax_id AS fiscal_customer_tax_id
FROM commercial_documents inv
LEFT JOIN commercial_documents q ON q.id = inv.parent_document_id
LEFT JOIN opportunities o ON o.id = inv.opportunity_id
LEFT JOIN customers c ON c.id = inv.customer_id
WHERE {where_sql}
ORDER BY inv.created_at DESC
LIMIT :limit
"""), params).mappings().all()
return [dict(r) for r in rows]
def _mark_local_pdf_format(result: dict[str, Any]) -> None:
document_id = _s(result.get("invoice_doc_id"))
if not document_id:
return
info = result.get("invoice_pdf") if isinstance(result.get("invoice_pdf"), dict) else {}
warning = _s((info or {}).get("format_warning"))
boxes = (info or {}).get("boxes") or []
first_box = ""
if boxes:
b = boxes[0]
first_box = f"{b.get('kind')} {b.get('width_mm')}x{b.get('height_mm')}mm a4={b.get('looks_a4')}"
blocked = warning in {"not_pdf_header", "empty_response", "non_a4_or_unexpected_mediabox"}
update_commercial_document_details(document_id, {"payload": {
"clientflow_pdf_format": info or {},
"clientflow_pdf_format_warning": warning,
"clientflow_pdf_first_box": first_box,
"clientflow_pdf_send_blocked": blocked,
"clientflow_pdf_format_checked_by": "audit_jasmin_converted_invoice_format",
}})
async def _audit_one(row: dict[str, Any], *, client: JasminClient, save_dir: Path | None, compare_quote_pdf: bool) -> dict[str, Any]:
invoice_external_id = _s(row.get("invoice_external_id"))
quotation_external_id = _s(row.get("quotation_external_id"))
result: dict[str, Any] = {
**{k: _json_default(v) for k, v in row.items() if not k.endswith("_payload")},
"invoice_local_payload_keys": sorted(_payload(row.get("invoice_payload")).keys()),
"quotation_local_payload_keys": sorted(_payload(row.get("quotation_payload")).keys()),
"remote_invoice": {},
"remote_quotation": {},
"invoice_pdf": {},
"quotation_pdf": {},
"finding": "OK",
"finding_detail": "",
}
try:
invoice_raw = await client.get_invoice(invoice_external_id)
result["remote_invoice"] = _selected_jasmin_fields(invoice_raw)
except Exception as exc:
result["finding"] = "REMOTE_INVOICE_ERROR"
result["finding_detail"] = str(exc)
return result
if quotation_external_id:
try:
quotation_raw = await client.get_quotation(quotation_external_id)
result["remote_quotation"] = _selected_jasmin_fields(quotation_raw)
except Exception as exc:
result["remote_quotation_error"] = str(exc)
try:
pdf, content_type = await client.print_invoice_pdf(invoice_external_id)
result["invoice_pdf"] = _pdf_info(pdf, content_type)
if save_dir:
save_dir.mkdir(parents=True, exist_ok=True)
name = re.sub(r"[^A-Za-z0-9_.-]+", "_", _s(row.get("invoice_number")) or invoice_external_id)
(save_dir / f"invoice_{name}.pdf").write_bytes(pdf)
except Exception as exc:
result["finding"] = "INVOICE_PDF_ERROR"
result["finding_detail"] = str(exc)
return result
if compare_quote_pdf and quotation_external_id:
try:
pdf, content_type = await client.print_quotation_pdf(quotation_external_id)
result["quotation_pdf"] = _pdf_info(pdf, content_type)
if save_dir:
save_dir.mkdir(parents=True, exist_ok=True)
name = re.sub(r"[^A-Za-z0-9_.-]+", "_", _s(row.get("quotation_number")) or quotation_external_id)
(save_dir / f"quotation_{name}.pdf").write_bytes(pdf)
except Exception as exc:
result["quotation_pdf_error"] = str(exc)
inv_pdf = result.get("invoice_pdf") or {}
warning = _s(inv_pdf.get("format_warning"))
inv_remote = result.get("remote_invoice") or {}
if warning:
result["finding"] = warning.upper()
result["finding_detail"] = f"PDF warning: {warning}"
elif not inv_remote.get("documentType") or not inv_remote.get("serie"):
result["finding"] = "MISSING_REMOTE_TYPE_OR_SERIE"
result["finding_detail"] = "GET invoice did not expose documentType/serie; check Jasmin payload."
else:
# If the remote response exposes print/report/layout-like keys, surface them.
hints = inv_remote.get("schema_hint_keys") or []
if hints:
result["finding_detail"] = "Remote invoice exposes layout/report-related keys: " + ", ".join(hints[:10])
return result
def _write_outputs(results: list[dict[str, Any]], prefix: str) -> dict[str, str]:
json_path = f"{prefix}.json"
md_path = f"{prefix}.md"
csv_path = f"{prefix}.csv"
Path(json_path).write_text(json.dumps(results, ensure_ascii=False, indent=2, default=_json_default), encoding="utf-8")
flat_rows = []
for r in results:
inv_pdf = r.get("invoice_pdf") or {}
boxes = inv_pdf.get("boxes") or []
first_box = boxes[0] if boxes else {}
flat_rows.append({
"finding": r.get("finding"),
"finding_detail": r.get("finding_detail"),
"opportunity_id": r.get("opportunity_id"),
"opportunity_title": r.get("opportunity_title"),
"fiscal_customer_name": r.get("fiscal_customer_name"),
"invoice_number": r.get("invoice_number"),
"invoice_external_id": r.get("invoice_external_id"),
"invoice_type": r.get("invoice_type"),
"invoice_serie": r.get("invoice_serie"),
"remote_invoice_type": _dig(r, "remote_invoice", "documentType"),
"remote_invoice_serie": _dig(r, "remote_invoice", "serie"),
"invoice_pdf_content_type": inv_pdf.get("content_type"),
"invoice_pdf_bytes": inv_pdf.get("bytes"),
"invoice_pdf_is_pdf_header": inv_pdf.get("is_pdf_header"),
"invoice_pdf_page_count_hint": inv_pdf.get("page_count_hint"),
"first_box_kind": first_box.get("kind"),
"first_box_width_mm": first_box.get("width_mm"),
"first_box_height_mm": first_box.get("height_mm"),
"first_box_looks_a4": first_box.get("looks_a4"),
"quotation_number": r.get("quotation_number"),
})
fieldnames = list(flat_rows[0].keys()) if flat_rows else ["finding"]
with open(csv_path, "w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(flat_rows)
lines = ["# Auditoria formato PDF faturas Jasmin convertidas", ""]
for r in results:
inv_pdf = r.get("invoice_pdf") or {}
boxes = inv_pdf.get("boxes") or []
box_text = "; ".join(f"{b.get('kind')} {b.get('width_mm')}x{b.get('height_mm')}mm a4={b.get('looks_a4')}" for b in boxes[:3]) or ""
lines.extend([
f"## {r.get('invoice_number') or r.get('invoice_external_id')}",
f"- Finding: **{r.get('finding')}** {r.get('finding_detail') or ''}",
f"- Oportunidade: `{r.get('opportunity_id')}` · {r.get('opportunity_title')}",
f"- Cliente fiscal: {r.get('fiscal_customer_name') or ''} · NIF {r.get('fiscal_customer_tax_id') or ''}",
f"- Invoice local: type={r.get('invoice_type') or ''} serie={r.get('invoice_serie') or ''} total={r.get('invoice_total') or ''}",
f"- Invoice remoto: type={_dig(r, 'remote_invoice', 'documentType') or ''} serie={_dig(r, 'remote_invoice', 'serie') or ''} number={_dig(r, 'remote_invoice', 'documentNumber') or _dig(r, 'remote_invoice', 'naturalKey') or ''}",
f"- PDF: content_type={inv_pdf.get('content_type') or ''} bytes={inv_pdf.get('bytes') or ''} pages={inv_pdf.get('page_count_hint') or ''} boxes={box_text}",
"",
])
Path(md_path).write_text("\n".join(lines), encoding="utf-8")
return {"json": json_path, "csv": csv_path, "markdown": md_path}
async def _main_async(args: argparse.Namespace) -> int:
candidates = _query_candidates(args)
print(f"CONVERTED_INVOICES_TO_SCAN={len(candidates)}")
client = JasminClient(timeout=float(args.timeout))
save_dir = Path(args.save_pdf_dir) if args.save_pdf_dir else None
results: list[dict[str, Any]] = []
for i, row in enumerate(candidates, start=1):
print(f"scanning={i}/{len(candidates)} invoice={row.get('invoice_number') or row.get('invoice_external_id')} opp={row.get('opportunity_id')} title={row.get('opportunity_title')}")
result = await _audit_one(row, client=client, save_dir=save_dir, compare_quote_pdf=not args.no_quote_pdf)
results.append(result)
if getattr(args, "mark_local", False):
try:
_mark_local_pdf_format(result)
except Exception as exc:
print(f" mark_local_error={exc}")
inv_pdf = result.get("invoice_pdf") or {}
boxes = inv_pdf.get("boxes") or []
box = boxes[0] if boxes else {}
print(
" finding={finding} content_type={ct} bytes={bytes} box={w}x{h}mm a4={a4}".format(
finding=result.get("finding"),
ct=inv_pdf.get("content_type"),
bytes=inv_pdf.get("bytes"),
w=box.get("width_mm"),
h=box.get("height_mm"),
a4=box.get("looks_a4"),
)
)
prefix = args.output_prefix or "/tmp/clientflow_jasmin_converted_invoice_format_audit"
paths = _write_outputs(results, prefix)
counts: dict[str, int] = {}
for r in results:
counts[_s(r.get("finding") or "UNKNOWN")] = counts.get(_s(r.get("finding") or "UNKNOWN"), 0) + 1
print("SUMMARY")
print(f"Findings: {len([r for r in results if r.get('finding') != 'OK'])}")
for key in sorted(counts):
print(f"{key}: {counts[key]}")
print(f"CSV: {paths['csv']}")
print(f"Markdown: {paths['markdown']}")
print(f"JSON: {paths['json']}")
if save_dir:
print(f"PDF_DIR: {save_dir}")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="Audit format/size of Jasmin PDFs generated after ORC→FA conversion.")
parser.add_argument("--days", type=int, default=14, help="Recent invoice window when no document/opportunity filter is supplied.")
parser.add_argument("--limit", type=int, default=30)
parser.add_argument("--document", help="Invoice document_number or external_id, e.g. FA.FA2026.135")
parser.add_argument("--opportunity-id", help="Limit to one opportunity UUID")
parser.add_argument("--include-all-invoices", action="store_true", help="Also scan invoices not linked to a parent quotation.")
parser.add_argument("--no-quote-pdf", action="store_true", help="Do not fetch parent quotation PDF for comparison.")
parser.add_argument("--save-pdf-dir", help="Optional directory to save fetched PDFs for manual inspection.")
parser.add_argument("--mark-local", action="store_true", help="Persist local PDF format flags in commercial_documents.payload; does not modify Jasmin.")
parser.add_argument("--output-prefix", default="/tmp/clientflow_jasmin_converted_invoice_format_audit")
parser.add_argument("--timeout", type=float, default=45.0)
args = parser.parse_args()
return asyncio.run(_main_async(args))
if __name__ == "__main__":
raise SystemExit(main())