Files

250 lines
20 KiB
Python

"""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"""
<tr>
<td><a class="cf-row-link" href="/customers/{esc(c.get('id'))}">{esc(c.get('name') or 'Cliente')}</a><div class="small text-secondary">NIF {esc(c.get('tax_id') or '')}</div></td>
<td><div class="text-break">{esc(c.get('email') or '')}</div><div class="small text-secondary">{esc(c.get('phone') or '')}</div></td>
<td>{esc(c.get('city_name') or '')}<div class="small text-secondary">{esc(c.get('postal_zone') or '')}</div></td>
<td><span class="cf-chip {jasmin_cls}">{esc(jasmin_state)}</span><div class="small text-secondary"><code>{esc(c.get('jasmin_customer_party_key') or '')}</code></div></td>
<td><span class="cf-chip cf-chip-blue">{int(c.get('opportunity_count') or 0)} oportunidades</span></td>
<td>{esc(fmt_dt(c.get('updated_at')))}</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="6" class="text-center text-secondary py-5">Sem clientes locais. Cria uma ficha ou associa a partir de uma oportunidade.</td></tr>'
error_html = f'<div class="alert alert-warning">{esc(error)}</div>' if error else ''
body = f"""
<div class="row g-3 mb-3">
<div class="col-lg-8">
<section class="card cf-card cf-filter-card">
<form method="get" action="/customers" class="row g-3 align-items-end">
<div class="col-lg-8"><label class="form-label small fw-bold text-secondary">Procurar cliente</label><input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="nome, NIF, email, telefone..."></div>
<div class="col-lg-4 d-flex gap-2"><button class="btn btn-primary flex-fill" type="submit">Filtrar</button><a class="btn btn-outline-secondary" href="/customers">Limpar</a></div>
</form>
</section>
</div>
<div class="col-lg-4">
<section class="card cf-card"><div class="card-body p-3"><h2 class="cf-section-title mb-2">Novo cliente</h2><form method="post" action="/customers/create" class="d-grid gap-2"><input class="form-control" name="name" placeholder="Nome fiscal" required><input class="form-control" name="tax_id" placeholder="NIF"><button class="btn btn-primary" type="submit">Criar ficha</button></form></div></section>
</div>
</div>
{error_html}
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Clientes</h2><div class="small text-secondary">Dados fiscais e moradas vivem aqui. A oportunidade mostra só o estado da compra.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Cliente</th><th>Contactos</th><th>Localidade</th><th>Jasmin</th><th>Pipeline</th><th>Atualizado</th></tr></thead><tbody>{rows}</tbody></table></div></div></section>
"""
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"""
<a class="cf-row-link d-inline-flex mb-3" href="/customers">← Voltar a clientes</a>
<section class="card cf-card">
<div class="card-body p-4">
<h1 class="h4 fw-bold mb-1">Novo cliente fiscal</h1>
<div class="text-secondary mb-4">Cria uma ficha fiscal para associar a oportunidades, documentos Jasmin e processos de envio.</div>
<form method="post" action="/customers/create" class="row g-3">
<div class="col-12"><label class="form-label small fw-bold text-secondary">Nome fiscal</label><input class="form-control" name="name" value="{esc(name or '')}" placeholder="Nome fiscal" required></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">NIF</label><input class="form-control" name="tax_id" value="{esc(tax_id or '')}" placeholder="NIF"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Email</label><input class="form-control" name="email" value="{esc(email or '')}" placeholder="email@empresa.pt"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Telefone</label><input class="form-control" name="phone" placeholder="Telefone"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">País</label><input class="form-control" name="country" value="PT"></div>
<div class="col-12 d-flex gap-2"><button class="btn btn-primary" type="submit">Criar ficha</button><a class="btn btn-outline-secondary" href="/customers">Cancelar</a></div>
</form>
</div>
</section>
"""
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", '<section class="cf-empty">Cliente não encontrado.</section>', "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"<tr><td>{esc(kind)}</td><td><code>{esc(number)}</code></td><td>{operation_status_badge(str(d.get('status') or 'created'))}</td><td>{money_html(d.get('total_amount') or d.get('amount') or 0)}</td><td>{esc(fmt_dt(d.get('created_at')))}</td></tr>"
if not doc_rows:
doc_rows = '<tr><td colspan="5" class="text-secondary py-4">Sem documentos Jasmin locais.</td></tr>'
opp_rows = ""
for o in opps:
opp_rows += f"<tr><td><a class='cf-row-link' href='/opportunities/{esc(o.get('id'))}'>{esc(o.get('title') or 'Oportunidade')}</a><div class='small text-secondary'>{esc(o.get('product_interest') or '')}</div></td><td>{opportunity_stage_badge(o.get('stage'))}</td><td>{money_html(o.get('value_amount') or 0)}</td><td>{esc(fmt_dt(o.get('updated_at')))}</td></tr>"
if not opp_rows:
opp_rows = '<tr><td colspan="4" class="text-secondary py-4">Sem oportunidades associadas.</td></tr>'
shipment_rows = ""
for sh in shipments:
shipment_rows += f"<tr><td>{esc(sh.get('carrier') or '')}</td><td>{esc(sh.get('service_name') or '')}</td><td>{operation_status_badge(str(sh.get('status') or 'created'))}</td><td><code>{esc(sh.get('external_reference') or '')}</code></td><td>{esc(sh.get('tracking_code') or '')}</td></tr>"
if not shipment_rows:
shipment_rows = '<tr><td colspan="5" class="text-secondary py-4">Sem envios Packlink locais.</td></tr>'
body = f"""
<a class="cf-row-link d-inline-flex mb-3" href="/customers">← Voltar a clientes</a>
<div class="row g-3">
<div class="col-lg-5">
<section class="card cf-card"><div class="card-body p-4"><h1 class="h4 fw-bold mb-1">{esc(customer.get('name'))}</h1><div class="text-secondary mb-3">NIF {esc(customer.get('tax_id') or '')}</div><form method="post" action="/customers/{esc(customer_id)}/update" class="row g-3">
<div class="col-12"><label class="form-label small fw-bold text-secondary">Nome fiscal</label><input class="form-control" name="name" value="{esc(customer.get('name') or '')}" required></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">NIF</label><input class="form-control" name="tax_id" value="{esc(customer.get('tax_id') or '')}"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">País</label><input class="form-control" name="country" value="{esc(customer.get('country') or 'PT')}"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Email</label><input class="form-control" name="email" value="{esc(customer.get('email') or '')}"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Telefone</label><input class="form-control" name="phone" value="{esc(customer.get('phone') or '')}"></div>
<div class="col-12"><label class="form-label small fw-bold text-secondary">Morada fiscal</label><input class="form-control" name="street_name" value="{esc(customer.get('street_name') or '')}"></div>
<div class="col-md-5"><label class="form-label small fw-bold text-secondary">Código postal</label><input class="form-control" name="postal_zone" value="{esc(customer.get('postal_zone') or '')}"></div>
<div class="col-md-7"><label class="form-label small fw-bold text-secondary">Cidade</label><input class="form-control" name="city_name" value="{esc(customer.get('city_name') or '')}"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Jasmin partyKey</label><input class="form-control" name="jasmin_customer_party_key" value="{esc(customer.get('jasmin_customer_party_key') or '')}"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Jasmin ID</label><input class="form-control" name="jasmin_customer_id" value="{esc(customer.get('jasmin_customer_id') or '')}"></div>
<div class="col-12"><button class="btn btn-primary" type="submit">Guardar cliente</button></div>
</form></div></section>
</div>
<div class="col-lg-7 d-grid gap-3">
<section class="card cf-card"><div class="card-body p-3">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3"><div><h2 class="cf-section-title mb-1">Nova oportunidade</h2><div class="small text-secondary">Cria um processo comercial manual já ligado a este cliente fiscal.</div></div></div>
<form method="post" action="/customers/{esc(customer_id)}/opportunities/create" class="row g-2 align-items-end">
<div class="col-md-3"><label class="form-label small fw-bold text-secondary">Origem</label><select class="form-select" name="origin"><option value="phone">Telefone</option><option value="whatsapp">WhatsApp</option><option value="email">Email</option><option value="presential">Presencial</option><option value="manual">Manual</option></select></div>
<div class="col-md-3"><label class="form-label small fw-bold text-secondary">Pedido</label><select class="form-select" name="request_type"><option value="quote">Orçamento</option><option value="info">Informação</option><option value="invoice">Fatura</option><option value="order">Encomenda</option><option value="support">Assistência</option></select></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Produto/interesse</label><input class="form-control" name="product_interest" placeholder="Ex.: carregador monofásico, cabo, instalação..."></div>
<div class="col-md-4"><label class="form-label small fw-bold text-secondary">Contacto</label><input class="form-control" name="contact_name" placeholder="Nome do contacto"></div>
<div class="col-md-4"><label class="form-label small fw-bold text-secondary">Email contacto</label><input class="form-control" name="contact_email" placeholder="email@empresa.pt"></div>
<div class="col-md-4"><label class="form-label small fw-bold text-secondary">Telefone contacto</label><input class="form-control" name="contact_phone" placeholder="telefone"></div>
<div class="col-12"><label class="form-label small fw-bold text-secondary">Notas</label><textarea class="form-control" name="notes" rows="2" placeholder="Resumo do pedido, contexto da chamada ou próximos passos"></textarea></div>
<div class="col-md-8"><div class="form-check"><input class="form-check-input" type="checkbox" name="create_task" value="1" id="create-task-from-customer" checked><label class="form-check-label small" for="create-task-from-customer">Criar tarefa inicial para a próxima ação</label></div></div>
<div class="col-md-4 d-grid"><button class="btn btn-primary" type="submit">Criar oportunidade</button></div>
</form>
</div></section>
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Oportunidades</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Oportunidade</th><th>Estado</th><th>Valor</th><th>Atualizada</th></tr></thead><tbody>{opp_rows}</tbody></table></div></div></section>
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Documentos Jasmin</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Tipo</th><th>Número/ID</th><th>Estado</th><th>Valor</th><th>Criado</th></tr></thead><tbody>{doc_rows}</tbody></table></div></div></section>
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Envios Packlink</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Transportadora</th><th>Serviço</th><th>Estado</th><th>Referência</th><th>Tracking</th></tr></thead><tbody>{shipment_rows}</tbody></table></div></div></section>
</div>
</div>
"""
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)