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 asyncio
import uuid 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.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -25,6 +25,10 @@ from app.models import Fatura, SelicMensal, ParametrosFormula
from datetime import date from datetime import date
from app.utils import avaliar_formula from app.utils import avaliar_formula
from app.routes import clientes 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() app = FastAPI()
@@ -32,7 +36,7 @@ templates = Jinja2Templates(directory="app/templates")
app.state.templates = templates app.state.templates = templates
app.mount("/static", StaticFiles(directory="app/static"), name="static") 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) os.makedirs(UPLOAD_DIR, exist_ok=True)
def _parse_referencia(ref: str): 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 # aceita vírgula como decimal vindo do banco
if isinstance(v, str): if isinstance(v, str):
v = v.replace(".", "").replace(",", ".") if re.search(r"[0-9],[0-9]", v) else v 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: try:
return float(eval(expr, {"__builtins__": {}}, {})) return float(eval(expr, {"__builtins__": {}}, {}))
@@ -113,10 +137,14 @@ async def dashboard(request: Request, cliente: str | None = None):
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
print("DBG /: abrindo sessão", flush=True) print("DBG /: abrindo sessão", flush=True)
r = await session.execute(text( r = await session.execute(text("""
"SELECT DISTINCT nome FROM faturas.faturas ORDER BY nome" SELECT id, nome_fantasia
)) FROM faturas.clientes
clientes = [c for c, in r.fetchall()] 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) print(f"DBG /: clientes={len(clientes)}", flush=True)
# Fórmulas # Fórmulas
@@ -135,7 +163,7 @@ async def dashboard(request: Request, cliente: str | None = None):
sql = "SELECT * FROM faturas.faturas" sql = "SELECT * FROM faturas.faturas"
params = {} params = {}
if cliente: if cliente:
sql += " WHERE nome = :cliente" sql += " WHERE cliente_id = :cliente"
params["cliente"] = cliente params["cliente"] = cliente
print("DBG /: SQL faturas ->", sql, params, flush=True) print("DBG /: SQL faturas ->", sql, params, flush=True)
@@ -254,21 +282,58 @@ def upload_page(request: Request):
}) })
@app.get("/relatorios", response_class=HTMLResponse) @app.get("/relatorios", response_class=HTMLResponse)
def relatorios_page(request: Request): async def relatorios_page(request: Request, cliente: str | None = Query(None)):
return templates.TemplateResponse("relatorios.html", {"request": request}) 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") @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: for file in files:
temp_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}_{file.filename}") temp_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}_{file.filename}")
with open(temp_path, "wb") as f: with open(temp_path, "wb") as f:
shutil.copyfileobj(file.file, f) shutil.copyfileobj(file.file, f)
await fila_processamento.put({ await fila_processamento.put({
"caminho_pdf": temp_path, "caminho_pdf": temp_path,
"nome_original": file.filename "nome_original": file.filename,
"cliente_id": cliente_id
}) })
return {"message": "Arquivos enviados para fila"} return {"message": "Arquivos enviados para fila"}
@app.post("/process-queue") @app.post("/process-queue")
async def process_queue(): async def process_queue():
resultados = await processar_em_lote() resultados = await processar_em_lote()
@@ -307,117 +372,109 @@ async def clear_all():
return {"message": "Fila e arquivos limpos"} return {"message": "Fila e arquivos limpos"}
@app.get("/export-excel") @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 import pandas as pd
# 1) Carregar faturas (com filtro por cliente, se houver)
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
# 1. Coletar faturas e tabela SELIC stmt = select(Fatura)
faturas_result = await session.execute(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() faturas = faturas_result.scalars().all()
selic_result = await session.execute(select(SelicMensal)) # 2) Montar dados conforme 'tipo'
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
}
dados = [] 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) if tipo == "aliquota_icms":
periodo = f"{mes:02d}/{ano} à {hoje.month:02d}/{hoje.year}" # Campos: (Cliente, UC, Referência, Valor Total, ICMS (%), ICMS (R$),
# Base ICMS (R$), Consumo (kWh), Tarifa, Nota Fiscal)
contexto = f.__dict__ for f in faturas:
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
dados.append({ 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, "UC": f.unidade_consumidora,
"Referência": f.referencia, "Referência": f.referencia,
"Nota Fiscal": f.nota_fiscal, "Nota Fiscal": f.nota_fiscal,
"Valor Total": f.valor_total, "Valor Total": f.valor_total,
"ICMS (%)": f.icms_aliq, "ICMS (%)": f.icms_aliq,
"ICMS (R$)": f.icms_valor, "ICMS (R$)": f.icms_valor,
"Base ICMS": f.icms_base, "Base ICMS (R$)": f.icms_base,
"PIS (%)": f.pis_aliq, "PIS (%)": f.pis_aliq,
"PIS (R$)": f.pis_valor, "PIS (R$)": f.pis_valor,
"Base PIS": f.pis_base, "Base PIS (R$)": f.pis_base,
"COFINS (%)": f.cofins_aliq, "COFINS (%)": f.cofins_aliq,
"COFINS (R$)": f.cofins_valor, "COFINS (R$)": f.cofins_valor,
"Base COFINS": f.cofins_base, "Base COFINS (R$)": f.cofins_base,
"Consumo (kWh)": f.consumo, "Consumo (kWh)": f.consumo,
"Tarifa": f.tarifa, "Tarifa": f.tarifa,
"Cidade": f.cidade,
"Estado": f.estado,
"Distribuidora": f.distribuidora, "Distribuidora": f.distribuidora,
"Data Processamento": f.data_processamento, "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: filename = "relatorio_geral.xlsx"
print(f"Erro ao processar fatura {f.nota_fiscal}: {e}")
df = pd.DataFrame(dados)
# 3) Gerar excel em memória
from io import BytesIO
output = BytesIO() output = BytesIO()
df = pd.DataFrame(dados)
with pd.ExcelWriter(output, engine="xlsxwriter") as writer: 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) 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 from app.parametros import router as parametros_router
app.include_router(parametros_router) app.include_router(parametros_router)
@@ -474,3 +531,70 @@ async def limpar_erros():
caminho = os.path.join(pasta, nome) caminho = os.path.join(pasta, nome)
if os.path.exists(caminho): if os.path.exists(caminho):
os.remove(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 # 📄 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 from sqlalchemy.dialects.postgresql import UUID
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -50,6 +50,8 @@ class Fatura(Base):
estado = Column(String) estado = Column(String)
distribuidora = Column(String) distribuidora = Column(String)
link_arquivo = Column("link_arquivo", String) link_arquivo = Column("link_arquivo", String)
cliente_id = Column(UUID(as_uuid=True), ForeignKey("faturas.clientes.id"), nullable=False)
class LogProcessamento(Base): class LogProcessamento(Base):

View File

@@ -93,8 +93,9 @@ async def editar_parametro(param_id: int, request: Request):
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
param = await session.get(ParametrosFormula, param_id) param = await session.get(ParametrosFormula, param_id)
if param: if param:
param.tipo = data.get("tipo", param.tipo) param.nome = data.get("nome", param.nome)
param.formula = data.get("formula", param.formula) param.formula = data.get("formula", param.formula)
param.ativo = data.get("ativo", param.ativo)
await session.commit() await session.commit()
return {"success": True} return {"success": True}
return {"success": False, "error": "Não encontrado"} 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]) @router.get("/parametros/formulas", response_model=List[ParametrosFormulaSchema])
async def listar_formulas(db: AsyncSession = Depends(get_session)): 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() return result.scalars().all()
@router.post("/parametros/formulas") @router.post("/parametros/formulas")
async def salvar_formula(form: ParametrosFormulaSchema, db: AsyncSession = Depends(get_session)): async def salvar_formula(form: ParametrosFormulaSchema, db: AsyncSession = Depends(get_session)):
result = await db.execute( result = await db.execute(
select(ParametrosFormula).filter_by(tipo=form.tipo) select(ParametrosFormula).filter_by(nome=form.nome)
) )
existente = result.scalar_one_or_none() existente = result.scalar_one_or_none()
if existente: if existente:
existente.formula = form.formula existente.formula = form.formula
existente.campos = form.campos existente.ativo = form.ativo
else: else:
novo = ParametrosFormula(**form.dict()) novo = ParametrosFormula(nome=form.nome, formula=form.formula, ativo=form.ativo)
db.add(novo) db.add(novo)
await db.commit() 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}") logger.error(f"Erro ao salvar em uploads: {e}")
return caminho_pdf_temp 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() inicio = time.perf_counter()
async with AsyncSessionLocal() as session: 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 dados['link_arquivo'] = caminho_final
# Salva fatura # Salva fatura
fatura = Fatura(**dados) dados['cliente_id'] = cliente_id
if cliente_id:
dados['cliente_id'] = cliente_id
fatura = Fatura(**dados)
session.add(fatura) session.add(fatura)
await session.commit() await session.commit()
@@ -125,15 +128,36 @@ async def processar_em_lote():
while not fila_processamento.empty(): while not fila_processamento.empty():
item = await fila_processamento.get() item = await fila_processamento.get()
try: 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_arquivos[item['nome_original']] = {
"status": resultado.get("status"), "status": resultado.get("status"),
"mensagem": resultado.get("mensagem", ""), "mensagem": resultado.get("mensagem", ""),
"tempo": resultado.get("tempo", "---"), "tempo": resultado.get("tempo", "---"),
"tamanho": os.path.getsize(item['caminho_pdf']) // 1024, # tamanho em KB "tamanho": _safe_size(temp_path) or _safe_size(dest_path),
"data": time.strftime("%d/%m/%Y", time.localtime(os.path.getmtime(item['caminho_pdf']))) "data": _safe_mtime(temp_path) or _safe_mtime(dest_path),
} }
resultados.append(status_arquivos[item['nome_original']]) resultados.append(status_arquivos[item['nome_original']])
except Exception as e: except Exception as e:
status_arquivos[item['nome_original']] = { status_arquivos[item['nome_original']] = {
@@ -156,17 +180,21 @@ async def processar_em_lote():
erros_txt.append(f"{nome} - {status.get('mensagem', 'Erro desconhecido')}") erros_txt.append(f"{nome} - {status.get('mensagem', 'Erro desconhecido')}")
if erros_txt: 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)) f.write("\n".join(erros_txt))
# Compacta PDFs com erro # 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: for nome in status_arquivos:
if status_arquivos[nome]['status'] == 'Erro': if status_arquivos[nome]['status'] == 'Erro':
caminho = os.path.join(UPLOADS_DIR, "temp", nome) caminho = os.path.join(UPLOADS_DIR, "temp", nome)
if os.path.exists(caminho): if os.path.exists(caminho):
zipf.write(caminho, arcname=nome) zipf.write(caminho, arcname=nome)
return resultados
return resultados
def limpar_arquivos_processados(): def limpar_arquivos_processados():
status_arquivos.clear() status_arquivos.clear()
@@ -192,6 +220,6 @@ async def garantir_selic_para_competencia(session, ano, mes):
if dados: if dados:
percentual = float(dados[0]["valor"].replace(",", ".")) 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) session.add(novo)
await session.commit() await session.commit()

View File

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

View File

@@ -3,43 +3,227 @@
{% block content %} {% block content %}
<h1>📊 Relatórios</h1> <h1>📊 Relatórios</h1>
<form method="get" style="margin-bottom: 20px;"> <div style="display:flex; gap:16px; align-items:flex-end; flex-wrap:wrap; margin: 6px 0 18px;">
<label for="cliente">Filtrar por Cliente:</label> <div class="combo-wrap">
<select name="cliente" id="cliente" onchange="this.form.submit()"> <label for="relatorio-cliente" style="font-size:13px;color:#374151">Selecionar Cliente:</label>
<option value="">Todos</option> <select id="relatorio-cliente" class="combo" style="min-width:340px;">
{% for c in clientes %} <option value="">Todos</option>
<option value="{{ c }}" {% if c == cliente_atual %}selected{% endif %}>{{ c }}</option> {% for c in clientes %}
{% endfor %} <option value="{{ c.id }}">{{ c.nome }}</option>
</select> {% endfor %}
</form> </select>
</div>
<div style="margin-bottom: 20px;"> <div class="combo-wrap">
<a href="/export-excel{% if cliente_atual %}?cliente={{ cliente_atual }}{% endif %}" class="btn btn-primary"> <label for="tipo-relatorio" style="font-size:13px;color:#374151">Tipo de relatório:</label>
📥 Baixar Relatório Corrigido (Excel) <select id="tipo-relatorio" class="combo" style="min-width:240px;">
</a> <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> </div>
<table style="width: 100%; border-collapse: collapse;"> <table class="table">
<thead> <thead>
<tr style="background: #2563eb; color: white;"> <tr>
<th style="padding: 10px;">Cliente</th> <th>Cliente</th>
<th>Data</th> <th>UC</th>
<th>Referência</th>
<th>Nota Fiscal</th>
<th>Valor Total</th> <th>Valor Total</th>
<th>ICMS na Base</th> <th>ICMS (%)</th>
<th>Status</th> <th>ICMS (R$)</th>
<th>PIS (R$)</th>
<th>COFINS (R$)</th>
<th>Distribuidora</th>
<th>Processado em</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="relatorios-body">
{% for f in faturas %} {% for f in faturas %}
<tr style="background: {{ loop.cycle('#ffffff', '#f0f4f8') }};"> <tr>
<td style="padding: 10px;">{{ f.nome }}</td> <td>{{ f.nome }}</td>
<td>{{ f.data_emissao }}</td> <td class="mono">{{ f.unidade_consumidora }}</td>
<td>R$ {{ '%.2f'|format(f.valor_total)|replace('.', ',') }}</td> <td class="mono">{{ f.referencia }}</td>
<td>{{ 'Sim' if f.com_icms else 'Não' }}</td> <td class="mono">{{ f.nota_fiscal }}</td>
<td>{{ f.status }}</td> <td>R$ {{ '%.2f'|format((f.valor_total or 0.0))|replace('.', ',') }}</td>
</tr> <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 %} {% endfor %}
</tbody> </tbody>
</table> </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 %} {% endblock %}

View File

@@ -4,6 +4,16 @@
<h1 style="font-size: 1.5rem; margin-bottom: 1rem;">📤 Upload de Faturas</h1> <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"> <div class="upload-box" id="upload-box">
<h3>Arraste faturas em PDF aqui ou clique para selecionar</h3> <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> <p style="color: gray; font-size: 0.9rem;">Apenas PDFs textuais (não escaneados)</p>
@@ -34,7 +44,6 @@
</div> </div>
<div id="tabela-wrapper" class="tabela-wrapper"></div> <div id="tabela-wrapper" class="tabela-wrapper"></div>
</div> </div>
ar
<script> <script>
let arquivos = []; let arquivos = [];
let statusInterval = null; let statusInterval = null;
@@ -43,18 +52,49 @@ ar
const fileTable = document.getElementById('file-table'); const fileTable = document.getElementById('file-table');
function handleFiles(files) {
if (processado) { // <<< NOVO: carrega clientes ativos no combo
document.getElementById("feedback-sucesso").innerText = ""; async function carregarClientes() {
document.getElementById("feedback-erro").innerText = "⚠️ Conclua ou inicie um novo processo antes de adicionar mais arquivos."; try {
document.getElementById("feedback-duplicado").innerText = ""; const r = await fetch('/api/clientes'); // se quiser só ativos: /api/clientes?ativos=true
document.getElementById("upload-feedback").classList.remove("hidden"); if (!r.ok) throw new Error('Falha ao carregar clientes');
return; 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 = []) { function renderTable(statusList = []) {
const grupos = ['Aguardando', 'Enviado', 'Erro', 'Duplicado']; const grupos = ['Aguardando', 'Enviado', 'Erro', 'Duplicado'];
@@ -102,7 +142,20 @@ function renderTable(statusList = []) {
} }
async function processar(btn) { async function processar(btn) {
if (!clienteSelecionado()) {
alert("Selecione um cliente antes de processar.");
return;
}
if (arquivos.length === 0) return alert("Nenhum arquivo selecionado."); 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"); document.getElementById("tabela-wrapper").classList.add("bloqueada");
if (processamentoFinalizado) { if (processamentoFinalizado) {
@@ -120,8 +173,9 @@ async function processar(btn) {
document.getElementById("barra-progresso").style.width = "0%"; document.getElementById("barra-progresso").style.width = "0%";
for (let i = 0; i < total; i++) { for (let i = 0; i < total; i++) {
const file = arquivos[i]; const file = arquivos[i]; // <- declare 'file' ANTES de usar
const formData = new FormData(); const formData = new FormData();
formData.append("cliente_id", clienteId); // <- usa o cache do cliente
formData.append("files", file); formData.append("files", file);
// Atualiza status visual antes do envio // Atualiza status visual antes do envio
@@ -129,14 +183,16 @@ async function processar(btn) {
nome: file.name, nome: file.name,
status: "Enviando...", status: "Enviando...",
mensagem: `(${i + 1}/${total})`, mensagem: `(${i + 1}/${total})`,
tempo: "---" tempo: "---",
tamanho: (file.size / 1024).toFixed(1) + " KB",
data: new Date(file.lastModified).toLocaleDateString()
}); });
renderTable(statusList); renderTable(statusList);
const start = performance.now(); const start = performance.now();
try { try {
await fetch("/upload-files", { method: "POST", body: formData }); 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}%`; document.getElementById("barra-progresso").style.width = `${progresso}%`;
statusList[i].status = "Enviado"; statusList[i].status = "Enviado";
@@ -147,9 +203,7 @@ async function processar(btn) {
} }
renderTable(statusList); renderTable(statusList);
await new Promise(r => setTimeout(r, 200)); // pequeno delay
// Delay de 200ms entre cada envio
await new Promise(r => setTimeout(r, 200));
} }
btn.innerText = "⏳ Iniciando processamento..."; btn.innerText = "⏳ Iniciando processamento...";
@@ -206,23 +260,39 @@ async function processar(btn) {
} }
} }
function limpar() { function limpar() {
fetch("/clear-all", { method: "POST" }); fetch("/clear-all", { method: "POST" });
arquivos = [];
processado = false;
document.getElementById("file-input").value = null;
renderTable();
// limpa feedback visual também // reset da fila/estado
document.getElementById("upload-feedback").classList.add("hidden"); arquivos = [];
document.getElementById("feedback-sucesso").innerText = ""; processado = false;
document.getElementById("feedback-erro").innerText = ""; processamentoFinalizado = false;
document.getElementById("feedback-duplicado").innerText = ""; if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
document.getElementById("tabela-wrapper").classList.remove("bloqueada");
processamentoFinalizado = false; // reset dos inputs/visual
document.getElementById("btn-selecionar").disabled = false; 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() { function baixarPlanilha() {
window.open('/export-excel', '_blank'); window.open('/export-excel', '_blank');
@@ -233,6 +303,7 @@ async function processar(btn) {
} }
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
carregarClientes();
updateStatus(); updateStatus();
const dragOverlay = document.getElementById("drag-overlay"); const dragOverlay = document.getElementById("drag-overlay");
@@ -255,11 +326,15 @@ window.addEventListener('DOMContentLoaded', () => {
e.preventDefault(); e.preventDefault();
}); });
window.addEventListener("drop", e => { window.addEventListener("drop", e => {
e.preventDefault(); e.preventDefault();
dragOverlay.classList.remove("active"); dragOverlay.classList.remove("active");
dragCounter = 0; dragCounter = 0;
handleFiles(e.dataTransfer.files); if (!clienteSelecionado()) {
alert('Selecione um cliente antes de anexar os arquivos.');
return;
}
handleFiles(e.dataTransfer.files);
}); });
}); });