Compare commits

..

12 Commits

Author SHA1 Message Date
b64068cfb6 Busca correta da unidade cosumidora na fatura
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-14 11:22:38 -03:00
98c6cf2363 Correção arredondamento dasalíquotas e valor taxa
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-14 08:44:41 -03:00
f6c8943d4e Seis casas decimais para estes campos: "ICMS (%)", "ICMS (%) (UF/Ref)", "Dif. ICMS (pp)", "PIS (%)", "COFINS (%)", "Consumo (kWh)"
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-13 23:10:11 -03:00
5a9fb44bdb Inclusão da coluna Arquivo PDF nas exportações em excel
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-13 22:37:09 -03:00
e6c0155758 Quatro casas decimais ao gerar o excel para os campos ICMS (%), ICMS (%) (UF/Ref), Dif. ICMS (pp), PIS (%), COFINS (%)
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-13 21:51:49 -03:00
fb08efed1d Ajustado abas tela parâmetro
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-11 23:02:55 -03:00
bc05faafea Correção erro em parametros.html
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-11 20:54:47 -03:00
4d2fcff4a8 Cadastro da alíquota do ICMS correta. Inclusão da nova alíquota e comparação em todos os relatórios.
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-11 18:45:57 -03:00
950eb2a826 Criação da tela de clientes e relatórios
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-11 13:14:54 -03:00
bcf9861e97 Criação da tela de clientes.
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-09 19:51:14 -03:00
3cfd3d3452 Ajuste no dashboard: gráfico dentro de card, removidas linhas de fundo, legendas com linha e valores exibidos nos pontos
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-09 16:12:34 -03:00
7f659a0058 feat(dashboard): reorganiza cards, remove indicadores antigos e adiciona 'Valor médio por fatura' junto aos demais; ajusta gráfico mensal para seguir padrão de design 2025-08-09 16:06:20 -03:00
15 changed files with 2274 additions and 468 deletions

View File

@@ -29,14 +29,19 @@ def extrair_dados(texto_final):
nota_fiscal = extrair_seguro(r'NOTA FISCAL Nº\s*(\d+)', texto_final) nota_fiscal = extrair_seguro(r'NOTA FISCAL Nº\s*(\d+)', texto_final)
# --- Unidade Consumidora (UC): 812 dígitos, SEM hífen ---
uc = extrair_seguro([ uc = extrair_seguro([
r'(\d{7,10}-\d)', r'UNIDADE\s*CONSUMIDORA\D*?(\d{8,12})',
r'UNIDADE\s+CONSUMIDORA\s*[:\-]?\s*(\d{6,})', r'\bUC\D*?(\d{8,12})',
r'(\d{6,})\s+FAZENDA', r'INSTALA[ÇC][ÃA]O\D*?(\d{8,12})',
r'(\d{6,})\s+AVENIDA',
r'(\d{6,})\s+RUA'
], texto_final) ], texto_final)
# fallback: maior sequência "solta" de 810 dígitos sem hífen
if not uc:
seqs = re.findall(r'(?<!\d)(\d{8,10})(?![\d-])', texto_final)
if seqs:
uc = max(seqs, key=len)
logging.debug("TEXTO PDF:\n" + texto_final) logging.debug("TEXTO PDF:\n" + texto_final)
referencia = extrair_seguro([ referencia = extrair_seguro([

View File

@@ -1,14 +1,15 @@
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
import os, shutil import os, shutil
from sqlalchemy import text from sqlalchemy import text
from datetime import date
import re
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from io import BytesIO from io import BytesIO
import pandas as pd
from app.models import ParametrosFormula from app.models import ParametrosFormula
from sqlalchemy.future import select from sqlalchemy.future import select
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
@@ -19,41 +20,257 @@ from app.processor import (
status_arquivos, status_arquivos,
limpar_arquivos_processados limpar_arquivos_processados
) )
from app.parametros import router as parametros_router
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from app.models import Fatura, SelicMensal, ParametrosFormula 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 sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_session
from fastapi import Query
from sqlalchemy import select as sqla_select
from app.models import AliquotaUF
import pandas as pd
from fastapi.responses import Response
app = FastAPI() app = FastAPI()
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/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):
"""Aceita 'JAN/2024', '01/2024', '202401' etc. Retorna (ano, mes)."""
meses = {'JAN':1,'FEV':2,'MAR':3,'ABR':4,'MAI':5,'JUN':6,'JUL':7,'AGO':8,'SET':9,'OUT':10,'NOV':11,'DEZ':12}
ref = (ref or "").strip().upper()
if "/" in ref:
a, b = [p.strip() for p in ref.split("/", 1)]
mes = meses.get(a, None)
if mes is None:
mes = int(re.sub(r"\D", "", a) or 1)
ano = int(re.sub(r"\D", "", b) or 0)
if ano < 100:
ano += 2000
else:
num = re.sub(r"\D", "", ref)
if len(num) >= 6:
ano, mes = int(num[:4]), int(num[4:6])
elif len(num) == 4:
ano, mes = int(num), 1
else:
ano, mes = 0, 0
return ano, mes
async def _carregar_selic_map(session):
res = await session.execute(text("SELECT ano, mes, percentual FROM faturas.selic_mensal"))
rows = res.mappings().all()
return {(int(r["ano"]), int(r["mes"])): float(r["percentual"]) for r in rows}
def _fator_selic_from_map(selic_map: dict, ano_inicio: int, mes_inicio: int, hoje: date) -> float:
try:
ano, mes = int(ano_inicio), int(mes_inicio)
except Exception:
return 1.0
if ano > hoje.year or (ano == hoje.year and mes > hoje.month):
return 1.0
fator = 1.0
while (ano < hoje.year) or (ano == hoje.year and mes <= hoje.month):
perc = selic_map.get((ano, mes))
if perc is not None:
fator *= (1 + (perc / 100.0))
mes += 1
if mes > 12:
mes = 1
ano += 1
return fator
def _avaliar_formula(texto_formula: str | None, contexto: dict) -> float:
if not texto_formula:
return 0.0
expr = str(texto_formula)
# Substitui nomes de campos por valores numéricos (None -> 0)
for campo, valor in contexto.items():
v = valor
if v is None or v == "":
v = 0
# 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
# 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__": {}}, {}))
except Exception:
return 0.0
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def dashboard(request: Request): async def dashboard(request: Request, cliente: str | None = None):
indicadores = [ print("DBG /: inicio", flush=True)
{"titulo": "Total de Faturas", "valor": 124}, try:
{"titulo": "Faturas com ICMS", "valor": "63%"}, async with AsyncSessionLocal() as session:
{"titulo": "Valor Total", "valor": "R$ 280.000,00"}, print("DBG /: abrindo sessão", flush=True)
]
analise_stf = { r = await session.execute(text("""
"antes": {"percentual_com_icms": 80, "media_valor": 1200}, SELECT id, nome_fantasia
"depois": {"percentual_com_icms": 20, "media_valor": 800}, 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
fp = await session.execute(text("""
SELECT formula FROM faturas.parametros_formula
WHERE nome = 'Cálculo PIS sobre ICMS' AND ativo = TRUE LIMIT 1
"""))
formula_pis = fp.scalar_one_or_none()
fc = await session.execute(text("""
SELECT formula FROM faturas.parametros_formula
WHERE nome = 'Cálculo COFINS sobre ICMS' AND ativo = TRUE LIMIT 1
"""))
formula_cofins = fc.scalar_one_or_none()
print(f"DBG /: tem_formulas pis={bool(formula_pis)} cofins={bool(formula_cofins)}", flush=True)
sql = "SELECT * FROM faturas.faturas"
params = {}
if cliente:
sql += " WHERE cliente_id = :cliente"
params["cliente"] = cliente
print("DBG /: SQL faturas ->", sql, params, flush=True)
ftrs = (await session.execute(text(sql), params)).mappings().all()
print(f"DBG /: total_faturas={len(ftrs)}", flush=True)
# ===== KPIs e Séries para o dashboard =====
from collections import defaultdict
total_faturas = len(ftrs)
qtd_icms_na_base = 0
soma_corrigida = 0.0
hoje = date.today()
selic_map = await _carregar_selic_map(session)
# Séries e somatórios comerciais
serie_mensal = defaultdict(float) # {(ano, mes): valor_corrigido}
sum_por_dist = defaultdict(float) # {"distribuidora": valor_corrigido}
somatorio_v_total = 0.0
contagem_com_icms = 0
for f in ftrs:
ctx = dict(f)
# PIS/COFINS sobre ICMS
v_pis = _avaliar_formula(formula_pis, ctx)
v_cof = _avaliar_formula(formula_cofins, ctx)
v_total = max(0.0, float(v_pis or 0) + float(v_cof or 0))
# % de faturas com ICMS na base
if (v_pis or 0) > 0:
qtd_icms_na_base += 1
contagem_com_icms += 1
# referência -> (ano,mes)
try:
ano, mes = _parse_referencia(f.get("referencia"))
except Exception:
ano, mes = hoje.year, hoje.month
# SELIC
fator = _fator_selic_from_map(selic_map, ano, mes, hoje)
valor_corrigido = v_total * fator
soma_corrigida += valor_corrigido
somatorio_v_total += v_total
# séries
serie_mensal[(ano, mes)] += valor_corrigido
dist = (f.get("distribuidora") or "").strip() or "Não informado"
sum_por_dist[dist] += valor_corrigido
percentual_icms_base = (qtd_icms_na_base / total_faturas * 100.0) if total_faturas else 0.0
valor_restituicao_corrigida = soma_corrigida
valor_medio_com_icms = (somatorio_v_total / contagem_com_icms) if contagem_com_icms else 0.0
# total de clientes (distinct já carregado)
total_clientes = len(clientes)
# Série mensal últimos 12 meses
ultimos = []
a, m = hoje.year, hoje.month
for _ in range(12):
ultimos.append((a, m))
m -= 1
if m == 0:
m = 12; a -= 1
ultimos.reverse()
serie_mensal_labels = [f"{mes:02d}/{ano}" for (ano, mes) in ultimos]
serie_mensal_valores = [round(serie_mensal.get((ano, mes), 0.0), 2) for (ano, mes) in ultimos]
# Top 5 distribuidoras
top5 = sorted(sum_por_dist.items(), key=lambda kv: kv[1], reverse=True)[:5]
top5_labels = [k for k, _ in top5]
top5_valores = [round(v, 2) for _, v in top5]
print("DBG /: calculos OK", flush=True)
print("DBG /: render template", flush=True)
return templates.TemplateResponse("dashboard.html", {
"request": request,
"clientes": clientes,
"cliente_atual": cliente or "",
"total_faturas": total_faturas,
"valor_restituicao_corrigida": valor_restituicao_corrigida,
"percentual_icms_base": percentual_icms_base,
# Novos dados para o template
"total_clientes": total_clientes,
"valor_medio_com_icms": valor_medio_com_icms,
"situacao_atual_percent": percentual_icms_base, # para gráfico de alerta
"serie_mensal_labels": serie_mensal_labels,
"serie_mensal_valores": serie_mensal_valores,
"top5_labels": top5_labels,
"top5_valores": top5_valores,
})
except Exception as e:
import traceback
print("ERR /:", e, flush=True)
traceback.print_exc()
# Página de erro amigável (sem derrubar servidor)
return HTMLResponse(
f"<pre style='padding:16px;color:#b91c1c;background:#fff1f2'>Falha no dashboard:\n{e}</pre>",
status_code=500
)
return templates.TemplateResponse("dashboard.html", {
"request": request,
"cliente_atual": "",
"clientes": ["Cliente A", "Cliente B"],
"indicadores": indicadores,
"analise_stf": analise_stf
})
@app.get("/upload", response_class=HTMLResponse) @app.get("/upload", response_class=HTMLResponse)
def upload_page(request: Request): def upload_page(request: Request):
@@ -64,21 +281,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()
@@ -117,119 +371,189 @@ 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", pattern="^(geral|exclusao_icms|aliquota_icms)$"),
cliente: str | None = Query(None)
):
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
# 1. Coletar faturas e tabela SELIC # 1) Faturas
faturas_result = await session.execute(select(Fatura)) stmt = select(Fatura)
faturas = faturas_result.scalars().all() if cliente:
stmt = stmt.where(Fatura.cliente_id == cliente)
faturas = (await session.execute(stmt)).scalars().all()
selic_result = await session.execute(select(SelicMensal)) # 2) Mapa de alíquotas cadastradas (UF/ano)
selic_tabela = selic_result.scalars().all() aliq_rows = (await session.execute(select(AliquotaUF))).scalars().all()
aliq_map = {(r.uf.upper(), int(r.exercicio)): float(r.aliq_icms) for r in aliq_rows}
# 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}" for f in faturas:
uf = (f.estado or "").strip().upper()
ano, _ = _parse_referencia(f.referencia or "")
aliq_nf = float(f.icms_aliq or 0.0)
aliq_cad = aliq_map.get((uf, ano))
diff_pp = (aliq_nf - aliq_cad) if aliq_cad is not None else None
confere = (abs(diff_pp) < 1e-6) if diff_pp is not None else None
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
dados.append({ dados.append({
"Nome": f.nome, "Cliente": f.nome,
"UF (fatura)": uf,
"Exercício (ref)": ano,
"Referência": f.referencia,
"Nota Fiscal": f.nota_fiscal,
"ICMS (%) NF": aliq_nf,
# novas colunas padronizadas
"ICMS (%) (UF/Ref)": aliq_cad,
"Dif. ICMS (pp)": diff_pp,
"ICMS confere?": "SIM" if confere else ("N/D" if confere is None else "NÃO"),
"Valor Total": f.valor_total,
"Distribuidora": f.distribuidora,
"Data Processamento": f.data_processamento,
"Arquivo PDF": f.arquivo_pdf,
})
filename = "relatorio_aliquota_icms.xlsx"
elif tipo == "exclusao_icms":
for f in faturas:
uf = (f.estado or "").strip().upper()
ano, _ = _parse_referencia(f.referencia or "")
aliq_nf = float(f.icms_aliq or 0.0)
aliq_cad = aliq_map.get((uf, ano))
diff_pp = (aliq_nf - aliq_cad) if aliq_cad is not None else None
confere = (abs(diff_pp) < 1e-6) if diff_pp is not None else None
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,
# novas colunas
"ICMS (%) (UF/Ref)": aliq_cad,
"Dif. ICMS (pp)": diff_pp,
"ICMS confere?": "SIM" if confere else ("N/D" if confere is None else "NÃO"),
"Consumo (kWh)": f.consumo,
"Tarifa": f.tarifa,
"Nota Fiscal": f.nota_fiscal,
"Arquivo PDF": f.arquivo_pdf,
})
filename = "relatorio_exclusao_icms.xlsx"
else: # geral
for f in faturas:
uf = (f.estado or "").strip().upper()
ano, _ = _parse_referencia(f.referencia or "")
aliq_nf = float(f.icms_aliq or 0.0)
aliq_cad = aliq_map.get((uf, ano))
diff_pp = (aliq_nf - aliq_cad) if aliq_cad is not None else None
confere = (abs(diff_pp) < 1e-6) if diff_pp is not None else None
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,
# novas colunas
"ICMS (%) (UF/Ref)": aliq_cad,
"Dif. ICMS (pp)": diff_pp,
"ICMS confere?": "SIM" if confere else ("N/D" if confere is None else "NÃO"),
"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, "Arquivo PDF": f.arquivo_pdf,
"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) Excel em memória
output = BytesIO()
df = pd.DataFrame(dados)
output = BytesIO() # força "Arquivo PDF" a ser a última coluna
with pd.ExcelWriter(output, engine="xlsxwriter") as writer: if "Arquivo PDF" in df.columns:
df.to_excel(writer, index=False, sheet_name="Faturas Corrigidas") cols = [c for c in df.columns if c != "Arquivo PDF"] + ["Arquivo PDF"]
df = df[cols]
output.seek(0) # converte colunas numéricas (percentuais, R$, etc.)
return StreamingResponse(output, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={ percent_cols = ["ICMS (%)", "ICMS (%) (UF/Ref)", "Dif. ICMS (pp)", "PIS (%)", "COFINS (%)"]
"Content-Disposition": "attachment; filename=faturas_corrigidas.xlsx" money_cols = ["Valor Total", "ICMS (R$)", "PIS (R$)", "COFINS (R$)",
}) "Base ICMS (R$)", "Base PIS (R$)", "Base COFINS (R$)"]
other_dec6 = ["Tarifa", "Consumo (kWh)"]
from decimal import Decimal
for col in percent_cols + money_cols + other_dec6:
if col in df.columns:
df[col] = df[col].map(lambda x: float(x) if isinstance(x, Decimal) else x)
df[col] = pd.to_numeric(df[col], errors="coerce")
# --- gera o XLSX ---
with pd.ExcelWriter(output, engine="xlsxwriter") as writer:
df.to_excel(writer, index=False, sheet_name="Relatório")
wb = writer.book
ws = writer.sheets["Relatório"]
fmt_dec6 = wb.add_format({"num_format": "0.000000"})
fmt_money6 = wb.add_format({"num_format": "#,##0.000000"})
fmt_money2 = wb.add_format({"num_format": "#,##0.00"})
for col in percent_cols:
if col in df.columns:
i = df.columns.get_loc(col)
ws.set_column(i, i, 14, fmt_dec6)
for col in money_cols:
if col in df.columns:
i = df.columns.get_loc(col)
ws.set_column(i, i, 14, fmt_money6) # ou fmt_money2 se quiser 2 casas
for col in other_dec6:
if col in df.columns:
i = df.columns.get_loc(col)
ws.set_column(i, i, 14, fmt_dec6)
# IMPORTANTE: só aqui, FORA do with
output.seek(0)
data = output.getvalue()
return Response(
content=data,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(len(data)),
},
)
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)
app.include_router(clientes.router)
def is_homolog(): def is_homolog():
return os.getenv("APP_ENV", "dev") == "homolog" return os.getenv("APP_ENV", "dev") == "homolog"
@@ -282,3 +606,98 @@ 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
# ❗ Inclua 'estado' no SELECT
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,
estado
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()
# 🔹 Carrega mapa de alíquotas UF/ano
aliq_rows = (await db.execute(select(AliquotaUF))).scalars().all()
aliq_map = {(r.uf.upper(), int(r.exercicio)): float(r.aliq_icms) for r in aliq_rows}
items = []
for r in rows:
uf = (r["estado"] or "").strip().upper()
ano, _mes = _parse_referencia(r["referencia"] or "")
aliq_nf = float(r["icms_aliq"] or 0.0)
aliq_cad = aliq_map.get((uf, ano))
diff_pp = (aliq_nf - aliq_cad) if aliq_cad is not None else None
ok = (abs(diff_pp) < 1e-6) if diff_pp is not None else None
items.append({
"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": aliq_nf,
"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,
# novos
"estado": uf,
"exercicio": ano,
"aliq_cadastral": aliq_cad,
"aliq_diff_pp": round(diff_pp, 4) if diff_pp is not None else None,
"aliq_ok": ok,
})
return {"items": items, "total": total, "page": page, "page_size": page_size}
async def _carregar_aliquota_map(session):
rows = (await session.execute(
text("SELECT uf, exercicio, aliq_icms FROM faturas.aliquotas_uf")
)).mappings().all()
# (UF, ANO) -> float
return {(r["uf"].upper(), int(r["exercicio"])): float(r["aliq_icms"]) for r in rows}

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
@@ -26,22 +26,22 @@ class Fatura(Base):
classificacao_tarifaria = Column("classificacao_tarifaria", String) classificacao_tarifaria = Column("classificacao_tarifaria", String)
unidade_consumidora = Column("unidade_consumidora", String) unidade_consumidora = Column("unidade_consumidora", String)
referencia = Column(String) referencia = Column(String)
valor_total = Column(Float) valor_total = Column(Numeric(18, 6, asdecimal=True))
pis_aliq = Column("pis_aliq", Float) pis_aliq = Column(Numeric(8, 6, asdecimal=True))
pis_valor = Column("pis_valor", Float) pis_valor = Column(Numeric(18, 6, asdecimal=True))
pis_base = Column("pis_base", Float) pis_base = Column(Numeric(18, 6, asdecimal=True))
icms_aliq = Column("icms_aliq", Float) icms_aliq = Column(Numeric(8, 6, asdecimal=True))
icms_valor = Column("icms_valor", Float) icms_valor = Column(Numeric(18, 6, asdecimal=True))
icms_base = Column("icms_base", Float) icms_base = Column(Numeric(18, 6, asdecimal=True))
cofins_aliq = Column("cofins_aliq", Float) cofins_aliq = Column(Numeric(8, 6, asdecimal=True))
cofins_valor = Column("cofins_valor", Float) cofins_valor = Column(Numeric(18, 6, asdecimal=True))
cofins_base = Column("cofins_base", Float) cofins_base = Column(Numeric(18, 6, asdecimal=True))
consumo = Column("consumo", Float) consumo = Column(Numeric(14, 6, asdecimal=True))
tarifa = Column("tarifa", Float) tarifa = Column(Numeric(12, 6, asdecimal=True))
nota_fiscal = Column(String) nota_fiscal = Column(String)
data_processamento = Column(DateTime, default=datetime.utcnow) data_processamento = Column(DateTime, default=datetime.utcnow)
@@ -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):
@@ -70,7 +72,7 @@ class AliquotaUF(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
uf = Column(String) uf = Column(String)
exercicio = Column(String) exercicio = Column(Integer)
aliq_icms = Column(Numeric(6, 4)) aliq_icms = Column(Numeric(6, 4))
class SelicMensal(Base): class SelicMensal(Base):
@@ -80,3 +82,15 @@ class SelicMensal(Base):
ano = Column(Integer, primary_key=True) ano = Column(Integer, primary_key=True)
mes = Column(Integer, primary_key=True) mes = Column(Integer, primary_key=True)
percentual = Column(Numeric(6, 4)) percentual = Column(Numeric(6, 4))
class Cliente(Base):
__tablename__ = "clientes"
__table_args__ = {"schema": "faturas"}
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
nome_fantasia = Column(String, nullable=False)
cnpj = Column(String(14), unique=True)
ativo = Column(Boolean, default=True)
data_criacao = Column(DateTime, default=datetime.utcnow)
data_atualizacao = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -1,5 +1,5 @@
# parametros.py # parametros.py
from fastapi import APIRouter, Request, Depends, Form from fastapi import APIRouter, Request, Depends, Form, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_session from app.database import get_session
from app.models import AliquotaUF, ParametrosFormula, SelicMensal from app.models import AliquotaUF, ParametrosFormula, SelicMensal
@@ -9,7 +9,7 @@ import datetime
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from sqlalchemy.future import select from sqlalchemy.future import select
from app.database import AsyncSessionLocal from app.database import AsyncSessionLocal
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse, JSONResponse
from app.models import Fatura from app.models import Fatura
from fastapi import Body from fastapi import Body
from app.database import engine from app.database import engine
@@ -21,8 +21,13 @@ import csv
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
import pandas as pd import pandas as pd
from io import BytesIO from io import BytesIO
from sqlalchemy import select
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
templates = Jinja2Templates(directory="app/templates")
router = APIRouter() router = APIRouter()
# === Schemas === # === Schemas ===
@@ -51,41 +56,57 @@ class SelicMensalSchema(BaseModel):
from_attributes = True from_attributes = True
# === Rotas === # === Rotas ===
templates = Jinja2Templates(directory="app/templates")
@router.get("/parametros") @router.get("/parametros")
async def parametros_page(request: Request): async def parametros_page(
async with AsyncSessionLocal() as session: request: Request,
# Consulta das fórmulas session: AsyncSession = Depends(get_session),
result = await session.execute(select(ParametrosFormula)) ):
parametros = result.scalars().all() # Fórmulas
result_formula = await session.execute(
text("SELECT id, nome, formula, ativo FROM faturas.parametros_formula ORDER BY id DESC")
)
formulas = [dict(row) for row in result_formula.mappings()]
# Consulta da tabela selic_mensal # SELIC (dados + última competência)
selic_result = await session.execute( result_selic = await session.execute(
select(SelicMensal).order_by(SelicMensal.ano.desc(), SelicMensal.mes.desc()) text("SELECT ano, mes, percentual FROM faturas.selic_mensal ORDER BY ano DESC, mes DESC")
) )
selic_dados = selic_result.scalars().all() selic_dados = [dict(row) for row in result_selic.mappings()]
ultima_data_selic = (
f"{selic_dados[0]['mes']:02d}/{selic_dados[0]['ano']}" if selic_dados else None
)
# Pega última data # Alíquotas por UF
ultima_data_selic = "-" result_aliquotas = await session.execute(
if selic_dados: text("""
ultima = selic_dados[0] SELECT uf,
ultima_data_selic = f"{ultima.mes:02d}/{ultima.ano}" exercicio,
aliq_icms AS aliquota
FROM faturas.aliquotas_uf
ORDER BY uf ASC, exercicio DESC
""")
)
aliquotas_uf = [dict(row) for row in result_aliquotas.mappings()]
# Campos numéricos da fatura
campos = [
col.name for col in Fatura.__table__.columns
if col.type.__class__.__name__ in ['Integer', 'Float', 'Numeric']
]
return templates.TemplateResponse("parametros.html", { # Campos disponíveis da tabela Fatura para o editor
campos_fatura = [c.name for c in Fatura.__table__.columns]
return templates.TemplateResponse(
"parametros.html",
{
"request": request, "request": request,
"lista_parametros": parametros, "parametros": None, # evita erro no Jinja
"parametros": {}, "formulas": formulas, # <-- usado no template
"campos_fatura": campos, "selic_dados": selic_dados, # <-- usado no template
"selic_dados": selic_dados, "aliquotas_uf": aliquotas_uf, # se precisar em JS
"ultima_data_selic": ultima_data_selic "ultima_data_selic": ultima_data_selic,
}) "data_maxima": None,
"campos_fatura": campos_fatura,
},
)
@router.post("/parametros/editar/{param_id}") @router.post("/parametros/editar/{param_id}")
async def editar_parametro(param_id: int, request: Request): async def editar_parametro(param_id: int, request: Request):
@@ -93,12 +114,35 @@ 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"}
@router.post("/parametros/ativar/{param_id}")
async def ativar_parametro(param_id: int, request: Request):
data = await request.json()
ativo = bool(data.get("ativo", True))
async with AsyncSessionLocal() as session:
param = await session.get(ParametrosFormula, param_id)
if not param:
return JSONResponse(status_code=404, content={"error": "Parâmetro não encontrado"})
param.ativo = ativo
await session.commit()
return {"success": True}
@router.get("/parametros/delete/{param_id}")
async def deletar_parametro(param_id: int):
async with AsyncSessionLocal() as session:
param = await session.get(ParametrosFormula, param_id)
if not param:
return RedirectResponse("/parametros?erro=1&msg=Parâmetro não encontrado", status_code=303)
await session.delete(param)
await session.commit()
return RedirectResponse("/parametros?ok=1&msg=Parâmetro removido", status_code=303)
@router.post("/parametros/testar") @router.post("/parametros/testar")
async def testar_formula(db: AsyncSession = Depends(get_session), data: dict = Body(...)): async def testar_formula(db: AsyncSession = Depends(get_session), data: dict = Body(...)):
formula = data.get("formula") formula = data.get("formula")
@@ -116,10 +160,18 @@ async def testar_formula(db: AsyncSession = Depends(get_session), data: dict = B
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@router.get("/parametros/aliquotas", response_model=List[AliquotaUFSchema]) @router.get("/parametros/aliquotas")
async def listar_aliquotas(db: AsyncSession = Depends(get_session)): async def listar_aliquotas(uf: str | None = None, db: AsyncSession = Depends(get_session)):
result = await db.execute(select(AliquotaUF).order_by(AliquotaUF.uf, AliquotaUF.exercicio)) stmt = select(AliquotaUF).order_by(AliquotaUF.uf, AliquotaUF.exercicio.desc())
return result.scalars().all() if uf:
stmt = stmt.where(AliquotaUF.uf == uf)
rows = (await db.execute(stmt)).scalars().all()
return [
{"uf": r.uf, "exercicio": int(r.exercicio), "aliquota": float(r.aliq_icms)}
for r in rows
]
@router.post("/parametros/aliquotas") @router.post("/parametros/aliquotas")
async def adicionar_aliquota(aliq: AliquotaUFSchema, db: AsyncSession = Depends(get_session)): async def adicionar_aliquota(aliq: AliquotaUFSchema, db: AsyncSession = Depends(get_session)):
@@ -140,21 +192,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()
@@ -226,5 +278,88 @@ def baixar_template_excel():
headers={"Content-Disposition": "attachment; filename=template_aliquotas.xlsx"} headers={"Content-Disposition": "attachment; filename=template_aliquotas.xlsx"}
) )
@router.post("/parametros/aliquotas/salvar")
async def salvar_aliquota(payload: dict, db: AsyncSession = Depends(get_session)):
uf = (payload.get("uf") or "").strip().upper()
exercicio = int(payload.get("exercicio") or 0)
aliquota = Decimal(str(payload.get("aliquota") or "0"))
orig_uf = (payload.get("original_uf") or "").strip().upper() or uf
orig_ex = int(payload.get("original_exercicio") or 0) or exercicio
if not uf or not exercicio or aliquota <= 0:
return JSONResponse(status_code=400, content={"error": "UF, exercício e alíquota são obrigatórios."})
# busca pelo registro original (antes da edição)
stmt = select(AliquotaUF).where(
AliquotaUF.uf == orig_uf,
AliquotaUF.exercicio == orig_ex
)
existente = (await db.execute(stmt)).scalar_one_or_none()
if existente:
# atualiza (inclusive a chave, se mudou)
existente.uf = uf
existente.exercicio = exercicio
existente.aliq_icms = aliquota
else:
# não existia o original -> upsert padrão
db.add(AliquotaUF(uf=uf, exercicio=exercicio, aliq_icms=aliquota))
await db.commit()
return {"success": True}
@router.post("/parametros/aliquotas/importar")
async def importar_aliquotas_csv(arquivo: UploadFile = File(...), db: AsyncSession = Depends(get_session)):
content = await arquivo.read()
text = content.decode("utf-8", errors="ignore")
# tenta ; depois ,
sniffer = csv.Sniffer()
dialect = sniffer.sniff(text.splitlines()[0] if text else "uf;exercicio;aliquota")
reader = csv.DictReader(io.StringIO(text), dialect=dialect)
count = 0
for row in reader:
uf = (row.get("uf") or row.get("UF") or "").strip().upper()
exercicio_str = (row.get("exercicio") or row.get("ano") or "").strip()
try:
exercicio = int(exercicio_str)
except Exception:
continue
aliquota_str = (row.get("aliquota") or row.get("aliq_icms") or "").replace(",", ".").strip()
if not uf or not exercicio or not aliquota_str:
continue
try:
aliquota = Decimal(aliquota_str)
except Exception:
continue
stmt = select(AliquotaUF).where(AliquotaUF.uf == uf, AliquotaUF.exercicio == exercicio)
existente = (await db.execute(stmt)).scalar_one_or_none()
if existente:
existente.aliq_icms = aliquota
else:
db.add(AliquotaUF(uf=uf, exercicio=exercicio, aliq_icms=aliquota))
count += 1
await db.commit()
return {"success": True, "qtd": count}
@router.delete("/parametros/aliquotas/{uf}/{exercicio}")
async def excluir_aliquota(uf: str, exercicio: int, db: AsyncSession = Depends(get_session)):
stmt = select(AliquotaUF).where(
AliquotaUF.uf == uf.upper(),
AliquotaUF.exercicio == exercicio
)
row = (await db.execute(stmt)).scalar_one_or_none()
if not row:
return JSONResponse(status_code=404, content={"error": "Registro não encontrado."})
await db.delete(row)
await db.commit()
return {"success": True}

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:
@@ -64,6 +64,41 @@ async def process_single_file(caminho_pdf_temp: str, nome_original: str):
dados = extrair_dados_pdf(caminho_pdf_temp) dados = extrair_dados_pdf(caminho_pdf_temp)
dados['arquivo_pdf'] = nome_original dados['arquivo_pdf'] = nome_original
from decimal import Decimal, ROUND_HALF_UP
_Q6 = Decimal("0.000000")
def _to_percent_6(x):
"""Converte para percent (se vier em fração) e quantiza em 6 casas."""
if x is None:
return None
try:
v = Decimal(str(x))
except Exception:
return None
# se vier em fração (ex.: 0.012872), vira 1.2872… (percentual)
if Decimal("0") < v <= Decimal("1"):
v = v * Decimal("100")
return v.quantize(_Q6, rounding=ROUND_HALF_UP)
def _to_dec6(x):
"""Apenas 6 casas, sem % (use para tarifa, bases, etc.)."""
if x is None:
return None
try:
v = Decimal(str(x))
except Exception:
return None
return v.quantize(_Q6, rounding=ROUND_HALF_UP)
dados['icms_aliq'] = _to_percent_6(dados.get('icms_aliq'))
dados['pis_aliq'] = _to_percent_6(dados.get('pis_aliq'))
dados['cofins_aliq'] = _to_percent_6(dados.get('cofins_aliq'))
# tarifa NÃO é percentual: apenas 6 casas
dados['tarifa'] = _to_dec6(dados.get('tarifa'))
# Verifica se a fatura já existe # Verifica se a fatura já existe
existente_result = await session.execute( existente_result = await session.execute(
select(Fatura).filter_by( select(Fatura).filter_by(
@@ -89,7 +124,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 +163,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 +215,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 +255,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()

72
app/routes/clientes.py Normal file
View File

@@ -0,0 +1,72 @@
# app/routes/clientes.py
from fastapi import APIRouter, Request, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, or_
from app.database import get_session
from app.models import Cliente
from pydantic import BaseModel
from uuid import UUID
import uuid
from fastapi.responses import HTMLResponse
router = APIRouter()
@router.get("/clientes")
async def clientes_page(request: Request):
return request.app.state.templates.TemplateResponse("clientes.html", {"request": request})
class ClienteIn(BaseModel):
nome_fantasia: str
cnpj: str | None = None
ativo: bool = True
@router.get("/api/clientes")
async def listar(
busca: str = Query(default="", description="Filtro por nome ou CNPJ"),
session: AsyncSession = Depends(get_session),
):
stmt = select(Cliente).order_by(Cliente.nome_fantasia)
if busca:
pattern = f"%{busca}%"
stmt = select(Cliente).where(
or_(
Cliente.nome_fantasia.ilike(pattern),
Cliente.cnpj.ilike(pattern),
)
).order_by(Cliente.nome_fantasia)
res = await session.execute(stmt)
clientes = res.scalars().all()
return [
{
"id": str(c.id),
"nome_fantasia": c.nome_fantasia,
"cnpj": c.cnpj,
"ativo": c.ativo,
}
for c in clientes
]
@router.post("/api/clientes")
async def criar_cliente(body: ClienteIn, session: AsyncSession = Depends(get_session)):
cliente = Cliente(**body.dict())
session.add(cliente)
await session.commit()
return {"id": str(cliente.id)}
@router.put("/api/clientes/{id}")
async def editar_cliente(id: UUID, body: ClienteIn, session: AsyncSession = Depends(get_session)):
await session.execute(
Cliente.__table__.update().where(Cliente.id == id).values(**body.dict())
)
await session.commit()
return {"ok": True}
@router.delete("/api/clientes/{id}")
async def excluir(id: uuid.UUID, session: AsyncSession = Depends(get_session)):
obj = await session.get(Cliente, id)
if not obj:
raise HTTPException(404, "Cliente não encontrado")
await session.delete(obj)
await session.commit()
return {"ok": True}

View File

@@ -1,90 +0,0 @@
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from sqlalchemy import create_engine, text
import os
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# Conexão com o banco de dados PostgreSQL
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/faturas")
engine = create_engine(DATABASE_URL)
@router.get("/dashboard")
def dashboard(request: Request, cliente: str = None):
with engine.connect() as conn:
filtros = ""
if cliente:
filtros = "WHERE nome = :cliente"
# Clientes únicos
clientes_query = text("SELECT DISTINCT nome FROM faturas ORDER BY nome")
clientes = [row[0] for row in conn.execute(clientes_query)]
# Indicadores
indicadores = []
indicadores.append({
"titulo": "Faturas com erro",
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE erro IS TRUE {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Faturas com valor total igual a R$ 0",
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE total = 0 {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Clientes únicos",
"valor": conn.execute(text(f"SELECT COUNT(DISTINCT nome) FROM faturas {filtros}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Total de faturas",
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas {filtros}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Faturas com campos nulos",
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE base_pis IS NULL OR base_cofins IS NULL OR base_icms IS NULL {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Alíquotas zeradas com valores diferentes de zero",
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE (aliq_pis = 0 AND pis > 0) OR (aliq_cofins = 0 AND cofins > 0) {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Faturas com ICMS incluso após decisão STF",
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE data_emissao > '2017-03-15' AND base_pis = base_icms {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
})
indicadores.append({
"titulo": "Valor total processado",
"valor": conn.execute(text(f"SELECT ROUND(SUM(total), 2) FROM faturas {filtros}"), {"cliente": cliente} if cliente else {}).scalar() or 0
})
# Análise do STF
def media_percentual_icms(data_inicio, data_fim):
result = conn.execute(text(f"""
SELECT
ROUND(AVG(CASE WHEN base_pis = base_icms THEN 100.0 ELSE 0.0 END), 2) AS percentual_com_icms,
ROUND(AVG(pis + cofins), 2) AS media_valor
FROM faturas
WHERE data_emissao BETWEEN :inicio AND :fim
{f'AND nome = :cliente' if cliente else ''}
"""), {"inicio": data_inicio, "fim": data_fim, "cliente": cliente} if cliente else {"inicio": data_inicio, "fim": data_fim}).mappings().first()
return result or {"percentual_com_icms": 0, "media_valor": 0}
analise_stf = {
"antes": media_percentual_icms("2000-01-01", "2017-03-15"),
"depois": media_percentual_icms("2017-03-16", "2099-12-31")
}
return templates.TemplateResponse("dashboard.html", {
"request": request,
"clientes": clientes,
"cliente_atual": cliente,
"indicadores": indicadores,
"analise_stf": analise_stf
})

View File

@@ -0,0 +1,140 @@
from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates
from sqlalchemy import create_engine, text
import os
from datetime import date
# Usa o avaliador de fórmulas já existente
from app.utils import avaliar_formula
router = APIRouter()
templates = Jinja2Templates(directory="app/templates")
# Conexão com o banco (use a mesma DATABASE_URL do restante do app)
DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_engine(DATABASE_URL)
def _parse_referencia(ref: str):
"""Aceita 'JAN/2024', '01/2024' ou '202401'. Retorna (ano, mes)."""
meses = {'JAN':1,'FEV':2,'MAR':3,'ABR':4,'MAI':5,'JUN':6,'JUL':7,'AGO':8,'SET':9,'OUT':10,'NOV':11,'DEZ':12}
ref = (ref or "").strip().upper()
if "/" in ref:
a, b = ref.split("/")
if a.isdigit():
mes, ano = int(a), int(b)
else:
mes, ano = meses.get(a, 1), int(b)
else:
ano, mes = int(ref[:4]), int(ref[4:]) if len(ref) >= 6 else 1
return ano, mes
def _fator_selic_acumulado(conn, ano_inicio, mes_inicio, hoje):
selic = conn.execute(text("""
SELECT ano, mes, percentual
FROM faturas.selic_mensal
""")).mappings().all()
selic_map = {(r["ano"], r["mes"]): float(r["percentual"]) for r in selic}
fator = 1.0
ano, mes = int(ano_inicio), int(mes_inicio)
while (ano < hoje.year) or (ano == hoje.year and mes <= hoje.month):
perc = selic_map.get((ano, mes))
if perc is not None:
fator *= (1 + perc/100.0)
mes += 1
if mes > 12:
mes = 1; ano += 1
return fator
@router.get("/dashboard")
def dashboard(request: Request, cliente: str | None = None):
with engine.begin() as conn:
# Lista de clientes (distinct nome)
clientes = [r[0] for r in conn.execute(text("""
SELECT DISTINCT nome FROM faturas.faturas ORDER BY nome
""")).fetchall()]
# Carrega fórmulas (ativas)
formula_pis = conn.execute(text("""
SELECT formula FROM faturas.parametros_formula
WHERE nome = 'Cálculo PIS sobre ICMS' AND ativo = TRUE
LIMIT 1
""")).scalar_one_or_none()
formula_cofins = conn.execute(text("""
SELECT formula FROM faturas.parametros_formula
WHERE nome = 'Cálculo COFINS sobre ICMS' AND ativo = TRUE
LIMIT 1
""")).scalar_one_or_none()
# Carrega faturas (com filtro opcional de cliente)
params = {}
sql = "SELECT * FROM faturas.faturas"
if cliente:
sql += " WHERE nome = :cliente"
params["cliente"] = cliente
faturas = conn.execute(text(sql), params).mappings().all()
total_faturas = len(faturas)
# Cálculos de restituição e % ICMS na base
hoje = date.today()
soma_corrigida = 0.0
qtd_icms_na_base = 0
for f in faturas:
contexto = dict(f) # usa colunas como variáveis da fórmula
# PIS sobre ICMS
v_pis_icms = avaliar_formula(formula_pis, contexto) if formula_pis else None
# COFINS sobre ICMS
v_cofins_icms = avaliar_formula(formula_cofins, contexto) if formula_cofins else None
# Contagem para % ICMS na base: considera PIS_sobre_ICMS > 0
if v_pis_icms and float(v_pis_icms) > 0:
qtd_icms_na_base += 1
# Corrigir pela SELIC desde a referência da fatura
try:
ano, mes = _parse_referencia(f.get("referencia"))
fator = _fator_selic_acumulado(conn, ano, mes, hoje)
except Exception:
fator = 1.0
valor_bruto = (float(v_pis_icms) if v_pis_icms else 0.0) + (float(v_cofins_icms) if v_cofins_icms else 0.0)
soma_corrigida += valor_bruto * fator
percentual_icms_base = (qtd_icms_na_base / total_faturas * 100.0) if total_faturas else 0.0
valor_restituicao_corrigida = soma_corrigida
# --- Análise STF (mantida) ---
def media_percentual_icms(inicio: str, fim: str):
# Aproximação: base PIS = base ICMS => configurado como proxy “com ICMS na base”
q = text(f"""
SELECT
ROUND(AVG(CASE WHEN icms_base IS NOT NULL AND pis_base = icms_base THEN 100.0 ELSE 0.0 END), 2) AS percentual_com_icms,
ROUND(AVG(COALESCE(pis_valor,0) + COALESCE(cofins_valor,0)), 2) AS media_valor
FROM faturas.faturas
WHERE data_processamento::date BETWEEN :inicio AND :fim
{ "AND nome = :cliente" if cliente else "" }
""")
params = {"inicio": inicio, "fim": fim}
if cliente: params["cliente"] = cliente
r = conn.execute(q, params).mappings().first() or {}
return {"percentual_com_icms": r.get("percentual_com_icms", 0), "media_valor": r.get("media_valor", 0)}
analise_stf = {
"antes": media_percentual_icms("2000-01-01", "2017-03-15"),
"depois": media_percentual_icms("2017-03-16", "2099-12-31")
}
return templates.TemplateResponse("dashboard.html", {
"request": request,
"clientes": clientes,
"cliente_atual": cliente or "",
"total_faturas": total_faturas,
"valor_restituicao_corrigida": valor_restituicao_corrigida,
"percentual_icms_base": percentual_icms_base,
"analise_stf": analise_stf
})

502
app/templates/clientes.html Normal file
View File

@@ -0,0 +1,502 @@
{% extends "index.html" %}
{% block title %}Clientes{% endblock %}
{% block content %}
<h1>🧾 Clientes</h1>
<div style="display:flex;justify-content:space-between;align-items:center;margin:16px 0;">
<input id="busca" type="text" placeholder="Pesquisar por nome/CNPJ…"
style="padding:.6rem;border:1px solid #ddd;border-radius:10px;min-width:280px;">
<button id="btnNovo" class="btn btn-primary" type="button">Novo Cliente</button>
</div>
<!-- Tabela -->
<div class="tbl-wrap">
<table class="tbl">
<thead>
<tr>
<th style="width:45%;">Cliente</th>
<th style="width:25%;">CNPJ</th>
<th style="width:15%;">Status</th>
<th style="width:15%; text-align:right;">Ações</th>
</tr>
</thead>
<tbody id="tbody-clientes">
<tr><td colspan="4" class="muted">Nenhum cliente encontrado.</td></tr>
</tbody>
</table>
</div>
<!-- Modal -->
<div id="modal" class="modal hidden" aria-hidden="true">
<div class="modal-backdrop" onclick="fecharModal()"></div>
<div class="modal-card">
<div id="status_bar" class="status-bar on" aria-hidden="true"></div>
<div class="modal-header">
<h3 id="modal-titulo">Novo Cliente</h3>
<button type="button" class="btn btn-secondary" onclick="fecharModal()"></button>
</div>
<form id="form-modal" onsubmit="return salvarModal(event)">
<input type="hidden" id="cli_id">
<div class="modal-body form-grid">
<div class="form-group">
<label>Nome fantasia *</label>
<input id="cli_nome" required>
</div>
<div class="form-group">
<label>CNPJ</label>
<input id="cli_cnpj"
inputmode="numeric"
autocomplete="off"
placeholder="00.000.000/0000-00">
</div>
<div class="form-group status-inline" id="grp_status">
<div style="flex:1">
<label>Status</label>
<select id="cli_ativo" onchange="setStatusUI(this.value === 'true')">
<option value="true">Ativo</option>
<option value="false">Inativo</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="fecharModal()">Cancelar</button>
<button type="submit" class="btn btn-primary">💾 Salvar</button>
</div>
</form>
</div>
</div>
<style>
.tbl-wrap{ background:#fff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb; }
.tbl{ width:100%; border-collapse:separate; border-spacing:0; }
.tbl thead th{
background:#2563eb; color:#fff; padding:12px; text-align:left; font-weight:700;
}
.tbl thead th:first-child{ border-top-left-radius:12px; }
.tbl thead th:last-child{ border-top-right-radius:12px; }
.tbl tbody td{ padding:12px; border-top:1px solid #eef2f7; vertical-align:middle; }
.tbl tbody tr:nth-child(even){ background:#f8fafc; }
.muted{ color:#6b7280; text-align:center; padding:16px; }
.badge{ display:inline-block; padding:.2rem .6rem; border-radius:999px; font-weight:700; font-size:.78rem; color:#fff; }
.on{ background:#16a34a; } .off{ background:#9ca3af; }
/* Modal */
.hidden{ display:none; }
.modal{
position: fixed;
inset: 0;
z-index: 1000;
display: flex; /* centraliza */
align-items: center; /* <-- centraliza vertical */
justify-content: center;
padding: 6vh 16px; /* respiro e evita colar nas bordas */
}
.modal-backdrop{
position: absolute;
inset: 0;
background: rgba(15,23,42,.45);
z-index: 0;
}
.modal-card{
position: relative;
z-index: 1; /* acima do backdrop */
width: min(760px, 92vw); /* largura consistente */
background: #fff;
border-radius: 20px;
overflow: hidden; /* barra acompanha cantos */
box-shadow: 0 18px 40px rgba(0,0,0,.18);
padding: 18px; /* respiro interno */
}
.modal-header{
display:flex; justify-content:space-between; align-items:center;
margin-bottom: 12px; position: relative; z-index: 1;
}
.modal-header h3{ margin:0; font-size:1.4rem; }
.modal-body{
margin-top: 6px; position: relative; z-index: 1;
}
.modal-footer{
display:flex; justify-content:flex-end; gap:.6rem; margin-top:16px;
position: relative; z-index: 1;
}
.form-grid{ display:grid; grid-template-columns:1fr; gap:14px; }
@media (min-width: 900px){ .form-grid{ grid-template-columns:1fr 1fr; } }
.form-group label{ display:block; margin-bottom:6px; color:#374151; }
.form-group input, .form-group select{
width:100%; padding:.65rem .8rem; border:1px solid #e5e7eb;
border-radius:12px; background:#fff;
}
.form-group input:focus, .form-group select:focus{
outline:none; border-color:#2563eb; box-shadow:0 0 0 3px rgba(37,99,235,.15);
}
.status-inline{ display:flex; align-items:flex-end; gap:12px; }
.badge{ display:inline-block; padding:.35rem .7rem; border-radius:999px; font-weight:700; color:#fff; }
.badge.on{ background:#16a34a; } /* ativo */
.badge.off{ background:#dc2626; } /* inativo */
.form-group label{ display:block; font-size:.9rem; color:#374151; margin-bottom:4px; }
.form-group input, .form-group select{
width:100%; padding:.55rem .7rem; border:1px solid #e5e7eb; border-radius:10px; background:#fff;
}
.hint{ font-size:.78rem; color:#6b7280; margin-top:4px; }
.btn {
padding: .5rem 1rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
display: inline-flex;
align-items: center;
gap: .4rem;
}
.btn-primary {
background-color: #2563eb;
color: white;
}
.btn-primary:hover {
background-color: #1d4ed8;
}
.btn-secondary {
background-color: #e5e7eb;
color: #374151;
}
.btn-secondary:hover {
background-color: #d1d5db;
}
.btn-danger {
background-color: #dc2626;
color: white;
}
.btn-danger:hover {
background-color: #b91c1c;
}
.status-bar{
position:absolute;
top:0; left:0;
width: 10px; /* espessura da barra */
height:100%;
background:#16a34a; /* default: ativo */
pointer-events:none; /* não intercepta cliques */
z-index: 0; /* fica por trás do conteúdo */
}
/* Cores por estado */
.status-bar.on { background:#16a34a; } /* ativo (verde) */
.status-bar.off { background:#ef4444; } /* inativo (vermelho) */
.status-ativo {
background-color: #16a34a; /* verde */
}
.status-inativo {
background-color: #ef4444; /* vermelho */
}
.modal.hidden {
display: none !important;
}
td.acoes { text-align: right; white-space: nowrap; }
.btn-icon{
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 18px;
background: #e5e7eb; /* cinza claro */
color: #374151;
cursor: pointer;
margin-left: 6px;
}
.btn-icon:hover{ background:#d1d5db; }
.btn-icon.danger{ background:#dc2626; color:#fff; }
.btn-icon.danger:hover{ background:#b91c1c; }
</style>
<script>
const onlyDigits = s => (s||'').replace(/\D/g,'');
const elBody = document.getElementById('tbody-clientes');
const elBusca = document.getElementById('busca');
function setStatusUI(isActive){
const bar = document.getElementById('status_bar');
if(!bar) return;
bar.classList.toggle('on', isActive);
bar.classList.toggle('off', !isActive);
}
// monta uma linha da tabela
function linha(c){
return `
<tr>
<td>${c.nome_fantasia || '-'}</td>
<td>${formatCNPJ(c.cnpj || '')}</td>
<td>
<span class="badge ${c.ativo ? 'on' : 'off'}">
${c.ativo ? 'Ativo' : 'Inativo'}
</span>
</td>
<td class="acoes">
<button class="btn-icon" title="Editar" aria-label="Editar"
onclick='abrirModalEditar(${JSON.stringify(c)})'>
✏️
</button>
<button class="btn-icon danger" title="Excluir" aria-label="Excluir"
onclick="removerCliente('${c.id}')">
🗑️
</button>
</td>
</tr>`;
}
// renderiza a lista no tbody (com filtro da busca)
function render(lista){
const termo = (elBusca.value || '').toLowerCase();
const filtrada = lista.filter(c =>
(c.nome_fantasia || '').toLowerCase().includes(termo) ||
(c.cnpj || '').includes(onlyDigits(termo))
);
elBody.innerHTML = filtrada.length
? filtrada.map(linha).join('')
: `<tr><td colspan="4" class="muted">Nenhum cliente encontrado.</td></tr>`;
}
// carrega clientes do backend e renderiza
async function carregar(busca = "") {
const r = await fetch('/api/clientes');
if (!r.ok){ console.error('Falha ao carregar clientes'); return; }
const dados = await r.json();
window.__clientes = dados; // guarda em memória para o filtro
render(dados);
}
// excluir cliente
async function carregar(busca = "") {
const r = await fetch(`/api/clientes?busca=${encodeURIComponent(busca)}`);
if (!r.ok) { console.error('Falha ao carregar clientes'); return; }
const dados = await r.json();
window.__clientes = dados; // mantém em memória, se quiser
render(dados);
}
function abrirModalNovo(){
const modal = document.getElementById('modal');
const grpStatus = document.getElementById('grp_status');
const inpId = document.getElementById('cli_id');
const inpNome = document.getElementById('cli_nome');
const inpCnpj = document.getElementById('cli_cnpj');
const selAtv = document.getElementById('cli_ativo');
document.getElementById('modal-titulo').textContent = 'Novo Cliente';
inpId.value = '';
inpNome.value = '';
inpCnpj.value = ''; // <<< nada de mask.textContent
selAtv.value = 'true';
setStatusUI(true);
// novo: não mostra o select de status
grpStatus.style.display = 'none';
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
setTimeout(()=> inpNome.focus(), 0);
}
function abrirModalEditar(c){
const modal = document.getElementById('modal');
const grpStatus = document.getElementById('grp_status');
const inpId = document.getElementById('cli_id');
const inpNome = document.getElementById('cli_nome');
const inpCnpj = document.getElementById('cli_cnpj');
const selAtv = document.getElementById('cli_ativo');
document.getElementById('modal-titulo').textContent = 'Editar Cliente';
inpId.value = c.id || '';
inpNome.value = c.nome_fantasia || '';
// Preenche já mascarado no próprio input
inpCnpj.value = formatCNPJ(c.cnpj || ''); // <<< em vez de mask.textContent
grpStatus.style.display = ''; // mostra no editar
const ativo = !!c.ativo;
selAtv.value = ativo ? 'true' : 'false';
setStatusUI(ativo);
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
setTimeout(()=> inpNome.focus(), 0);
}
function fecharModal(){
const modal = document.getElementById('modal');
modal.classList.add('hidden');
modal.setAttribute('aria-hidden', 'true');
}
// sincroniza barra quando trocar o select (só visível no modo edição)
document.getElementById('cli_ativo').addEventListener('change', (e)=>{
setStatusUI(e.target.value === 'true');
});
// LIGA o botão "Novo Cliente"
document.addEventListener('DOMContentLoaded', ()=>{
const btnNovo = document.getElementById('btnNovo');
if (btnNovo) btnNovo.addEventListener('click', abrirModalNovo);
});
function formatCNPJ(d){ // 14 dígitos -> 00.000.000/0000-00
d = onlyDigits(d).slice(0,14);
let out = '';
if (d.length > 0) out += d.substring(0,2);
if (d.length > 2) out += '.' + d.substring(2,5);
if (d.length > 5) out += '.' + d.substring(5,8);
if (d.length > 8) out += '/' + d.substring(8,12);
if (d.length > 12) out += '-' + d.substring(12,14);
return out;
}
function maskCNPJ(ev){
const el = ev.target;
const caret = el.selectionStart;
const before = el.value;
el.value = formatCNPJ(el.value);
// caret simples (bom o suficiente aqui)
const diff = el.value.length - before.length;
el.selectionStart = el.selectionEnd = Math.max(0, (caret||0) + diff);
}
// valida CNPJ com dígitos verificadores
function isValidCNPJ(v){
const c = onlyDigits(v);
if (c.length !== 14) return false;
if (/^(\d)\1{13}$/.test(c)) return false; // todos iguais
const calc = (base) => {
const nums = base.split('').map(n=>parseInt(n,10));
const pesos = [];
for (let i=0;i<nums.length;i++){
pesos.push( (nums.length+1-i) > 9 ? (nums.length+1-i)-8 : (nums.length+1-i) );
}
let soma = 0;
for (let i=0;i<nums.length;i++) soma += nums[i] * pesos[i];
const r = soma % 11;
return (r < 2) ? 0 : (11 - r);
};
const d1 = calc(c.substring(0,12));
const d2 = calc(c.substring(0,12) + d1);
return c.endsWith(`${d1}${d2}`);
}
// ligar máscara
document.addEventListener('DOMContentLoaded', ()=>{
const cnpjEl = document.getElementById('cli_cnpj');
if (cnpjEl){
cnpjEl.addEventListener('input', maskCNPJ);
}
});
async function salvarModal(e){
e.preventDefault();
const btnSalvar = document.querySelector('#form-modal .btn.btn-primary[type="submit"]');
if (btnSalvar) btnSalvar.disabled = true;
try {
const nome = document.getElementById('cli_nome').value.trim();
const cnpjEl = document.getElementById('cli_cnpj');
const ativo = document.getElementById('cli_ativo').value === 'true';
const id = document.getElementById('cli_id').value || null;
const cnpjDigits = onlyDigits(cnpjEl.value);
if (!nome){
alert('Informe o nome fantasia.');
document.getElementById('cli_nome').focus();
return;
}
if (cnpjDigits && !isValidCNPJ(cnpjEl.value)){
alert('CNPJ inválido.');
cnpjEl.focus();
return;
}
const payload = { nome_fantasia: nome, cnpj: cnpjDigits || null, ativo };
const url = id ? `/api/clientes/${id}` : '/api/clientes';
const method = id ? 'PUT' : 'POST';
const r = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (r.status === 409) {
const { detail } = await r.json().catch(() => ({ detail: 'CNPJ já cadastrado.' }));
alert(detail || 'CNPJ já cadastrado.');
return;
}
if (!r.ok){
alert('Erro ao salvar.');
return;
}
fecharModal();
carregar();
} finally {
if (btnSalvar) btnSalvar.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', () => {
// Botão Novo Cliente
const btnNovo = document.getElementById('btnNovo');
if (btnNovo) btnNovo.addEventListener('click', abrirModalNovo);
// Campo de busca
const busca = document.getElementById('busca');
const debounce = (fn, wait=250) => {
let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); };
};
if (busca) {
busca.addEventListener('input', debounce(() => {
carregar(busca.value.trim()); // <-- agora consulta o backend a cada digitação (com debounce)
}, 250));
}
// Máscara no CNPJ do modal
const cnpjEl = document.getElementById('cli_cnpj');
if (cnpjEl) cnpjEl.addEventListener('input', maskCNPJ);
// Carregar clientes na tabela ao abrir a página
carregar();
});
</script>
{% endblock %}

View File

@@ -2,106 +2,254 @@
{% block title %}Dashboard{% endblock %} {% block title %}Dashboard{% endblock %}
{% block content %} {% block content %}
<h1 style="display: flex; align-items: center; gap: 10px;"> <div id="loading" class="loading-backdrop">
<i class="fas fa-chart-line"></i> Dashboard de Faturas <div class="spinner"></div>
</h1> <div class="loading-msg">Carregando dados…</div>
<form method="get" style="margin: 20px 0;">
<label for="cliente">Selecionar 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>
<!-- Cards -->
<div style="display: flex; flex-wrap: wrap; gap: 20px; margin-bottom: 30px;">
{% for indicador in indicadores %}
<div style="
flex: 1 1 220px;
background: #2563eb;
color: white;
padding: 20px;
border-radius: 10px;
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
">
<strong>{{ indicador.titulo }}</strong>
<div style="font-size: 1.6rem; font-weight: bold; margin-top: 10px;">
{{ indicador.valor }}
</div>
</div>
{% endfor %}
</div> </div>
<h2 style="margin-bottom: 20px;"><i class="fas fa-chart-bar"></i> Análise da Decisão do STF (RE 574.706 15/03/2017)</h2>
<div style="display: flex; flex-wrap: wrap; gap: 20px;"> <style>
<div style="flex: 1;"> /* ---- Combobox estilizado ---- */
<h4>% de Faturas com ICMS na Base PIS/COFINS</h4> .combo {
<canvas id="graficoICMS"></canvas> 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;
}
/* ---- Cards ---- */
.cards { display: grid; grid-template-columns: repeat(12, 1fr); gap: 18px; margin: 22px 0 32px; }
.card {
grid-column: span 12;
display: grid; grid-template-columns: 82px 1fr; align-items: start;
background: #1f2937; /* cinza escuro */
color: #f9fafb; /* texto claro */
border-radius: 18px; padding: 18px;
box-shadow: 0 12px 34px rgba(0,0,0,.08);
position: relative; overflow: hidden;
transition: transform .18s ease, box-shadow .18s ease;
animation: pop .35s ease both;
}
.card:hover { transform: translateY(-2px); box-shadow: 0 18px 44px rgba(0,0,0,.1); }
@keyframes pop { from{ transform: scale(.98); opacity:.0 } to{ transform: scale(1); opacity:1 } }
.card .icon {
width: 72px; height: 72px; border-radius: 16px;
display: grid; place-items: center; font-size: 38px; color: #fff;
box-shadow: inset 0 0 40px rgba(255,255,255,.2);
}
.icon.blue { background: linear-gradient(135deg, #2563eb, #3b82f6); }
.icon.green { background: linear-gradient(135deg, #059669, #10b981); }
.icon.amber { background: linear-gradient(135deg, #d97706, #f59e0b); }
.metrics { padding-left: 16px; }
.value { font-size: 30px; font-weight: 800; color: #f9fafb; text-align: right; }
.label { margin-top: 6px; font-size: 13px; color: #d1d5db; text-align: right; }
/* Responsivo */
@media (min-width: 640px) { .card { grid-column: span 6; } }
@media (min-width: 1024px){ .card { grid-column: span 4; } }
.loading-backdrop{
position:fixed; inset:0; z-index:9999;
background:rgba(17,24,39,.55); backdrop-filter: blur(2px);
display:flex; align-items:center; justify-content:center; gap:12px;
transition:opacity .25s ease; opacity:1; pointer-events:auto;
}
.loading-backdrop.hide{ opacity:0; pointer-events:none; }
.spinner{
width:40px; height:40px; border:4px solid rgba(255,255,255,.3);
border-top-color:#60a5fa; border-radius:50%; animation:spin 1s linear infinite;
}
@keyframes spin{ to{ transform:rotate(360deg) } }
.loading-msg{ color:#fff; font-weight:600; }
/* Card simples para gráficos */
.panel{
background:#1f2937; /* mesmo fundo dos cards */
color:#f9fafb;
border-radius:18px;
padding:16px 18px 22px;
box-shadow:0 12px 34px rgba(0,0,0,.08);
margin-top:10px;
}
.panel-title{
margin:0 0 10px 0;
font-weight:700;
display:flex;align-items:center;gap:10px;
}
</style>
<h1 style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
<i class="fas fa-chart-line"></i> Dashboard de Faturas
</h1>
<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 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> </div>
<div style="flex: 1;"> </form>
<h4>Valor Médio de Tributos com ICMS</h4>
<canvas id="graficoValor"></canvas> <script>
document.getElementById('cliente').addEventListener('change', function () {
const u = new URL(window.location);
if (this.value) u.searchParams.set('cliente', this.value);
else u.searchParams.delete('cliente');
u.pathname = "/"; // garante que fica na raiz
window.location = u.toString();
});
</script>
<!-- Cards -->
<div class="cards">
<!-- Total de Clientes -->
<div class="card">
<div class="icon" style="background: linear-gradient(135deg,#7c3aed,#a78bfa)"><i class="fas fa-users"></i></div>
<div class="metrics">
<div class="value">{{ '{:,}'.format(total_clientes or 0).replace(',', '.') }}</div>
<div class="label">Total de clientes</div>
</div>
</div> </div>
<!-- Total de Faturas -->
<div class="card">
<div class="icon blue"><i class="fas fa-file-invoice"></i></div>
<div class="metrics">
<div class="value">{{ '{:,}'.format(total_faturas or 0).replace(',', '.') }}</div>
<div class="label">Total de faturas processadas</div>
</div>
</div>
<!-- Restituição Corrigida -->
<div class="card">
<div class="icon green"><i class="fas fa-hand-holding-usd"></i></div>
<div class="metrics">
<div class="value">R$ {{ '{:,.2f}'.format(valor_restituicao_corrigida or 0).replace(',', 'X').replace('.', ',').replace('X', '.') }}</div>
<div class="label">Restituição corrigida (PIS+COFINS sobre ICMS)</div>
</div>
</div>
<!-- % ICMS na Base -->
<div class="card">
<div class="icon amber"><i class="fas fa-percentage"></i></div>
<div class="metrics">
<div class="value">{{ '{:.1f}%'.format(percentual_icms_base or 0) }}</div>
<div class="label">% de faturas com ICMS na base do PIS/COFINS</div>
</div>
</div>
<!-- Valor médio por fatura com ICMS na base -->
<div class="card">
<div class="icon" style="background: linear-gradient(135deg,#ef4444,#f97316)">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="metrics">
<div class="value">R$ {{ '{:,.2f}'.format(valor_medio_com_icms or 0).replace(',', 'X').replace('.', ',').replace('X', '.') }}</div>
<div class="label">Valor médio (PIS+COFINS sobre ICMS) por fatura</div>
</div>
</div>
</div>
<!-- Evolução mensal (card) -->
<div class="panel">
<h2 class="panel-title">
<i class="fas fa-chart-line"></i>
Evolução mensal do valor passível de recuperação
</h2>
<canvas id="graficoEvolucao" style="max-height:360px"></canvas>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
<script> <script>
const ctx1 = document.getElementById('graficoICMS').getContext('2d'); const ctxE = document.getElementById('graficoEvolucao').getContext('2d');
new Chart(ctx1, {
type: 'bar',
data: {
labels: ['Antes da Decisão', 'Depois da Decisão'],
datasets: [{
label: '% com ICMS na Base',
data: {{ [analise_stf.antes.percentual_com_icms, analise_stf.depois.percentual_com_icms] | tojson }},
backgroundColor: ['#f39c12', '#e74c3c']
}]
},
options: {
responsive: true,
plugins: {
legend: { display: true },
title: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
title: { display: true, text: '%' }
}
}
}
});
const ctx2 = document.getElementById('graficoValor').getContext('2d'); const evoLabels = {{ serie_mensal_labels | tojson }};
new Chart(ctx2, { const evoValores = {{ serie_mensal_valores | tojson }};
type: 'bar',
data: { new Chart(ctxE, {
labels: ['Antes da Decisão', 'Depois da Decisão'], type: 'line',
datasets: [{ data: {
label: 'Valor Médio de PIS/COFINS com ICMS', labels: evoLabels,
data: {{ [analise_stf.antes.media_valor, analise_stf.depois.media_valor] | tojson }}, datasets: [{
backgroundColor: ['#2980b9', '#27ae60'] label: 'Valor corrigido (R$)',
}] data: evoValores,
}, fill: false,
options: { tension: 0.25,
responsive: true, borderWidth: 3,
plugins: { pointRadius: 4,
legend: { display: true }, pointHoverRadius: 5
title: { display: false } }]
}, },
scales: { options: {
y: { responsive: true,
beginAtZero: true, plugins: {
title: { display: true, text: 'R$' } legend: {
labels: {
usePointStyle: true, // legenda com “linha”, não retângulo
pointStyle: 'line'
}
},
datalabels: {
align: 'top',
anchor: 'end',
color: '#e5e7eb',
font: { weight: 600, size: 11 },
formatter: (v) => 'R$ ' + Number(v).toLocaleString('pt-BR', {
minimumFractionDigits: 2, maximumFractionDigits: 2
})
},
tooltip: {
callbacks: {
label: (ctx) => {
const v = ctx.parsed.y ?? 0;
return 'R$ ' + Number(v).toLocaleString('pt-BR', {
minimumFractionDigits: 2, maximumFractionDigits: 2
});
}
}
}
},
scales: {
x: { grid: { display: false } }, // remove linhas do fundo
y: {
grid: { display: false },
ticks: { callback: v => 'R$ ' + Number(v).toLocaleString('pt-BR') }
}
} }
} },
} plugins: [ChartDataLabels] // ativa o plugin de rótulos
}); });
// Mostra overlay ao iniciar; esconde quando tudo carregar
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('loading');
// garante visível até 'load'
el.classList.remove('hide');
});
window.addEventListener('load', () => {
const el = document.getElementById('loading');
el.classList.add('hide');
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -154,6 +154,10 @@
<i class="fas fa-tachometer-alt"></i> <i class="fas fa-tachometer-alt"></i>
<span>Dashboard</span> <span>Dashboard</span>
</a> </a>
<a href="/clientes" class="menu-item">
<i class="fas fa-building"></i>
<span>Clientes</span>
</a>
<a href="/upload" class="menu-item"> <a href="/upload" class="menu-item">
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
<span>Upload</span> <span>Upload</span>

View File

@@ -22,9 +22,9 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="aliquota_icms">Alíquota de ICMS (%):</label> <label for="aliquota_icms">Alíquota de ICMS (%):</label>
<input type="number" step="0.01" name="aliquota_icms" id="aliquota_icms" value="{{ parametros.aliquota_icms or '' }}" /> <input type="text" id="aliquota" name="aliquota" inputmode="decimal" pattern="[0-9]+([,][0-9]+)?" placeholder="Ex: 20,7487">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="formula">Fórmula:</label> <label for="formula">Fórmula:</label>
@@ -32,7 +32,7 @@
<div style="margin-bottom: 0.5rem;"> <div style="margin-bottom: 0.5rem;">
<strong>Campos disponíveis:</strong> <strong>Campos disponíveis:</strong>
<div class="campo-badges"> <div class="campo-badges">
{% for campo in campos_fatura %} {% for campo in (campos_fatura or []) %}
<span class="badge-campo" onclick="inserirNoEditor('{{ campo }}')">{{ campo }}</span> <span class="badge-campo" onclick="inserirNoEditor('{{ campo }}')">{{ campo }}</span>
{% endfor %} {% endfor %}
</div> </div>
@@ -61,36 +61,34 @@
</form> </form>
<hr style="margin-top: 2rem; margin-bottom: 1rem;"> <hr style="margin-top: 2rem; margin-bottom: 1rem;">
<h3 style="margin-top: 2rem;">📋 Fórmulas Salvas</h3> <h3 style="margin-top: 2rem;">📋 Fórmulas Salvas</h3>
<div class="card-list"> <div class="card-list">
{% for param in lista_parametros %} {% for param in (formulas or []) %}
<div class="param-card {{ 'ativo' if param.ativo else 'inativo' }}" id="card-{{ param.id }}"> <div class="param-card {{ 'ativo' if param.ativo else 'inativo' }}" id="card-{{ param.id }}">
<div style="display:flex; justify-content:space-between; align-items:center;"> <div style="display:flex; justify-content:space-between; align-items:center;">
<input type="text" class="edit-nome" value="{{ param.nome }}" data-id="{{ param.id }}" <input type="text" class="edit-nome" value="{{ param.nome }}" data-id="{{ param.id }}"
onkeydown="if(event.key==='Enter'){ event.preventDefault(); salvarInline('{{ param.id }}') }" /> onkeydown="if(event.key==='Enter'){ event.preventDefault(); salvarInline('{{ param.id }}') }" />
<span class="badge-status">{{ 'Ativo ✅' if param.ativo else 'Inativo ❌' }}</span> <span class="badge-status">{{ 'Ativo ✅' if param.ativo else 'Inativo ❌' }}</span>
</div>
<textarea class="edit-formula" data-id="{{ param.id }}" title="{{ param.formula }}">{{ param.formula }}</textarea>
<!-- botão de testar e salvar -->
<div style="display: flex; justify-content: space-between; align-items:center;">
<label>
<input type="checkbox" class="toggle-ativo" data-id="{{ param.id }}" {% if param.ativo %}checked{% endif %}>
Ativo
</label>
<div class="actions">
<button type="button" class="btn btn-sm btn-secondary btn-testar" data-id="{{ param.id }}">🧪 Testar</button>
<button class="btn btn-sm btn-primary" onclick="salvarInline('{{ param.id }}')">💾 Salvar</button>
<a href="/parametros/delete/{{ param.id }}" class="btn btn-sm btn-danger" onclick="return confirm('Deseja excluir?')">🗑️ Excluir</a>
</div>
</div>
<div class="mensagem-info" id="resultado-inline-{{ param.id }}" style="margin-top: 0.5rem; display:none;"></div>
</div> </div>
{% else %} <textarea class="edit-formula" data-id="{{ param.id }}" title="{{ param.formula }}">{{ param.formula }}</textarea>
<p style="color:gray;">Nenhuma fórmula cadastrada.</p>
{% endfor %}
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items:center;">
<label>
<input type="checkbox" class="toggle-ativo" data-id="{{ param.id }}" {% if param.ativo %}checked{% endif %}>
Ativo
</label>
<div class="actions">
<button type="button" class="btn btn-sm btn-secondary btn-testar" data-id="{{ param.id }}">🧪 Testar</button>
<button class="btn btn-sm btn-primary" onclick="salvarInline('{{ param.id }}')">💾 Salvar</button>
<a href="/parametros/delete/{{ param.id }}" class="btn btn-sm btn-danger" onclick="return confirm('Deseja excluir?')">🗑️ Excluir</a>
</div>
</div>
<div class="mensagem-info" id="resultado-inline-{{ param.id }}" style="margin-top: 0.5rem; display:none;"></div>
</div>
{% else %}
<p style="color:gray;">Nenhuma fórmula cadastrada.</p>
{% endfor %}
</div>
</div>
<!-- ABA SELIC --> <!-- ABA SELIC -->
<div id="selic" class="tab-content"> <div id="selic" class="tab-content">
@@ -105,16 +103,16 @@
<button type="submit" class="btn btn-primary">⬇️ Atualizar Fatores SELIC</button> <button type="submit" class="btn btn-primary">⬇️ Atualizar Fatores SELIC</button>
<button type="button" class="btn btn-secondary" onclick="mostrarFeedback('🔁 Atualização', 'Função de recarga futura')">🔄 Recarregar</button> <button type="button" class="btn btn-secondary" onclick="mostrarFeedback('🔁 Atualização', 'Função de recarga futura')">🔄 Recarregar</button>
</div> </div>
<div class="mensagem-info" style="margin-top:1rem;">Última data coletada da SELIC: <strong>{{ ultima_data_selic }}</strong></div> <div class="mensagem-info" style="margin-top:1rem;">Última data coletada da SELIC: <strong>{{ ultima_data_selic or '-' }}</strong></div>
</form> </form>
<table class="selic-table"> <table class="selic-table">
<thead><tr><th>Competência</th><th>Fator</th></tr></thead> <thead><tr><th>Competência</th><th>Fator</th></tr></thead>
<tbody> <tbody>
{% for item in selic_dados %} {% for item in selic_dados %}
<tr> <tr>
<td>{{ "%02d"|format(item.mes) }}/{{ item.ano }}</td> <td>{{ "%02d"|format(item.mes) }}/{{ item.ano }}</td>
<td>{{ "%.4f"|format(item.percentual) }}</td> <td>{{ "%.4f"|format(item.percentual) }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -124,7 +122,7 @@
<!-- ABA ALÍQUOTAS --> <!-- ABA ALÍQUOTAS -->
<div id="aliquotas" class="tab-content"> <div id="aliquotas" class="tab-content">
<div class="formulario-box"> <div class="formulario-box">
<form onsubmit="return salvarAliquota(this)"> <form onsubmit="return salvarAliquota(this, event)">
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));"> <div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));">
<div class="form-group"> <div class="form-group">
<label>UF:</label> <label>UF:</label>
@@ -141,7 +139,10 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Alíquota ICMS (%):</label> <label>Alíquota ICMS (%):</label>
<input name="aliquota" type="number" step="0.0001" required /> <input id="aliquota-uf" name="aliquota"
type="text" inputmode="decimal"
pattern="[0-9]+([,][0-9]+)?"
placeholder="Ex: 20,7487" required />
</div> </div>
</div> </div>
<!-- Bloco com espaçamento e alinhamento central --> <!-- Bloco com espaçamento e alinhamento central -->
@@ -160,12 +161,35 @@
</div> </div>
</div> </div>
<input type="hidden" id="orig-uf" name="original_uf">
<input type="hidden" id="orig-exercicio" name="original_exercicio">
</form> </form>
<table class="selic-table"> <!-- Filtro de UF para a tabela -->
<thead><tr><th>UF</th><th>Exercício</th><th>Alíquota</th></tr></thead> <div style="display:flex; align-items:center; gap:12px; margin:14px 0;">
<tbody id="tabela-aliquotas"></tbody> <label for="filtro-uf" style="font-weight:600;">Filtrar por UF:</label>
</table> <select id="filtro-uf" style="min-width:220px; padding:.5rem .75rem; border:1px solid #ddd; border-radius:8px;">
<option value="">Todas</option>
{% for uf in ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO'] %}
<option value="{{ uf }}">{{ uf }}</option>
{% endfor %}
</select>
<span id="total-aliquotas" class="muted"></span>
</div>
<table class="selic-table">
<thead>
<tr>
<th>UF</th>
<th>Exercício</th>
<th>Alíquota</th>
<th style="width:140px;">Ações</th>
</tr>
</thead>
<tbody id="tabela-aliquotas"></tbody>
</table>
</div> </div>
</div> </div>
@@ -581,16 +605,39 @@
// ✅ Carrega tabela de alíquotas // ✅ Carrega tabela de alíquotas
async function carregarAliquotas() { async function carregarAliquotas() {
const res = await fetch("/parametros/aliquotas"); const uf = document.getElementById("filtro-uf")?.value || "";
const url = new URL("/parametros/aliquotas", window.location.origin);
if (uf) url.searchParams.set("uf", uf);
const res = await fetch(url);
const dados = await res.json(); const dados = await res.json();
const tbody = document.getElementById("tabela-aliquotas"); const tbody = document.getElementById("tabela-aliquotas");
if (!dados.length) {
tbody.innerHTML = `<tr><td colspan="3" style="padding:.6rem;">Nenhum registro.</td></tr>`;
} else {
tbody.innerHTML = dados.map(a => ` tbody.innerHTML = dados.map(a => `
<tr><td>${a.uf}</td><td>${a.exercicio}</td><td>${a.aliquota.toFixed(4)}%</td></tr> <tr>
<td>${a.uf}</td>
<td>${a.exercicio}</td>
<td>${Number(a.aliquota).toLocaleString('pt-BR', {minimumFractionDigits:4, maximumFractionDigits:4})}%</td>
<td style="display:flex; gap:8px;">
<button class="btn btn-sm btn-secondary"
onclick="editarAliquota('${a.uf}', ${a.exercicio}, ${Number(a.aliquota)})">✏️ Editar</button>
<button class="btn btn-sm btn-danger"
onclick="excluirAliquota('${a.uf}', ${a.exercicio})">🗑️ Excluir</button>
</td>
</tr>
`).join(''); `).join('');
}
document.getElementById("total-aliquotas").textContent = `Registros: ${dados.length}`;
} }
// ✅ Eventos após carregar DOM // ✅ Eventos após carregar DOM
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.getElementById("filtro-uf")?.addEventListener("change", carregarAliquotas);
carregarAliquotas(); carregarAliquotas();
// Ativar/desativar checkbox // Ativar/desativar checkbox
@@ -625,13 +672,11 @@
} }
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {
const abaUrl = new URLSearchParams(window.location.search).get("aba"); const aba = new URLSearchParams(window.location.search).get("aba");
if (abaUrl === "selic") { if (aba === "formulas" || aba === "selic" || aba === "aliquotas") {
document.querySelector(".tab.active")?.classList.remove("active"); switchTab(aba);
document.querySelector(".tab-content.active")?.classList.remove("active"); } else {
switchTab("formulas"); // padrão
document.querySelector(".tab:nth-child(2)").classList.add("active"); // Ativa o botão da aba
document.getElementById("selic").classList.add("active"); // Ativa o conteúdo da aba
} }
}); });
@@ -657,6 +702,86 @@
}); });
} }
async function salvarAliquota(form, ev) {
ev?.preventDefault();
const uf = form.uf.value?.trim();
const exercicio = Number(form.exercicio.value?.trim());
const aliquotaStr = form.aliquota.value?.trim();
const aliquota = parseFloat(aliquotaStr.replace(',', '.')); // vírgula -> ponto
// 👇 LE OS ORIGINAIS
const original_uf = document.getElementById('orig-uf').value || null;
const original_exercicio = document.getElementById('orig-exercicio').value
? Number(document.getElementById('orig-exercicio').value)
: null;
if (!uf || !exercicio || isNaN(exercicio) || !aliquotaStr || isNaN(aliquota)) {
mostrarFeedback("❌ Erro", "Preencha UF, exercício e alíquota válidos.");
return false;
}
const res = await fetch("/parametros/aliquotas/salvar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ uf, exercicio, aliquota, original_uf, original_exercicio })
});
if (!res.ok) {
const msg = await res.text();
mostrarFeedback("❌ Erro ao salvar", msg || "Falha na operação.");
return false;
}
mostrarFeedback("✅ Salvo", "Alíquota registrada/atualizada com sucesso.");
cancelarEdicao(); // limpa modo edição
carregarAliquotas();
return false;
}
function editarAliquota(uf, exercicio, aliquota) {
const form = document.querySelector('#aliquotas form');
form.uf.value = uf;
form.exercicio.value = String(exercicio);
// Mostrar no input com vírgula e 4 casas
const valorBR = Number(aliquota).toLocaleString('pt-BR', {
minimumFractionDigits: 4, maximumFractionDigits: 4
});
form.querySelector('[name="aliquota"]').value = valorBR;
// 👇 GUARDA A CHAVE ORIGINAL
document.getElementById('orig-uf').value = uf;
document.getElementById('orig-exercicio').value = String(exercicio);
document.getElementById('aliquotas')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function cancelarEdicao(){
const form = document.querySelector('#aliquotas form');
form.reset();
document.getElementById('orig-uf').value = '';
document.getElementById('orig-exercicio').value = '';
}
async function excluirAliquota(uf, exercicio){
if(!confirm(`Excluir a alíquota de ${uf}/${exercicio}?`)) return;
const res = await fetch(`/parametros/aliquotas/${encodeURIComponent(uf)}/${exercicio}`, {
method: 'DELETE'
});
if(!res.ok){
const msg = await res.text();
mostrarFeedback("❌ Erro", msg || "Falha ao excluir.");
return;
}
mostrarFeedback("🗑️ Excluída", "Alíquota removida com sucesso.");
carregarAliquotas();
}
</script> </script>
<!-- Feedback estilo popup --> <!-- Feedback estilo popup -->

View File

@@ -3,36 +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>
<table style="width: 100%; border-collapse: collapse;"> <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 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>
@@ -22,8 +32,10 @@
<a class="btn btn-secondary" href="/erros/log">📄 Ver Log de Erros (.txt)</a> <a class="btn btn-secondary" href="/erros/log">📄 Ver Log de Erros (.txt)</a>
</div> </div>
{% endif %} {% endif %}
<!--
<button class="btn btn-success" onclick="baixarPlanilha()">📅 Abrir Planilha</button> <button class="btn btn-success" onclick="baixarPlanilha()">📅 Abrir Planilha</button>
<button class="btn btn-success" onclick="gerarRelatorio()">📊 Gerar Relatório</button> <button class="btn btn-success" onclick="gerarRelatorio()">📊 Gerar Relatório</button>
-->
{% if app_env != "producao" %} {% if app_env != "producao" %}
<button class="btn btn-warning" onclick="limparFaturas()">🧹 Limpar Faturas (Teste)</button> <button class="btn btn-warning" onclick="limparFaturas()">🧹 Limpar Faturas (Teste)</button>
{% endif %} {% endif %}
@@ -34,7 +46,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 +54,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 +144,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 +175,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 +185,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 +205,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 +262,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 +305,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 +328,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);
}); });
}); });

View File

@@ -7,4 +7,5 @@ python-multipart==0.0.6
openpyxl==3.1.2 openpyxl==3.1.2
pandas==2.2.2 pandas==2.2.2
PyMuPDF==1.22.5 PyMuPDF==1.22.5
httpx==0.27.0 httpx==0.27.0
xlsxwriter==3.2.0