# app/routers/clientes.py """ Rotas de gestão de CLIENTE: listar/buscar (paginado), criar, editar, ver detalhe (contatos + status de Clientes_Chaves), excluir. """ import math from typing import Optional from urllib.parse import urlencode import pymysql from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from ..auth import exigir_login from ..csrf import exigir_csrf, token_do_contexto from ..db import chaves as db_chaves from ..db import clientes as db_clientes from ..db import contatos as db_contatos from ..normalize import normalizar_documento, normalizar_telefone, validar_dv_cpf, validar_dv_cnpj router = APIRouter(prefix="/clientes") templates = Jinja2Templates(directory="app/templates") TAMANHO_PAGINA = 100 def _redirect_com_mensagem(url: str, mensagem: str, tipo: str = "success") -> RedirectResponse: separador = "&" if "?" in url else "?" return RedirectResponse(url + separador + urlencode({"mensagem": mensagem, "mensagem_tipo": tipo}), status_code=303) def _ler_form_cliente(form: dict) -> dict: return {campo: (form.get(campo) or "").strip() for campo in db_clientes.CAMPOS_CLIENTE} def _normalizar_documento_e_telefones(dados: dict) -> None: """ Grava CNPJ/CPF e telefones sempre sem máscara (só dígitos/letras), igual ao que CLIENTE_CONTATO.TELEFONE já faz — evita que CLIENTE fique com dois formatos diferentes convivendo (achado da revisão final: docs/superpowers/plans/2026-08-13-painel-clientes-web.md). Só afeta escritas novas feitas por este painel; registros legados só passam a ficar sem máscara quando forem reeditados e salvos aqui. """ dados["CNPJ"] = normalizar_documento(dados["CNPJ"])[0] dados["TELEFONE"] = normalizar_telefone(dados["TELEFONE"]) dados["CELULAR"] = normalizar_telefone(dados["CELULAR"]) @router.get("", response_class=HTMLResponse) def listar( request: Request, busca: Optional[str] = None, pagina: int = 1, mensagem: Optional[str] = None, mensagem_tipo: Optional[str] = None, usuario: str = Depends(exigir_login), ): pagina = max(pagina, 1) total = db_clientes.contar_clientes(busca=busca or None) total_paginas = max(math.ceil(total / TAMANHO_PAGINA), 1) pagina = min(pagina, total_paginas) offset = (pagina - 1) * TAMANHO_PAGINA clientes = db_clientes.listar_clientes(busca=busca or None, limite=TAMANHO_PAGINA, offset=offset) return templates.TemplateResponse( request, "clientes_lista.html", { "clientes": clientes, "busca": busca, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo, "pagina": pagina, "total_paginas": total_paginas, "total": total, }, ) @router.get("/novo", response_class=HTMLResponse) def form_novo(request: Request, usuario: str = Depends(exigir_login)): return templates.TemplateResponse( request, "cliente_form.html", { "cliente": None, "dados": {c: "" for c in db_clientes.CAMPOS_CLIENTE}, "campos": db_clientes.CAMPOS_CLIENTE, "csrf_token": token_do_contexto(request), }, ) @router.post("/novo", dependencies=[Depends(exigir_csrf)]) async def criar(request: Request, usuario: str = Depends(exigir_login)): form = dict((await request.form())) dados = _ler_form_cliente(form) _normalizar_documento_e_telefones(dados) confirmar_duplicado = form.get("confirmar_codigo_duplicado") == "1" confirmar_dv = form.get("confirmar_dv_invalido") == "1" if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"]): return templates.TemplateResponse( request, "cliente_form.html", { "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True, "confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv, "csrf_token": token_do_contexto(request), }, ) documento_normalizado, formato_valido, tipo = normalizar_documento(dados["CNPJ"]) dv_invalido = False if formato_valido: dv_invalido = not ( validar_dv_cpf(documento_normalizado) if tipo == "CPF" else validar_dv_cnpj(documento_normalizado) ) if dv_invalido and not confirmar_dv: return templates.TemplateResponse( request, "cliente_form.html", { "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_dv_invalido": True, "confirmar_codigo_duplicado": confirmar_duplicado, "confirmar_dv_invalido": confirmar_dv, "csrf_token": token_do_contexto(request), }, ) try: novo_id = db_clientes.criar_cliente(dados) except (pymysql.err.DataError, pymysql.err.IntegrityError) as exc: return templates.TemplateResponse( request, "cliente_form.html", { "cliente": None, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc), "csrf_token": token_do_contexto(request), }, ) return _redirect_com_mensagem(f"/clientes/{novo_id}", "Cliente criado.") @router.get("/{cliente_id}", response_class=HTMLResponse) def detalhe( request: Request, cliente_id: int, mensagem: Optional[str] = None, mensagem_tipo: Optional[str] = None, usuario: str = Depends(exigir_login), ): cliente = db_clientes.obter_cliente(cliente_id) if cliente is None: return _redirect_com_mensagem("/clientes", "Cliente não encontrado.", "danger") contatos = db_contatos.listar_contatos_por_cliente(cliente_id) status = db_chaves.status_chaves(cliente["CODIGO"]) return templates.TemplateResponse( request, "cliente_detalhe.html", { "cliente": cliente, "contatos": contatos, "status_chaves": status, "mensagem": mensagem, "mensagem_tipo": mensagem_tipo, "csrf_token": token_do_contexto(request), }, ) @router.get("/{cliente_id}/editar", response_class=HTMLResponse) def form_editar(request: Request, cliente_id: int, usuario: str = Depends(exigir_login)): cliente = db_clientes.obter_cliente(cliente_id) if cliente is None: return _redirect_com_mensagem("/clientes", "Cliente não encontrado.", "danger") return templates.TemplateResponse( request, "cliente_form.html", { "cliente": cliente, "dados": cliente, "campos": db_clientes.CAMPOS_CLIENTE, "csrf_token": token_do_contexto(request), }, ) @router.post("/{cliente_id}/editar", dependencies=[Depends(exigir_csrf)]) async def editar(request: Request, cliente_id: int, usuario: str = Depends(exigir_login)): form = dict((await request.form())) dados = _ler_form_cliente(form) _normalizar_documento_e_telefones(dados) confirmar_duplicado = form.get("confirmar_codigo_duplicado") == "1" if not confirmar_duplicado and db_clientes.codigo_em_uso(dados["CODIGO"], ignorar_id=cliente_id): return templates.TemplateResponse( request, "cliente_form.html", { "cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "aviso_codigo_duplicado": True, "csrf_token": token_do_contexto(request), }, ) try: db_clientes.atualizar_cliente(cliente_id, dados) except (pymysql.err.DataError, pymysql.err.IntegrityError) as exc: return templates.TemplateResponse( request, "cliente_form.html", { "cliente": {"ID": cliente_id}, "dados": dados, "campos": db_clientes.CAMPOS_CLIENTE, "erro_gravacao": str(exc), "csrf_token": token_do_contexto(request), }, ) return _redirect_com_mensagem(f"/clientes/{cliente_id}", "Cliente atualizado.") @router.post("/{cliente_id}/excluir", dependencies=[Depends(exigir_csrf)]) def excluir(cliente_id: int, usuario: str = Depends(exigir_login)): try: db_clientes.excluir_cliente(cliente_id) except db_clientes.ClienteComDependenciasError as exc: return _redirect_com_mensagem(f"/clientes/{cliente_id}", str(exc), "danger") return _redirect_com_mensagem("/clientes", "Cliente excluído.")