Release v4928.1.4.2 stable
This commit is contained in:
383
app/packlink_service.py
Normal file
383
app/packlink_service.py
Normal file
@@ -0,0 +1,383 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user