Files
clientflow_backend/app/packlink_service.py
2026-06-09 22:55:58 +01:00

384 lines
14 KiB
Python

"""Serviço Packlink para ClientFlow.
Implementa a primeira integração segura:
- normalização de códigos postais PT para cotação;
- criação de payload de envio a partir da oportunidade/preparação;
- criação do envio via Packlink e registo em operation_links.
"""
from __future__ import annotations
import json
import os
import re
import uuid
from datetime import date, timedelta
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import text
from app.config import settings
from app.db import engine
from app.integration_outbox_service import create_outbox_item
from app.commercial_service import get_customer_for_opportunity
from app.operation_service import register_operation_action
from app.opportunity_service import get_opportunity
from app.packlink_client import PacklinkClient, PacklinkError
from app.product_service import list_opportunity_items
from app.workflow_guard import validate_operation_action
class PacklinkPayloadError(ValueError):
pass
def _env(name: str, default: str = "") -> str:
return os.getenv(name, default).strip()
def _env_float(name: str, default: float) -> float:
value = _env(name)
if not value:
return default
try:
return float(value)
except ValueError:
return default
def _as_float(value: Any, default: float = 0.0) -> float:
if value is None:
return default
if isinstance(value, Decimal):
return float(value)
try:
return float(value)
except Exception:
return default
def _compact(value: Any, limit: int = 120) -> str:
value = re.sub(r"\s+", " ", str(value or "")).strip()
return value[:limit].strip()
def normalize_packlink_zip(country: str, zip_code: str, *, for_quote: bool = True) -> str:
"""Normaliza código postal para Packlink.
Nos testes reais de PT, /services aceitou 4000 mas rejeitou 4000-001.
Para moradas de envio pode ser útil manter o código completo; para cotação
usamos os 4 primeiros dígitos.
"""
country = str(country or "").upper().strip()
zip_code = str(zip_code or "").strip()
if country == "PT" and for_quote:
match = re.search(r"\d{4}", zip_code)
if match:
return match.group(0)
return zip_code
def default_package() -> Dict[str, Any]:
return {
"height": int(_env_float("PACKLINK_DEFAULT_PACKAGE_HEIGHT", 10)),
"width": int(_env_float("PACKLINK_DEFAULT_PACKAGE_WIDTH", 20)),
"length": int(_env_float("PACKLINK_DEFAULT_PACKAGE_LENGTH", 30)),
"weight": round(_env_float("PACKLINK_DEFAULT_PACKAGE_WEIGHT", 2), 2),
}
def get_latest_shipment_preparation_for_opportunity(opportunity_id: str) -> Optional[Dict[str, Any]]:
sql = text("""
SELECT tp.*
FROM task_preparations tp
JOIN tasks t ON t.id = tp.task_id
WHERE t.opportunity_id = CAST(:opportunity_id AS UUID)
AND tp.prep_type IN ('shipment', 'pickup')
ORDER BY tp.created_at DESC
LIMIT 1
""")
with engine.begin() as conn:
row = conn.execute(sql, {"opportunity_id": opportunity_id}).mappings().first()
return dict(row) if row else None
def split_pt_address(address: str) -> Tuple[str, str, str]:
"""Extrai rua, código postal e cidade de uma morada PT em texto livre."""
address = str(address or "").strip()
if not address:
return "", "", ""
clean = re.sub(r"\s+", " ", address.replace("\n", " ")).strip()
match = re.search(r"\b(\d{4}-\d{3}|\d{4})\b\s*(.*)$", clean)
if not match:
return clean, "", ""
zip_code = match.group(1)
city = _compact(match.group(2), 80)
street = clean[: match.start()].strip(" ,-")
return street, zip_code, city
def _metadata_dict(opp: Dict[str, Any]) -> Dict[str, Any]:
value = opp.get("metadata") or {}
if isinstance(value, dict):
return value
try:
return json.loads(value)
except Exception:
return {}
def _shipment_data_from_sources(opportunity: Dict[str, Any]) -> Dict[str, Any]:
metadata = _metadata_dict(opportunity)
shipment = {}
for key in ("packlink", "shipment", "shipping"):
if isinstance(metadata.get(key), dict):
shipment.update(metadata[key])
prep = get_latest_shipment_preparation_for_opportunity(str(opportunity["id"]))
if prep:
extracted = prep.get("extracted_data") or {}
if isinstance(extracted, str):
try:
extracted = json.loads(extracted)
except Exception:
extracted = {}
if isinstance(extracted, dict):
prep_shipment = extracted.get("shipment") or {}
if isinstance(prep_shipment, dict):
# A preparação é mais recente, mas não deve apagar campos já explícitos.
shipment = {**prep_shipment, **shipment}
return shipment
def build_to_address(opportunity: Dict[str, Any]) -> Dict[str, Any]:
shipment = _shipment_data_from_sources(opportunity)
linked_customer = None
try:
linked_customer = get_customer_for_opportunity(str(opportunity.get("id")))
except Exception:
linked_customer = None
linked_customer = linked_customer or {}
delivery_address = (
shipment.get("delivery_address")
or shipment.get("address")
or shipment.get("street1")
or linked_customer.get("street_name")
or ""
)
street, parsed_zip, parsed_city = split_pt_address(delivery_address)
country = _compact(shipment.get("country") or shipment.get("recipient_country") or linked_customer.get("country") or "PT", 2).upper() or "PT"
zip_code = _compact(shipment.get("zip_code") or shipment.get("postal_code") or linked_customer.get("postal_zone") or parsed_zip, 20)
city = _compact(shipment.get("city") or linked_customer.get("city_name") or parsed_city, 80)
street1 = _compact(shipment.get("street1") or street or linked_customer.get("street_name") or delivery_address, 120)
name = _compact(
shipment.get("recipient_name")
or shipment.get("name")
or linked_customer.get("name")
or opportunity.get("customer_name")
or opportunity.get("customer_email")
or "Cliente",
60,
)
phone = _compact(shipment.get("recipient_phone") or linked_customer.get("phone") or opportunity.get("customer_phone") or _env("PACKLINK_FALLBACK_PHONE"), 30)
email = _compact(shipment.get("recipient_email") or linked_customer.get("email") or opportunity.get("customer_email") or _env("PACKLINK_FALLBACK_EMAIL"), 120)
missing = []
if not street1:
missing.append("morada de entrega")
if not zip_code:
missing.append("código postal de entrega")
if not city:
missing.append("cidade de entrega")
if not phone:
missing.append("telefone do destinatário")
if not email:
missing.append("email do destinatário")
if missing:
raise PacklinkPayloadError("Dados em falta para Packlink: " + ", ".join(missing))
return {
"name": name,
"surname": _compact(shipment.get("recipient_surname") or ".", 60),
"company": _compact(shipment.get("company") or "", 80),
"street1": street1,
"street2": _compact(shipment.get("street2") or "", 120),
"zip_code": zip_code,
"city": city,
"country": country,
"phone": phone,
"email": email,
}
def build_from_address() -> Dict[str, Any]:
required = {
"PACKLINK_SENDER_NAME": _env("PACKLINK_SENDER_NAME"),
"PACKLINK_SENDER_STREET1": _env("PACKLINK_SENDER_STREET1"),
"PACKLINK_SENDER_ZIP": _env("PACKLINK_SENDER_ZIP"),
"PACKLINK_SENDER_CITY": _env("PACKLINK_SENDER_CITY"),
"PACKLINK_SENDER_PHONE": _env("PACKLINK_SENDER_PHONE"),
"PACKLINK_SENDER_EMAIL": _env("PACKLINK_SENDER_EMAIL"),
}
missing = [k for k, v in required.items() if not v]
if missing:
raise PacklinkPayloadError("Configuração Packlink remetente em falta: " + ", ".join(missing))
return {
"name": required["PACKLINK_SENDER_NAME"],
"surname": _env("PACKLINK_SENDER_SURNAME", "."),
"company": _env("PACKLINK_SENDER_COMPANY"),
"street1": required["PACKLINK_SENDER_STREET1"],
"street2": _env("PACKLINK_SENDER_STREET2"),
"zip_code": required["PACKLINK_SENDER_ZIP"],
"city": required["PACKLINK_SENDER_CITY"],
"country": _env("PACKLINK_SENDER_COUNTRY", "PT"),
"phone": required["PACKLINK_SENDER_PHONE"],
"email": required["PACKLINK_SENDER_EMAIL"],
}
def default_collection_date() -> str:
# Dia seguinte; a Packlink devolve datas reais em /services, mas isto é um fallback.
return (date.today() + timedelta(days=int(_env_float("PACKLINK_COLLECTION_DAYS_AHEAD", 1)))).strftime("%Y/%m/%d")
def build_packlink_shipment_payload(opportunity_id: str) -> Dict[str, Any]:
opportunity = get_opportunity(opportunity_id)
if not opportunity:
raise PacklinkPayloadError(f"Oportunidade não encontrada: {opportunity_id}")
to_address = build_to_address(opportunity)
from_address = build_from_address()
items = list_opportunity_items(opportunity_id)
content_names = [str(i.get("product_name") or i.get("sku") or "Produto") for i in items] or [opportunity.get("product_interest") or "Produto"]
content_value = sum(_as_float(i.get("total_price")) for i in items) or _as_float(opportunity.get("value_amount"), 1.0)
service_id = _env("PACKLINK_DEFAULT_SERVICE_ID", "20571")
service_name = _env("PACKLINK_DEFAULT_SERVICE", "Paq 24")
carrier = _env("PACKLINK_DEFAULT_CARRIER", "Correos Express")
return {
"platform": _env("PACKLINK_PLATFORM", "PRO"),
"platform_country": _env("PACKLINK_PLATFORM_COUNTRY", "UN"),
"source": _env("PACKLINK_SOURCE", "PRO"),
"service": service_name,
"carrier": carrier,
"service_id": service_id,
"collection_date": _env("PACKLINK_COLLECTION_DATE") or default_collection_date(),
"collection_time": _env("PACKLINK_COLLECTION_TIME", "09:00-14:00"),
"from": from_address,
"to": to_address,
"packages": [default_package()],
"content": content_names[:5],
"contentvalue": round(float(content_value or 1.0), 2),
"content_second_hand": False,
"shipment_custom_reference": _compact(f"CF-{opportunity_id}", 50),
"priority": False,
"contentValue_currency": str(opportunity.get("currency") or "EUR"),
"has_customs": False,
}
async def quote_opportunity_shipping(opportunity_id: str) -> List[Dict[str, Any]]:
opportunity = get_opportunity(opportunity_id)
if not opportunity:
raise PacklinkPayloadError(f"Oportunidade não encontrada: {opportunity_id}")
to_address = build_to_address(opportunity)
from_address = build_from_address()
client = PacklinkClient()
return await client.quote_services(
from_country=from_address["country"],
from_zip=normalize_packlink_zip(from_address["country"], from_address["zip_code"], for_quote=True),
to_country=to_address["country"],
to_zip=normalize_packlink_zip(to_address["country"], to_address["zip_code"], for_quote=True),
packages=[default_package()],
source=_env("PACKLINK_SOURCE", "PRO"),
)
def enqueue_packlink_shipment(opportunity_id: str, *, created_by: str = "operator") -> str:
validate_operation_action(opportunity_id, "packlink_shipment")
payload = build_packlink_shipment_payload(opportunity_id)
outbox_id = create_outbox_item(
business_event_id=str(uuid.uuid4()),
target_system="packlink",
action_type="create_shipment",
payload={"opportunity_id": opportunity_id, "shipment": payload, "created_by": created_by},
idempotency_key=f"packlink:create_shipment:{opportunity_id}",
)
if not outbox_id:
raise PacklinkPayloadError("Já existe pedido Packlink pendente/enviado para esta oportunidade")
return outbox_id
async def create_shipment_from_outbox_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
opportunity_id = str(payload.get("opportunity_id") or "").strip()
if not opportunity_id:
raise PacklinkPayloadError("opportunity_id em falta no payload Packlink")
shipment_payload = payload.get("shipment") or build_packlink_shipment_payload(opportunity_id)
client = PacklinkClient()
result = await client.create_shipment(shipment_payload)
reference = result.get("reference") or result.get("packlink_reference") or result.get("id")
if not reference:
raise PacklinkError(f"Packlink não devolveu referência: {result}")
shipment = None
labels: List[Any] = []
tracking: List[Dict[str, Any]] = []
try:
shipment = await client.get_shipment(str(reference))
except Exception:
shipment = None
try:
labels = await client.get_labels(str(reference))
except Exception:
labels = []
try:
tracking = await client.get_tracking(str(reference))
except Exception:
tracking = []
payload_to_store = {
"packlink_result": result,
"shipment": shipment,
"labels": labels,
"tracking": tracking,
"request": shipment_payload,
}
external_url = (settings.packlink_public_url or "https://pro.packlink.pt").rstrip("/")
try:
from app.commercial_service import upsert_shipment_record, get_customer_for_opportunity
linked_customer = get_customer_for_opportunity(opportunity_id) or {}
upsert_shipment_record({
"customer_id": linked_customer.get("id"),
"opportunity_id": opportunity_id,
"external_reference": str(reference),
"carrier": shipment_payload.get("carrier"),
"service_id": shipment_payload.get("service_id"),
"service_name": shipment_payload.get("service"),
"status": "created",
"price": result.get("price") or result.get("total_price"),
"currency": result.get("currency") or shipment_payload.get("contentValue_currency") or "EUR",
"payload": payload_to_store,
})
except Exception:
pass
register_operation_action(
opportunity_id,
"packlink_shipment",
external_id=str(reference),
external_name=f"Packlink {reference}",
external_url=external_url,
note="Envio Packlink criado automaticamente pelo outbox.",
payload=payload_to_store,
created_by=str(payload.get("created_by") or "system"),
)
return {"reference": reference, "shipment": shipment, "labels": labels, "tracking": tracking}