Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
381
app/jasmin_fiscal_sync_service.py
Normal file
381
app/jasmin_fiscal_sync_service.py
Normal file
@@ -0,0 +1,381 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
from app.commercial_service import ensure_commercial_schema, normalize_tax_id
|
||||
from app.opportunity_service import ensure_opportunity_schema
|
||||
|
||||
|
||||
def _clean(value: Any, max_len: int = 260) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
text_value = re.sub(r"\s+", " ", str(value).replace("\n", " ")).strip()
|
||||
return text_value[:max_len]
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value or {}, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _as_dict(value: Any) -> Dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _first_from(data: Dict[str, Any], *keys: str) -> str:
|
||||
for key in keys:
|
||||
if key in data and _clean(data.get(key)):
|
||||
return _clean(data.get(key))
|
||||
return ""
|
||||
|
||||
|
||||
def _deep_first(data: Dict[str, Any], *keys: str) -> str:
|
||||
stack: List[Any] = [data]
|
||||
seen = 0
|
||||
while stack and seen < 100:
|
||||
seen += 1
|
||||
current = stack.pop(0)
|
||||
if isinstance(current, dict):
|
||||
direct = _first_from(current, *keys)
|
||||
if direct:
|
||||
return direct
|
||||
for value in current.values():
|
||||
if isinstance(value, (dict, list)):
|
||||
stack.append(value)
|
||||
elif isinstance(current, list):
|
||||
stack.extend(v for v in current if isinstance(v, (dict, list)))
|
||||
return ""
|
||||
|
||||
|
||||
def _split_pt_address(address: str) -> Tuple[str, str, str]:
|
||||
clean = _clean(address, 400)
|
||||
if not clean:
|
||||
return "", "", ""
|
||||
match = re.search(r"\b(\d{4}-\d{3}|\d{4})\b\s*(.*)$", clean)
|
||||
if not match:
|
||||
return clean, "", ""
|
||||
return clean[: match.start()].strip(" ,-"), match.group(1), _clean(match.group(2), 90)
|
||||
|
||||
|
||||
def _normalize_customer_payload(doc: Dict[str, Any]) -> Dict[str, Any]:
|
||||
payload = _as_dict(doc.get("payload"))
|
||||
record = _as_dict(payload.get("record") or payload.get("jasmin_details") or payload.get("jasmin_payload") or payload)
|
||||
nested_customer: Dict[str, Any] = {}
|
||||
for key in ("customer", "customerParty", "buyerCustomerParty", "party", "billing", "invoiceTo", "shipTo"):
|
||||
if isinstance(record.get(key), dict):
|
||||
nested_customer.update(record.get(key) or {})
|
||||
merged = {**record, **nested_customer}
|
||||
|
||||
name = _deep_first(merged, "customerName", "partyName", "buyerCustomerPartyName", "name", "companyName") or _clean(doc.get("company"))
|
||||
tax_id = normalize_tax_id(_deep_first(merged, "customerTaxId", "customerTaxID", "companyTaxID", "companyTaxId", "partyTaxID", "taxID", "taxId", "vat", "nif"))
|
||||
email = _deep_first(merged, "customerEmail", "electronicMail", "email", "billingEmail")
|
||||
phone = _deep_first(merged, "phone", "telephone", "mobile", "phoneNumber")
|
||||
street = _deep_first(merged, "streetName", "street", "address", "billingAddress", "addressLine1", "line1")
|
||||
parsed_street, parsed_zip, parsed_city = _split_pt_address(street)
|
||||
street = parsed_street or street
|
||||
postal = _deep_first(merged, "postalZone", "postalCode", "zip", "postcode") or parsed_zip
|
||||
city = _deep_first(merged, "cityName", "city", "locality") or parsed_city
|
||||
country = _deep_first(merged, "country", "countryCode", "countryKey") or "PT"
|
||||
party_key = _deep_first(merged, "customerPartyKey", "buyerCustomerParty", "partyKey", "naturalKey") or _clean(doc.get("customer_party_key"))
|
||||
|
||||
return {k: v for k, v in {
|
||||
"name": name,
|
||||
"tax_id": tax_id,
|
||||
"email": email,
|
||||
"phone": phone,
|
||||
"street_name": street,
|
||||
"postal_zone": postal,
|
||||
"city_name": city,
|
||||
"country": country or "PT",
|
||||
"jasmin_customer_party_key": party_key,
|
||||
"jasmin_customer_id": _deep_first(merged, "customerId", "id", "uuid"),
|
||||
}.items() if _clean(v)}
|
||||
|
||||
|
||||
def _missing_fields(customer: Dict[str, Any]) -> List[str]:
|
||||
labels = []
|
||||
for key, label in [
|
||||
("email", "email de faturação"),
|
||||
("street_name", "morada fiscal"),
|
||||
("postal_zone", "código postal"),
|
||||
("city_name", "localidade"),
|
||||
]:
|
||||
if not _clean(customer.get(key)):
|
||||
labels.append(label)
|
||||
return labels
|
||||
|
||||
|
||||
def _select_candidate_doc(conn: Any, opportunity_id: str) -> Optional[Dict[str, Any]]:
|
||||
rows = conn.execute(text("""
|
||||
SELECT
|
||||
d.id::text,
|
||||
d.customer_id::text,
|
||||
d.document_kind,
|
||||
d.document_number,
|
||||
d.customer_party_key,
|
||||
d.payload,
|
||||
d.company,
|
||||
d.created_at,
|
||||
d.updated_at,
|
||||
c.id::text AS doc_customer_id,
|
||||
c.name AS doc_customer_name,
|
||||
c.tax_id AS doc_customer_tax_id,
|
||||
c.email AS doc_customer_email,
|
||||
c.phone AS doc_customer_phone,
|
||||
c.street_name AS doc_customer_street_name,
|
||||
c.postal_zone AS doc_customer_postal_zone,
|
||||
c.city_name AS doc_customer_city_name,
|
||||
c.country AS doc_customer_country,
|
||||
c.jasmin_customer_party_key AS doc_customer_party_key,
|
||||
c.jasmin_customer_id AS doc_customer_jasmin_id
|
||||
FROM commercial_documents d
|
||||
LEFT JOIN customers c ON c.id = d.customer_id
|
||||
WHERE d.opportunity_id = CAST(:opportunity_id AS UUID)
|
||||
AND d.system = 'jasmin'
|
||||
AND d.document_kind IN ('quotation', 'invoice', 'proforma')
|
||||
AND COALESCE(d.is_active, TRUE) = TRUE
|
||||
ORDER BY
|
||||
CASE d.document_kind WHEN 'invoice' THEN 1 WHEN 'quotation' THEN 2 WHEN 'proforma' THEN 3 ELSE 4 END,
|
||||
COALESCE(d.document_date, d.created_at::date) DESC,
|
||||
d.created_at DESC
|
||||
LIMIT 5
|
||||
"""), {"opportunity_id": opportunity_id}).mappings().all()
|
||||
best = None
|
||||
best_score = -1
|
||||
for row in rows:
|
||||
doc = dict(row)
|
||||
candidate = _normalize_customer_payload(doc)
|
||||
if doc.get("doc_customer_id"):
|
||||
candidate = {
|
||||
**candidate,
|
||||
"id": doc.get("doc_customer_id"),
|
||||
"name": candidate.get("name") or doc.get("doc_customer_name"),
|
||||
"tax_id": candidate.get("tax_id") or doc.get("doc_customer_tax_id"),
|
||||
"email": candidate.get("email") or doc.get("doc_customer_email"),
|
||||
"phone": candidate.get("phone") or doc.get("doc_customer_phone"),
|
||||
"street_name": candidate.get("street_name") or doc.get("doc_customer_street_name"),
|
||||
"postal_zone": candidate.get("postal_zone") or doc.get("doc_customer_postal_zone"),
|
||||
"city_name": candidate.get("city_name") or doc.get("doc_customer_city_name"),
|
||||
"country": candidate.get("country") or doc.get("doc_customer_country") or "PT",
|
||||
"jasmin_customer_party_key": candidate.get("jasmin_customer_party_key") or doc.get("doc_customer_party_key"),
|
||||
"jasmin_customer_id": candidate.get("jasmin_customer_id") or doc.get("doc_customer_jasmin_id"),
|
||||
}
|
||||
score = sum(1 for key in ("name", "tax_id", "email", "street_name", "postal_zone", "city_name") if _clean(candidate.get(key)))
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = {"document": doc, "candidate": candidate}
|
||||
return best
|
||||
|
||||
|
||||
def get_jasmin_fiscal_sync_preview(opportunity_id: str) -> Dict[str, Any]:
|
||||
"""Read-only preview for fiscal data available in linked Jasmin documents."""
|
||||
ensure_opportunity_schema()
|
||||
ensure_commercial_schema()
|
||||
with engine.begin() as conn:
|
||||
opp = conn.execute(text("""
|
||||
SELECT o.id::text, o.local_customer_id::text, o.title,
|
||||
c.id::text AS customer_id, c.name, c.tax_id, c.email, c.phone,
|
||||
c.street_name, c.postal_zone, c.city_name, c.country,
|
||||
c.jasmin_customer_party_key, c.jasmin_customer_id
|
||||
FROM opportunities o
|
||||
LEFT JOIN customers c ON c.id = o.local_customer_id
|
||||
WHERE o.id = CAST(:opportunity_id AS UUID)
|
||||
LIMIT 1
|
||||
"""), {"opportunity_id": opportunity_id}).mappings().first()
|
||||
if not opp:
|
||||
return {"available": False, "reason": "opportunity_not_found"}
|
||||
selected = _select_candidate_doc(conn, opportunity_id)
|
||||
if not selected:
|
||||
return {"available": False, "reason": "no_jasmin_document"}
|
||||
candidate = selected["candidate"]
|
||||
if not candidate.get("id") and candidate.get("tax_id"):
|
||||
row = conn.execute(text("""
|
||||
SELECT id::text, name, tax_id, email, phone, street_name, postal_zone,
|
||||
city_name, country, jasmin_customer_party_key, jasmin_customer_id
|
||||
FROM customers
|
||||
WHERE tax_id = :tax_id
|
||||
LIMIT 1
|
||||
"""), {"tax_id": candidate.get("tax_id")}).mappings().first()
|
||||
if row:
|
||||
c = dict(row)
|
||||
candidate = {**candidate, "id": c.get("id")}
|
||||
for key in ("name", "tax_id", "email", "phone", "street_name", "postal_zone", "city_name", "country", "jasmin_customer_party_key", "jasmin_customer_id"):
|
||||
candidate[key] = candidate.get(key) or c.get(key)
|
||||
linked = dict(opp)
|
||||
linked_tax = normalize_tax_id(linked.get("tax_id"))
|
||||
cand_tax = normalize_tax_id(candidate.get("tax_id"))
|
||||
conflict = bool(linked.get("customer_id") and linked_tax and cand_tax and linked_tax != cand_tax)
|
||||
missing_now = _missing_fields(linked if linked.get("customer_id") else {})
|
||||
fillable = [key for key in ("email", "phone", "street_name", "postal_zone", "city_name", "country") if not _clean(linked.get(key)) and _clean(candidate.get(key))]
|
||||
if not linked.get("customer_id"):
|
||||
fillable = [key for key in ("name", "tax_id", "email", "phone", "street_name", "postal_zone", "city_name", "country") if _clean(candidate.get(key))]
|
||||
return {
|
||||
"available": bool(candidate.get("name") or candidate.get("tax_id")),
|
||||
"conflict": conflict,
|
||||
"linked_customer_id": linked.get("customer_id"),
|
||||
"candidate_customer_id": candidate.get("id"),
|
||||
"candidate": candidate,
|
||||
"document": {k: selected["document"].get(k) for k in ("id", "document_kind", "document_number")},
|
||||
"fillable_fields": fillable,
|
||||
"missing_fields": missing_now,
|
||||
"reason": "nif_divergent" if conflict else "ok",
|
||||
}
|
||||
|
||||
|
||||
def apply_jasmin_fiscal_sync(opportunity_id: str, *, actor: str = "operator") -> Dict[str, Any]:
|
||||
"""Fill only empty customer fields from Jasmin and link/create when safe."""
|
||||
ensure_opportunity_schema()
|
||||
ensure_commercial_schema()
|
||||
preview = get_jasmin_fiscal_sync_preview(opportunity_id)
|
||||
if not preview.get("available"):
|
||||
raise ValueError("Não foram encontrados dados fiscais Jasmin associados a esta oportunidade.")
|
||||
if preview.get("conflict"):
|
||||
raise ValueError("NIF divergente entre cliente fiscal local e dados Jasmin; rever associação antes de importar.")
|
||||
candidate = dict(preview.get("candidate") or {})
|
||||
if not (candidate.get("name") or candidate.get("tax_id")):
|
||||
raise ValueError("Dados Jasmin insuficientes para criar/associar cliente fiscal.")
|
||||
|
||||
with engine.begin() as conn:
|
||||
opp = conn.execute(text("""
|
||||
SELECT o.id::text, o.local_customer_id::text
|
||||
FROM opportunities o
|
||||
WHERE o.id = CAST(:opportunity_id AS UUID)
|
||||
LIMIT 1
|
||||
"""), {"opportunity_id": opportunity_id}).mappings().first()
|
||||
linked_customer_id = str(opp.get("local_customer_id") or "") if opp else ""
|
||||
target_customer_id = linked_customer_id or str(preview.get("candidate_customer_id") or "")
|
||||
created = False
|
||||
if target_customer_id:
|
||||
existing = conn.execute(text("SELECT id::text FROM customers WHERE id = CAST(:id AS UUID) LIMIT 1"), {"id": target_customer_id}).mappings().first()
|
||||
if not existing:
|
||||
target_customer_id = ""
|
||||
if not target_customer_id:
|
||||
target_customer_id = conn.execute(text("""
|
||||
INSERT INTO customers (
|
||||
name, tax_id, email, phone, street_name, postal_zone, city_name, country,
|
||||
jasmin_customer_party_key, jasmin_customer_id, metadata, updated_at
|
||||
) VALUES (
|
||||
:name, NULLIF(:tax_id,''), NULLIF(:email,''), NULLIF(:phone,''), NULLIF(:street_name,''),
|
||||
NULLIF(:postal_zone,''), NULLIF(:city_name,''), COALESCE(NULLIF(:country,''), 'PT'),
|
||||
NULLIF(:party_key,''), NULLIF(:jasmin_id,''), CAST(:metadata AS JSONB), now()
|
||||
)
|
||||
RETURNING id::text
|
||||
"""), {
|
||||
"name": _clean(candidate.get("name")) or "Cliente Jasmin",
|
||||
"tax_id": normalize_tax_id(candidate.get("tax_id")),
|
||||
"email": _clean(candidate.get("email")),
|
||||
"phone": _clean(candidate.get("phone")),
|
||||
"street_name": _clean(candidate.get("street_name")),
|
||||
"postal_zone": _clean(candidate.get("postal_zone")),
|
||||
"city_name": _clean(candidate.get("city_name")),
|
||||
"country": _clean(candidate.get("country") or "PT"),
|
||||
"party_key": _clean(candidate.get("jasmin_customer_party_key")),
|
||||
"jasmin_id": _clean(candidate.get("jasmin_customer_id")),
|
||||
"metadata": _json({"source": "jasmin_fiscal_sync", "opportunity_id": opportunity_id}),
|
||||
}).scalar()
|
||||
created = True
|
||||
before = conn.execute(text("""
|
||||
SELECT id::text, name, tax_id, email, phone, street_name, postal_zone,
|
||||
city_name, country, jasmin_customer_party_key, jasmin_customer_id
|
||||
FROM customers WHERE id = CAST(:id AS UUID)
|
||||
"""), {"id": target_customer_id}).mappings().first()
|
||||
before_dict = dict(before or {})
|
||||
local_tax = normalize_tax_id(before_dict.get("tax_id"))
|
||||
jasmin_tax = normalize_tax_id(candidate.get("tax_id"))
|
||||
if local_tax and jasmin_tax and local_tax != jasmin_tax:
|
||||
raise ValueError("NIF divergente entre cliente local e Jasmin; importação bloqueada.")
|
||||
params = {
|
||||
"id": target_customer_id,
|
||||
"tax_id": jasmin_tax,
|
||||
"email": _clean(candidate.get("email")),
|
||||
"phone": _clean(candidate.get("phone")),
|
||||
"street_name": _clean(candidate.get("street_name")),
|
||||
"postal_zone": _clean(candidate.get("postal_zone")),
|
||||
"city_name": _clean(candidate.get("city_name")),
|
||||
"country": _clean(candidate.get("country") or "PT"),
|
||||
"party_key": _clean(candidate.get("jasmin_customer_party_key")),
|
||||
"jasmin_id": _clean(candidate.get("jasmin_customer_id")),
|
||||
"metadata": _json({"jasmin_fiscal_sync": {"opportunity_id": opportunity_id, "document": preview.get("document"), "actor": actor}}),
|
||||
}
|
||||
row = conn.execute(text("""
|
||||
UPDATE customers
|
||||
SET tax_id = COALESCE(NULLIF(tax_id,''), NULLIF(:tax_id,'')),
|
||||
email = COALESCE(NULLIF(email,''), NULLIF(:email,'')),
|
||||
phone = COALESCE(NULLIF(phone,''), NULLIF(:phone,'')),
|
||||
street_name = COALESCE(NULLIF(street_name,''), NULLIF(:street_name,'')),
|
||||
postal_zone = COALESCE(NULLIF(postal_zone,''), NULLIF(:postal_zone,'')),
|
||||
city_name = COALESCE(NULLIF(city_name,''), NULLIF(:city_name,'')),
|
||||
country = COALESCE(NULLIF(country,''), NULLIF(:country,''), 'PT'),
|
||||
jasmin_customer_party_key = COALESCE(NULLIF(jasmin_customer_party_key,''), NULLIF(:party_key,'')),
|
||||
jasmin_customer_id = COALESCE(NULLIF(jasmin_customer_id,''), NULLIF(:jasmin_id,'')),
|
||||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:id AS UUID)
|
||||
RETURNING id::text, name, tax_id, email, phone, street_name, postal_zone,
|
||||
city_name, country, jasmin_customer_party_key, jasmin_customer_id
|
||||
"""), params).mappings().first()
|
||||
conn.execute(text("""
|
||||
UPDATE opportunities
|
||||
SET local_customer_id = CAST(:customer_id AS UUID), updated_at = now()
|
||||
WHERE id = CAST(:opportunity_id AS UUID)
|
||||
AND (local_customer_id IS NULL OR local_customer_id = CAST(:customer_id AS UUID))
|
||||
"""), {"customer_id": target_customer_id, "opportunity_id": opportunity_id})
|
||||
conn.execute(text("""
|
||||
UPDATE commercial_documents
|
||||
SET customer_id = COALESCE(customer_id, CAST(:customer_id AS UUID)), updated_at = now()
|
||||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||||
AND system = 'jasmin'
|
||||
"""), {"customer_id": target_customer_id, "opportunity_id": opportunity_id})
|
||||
after_dict = dict(row or {})
|
||||
filled = [key for key in ("tax_id", "email", "phone", "street_name", "postal_zone", "city_name", "country") if not _clean(before_dict.get(key)) and _clean(after_dict.get(key))]
|
||||
conn.execute(text("""
|
||||
INSERT INTO opportunity_events (id, opportunity_id, event_type, note, payload, created_by)
|
||||
VALUES (gen_random_uuid(), CAST(:opportunity_id AS UUID), 'jasmin_fiscal_sync', :note, CAST(:payload AS JSONB), :actor)
|
||||
"""), {
|
||||
"opportunity_id": opportunity_id,
|
||||
"note": "Dados fiscais completados a partir do Jasmin." if filled or created else "Cliente fiscal associado a partir do Jasmin.",
|
||||
"payload": _json({"customer_id": target_customer_id, "created_customer": created, "filled_fields": filled, "document": preview.get("document"), "candidate": candidate}),
|
||||
"actor": actor,
|
||||
})
|
||||
return {"customer_id": target_customer_id, "created_customer": created, "filled_fields": filled, "customer": after_dict}
|
||||
|
||||
|
||||
def audit_jasmin_fiscal_gaps(limit: int = 200) -> List[Dict[str, Any]]:
|
||||
ensure_opportunity_schema()
|
||||
ensure_commercial_schema()
|
||||
findings: List[Dict[str, Any]] = []
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(text("""
|
||||
SELECT DISTINCT o.id::text, o.title, o.customer_name
|
||||
FROM opportunities o
|
||||
JOIN commercial_documents d ON d.opportunity_id = o.id AND d.system = 'jasmin'
|
||||
LEFT JOIN customers c ON c.id = o.local_customer_id
|
||||
WHERE COALESCE(o.status, 'open') <> 'closed'
|
||||
AND (
|
||||
o.local_customer_id IS NULL
|
||||
OR COALESCE(c.email,'') = ''
|
||||
OR COALESCE(c.street_name,'') = ''
|
||||
OR COALESCE(c.postal_zone,'') = ''
|
||||
OR COALESCE(c.city_name,'') = ''
|
||||
)
|
||||
ORDER BY o.id
|
||||
LIMIT :limit
|
||||
"""), {"limit": int(limit)}).mappings().all()
|
||||
for row in rows:
|
||||
preview = get_jasmin_fiscal_sync_preview(str(row.get("id")))
|
||||
if preview.get("available") and (preview.get("fillable_fields") or not preview.get("linked_customer_id")):
|
||||
cand = preview.get("candidate") or {}
|
||||
findings.append({
|
||||
"severity": "médio" if not preview.get("linked_customer_id") else "baixo",
|
||||
"code": "jasmin_fiscal_data_available",
|
||||
"title": row.get("title") or row.get("customer_name") or row.get("id"),
|
||||
"detail": "Dados fiscais vazios no ClientFlow mas disponíveis no Jasmin.",
|
||||
"url": f"/opportunities/{row.get('id')}",
|
||||
"candidate_name": cand.get("name"),
|
||||
"candidate_tax_id": cand.get("tax_id"),
|
||||
})
|
||||
return findings
|
||||
Reference in New Issue
Block a user