Files
clientflow_backend/scripts/probe_jasmin_invoice_print_template_models.py

551 lines
24 KiB
Python

#!/usr/bin/env python3
"""probe_jasmin_invoice_print_template_models: test Jasmin invoice print with UI template model keys.
The Jasmin UI route /reporting/templates/list?listname=templates shows template model
keys such as Document, Slip, SlipWithShippingDetails and SummaryReport. Previous
probes tested human labels and report names; this one tests the internal-looking
model keys against /billing/invoices/{id}/print using GET query parameters by
default, and can optionally test POST JSON bodies behind an explicit confirmation.
Read-only by default: only GET /print and catalog/list routes are called. GET /print
may mark a document as printed/reprinted in some ERPs, so use a test invoice when
possible. POST /print is opt-in with --probe-post and --confirm-invoice.
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import hashlib
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import urlencode
import httpx
from sqlalchemy import text
from app.db import engine
from app.jasmin_client import JasminClient
OUT_PREFIX = "/tmp/clientflow_jasmin_invoice_print_template_models_probe"
POINT_TO_MM = 25.4 / 72.0
A4_PT = (595.0, 842.0)
DEFAULT_MODELS = [
"Document",
"DOCUMENT (1)",
"Slip",
"SlipWithShippingDetails",
"SummaryReport",
"List",
]
PRINT_PARAM_KEYS = [
"model",
"template",
"templateKey",
"templateModel",
"printModel",
"printTemplate",
"printTemplateKey",
"printTemplateModel",
"layout",
"layoutKey",
"printLayout",
"documentTemplate",
"documentTemplateKey",
"reportTemplate",
"reportTemplateKey",
"reportName",
"printedReportName",
]
CATALOG_API_PATHS = [
"/reporting/templates/list?listname=templates",
"/reporting/templates/list",
"/reporting/templates?listname=templates",
"/reporting/templates",
"/reporting/templates/odata?listname=templates",
"/reporting/templates/odata",
"/reporting/templateModels/list?listname=templates",
"/reporting/templateModels",
"/reporting/templates/Document",
"/reporting/templates/Slip",
"/reporting/templates/SlipWithShippingDetails",
]
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 _looks_a4(w: float, h: float, tolerance_pt: float = 20.0) -> bool:
return any(abs(a - A4_PT[0]) <= tolerance_pt and abs(b - A4_PT[1]) <= tolerance_pt for a, b in ((w, h), (h, w)))
def _extract_pdf_boxes(data: bytes) -> list[dict[str, Any]]:
latin = data.decode("latin-1", errors="ignore")
pattern = re.compile(r"/(MediaBox|CropBox)\s*\[\s*([-+0-9.]+)\s+([-+0-9.]+)\s+([-+0-9.]+)\s+([-+0-9.]+)\s*\]", re.I)
out: list[dict[str, Any]] = []
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, h = abs(x1 - x0), abs(y1 - y0)
out.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 out
def _pdf_info(data: bytes, content_type: str) -> dict[str, Any]:
info: dict[str, Any] = {
"content_type": content_type,
"bytes": len(data),
"sha256_12": hashlib.sha256(data).hexdigest()[:12] if data else "",
"is_pdf_header": data[:5] == b"%PDF-",
"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"
info["body_preview"] = data[:800].decode("utf-8", errors="replace")
return info
boxes = _extract_pdf_boxes(data)
info["boxes"] = boxes
mediaboxes = [b for b in boxes if _s(b.get("kind")).lower() == "mediabox"]
if mediaboxes and not any(b.get("looks_a4") for b in mediaboxes):
info["format_warning"] = "non_a4_or_unexpected_mediabox"
return info
def _box_summary(info: dict[str, Any]) -> str:
boxes = info.get("boxes") or []
if not boxes:
return f"content_type={info.get('content_type')} bytes={info.get('bytes')} warning={info.get('format_warning')}"
b = boxes[0]
return f"{b.get('kind')} {b.get('width_mm')}x{b.get('height_mm')}mm a4={b.get('looks_a4')} warning={info.get('format_warning') or ''}"
def _query_local_invoice(value: str) -> dict[str, Any] | None:
if not value:
return None
with engine.begin() as conn:
row = conn.execute(text("""
SELECT id::text AS local_doc_id, external_id, document_number, document_type,
serie, series_number, payload, created_at
FROM commercial_documents
WHERE system = 'jasmin'
AND document_kind = 'invoice'
AND (document_number = :v OR external_id = :v OR id::text = :v)
ORDER BY created_at DESC
LIMIT 1
"""), {"v": value}).mappings().first()
return dict(row) if row else None
def _split_path_query(path: str) -> tuple[str, dict[str, str]]:
if "?" not in path:
return path, {}
base, query = path.split("?", 1)
params: dict[str, str] = {}
for part in query.split("&"):
if not part:
continue
if "=" in part:
k, v = part.split("=", 1)
else:
k, v = part, ""
params[k] = v
return base, params
async def _raw_request(
client: JasminClient,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json_body: Any = None,
accept: str = "application/json",
scope: str = "api",
) -> tuple[int, bytes, str, dict[str, str], str]:
token = await client.get_token()
path_base, path_params = _split_path_query(path)
merged = {**path_params, **(params or {})}
if scope == "ui":
url = f"{client.config.base_url.rstrip('/')}/{client.config.account}/{client.config.subscription}/{path_base.lstrip('/')}"
else:
url = f"{client.api_root}/{path_base.lstrip('/')}"
headers = {"Authorization": f"Bearer {token}", "Accept": accept}
if json_body is not None:
headers["Content-Type"] = "application/json"
async with httpx.AsyncClient(timeout=client.timeout, follow_redirects=True) as hc:
response = await hc.request(method.upper(), url, params=merged, json=json_body, headers=headers)
return response.status_code, response.content, response.headers.get("content-type", ""), {
"allow": response.headers.get("allow", ""),
"content-disposition": response.headers.get("content-disposition", ""),
"location": response.headers.get("location", ""),
}, str(response.url)
def _decode_json_or_text(data: bytes, content_type: str) -> tuple[Any, str]:
text_body = data.decode("utf-8", errors="replace")
if "json" in (content_type or "").lower() or text_body.strip().startswith(("{", "[")):
try:
return json.loads(text_body), text_body
except Exception:
pass
return None, text_body
def _search_hits(obj: Any, text_body: str, models: list[str]) -> list[str]:
haystack = text_body
if obj is not None:
haystack = json.dumps(obj, ensure_ascii=False, default=_json_default)
hl = haystack.lower()
wanted = models + [
"Fatura de Mercadorias",
"Fatura de Mercadorias (totais de linha sem imposto)",
"Fatura de Serviços",
"Fatura de Serviços (totais de linha sem imposto)",
"Talão de Fatura",
"Talão de Fatura com Dados de Entrega",
]
return [x for x in wanted if x.lower() in hl]
def _interesting_preview(obj: Any, text_body: str) -> str:
if obj is None:
return text_body[:1200]
rx = re.compile(r"document|slip|summary|template|report|fatura|mercadorias|servi[cç]os|tal[aã]o|layout|print", re.I)
records: list[Any] = []
def walk(x: Any) -> None:
if len(records) >= 25:
return
if isinstance(x, dict):
txt = json.dumps(x, ensure_ascii=False, default=_json_default)
if rx.search(txt):
compact = {k: v for k, v in x.items() if rx.search(str(k)) or (isinstance(v, str) and rx.search(v))}
records.append(compact or {k: v for k, v in list(x.items())[:10] if not isinstance(v, (list, dict))})
for v in x.values():
walk(v)
elif isinstance(x, list):
for v in x:
walk(v)
walk(obj)
if records:
return json.dumps(records[:10], ensure_ascii=False, default=_json_default)[:2000]
return json.dumps(obj, ensure_ascii=False, default=_json_default)[:1200]
async def _probe_catalog_routes(client: JasminClient, models: list[str]) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
route_specs: list[tuple[str, str]] = []
for p in CATALOG_API_PATHS:
route_specs.append(("api", p))
# The exact browser route seen in the UI. This is likely a SPA route, but we test it because
# it may still expose server-side JSON or useful hints in HTML/JS.
route_specs.extend([
("ui", "/reporting/templates/list?listname=templates"),
("ui", "/reporting/templates/list"),
("ui", "/reporting/templates/Document"),
("ui", "/reporting/templates/Slip"),
("ui", "/reporting/templates/SlipWithShippingDetails"),
])
for i, (scope, path) in enumerate(route_specs, start=1):
try:
status, content, content_type, headers, url = await _raw_request(client, "GET", path, accept="application/json,text/html", scope=scope)
obj, text_body = _decode_json_or_text(content, content_type)
hits = _search_hits(obj, text_body, models)
row = {
"scope": scope,
"method": "GET",
"path": path,
"url": url,
"status_code": status,
"content_type": content_type,
"bytes": len(content),
"headers_subset": headers,
"hits": hits,
"preview": _interesting_preview(obj, text_body) if (hits or status in {200, 400, 405}) else text_body[:300],
}
rows.append(row)
hit_txt = f" hits={hits}" if hits else ""
print(f"CAT {i:02d}/{len(route_specs)} {scope.upper()} {path}: status={status} bytes={len(content)}{hit_txt}")
except Exception as exc:
rows.append({"scope": scope, "method": "GET", "path": path, "error": str(exc)})
print(f"CAT {i:02d}/{len(route_specs)} {scope.upper()} {path}: ERROR {exc}")
return rows
def _get_variants(models: list[str]) -> list[dict[str, Any]]:
variants: list[dict[str, Any]] = []
for model in models:
for key in PRINT_PARAM_KEYS:
variants.append({"name": f"GET {key}={model}", "params": {key: model}})
variants.append({"name": f"GET listname=templates&model={model}", "params": {"listname": "templates", "model": model}})
variants.append({"name": f"GET listname=templates&template={model}", "params": {"listname": "templates", "template": model}})
return variants
def _post_variants(models: list[str]) -> list[dict[str, Any]]:
keys = ["model", "template", "templateKey", "printModel", "printTemplate", "layout", "printLayout"]
variants: list[dict[str, Any]] = []
for model in models:
for key in keys:
variants.append({"name": f"POST {key}={model}", "body": {key: model}})
variants.append({"name": f"POST model+listname={model}", "body": {"listname": "templates", "model": model}})
return variants
async def _probe_print_gets(client: JasminClient, invoice_id: str, models: list[str], save_dir: str, max_variants: int) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
out_dir = Path(save_dir) if save_dir else None
if out_dir:
out_dir.mkdir(parents=True, exist_ok=True)
variants = _get_variants(models)[:max_variants]
for i, v in enumerate(variants, start=1):
try:
status, content, content_type, headers, url = await _raw_request(
client,
"GET",
f"/billing/invoices/{invoice_id}/print",
params=v["params"],
accept="application/pdf,application/json",
)
pdf = _pdf_info(content, content_type)
if out_dir and status < 400 and pdf.get("is_pdf_header"):
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", v["name"]).strip("_")[:120]
(out_dir / f"get_{i:03d}_{safe}.pdf").write_bytes(content)
rows.append({"name": v["name"], "params": v["params"], "status_code": status, "content_type": content_type, "headers_subset": headers, "url": url, "pdf": pdf})
ok = " OK_A4" if (pdf.get("is_pdf_header") and not pdf.get("format_warning")) else ""
print(f"GET {i:03d}/{len(variants)} {v['name']}: status={status} {_box_summary(pdf)}{ok}")
except Exception as exc:
rows.append({"name": v["name"], "params": v["params"], "error": str(exc)})
print(f"GET {i:03d}/{len(variants)} {v['name']}: ERROR {exc}")
return rows
async def _probe_print_posts(client: JasminClient, invoice_id: str, models: list[str], save_dir: str, max_variants: int) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
out_dir = Path(save_dir) if save_dir else None
if out_dir:
out_dir.mkdir(parents=True, exist_ok=True)
variants = _post_variants(models)[:max_variants]
for i, v in enumerate(variants, start=1):
try:
status, content, content_type, headers, url = await _raw_request(
client,
"POST",
f"/billing/invoices/{invoice_id}/print",
json_body=v["body"],
accept="application/pdf,application/json",
)
pdf = _pdf_info(content, content_type)
if out_dir and status < 400 and pdf.get("is_pdf_header"):
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", v["name"]).strip("_")[:120]
(out_dir / f"post_{i:03d}_{safe}.pdf").write_bytes(content)
rows.append({"name": v["name"], "body": v["body"], "status_code": status, "content_type": content_type, "headers_subset": headers, "url": url, "pdf": pdf})
ok = " OK_A4" if (pdf.get("is_pdf_header") and not pdf.get("format_warning")) else ""
print(f"POST {i:03d}/{len(variants)} {v['name']}: status={status} {_box_summary(pdf)}{ok}")
except Exception as exc:
rows.append({"name": v["name"], "body": v["body"], "error": str(exc)})
print(f"POST {i:03d}/{len(variants)} {v['name']}: ERROR {exc}")
return rows
def _write_outputs(result: dict[str, Any]) -> None:
json_path = Path(OUT_PREFIX + ".json")
md_path = Path(OUT_PREFIX + ".md")
csv_path = Path(OUT_PREFIX + ".csv")
json_path.write_text(json.dumps(result, ensure_ascii=False, indent=2, default=_json_default), encoding="utf-8")
catalog = result.get("catalog_probe") or []
get_rows = result.get("print_get_probe") or []
post_rows = result.get("print_post_probe") or []
good = [r for r in get_rows + post_rows if (r.get("pdf") or {}).get("is_pdf_header") and not (r.get("pdf") or {}).get("format_warning")]
hits = [r for r in catalog if r.get("hits")]
readable = [r for r in catalog if int(r.get("status_code") or 0) == 200]
lines = [
"# Probe Jasmin invoice print template model keys",
"",
f"- Invoice input: `{result.get('invoice_input')}`",
f"- Invoice external id: `{result.get('invoice_external_id')}`",
f"- Model keys tested: `{', '.join(result.get('models') or [])}`",
f"- Catalog routes 200: `{len(readable)}`",
f"- Catalog/model hits: `{len(hits)}`",
f"- Print GET variants: `{len(get_rows)}`",
f"- Print POST variants: `{len(post_rows)}`",
f"- A4/OK variants: `{len(good)}`",
"",
"## Recomendação",
result.get("recommendation") or "",
"",
]
if good:
lines.append("## Variantes que devolveram A4")
for r in good[:20]:
detail = r.get("params") or r.get("body")
lines.append(f"- `{r.get('name')}` `{detail}` → `{_box_summary(r.get('pdf') or {})}`")
lines.append("")
if hits:
lines.append("## Catalog routes com hits")
for r in hits[:20]:
lines.append(f"- `{r.get('scope')} {r.get('path')}` status={r.get('status_code')} hits={r.get('hits')}")
prev = (r.get("preview") or "")[:700].replace("\n", " ")
if prev:
lines.append(f" - preview: `{prev}`")
lines.append("")
lines.append("## Primeiras variantes GET")
for r in get_rows[:30]:
lines.append(f"- `{r.get('name')}` status={r.get('status_code')} `{_box_summary(r.get('pdf') or {})}`")
if post_rows:
lines.append("")
lines.append("## Primeiras variantes POST")
for r in post_rows[:30]:
lines.append(f"- `{r.get('name')}` status={r.get('status_code')} `{_box_summary(r.get('pdf') or {})}`")
md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
with csv_path.open("w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=["kind", "name_or_path", "status_code", "content_type", "bytes", "warning", "a4", "hits", "summary"])
writer.writeheader()
for r in catalog:
writer.writerow({
"kind": "catalog",
"name_or_path": f"{r.get('scope')} {r.get('path')}",
"status_code": r.get("status_code"),
"content_type": r.get("content_type"),
"bytes": r.get("bytes"),
"warning": r.get("error") or "",
"a4": "",
"hits": "; ".join(r.get("hits") or []),
"summary": (r.get("preview") or "")[:300].replace("\n", " "),
})
for kind, rows in (("print_get", get_rows), ("print_post", post_rows)):
for r in rows:
p = r.get("pdf") or {}
boxes = p.get("boxes") or []
writer.writerow({
"kind": kind,
"name_or_path": r.get("name"),
"status_code": r.get("status_code"),
"content_type": p.get("content_type") or r.get("content_type"),
"bytes": p.get("bytes"),
"warning": p.get("format_warning") or r.get("error") or "",
"a4": any(b.get("looks_a4") for b in boxes),
"hits": "",
"summary": _box_summary(p),
})
print(f"JSON: {json_path}")
print(f"Markdown: {md_path}")
print(f"CSV: {csv_path}")
async def _main_async(args: argparse.Namespace) -> int:
models = [x.strip() for x in (args.models.split("|") if args.models else DEFAULT_MODELS) if x.strip()]
client = JasminClient()
local = _query_local_invoice(args.invoice) if args.invoice else None
invoice_id = _s(args.invoice_external_id or (local or {}).get("external_id") or args.invoice)
remote_invoice: dict[str, Any] = {}
if invoice_id:
try:
remote_raw = await client.get_invoice(invoice_id)
remote_invoice = {
"id": remote_raw.get("id"),
"naturalKey": remote_raw.get("naturalKey"),
"documentType": remote_raw.get("documentType"),
"serie": remote_raw.get("serie"),
"printedReportName": remote_raw.get("printedReportName"),
"printLayout": remote_raw.get("printLayout"),
"isPrinted": remote_raw.get("isPrinted"),
"isReprinted": remote_raw.get("isReprinted"),
}
invoice_id = _s(remote_invoice.get("id") or invoice_id)
except Exception as exc:
remote_invoice = {"fetch_error": str(exc)}
if not invoice_id:
raise SystemExit("Indica --invoice ou --invoice-external-id")
catalog_rows = [] if args.skip_catalog else await _probe_catalog_routes(client, models)
get_rows = await _probe_print_gets(client, invoice_id, models, args.save_pdf_dir, args.max_get_variants)
post_rows: list[dict[str, Any]] = []
if args.probe_post:
if args.confirm_invoice != args.invoice:
raise SystemExit("Para --probe-post passa --confirm-invoice igual ao valor de --invoice.")
post_rows = await _probe_print_posts(client, invoice_id, models, args.save_pdf_dir, args.max_post_variants)
good = [r for r in get_rows + post_rows if (r.get("pdf") or {}).get("is_pdf_header") and not (r.get("pdf") or {}).get("format_warning")]
hits = [r for r in catalog_rows if r.get("hits")]
recommendation = (
"Encontrada variante de impressão A4. Adaptar ClientFlow para usar os parâmetros/body indicados no JSON/Markdown."
if good else
"Os modelos visíveis na UI foram testados por chave interna (Document/Slip/SlipWithShippingDetails), mas nenhuma variante devolveu A4. A seleção de modelo parece depender da configuração Jasmin ou de endpoint interno não exposto. Mantém bloqueio de PDF não-A4 no ClientFlow e muda o modelo padrão da série/tipo FA no Jasmin."
)
if hits and not good:
recommendation += " Foram encontrados sinais de catálogo, mas ainda não houve parâmetro /print que selecionasse o modelo A4."
result = {
"resolved_at": datetime.utcnow().isoformat() + "Z",
"invoice_input": args.invoice,
"invoice_external_id": invoice_id,
"models": models,
"local_invoice": local or {},
"remote_invoice": remote_invoice,
"catalog_probe": catalog_rows,
"print_get_probe": get_rows,
"print_post_probe": post_rows,
"recommendation": recommendation,
}
print("SUMMARY")
print(f"catalog_routes={len(catalog_rows)} catalog_hits={len(hits)} get_variants={len(get_rows)} post_variants={len(post_rows)} a4_ok={len(good)}")
print(recommendation)
_write_outputs(result)
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Probe Jasmin /print using UI template model keys such as Document and Slip.")
p.add_argument("--invoice", default="", help="Invoice natural key/local id/external id, e.g. FA.FA2026.137")
p.add_argument("--invoice-external-id", default="", help="Explicit Jasmin invoice GUID")
p.add_argument("--models", default="|".join(DEFAULT_MODELS), help="Pipe-separated model keys to test")
p.add_argument("--skip-catalog", action="store_true", help="Skip /reporting/templates catalog route probes")
p.add_argument("--max-get-variants", type=int, default=140)
p.add_argument("--probe-post", action="store_true", help="Also POST JSON bodies to /billing/invoices/{id}/print; requires --confirm-invoice")
p.add_argument("--confirm-invoice", default="", help="Must match --invoice for --probe-post")
p.add_argument("--max-post-variants", type=int, default=60)
p.add_argument("--save-pdf-dir", default="", help="Save PDFs returned by print probes")
return p
def main() -> int:
return asyncio.run(_main_async(build_parser().parse_args()))
if __name__ == "__main__":
raise SystemExit(main())