"""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"""
{error_html}
Clientes
Dados fiscais e moradas vivem aqui. A oportunidade mostra só o estado da compra.
| Cliente | Contactos | Localidade | Jasmin | Pipeline | Atualizado |
{rows}
"""
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.
"""
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", '', "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
| Oportunidade | Estado | Valor | Atualizada |
{opp_rows}
Documentos Jasmin
| Tipo | Número/ID | Estado | Valor | Criado |
{doc_rows}
Envios Packlink
| Transportadora | Serviço | Estado | Referência | Tracking |
{shipment_rows}
"""
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)