Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

View File

@@ -0,0 +1,522 @@
#!/usr/bin/env python3
"""Probe Jasmin quotation -> invoice API using a specific quotation.
Safe by default: it only reads local DB/Jasmin, fetches PDFs, and prints a
recommended experiment plan. It will only create an invoice in Jasmin when the
operator passes --execute-convert plus --confirm-document matching the quotation.
Use case: understand whether fromQuotation can be parameterized so ClientFlow can
convert ORC -> FA without using the Jasmin UI and still obtain an A4 invoice PDF.
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import hashlib
import json
import os
import re
from datetime import date, datetime
from pathlib import Path
from typing import Any, Iterable
import httpx
from sqlalchemy import text
from app.config import settings
from app.db import engine
from app.jasmin_client import JasminClient, JasminError
A4_PORTRAIT_PT = (595.0, 842.0)
POINT_TO_MM = 25.4 / 72.0
OUT_PREFIX = "/tmp/clientflow_jasmin_quotation_invoice_api_probe"
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 _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 _interesting_fields(raw: dict[str, Any]) -> dict[str, Any]:
rx = re.compile(r"print|report|layout|template|format|paper|serie|series|type|fiscal|operation", re.I)
return {k: raw.get(k) for k in sorted(raw.keys()) if rx.search(k)}
def _selected_doc_fields(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
return {"raw_type": type(raw).__name__, "raw_preview": _s(raw)[:500]}
return {
"id": _first(raw, ["id", "key"]),
"naturalKey": raw.get("naturalKey"),
"documentNumber": raw.get("documentNumber"),
"documentType": _first(raw, ["documentType", "documentTypeKey"]),
"documentTypeDescription": raw.get("documentTypeDescription"),
"serie": _first(raw, ["serie", "serieKey"]),
"serieDescription": raw.get("serieDescription"),
"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"]),
"priceList": _first(raw, ["priceList", "priceListKey"]),
"printLayout": _first(raw, ["printLayout", "report", "reportName", "reportKey", "documentReport", "printTemplate"]),
"printedReportName": raw.get("printedReportName"),
"isPrinted": raw.get("isPrinted"),
"isReprinted": raw.get("isReprinted"),
"operationType": raw.get("operationType"),
"fiscalDocumentType": raw.get("fiscalDocumentType"),
"line_count": len(raw.get("documentLines") or raw.get("lines") or []) if isinstance(raw.get("documentLines") or raw.get("lines") or [], list) else None,
"interesting_fields": _interesting_fields(raw),
}
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 _extract_pdf_boxes(data: bytes) -> list[dict[str, Any]]:
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 _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-",
"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"
info["body_preview"] = data[:300].decode("utf-8", errors="replace")
return info
latin = data.decode("latin-1", errors="ignore")
info["page_count_hint"] = len(re.findall(r"/Type\s*/Page(?!s)\b", latin)) 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 _s(b.get("kind")).lower() == "mediabox"):
info["format_warning"] = "non_a4_or_unexpected_mediabox"
return info
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")
q = result.get("quotation") or {}
qp = result.get("quotation_pdf") or {}
linked = result.get("linked_invoice") or {}
cp = result.get("conversion_probe") or {}
lines = [
"# Probe Jasmin ORC → FA via API",
"",
f"- Quotation: `{q.get('local_number') or q.get('remote', {}).get('naturalKey') or result.get('requested_quotation')}`",
f"- External ID: `{q.get('external_id') or q.get('remote', {}).get('id')}`",
f"- Quotation PDF: `{_pdf_box_summary(qp)}`",
f"- Linked invoice: `{linked.get('invoice_number') or ''}`",
f"- API fromQuotation OPTIONS/POST surface: `{cp.get('summary') or 'ver JSON'}`",
"",
"## Recomendação",
result.get("recommendation") or "Sem recomendação automática.",
"",
"## Body candidates",
"```json",
json.dumps(result.get("body_candidates") or {}, ensure_ascii=False, indent=2, default=_json_default),
"```",
]
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=[
"quotation", "quotation_external_id", "quotation_pdf", "linked_invoice", "linked_invoice_pdf",
"recommendation_short",
])
writer.writeheader()
writer.writerow({
"quotation": q.get("local_number") or result.get("requested_quotation"),
"quotation_external_id": q.get("external_id") or q.get("remote", {}).get("id"),
"quotation_pdf": _pdf_box_summary(qp),
"linked_invoice": linked.get("invoice_number") or "",
"linked_invoice_pdf": _pdf_box_summary(linked.get("invoice_pdf") or {}),
"recommendation_short": _s(result.get("recommendation")).split("\n")[0][:300],
})
print(f"JSON: {json_path}")
print(f"Markdown: {md_path}")
print(f"CSV: {csv_path}")
def _pdf_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')}"
box = boxes[0]
return f"{box.get('kind')} {box.get('width_mm')}x{box.get('height_mm')}mm a4={box.get('looks_a4')} warning={info.get('format_warning') or ''}"
def _parse_orc_number(value: str) -> dict[str, Any]:
m = re.match(r"^([A-Z]+)\.([A-Z]+\d{4})\.(\d+)$", value.strip(), re.I)
if not m:
return {}
return {"document_type": m.group(1).upper(), "serie": m.group(2).upper(), "series_number": int(m.group(3))}
def _query_local_quotation(number_or_id: str) -> dict[str, Any] | None:
with engine.begin() as conn:
row = conn.execute(text("""
SELECT
q.id::text AS local_doc_id,
q.opportunity_id::text AS opportunity_id,
q.external_id,
q.document_number,
q.document_type,
q.serie,
q.series_number,
q.status,
q.total_amount,
q.payload,
q.created_at,
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,
inv.id::text AS linked_invoice_doc_id,
inv.external_id AS linked_invoice_external_id,
inv.document_number AS linked_invoice_number
FROM commercial_documents q
LEFT JOIN commercial_documents inv ON inv.parent_document_id = q.id AND inv.document_kind = 'invoice'
LEFT JOIN opportunities o ON o.id = q.opportunity_id
LEFT JOIN customers c ON c.id = q.customer_id
WHERE q.system = 'jasmin'
AND q.document_kind = 'quotation'
AND (q.document_number = :key OR q.external_id = :key OR q.id::text = :key)
ORDER BY q.created_at DESC
LIMIT 1
"""), {"key": number_or_id}).mappings().first()
return dict(row) if row else None
async def _resolve_remote_quotation(client: JasminClient, key: str, local: dict[str, Any] | None) -> tuple[str, dict[str, Any], dict[str, Any]]:
if local and _s(local.get("external_id")):
qid = _s(local.get("external_id"))
return qid, await client.get_quotation(qid), {"source": "local_external_id"}
parsed = _parse_orc_number(key)
filters = [f"naturalKey eq '{key}'"]
if parsed:
filters.append(
"documentType eq '{document_type}' and serie eq '{serie}' and seriesNumber eq {series_number}".format(**parsed)
)
errors: list[str] = []
for flt in filters:
try:
data = await client.list_quotations(top=5, filter=flt, orderby="documentDate desc")
values = data.get("value") if isinstance(data, dict) else []
if values:
remote = values[0]
qid = _s(remote.get("id") or remote.get("key"))
if qid:
return qid, await client.get_quotation(qid), {"source": "odata", "filter": flt, "odata_hit": _selected_doc_fields(remote)}
except Exception as exc:
errors.append(f"{flt}: {exc}")
raise JasminError(f"Não consegui resolver orçamento remoto {key}. Erros OData: {errors}")
async def _raw_request_with_meta(client: JasminClient, method: str, path: str, *, params: dict[str, Any] | None = None, json_body: Any = None, accept: str = "application/json") -> dict[str, Any]:
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 http:
resp = await http.request(method.upper(), url, params=params, json=json_body, headers=headers)
content_type = resp.headers.get("content-type", "")
body: Any
try:
body = resp.json()
except Exception:
body = resp.text[:2000]
return {
"method": method.upper(),
"path": path,
"params": params or {},
"status_code": resp.status_code,
"content_type": content_type,
"headers_subset": {k: v for k, v in resp.headers.items() if k.lower() in {"allow", "content-type", "location"}},
"body_preview": body,
}
def _body_candidates(remote_quote: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
company = _first(remote_quote, ["company", "companyKey"]) or settings.jasmin_company_key
document_date = args.document_date or date.today().isoformat()
invoice_type = args.invoice_type
invoice_serie = args.invoice_serie
base = {
"documentType": invoice_type,
"serie": invoice_serie,
"company": company,
"documentDate": document_date,
"postingDate": document_date,
}
inherited_print: dict[str, Any] = {}
for key in ("printLayout", "printedReportName", "printAllDiscounts", "printAllPaymentMethods"):
if remote_quote.get(key) not in (None, ""):
inherited_print[key] = remote_quote.get(key)
if args.print_layout:
inherited_print["printLayout"] = args.print_layout
if args.printed_report_name:
inherited_print["printedReportName"] = args.printed_report_name
return {
"current_empty": {},
"explicit_invoice_type_serie": base,
"explicit_invoice_type_serie_plus_print_fields": {**base, **inherited_print},
"notes": [
"Não executar todos os candidatos no mesmo orçamento: cada POST pode criar uma fatura real.",
"Se o endpoint ignorar estes campos, a correção tem de ser no print endpoint ou na configuração Jasmin da série FA.",
],
}
def _body_for_mode(candidates: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
if args.body_json:
parsed = json.loads(args.body_json)
if not isinstance(parsed, dict):
raise SystemExit("--body-json tem de ser um objeto JSON")
return parsed
key_map = {
"empty": "current_empty",
"explicit-fa": "explicit_invoice_type_serie",
"explicit-fa-print": "explicit_invoice_type_serie_plus_print_fields",
}
return dict(candidates.get(key_map[args.body_mode]) or {})
async def _fetch_and_save_pdf(client: JasminClient, kind: str, external_id: str, save_dir: Path | None, label: str) -> dict[str, Any]:
if kind == "quotation":
data, content_type = await client.print_quotation_pdf(external_id)
elif kind == "invoice":
data, content_type = await client.print_invoice_pdf(external_id)
else:
raise ValueError(kind)
if save_dir:
save_dir.mkdir(parents=True, exist_ok=True)
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", label).strip("_") or external_id
(save_dir / f"{safe}.pdf").write_bytes(data)
return _pdf_info(data, content_type)
async def _main_async(args: argparse.Namespace) -> int:
client = JasminClient()
local = _query_local_quotation(args.quotation)
qid, remote_quote, resolve_meta = await _resolve_remote_quotation(client, args.quotation, local)
save_dir = Path(args.save_pdf_dir) if args.save_pdf_dir else None
body_candidates = _body_candidates(remote_quote, args)
result: dict[str, Any] = {
"requested_quotation": args.quotation,
"resolved_at": datetime.utcnow().isoformat() + "Z",
"quotation": {
"local_number": _s(local.get("document_number")) if local else "",
"local_doc_id": _s(local.get("local_doc_id")) if local else "",
"opportunity_id": _s(local.get("opportunity_id")) if local else "",
"external_id": qid,
"resolve_meta": resolve_meta,
"remote": _selected_doc_fields(remote_quote),
"local_payload_keys": sorted(_payload(local.get("payload") if local else None).keys()),
},
"quotation_pdf": await _fetch_and_save_pdf(client, "quotation", qid, save_dir, f"{args.quotation}_quotation"),
"linked_invoice": {},
"api_surface": {},
"body_candidates": body_candidates,
"conversion_probe": {"executed": False},
}
if local and _s(local.get("linked_invoice_external_id")):
inv_id = _s(local.get("linked_invoice_external_id"))
remote_invoice = await client.get_invoice(inv_id)
result["linked_invoice"] = {
"invoice_number": _s(local.get("linked_invoice_number")),
"external_id": inv_id,
"remote": _selected_doc_fields(remote_invoice),
"invoice_pdf": await _fetch_and_save_pdf(client, "invoice", inv_id, save_dir, f"{local.get('linked_invoice_number') or inv_id}_invoice"),
}
if args.probe_options:
surface: dict[str, Any] = {}
for method, path in [
("OPTIONS", f"/billing/invoices/fromQuotation/{qid}"),
("GET", f"/billing/invoices/fromQuotation/{qid}"),
]:
try:
surface[f"{method} {path}"] = await _raw_request_with_meta(client, method, path)
except Exception as exc:
surface[f"{method} {path}"] = {"error": str(exc)}
result["api_surface"] = surface
if args.execute_convert:
if args.confirm_document != args.quotation:
raise SystemExit("Segurança: para criar fatura real, passa --confirm-document exatamente igual a --quotation")
body = _body_for_mode(body_candidates, args)
path = f"/billing/invoices/fromQuotation/{qid}"
conv = await _raw_request_with_meta(client, "POST", path, json_body=body)
conv["body_sent"] = body
conv["warning"] = "Este POST pode ter criado uma fatura fiscal real no Jasmin. Importar/sincronizar no ClientFlow se for documento válido."
new_id = _s(conv.get("body_preview")).strip('"') if conv.get("status_code", 0) < 400 else ""
if isinstance(conv.get("body_preview"), str):
new_id = _s(conv.get("body_preview")).strip('"')
elif isinstance(conv.get("body_preview"), dict):
new_id = _s(conv["body_preview"].get("id") or conv["body_preview"].get("key") or conv["body_preview"].get("value"))
conv["created_invoice_external_id_guess"] = new_id
if new_id:
try:
new_invoice = await client.get_invoice(new_id)
conv["created_invoice_remote"] = _selected_doc_fields(new_invoice)
natural = _s(new_invoice.get("naturalKey") or new_invoice.get("documentNumber") or new_id)
conv["created_invoice_pdf"] = await _fetch_and_save_pdf(client, "invoice", new_id, save_dir, f"{natural}_invoice")
except Exception as exc:
conv["created_invoice_fetch_error"] = str(exc)
result["conversion_probe"] = {"executed": True, **conv}
result["recommendation"] = _recommend(result)
_print_summary(result)
_write_outputs(result)
return 0
def _recommend(result: dict[str, Any]) -> str:
qpdf = result.get("quotation_pdf") or {}
linked = result.get("linked_invoice") or {}
ipdf = linked.get("invoice_pdf") or {}
conv = result.get("conversion_probe") or {}
cpdf = conv.get("created_invoice_pdf") or {}
if cpdf:
if not cpdf.get("format_warning"):
return "A variante executada criou fatura com PDF A4. Adaptar ClientFlow para usar esse body/estratégia e sincronizar a fatura criada."
return "A variante executada também devolveu PDF não-A4. O body de conversão não resolveu; testar print endpoint/layout ou corrigir configuração Jasmin da série FA."
if qpdf and not qpdf.get("format_warning") and ipdf and ipdf.get("format_warning"):
return (
"O ORC imprime A4, mas a FA ligada imprime em formato estranho. Antes de assumir erro de configuração, "
"usar este script com --execute-convert num orçamento de teste/real autorizado e body-mode explicit-fa-print. "
"Se continuar não-A4, a correção é no endpoint de print/layout da FA ou na configuração da série FA2026."
)
return (
"Probe read-only concluído. Verifica campos interesting_fields e body_candidates. "
"Só executar conversão com --execute-convert se este ORC puder gerar uma fatura real."
)
def _print_summary(result: dict[str, Any]) -> None:
q = result.get("quotation") or {}
print(f"QUOTATION={result.get('requested_quotation')} external_id={q.get('external_id')}")
print(f"quotation_pdf={_pdf_box_summary(result.get('quotation_pdf') or {})}")
linked = result.get("linked_invoice") or {}
if linked:
print(f"linked_invoice={linked.get('invoice_number')} external_id={linked.get('external_id')}")
print(f"linked_invoice_pdf={_pdf_box_summary(linked.get('invoice_pdf') or {})}")
else:
print("linked_invoice=none")
conv = result.get("conversion_probe") or {}
if conv.get("executed"):
print(f"conversion_status={conv.get('status_code')} created_invoice={conv.get('created_invoice_external_id_guess')}")
print(f"created_invoice_pdf={_pdf_box_summary(conv.get('created_invoice_pdf') or {})}")
else:
print("conversion_executed=False")
print("RECOMMENDATION:")
print(result.get("recommendation") or "")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Probe Jasmin ORC->FA conversion API safely.")
p.add_argument("--quotation", required=True, help="Quotation document number/external id, e.g. ORC.ORC2026.185")
p.add_argument("--save-pdf-dir", default="", help="Directory where fetched PDFs are saved for inspection")
p.add_argument("--probe-options", action=argparse.BooleanOptionalAction, default=True, help="Probe safe OPTIONS/GET surface for fromQuotation endpoint")
p.add_argument("--invoice-type", default=os.getenv("JASMIN_INVOICE_TYPE", "FA"))
p.add_argument("--invoice-serie", default=os.getenv("JASMIN_INVOICE_SERIE", "FA2026"))
p.add_argument("--document-date", default="", help="YYYY-MM-DD used in body candidates; defaults to today")
p.add_argument("--print-layout", default=os.getenv("JASMIN_INVOICE_PRINT_LAYOUT", ""))
p.add_argument("--printed-report-name", default=os.getenv("JASMIN_INVOICE_PRINTED_REPORT_NAME", ""))
p.add_argument("--execute-convert", action="store_true", help="DANGEROUS: performs POST fromQuotation and can create a real invoice")
p.add_argument("--confirm-document", default="", help="Required with --execute-convert; must exactly match --quotation")
p.add_argument("--body-mode", choices=["empty", "explicit-fa", "explicit-fa-print"], default="explicit-fa-print")
p.add_argument("--body-json", default="", help="Custom JSON object body for conversion; overrides --body-mode")
return p
def main() -> int:
args = build_parser().parse_args()
return asyncio.run(_main_async(args))
if __name__ == "__main__":
raise SystemExit(main())