563 lines
22 KiB
Python
563 lines
22 KiB
Python
#!/usr/bin/env python3
|
|
"""probe_jasmin_print_layout_catalog
|
|
|
|
Probe Jasmin API for print-layout/report catalog endpoints.
|
|
|
|
Read-only by default. It searches likely Jasmin REST/OData endpoints for the
|
|
print model labels visible in the UI, e.g. "Fatura de Mercadorias" and
|
|
"Talão de Fatura". The goal is to discover whether the API exposes an internal
|
|
layout/report identifier that ClientFlow can use when printing invoices.
|
|
|
|
Optional --probe-print-post tests POST /billing/invoices/{id}/print with JSON
|
|
bodies containing the candidate labels. This should not create documents, but it
|
|
may mark the invoice as printed/reprinted in Jasmin, so it requires
|
|
--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
|
|
|
|
import httpx
|
|
from sqlalchemy import text
|
|
|
|
from app.db import engine
|
|
from app.jasmin_client import JasminClient
|
|
|
|
OUT_PREFIX = "/tmp/clientflow_jasmin_print_layout_catalog_probe"
|
|
POINT_TO_MM = 25.4 / 72.0
|
|
A4_PT = (595.0, 842.0)
|
|
|
|
DEFAULT_LABELS = [
|
|
"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",
|
|
]
|
|
|
|
CATALOG_ENDPOINTS = [
|
|
# Metadata roots.
|
|
"/$metadata",
|
|
"/businessCore/$metadata",
|
|
"/billing/$metadata",
|
|
"/billingCore/$metadata",
|
|
"/sales/$metadata",
|
|
"/salesCore/$metadata",
|
|
"/platform/$metadata",
|
|
|
|
# Likely print/report catalogs.
|
|
"/businessCore/printLayouts",
|
|
"/businessCore/printLayouts/odata",
|
|
"/businessCore/printFormats",
|
|
"/businessCore/printFormats/odata",
|
|
"/businessCore/printSettings",
|
|
"/businessCore/printSettings/odata",
|
|
"/businessCore/printingSettings",
|
|
"/businessCore/printingSettings/odata",
|
|
"/businessCore/printingPreferences",
|
|
"/businessCore/printingPreferences/odata",
|
|
"/businessCore/reportSettings",
|
|
"/businessCore/reportSettings/odata",
|
|
"/businessCore/reportTemplates",
|
|
"/businessCore/reportTemplates/odata",
|
|
"/businessCore/reports",
|
|
"/businessCore/reports/odata",
|
|
"/businessCore/documentLayouts",
|
|
"/businessCore/documentLayouts/odata",
|
|
"/businessCore/documentPrintLayouts",
|
|
"/businessCore/documentPrintLayouts/odata",
|
|
"/businessCore/printLayoutSelections",
|
|
"/businessCore/printLayoutSelections/odata",
|
|
"/businessCore/reporting/printLayouts",
|
|
"/businessCore/reporting/printLayouts/odata",
|
|
"/businessCore/reporting/reports",
|
|
"/businessCore/reporting/reports/odata",
|
|
|
|
# Platform/reporting guesses.
|
|
"/platform/printLayouts",
|
|
"/platform/printLayouts/odata",
|
|
"/platform/reports",
|
|
"/platform/reports/odata",
|
|
"/platform/reportTemplates",
|
|
"/platform/reportTemplates/odata",
|
|
"/platform/reporting/printLayouts",
|
|
"/platform/reporting/printLayouts/odata",
|
|
|
|
# Billing-specific guesses.
|
|
"/billing/printLayouts",
|
|
"/billing/printLayouts/odata",
|
|
"/billing/reportTemplates",
|
|
"/billing/reportTemplates/odata",
|
|
"/billing/reports",
|
|
"/billing/reports/odata",
|
|
"/billing/invoicePrintLayouts",
|
|
"/billing/invoicePrintLayouts/odata",
|
|
"/billing/invoiceReports",
|
|
"/billing/invoiceReports/odata",
|
|
"/billing/documentPrintLayouts",
|
|
"/billing/documentPrintLayouts/odata",
|
|
"/billing/documentTypes",
|
|
"/billing/documentTypes/odata",
|
|
"/billing/invoiceTypes",
|
|
"/billing/invoiceTypes/odata",
|
|
"/billingCore/printLayouts",
|
|
"/billingCore/printLayouts/odata",
|
|
"/billingCore/reports",
|
|
"/billingCore/reports/odata",
|
|
"/billingCore/documentTypes",
|
|
"/billingCore/documentTypes/odata",
|
|
"/billingCore/invoiceTypes",
|
|
"/billingCore/invoiceTypes/odata",
|
|
|
|
# Sales catalogs may include report names seen in ORC payloads.
|
|
"/sales/printLayouts",
|
|
"/sales/printLayouts/odata",
|
|
"/sales/reports",
|
|
"/sales/reports/odata",
|
|
"/sales/documentPrintLayouts",
|
|
"/sales/documentPrintLayouts/odata",
|
|
"/salesCore/printLayouts",
|
|
"/salesCore/printLayouts/odata",
|
|
"/salesCore/reports",
|
|
"/salesCore/reports/odata",
|
|
]
|
|
|
|
|
|
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),
|
|
"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[:500].decode("utf-8", errors="replace")
|
|
return info
|
|
boxes = _extract_pdf_boxes(data)
|
|
info["boxes"] = boxes
|
|
if boxes and not any(b.get("looks_a4") for b in boxes if _s(b.get("kind")).lower() == "mediabox"):
|
|
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
|
|
|
|
|
|
async def _raw_request(
|
|
client: JasminClient,
|
|
method: str,
|
|
path: str,
|
|
*,
|
|
params: dict[str, Any] | None = None,
|
|
json_body: Any = None,
|
|
accept: str = "application/json",
|
|
) -> tuple[int, bytes, str, dict[str, str]]:
|
|
token = await client.get_token()
|
|
url = f"{client.api_root}/{path.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=params or {}, 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", ""),
|
|
}
|
|
|
|
|
|
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 _flatten_strings(obj: Any, *, limit: int = 3000) -> list[str]:
|
|
out: list[str] = []
|
|
def walk(x: Any) -> None:
|
|
if len(out) >= limit:
|
|
return
|
|
if isinstance(x, dict):
|
|
for k, v in x.items():
|
|
if isinstance(k, str):
|
|
out.append(k)
|
|
walk(v)
|
|
elif isinstance(x, list):
|
|
for v in x:
|
|
walk(v)
|
|
elif isinstance(x, str):
|
|
out.append(x)
|
|
walk(obj)
|
|
return out
|
|
|
|
|
|
def _label_matches(obj: Any, text_body: str, labels: list[str]) -> list[str]:
|
|
haystack = "\n".join(_flatten_strings(obj)) if obj is not None else text_body
|
|
haystack_l = haystack.lower()
|
|
hits = []
|
|
for label in labels:
|
|
l = label.lower()
|
|
# Exact display label or relaxed key words.
|
|
if l in haystack_l:
|
|
hits.append(label)
|
|
return hits
|
|
|
|
|
|
def _extract_interesting_records(obj: Any, labels: list[str], *, max_records: int = 20) -> list[dict[str, Any]]:
|
|
records: list[dict[str, Any]] = []
|
|
wanted = [x.lower() for x in labels] + ["fatura", "mercadorias", "serviços", "servicos", "talão", "talao", "invoice", "goods", "services", "receipt"]
|
|
|
|
def interesting_text(x: Any) -> bool:
|
|
if isinstance(x, dict):
|
|
txt = json.dumps(x, ensure_ascii=False, default=_json_default).lower()
|
|
else:
|
|
txt = _s(x).lower()
|
|
return any(w in txt for w in wanted)
|
|
|
|
def compact_record(d: dict[str, Any]) -> dict[str, Any]:
|
|
rx = re.compile(r"id|key|name|description|report|layout|print|template|document|serie|type|fiscal", re.I)
|
|
out = {k: v for k, v in d.items() if rx.search(str(k)) and not isinstance(v, (list, dict))}
|
|
if not out:
|
|
out = {k: v for k, v in list(d.items())[:12] if not isinstance(v, (list, dict))}
|
|
return out
|
|
|
|
def walk(x: Any) -> None:
|
|
if len(records) >= max_records:
|
|
return
|
|
if isinstance(x, dict):
|
|
if interesting_text(x):
|
|
records.append(compact_record(x))
|
|
for v in x.values():
|
|
walk(v)
|
|
elif isinstance(x, list):
|
|
for v in x:
|
|
walk(v)
|
|
walk(obj)
|
|
return records
|
|
|
|
|
|
def _endpoint_params(path: str) -> dict[str, Any]:
|
|
if "odata" in path.lower():
|
|
return {"$top": 100}
|
|
return {}
|
|
|
|
|
|
async def _probe_catalog(client: JasminClient, endpoints: list[str], labels: list[str], max_endpoints: int) -> list[dict[str, Any]]:
|
|
rows: list[dict[str, Any]] = []
|
|
for i, path in enumerate(endpoints[:max_endpoints], start=1):
|
|
params = _endpoint_params(path)
|
|
try:
|
|
status, content, content_type, headers_subset = await _raw_request(client, "GET", path, params=params, accept="application/json")
|
|
obj, text_body = _decode_json_or_text(content, content_type)
|
|
hits = _label_matches(obj, text_body, labels)
|
|
records = _extract_interesting_records(obj, labels) if obj is not None and status < 500 else []
|
|
row = {
|
|
"path": path,
|
|
"method": "GET",
|
|
"params": params,
|
|
"status_code": status,
|
|
"content_type": content_type,
|
|
"bytes": len(content),
|
|
"headers_subset": headers_subset,
|
|
"label_hits": hits,
|
|
"interesting_records": records,
|
|
"body_preview": text_body[:1500] 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"{i:02d}/{min(max_endpoints, len(endpoints))} GET {path}: status={status} bytes={len(content)}{hit_txt}")
|
|
except Exception as exc:
|
|
rows.append({"path": path, "method": "GET", "error": str(exc)})
|
|
print(f"{i:02d}/{min(max_endpoints, len(endpoints))} GET {path}: ERROR {exc}")
|
|
return rows
|
|
|
|
|
|
def _print_post_bodies(labels: list[str]) -> list[dict[str, Any]]:
|
|
out: list[dict[str, Any]] = []
|
|
keys = ["printLayout", "layout", "layoutKey", "template", "templateKey", "reportName", "printedReportName"]
|
|
for label in labels:
|
|
for key in keys:
|
|
out.append({"name": f"{key}={label}", "body": {key: label}})
|
|
return out
|
|
|
|
|
|
async def _probe_print_post(client: JasminClient, invoice_id: str, labels: 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 = _print_post_bodies(labels)[:max_variants]
|
|
for i, v in enumerate(variants, start=1):
|
|
try:
|
|
status, content, content_type, headers_subset = 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_.-]+", "_", f"post_{i:02d}_{v['name']}").strip("_")
|
|
(out_dir / f"{safe}.pdf").write_bytes(content)
|
|
rows.append({
|
|
"name": v["name"],
|
|
"body": v["body"],
|
|
"status_code": status,
|
|
"content_type": content_type,
|
|
"headers_subset": headers_subset,
|
|
"pdf": pdf,
|
|
})
|
|
print(f"POST {i:02d}/{len(variants)} {v['name']}: status={status} {_box_summary(pdf)}")
|
|
except Exception as exc:
|
|
rows.append({"name": v["name"], "body": v["body"], "error": str(exc)})
|
|
print(f"POST {i:02d}/{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")
|
|
|
|
endpoints = result.get("catalog_endpoints") or []
|
|
hits = [r for r in endpoints if r.get("label_hits")]
|
|
readable = [r for r in endpoints if int(r.get("status_code") or 0) == 200]
|
|
post_rows = result.get("print_post_probe") or []
|
|
post_ok = [r for r in post_rows if (r.get("pdf") or {}).get("is_pdf_header") and not (r.get("pdf") or {}).get("format_warning")]
|
|
|
|
lines = [
|
|
"# Probe Jasmin print-layout/catalog API",
|
|
"",
|
|
f"- Resolved at: `{result.get('resolved_at')}`",
|
|
f"- Labels procurados: `{', '.join(result.get('labels') or [])}`",
|
|
f"- Endpoints testados: `{len(endpoints)}`",
|
|
f"- Endpoints 200: `{len(readable)}`",
|
|
f"- Endpoints com labels exatos: `{len(hits)}`",
|
|
"",
|
|
"## Resultado",
|
|
]
|
|
if hits:
|
|
lines.append("Foram encontrados labels visíveis em endpoints da API:")
|
|
for r in hits[:20]:
|
|
lines.append(f"- `{r.get('path')}` hits={r.get('label_hits')} status={r.get('status_code')}")
|
|
for rec in (r.get("interesting_records") or [])[:5]:
|
|
lines.append(f" - `{json.dumps(rec, ensure_ascii=False, default=_json_default)}`")
|
|
else:
|
|
lines.append("Não foi encontrado catálogo público/readable com estes labels exatos. Pode existir endpoint interno não exposto, ou o seletor da UI não estar disponível na API pública usada pelo ClientFlow.")
|
|
|
|
if post_rows:
|
|
lines.extend(["", "## POST /print com body JSON", ""])
|
|
if post_ok:
|
|
lines.append("Encontrada variante POST que devolve A4/sem warning:")
|
|
for r in post_ok[:10]:
|
|
lines.append(f"- `{r.get('name')}` body=`{r.get('body')}` → `{_box_summary(r.get('pdf') or {})}`")
|
|
else:
|
|
lines.append("Nenhuma variante POST /print testada devolveu A4.")
|
|
for r in post_rows[:12]:
|
|
lines.append(f"- `{r.get('name')}` status={r.get('status_code')} `{_box_summary(r.get('pdf') or {})}`")
|
|
|
|
lines.extend(["", "## Endpoints 200/405 mais relevantes", ""])
|
|
for r in [x for x in endpoints if int(x.get("status_code") or 0) in {200, 400, 405}][:30]:
|
|
lines.append(f"- `{r.get('method')} {r.get('path')}` status={r.get('status_code')} type={r.get('content_type')} bytes={r.get('bytes')} hits={r.get('label_hits')}")
|
|
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", "path_or_name", "status_code", "content_type", "bytes", "label_hits", "warning", "summary"])
|
|
writer.writeheader()
|
|
for r in endpoints:
|
|
writer.writerow({
|
|
"kind": "endpoint",
|
|
"path_or_name": r.get("path"),
|
|
"status_code": r.get("status_code"),
|
|
"content_type": r.get("content_type"),
|
|
"bytes": r.get("bytes"),
|
|
"label_hits": "; ".join(r.get("label_hits") or []),
|
|
"warning": "",
|
|
"summary": (r.get("body_preview") or "")[:300].replace("\n", " "),
|
|
})
|
|
for r in post_rows:
|
|
p = r.get("pdf") or {}
|
|
writer.writerow({
|
|
"kind": "print_post",
|
|
"path_or_name": r.get("name"),
|
|
"status_code": r.get("status_code"),
|
|
"content_type": p.get("content_type") or r.get("content_type"),
|
|
"bytes": p.get("bytes"),
|
|
"label_hits": "",
|
|
"warning": p.get("format_warning"),
|
|
"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:
|
|
labels = [x.strip() for x in (args.labels.split("|") if args.labels else DEFAULT_LABELS) 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)}
|
|
|
|
endpoints = list(dict.fromkeys(CATALOG_ENDPOINTS + [
|
|
*( [
|
|
f"/billing/invoices/{invoice_id}/printLayouts",
|
|
f"/billing/invoices/{invoice_id}/printOptions",
|
|
f"/billing/invoices/{invoice_id}/printSettings",
|
|
f"/billing/invoices/{invoice_id}/availableReports",
|
|
f"/billing/invoices/{invoice_id}/reports",
|
|
] if invoice_id else [] )
|
|
]))
|
|
catalog = await _probe_catalog(client, endpoints, labels, args.max_endpoints)
|
|
|
|
post_rows: list[dict[str, Any]] = []
|
|
if args.probe_print_post:
|
|
if not invoice_id:
|
|
raise SystemExit("--probe-print-post exige --invoice ou --invoice-external-id")
|
|
if args.confirm_invoice != args.invoice:
|
|
raise SystemExit("Para testar POST /print, passa --confirm-invoice igual ao valor de --invoice.")
|
|
post_rows = await _probe_print_post(client, invoice_id, labels, args.save_pdf_dir, args.max_print_post_variants)
|
|
|
|
hits = [r for r in catalog if r.get("label_hits")]
|
|
result = {
|
|
"resolved_at": datetime.utcnow().isoformat() + "Z",
|
|
"labels": labels,
|
|
"invoice_input": args.invoice,
|
|
"invoice_external_id": invoice_id,
|
|
"local_invoice": local or {},
|
|
"remote_invoice": remote_invoice,
|
|
"catalog_endpoints": catalog,
|
|
"print_post_probe": post_rows,
|
|
"recommendation": (
|
|
"Encontrado endpoint com labels/modelos de impressão. Usar os records no JSON para identificar a chave/id interna e fazer novo teste de impressão com esse id."
|
|
if hits else
|
|
"Não foi encontrado catálogo público com os labels. O seletor de modelo pode estar apenas na UI Jasmin ou noutro endpoint não documentado; manter bloqueio PDF não-A4 e corrigir configuração Jasmin, salvo se o suporte fornecer endpoint/id de layout."
|
|
),
|
|
}
|
|
print("SUMMARY")
|
|
print(f"catalog_endpoints={len(catalog)} label_hits={len(hits)} print_post_variants={len(post_rows)}")
|
|
print(result["recommendation"])
|
|
_write_outputs(result)
|
|
return 0
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(description="Probe Jasmin API for print-layout/report catalog endpoints.")
|
|
p.add_argument("--invoice", default="", help="Optional 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("--labels", default="|".join(DEFAULT_LABELS), help="Pipe-separated labels to search for")
|
|
p.add_argument("--max-endpoints", type=int, default=120)
|
|
p.add_argument("--probe-print-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-print-post")
|
|
p.add_argument("--max-print-post-variants", type=int, default=30)
|
|
p.add_argument("--save-pdf-dir", default="", help="Save PDFs returned by optional POST /print probe")
|
|
return p
|
|
|
|
|
|
def main() -> int:
|
|
return asyncio.run(_main_async(build_parser().parse_args()))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|