384 lines
14 KiB
Python
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}
|