"""Customer routes. Moved from app.admin_dashboard in v4.7.2. The handlers still reuse legacy helpers to keep this refactor behavior-preserving. """ from fastapi import APIRouter import re from fastapi.responses import PlainTextResponse import app.admin_dashboard as legacy from app.admin_dashboard import * # noqa: F401,F403 router = APIRouter() _EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") def _valid_optional_email(value: str) -> bool: value = str(value or "").strip() return not value or bool(_EMAIL_RE.match(value)) @router.get("/customers", response_class=HTMLResponse) @router.get("/clientes", response_class=HTMLResponse) async def customers_page(q: Optional[str] = None): try: from app.commercial_service import list_customers customer_rows = list_customers(q=q, limit=300) error = "" except Exception as exc: customer_rows = [] error = str(exc) rows = "" for c in customer_rows: jasmin_state = "Ligado" if c.get("jasmin_customer_party_key") else "Por validar" jasmin_cls = "cf-chip-green" if c.get("jasmin_customer_party_key") else "cf-chip-orange" rows += f""" {esc(c.get('name') or 'Cliente')}
NIF {esc(c.get('tax_id') or '—')}
{esc(c.get('email') or '—')}
{esc(c.get('phone') or '')}
{esc(c.get('city_name') or '—')}
{esc(c.get('postal_zone') or '')}
{esc(jasmin_state)}
{esc(c.get('jasmin_customer_party_key') or '')}
{int(c.get('opportunity_count') or 0)} oportunidades {esc(fmt_dt(c.get('updated_at')))} """ if not rows: rows = 'Sem clientes locais. Cria uma ficha ou associa a partir de uma oportunidade.' error_html = f'
{esc(error)}
' if error else '' body = f"""
Limpar

Novo cliente

{error_html}

Clientes

Dados fiscais e moradas vivem aqui. A oportunidade mostra só o estado da compra.
{rows}
ClienteContactosLocalidadeJasminPipelineAtualizado
""" return layout("Clientes", "Ficha fiscal, contactos e documentos por cliente", body, "customers") @router.post("/customers/create") async def create_customer_action(request: Request): form = await request.form() try: from app.commercial_service import upsert_customer customer = upsert_customer({ "name": str(form.get("name") or "").strip(), "tax_id": str(form.get("tax_id") or "").strip(), "email": str(form.get("email") or "").strip(), "phone": str(form.get("phone") or "").strip(), }) except ValueError as exc: return PlainTextResponse(f"Dados inválidos ao criar cliente: {exc}", status_code=422) except Exception as exc: return PlainTextResponse(f"Erro ao criar cliente: {exc}", status_code=500) return RedirectResponse(f"/customers/{customer.get('id')}", status_code=303) @router.get("/customers/new", response_class=HTMLResponse) async def customer_new_page(name: Optional[str] = None, tax_id: Optional[str] = None, email: Optional[str] = None): body = f""" ← Voltar a clientes

Novo cliente fiscal

Cria uma ficha fiscal para associar a oportunidades, documentos Jasmin e processos de envio.
Cancelar
""" return layout("Novo cliente", "Criar ficha fiscal", body, "customers") @router.get("/customers/{customer_id}", response_class=HTMLResponse) async def customer_detail_page(customer_id: str): if not is_uuid_text(customer_id): return PlainTextResponse("Identificador de cliente inválido.", status_code=422) try: from app.commercial_service import get_customer, list_commercial_documents, list_opportunities_for_customer, list_shipments customer = get_customer(customer_id) if not customer: return layout("Cliente não encontrado", "Clientes", '
Cliente não encontrado.
', "customers") docs = list_commercial_documents(customer_id=customer_id, limit=100) opps = list_opportunities_for_customer(customer_id, limit=50) shipments = list_shipments(customer_id=customer_id, limit=50) except Exception as exc: return PlainTextResponse(f"Erro ao abrir cliente: {exc}", status_code=500) doc_rows = "" for d in docs: number = d.get("document_number") or " ".join([str(d.get("document_type") or ""), str(d.get("serie") or ""), str(d.get("series_number") or "")]).strip() or d.get("external_id") or "—" kind = {"quotation": "Orçamento", "invoice": "Fatura"}.get(str(d.get("document_kind") or ""), d.get("document_kind") or "Documento") doc_rows += f"{esc(kind)}{esc(number)}{operation_status_badge(str(d.get('status') or 'created'))}{money_html(d.get('total_amount') or d.get('amount') or 0)}{esc(fmt_dt(d.get('created_at')))}" if not doc_rows: doc_rows = 'Sem documentos Jasmin locais.' opp_rows = "" for o in opps: opp_rows += f"{esc(o.get('title') or 'Oportunidade')}
{esc(o.get('product_interest') or '')}
{opportunity_stage_badge(o.get('stage'))}{money_html(o.get('value_amount') or 0)}{esc(fmt_dt(o.get('updated_at')))}" if not opp_rows: opp_rows = 'Sem oportunidades associadas.' shipment_rows = "" for sh in shipments: shipment_rows += f"{esc(sh.get('carrier') or '—')}{esc(sh.get('service_name') or '—')}{operation_status_badge(str(sh.get('status') or 'created'))}{esc(sh.get('external_reference') or '')}{esc(sh.get('tracking_code') or '—')}" if not shipment_rows: shipment_rows = 'Sem envios Packlink locais.' body = f""" ← Voltar a clientes

{esc(customer.get('name'))}

NIF {esc(customer.get('tax_id') or '—')}

Nova oportunidade

Cria um processo comercial manual já ligado a este cliente fiscal.

Oportunidades

{opp_rows}
OportunidadeEstadoValorAtualizada

Documentos Jasmin

{doc_rows}
TipoNúmero/IDEstadoValorCriado

Envios Packlink

{shipment_rows}
TransportadoraServiçoEstadoReferênciaTracking
""" return layout(str(customer.get("name") or "Cliente"), "Ficha fiscal, oportunidades e documentos", body, "customers") @router.post("/customers/{customer_id}/opportunities/create") async def create_customer_opportunity_action(customer_id: str, request: Request): if not is_uuid_text(customer_id): return PlainTextResponse("Identificador de cliente inválido.", status_code=422) form = await request.form() contact_email = str(form.get("contact_email") or "").strip() if not _valid_optional_email(contact_email): return PlainTextResponse("Email de contacto inválido.", status_code=422) meaningful = any(str(form.get(k) or "").strip() for k in ("contact_name", "contact_phone", "product_interest", "notes")) or bool(contact_email) if not meaningful: return PlainTextResponse("Dados insuficientes para criar oportunidade.", status_code=422) if contact_email and not any(str(form.get(k) or "").strip() for k in ("contact_name", "contact_phone", "product_interest", "notes")): return PlainTextResponse("Dados insuficientes para criar oportunidade: indique produto, notas ou outro contacto válido.", status_code=422) try: from app.opportunity_service import create_manual_opportunity_from_customer result = create_manual_opportunity_from_customer( customer_id, origin=str(form.get("origin") or "phone").strip(), request_type=str(form.get("request_type") or "quote").strip(), contact_name=str(form.get("contact_name") or "").strip(), contact_email=str(form.get("contact_email") or "").strip(), contact_phone=str(form.get("contact_phone") or "").strip(), product_interest=str(form.get("product_interest") or "").strip(), notes=str(form.get("notes") or "").strip(), create_task=bool(form.get("create_task")), created_by="operator", ) except ValueError as exc: return PlainTextResponse(f"Dados inválidos ao criar oportunidade: {exc}", status_code=422) except Exception as exc: return PlainTextResponse(f"Erro ao criar oportunidade: {exc}", status_code=500) return RedirectResponse(result.get("next_url") or f"/customers/{customer_id}", status_code=303) @router.post("/customers/{customer_id}/update") async def update_customer_action(customer_id: str, request: Request): if not is_uuid_text(customer_id): return PlainTextResponse("Identificador de cliente inválido.", status_code=422) form = await request.form() try: from app.commercial_service import update_customer update_customer(customer_id, { "name": str(form.get("name") or "").strip(), "tax_id": str(form.get("tax_id") or "").strip(), "email": str(form.get("email") or "").strip(), "phone": str(form.get("phone") or "").strip(), "street_name": str(form.get("street_name") or "").strip(), "postal_zone": str(form.get("postal_zone") or "").strip(), "city_name": str(form.get("city_name") or "").strip(), "country": str(form.get("country") or "PT").strip(), "jasmin_customer_party_key": str(form.get("jasmin_customer_party_key") or "").strip(), "jasmin_customer_id": str(form.get("jasmin_customer_id") or "").strip(), }) except ValueError as exc: return PlainTextResponse(f"Dados inválidos ao guardar cliente: {exc}", status_code=422) except Exception as exc: # Keep database details out of the operator UI. Duplicate NIFs are a # business conflict, not a technical 500. status = 409 if exc.__class__.__name__ == "DuplicateCustomerTaxIdError" else 500 return PlainTextResponse(f"Erro ao guardar cliente: {exc}", status_code=status) return RedirectResponse(f"/customers/{customer_id}", status_code=303)