"""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}