Compare commits
20 Commits
e7c2a64714
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b64068cfb6 | |||
| 98c6cf2363 | |||
| f6c8943d4e | |||
| 5a9fb44bdb | |||
| e6c0155758 | |||
| fb08efed1d | |||
| bc05faafea | |||
| 4d2fcff4a8 | |||
| 950eb2a826 | |||
| bcf9861e97 | |||
| 3cfd3d3452 | |||
| 7f659a0058 | |||
| 291eec35a8 | |||
| 5cf4fe0af3 | |||
| 60fe5b3995 | |||
| d8db2a60e5 | |||
| b51eeac014 | |||
| f639f306be | |||
| a024125982 | |||
| 5eac7782a1 |
@@ -19,7 +19,6 @@ steps:
|
||||
target: /home/app_fatura_homolog
|
||||
rm: false
|
||||
|
||||
|
||||
- name: restart homolog container
|
||||
image: appleboy/drone-ssh
|
||||
settings:
|
||||
@@ -28,9 +27,9 @@ steps:
|
||||
password: F6tC5tCh29XQRpzp
|
||||
port: 22
|
||||
script:
|
||||
- docker rm -f FaturasHomolog || true
|
||||
- cd /home/app_fatura_homolog
|
||||
- docker compose -f docker-compose-homolog.yml up -d
|
||||
- docker compose -f docker-compose-homolog.yml down
|
||||
- docker compose -f docker-compose-homolog.yml up -d --build
|
||||
|
||||
---
|
||||
kind: pipeline
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ __pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.vscode/
|
||||
uploads/
|
||||
@@ -3,16 +3,16 @@ from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from contextlib import asynccontextmanager
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
load_dotenv()
|
||||
|
||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
async_engine = create_async_engine(DATABASE_URL, echo=False, future=True)
|
||||
AsyncSessionLocal = sessionmaker(bind=async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
engine = create_async_engine(DATABASE_URL)
|
||||
AsyncSessionLocal = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
|
||||
Base = declarative_base()
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_session():
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@@ -29,14 +29,19 @@ def extrair_dados(texto_final):
|
||||
|
||||
nota_fiscal = extrair_seguro(r'NOTA FISCAL Nº\s*(\d+)', texto_final)
|
||||
|
||||
# --- Unidade Consumidora (UC): 8–12 dígitos, SEM hífen ---
|
||||
uc = extrair_seguro([
|
||||
r'(\d{7,10}-\d)',
|
||||
r'UNIDADE\s+CONSUMIDORA\s*[:\-]?\s*(\d{6,})',
|
||||
r'(\d{6,})\s+FAZENDA',
|
||||
r'(\d{6,})\s+AVENIDA',
|
||||
r'(\d{6,})\s+RUA'
|
||||
r'UNIDADE\s*CONSUMIDORA\D*?(\d{8,12})',
|
||||
r'\bUC\D*?(\d{8,12})',
|
||||
r'INSTALA[ÇC][ÃA]O\D*?(\d{8,12})',
|
||||
], texto_final)
|
||||
|
||||
# fallback: maior sequência "solta" de 8–10 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)
|
||||
|
||||
referencia = extrair_seguro([
|
||||
|
||||
660
app/main.py
660
app/main.py
@@ -1,14 +1,15 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from fastapi import FastAPI, Request, UploadFile, File
|
||||
from fastapi import FastAPI, HTTPException, Request, UploadFile, File, Depends, Form
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import os, shutil
|
||||
from sqlalchemy import text
|
||||
from fastapi import Depends
|
||||
from datetime import date
|
||||
import re
|
||||
from fastapi.responses import StreamingResponse
|
||||
from io import BytesIO
|
||||
import pandas as pd
|
||||
from app.models import ParametrosFormula
|
||||
from sqlalchemy.future import select
|
||||
from app.database import AsyncSessionLocal
|
||||
@@ -19,35 +20,257 @@ from app.processor import (
|
||||
status_arquivos,
|
||||
limpar_arquivos_processados
|
||||
)
|
||||
from app.parametros import router as parametros_router
|
||||
from fastapi.responses import FileResponse
|
||||
from app.models import Fatura, SelicMensal, ParametrosFormula
|
||||
from datetime import date
|
||||
from app.utils import avaliar_formula
|
||||
from app.routes import clientes
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_session
|
||||
from fastapi import Query
|
||||
from sqlalchemy import select as sqla_select
|
||||
from app.models import AliquotaUF
|
||||
import pandas as pd
|
||||
from fastapi.responses import Response
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
app.state.templates = templates
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
UPLOAD_DIR = "uploads/temp"
|
||||
UPLOAD_DIR = os.path.join("app", "uploads", "temp")
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
def _parse_referencia(ref: str):
|
||||
"""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)
|
||||
def dashboard(request: Request):
|
||||
indicadores = [
|
||||
{"titulo": "Total de Faturas", "valor": 124},
|
||||
{"titulo": "Faturas com ICMS", "valor": "63%"},
|
||||
{"titulo": "Valor Total", "valor": "R$ 280.000,00"},
|
||||
]
|
||||
async def dashboard(request: Request, cliente: str | None = None):
|
||||
print("DBG /: inicio", flush=True)
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
print("DBG /: abrindo sessão", flush=True)
|
||||
|
||||
analise_stf = {
|
||||
"antes": {"percentual_com_icms": 80, "media_valor": 1200},
|
||||
"depois": {"percentual_com_icms": 20, "media_valor": 800},
|
||||
}
|
||||
r = await session.execute(text("""
|
||||
SELECT id, nome_fantasia
|
||||
FROM faturas.clientes
|
||||
WHERE ativo = TRUE
|
||||
ORDER BY nome_fantasia
|
||||
"""))
|
||||
clientes = [{"id": id_, "nome": nome} for id_, nome in r.fetchall()]
|
||||
|
||||
print(f"DBG /: clientes={len(clientes)}", flush=True)
|
||||
|
||||
# Fórmulas
|
||||
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)
|
||||
def upload_page(request: Request):
|
||||
@@ -58,31 +281,58 @@ def upload_page(request: Request):
|
||||
})
|
||||
|
||||
@app.get("/relatorios", response_class=HTMLResponse)
|
||||
def relatorios_page(request: Request):
|
||||
return templates.TemplateResponse("relatorios.html", {"request": request})
|
||||
|
||||
@app.get("/parametros", response_class=HTMLResponse)
|
||||
async def parametros_page(request: Request):
|
||||
async def relatorios_page(request: Request, cliente: str | None = Query(None)):
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(ParametrosFormula))
|
||||
parametros = result.scalars().first()
|
||||
return templates.TemplateResponse("parametros.html", {
|
||||
"request": request,
|
||||
"parametros": parametros or {}
|
||||
})
|
||||
# Carregar clientes ativos para o combo
|
||||
r_cli = await session.execute(text("""
|
||||
SELECT id, nome_fantasia
|
||||
FROM faturas.clientes
|
||||
WHERE ativo = TRUE
|
||||
ORDER BY nome_fantasia
|
||||
"""))
|
||||
clientes = [{"id": str(row.id), "nome": row.nome_fantasia} for row in r_cli]
|
||||
|
||||
# Carregar faturas (todas ou filtradas por cliente)
|
||||
if cliente:
|
||||
r_fat = await session.execute(text("""
|
||||
SELECT *
|
||||
FROM faturas.faturas
|
||||
WHERE cliente_id = :cid
|
||||
ORDER BY data_processamento DESC
|
||||
"""), {"cid": cliente})
|
||||
else:
|
||||
r_fat = await session.execute(text("""
|
||||
SELECT *
|
||||
FROM faturas.faturas
|
||||
ORDER BY data_processamento DESC
|
||||
"""))
|
||||
|
||||
faturas = r_fat.mappings().all()
|
||||
|
||||
return templates.TemplateResponse("relatorios.html", {
|
||||
"request": request,
|
||||
"clientes": clientes,
|
||||
"cliente_selecionado": cliente or "",
|
||||
"faturas": faturas
|
||||
})
|
||||
|
||||
@app.post("/upload-files")
|
||||
async def upload_files(files: list[UploadFile] = File(...)):
|
||||
async def upload_files(
|
||||
cliente_id: str = Form(...),
|
||||
files: list[UploadFile] = File(...)
|
||||
):
|
||||
for file in files:
|
||||
temp_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}_{file.filename}")
|
||||
with open(temp_path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
await fila_processamento.put({
|
||||
"caminho_pdf": temp_path,
|
||||
"nome_original": file.filename
|
||||
"nome_original": file.filename,
|
||||
"cliente_id": cliente_id
|
||||
})
|
||||
return {"message": "Arquivos enviados para fila"}
|
||||
|
||||
|
||||
@app.post("/process-queue")
|
||||
async def process_queue():
|
||||
resultados = await processar_em_lote()
|
||||
@@ -97,8 +347,11 @@ async def get_status():
|
||||
"nome": nome,
|
||||
"status": status.get("status", "Erro"),
|
||||
"mensagem": status.get("mensagem", "---"),
|
||||
"tempo": status.get("tempo", "---") # ✅ AQUI
|
||||
"tempo": status.get("tempo", "---"),
|
||||
"tamanho": f"{status.get('tamanho', 0)} KB",
|
||||
"data": status.get("data", "")
|
||||
})
|
||||
|
||||
else:
|
||||
files.append({
|
||||
"nome": nome,
|
||||
@@ -118,49 +371,189 @@ async def clear_all():
|
||||
return {"message": "Fila e arquivos limpos"}
|
||||
|
||||
@app.get("/export-excel")
|
||||
async def export_excel():
|
||||
async def export_excel(
|
||||
tipo: str = Query("geral", pattern="^(geral|exclusao_icms|aliquota_icms)$"),
|
||||
cliente: str | None = Query(None)
|
||||
):
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(Fatura))
|
||||
faturas = result.scalars().all()
|
||||
# 1) Faturas
|
||||
stmt = select(Fatura)
|
||||
if cliente:
|
||||
stmt = stmt.where(Fatura.cliente_id == cliente)
|
||||
faturas = (await session.execute(stmt)).scalars().all()
|
||||
|
||||
# 2) Mapa de alíquotas cadastradas (UF/ano)
|
||||
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}
|
||||
|
||||
dados = []
|
||||
for f in faturas:
|
||||
dados.append({
|
||||
"Nome": f.nome,
|
||||
"UC": f.unidade_consumidora,
|
||||
"Referência": f.referencia,
|
||||
"Nota Fiscal": f.nota_fiscal,
|
||||
"Valor Total": f.valor_total,
|
||||
"ICMS (%)": f.icms_aliq,
|
||||
"ICMS (R$)": f.icms_valor,
|
||||
"Base ICMS": f.icms_base,
|
||||
"PIS (%)": f.pis_aliq,
|
||||
"PIS (R$)": f.pis_valor,
|
||||
"Base PIS": f.pis_base,
|
||||
"COFINS (%)": f.cofins_aliq,
|
||||
"COFINS (R$)": f.cofins_valor,
|
||||
"Base COFINS": f.cofins_base,
|
||||
"Consumo (kWh)": f.consumo,
|
||||
"Tarifa": f.tarifa,
|
||||
"Cidade": f.cidade,
|
||||
"Estado": f.estado,
|
||||
"Distribuidora": f.distribuidora,
|
||||
"Data Processamento": f.data_processamento,
|
||||
})
|
||||
|
||||
df = pd.DataFrame(dados)
|
||||
output = BytesIO()
|
||||
df.to_excel(output, index=False, sheet_name="Faturas")
|
||||
output.seek(0)
|
||||
if tipo == "aliquota_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,
|
||||
"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,
|
||||
"Referência": f.referencia,
|
||||
"Nota Fiscal": f.nota_fiscal,
|
||||
"Valor Total": f.valor_total,
|
||||
"ICMS (%)": f.icms_aliq,
|
||||
"ICMS (R$)": f.icms_valor,
|
||||
|
||||
# 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 (R$)": f.pis_valor,
|
||||
"Base PIS (R$)": f.pis_base,
|
||||
"COFINS (%)": f.cofins_aliq,
|
||||
"COFINS (R$)": f.cofins_valor,
|
||||
"Base COFINS (R$)": f.cofins_base,
|
||||
"Consumo (kWh)": f.consumo,
|
||||
"Tarifa": f.tarifa,
|
||||
"Distribuidora": f.distribuidora,
|
||||
"Data Processamento": f.data_processamento,
|
||||
"Arquivo PDF": f.arquivo_pdf,
|
||||
})
|
||||
filename = "relatorio_geral.xlsx"
|
||||
|
||||
# 3) Excel em memória
|
||||
output = BytesIO()
|
||||
df = pd.DataFrame(dados)
|
||||
|
||||
# força "Arquivo PDF" a ser a última coluna
|
||||
if "Arquivo PDF" in df.columns:
|
||||
cols = [c for c in df.columns if c != "Arquivo PDF"] + ["Arquivo PDF"]
|
||||
df = df[cols]
|
||||
|
||||
# converte colunas numéricas (percentuais, R$, etc.)
|
||||
percent_cols = ["ICMS (%)", "ICMS (%) (UF/Ref)", "Dif. ICMS (pp)", "PIS (%)", "COFINS (%)"]
|
||||
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)),
|
||||
},
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": "attachment; filename=relatorio_faturas.xlsx"}
|
||||
)
|
||||
|
||||
from app.parametros import router as parametros_router
|
||||
app.include_router(parametros_router)
|
||||
app.include_router(clientes.router)
|
||||
|
||||
def is_homolog():
|
||||
return os.getenv("APP_ENV", "dev") == "homolog"
|
||||
@@ -183,3 +576,128 @@ async def limpar_faturas():
|
||||
os.remove(caminho)
|
||||
|
||||
return {"message": "Faturas e arquivos apagados com sucesso."}
|
||||
|
||||
@app.get("/erros/download")
|
||||
async def download_erros():
|
||||
zip_path = os.path.join("app", "uploads", "erros", "faturas_erro.zip")
|
||||
if os.path.exists(zip_path):
|
||||
response = FileResponse(zip_path, filename="faturas_erro.zip", media_type="application/zip")
|
||||
# ⚠️ Agendar exclusão após resposta
|
||||
asyncio.create_task(limpar_erros())
|
||||
return response
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Arquivo de erro não encontrado.")
|
||||
|
||||
@app.get("/erros/log")
|
||||
async def download_log_erros():
|
||||
txt_path = os.path.join("app", "uploads", "erros", "erros.txt")
|
||||
if os.path.exists(txt_path):
|
||||
response = FileResponse(txt_path, filename="erros.txt", media_type="text/plain")
|
||||
# ⚠️ Agendar exclusão após resposta
|
||||
asyncio.create_task(limpar_erros())
|
||||
return response
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Log de erro não encontrado.")
|
||||
|
||||
async def limpar_erros():
|
||||
await asyncio.sleep(5) # Aguarda 5 segundos para garantir que o download inicie
|
||||
pasta = os.path.join("app", "uploads", "erros")
|
||||
for nome in ["faturas_erro.zip", "erros.txt"]:
|
||||
caminho = os.path.join(pasta, nome)
|
||||
if os.path.exists(caminho):
|
||||
os.remove(caminho)
|
||||
|
||||
@app.get("/api/clientes")
|
||||
async def listar_clientes(db: AsyncSession = Depends(get_session)):
|
||||
sql = text("""
|
||||
SELECT id, nome_fantasia, cnpj, ativo
|
||||
FROM faturas.clientes
|
||||
WHERE ativo = TRUE
|
||||
ORDER BY nome_fantasia
|
||||
""")
|
||||
res = await db.execute(sql)
|
||||
rows = res.mappings().all()
|
||||
return [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"nome_fantasia": r["nome_fantasia"],
|
||||
"cnpj": r["cnpj"],
|
||||
"ativo": bool(r["ativo"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@app.get("/api/relatorios")
|
||||
async def api_relatorios(
|
||||
cliente: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=5, le=200),
|
||||
db: AsyncSession = Depends(get_session),
|
||||
):
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
where = "WHERE cliente_id = :cliente" if cliente else ""
|
||||
params = {"limit": page_size, "offset": offset}
|
||||
if cliente:
|
||||
params["cliente"] = cliente
|
||||
|
||||
# ❗ 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}
|
||||
@@ -1,24 +1,21 @@
|
||||
# 📄 models.py
|
||||
from sqlalchemy import Column, String, Integer, Float, DateTime, Text
|
||||
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from app.database import Base
|
||||
from sqlalchemy import Boolean
|
||||
from sqlalchemy import Column, Integer, String, Numeric
|
||||
|
||||
|
||||
class ParametrosFormula(Base):
|
||||
__tablename__ = "parametros_formula"
|
||||
__table_args__ = {"schema": "faturas"}
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tipo = Column(String(20))
|
||||
id = Column(Integer, primary_key=True)
|
||||
nome = Column(String(50))
|
||||
formula = Column(Text)
|
||||
ativo = Column(Boolean)
|
||||
aliquota_icms = Column(Float)
|
||||
incluir_icms = Column(Integer)
|
||||
incluir_pis = Column(Integer)
|
||||
incluir_cofins = Column(Integer)
|
||||
ativo = Column(Boolean, default=True)
|
||||
|
||||
class Fatura(Base):
|
||||
__tablename__ = "faturas"
|
||||
@@ -29,22 +26,22 @@ class Fatura(Base):
|
||||
classificacao_tarifaria = Column("classificacao_tarifaria", String)
|
||||
unidade_consumidora = Column("unidade_consumidora", String)
|
||||
referencia = Column(String)
|
||||
valor_total = Column(Float)
|
||||
valor_total = Column(Numeric(18, 6, asdecimal=True))
|
||||
|
||||
pis_aliq = Column("pis_aliq", Float)
|
||||
pis_valor = Column("pis_valor", Float)
|
||||
pis_base = Column("pis_base", Float)
|
||||
pis_aliq = Column(Numeric(8, 6, asdecimal=True))
|
||||
pis_valor = Column(Numeric(18, 6, asdecimal=True))
|
||||
pis_base = Column(Numeric(18, 6, asdecimal=True))
|
||||
|
||||
icms_aliq = Column("icms_aliq", Float)
|
||||
icms_valor = Column("icms_valor", Float)
|
||||
icms_base = Column("icms_base", Float)
|
||||
icms_aliq = Column(Numeric(8, 6, asdecimal=True))
|
||||
icms_valor = Column(Numeric(18, 6, asdecimal=True))
|
||||
icms_base = Column(Numeric(18, 6, asdecimal=True))
|
||||
|
||||
cofins_aliq = Column("cofins_aliq", Float)
|
||||
cofins_valor = Column("cofins_valor", Float)
|
||||
cofins_base = Column("cofins_base", Float)
|
||||
cofins_aliq = Column(Numeric(8, 6, asdecimal=True))
|
||||
cofins_valor = Column(Numeric(18, 6, asdecimal=True))
|
||||
cofins_base = Column(Numeric(18, 6, asdecimal=True))
|
||||
|
||||
consumo = Column("consumo", Float)
|
||||
tarifa = Column("tarifa", Float)
|
||||
consumo = Column(Numeric(14, 6, asdecimal=True))
|
||||
tarifa = Column(Numeric(12, 6, asdecimal=True))
|
||||
|
||||
nota_fiscal = Column(String)
|
||||
data_processamento = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -53,6 +50,8 @@ class Fatura(Base):
|
||||
estado = Column(String)
|
||||
distribuidora = Column(String)
|
||||
link_arquivo = Column("link_arquivo", String)
|
||||
cliente_id = Column(UUID(as_uuid=True), ForeignKey("faturas.clientes.id"), nullable=False)
|
||||
|
||||
|
||||
|
||||
class LogProcessamento(Base):
|
||||
@@ -73,14 +72,25 @@ class AliquotaUF(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
uf = Column(String)
|
||||
exercicio = Column(String)
|
||||
aliquota = Column(Float)
|
||||
exercicio = Column(Integer)
|
||||
aliq_icms = Column(Numeric(6, 4))
|
||||
|
||||
class SelicMensal(Base):
|
||||
__tablename__ = "selic_mensal"
|
||||
__table_args__ = {'schema': 'faturas'}
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
ano = Column(Integer)
|
||||
mes = Column(Integer)
|
||||
fator = Column(Float)
|
||||
ano = Column(Integer, primary_key=True)
|
||||
mes = Column(Integer, primary_key=True)
|
||||
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)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# parametros.py
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import APIRouter, Request, Depends, Form, UploadFile, File
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_session
|
||||
from app.models import AliquotaUF, ParametrosFormula, SelicMensal
|
||||
@@ -10,90 +9,357 @@ import datetime
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.future import select
|
||||
from app.database import AsyncSessionLocal
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from app.models import Fatura
|
||||
from fastapi import Body
|
||||
from app.database import engine
|
||||
import httpx
|
||||
from app.models import SelicMensal
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
import io
|
||||
import csv
|
||||
from fastapi.responses import StreamingResponse
|
||||
import pandas as pd
|
||||
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()
|
||||
|
||||
# === Schemas ===
|
||||
class AliquotaUFSchema(BaseModel):
|
||||
uf: str
|
||||
exercicio: int
|
||||
aliquota: float
|
||||
aliq_icms: float
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
from_attributes = True
|
||||
|
||||
class ParametrosFormulaSchema(BaseModel):
|
||||
nome: str
|
||||
formula: str
|
||||
campos: str
|
||||
ativo: bool = True
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SelicMensalSchema(BaseModel):
|
||||
mes: str # 'YYYY-MM'
|
||||
fator: float
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
from_attributes = True
|
||||
|
||||
# === Rotas ===
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
@router.get("/parametros")
|
||||
async def parametros_page(request: Request):
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(ParametrosFormula).where(ParametrosFormula.ativo == True))
|
||||
parametros = result.scalars().first()
|
||||
return templates.TemplateResponse("parametros.html", {
|
||||
"request": request,
|
||||
"parametros": parametros or {}
|
||||
})
|
||||
async def parametros_page(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
# 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()]
|
||||
|
||||
# SELIC (dados + última competência)
|
||||
result_selic = await session.execute(
|
||||
text("SELECT ano, mes, percentual FROM faturas.selic_mensal ORDER BY ano DESC, mes DESC")
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
# Alíquotas por UF
|
||||
result_aliquotas = await session.execute(
|
||||
text("""
|
||||
SELECT uf,
|
||||
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 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,
|
||||
"parametros": None, # evita erro no Jinja
|
||||
"formulas": formulas, # <-- usado no template
|
||||
"selic_dados": selic_dados, # <-- usado no template
|
||||
"aliquotas_uf": aliquotas_uf, # se precisar em JS
|
||||
"ultima_data_selic": ultima_data_selic,
|
||||
"data_maxima": None,
|
||||
"campos_fatura": campos_fatura,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.post("/parametros/editar/{param_id}")
|
||||
async def editar_parametro(param_id: int, request: Request):
|
||||
data = await request.json()
|
||||
async with AsyncSessionLocal() as session:
|
||||
param = await session.get(ParametrosFormula, param_id)
|
||||
if param:
|
||||
param.nome = data.get("nome", param.nome)
|
||||
param.formula = data.get("formula", param.formula)
|
||||
param.ativo = data.get("ativo", param.ativo)
|
||||
await session.commit()
|
||||
return {"success": True}
|
||||
return {"success": False, "error": "Não encontrado"}
|
||||
|
||||
@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")
|
||||
async def testar_formula(db: AsyncSession = Depends(get_session), data: dict = Body(...)):
|
||||
formula = data.get("formula")
|
||||
|
||||
exemplo = await db.execute(select(Fatura).limit(1))
|
||||
fatura = exemplo.scalar_one_or_none()
|
||||
if not fatura:
|
||||
return {"success": False, "error": "Sem dados para teste."}
|
||||
|
||||
try:
|
||||
contexto = {col.name: getattr(fatura, col.name) for col in Fatura.__table__.columns}
|
||||
resultado = eval(formula, {}, contexto)
|
||||
return {"success": True, "resultado": resultado}
|
||||
except Exception as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
@router.get("/parametros/aliquotas")
|
||||
async def listar_aliquotas(uf: str | None = None, db: AsyncSession = Depends(get_session)):
|
||||
stmt = select(AliquotaUF).order_by(AliquotaUF.uf, AliquotaUF.exercicio.desc())
|
||||
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.get("/parametros/aliquotas", response_model=List[AliquotaUFSchema])
|
||||
def listar_aliquotas(db: AsyncSession = Depends(get_session)):
|
||||
return db.query(AliquotaUF).all()
|
||||
|
||||
@router.post("/parametros/aliquotas")
|
||||
def adicionar_aliquota(aliq: AliquotaUFSchema, db: AsyncSession = Depends(get_session)):
|
||||
existente = db.query(AliquotaUF).filter_by(uf=aliq.uf, exercicio=aliq.exercicio).first()
|
||||
async def adicionar_aliquota(aliq: AliquotaUFSchema, db: AsyncSession = Depends(get_session)):
|
||||
result = await db.execute(
|
||||
select(AliquotaUF).filter_by(uf=aliq.uf, exercicio=aliq.exercicio)
|
||||
)
|
||||
existente = result.scalar_one_or_none()
|
||||
|
||||
if existente:
|
||||
existente.aliquota = aliq.aliquota
|
||||
existente.aliq_icms = aliq.aliq_icms # atualizado
|
||||
else:
|
||||
novo = AliquotaUF(**aliq.dict())
|
||||
db.add(novo)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
await db.commit()
|
||||
return RedirectResponse(url="/parametros?ok=true&msg=Alíquota salva com sucesso", status_code=303)
|
||||
|
||||
|
||||
@router.get("/parametros/formulas", response_model=List[ParametrosFormulaSchema])
|
||||
def listar_formulas(db: AsyncSession = Depends(get_session)):
|
||||
return db.query(ParametrosFormula).all()
|
||||
async def listar_formulas(db: AsyncSession = Depends(get_session)):
|
||||
result = await db.execute(select(ParametrosFormula).order_by(ParametrosFormula.nome))
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/parametros/formulas")
|
||||
def salvar_formula(form: ParametrosFormulaSchema, db: AsyncSession = Depends(get_session)):
|
||||
existente = db.query(ParametrosFormula).filter_by(nome=form.nome).first()
|
||||
async def salvar_formula(form: ParametrosFormulaSchema, db: AsyncSession = Depends(get_session)):
|
||||
result = await db.execute(
|
||||
select(ParametrosFormula).filter_by(nome=form.nome)
|
||||
)
|
||||
existente = result.scalar_one_or_none()
|
||||
|
||||
if existente:
|
||||
existente.formula = form.formula
|
||||
existente.campos = form.campos
|
||||
existente.ativo = form.ativo
|
||||
else:
|
||||
novo = ParametrosFormula(**form.dict())
|
||||
novo = ParametrosFormula(nome=form.nome, formula=form.formula, ativo=form.ativo)
|
||||
db.add(novo)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
await db.commit()
|
||||
return RedirectResponse(url="/parametros?ok=true&msg=Parâmetro salvo com sucesso", status_code=303)
|
||||
|
||||
@router.get("/parametros/selic", response_model=List[SelicMensalSchema])
|
||||
def listar_selic(db: AsyncSession = Depends(get_session)):
|
||||
return db.query(SelicMensal).order_by(SelicMensal.mes.desc()).all()
|
||||
async def listar_selic(db: AsyncSession = Depends(get_session)):
|
||||
result = await db.execute(select(SelicMensal).order_by(SelicMensal.mes.desc()))
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/parametros/selic/importar")
|
||||
async def importar_selic(request: Request, data_maxima: str = Form(None)):
|
||||
try:
|
||||
hoje = datetime.date.today()
|
||||
inicio = datetime.date(hoje.year - 5, 1, 1)
|
||||
fim = datetime.datetime.strptime(data_maxima, "%Y-%m-%d").date() if data_maxima else hoje
|
||||
|
||||
url = (
|
||||
f"https://api.bcb.gov.br/dados/serie/bcdata.sgs.4390/dados?"
|
||||
f"formato=json&dataInicial={inicio.strftime('%d/%m/%Y')}&dataFinal={fim.strftime('%d/%m/%Y')}"
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
dados = response.json()
|
||||
|
||||
registros = []
|
||||
for item in dados:
|
||||
data = datetime.datetime.strptime(item['data'], "%d/%m/%Y")
|
||||
ano, mes = data.year, data.month
|
||||
percentual = float(item['valor'].replace(',', '.'))
|
||||
registros.append({"ano": ano, "mes": mes, "percentual": percentual})
|
||||
|
||||
async with engine.begin() as conn:
|
||||
stmt = pg_insert(SelicMensal.__table__).values(registros)
|
||||
upsert_stmt = stmt.on_conflict_do_update(
|
||||
index_elements=['ano', 'mes'],
|
||||
set_={'percentual': stmt.excluded.percentual}
|
||||
)
|
||||
await conn.execute(upsert_stmt)
|
||||
|
||||
return RedirectResponse("/parametros?aba=selic", status_code=303)
|
||||
|
||||
except Exception as e:
|
||||
return RedirectResponse(f"/parametros?erro=1&msg={str(e)}", status_code=303)
|
||||
|
||||
@router.get("/parametros/aliquotas/template")
|
||||
def baixar_template_excel():
|
||||
df = pd.DataFrame(columns=["UF", "Exercício", "Alíquota"])
|
||||
df.loc[0] = ["SP", "2025", "18"] # exemplo opcional
|
||||
df.loc[1] = ["MG", "2025", "12"] # exemplo opcional
|
||||
|
||||
output = BytesIO()
|
||||
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
||||
df.to_excel(writer, sheet_name='Template', index=False)
|
||||
|
||||
# Adiciona instrução como observação na célula A5 (linha 5)
|
||||
sheet = writer.sheets['Template']
|
||||
sheet.cell(row=5, column=1).value = (
|
||||
"⚠️ Após preencher, salve como CSV (.csv separado por vírgulas) para importar no sistema."
|
||||
)
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
output,
|
||||
media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
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()
|
||||
|
||||
@router.post("/parametros/selic")
|
||||
def salvar_selic(selic: SelicMensalSchema, db: AsyncSession = Depends(get_session)):
|
||||
existente = db.query(SelicMensal).filter_by(mes=selic.mes).first()
|
||||
if existente:
|
||||
existente.fator = selic.fator
|
||||
# atualiza (inclusive a chave, se mudou)
|
||||
existente.uf = uf
|
||||
existente.exercicio = exercicio
|
||||
existente.aliq_icms = aliquota
|
||||
else:
|
||||
novo = SelicMensal(**selic.dict())
|
||||
db.add(novo)
|
||||
db.commit()
|
||||
return {"status": "ok"}
|
||||
# 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}
|
||||
|
||||
137
app/processor.py
137
app/processor.py
@@ -2,6 +2,7 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import asyncio
|
||||
import httpx
|
||||
from sqlalchemy.future import select
|
||||
from app.utils import extrair_dados_pdf
|
||||
from app.database import AsyncSessionLocal
|
||||
@@ -9,6 +10,9 @@ from app.models import Fatura, LogProcessamento
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from app.models import SelicMensal
|
||||
from sqlalchemy import select
|
||||
from zipfile import ZipFile
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -28,6 +32,9 @@ def remover_arquivo_temp(caminho_pdf):
|
||||
logger.warning(f"Falha ao remover arquivo temporário: {e}")
|
||||
|
||||
def salvar_em_uploads(caminho_pdf_temp, nome_original, nota_fiscal):
|
||||
ERROS_DIR = os.path.join("app", "uploads", "erros")
|
||||
os.makedirs(ERROS_DIR, exist_ok=True)
|
||||
erros_detectados = []
|
||||
try:
|
||||
extensao = os.path.splitext(nome_original)[1].lower()
|
||||
nome_destino = f"{nota_fiscal}_{uuid.uuid4().hex[:6]}{extensao}"
|
||||
@@ -35,10 +42,21 @@ def salvar_em_uploads(caminho_pdf_temp, nome_original, nota_fiscal):
|
||||
shutil.copy2(caminho_pdf_temp, destino_final)
|
||||
return destino_final
|
||||
except Exception as e:
|
||||
# Copiar o arquivo com erro
|
||||
extensao = os.path.splitext(nome_original)[1].lower()
|
||||
nome_arquivo = f"{uuid.uuid4().hex[:6]}_erro{extensao}"
|
||||
caminho_pdf = caminho_pdf_temp
|
||||
|
||||
shutil.copy2(caminho_pdf, os.path.join(ERROS_DIR, nome_arquivo))
|
||||
|
||||
mensagem = f"{nome_arquivo}: {str(e)}"
|
||||
|
||||
erros_detectados.append(mensagem)
|
||||
|
||||
logger.error(f"Erro ao salvar em uploads: {e}")
|
||||
return caminho_pdf_temp
|
||||
|
||||
async def process_single_file(caminho_pdf_temp: str, nome_original: str):
|
||||
async def process_single_file(caminho_pdf_temp: str, nome_original: str, cliente_id: str | None = None):
|
||||
inicio = time.perf_counter()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
@@ -46,6 +64,41 @@ async def process_single_file(caminho_pdf_temp: str, nome_original: str):
|
||||
dados = extrair_dados_pdf(caminho_pdf_temp)
|
||||
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
|
||||
existente_result = await session.execute(
|
||||
select(Fatura).filter_by(
|
||||
@@ -62,11 +115,18 @@ async def process_single_file(caminho_pdf_temp: str, nome_original: str):
|
||||
"tempo": f"{duracao}s"
|
||||
}
|
||||
|
||||
data_comp = dados.get("competencia")
|
||||
if data_comp:
|
||||
await garantir_selic_para_competencia(session, data_comp.year, data_comp.month)
|
||||
|
||||
# Salva arquivo final
|
||||
caminho_final = salvar_em_uploads(caminho_pdf_temp, nome_original, dados['nota_fiscal'])
|
||||
dados['link_arquivo'] = caminho_final
|
||||
|
||||
# Salva fatura
|
||||
dados['cliente_id'] = cliente_id
|
||||
if cliente_id:
|
||||
dados['cliente_id'] = cliente_id
|
||||
fatura = Fatura(**dados)
|
||||
session.add(fatura)
|
||||
|
||||
@@ -97,19 +157,42 @@ async def process_single_file(caminho_pdf_temp: str, nome_original: str):
|
||||
"trace": erro_str
|
||||
}
|
||||
|
||||
|
||||
async def processar_em_lote():
|
||||
import traceback # para exibir erros
|
||||
resultados = []
|
||||
while not fila_processamento.empty():
|
||||
item = await fila_processamento.get()
|
||||
try:
|
||||
resultado = await process_single_file(item['caminho_pdf'], item['nome_original'])
|
||||
resultado = await process_single_file(
|
||||
item['caminho_pdf'],
|
||||
item['nome_original'],
|
||||
item.get('cliente_id')
|
||||
)
|
||||
# tentar tamanho/data do TEMP; se não existir mais, tenta do destino final; senão, 0/""
|
||||
temp_path = item['caminho_pdf']
|
||||
dest_path = (resultado.get("dados") or {}).get("link_arquivo", "")
|
||||
|
||||
def _safe_size(p):
|
||||
try:
|
||||
return os.path.getsize(p) // 1024
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def _safe_mtime(p):
|
||||
try:
|
||||
return time.strftime("%d/%m/%Y", time.localtime(os.path.getmtime(p)))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
status_arquivos[item['nome_original']] = {
|
||||
"status": resultado.get("status"),
|
||||
"mensagem": resultado.get("mensagem", ""),
|
||||
"tempo": resultado.get("tempo", "---")
|
||||
"tempo": resultado.get("tempo", "---"),
|
||||
"tamanho": _safe_size(temp_path) or _safe_size(dest_path),
|
||||
"data": _safe_mtime(temp_path) or _safe_mtime(dest_path),
|
||||
}
|
||||
|
||||
|
||||
resultados.append(status_arquivos[item['nome_original']])
|
||||
except Exception as e:
|
||||
status_arquivos[item['nome_original']] = {
|
||||
@@ -125,9 +208,53 @@ async def processar_em_lote():
|
||||
})
|
||||
print(f"Erro ao processar {item['nome_original']}: {e}")
|
||||
print(traceback.format_exc())
|
||||
return resultados
|
||||
# Após o loop, salvar TXT com erros
|
||||
erros_txt = []
|
||||
for nome, status in status_arquivos.items():
|
||||
if status['status'] == 'Erro':
|
||||
erros_txt.append(f"{nome} - {status.get('mensagem', 'Erro desconhecido')}")
|
||||
|
||||
if erros_txt:
|
||||
erros_dir = os.path.join(UPLOADS_DIR, "erros")
|
||||
os.makedirs(erros_dir, exist_ok=True) # <- GARANTE A PASTA
|
||||
|
||||
with open(os.path.join(erros_dir, "erros.txt"), "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(erros_txt))
|
||||
|
||||
# Compacta PDFs com erro
|
||||
with ZipFile(os.path.join(erros_dir, "faturas_erro.zip"), "w") as zipf:
|
||||
for nome in status_arquivos:
|
||||
if status_arquivos[nome]['status'] == 'Erro':
|
||||
caminho = os.path.join(UPLOADS_DIR, "temp", nome)
|
||||
if os.path.exists(caminho):
|
||||
zipf.write(caminho, arcname=nome)
|
||||
|
||||
return resultados
|
||||
|
||||
def limpar_arquivos_processados():
|
||||
status_arquivos.clear()
|
||||
while not fila_processamento.empty():
|
||||
fila_processamento.get_nowait()
|
||||
|
||||
async def garantir_selic_para_competencia(session, ano, mes):
|
||||
# Verifica se já existe
|
||||
result = await session.execute(select(SelicMensal).filter_by(ano=ano, mes=mes))
|
||||
existente = result.scalar_one_or_none()
|
||||
if existente:
|
||||
return # já tem
|
||||
|
||||
# Busca na API do Banco Central
|
||||
url = (
|
||||
f"https://api.bcb.gov.br/dados/serie/bcdata.sgs.4390/dados?"
|
||||
f"formato=json&dataInicial=01/{mes:02d}/{ano}&dataFinal=30/{mes:02d}/{ano}"
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
dados = resp.json()
|
||||
|
||||
if dados:
|
||||
percentual = float(dados[0]["valor"].replace(",", "."))
|
||||
novo = SelicMensal(ano=ano, mes=mes, percentual=percentual)
|
||||
session.add(novo)
|
||||
await session.commit()
|
||||
@@ -1,9 +0,0 @@
|
||||
fastapi==0.110.0
|
||||
uvicorn[standard]==0.29.0
|
||||
jinja2==3.1.3
|
||||
sqlalchemy==2.0.30
|
||||
asyncpg==0.29.0
|
||||
python-multipart==0.0.6
|
||||
openpyxl==3.1.2
|
||||
pandas==2.2.2
|
||||
PyMuPDF==1.22.5
|
||||
72
app/routes/clientes.py
Normal file
72
app/routes/clientes.py
Normal 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}
|
||||
@@ -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
|
||||
})
|
||||
140
app/routes/dashboard_unused.py
Normal file
140
app/routes/dashboard_unused.py
Normal 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
502
app/templates/clientes.html
Normal 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 %}
|
||||
@@ -2,106 +2,254 @@
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h1 style="display: flex; align-items: center; gap: 10px;">
|
||||
<i class="fas fa-chart-line"></i> Dashboard de Faturas
|
||||
</h1>
|
||||
|
||||
<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 id="loading" class="loading-backdrop">
|
||||
<div class="spinner"></div>
|
||||
<div class="loading-msg">Carregando dados…</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;">
|
||||
<div style="flex: 1;">
|
||||
<h4>% de Faturas com ICMS na Base PIS/COFINS</h4>
|
||||
<canvas id="graficoICMS"></canvas>
|
||||
<style>
|
||||
/* ---- Combobox estilizado ---- */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ---- 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 style="flex: 1;">
|
||||
<h4>Valor Médio de Tributos com ICMS</h4>
|
||||
<canvas id="graficoValor"></canvas>
|
||||
</form>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
|
||||
<script>
|
||||
const ctx1 = document.getElementById('graficoICMS').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 ctxE = document.getElementById('graficoEvolucao').getContext('2d');
|
||||
|
||||
const ctx2 = document.getElementById('graficoValor').getContext('2d');
|
||||
new Chart(ctx2, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ['Antes da Decisão', 'Depois da Decisão'],
|
||||
datasets: [{
|
||||
label: 'Valor Médio de PIS/COFINS com ICMS',
|
||||
data: {{ [analise_stf.antes.media_valor, analise_stf.depois.media_valor] | tojson }},
|
||||
backgroundColor: ['#2980b9', '#27ae60']
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
title: { display: false }
|
||||
const evoLabels = {{ serie_mensal_labels | tojson }};
|
||||
const evoValores = {{ serie_mensal_valores | tojson }};
|
||||
|
||||
new Chart(ctxE, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: evoLabels,
|
||||
datasets: [{
|
||||
label: 'Valor corrigido (R$)',
|
||||
data: evoValores,
|
||||
fill: false,
|
||||
tension: 0.25,
|
||||
borderWidth: 3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 5
|
||||
}]
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: { display: true, text: 'R$' }
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -154,6 +154,10 @@
|
||||
<i class="fas fa-tachometer-alt"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a href="/clientes" class="menu-item">
|
||||
<i class="fas fa-building"></i>
|
||||
<span>Clientes</span>
|
||||
</a>
|
||||
<a href="/upload" class="menu-item">
|
||||
<i class="fas fa-upload"></i>
|
||||
<span>Upload</span>
|
||||
|
||||
@@ -9,33 +9,43 @@
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('formulas')">📄 Fórmulas</button>
|
||||
<button class="tab" onclick="switchTab('selic')">📊 Gestão SELIC</button>
|
||||
<button class="tab" onclick="switchTab('aliquotas')">🧾 Cadastro de Alíquotas por Estado</button>
|
||||
</div>
|
||||
|
||||
<!-- ABA FÓRMULAS -->
|
||||
<div id="formulas" class="tab-content active">
|
||||
<form method="post" class="formulario-box">
|
||||
<div class="grid">
|
||||
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));">
|
||||
<div class="form-group">
|
||||
<label for="tipo">Tipo:</label>
|
||||
<input type="text" name="tipo" id="tipo" value="{{ parametros.tipo or '' }}" required />
|
||||
<label for="nome">Nome:</label>
|
||||
<input type="text" name="nome" id="nome" value="{{ parametros.nome or '' }}" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<label for="formula">Fórmula:</label>
|
||||
<div class="editor-box">
|
||||
<select onchange="inserirCampo(this)">
|
||||
<option value="">Inserir campo...</option>
|
||||
<option value="pis_base">pis_base</option>
|
||||
<option value="cofins_base">cofins_base</option>
|
||||
<option value="icms_valor">icms_valor</option>
|
||||
<option value="pis_aliq">pis_aliq</option>
|
||||
<option value="cofins_aliq">cofins_aliq</option>
|
||||
</select>
|
||||
<div style="margin-bottom: 0.5rem;">
|
||||
<strong>Campos disponíveis:</strong>
|
||||
<div class="campo-badges">
|
||||
{% for campo in (campos_fatura or []) %}
|
||||
<span class="badge-campo" onclick="inserirNoEditor('{{ campo }}')">{{ campo }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 0.5rem;">
|
||||
<strong>Operadores:</strong>
|
||||
<div class="campo-badges">
|
||||
{% for op in ['+', '-', '*', '/', '(', ')'] %}
|
||||
<span class="badge-operador" onclick="inserirNoEditor('{{ op }}')">{{ op }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<textarea name="formula" id="formula" rows="3" required>{{ parametros.formula or '' }}</textarea>
|
||||
<div class="actions-inline">
|
||||
<button type="button" class="btn btn-secondary" onclick="testarFormula()">🧪 Testar Fórmula</button>
|
||||
@@ -45,64 +55,173 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group check-group">
|
||||
<label><input type="checkbox" name="incluir_icms" value="1" {% if parametros.incluir_icms %}checked{% endif %}> Incluir ICMS</label>
|
||||
<label><input type="checkbox" name="incluir_pis" value="1" {% if parametros.incluir_pis %}checked{% endif %}> Incluir PIS</label>
|
||||
<label><input type="checkbox" name="incluir_cofins" value="1" {% if parametros.incluir_cofins %}checked{% endif %}> Incluir COFINS</label>
|
||||
<label><input type="checkbox" name="ativo" value="1" {% if parametros.ativo %}checked{% endif %}> Ativo</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">💾 Salvar Parâmetro</button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-primary pulse" onclick="limparFormulario()">🔁 Novo Parâmetro</button>
|
||||
|
||||
<h3 style="margin-top: 2rem;">📋 Fórmulas Salvas</h3>
|
||||
<div class="card-list">
|
||||
{% for param in lista_parametros %}
|
||||
<div class="param-card">
|
||||
<h4>{{ param.tipo }}</h4>
|
||||
<code>{{ param.formula }}</code>
|
||||
<div class="actions">
|
||||
<a href="?editar={{ param.id }}" class="btn btn-sm">✏️ Editar</a>
|
||||
<a href="?testar={{ param.id }}" class="btn btn-sm btn-secondary">🧪 Testar</a>
|
||||
<a href="/parametros/delete/{{ param.id }}" class="btn btn-sm btn-danger">🗑️ Excluir</a>
|
||||
</form>
|
||||
<hr style="margin-top: 2rem; margin-bottom: 1rem;">
|
||||
<h3 style="margin-top: 2rem;">📋 Fórmulas Salvas</h3>
|
||||
<div class="card-list">
|
||||
{% for param in (formulas or []) %}
|
||||
<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;">
|
||||
<input type="text" class="edit-nome" value="{{ param.nome }}" data-id="{{ param.id }}"
|
||||
onkeydown="if(event.key==='Enter'){ event.preventDefault(); salvarInline('{{ param.id }}') }" />
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{% else %}
|
||||
<p style="color:gray;">Nenhuma fórmula cadastrada.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ABA SELIC -->
|
||||
<div id="selic" class="tab-content" style="display:none;">
|
||||
<div id="selic" class="tab-content">
|
||||
<div class="formulario-box">
|
||||
<h3>📈 Gestão da SELIC</h3>
|
||||
<p>Utilize o botão abaixo para importar os fatores SELIC automaticamente a partir da API do Banco Central.</p>
|
||||
<form method="post" action="/parametros/selic/importar">
|
||||
<form method="post" action="/parametros/selic/importar" onsubmit="mostrarLoadingSelic()">
|
||||
<div class="form-group">
|
||||
<label for="data_maxima">Data máxima para cálculo da SELIC:</label>
|
||||
<input type="date" id="data_maxima" name="data_maxima" value="{{ data_maxima or '' }}" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">⬇️ Atualizar Fatores SELIC</button>
|
||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
|
||||
<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>
|
||||
</div>
|
||||
<div class="mensagem-info" style="margin-top:1rem;">Última data coletada da SELIC: <strong>{{ ultima_data_selic or '-' }}</strong></div>
|
||||
</form>
|
||||
<div class="mensagem-info" style="margin-top:1rem;">Última data coletada da SELIC: <strong>{{ ultima_data_selic }}</strong></div>
|
||||
<table class="selic-table">
|
||||
<thead><tr><th>Competência</th><th>Fator</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in selic_dados %}
|
||||
<tr><td>{{ item.competencia }}</td><td>{{ item.fator }}</td></tr>
|
||||
<tr>
|
||||
<td>{{ "%02d"|format(item.mes) }}/{{ item.ano }}</td>
|
||||
<td>{{ "%.4f"|format(item.percentual) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ABA ALÍQUOTAS -->
|
||||
<div id="aliquotas" class="tab-content">
|
||||
<div class="formulario-box">
|
||||
<form onsubmit="return salvarAliquota(this, event)">
|
||||
<div class="grid" style="grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));">
|
||||
<div class="form-group">
|
||||
<label>UF:</label>
|
||||
<select name="uf" required>
|
||||
<option value="">Selecione o Estado</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>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Exercício:</label>
|
||||
<input name="exercicio" type="number" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Alíquota ICMS (%):</label>
|
||||
<input id="aliquota-uf" name="aliquota"
|
||||
type="text" inputmode="decimal"
|
||||
pattern="[0-9]+([,][0-9]+)?"
|
||||
placeholder="Ex: 20,7487" required />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bloco com espaçamento e alinhamento central -->
|
||||
<div class="grupo-botoes">
|
||||
<!-- Botão salvar -->
|
||||
<button class="btn btn-primary" type="submit">💾 Salvar Alíquota</button>
|
||||
|
||||
<!-- Importação e template -->
|
||||
<div style="display: flex; flex-wrap: wrap; justify-content: center; gap: 1rem;">
|
||||
<label for="arquivo_aliquotas" class="btn btn-secondary" style="cursor: pointer;">
|
||||
📎 Importar CSV
|
||||
<input type="file" name="arquivo_aliquotas" accept=".csv" onchange="enviarArquivoAliquotas(this)" style="display: none;" />
|
||||
</label>
|
||||
|
||||
<a href="/parametros/aliquotas/template" class="btn btn-secondary">📥 Baixar Template CSV</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="orig-uf" name="original_uf">
|
||||
<input type="hidden" id="orig-exercicio" name="original_exercicio">
|
||||
|
||||
</form>
|
||||
|
||||
<!-- Filtro de UF para a tabela -->
|
||||
<div style="display:flex; align-items:center; gap:12px; margin:14px 0;">
|
||||
<label for="filtro-uf" style="font-weight:600;">Filtrar por UF:</label>
|
||||
<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>
|
||||
|
||||
<!-- ESTILOS -->
|
||||
<style>
|
||||
.tabs { display: flex; gap: 1rem; margin-bottom: 1rem; }
|
||||
.tab { background: none; border: none; font-weight: bold; cursor: pointer; padding: 0.5rem 1rem; border-bottom: 2px solid transparent; }
|
||||
.tab.active { border-bottom: 2px solid #2563eb; color: #2563eb; }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
/* Abas */
|
||||
.tabs {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.tab {
|
||||
background: none;
|
||||
border: none;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.tab.active {
|
||||
border-bottom: 2px solid #2563eb;
|
||||
color: #2563eb;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Formulário principal */
|
||||
.formulario-box {
|
||||
background: #fff;
|
||||
padding: 2rem;
|
||||
@@ -112,95 +231,574 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label { display: block; font-weight: bold; margin-bottom: 0.5rem; }
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"],
|
||||
.form-group input[type="date"],
|
||||
.form-group textarea { width: 100%; padding: 0.6rem; border-radius: 6px; border: 1px solid #ccc; }
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.form-group.check-group { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.check-group label { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.form-group.check-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.check-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.editor-box select { margin-bottom: 0.5rem; }
|
||||
.editor-box textarea { font-family: monospace; }
|
||||
.actions-inline { display: flex; gap: 1rem; align-items: center; margin-top: 0.5rem; }
|
||||
/* Grade do formulário */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn { padding: 0.5rem 1rem; border: none; border-radius: 6px; font-weight: 600; cursor: pointer; }
|
||||
.btn-primary { background-color: #2563eb; color: white; }
|
||||
.btn-secondary { background-color: #6c757d; color: white; }
|
||||
.btn-danger { background-color: #dc3545; color: white; }
|
||||
.btn-sm { font-size: 0.85rem; padding: 0.3rem 0.6rem; }
|
||||
/* Botões */
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
.btn-sm {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
}
|
||||
|
||||
/* Cards de fórmulas salvas */
|
||||
.card-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.param-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
background: #f9f9f9;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #2563eb;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
|
||||
box-sizing: border-box;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.param-card.ativo {
|
||||
border-left-color: #198754;
|
||||
background: #f6fff9;
|
||||
}
|
||||
|
||||
.param-card.inativo {
|
||||
border-left-color: #adb5bd;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.param-card input.edit-nome {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.edit-formula {
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.param-card .actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.badge-status {
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
background: #198754;
|
||||
}
|
||||
|
||||
.param-card.inativo .badge-status {
|
||||
background: #adb5bd;
|
||||
}
|
||||
|
||||
/* Badge de campos e operadores */
|
||||
.campo-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.badge-campo, .badge-operador {
|
||||
background: #e0e7ff;
|
||||
color: #1e3a8a;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.badge-campo:hover, .badge-operador:hover {
|
||||
background: #c7d2fe;
|
||||
}
|
||||
|
||||
/* Mensagens */
|
||||
.mensagem-info {
|
||||
background: #e0f7fa;
|
||||
padding: 1rem;
|
||||
border-left: 4px solid #2563eb;
|
||||
border-radius: 6px;
|
||||
color: #007b83;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
|
||||
.card-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
/* Tabela SELIC */
|
||||
.selic-table {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.param-card {
|
||||
background: #f9f9f9;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #2563eb;
|
||||
.selic-table th,
|
||||
.selic-table td {
|
||||
padding: 0.6rem;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
.selic-table th {
|
||||
text-align: left;
|
||||
background: #f1f1f1;
|
||||
}
|
||||
.param-card code { display: block; margin: 0.5rem 0; color: #333; }
|
||||
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
|
||||
.selic-table { width: 100%; margin-top: 1rem; border-collapse: collapse; }
|
||||
.selic-table th, .selic-table td { padding: 0.6rem; border-bottom: 1px solid #ccc; }
|
||||
.selic-table th { text-align: left; background: #f1f1f1; }
|
||||
/* Popup de feedback */
|
||||
.feedback-popup {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.feedback-content {
|
||||
background-color: #fff;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.feedback-content h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.feedback-content p {
|
||||
margin: 0.25rem 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ccc;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.grupo-botoes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: 1.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// 🟡 Alterna entre abas
|
||||
function switchTab(tabId) {
|
||||
// Remove classe 'active' de todos os botões e abas
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
// Ativa a aba correspondente
|
||||
document.getElementById(tabId).classList.add('active');
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Ativa o botão correspondente
|
||||
const button = document.querySelector(`.tab[onclick="switchTab('${tabId}')"]`);
|
||||
if (button) button.classList.add('active');
|
||||
}
|
||||
|
||||
function inserirCampo(select) {
|
||||
const campo = select.value;
|
||||
if (campo) {
|
||||
const formula = document.getElementById("formula");
|
||||
const start = formula.selectionStart;
|
||||
const end = formula.selectionEnd;
|
||||
formula.value = formula.value.slice(0, start) + campo + formula.value.slice(end);
|
||||
formula.focus();
|
||||
formula.setSelectionRange(start + campo.length, start + campo.length);
|
||||
select.selectedIndex = 0;
|
||||
}
|
||||
|
||||
// ✅ Insere valores no editor de fórmulas
|
||||
function inserirNoEditor(valor) {
|
||||
const formula = document.getElementById("formula");
|
||||
const start = formula.selectionStart;
|
||||
const end = formula.selectionEnd;
|
||||
formula.value = formula.value.slice(0, start) + valor + formula.value.slice(end);
|
||||
formula.focus();
|
||||
formula.setSelectionRange(start + valor.length, start + valor.length);
|
||||
}
|
||||
|
||||
// ✅ Feedback visual em popup
|
||||
function mostrarFeedback(titulo, mensagem) {
|
||||
document.getElementById("feedback-titulo").innerText = titulo;
|
||||
document.getElementById("feedback-mensagem").innerText = mensagem;
|
||||
document.getElementById("parametros-feedback").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function fecharFeedbackParametros() {
|
||||
document.getElementById("parametros-feedback").classList.add("hidden");
|
||||
}
|
||||
|
||||
// ✅ Testa fórmula principal (formulário superior)
|
||||
function testarFormula() {
|
||||
const formula = document.getElementById("formula").value;
|
||||
const vars = {
|
||||
pis_base: 84.38,
|
||||
cofins_base: 84.38,
|
||||
icms_valor: 24.47,
|
||||
pis_aliq: 0.012872,
|
||||
cofins_aliq: 0.059287
|
||||
};
|
||||
try {
|
||||
const resultado = eval(formula.replace(/\b(\w+)\b/g, match => vars.hasOwnProperty(match) ? vars[match] : match));
|
||||
document.getElementById("resultado-teste").innerText = "Resultado: R$ " + resultado.toFixed(5);
|
||||
document.getElementById("resultado-teste").style.display = "block";
|
||||
} catch (e) {
|
||||
document.getElementById("resultado-teste").innerText = "Erro: " + e.message;
|
||||
document.getElementById("resultado-teste").style.display = "block";
|
||||
const nome = document.getElementById("nome").value.trim();
|
||||
const formula = document.getElementById("formula").value.trim();
|
||||
const output = document.getElementById("resultado-teste");
|
||||
|
||||
if (!nome || !formula) {
|
||||
output.innerText = "❌ Preencha o nome e a fórmula para testar.";
|
||||
output.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/parametros/testar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nome, formula })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
output.style.display = "block";
|
||||
if (data.success) {
|
||||
output.innerText = `✅ Fórmula válida. Resultado: ${data.resultado}`;
|
||||
} else {
|
||||
output.innerText = `❌ Erro: ${data.error}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Testa fórmula inline nos cards salvos
|
||||
function testarFormulaInline(id) {
|
||||
const nome = document.querySelector(`.edit-nome[data-id='${id}']`)?.value;
|
||||
const formula = document.querySelector(`.edit-formula[data-id='${id}']`)?.value;
|
||||
const output = document.getElementById(`resultado-inline-${id}`);
|
||||
|
||||
if (!formula) {
|
||||
output.innerText = "❌ Fórmula não preenchida.";
|
||||
output.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/parametros/testar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nome, formula })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
output.style.display = 'block';
|
||||
if (data.success) {
|
||||
output.innerText = `✅ Fórmula válida. Resultado: ${data.resultado}`;
|
||||
} else {
|
||||
output.innerText = `❌ Erro: ${data.error}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Salva edição inline nos cards
|
||||
async function salvarInline(id) {
|
||||
const inputNome = document.querySelector(`.edit-nome[data-id='${id}']`);
|
||||
const textareaFormula = document.querySelector(`.edit-formula[data-id='${id}']`);
|
||||
|
||||
const nome = inputNome.value.trim();
|
||||
const formula = textareaFormula.value.trim();
|
||||
|
||||
if (!nome || !formula) {
|
||||
alert("Preencha todos os campos.");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`/parametros/editar/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nome, formula })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
mostrarFeedback("✅ Atualizado", "Parâmetro salvo com sucesso.");
|
||||
} else {
|
||||
mostrarFeedback("❌ Erro", "Erro ao salvar.");
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Carrega tabela de alíquotas
|
||||
async function carregarAliquotas() {
|
||||
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 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 => `
|
||||
<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('');
|
||||
}
|
||||
|
||||
document.getElementById("total-aliquotas").textContent = `Registros: ${dados.length}`;
|
||||
}
|
||||
|
||||
|
||||
// ✅ Eventos após carregar DOM
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById("filtro-uf")?.addEventListener("change", carregarAliquotas);
|
||||
carregarAliquotas();
|
||||
|
||||
// Ativar/desativar checkbox
|
||||
document.querySelectorAll('.toggle-ativo').forEach(input => {
|
||||
input.addEventListener('change', async function () {
|
||||
const id = this.dataset.id;
|
||||
const ativo = this.checked;
|
||||
const response = await fetch(`/parametros/ativar/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ativo })
|
||||
});
|
||||
if (response.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Erro ao atualizar status.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Botões de teste inline
|
||||
document.querySelectorAll('.btn-testar').forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
const id = this.dataset.id;
|
||||
testarFormulaInline(id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function mostrarLoadingSelic() {
|
||||
document.getElementById("selic-loading").classList.remove("hidden");
|
||||
}
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
const aba = new URLSearchParams(window.location.search).get("aba");
|
||||
if (aba === "formulas" || aba === "selic" || aba === "aliquotas") {
|
||||
switchTab(aba);
|
||||
} else {
|
||||
switchTab("formulas"); // padrão
|
||||
}
|
||||
});
|
||||
|
||||
function enviarArquivoAliquotas(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("arquivo", file);
|
||||
|
||||
fetch("/parametros/aliquotas/importar", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
mostrarFeedback("✅ Importado", `${data.qtd} alíquotas foram importadas.`);
|
||||
carregarAliquotas();
|
||||
} else {
|
||||
mostrarFeedback("❌ Erro", data.error || "Falha na importação.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<!-- Feedback estilo popup -->
|
||||
<div id="parametros-feedback" class="feedback-popup hidden">
|
||||
<div class="feedback-content">
|
||||
<h3 id="feedback-titulo">✅ Ação Concluída</h3>
|
||||
<p id="feedback-mensagem">Parâmetro salvo com sucesso.</p>
|
||||
<button onclick="fecharFeedbackParametros()" class="btn btn-primary" style="margin-top: 1rem;">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="selic-loading" class="feedback-popup hidden">
|
||||
<div class="feedback-content">
|
||||
<h3>⏳ Atualizando SELIC</h3>
|
||||
<p>Aguarde enquanto os fatores SELIC estão sendo carregados...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -3,36 +3,227 @@
|
||||
{% block content %}
|
||||
<h1>📊 Relatórios</h1>
|
||||
|
||||
<form method="get" style="margin-bottom: 20px;">
|
||||
<label for="cliente">Filtrar por Cliente:</label>
|
||||
<select name="cliente" id="cliente" onchange="this.form.submit()">
|
||||
<option value="">Todos</option>
|
||||
{% for c in clientes %}
|
||||
<option value="{{ c }}" {% if c == cliente_atual %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
<div style="display:flex; gap:16px; align-items:flex-end; flex-wrap:wrap; margin: 6px 0 18px;">
|
||||
<div class="combo-wrap">
|
||||
<label for="relatorio-cliente" style="font-size:13px;color:#374151">Selecionar Cliente:</label>
|
||||
<select id="relatorio-cliente" class="combo" style="min-width:340px;">
|
||||
<option value="">Todos</option>
|
||||
{% for c in clientes %}
|
||||
<option value="{{ c.id }}">{{ c.nome }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<tr style="background: #2563eb; color: white;">
|
||||
<th style="padding: 10px;">Cliente</th>
|
||||
<th>Data</th>
|
||||
<tr>
|
||||
<th>Cliente</th>
|
||||
<th>UC</th>
|
||||
<th>Referência</th>
|
||||
<th>Nota Fiscal</th>
|
||||
<th>Valor Total</th>
|
||||
<th>ICMS na Base</th>
|
||||
<th>Status</th>
|
||||
<th>ICMS (%)</th>
|
||||
<th>ICMS (R$)</th>
|
||||
<th>PIS (R$)</th>
|
||||
<th>COFINS (R$)</th>
|
||||
<th>Distribuidora</th>
|
||||
<th>Processado em</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="relatorios-body">
|
||||
{% for f in faturas %}
|
||||
<tr style="background: {{ loop.cycle('#ffffff', '#f0f4f8') }};">
|
||||
<td style="padding: 10px;">{{ f.nome }}</td>
|
||||
<td>{{ f.data_emissao }}</td>
|
||||
<td>R$ {{ '%.2f'|format(f.valor_total)|replace('.', ',') }}</td>
|
||||
<td>{{ 'Sim' if f.com_icms else 'Não' }}</td>
|
||||
<td>{{ f.status }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ f.nome }}</td>
|
||||
<td class="mono">{{ f.unidade_consumidora }}</td>
|
||||
<td class="mono">{{ f.referencia }}</td>
|
||||
<td class="mono">{{ f.nota_fiscal }}</td>
|
||||
<td>R$ {{ '%.2f'|format((f.valor_total or 0.0))|replace('.', ',') }}</td>
|
||||
<td>{{ '%.2f'|format((f.icms_aliq or 0.0))|replace('.', ',') }}</td>
|
||||
<td>R$ {{ '%.2f'|format((f.icms_valor or 0.0))|replace('.', ',') }}</td>
|
||||
<td>R$ {{ '%.2f'|format((f.pis_valor or 0.0))|replace('.', ',') }}</td>
|
||||
<td>R$ {{ '%.2f'|format((f.cofins_valor or 0.0))|replace('.', ',') }}</td>
|
||||
<td>{{ f.distribuidora or '-' }}</td>
|
||||
<td class="muted">{{ f.data_processamento }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="pager" style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:12px;">
|
||||
<div id="range" class="muted">Mostrando 0–0 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 %}
|
||||
|
||||
@@ -4,104 +4,253 @@
|
||||
|
||||
<h1 style="font-size: 1.5rem; margin-bottom: 1rem;">📤 Upload de Faturas</h1>
|
||||
|
||||
<!-- Seletor de Cliente (obrigatório) -->
|
||||
<div style="display:flex; gap:12px; align-items:center; margin: 0 0 14px 0;">
|
||||
<label for="select-cliente" style="font-weight:600;">Cliente:</label>
|
||||
<select id="select-cliente" style="min-width:320px; padding:.6rem .8rem; border:1px solid #ddd; border-radius:10px;">
|
||||
<option value="">— Selecione um cliente —</option>
|
||||
</select>
|
||||
<span id="cliente-aviso" class="muted">Selecione o cliente antes de anexar/ processar.</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="upload-box" id="upload-box">
|
||||
<h3>Arraste faturas em PDF aqui ou clique para selecionar</h3>
|
||||
<p style="color: gray; font-size: 0.9rem;">Apenas PDFs textuais (não escaneados)</p>
|
||||
<br />
|
||||
<button class="btn btn-primary" onclick="document.getElementById('file-input').click()">Selecionar Arquivos</button>
|
||||
<button class="btn btn-primary" id="btn-selecionar" onclick="document.getElementById('file-input').click()">Selecionar Arquivos</button>
|
||||
<input type="file" id="file-input" accept=".pdf" multiple onchange="handleFiles(this.files)" style="display:none;" />
|
||||
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button class="btn btn-primary" onclick="processar(this)">Processar Faturas</button>
|
||||
<button class="btn btn-danger" onclick="limpar()">Limpar Tudo</button>
|
||||
<button class="btn btn-primary pulse" onclick="limpar()" style="font-weight: bold;">🔁 Novo Processo</button>
|
||||
{% if status_resultados|selectattr("status", "equalto", "Erro")|list %}
|
||||
<div style="margin-top: 2rem;">
|
||||
<a class="btn btn-danger" href="/erros/download">⬇️ Baixar Faturas com Erro (.zip)</a>
|
||||
<a class="btn btn-secondary" href="/erros/log">📄 Ver Log de Erros (.txt)</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!--
|
||||
<button class="btn btn-success" onclick="baixarPlanilha()">📅 Abrir Planilha</button>
|
||||
<button class="btn btn-success" onclick="gerarRelatorio()">📊 Gerar Relatório</button>
|
||||
-->
|
||||
{% if app_env != "producao" %}
|
||||
<button class="btn btn-warning" onclick="limparFaturas()">🧹 Limpar Faturas (Teste)</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Arquivo</th>
|
||||
<th>Status</th>
|
||||
<th>Mensagem</th>
|
||||
<th>Tempo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="file-table">
|
||||
<tr><td colspan="3" style="text-align: center; color: #aaa;">Nenhum arquivo selecionado.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div id="overlay-bloqueio" class="overlay-bloqueio hidden">
|
||||
⏳ Tabela bloqueada até finalizar o processo
|
||||
<div id="barra-progresso" class="barra-processamento"></div>
|
||||
</div>
|
||||
<div id="tabela-wrapper" class="tabela-wrapper"></div>
|
||||
</div>
|
||||
<script>
|
||||
let arquivos = [];
|
||||
let statusInterval = null;
|
||||
let processado = false;
|
||||
let processamentoFinalizado = false;
|
||||
|
||||
const fileTable = document.getElementById('file-table');
|
||||
|
||||
function handleFiles(files) {
|
||||
arquivos = [...arquivos, ...files];
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderTable(statusList = []) {
|
||||
const rows = statusList.length ? statusList : arquivos.map(file => ({
|
||||
nome: file.name,
|
||||
status: 'Aguardando',
|
||||
mensagem: ''
|
||||
}));
|
||||
|
||||
fileTable.innerHTML = rows.length
|
||||
? rows.map(file => {
|
||||
const status = file.status || 'Aguardando';
|
||||
const statusClass = status === 'Concluído' ? 'status-ok'
|
||||
: status === 'Erro' ? 'status-error'
|
||||
: status === 'Aguardando' ? 'status-warn'
|
||||
: 'status-processing';
|
||||
return `
|
||||
<tr>
|
||||
<td>${file.nome}</td>
|
||||
<td class="${statusClass}">${status}</td>
|
||||
<td>${file.mensagem || '---'}</td>
|
||||
<td>${file.tempo || '---'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')
|
||||
: '<tr><td colspan="3" style="text-align:center; color: #aaa;">Nenhum arquivo selecionado.</td></tr>';
|
||||
}
|
||||
|
||||
async function processar(btn) {
|
||||
if (arquivos.length === 0) return alert("Nenhum arquivo selecionado.");
|
||||
btn.disabled = true;
|
||||
btn.innerText = "⏳ Processando...";
|
||||
|
||||
const formData = new FormData();
|
||||
arquivos.forEach(file => formData.append("files", file));
|
||||
|
||||
// <<< NOVO: carrega clientes ativos no combo
|
||||
async function carregarClientes() {
|
||||
try {
|
||||
await fetch("/upload-files", { method: "POST", body: formData });
|
||||
const r = await fetch('/api/clientes'); // se quiser só ativos: /api/clientes?ativos=true
|
||||
if (!r.ok) throw new Error('Falha ao carregar clientes');
|
||||
const lista = await r.json();
|
||||
|
||||
const response = await fetch("/process-queue", { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const msg = await response.text();
|
||||
throw new Error(`Erro no processamento: ${msg}`);
|
||||
}
|
||||
|
||||
arquivos = []; // <- só limpa se o processamento for bem-sucedido
|
||||
statusInterval = setInterval(updateStatus, 1000);
|
||||
} catch (err) {
|
||||
alert("Erro ao processar faturas:\n" + err.message);
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
btn.innerText = "Processar Faturas";
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
function renderTable(statusList = []) {
|
||||
const grupos = ['Aguardando', 'Enviado', 'Erro', 'Duplicado'];
|
||||
const dados = statusList.length ? statusList : arquivos.map(file => ({
|
||||
nome: file.name,
|
||||
status: 'Aguardando',
|
||||
mensagem: '',
|
||||
tempo: '---',
|
||||
tamanho: (file.size / 1024).toFixed(1) + " KB",
|
||||
data: new Date(file.lastModified).toLocaleDateString()
|
||||
}));
|
||||
|
||||
const htmlGrupos = grupos.map(grupo => {
|
||||
const rows = dados.filter(f => f.status === grupo);
|
||||
if (!rows.length) return '';
|
||||
|
||||
const linhas = rows.map(file => `
|
||||
<tr>
|
||||
<td>${file.nome}<br><small>${file.tamanho} • ${file.data}</small></td>
|
||||
<td class="${grupo === 'Concluído' ? 'status-ok' :
|
||||
grupo === 'Erro' ? 'status-error' :
|
||||
grupo === 'Aguardando' ? 'status-warn' :
|
||||
'status-processing'}">
|
||||
${grupo === 'Concluído' ? '✔️' :
|
||||
grupo === 'Erro' ? '❌' :
|
||||
grupo === 'Duplicado' ? '📄' :
|
||||
'⌛'} ${file.status}
|
||||
</td>
|
||||
<td>
|
||||
${file.mensagem ? `<div class="status-msg">${file.mensagem}</div>` : ""}
|
||||
${file.tempo || '---'}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<details open class="grupo-status">
|
||||
<summary><strong>${grupo}</strong> (${rows.length})</summary>
|
||||
<table class="grupo-tabela"><tbody>${linhas}</tbody></table>
|
||||
</details>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
document.getElementById("tabela-wrapper").innerHTML = htmlGrupos;
|
||||
}
|
||||
|
||||
async function processar(btn) {
|
||||
if (!clienteSelecionado()) {
|
||||
alert("Selecione um cliente antes de processar.");
|
||||
return;
|
||||
}
|
||||
if (arquivos.length === 0) return alert("Nenhum arquivo selecionado.");
|
||||
|
||||
// Confirmação
|
||||
const clienteTxt = document.querySelector('#select-cliente option:checked')?.textContent || '';
|
||||
if (!confirm(`Confirmar processamento de ${arquivos.length} arquivo(s) para o cliente:\n\n${clienteTxt}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clienteId = clienteSelecionado();
|
||||
|
||||
document.getElementById("tabela-wrapper").classList.add("bloqueada");
|
||||
|
||||
if (processamentoFinalizado) {
|
||||
showPopup("⚠️ Conclua ou inicie um novo processo antes de processar novamente.");
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerText = "⏳ Enviando arquivos...";
|
||||
|
||||
const statusList = [];
|
||||
const total = arquivos.length;
|
||||
|
||||
document.getElementById("overlay-bloqueio").classList.remove("hidden");
|
||||
document.getElementById("barra-progresso").style.width = "0%";
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const file = arquivos[i]; // <- declare 'file' ANTES de usar
|
||||
const formData = new FormData();
|
||||
formData.append("cliente_id", clienteId); // <- usa o cache do cliente
|
||||
formData.append("files", file);
|
||||
|
||||
// Atualiza status visual antes do envio
|
||||
statusList.push({
|
||||
nome: file.name,
|
||||
status: "Enviando...",
|
||||
mensagem: `(${i + 1}/${total})`,
|
||||
tempo: "---",
|
||||
tamanho: (file.size / 1024).toFixed(1) + " KB",
|
||||
data: new Date(file.lastModified).toLocaleDateString()
|
||||
});
|
||||
renderTable(statusList);
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await fetch("/upload-files", { method: "POST", body: formData });
|
||||
const progresso = Math.round(((i + 1) / total) * 100);
|
||||
document.getElementById("barra-progresso").style.width = `${progresso}%`;
|
||||
|
||||
statusList[i].status = "Enviado";
|
||||
statusList[i].tempo = `${((performance.now() - start) / 1000).toFixed(2)}s`;
|
||||
} catch (err) {
|
||||
statusList[i].status = "Erro";
|
||||
statusList[i].mensagem = err.message;
|
||||
}
|
||||
|
||||
renderTable(statusList);
|
||||
await new Promise(r => setTimeout(r, 200)); // pequeno delay
|
||||
}
|
||||
|
||||
btn.innerText = "⏳ Iniciando processamento...";
|
||||
try {
|
||||
const res = await fetch("/process-queue", { method: "POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
statusInterval = setInterval(updateStatus, 1000);
|
||||
|
||||
// Mensagem final após pequeno delay
|
||||
setTimeout(async () => {
|
||||
const res = await fetch("/get-status");
|
||||
const data = await res.json();
|
||||
const finalStatus = data.files;
|
||||
|
||||
const concluidos = finalStatus.filter(f => f.status === "Concluído").length;
|
||||
const erros = finalStatus.filter(f => f.status === "Erro").length;
|
||||
const duplicados = finalStatus.filter(f => f.status === "Duplicado").length;
|
||||
|
||||
document.getElementById("feedback-sucesso").innerText = `✔️ ${concluidos} enviados com sucesso`;
|
||||
document.getElementById("feedback-erro").innerText = `❌ ${erros} com erro(s)`;
|
||||
document.getElementById("feedback-duplicado").innerText = `📄 ${duplicados } duplicado(s)`;
|
||||
|
||||
document.getElementById("tabela-wrapper").classList.remove("bloqueada");
|
||||
|
||||
document.getElementById("upload-feedback").classList.remove("hidden");
|
||||
|
||||
processamentoFinalizado = true;
|
||||
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
document.getElementById("feedback-sucesso").innerText = "";
|
||||
document.getElementById("feedback-erro").innerText = `❌ Erro ao iniciar: ${e.message}`;
|
||||
document.getElementById("upload-feedback").classList.remove("hidden");
|
||||
document.getElementById("overlay-bloqueio").classList.add("hidden");
|
||||
|
||||
} finally {
|
||||
processado = true;
|
||||
document.getElementById("btn-selecionar").disabled = true;
|
||||
btn.innerText = "Processar Faturas";
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
async function updateStatus() {
|
||||
const res = await fetch("/get-status");
|
||||
@@ -113,12 +262,39 @@ function renderTable(statusList = []) {
|
||||
}
|
||||
}
|
||||
|
||||
function limpar() {
|
||||
fetch("/clear-all", { method: "POST" });
|
||||
arquivos = [];
|
||||
document.getElementById("file-input").value = null;
|
||||
renderTable();
|
||||
}
|
||||
function limpar() {
|
||||
fetch("/clear-all", { method: "POST" });
|
||||
|
||||
// reset da fila/estado
|
||||
arquivos = [];
|
||||
processado = false;
|
||||
processamentoFinalizado = false;
|
||||
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
|
||||
|
||||
// reset dos inputs/visual
|
||||
document.getElementById("file-input").value = null;
|
||||
document.getElementById("tabela-wrapper").classList.remove("bloqueada");
|
||||
document.getElementById("overlay-bloqueio").classList.add("hidden");
|
||||
document.getElementById("barra-progresso").style.width = "0%";
|
||||
document.getElementById("btn-selecionar").disabled = false;
|
||||
|
||||
// 🔓 permitir mudar o cliente novamente
|
||||
const sel = document.getElementById("select-cliente");
|
||||
sel.disabled = false;
|
||||
sel.value = ""; // <- se NÃO quiser limpar a escolha anterior, remova esta linha
|
||||
document.getElementById("cliente-aviso").textContent =
|
||||
"Selecione o cliente antes de anexar/ processar.";
|
||||
|
||||
// limpar feedback
|
||||
document.getElementById("upload-feedback").classList.add("hidden");
|
||||
document.getElementById("feedback-sucesso").innerText = "";
|
||||
document.getElementById("feedback-erro").innerText = "";
|
||||
document.getElementById("feedback-duplicado").innerText = "";
|
||||
|
||||
// limpar tabela
|
||||
renderTable();
|
||||
}
|
||||
|
||||
|
||||
function baixarPlanilha() {
|
||||
window.open('/export-excel', '_blank');
|
||||
@@ -129,6 +305,7 @@ function renderTable(statusList = []) {
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
carregarClientes();
|
||||
updateStatus();
|
||||
|
||||
const dragOverlay = document.getElementById("drag-overlay");
|
||||
@@ -151,11 +328,15 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
window.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dragOverlay.classList.remove("active");
|
||||
dragCounter = 0;
|
||||
handleFiles(e.dataTransfer.files);
|
||||
window.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dragOverlay.classList.remove("active");
|
||||
dragCounter = 0;
|
||||
if (!clienteSelecionado()) {
|
||||
alert('Selecione um cliente antes de anexar os arquivos.');
|
||||
return;
|
||||
}
|
||||
handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,6 +357,15 @@ window.addEventListener("dragover", e => {
|
||||
window.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
function fecharFeedback() {
|
||||
document.getElementById("upload-feedback").classList.add("hidden");
|
||||
document.getElementById("tabela-faturas")?.classList.remove("tabela-bloqueada");
|
||||
document.getElementById("tabela-wrapper").classList.remove("bloqueada");
|
||||
document.getElementById("overlay-bloqueio").classList.add("hidden");
|
||||
document.getElementById("barra-progresso").style.width = "0%";
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -290,6 +480,135 @@ window.addEventListener("drop", e => {
|
||||
}
|
||||
}
|
||||
|
||||
.feedback-popup {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.feedback-content {
|
||||
background-color: #fff;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.feedback-content h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.feedback-content p {
|
||||
margin: 0.25rem 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabela-bloqueada {
|
||||
pointer-events: none;
|
||||
opacity: 0.4;
|
||||
filter: grayscale(0.4);
|
||||
}
|
||||
|
||||
.tabela-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.overlay-bloqueio {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 2rem 2rem 3rem 2rem;
|
||||
border-radius: 12px;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
z-index: 999;
|
||||
box-shadow: 0 0 6px rgba(0, 0, 0, 0.15);
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
#barra-progresso {
|
||||
margin-top: 1.2rem;
|
||||
height: 6px;
|
||||
width: 0%;
|
||||
background: linear-gradient(270deg, #4361ee, #66bbff, #4361ee);
|
||||
background-size: 600% 600%;
|
||||
animation: animarBarra 1.5s linear infinite;
|
||||
border-radius: 8px;
|
||||
transition: width 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.grupo-status {
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.grupo-status summary {
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.grupo-tabela {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.grupo-tabela td {
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.barra-processamento {
|
||||
height: 5px;
|
||||
width: 0%; /* importante iniciar assim */
|
||||
background: linear-gradient(270deg, #4361ee, #66bbff, #4361ee);
|
||||
background-size: 600% 600%;
|
||||
animation: animarBarra 1.5s linear infinite;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@keyframes animarBarra {
|
||||
0% { background-position: 0% 50%; }
|
||||
100% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulseAnim 1.8s infinite;
|
||||
}
|
||||
@keyframes pulseAnim {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.06); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.status-msg {
|
||||
color: #dc3545;
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.25rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -302,4 +621,14 @@ window.addEventListener("drop", e => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="upload-feedback" class="feedback-popup hidden">
|
||||
<div class="feedback-content">
|
||||
<h3>📦 Upload concluído!</h3>
|
||||
<p id="feedback-sucesso">✔️ 0 enviados com sucesso</p>
|
||||
<p id="feedback-erro">❌ 0 com erro(s)</p>
|
||||
<p id="feedback-duplicado">📄 0 duplicado(s)</p>
|
||||
<button onclick="fecharFeedback()" class="btn btn-primary" style="margin-top: 1rem;">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -63,3 +63,9 @@ async def adicionar_fatura(dados, caminho_pdf):
|
||||
logger.error(f"Erro ao adicionar fatura no banco: {e}")
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
def avaliar_formula(formula: str, contexto: dict):
|
||||
try:
|
||||
return eval(formula, {}, contexto)
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
@@ -7,3 +7,5 @@ python-multipart==0.0.6
|
||||
openpyxl==3.1.2
|
||||
pandas==2.2.2
|
||||
PyMuPDF==1.22.5
|
||||
httpx==0.27.0
|
||||
xlsxwriter==3.2.0
|
||||
Reference in New Issue
Block a user