Criação da tela de clientes e relatórios
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-08-11 13:14:54 -03:00
parent bcf9861e97
commit 950eb2a826
7 changed files with 595 additions and 181 deletions

View File

@@ -1,6 +1,6 @@
import asyncio
import uuid
from fastapi import FastAPI, HTTPException, Request, UploadFile, File
from fastapi import FastAPI, HTTPException, Request, UploadFile, File, Depends, Form
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
@@ -25,6 +25,10 @@ from app.models import Fatura, SelicMensal, ParametrosFormula
from datetime import date
from app.utils import avaliar_formula
from app.routes import clientes
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_session
from fastapi import Query
from sqlalchemy import select as sqla_select
app = FastAPI()
@@ -32,7 +36,7 @@ templates = Jinja2Templates(directory="app/templates")
app.state.templates = templates
app.mount("/static", StaticFiles(directory="app/static"), name="static")
UPLOAD_DIR = "uploads/temp"
UPLOAD_DIR = os.path.join("app", "uploads", "temp")
os.makedirs(UPLOAD_DIR, exist_ok=True)
def _parse_referencia(ref: str):
@@ -99,7 +103,27 @@ def _avaliar_formula(texto_formula: str | None, contexto: dict) -> float:
# aceita vírgula como decimal vindo do banco
if isinstance(v, str):
v = v.replace(".", "").replace(",", ".") if re.search(r"[0-9],[0-9]", v) else v
expr = re.sub(rf'\b{re.escape(str(campo))}\b', str(v), expr)
# nome do campo escapado na regex
pat = rf'\b{re.escape(str(campo))}\b'
# normaliza o valor para número; se não der, vira 0
val = v
if val is None or val == "":
num = 0.0
else:
if isinstance(val, str):
# troca vírgula decimal e remove separador de milhar simples
val_norm = val.replace(".", "").replace(",", ".")
else:
val_norm = val
try:
num = float(val_norm)
except Exception:
num = 0.0
# usa lambda para evitar interpretação de backslashes no replacement
expr = re.sub(pat, lambda m: str(num), expr)
try:
return float(eval(expr, {"__builtins__": {}}, {}))
@@ -113,10 +137,14 @@ async def dashboard(request: Request, cliente: str | None = None):
async with AsyncSessionLocal() as session:
print("DBG /: abrindo sessão", flush=True)
r = await session.execute(text(
"SELECT DISTINCT nome FROM faturas.faturas ORDER BY nome"
))
clientes = [c for c, in r.fetchall()]
r = await session.execute(text("""
SELECT id, nome_fantasia
FROM faturas.clientes
WHERE ativo = TRUE
ORDER BY nome_fantasia
"""))
clientes = [{"id": id_, "nome": nome} for id_, nome in r.fetchall()]
print(f"DBG /: clientes={len(clientes)}", flush=True)
# Fórmulas
@@ -135,7 +163,7 @@ async def dashboard(request: Request, cliente: str | None = None):
sql = "SELECT * FROM faturas.faturas"
params = {}
if cliente:
sql += " WHERE nome = :cliente"
sql += " WHERE cliente_id = :cliente"
params["cliente"] = cliente
print("DBG /: SQL faturas ->", sql, params, flush=True)
@@ -254,21 +282,58 @@ def upload_page(request: Request):
})
@app.get("/relatorios", response_class=HTMLResponse)
def relatorios_page(request: Request):
return templates.TemplateResponse("relatorios.html", {"request": request})
async def relatorios_page(request: Request, cliente: str | None = Query(None)):
async with AsyncSessionLocal() as session:
# Carregar clientes ativos para o combo
r_cli = await session.execute(text("""
SELECT id, nome_fantasia
FROM faturas.clientes
WHERE ativo = TRUE
ORDER BY nome_fantasia
"""))
clientes = [{"id": str(row.id), "nome": row.nome_fantasia} for row in r_cli]
# Carregar faturas (todas ou filtradas por cliente)
if cliente:
r_fat = await session.execute(text("""
SELECT *
FROM faturas.faturas
WHERE cliente_id = :cid
ORDER BY data_processamento DESC
"""), {"cid": cliente})
else:
r_fat = await session.execute(text("""
SELECT *
FROM faturas.faturas
ORDER BY data_processamento DESC
"""))
faturas = r_fat.mappings().all()
return templates.TemplateResponse("relatorios.html", {
"request": request,
"clientes": clientes,
"cliente_selecionado": cliente or "",
"faturas": faturas
})
@app.post("/upload-files")
async def upload_files(files: list[UploadFile] = File(...)):
async def upload_files(
cliente_id: str = Form(...),
files: list[UploadFile] = File(...)
):
for file in files:
temp_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}_{file.filename}")
with open(temp_path, "wb") as f:
shutil.copyfileobj(file.file, f)
await fila_processamento.put({
"caminho_pdf": temp_path,
"nome_original": file.filename
"nome_original": file.filename,
"cliente_id": cliente_id
})
return {"message": "Arquivos enviados para fila"}
@app.post("/process-queue")
async def process_queue():
resultados = await processar_em_lote()
@@ -307,117 +372,109 @@ async def clear_all():
return {"message": "Fila e arquivos limpos"}
@app.get("/export-excel")
async def export_excel():
async def export_excel(
tipo: str = Query("geral", regex="^(geral|exclusao_icms|aliquota_icms)$"),
cliente: str | None = Query(None)
):
import pandas as pd
# 1) Carregar faturas (com filtro por cliente, se houver)
async with AsyncSessionLocal() as session:
# 1. Coletar faturas e tabela SELIC
faturas_result = await session.execute(select(Fatura))
stmt = select(Fatura)
if cliente:
# filtra por cliente_id (UUID em string)
stmt = stmt.where(Fatura.cliente_id == cliente)
faturas_result = await session.execute(stmt)
faturas = faturas_result.scalars().all()
selic_result = await session.execute(select(SelicMensal))
selic_tabela = selic_result.scalars().all()
# 2. Criar mapa {(ano, mes): percentual}
selic_map = {(s.ano, s.mes): float(s.percentual) for s in selic_tabela}
hoje = date.today()
def calcular_fator_selic(ano_inicio, mes_inicio):
fator = 1.0
ano, mes = ano_inicio, mes_inicio
while (ano < hoje.year) or (ano == hoje.year and mes <= hoje.month):
percentual = selic_map.get((ano, mes))
if percentual:
fator *= (1 + percentual / 100)
mes += 1
if mes > 12:
mes = 1
ano += 1
return fator
# 3. Buscar fórmulas exatas por nome
formula_pis_result = await session.execute(
select(ParametrosFormula.formula).where(
ParametrosFormula.nome == "Cálculo PIS sobre ICMS",
ParametrosFormula.ativo == True
).limit(1)
)
formula_cofins_result = await session.execute(
select(ParametrosFormula.formula).where(
ParametrosFormula.nome == "Cálculo COFINS sobre ICMS",
ParametrosFormula.ativo == True
).limit(1)
)
formula_pis = formula_pis_result.scalar_one_or_none()
formula_cofins = formula_cofins_result.scalar_one_or_none()
# 4. Montar dados
mes_map = {
'JAN': 1, 'FEV': 2, 'MAR': 3, 'ABR': 4, 'MAI': 5, 'JUN': 6,
'JUL': 7, 'AGO': 8, 'SET': 9, 'OUT': 10, 'NOV': 11, 'DEZ': 12
}
# 2) Montar dados conforme 'tipo'
dados = []
for f in faturas:
try:
if "/" in f.referencia:
mes_str, ano_str = f.referencia.split("/")
mes = mes_map.get(mes_str.strip().upper())
ano = int(ano_str)
if not mes or not ano:
raise ValueError("Mês ou ano inválido")
else:
ano = int(f.referencia[:4])
mes = int(f.referencia[4:])
fator = calcular_fator_selic(ano, mes)
periodo = f"{mes:02d}/{ano} à {hoje.month:02d}/{hoje.year}"
contexto = f.__dict__
valor_pis_icms = avaliar_formula(formula_pis, contexto) if formula_pis else None
valor_cofins_icms = avaliar_formula(formula_cofins, contexto) if formula_cofins else None
if tipo == "aliquota_icms":
# Campos: (Cliente, UC, Referência, Valor Total, ICMS (%), ICMS (R$),
# Base ICMS (R$), Consumo (kWh), Tarifa, Nota Fiscal)
for f in faturas:
dados.append({
"Nome": f.nome,
"Cliente": f.nome,
"UC": f.unidade_consumidora,
"Referência": f.referencia,
"Valor Total": f.valor_total,
"ICMS (%)": f.icms_aliq,
"ICMS (R$)": f.icms_valor,
"Base ICMS (R$)": f.icms_base,
"Consumo (kWh)": f.consumo,
"Tarifa": f.tarifa,
"Nota Fiscal": f.nota_fiscal,
})
filename = "relatorio_aliquota_icms.xlsx"
elif tipo == "exclusao_icms":
# Campos: (Cliente, UC, Referência, Valor Total, PIS (%), ICMS (%), COFINS (%),
# PIS (R$), ICMS (R$), COFINS (R$), Base PIS (R$), Base ICMS (R$),
# Base COFINS (R$), Consumo (kWh), Tarifa, Nota Fiscal)
for f in faturas:
dados.append({
"Cliente": f.nome,
"UC": f.unidade_consumidora,
"Referência": f.referencia,
"Valor Total": f.valor_total,
"PIS (%)": f.pis_aliq,
"ICMS (%)": f.icms_aliq,
"COFINS (%)": f.cofins_aliq,
"PIS (R$)": f.pis_valor,
"ICMS (R$)": f.icms_valor,
"COFINS (R$)": f.cofins_valor,
"Base PIS (R$)": f.pis_base,
"Base ICMS (R$)": f.icms_base,
"Base COFINS (R$)": f.cofins_base,
"Consumo (kWh)": f.consumo,
"Tarifa": f.tarifa,
"Nota Fiscal": f.nota_fiscal,
})
filename = "relatorio_exclusao_icms.xlsx"
else: # "geral" (mantém seu relatório atual — sem fórmulas SELIC)
# Se quiser manter exatamente o que já tinha com SELIC e fórmulas,
# você pode copiar sua lógica anterior aqui. Abaixo deixo um "geral"
# simplificado com as colunas principais.
for f in faturas:
dados.append({
"Cliente": f.nome,
"UC": f.unidade_consumidora,
"Referência": f.referencia,
"Nota Fiscal": f.nota_fiscal,
"Valor Total": f.valor_total,
"ICMS (%)": f.icms_aliq,
"ICMS (R$)": f.icms_valor,
"Base ICMS": f.icms_base,
"Base ICMS (R$)": f.icms_base,
"PIS (%)": f.pis_aliq,
"PIS (R$)": f.pis_valor,
"Base PIS": f.pis_base,
"Base PIS (R$)": f.pis_base,
"COFINS (%)": f.cofins_aliq,
"COFINS (R$)": f.cofins_valor,
"Base COFINS": f.cofins_base,
"Base COFINS (R$)": f.cofins_base,
"Consumo (kWh)": f.consumo,
"Tarifa": f.tarifa,
"Cidade": f.cidade,
"Estado": f.estado,
"Distribuidora": f.distribuidora,
"Data Processamento": f.data_processamento,
"Fator SELIC acumulado": fator,
"Período SELIC usado": periodo,
"PIS sobre ICMS": valor_pis_icms,
"Valor Corrigido PIS (ICMS)": valor_pis_icms * fator if valor_pis_icms else None,
"COFINS sobre ICMS": valor_cofins_icms,
"Valor Corrigido COFINS (ICMS)": valor_cofins_icms * fator if valor_cofins_icms else None,
})
except Exception as e:
print(f"Erro ao processar fatura {f.nota_fiscal}: {e}")
df = pd.DataFrame(dados)
filename = "relatorio_geral.xlsx"
# 3) Gerar excel em memória
from io import BytesIO
output = BytesIO()
df = pd.DataFrame(dados)
with pd.ExcelWriter(output, engine="xlsxwriter") as writer:
df.to_excel(writer, index=False, sheet_name="Faturas Corrigidas")
df.to_excel(writer, index=False, sheet_name="Relatório")
output.seek(0)
return StreamingResponse(output, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={
"Content-Disposition": "attachment; filename=faturas_corrigidas.xlsx"
})
# 4) Responder
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
)
from app.parametros import router as parametros_router
app.include_router(parametros_router)
@@ -474,3 +531,70 @@ async def limpar_erros():
caminho = os.path.join(pasta, nome)
if os.path.exists(caminho):
os.remove(caminho)
@app.get("/api/clientes")
async def listar_clientes(db: AsyncSession = Depends(get_session)):
sql = text("""
SELECT id, nome_fantasia, cnpj, ativo
FROM faturas.clientes
WHERE ativo = TRUE
ORDER BY nome_fantasia
""")
res = await db.execute(sql)
rows = res.mappings().all()
return [
{
"id": str(r["id"]),
"nome_fantasia": r["nome_fantasia"],
"cnpj": r["cnpj"],
"ativo": bool(r["ativo"]),
}
for r in rows
]
@app.get("/api/relatorios")
async def api_relatorios(
cliente: str | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=5, le=200),
db: AsyncSession = Depends(get_session),
):
offset = (page - 1) * page_size
where = "WHERE cliente_id = :cliente" if cliente else ""
params = {"limit": page_size, "offset": offset}
if cliente:
params["cliente"] = cliente
sql = text(f"""
SELECT id, nome, unidade_consumidora, referencia, nota_fiscal,
valor_total, icms_aliq, icms_valor, pis_aliq, pis_valor,
cofins_aliq, cofins_valor, distribuidora, data_processamento
FROM faturas.faturas
{where}
ORDER BY data_processamento DESC
LIMIT :limit OFFSET :offset
""")
count_sql = text(f"SELECT COUNT(*) AS total FROM faturas.faturas {where}")
rows = (await db.execute(sql, params)).mappings().all()
total = (await db.execute(count_sql, params)).scalar_one()
items = [{
"id": str(r["id"]),
"nome": r["nome"],
"unidade_consumidora": r["unidade_consumidora"],
"referencia": r["referencia"],
"nota_fiscal": r["nota_fiscal"],
"valor_total": float(r["valor_total"]) if r["valor_total"] is not None else None,
"icms_aliq": r["icms_aliq"],
"icms_valor": r["icms_valor"],
"pis_aliq": r["pis_aliq"],
"pis_valor": r["pis_valor"],
"cofins_aliq": r["cofins_aliq"],
"cofins_valor": r["cofins_valor"],
"distribuidora": r["distribuidora"],
"data_processamento": r["data_processamento"].isoformat() if r["data_processamento"] else None,
} for r in rows]
return {"items": items, "total": total, "page": page, "page_size": page_size}

View File

@@ -1,5 +1,5 @@
# 📄 models.py
from sqlalchemy import Column, String, Integer, Float, DateTime, Text
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
import uuid
from datetime import datetime
@@ -50,6 +50,8 @@ class Fatura(Base):
estado = Column(String)
distribuidora = Column(String)
link_arquivo = Column("link_arquivo", String)
cliente_id = Column(UUID(as_uuid=True), ForeignKey("faturas.clientes.id"), nullable=False)
class LogProcessamento(Base):

View File

@@ -93,8 +93,9 @@ async def editar_parametro(param_id: int, request: Request):
async with AsyncSessionLocal() as session:
param = await session.get(ParametrosFormula, param_id)
if param:
param.tipo = data.get("tipo", param.tipo)
param.nome = data.get("nome", param.nome)
param.formula = data.get("formula", param.formula)
param.ativo = data.get("ativo", param.ativo)
await session.commit()
return {"success": True}
return {"success": False, "error": "Não encontrado"}
@@ -140,21 +141,21 @@ async def adicionar_aliquota(aliq: AliquotaUFSchema, db: AsyncSession = Depends(
@router.get("/parametros/formulas", response_model=List[ParametrosFormulaSchema])
async def listar_formulas(db: AsyncSession = Depends(get_session)):
result = await db.execute(select(ParametrosFormula).order_by(ParametrosFormula.tipo))
result = await db.execute(select(ParametrosFormula).order_by(ParametrosFormula.nome))
return result.scalars().all()
@router.post("/parametros/formulas")
async def salvar_formula(form: ParametrosFormulaSchema, db: AsyncSession = Depends(get_session)):
result = await db.execute(
select(ParametrosFormula).filter_by(tipo=form.tipo)
select(ParametrosFormula).filter_by(nome=form.nome)
)
existente = result.scalar_one_or_none()
if existente:
existente.formula = form.formula
existente.campos = form.campos
existente.ativo = form.ativo
else:
novo = ParametrosFormula(**form.dict())
novo = ParametrosFormula(nome=form.nome, formula=form.formula, ativo=form.ativo)
db.add(novo)
await db.commit()

View File

@@ -56,7 +56,7 @@ def salvar_em_uploads(caminho_pdf_temp, nome_original, nota_fiscal):
logger.error(f"Erro ao salvar em uploads: {e}")
return caminho_pdf_temp
async def process_single_file(caminho_pdf_temp: str, nome_original: str):
async def process_single_file(caminho_pdf_temp: str, nome_original: str, cliente_id: str | None = None):
inicio = time.perf_counter()
async with AsyncSessionLocal() as session:
@@ -89,7 +89,10 @@ async def process_single_file(caminho_pdf_temp: str, nome_original: str):
dados['link_arquivo'] = caminho_final
# Salva fatura
fatura = Fatura(**dados)
dados['cliente_id'] = cliente_id
if cliente_id:
dados['cliente_id'] = cliente_id
fatura = Fatura(**dados)
session.add(fatura)
await session.commit()
@@ -125,15 +128,36 @@ async def processar_em_lote():
while not fila_processamento.empty():
item = await fila_processamento.get()
try:
resultado = await process_single_file(item['caminho_pdf'], item['nome_original'])
resultado = await process_single_file(
item['caminho_pdf'],
item['nome_original'],
item.get('cliente_id')
)
# tentar tamanho/data do TEMP; se não existir mais, tenta do destino final; senão, 0/""
temp_path = item['caminho_pdf']
dest_path = (resultado.get("dados") or {}).get("link_arquivo", "")
def _safe_size(p):
try:
return os.path.getsize(p) // 1024
except Exception:
return 0
def _safe_mtime(p):
try:
return time.strftime("%d/%m/%Y", time.localtime(os.path.getmtime(p)))
except Exception:
return ""
status_arquivos[item['nome_original']] = {
"status": resultado.get("status"),
"mensagem": resultado.get("mensagem", ""),
"tempo": resultado.get("tempo", "---"),
"tamanho": os.path.getsize(item['caminho_pdf']) // 1024, # tamanho em KB
"data": time.strftime("%d/%m/%Y", time.localtime(os.path.getmtime(item['caminho_pdf'])))
"tamanho": _safe_size(temp_path) or _safe_size(dest_path),
"data": _safe_mtime(temp_path) or _safe_mtime(dest_path),
}
resultados.append(status_arquivos[item['nome_original']])
except Exception as e:
status_arquivos[item['nome_original']] = {
@@ -156,17 +180,21 @@ async def processar_em_lote():
erros_txt.append(f"{nome} - {status.get('mensagem', 'Erro desconhecido')}")
if erros_txt:
with open(os.path.join(UPLOADS_DIR, "erros", "erros.txt"), "w", encoding="utf-8") as f:
erros_dir = os.path.join(UPLOADS_DIR, "erros")
os.makedirs(erros_dir, exist_ok=True) # <- GARANTE A PASTA
with open(os.path.join(erros_dir, "erros.txt"), "w", encoding="utf-8") as f:
f.write("\n".join(erros_txt))
# Compacta PDFs com erro
with ZipFile(os.path.join(UPLOADS_DIR, "erros", "faturas_erro.zip"), "w") as zipf:
with ZipFile(os.path.join(erros_dir, "faturas_erro.zip"), "w") as zipf:
for nome in status_arquivos:
if status_arquivos[nome]['status'] == 'Erro':
caminho = os.path.join(UPLOADS_DIR, "temp", nome)
if os.path.exists(caminho):
zipf.write(caminho, arcname=nome)
return resultados
return resultados
def limpar_arquivos_processados():
status_arquivos.clear()
@@ -192,6 +220,6 @@ async def garantir_selic_para_competencia(session, ano, mes):
if dados:
percentual = float(dados[0]["valor"].replace(",", "."))
novo = SelicMensal(ano=ano, mes=mes, fator=percentual)
novo = SelicMensal(ano=ano, mes=mes, percentual=percentual)
session.add(novo)
await session.commit()

View File

@@ -100,11 +100,11 @@
<form method="get" action="/" style="margin: 6px 0 18px">
<div class="combo-wrap">
<label for="cliente" style="font-size:13px;color:#374151">Selecionar Cliente:</label>
<select class="combo" name="cliente" id="cliente">
<option value="">Todos</option>
{% for c in clientes %}
<option value="{{ c }}" {% if c == cliente_atual %}selected{% endif %}>{{ c }}</option>
{% endfor %}
<select id="cliente" name="cliente" class="combo">
<option value="">Todos</option>
{% for c in clientes %}
<option value="{{ c.id }}" {% if cliente_selecionado == c.id %}selected{% endif %}>{{ c.nome }}</option>
{% endfor %}
</select>
</div>
</form>

View File

@@ -3,43 +3,227 @@
{% block content %}
<h1>📊 Relatórios</h1>
<form method="get" style="margin-bottom: 20px;">
<label for="cliente">Filtrar por Cliente:</label>
<select name="cliente" id="cliente" onchange="this.form.submit()">
<option value="">Todos</option>
{% for c in clientes %}
<option value="{{ c }}" {% if c == cliente_atual %}selected{% endif %}>{{ c }}</option>
{% endfor %}
</select>
</form>
<div style="display:flex; gap:16px; align-items:flex-end; flex-wrap:wrap; margin: 6px 0 18px;">
<div class="combo-wrap">
<label for="relatorio-cliente" style="font-size:13px;color:#374151">Selecionar Cliente:</label>
<select id="relatorio-cliente" class="combo" style="min-width:340px;">
<option value="">Todos</option>
{% for c in clientes %}
<option value="{{ c.id }}">{{ c.nome }}</option>
{% endfor %}
</select>
</div>
<div style="margin-bottom: 20px;">
<a href="/export-excel{% if cliente_atual %}?cliente={{ cliente_atual }}{% endif %}" class="btn btn-primary">
📥 Baixar Relatório Corrigido (Excel)
</a>
<div class="combo-wrap">
<label for="tipo-relatorio" style="font-size:13px;color:#374151">Tipo de relatório:</label>
<select id="tipo-relatorio" class="combo" style="min-width:240px;">
<option value="geral">1. Geral</option>
<option value="exclusao_icms">2. Exclusão do ICMS</option>
<option value="aliquota_icms">3. Alíquota ICMS (%)</option>
</select>
</div>
<div class="combo-wrap">
<label for="page-size" style="font-size:13px;color:#374151">Itens por página:</label>
<select id="page-size" class="combo" style="width:140px;">
<option>20</option>
<option>50</option>
<option>100</option>
</select>
</div>
<div>
<a id="link-excel" class="btn btn-primary" href="/export-excel">📥 Baixar (Excel)</a>
</div>
</div>
<table style="width: 100%; border-collapse: collapse;">
<table class="table">
<thead>
<tr style="background: #2563eb; color: white;">
<th style="padding: 10px;">Cliente</th>
<th>Data</th>
<tr>
<th>Cliente</th>
<th>UC</th>
<th>Referência</th>
<th>Nota Fiscal</th>
<th>Valor Total</th>
<th>ICMS na Base</th>
<th>Status</th>
<th>ICMS (%)</th>
<th>ICMS (R$)</th>
<th>PIS (R$)</th>
<th>COFINS (R$)</th>
<th>Distribuidora</th>
<th>Processado em</th>
</tr>
</thead>
<tbody>
<tbody id="relatorios-body">
{% for f in faturas %}
<tr style="background: {{ loop.cycle('#ffffff', '#f0f4f8') }};">
<td style="padding: 10px;">{{ f.nome }}</td>
<td>{{ f.data_emissao }}</td>
<td>R$ {{ '%.2f'|format(f.valor_total)|replace('.', ',') }}</td>
<td>{{ 'Sim' if f.com_icms else 'Não' }}</td>
<td>{{ f.status }}</td>
</tr>
<tr>
<td>{{ f.nome }}</td>
<td class="mono">{{ f.unidade_consumidora }}</td>
<td class="mono">{{ f.referencia }}</td>
<td class="mono">{{ f.nota_fiscal }}</td>
<td>R$ {{ '%.2f'|format((f.valor_total or 0.0))|replace('.', ',') }}</td>
<td>{{ '%.2f'|format((f.icms_aliq or 0.0))|replace('.', ',') }}</td>
<td>R$ {{ '%.2f'|format((f.icms_valor or 0.0))|replace('.', ',') }}</td>
<td>R$ {{ '%.2f'|format((f.pis_valor or 0.0))|replace('.', ',') }}</td>
<td>R$ {{ '%.2f'|format((f.cofins_valor or 0.0))|replace('.', ',') }}</td>
<td>{{ f.distribuidora or '-' }}</td>
<td class="muted">{{ f.data_processamento }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div id="pager" style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:12px;">
<div id="range" class="muted">Mostrando 00 de 0</div>
<div style="display:flex; gap:8px;">
<button id="prev" class="btn btn-primary">◀ Anterior</button>
<button id="next" class="btn btn-primary">Próxima ▶</button>
</div>
</div>
<style>
.combo {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 12px 44px 12px 14px;
font-size: 14px;
line-height: 1.2;
color: #111827;
box-shadow: 0 6px 20px rgba(0,0,0,.06);
transition: box-shadow .2s ease, border-color .2s ease, transform .2s ease;
}
.combo:focus { outline: none; border-color: #2563eb; box-shadow: 0 8px 28px rgba(37,99,235,.18); }
.combo-wrap { position: relative; display: inline-flex; align-items: center; gap: 8px; }
.combo-wrap:after {
content: "▾"; position: absolute; right: 12px; pointer-events: none; color:#6b7280; font-size: 12px;
}
/* tabela no estilo “clientes” */
.table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 12px 34px rgba(0,0,0,.06);
}
.table thead th {
background: #2563eb;
color: #ffffff;
font-size: 12px;
text-transform: uppercase;
letter-spacing: .04em;
padding: 12px 14px;
text-align: left;
}
.table tbody td {
border-top: 1px solid #eef2f7;
padding: 12px 14px;
font-size: 14px;
color: #374151;
}
.table tbody tr:nth-child(odd){ background:#fafafa; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
.muted { color:#6b7280; }
#pager .btn[disabled]{ opacity:.5; cursor:not-allowed; }
</style>
<script>
let page = 1;
let pageSize = 20;
let total = 0;
function updateExcelLink() {
const cliente = document.getElementById('relatorio-cliente').value || '';
const tipo = document.getElementById('tipo-relatorio').value || 'geral';
const params = new URLSearchParams();
params.set('tipo', tipo);
if (cliente) params.set('cliente', cliente);
document.getElementById('link-excel').setAttribute('href', `/export-excel?${params.toString()}`);
}
async function carregarTabela() {
const cliente = document.getElementById('relatorio-cliente').value || '';
const url = new URL('/api/relatorios', window.location.origin);
url.searchParams.set('page', page);
url.searchParams.set('page_size', pageSize);
if (cliente) url.searchParams.set('cliente', cliente);
const r = await fetch(url);
const data = await r.json();
total = data.total;
renderRows(data.items);
updatePager();
updateExcelLink();
}
function renderRows(items) {
const tbody = document.getElementById('relatorios-body');
if (!items.length) {
tbody.innerHTML = `<tr><td colspan="11" style="padding:14px;">Nenhum registro encontrado.</td></tr>`;
return;
}
const fmtBRL = (v) => (v || v === 0) ? Number(v).toLocaleString('pt-BR',{style:'currency',currency:'BRL'}) : '';
const fmtNum = (v) => (v || v === 0) ? Number(v).toLocaleString('pt-BR') : '';
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('pt-BR') : '';
tbody.innerHTML = items.map(f => `
<tr>
<td>${f.nome || ''}</td>
<td class="mono">${f.unidade_consumidora || ''}</td>
<td class="mono">${f.referencia || ''}</td>
<td class="mono">${f.nota_fiscal || ''}</td>
<td>${fmtBRL(f.valor_total)}</td>
<td>${fmtNum(f.icms_aliq)}</td>
<td>${fmtBRL(f.icms_valor)}</td>
<td>${fmtBRL(f.pis_valor)}</td>
<td>${fmtBRL(f.cofins_valor)}</td>
<td>${f.distribuidora || '-'}</td>
<td class="muted">${fmtDate(f.data_processamento)}</td>
</tr>
`).join('');
}
function updatePager() {
const start = total ? (page - 1) * pageSize + 1 : 0;
const end = Math.min(page * pageSize, total);
document.getElementById('range').textContent = `Mostrando ${start}${end} de ${total}`;
document.getElementById('prev').disabled = page <= 1;
document.getElementById('next').disabled = page * pageSize >= total;
}
document.getElementById('prev').addEventListener('click', () => {
if (page > 1) { page--; carregarTabela(); }
});
document.getElementById('next').addEventListener('click', () => {
if (page * pageSize < total) { page++; carregarTabela(); }
});
document.getElementById('page-size').addEventListener('change', (e) => {
pageSize = parseInt(e.target.value, 10);
page = 1;
carregarTabela();
// não precisa alterar o link aqui
});
document.getElementById('relatorio-cliente').addEventListener('change', () => {
page = 1;
carregarTabela();
updateExcelLink();
});
window.addEventListener('DOMContentLoaded', () => {
const pre = "{{ cliente_selecionado or '' }}";
if (pre) document.getElementById('relatorio-cliente').value = pre;
updateExcelLink();
carregarTabela();
});
document.getElementById('tipo-relatorio').addEventListener('change', () => {
updateExcelLink();
});
</script>
{% endblock %}

View File

@@ -4,6 +4,16 @@
<h1 style="font-size: 1.5rem; margin-bottom: 1rem;">📤 Upload de Faturas</h1>
<!-- Seletor de Cliente (obrigatório) -->
<div style="display:flex; gap:12px; align-items:center; margin: 0 0 14px 0;">
<label for="select-cliente" style="font-weight:600;">Cliente:</label>
<select id="select-cliente" style="min-width:320px; padding:.6rem .8rem; border:1px solid #ddd; border-radius:10px;">
<option value="">— Selecione um cliente —</option>
</select>
<span id="cliente-aviso" class="muted">Selecione o cliente antes de anexar/ processar.</span>
</div>
<div class="upload-box" id="upload-box">
<h3>Arraste faturas em PDF aqui ou clique para selecionar</h3>
<p style="color: gray; font-size: 0.9rem;">Apenas PDFs textuais (não escaneados)</p>
@@ -34,7 +44,6 @@
</div>
<div id="tabela-wrapper" class="tabela-wrapper"></div>
</div>
ar
<script>
let arquivos = [];
let statusInterval = null;
@@ -43,18 +52,49 @@ ar
const fileTable = document.getElementById('file-table');
function handleFiles(files) {
if (processado) {
document.getElementById("feedback-sucesso").innerText = "";
document.getElementById("feedback-erro").innerText = "⚠️ Conclua ou inicie um novo processo antes de adicionar mais arquivos.";
document.getElementById("feedback-duplicado").innerText = "";
document.getElementById("upload-feedback").classList.remove("hidden");
return;
// <<< NOVO: carrega clientes ativos no combo
async function carregarClientes() {
try {
const r = await fetch('/api/clientes'); // se quiser só ativos: /api/clientes?ativos=true
if (!r.ok) throw new Error('Falha ao carregar clientes');
const lista = await r.json();
const sel = document.getElementById('select-cliente');
sel.innerHTML = `<option value="">— Selecione um cliente —</option>` +
lista.map(c => `<option value="${c.id}">${c.nome_fantasia}${c.cnpj ? ' — ' + c.cnpj : ''}</option>`).join('');
} catch (e) {
console.error(e);
alert('Não foi possível carregar a lista de clientes.');
}
}
function clienteSelecionado() {
return (document.getElementById('select-cliente')?.value || '').trim();
}
// <<< AJUSTE: impedir anexar sem cliente
function handleFiles(files) {
if (!clienteSelecionado()) {
alert('Selecione um cliente antes de anexar os arquivos.');
return;
}
if (processado) {
document.getElementById("feedback-sucesso").innerText = "";
document.getElementById("feedback-erro").innerText = "⚠️ Conclua ou inicie um novo processo antes de adicionar mais arquivos.";
document.getElementById("feedback-duplicado").innerText = "";
document.getElementById("upload-feedback").classList.remove("hidden");
return;
}
arquivos = [...arquivos, ...files];
// trava o combo após começar a anexar (opcional)
document.getElementById('select-cliente').disabled = true;
renderTable();
}
arquivos = [...arquivos, ...files];
renderTable();
}
function renderTable(statusList = []) {
const grupos = ['Aguardando', 'Enviado', 'Erro', 'Duplicado'];
@@ -102,7 +142,20 @@ function renderTable(statusList = []) {
}
async function processar(btn) {
if (!clienteSelecionado()) {
alert("Selecione um cliente antes de processar.");
return;
}
if (arquivos.length === 0) return alert("Nenhum arquivo selecionado.");
// Confirmação
const clienteTxt = document.querySelector('#select-cliente option:checked')?.textContent || '';
if (!confirm(`Confirmar processamento de ${arquivos.length} arquivo(s) para o cliente:\n\n${clienteTxt}`)) {
return;
}
const clienteId = clienteSelecionado();
document.getElementById("tabela-wrapper").classList.add("bloqueada");
if (processamentoFinalizado) {
@@ -120,8 +173,9 @@ async function processar(btn) {
document.getElementById("barra-progresso").style.width = "0%";
for (let i = 0; i < total; i++) {
const file = arquivos[i];
const file = arquivos[i]; // <- declare 'file' ANTES de usar
const formData = new FormData();
formData.append("cliente_id", clienteId); // <- usa o cache do cliente
formData.append("files", file);
// Atualiza status visual antes do envio
@@ -129,14 +183,16 @@ async function processar(btn) {
nome: file.name,
status: "Enviando...",
mensagem: `(${i + 1}/${total})`,
tempo: "---"
tempo: "---",
tamanho: (file.size / 1024).toFixed(1) + " KB",
data: new Date(file.lastModified).toLocaleDateString()
});
renderTable(statusList);
const start = performance.now();
try {
await fetch("/upload-files", { method: "POST", body: formData });
let progresso = Math.round(((i + 1) / total) * 100);
const progresso = Math.round(((i + 1) / total) * 100);
document.getElementById("barra-progresso").style.width = `${progresso}%`;
statusList[i].status = "Enviado";
@@ -147,9 +203,7 @@ async function processar(btn) {
}
renderTable(statusList);
// Delay de 200ms entre cada envio
await new Promise(r => setTimeout(r, 200));
await new Promise(r => setTimeout(r, 200)); // pequeno delay
}
btn.innerText = "⏳ Iniciando processamento...";
@@ -206,23 +260,39 @@ async function processar(btn) {
}
}
function limpar() {
fetch("/clear-all", { method: "POST" });
arquivos = [];
processado = false;
document.getElementById("file-input").value = null;
renderTable();
function limpar() {
fetch("/clear-all", { method: "POST" });
// limpa feedback visual também
document.getElementById("upload-feedback").classList.add("hidden");
document.getElementById("feedback-sucesso").innerText = "";
document.getElementById("feedback-erro").innerText = "";
document.getElementById("feedback-duplicado").innerText = "";
document.getElementById("tabela-wrapper").classList.remove("bloqueada");
processamentoFinalizado = false;
document.getElementById("btn-selecionar").disabled = false;
// reset da fila/estado
arquivos = [];
processado = false;
processamentoFinalizado = false;
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
// reset dos inputs/visual
document.getElementById("file-input").value = null;
document.getElementById("tabela-wrapper").classList.remove("bloqueada");
document.getElementById("overlay-bloqueio").classList.add("hidden");
document.getElementById("barra-progresso").style.width = "0%";
document.getElementById("btn-selecionar").disabled = false;
// 🔓 permitir mudar o cliente novamente
const sel = document.getElementById("select-cliente");
sel.disabled = false;
sel.value = ""; // <- se NÃO quiser limpar a escolha anterior, remova esta linha
document.getElementById("cliente-aviso").textContent =
"Selecione o cliente antes de anexar/ processar.";
// limpar feedback
document.getElementById("upload-feedback").classList.add("hidden");
document.getElementById("feedback-sucesso").innerText = "";
document.getElementById("feedback-erro").innerText = "";
document.getElementById("feedback-duplicado").innerText = "";
// limpar tabela
renderTable();
}
}
function baixarPlanilha() {
window.open('/export-excel', '_blank');
@@ -233,6 +303,7 @@ async function processar(btn) {
}
window.addEventListener('DOMContentLoaded', () => {
carregarClientes();
updateStatus();
const dragOverlay = document.getElementById("drag-overlay");
@@ -255,11 +326,15 @@ window.addEventListener('DOMContentLoaded', () => {
e.preventDefault();
});
window.addEventListener("drop", e => {
e.preventDefault();
dragOverlay.classList.remove("active");
dragCounter = 0;
handleFiles(e.dataTransfer.files);
window.addEventListener("drop", e => {
e.preventDefault();
dragOverlay.classList.remove("active");
dragCounter = 0;
if (!clienteSelecionado()) {
alert('Selecione um cliente antes de anexar os arquivos.');
return;
}
handleFiles(e.dataTransfer.files);
});
});