Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

View File

@@ -4,11 +4,19 @@ 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)
@@ -70,13 +78,39 @@ async def create_customer_action(request: Request):
"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)
@@ -131,7 +165,7 @@ async def customer_detail_page(customer_id: str):
<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="proforma">Pró-forma</option><option value="invoice">Fatura</option><option value="order">Encomenda</option><option value="support">Assistência</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>
@@ -152,7 +186,17 @@ async def customer_detail_page(customer_id: str):
@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(
@@ -167,6 +211,8 @@ async def create_customer_opportunity_action(customer_id: str, request: Request)
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)
@@ -174,6 +220,8 @@ async def create_customer_opportunity_action(customer_id: str, request: Request)
@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
@@ -189,6 +237,8 @@ async def update_customer_action(customer_id: str, request: Request):
"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.