Compare commits

...

65 Commits

Author SHA1 Message Date
b64068cfb6 Busca correta da unidade cosumidora na fatura
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-14 11:22:38 -03:00
98c6cf2363 Correção arredondamento dasalíquotas e valor taxa
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-14 08:44:41 -03:00
f6c8943d4e Seis casas decimais para estes campos: "ICMS (%)", "ICMS (%) (UF/Ref)", "Dif. ICMS (pp)", "PIS (%)", "COFINS (%)", "Consumo (kWh)"
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-13 23:10:11 -03:00
5a9fb44bdb Inclusão da coluna Arquivo PDF nas exportações em excel
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-13 22:37:09 -03:00
e6c0155758 Quatro casas decimais ao gerar o excel para os campos ICMS (%), ICMS (%) (UF/Ref), Dif. ICMS (pp), PIS (%), COFINS (%)
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-13 21:51:49 -03:00
fb08efed1d Ajustado abas tela parâmetro
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-11 23:02:55 -03:00
bc05faafea Correção erro em parametros.html
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-11 20:54:47 -03:00
4d2fcff4a8 Cadastro da alíquota do ICMS correta. Inclusão da nova alíquota e comparação em todos os relatórios.
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-11 18:45:57 -03:00
950eb2a826 Criação da tela de clientes e relatórios
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-11 13:14:54 -03:00
bcf9861e97 Criação da tela de clientes.
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-09 19:51:14 -03:00
3cfd3d3452 Ajuste no dashboard: gráfico dentro de card, removidas linhas de fundo, legendas com linha e valores exibidos nos pontos
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-09 16:12:34 -03:00
7f659a0058 feat(dashboard): reorganiza cards, remove indicadores antigos e adiciona 'Valor médio por fatura' junto aos demais; ajusta gráfico mensal para seguir padrão de design 2025-08-09 16:06:20 -03:00
291eec35a8 Inclui httpx nas dependências
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-30 10:14:23 -03:00
5cf4fe0af3 Adiciona httpx ao requirements
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-30 10:05:43 -03:00
60fe5b3995 Atualiza requirements com dependências (httpx, etc)
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-30 09:57:52 -03:00
d8db2a60e5 Atualização: template Excel de alíquotas e layout da aba
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-30 09:48:44 -03:00
b51eeac014 Corrige indentação e reinício com rebuild no ambiente de homologação
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-29 17:22:12 -03:00
f639f306be Corrige reinício e rebuild do container de homologação no Drone
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-07-29 17:20:18 -03:00
a024125982 Atualização da tela de upload: feedback final, bloqueio visual, barra de progresso e agrupamento
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-29 17:15:39 -03:00
5eac7782a1 Melhorias no upload de faturas: overlay com bloqueio visual e barra de progresso, validações por status no backend, feedback final com contagem de duplicados, impedimento de novos envios até reinício 2025-07-29 17:09:58 -03:00
e7c2a64714 Ajusta estrutura de parâmetros, move arquivos e corrige referências
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-29 14:14:04 -03:00
e9ed45ba21 Ajustes gerais: overlay visual, validação por banco e limpeza segura em homologação 2025-07-29 14:10:14 -03:00
73d51e5938 Corrige .drone.yml com deploy funcional para homologação e produção
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-29 00:35:17 -03:00
06a7f17e8d Corrige pipeline Drone CI (remove cd)
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-29 00:28:58 -03:00
6f69e6100a Corrige pipeline Drone CI (remove cd)
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-29 00:27:37 -03:00
a522926166 Remove cd do .drone.yml e força novo deploy
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-29 00:23:02 -03:00
b1edae62b5 Corrige comando de reinício container Drone CI
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-29 00:20:21 -03:00
afe6228eec Corrige caminho de compose para deploy homolog
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-29 00:18:16 -03:00
597949191b Corrige caminho de build no Drone CI
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-29 00:14:34 -03:00
6a3d44ba62 Força execução do Drone CI
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-29 00:11:49 -03:00
8a9d452160 Corrige execução do container de homologação no pipeline
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-29 00:03:55 -03:00
e8a8401483 Corrige pipeline Drone para homologação (build + deploy)
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-29 00:00:04 -03:00
86bbe1aa55 Corrige caminho do main.py no Dockerfile para 'app.main:app'
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-28 23:51:11 -03:00
6b92420cef Forçando deploy para homologação
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-28 23:40:30 -03:00
f21858d100 Forçar deploy para homologação
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-28 23:37:25 -03:00
7ce2fe158d Trigger forçado do deploy para homologação via Drone
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-28 23:21:26 -03:00
da25e719be Força atualização da versão com drag global e estrutura app/ consolidada
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-28 22:59:51 -03:00
ea05d69760 Atualiza estrutura da aplicação com pasta app reorganizada e exibição de tempo de processamento
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-28 22:47:31 -03:00
f3c2b08a69 Atualiza exibição do tempo por processo e garante consistência da estrutura em app/
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-28 22:31:31 -03:00
d863d7f9e2 feat: adiciona suporte ao .env e tasks.json para deploy local e remoto
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-28 20:46:37 -03:00
dc60bb56a3 refactor: carrega DATABASE_URL do .env 2025-07-28 20:30:36 -03:00
3ec0d04a56 chore: adiciona .venv e arquivos locais ao .gitignore 2025-07-28 20:26:50 -03:00
root
f19d9658f0 ci: teste deploy para homologação
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-28 19:10:39 -03:00
root
a4e5966f54 ci: corrige indentação e exclui docker-compose.yml da homologação
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-28 19:01:28 -03:00
root
928d92bd05 ci: corrige deploy para ignorar docker-compose.yml
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-07-28 18:55:57 -03:00
root
cd64877237 ci: ignora docker-compose.yml no deploy de homologação
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-28 18:52:19 -03:00
root
8a5880e5fa ci: corrige exclusão do docker-compose.yml no deploy de homologação
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-28 18:42:43 -03:00
root
6431bcaa82 ci: ajusta deploy de homologação sem sobrescrever docker-compose
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-28 18:38:02 -03:00
root
de7382846c ci: corrige indentação de scp_args para ignorar docker-compose.yml
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-28 18:24:39 -03:00
root
6ecc8b22a5 ci: ignora docker-compose.yml no deploy para homologação
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-07-28 18:18:40 -03:00
root
e9d11223a7 ci: força novo deploy de homologação após correção
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-28 18:06:25 -03:00
root
72c2ca0aa9 ci: força novo deploy de homologação
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-28 18:02:55 -03:00
root
cddb37ab91 ci: testa homologação com nome e porta corrigidos
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-28 17:28:24 -03:00
root
d31a3cc822 ci: testa homologação com nome e porta corrigidos 2025-07-28 17:27:53 -03:00
root
e30527a3c2 ci: teste de deploy em produção
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-28 17:23:55 -03:00
root
fb4e0ad8b9 ci: atualiza pipeline com remoção forçada dos containers
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-28 14:15:14 -03:00
root
321b88b5d1 ci: testa pipeline de produção
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-28 14:04:09 -03:00
root
32df563026 ci: atualiza pipelines Drone CI (homologação e produção)
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-28 13:44:17 -03:00
root
eeb15d731f feat: primeira versão da produção
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-28 13:29:45 -03:00
root
3aabc7b5c5 ci: trigger pipeline após corrigir nome do container
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-28 12:54:10 -03:00
root
8d8e760e2a fix: corrige imagens do pipeline Drone CI
Some checks failed
continuous-integration/drone/push Build is failing
2025-07-28 12:48:03 -03:00
root
b3f7f9404b ci: atualiza pipeline drone
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-07-28 12:44:40 -03:00
root
0f48d33483 trigger build
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-07-28 12:01:38 -03:00
root
b9c017e83b Merge branch 'main' of https://git.wlissesmenezes.adv.br/ewerton.almeida/app_faturas
Some checks reported errors
continuous-integration/drone/push Build encountered an error
2025-07-28 11:54:02 -03:00
root
aff457b010 feat: adiciona pipeline Drone CI 2025-07-28 11:51:55 -03:00
87 changed files with 4289 additions and 865 deletions

65
.drone.yml Normal file
View File

@@ -0,0 +1,65 @@
---
kind: pipeline
type: docker
name: homolog
trigger:
branch:
- main
steps:
- name: deploy to homolog
image: appleboy/drone-scp
settings:
host: 216.22.5.141
username: root
password: F6tC5tCh29XQRpzp
port: 22
source: .
target: /home/app_fatura_homolog
rm: false
- name: restart homolog container
image: appleboy/drone-ssh
settings:
host: 216.22.5.141
username: root
password: F6tC5tCh29XQRpzp
port: 22
script:
- cd /home/app_fatura_homolog
- docker compose -f docker-compose-homolog.yml down
- docker compose -f docker-compose-homolog.yml up -d --build
---
kind: pipeline
type: docker
name: production
trigger:
branch:
- production
steps:
- name: deploy to production
image: appleboy/drone-scp
settings:
host: 216.22.5.141
username: root
password: F6tC5tCh29XQRpzp
port: 22
source: .
target: /home/app_fatura
rm: true
- name: restart production container
image: appleboy/drone-ssh
settings:
host: 216.22.5.141
username: root
password: F6tC5tCh29XQRpzp
port: 22
script:
- docker rm -f Faturas || true
- cd /home/app_fatura
- docker compose up -d

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.env
__pycache__/
*.pyc
.venv/
.vscode/
uploads/

View File

@@ -19,4 +19,4 @@ RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 5000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "5000"]

4
README.md Normal file
View File

@@ -0,0 +1,4 @@
# trigger novo build Drone CI
# teste de produção
# novo teste
# novo teste

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

View File

22
app/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get install -y \
build-essential \
python3-dev \
libffi-dev \
libmupdf-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . /app
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
EXPOSE 5000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5000"]

View File

@@ -2,7 +2,7 @@
from decimal import Decimal
from datetime import datetime
from models import ParametrosFormula, SelicMensal
from app.models import ParametrosFormula, SelicMensal
from sqlalchemy.future import select
from sqlalchemy.ext.asyncio import AsyncSession
import re

18
app/database.py Executable file
View File

@@ -0,0 +1,18 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
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")
engine = create_async_engine(DATABASE_URL)
AsyncSessionLocal = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
Base = declarative_base()
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session

19
app/docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
version: '3.9'
services:
app_fatura:
build:
context: .
dockerfile: Dockerfile
container_name: Faturas
ports:
- "5000:5000"
volumes:
- ./uploads:/app/uploads
restart: unless-stopped
networks:
- icontainer-network
networks:
icontainer-network:
external: true

4
app/git-commit.ps1 Normal file
View File

@@ -0,0 +1,4 @@
$message = Read-Host "Digite a descrição do commit"
git add .
git commit -m "$message"
git push origin main

View File

@@ -29,14 +29,19 @@ def extrair_dados(texto_final):
nota_fiscal = extrair_seguro(r'NOTA FISCAL Nº\s*(\d+)', texto_final)
# --- Unidade Consumidora (UC): 812 dígitos, SEM hífen ---
uc = extrair_seguro([
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 810 dígitos sem hífen
if not uc:
seqs = re.findall(r'(?<!\d)(\d{8,10})(?![\d-])', texto_final)
if seqs:
uc = max(seqs, key=len)
logging.debug("TEXTO PDF:\n" + texto_final)
referencia = extrair_seguro([

703
app/main.py Normal file
View File

@@ -0,0 +1,703 @@
import asyncio
import uuid
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 datetime import date
import re
from fastapi.responses import StreamingResponse
from io import BytesIO
from app.models import ParametrosFormula
from sqlalchemy.future import select
from app.database import AsyncSessionLocal
from app.models import Fatura
from app.processor import (
fila_processamento,
processar_em_lote,
status_arquivos,
limpar_arquivos_processados
)
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 = 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)
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)
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
)
@app.get("/upload", response_class=HTMLResponse)
def upload_page(request: Request):
app_env = os.getenv("APP_ENV", "dev") # Captura variável de ambiente
return templates.TemplateResponse("upload.html", {
"request": request,
"app_env": app_env # Passa para o template
})
@app.get("/relatorios", response_class=HTMLResponse)
async def relatorios_page(request: Request, cliente: str | None = Query(None)):
async with AsyncSessionLocal() as session:
# Carregar clientes ativos para o combo
r_cli = await session.execute(text("""
SELECT id, nome_fantasia
FROM faturas.clientes
WHERE ativo = TRUE
ORDER BY nome_fantasia
"""))
clientes = [{"id": str(row.id), "nome": row.nome_fantasia} for row in r_cli]
# Carregar faturas (todas ou filtradas por cliente)
if cliente:
r_fat = await session.execute(text("""
SELECT *
FROM faturas.faturas
WHERE cliente_id = :cid
ORDER BY data_processamento DESC
"""), {"cid": cliente})
else:
r_fat = await session.execute(text("""
SELECT *
FROM faturas.faturas
ORDER BY data_processamento DESC
"""))
faturas = r_fat.mappings().all()
return templates.TemplateResponse("relatorios.html", {
"request": request,
"clientes": clientes,
"cliente_selecionado": cliente or "",
"faturas": faturas
})
@app.post("/upload-files")
async def upload_files(
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,
"cliente_id": cliente_id
})
return {"message": "Arquivos enviados para fila"}
@app.post("/process-queue")
async def process_queue():
resultados = await processar_em_lote()
return {"message": "Processamento concluído", "resultados": resultados}
@app.get("/get-status")
async def get_status():
files = []
for nome, status in status_arquivos.items():
if isinstance(status, dict):
files.append({
"nome": nome,
"status": status.get("status", "Erro"),
"mensagem": status.get("mensagem", "---"),
"tempo": status.get("tempo", "---"),
"tamanho": f"{status.get('tamanho', 0)} KB",
"data": status.get("data", "")
})
else:
files.append({
"nome": nome,
"status": status,
"mensagem": "---" if status == "Concluído" else status,
"tempo": "---" # ✅ AQUI também
})
is_processing = not fila_processamento.empty()
return JSONResponse(content={"is_processing": is_processing, "files": files})
@app.post("/clear-all")
async def clear_all():
limpar_arquivos_processados()
for f in os.listdir(UPLOAD_DIR):
os.remove(os.path.join(UPLOAD_DIR, f))
return {"message": "Fila e arquivos limpos"}
@app.get("/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:
# 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 = []
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)),
},
)
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"
@app.post("/limpar-faturas")
async def limpar_faturas():
app_env = os.getenv("APP_ENV", "dev")
if app_env not in ["homolog", "dev", "local"]:
return JSONResponse(status_code=403, content={"message": "Operação não permitida neste ambiente."})
async with AsyncSessionLocal() as session:
print("🧪 Limpando faturas do banco...")
await session.execute(text("DELETE FROM faturas.faturas"))
await session.commit()
upload_path = os.path.join("app", "uploads")
for nome in os.listdir(upload_path):
caminho = os.path.join(upload_path, nome)
if os.path.isfile(caminho):
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}

96
app/models.py Executable file
View File

@@ -0,0 +1,96 @@
# 📄 models.py
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)
nome = Column(String(50))
formula = Column(Text)
ativo = Column(Boolean, default=True)
class Fatura(Base):
__tablename__ = "faturas"
__table_args__ = {'schema': 'faturas'}
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
nome = Column(String)
classificacao_tarifaria = Column("classificacao_tarifaria", String)
unidade_consumidora = Column("unidade_consumidora", String)
referencia = Column(String)
valor_total = Column(Numeric(18, 6, asdecimal=True))
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(Numeric(8, 6, asdecimal=True))
icms_valor = Column(Numeric(18, 6, asdecimal=True))
icms_base = Column(Numeric(18, 6, asdecimal=True))
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(Numeric(14, 6, asdecimal=True))
tarifa = Column(Numeric(12, 6, asdecimal=True))
nota_fiscal = Column(String)
data_processamento = Column(DateTime, default=datetime.utcnow)
arquivo_pdf = Column("arquivo_pdf", String)
cidade = Column(String)
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):
__tablename__ = "logs_processamento"
__table_args__ = {'schema': 'faturas'}
id = Column(Integer, primary_key=True, autoincrement=True)
nome_arquivo = Column(String)
status = Column(String)
mensagem = Column(Text)
acao = Column(String) # nova coluna existente no banco
data_log = Column("data_log", DateTime, default=datetime.utcnow)
class AliquotaUF(Base):
__tablename__ = "aliquotas_uf"
__table_args__ = {'schema': 'faturas'}
id = Column(Integer, primary_key=True, autoincrement=True)
uf = Column(String)
exercicio = Column(Integer)
aliq_icms = Column(Numeric(6, 4))
class SelicMensal(Base):
__tablename__ = "selic_mensal"
__table_args__ = {'schema': 'faturas'}
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)

View File

365
app/parametros.py Normal file
View File

@@ -0,0 +1,365 @@
# parametros.py
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
from typing import List
from pydantic import BaseModel
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
aliq_icms: float
class Config:
from_attributes = True
class ParametrosFormulaSchema(BaseModel):
nome: str
formula: str
ativo: bool = True
class Config:
from_attributes = True
class SelicMensalSchema(BaseModel):
mes: str # 'YYYY-MM'
fator: float
class Config:
from_attributes = True
# === Rotas ===
@router.get("/parametros")
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.post("/parametros/aliquotas")
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.aliq_icms = aliq.aliq_icms # atualizado
else:
novo = AliquotaUF(**aliq.dict())
db.add(novo)
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])
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")
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.ativo = form.ativo
else:
novo = ParametrosFormula(nome=form.nome, formula=form.formula, ativo=form.ativo)
db.add(novo)
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])
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()
if existente:
# atualiza (inclusive a chave, se mudou)
existente.uf = uf
existente.exercicio = exercicio
existente.aliq_icms = aliquota
else:
# não existia o original -> upsert padrão
db.add(AliquotaUF(uf=uf, exercicio=exercicio, aliq_icms=aliquota))
await db.commit()
return {"success": True}
@router.post("/parametros/aliquotas/importar")
async def importar_aliquotas_csv(arquivo: UploadFile = File(...), db: AsyncSession = Depends(get_session)):
content = await arquivo.read()
text = content.decode("utf-8", errors="ignore")
# tenta ; depois ,
sniffer = csv.Sniffer()
dialect = sniffer.sniff(text.splitlines()[0] if text else "uf;exercicio;aliquota")
reader = csv.DictReader(io.StringIO(text), dialect=dialect)
count = 0
for row in reader:
uf = (row.get("uf") or row.get("UF") or "").strip().upper()
exercicio_str = (row.get("exercicio") or row.get("ano") or "").strip()
try:
exercicio = int(exercicio_str)
except Exception:
continue
aliquota_str = (row.get("aliquota") or row.get("aliq_icms") or "").replace(",", ".").strip()
if not uf or not exercicio or not aliquota_str:
continue
try:
aliquota = Decimal(aliquota_str)
except Exception:
continue
stmt = select(AliquotaUF).where(AliquotaUF.uf == uf, AliquotaUF.exercicio == exercicio)
existente = (await db.execute(stmt)).scalar_one_or_none()
if existente:
existente.aliq_icms = aliquota
else:
db.add(AliquotaUF(uf=uf, exercicio=exercicio, aliq_icms=aliquota))
count += 1
await db.commit()
return {"success": True, "qtd": count}
@router.delete("/parametros/aliquotas/{uf}/{exercicio}")
async def excluir_aliquota(uf: str, exercicio: int, db: AsyncSession = Depends(get_session)):
stmt = select(AliquotaUF).where(
AliquotaUF.uf == uf.upper(),
AliquotaUF.exercicio == exercicio
)
row = (await db.execute(stmt)).scalar_one_or_none()
if not row:
return JSONResponse(status_code=404, content={"error": "Registro não encontrado."})
await db.delete(row)
await db.commit()
return {"success": True}

260
app/processor.py Normal file
View File

@@ -0,0 +1,260 @@
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
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__)
UPLOADS_DIR = os.path.join("app", "uploads")
TEMP_DIR = os.path.join(UPLOADS_DIR, "temp")
fila_processamento = asyncio.Queue()
status_arquivos = {}
def remover_arquivo_temp(caminho_pdf):
try:
if os.path.exists(caminho_pdf) and TEMP_DIR in caminho_pdf:
os.remove(caminho_pdf)
logger.info(f"Arquivo temporário removido: {os.path.basename(caminho_pdf)}")
except Exception as e:
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}"
destino_final = os.path.join(UPLOADS_DIR, nome_destino)
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, cliente_id: str | None = None):
inicio = time.perf_counter()
async with AsyncSessionLocal() as session:
try:
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(
nota_fiscal=dados['nota_fiscal'],
unidade_consumidora=dados['unidade_consumidora']
)
)
if existente_result.scalar_one_or_none():
duracao = round(time.perf_counter() - inicio, 2)
remover_arquivo_temp(caminho_pdf_temp)
return {
"status": "Duplicado",
"dados": dados,
"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)
await session.commit()
remover_arquivo_temp(caminho_pdf_temp)
duracao = round(time.perf_counter() - inicio, 2)
return {
"status": "Concluído",
"dados": dados,
"tempo": f"{duracao}s"
}
except Exception as e:
erro_str = traceback.format_exc()
duracao = round(time.perf_counter() - inicio, 2)
await session.rollback()
remover_arquivo_temp(caminho_pdf_temp)
print(f"\n📄 ERRO no arquivo: {nome_original}")
print(f"⏱ Tempo até erro: {duracao}s")
print(f"❌ Erro detalhado:\n{erro_str}")
return {
"status": "Erro",
"mensagem": str(e),
"tempo": f"{duracao}s",
"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'],
item.get('cliente_id')
)
# tentar tamanho/data do TEMP; se não existir mais, tenta do destino final; senão, 0/""
temp_path = item['caminho_pdf']
dest_path = (resultado.get("dados") or {}).get("link_arquivo", "")
def _safe_size(p):
try:
return os.path.getsize(p) // 1024
except Exception:
return 0
def _safe_mtime(p):
try:
return time.strftime("%d/%m/%Y", time.localtime(os.path.getmtime(p)))
except Exception:
return ""
status_arquivos[item['nome_original']] = {
"status": resultado.get("status"),
"mensagem": resultado.get("mensagem", ""),
"tempo": resultado.get("tempo", "---"),
"tamanho": _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']] = {
"status": "Erro",
"mensagem": str(e),
"tempo": "---"
}
resultados.append({
"nome": item['nome_original'],
"status": "Erro",
"mensagem": str(e)
})
print(f"Erro ao processar {item['nome_original']}: {e}")
print(traceback.format_exc())
# 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()

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

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

View File

@@ -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
})

View File

@@ -1,6 +1,6 @@
# startup.py
import logging
from routes.selic import atualizar_selic_com_base_na_competencia
from app.routes.selic import atualizar_selic_com_base_na_competencia
async def executar_rotinas_iniciais(db):
try:

View File

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 397 B

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

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

255
app/templates/dashboard.html Executable file
View File

@@ -0,0 +1,255 @@
{% extends "index.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<div id="loading" class="loading-backdrop">
<div class="spinner"></div>
<div class="loading-msg">Carregando dados…</div>
</div>
<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>
</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 ctxE = document.getElementById('graficoEvolucao').getContext('2d');
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
}]
},
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 %}

View File

@@ -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>

804
app/templates/parametros.html Executable file
View File

@@ -0,0 +1,804 @@
{% extends "index.html" %}
{% block title %}Parâmetros de Cálculo{% endblock %}
{% block content %}
<h1 style="font-size: 1.6rem; margin-bottom: 1rem; display:flex; align-items:center; gap:0.5rem;">
⚙️ Parâmetros de Cálculo
</h1>
<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" style="grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));">
<div class="form-group">
<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="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">
<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>
<span class="exemplo">Ex: (pis_base - (pis_base - icms_valor)) * pis_aliq</span>
</div>
<div id="resultado-teste" class="mensagem-info" style="display:none;"></div>
</div>
</div>
<button type="submit" class="btn btn-primary">💾 Salvar Parâmetro</button>
<button type="button" class="btn btn-primary pulse" onclick="limparFormulario()">🔁 Novo Parâmetro</button>
</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>
</div>
<!-- ABA SELIC -->
<div id="selic" class="tab-content">
<div class="formulario-box">
<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" 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>
<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>
<table class="selic-table">
<thead><tr><th>Competência</th><th>Fator</th></tr></thead>
<tbody>
{% for item in selic_dados %}
<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>
/* 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;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0,0,0,0.05);
max-width: 850px;
margin: 0 auto;
}
.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.check-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.check-group label {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Grade do formulário */
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
/* 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;
}
/* Tabela SELIC */
.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');
// Ativa o botão correspondente
const button = document.querySelector(`.tab[onclick="switchTab('${tabId}')"]`);
if (button) button.classList.add('active');
}
// ✅ 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 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 %}

229
app/templates/relatorios.html Executable file
View File

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

634
app/templates/upload.html Executable file
View File

@@ -0,0 +1,634 @@
{% extends "index.html" %}
{% block title %}Upload de Faturas{% endblock %}
{% block content %}
<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" 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-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>
<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');
// <<< NOVO: carrega clientes ativos no combo
async function carregarClientes() {
try {
const r = await fetch('/api/clientes'); // se quiser só ativos: /api/clientes?ativos=true
if (!r.ok) throw new Error('Falha ao carregar clientes');
const lista = await r.json();
const sel = document.getElementById('select-cliente');
sel.innerHTML = `<option value="">— Selecione um cliente —</option>` +
lista.map(c => `<option value="${c.id}">${c.nome_fantasia}${c.cnpj ? ' — ' + c.cnpj : ''}</option>`).join('');
} catch (e) {
console.error(e);
alert('Não foi possível carregar a lista de clientes.');
}
}
function clienteSelecionado() {
return (document.getElementById('select-cliente')?.value || '').trim();
}
// <<< AJUSTE: impedir anexar sem cliente
function handleFiles(files) {
if (!clienteSelecionado()) {
alert('Selecione um cliente antes de anexar os arquivos.');
return;
}
if (processado) {
document.getElementById("feedback-sucesso").innerText = "";
document.getElementById("feedback-erro").innerText = "⚠️ Conclua ou inicie um novo processo antes de adicionar mais arquivos.";
document.getElementById("feedback-duplicado").innerText = "";
document.getElementById("upload-feedback").classList.remove("hidden");
return;
}
arquivos = [...arquivos, ...files];
// trava o combo após começar a anexar (opcional)
document.getElementById('select-cliente').disabled = true;
renderTable();
}
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");
const data = await res.json();
renderTable(data.files);
if (!data.is_processing && statusInterval) {
clearInterval(statusInterval);
}
}
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');
}
function gerarRelatorio() {
window.open('/generate-report', '_blank');
}
window.addEventListener('DOMContentLoaded', () => {
carregarClientes();
updateStatus();
const dragOverlay = document.getElementById("drag-overlay");
let dragCounter = 0;
window.addEventListener("dragenter", e => {
dragCounter++;
dragOverlay.classList.add("active");
});
window.addEventListener("dragleave", e => {
dragCounter--;
if (dragCounter <= 0) {
dragOverlay.classList.remove("active");
dragCounter = 0;
}
});
window.addEventListener("dragover", e => {
e.preventDefault();
});
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);
});
});
async function limparFaturas() {
if (!confirm("Deseja realmente limpar todas as faturas e arquivos (somente homologação)?")) return;
const res = await fetch("/limpar-faturas", { method: "POST" });
const data = await res.json();
alert(data.message || "Concluído");
updateStatus(); // atualiza visual
}
window.addEventListener("dragover", e => {
e.preventDefault();
});
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>
.upload-box {
background: #fff;
border: 2px dashed #ccc;
padding: 2rem;
text-align: center;
border-radius: 10px;
margin-bottom: 2rem;
}
.upload-box.dragover {
background-color: #eef2ff;
border-color: #4361ee;
}
.buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 2rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease-in-out;
}
.btn:hover { opacity: 0.9; }
.btn-primary { background-color: #4361ee; color: white; }
.btn-success { background-color: #198754; color: white; }
.btn-danger { background-color: #dc3545; color: white; }
table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 0 10px rgba(0,0,0,0.05);
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background-color: #f2f2f2;
font-size: 0.85rem;
color: #555;
text-transform: uppercase;
letter-spacing: 1px;
}
.status-ok { color: #198754; }
.status-error { color: #dc3545; }
.status-warn { color: #ffc107; }
.status-processing {
color: #0d6efd; /* azul */
font-weight: bold;
}
.drag-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(67, 97, 238, 0.7); /* institucional */
z-index: 9999;
display: none;
align-items: center;
justify-content: center;
transition: opacity 0.2s ease-in-out;
opacity: 0;
}
.drag-overlay.active {
display: flex;
opacity: 1;
}
.drag-overlay-content {
text-align: center;
color: white;
font-size: 1.1rem;
font-weight: 600;
animation: fadeInUp 0.3s ease;
text-shadow: 1px 1px 6px rgba(0, 0, 0, 0.4);
margin-top: 0;
}
.drag-overlay-content svg {
margin-bottom: 1rem;
width: 72px;
height: 72px;
fill: white;
filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.4));
}
@keyframes fadeInUp {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.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>
<div class="drag-overlay" id="drag-overlay">
<div class="drag-overlay-content">
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="#fff" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.477 2 12c0 5.522 4.477 10 10 10s10-4.478 10-10c0-5.523-4.477-10-10-10Zm0 13a1 1 0 0 1-1-1v-2.586l-.293.293a1 1 0 0 1-1.414-1.414l2.707-2.707a1 1 0 0 1 1.414 0l2.707 2.707a1 1 0 0 1-1.414 1.414L13 11.414V14a1 1 0 0 1-1 1Z"/>
</svg>
<p>Solte os arquivos para enviar</p>
</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 %}

View File

@@ -3,10 +3,10 @@ import fitz
import logging
import re
from datetime import datetime
from database import AsyncSessionLocal
from models import Fatura, LogProcessamento
from calculos import calcular_campos_dinamicos
from layouts.equatorial_go import extrair_dados as extrair_dados_equatorial
from app.database import AsyncSessionLocal
from app.models import Fatura, LogProcessamento
from app.calculos import calcular_campos_dinamicos
from app.layouts.equatorial_go import extrair_dados as extrair_dados_equatorial
from sqlalchemy.future import select
logger = logging.getLogger(__name__)
@@ -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

BIN
build.log Normal file

Binary file not shown.

29
corrigir-estrutura.ps1 Normal file
View File

@@ -0,0 +1,29 @@
# Caminho base
$base = Get-Location
$appFolder = Join-Path $base "app"
# Arquivos candidatos na raiz
$arquivosRaiz = Get-ChildItem -Path $base -File -Filter *.py
foreach ($arquivo in $arquivosRaiz) {
$destino = Join-Path $appFolder $arquivo.Name
if (-Not (Test-Path $destino)) {
Write-Host "🟢 Movendo novo arquivo para app/: $($arquivo.Name)"
Move-Item $arquivo.FullName $destino
}
else {
$modificadoRaiz = (Get-Item $arquivo.FullName).LastWriteTime
$modificadoApp = (Get-Item $destino).LastWriteTime
if ($modificadoRaiz -gt $modificadoApp) {
Write-Host "🔄 Substituindo por versão mais recente: $($arquivo.Name)"
Move-Item -Force $arquivo.FullName $destino
}
else {
Write-Host "⚪ Ignorando $($arquivo.Name) (versão dentro de app/ é mais nova)"
}
}
}
Write-Host "`n✅ Finalizado. Revise a pasta app/ e apague os arquivos da raiz se desejar."

View File

@@ -1,15 +0,0 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from contextlib import contextmanager
# database.py
DATABASE_URL = "postgresql+asyncpg://fatura:102030@ic-postgresql-FtOY:5432/app_faturas"
async_engine = create_async_engine(DATABASE_URL, echo=False, future=True)
AsyncSessionLocal = sessionmaker(bind=async_engine, class_=AsyncSession, expire_on_commit=False)
Base = declarative_base()
async def get_session():
async with AsyncSessionLocal() as session:
yield session

14
deploy-homolog.ps1 Normal file
View File

@@ -0,0 +1,14 @@
# deploy-homolog.ps1
$source = "$PSScriptRoot\app\*"
$destination = "\\216.22.5.141\home\app_fatura_homolog\app"
# (1) Envia somente a pasta `app/`
Copy-Item -Path $source -Destination $destination -Recurse -Force
# (2) Envia arquivos raiz essenciais (excluindo os antigos bagunçados)
$arquivos = @(".env", "requirements.txt", "docker-compose.yml", "Dockerfile", ".drone.yml")
foreach ($arquivo in $arquivos) {
Copy-Item "$PSScriptRoot\$arquivo" "\\216.22.5.141\home\app_fatura_homolog\" -Force
}
Write-Host "Deploy concluído para homologação."

18
deploy-prod.ps1 Normal file
View File

@@ -0,0 +1,18 @@
$message = Read-Host "Digite a descrição do commit para produção"
Write-Host "→ Mudando para a branch 'production'..."
git checkout production
Write-Host "→ Mesclando alterações da branch 'main'..."
git merge main
Write-Host "→ Commitando descrição interativa..."
git commit --allow-empty -m "$message"
Write-Host "→ Enviando para o repositório remoto..."
git push origin production
Write-Host "→ Voltando para a branch 'main'..."
git checkout main
Write-Host "✅ Deploy para produção finalizado!"

BIN
deploy.log Normal file

Binary file not shown.

BIN
drone-debug.log Normal file

Binary file not shown.

BIN
dummy.txt Normal file

Binary file not shown.

136
main.py
View File

@@ -1,136 +0,0 @@
import uuid
from fastapi import FastAPI, Request, UploadFile, File
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
import os, shutil
from fastapi.responses import StreamingResponse
from io import BytesIO
import pandas as pd
from sqlalchemy.future import select
from database import AsyncSessionLocal
from models import Fatura
from processor import (
fila_processamento,
processar_em_lote,
status_arquivos,
limpar_arquivos_processados
)
app = FastAPI()
templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="static"), name="static")
UPLOAD_DIR = "uploads/temp"
os.makedirs(UPLOAD_DIR, exist_ok=True)
@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"},
]
analise_stf = {
"antes": {"percentual_com_icms": 80, "media_valor": 1200},
"depois": {"percentual_com_icms": 20, "media_valor": 800},
}
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):
return templates.TemplateResponse("upload.html", {"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)
def parametros_page(request: Request):
return templates.TemplateResponse("parametros.html", {"request": request})
@app.post("/upload-files")
async def upload_files(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
})
return {"message": "Arquivos enviados para fila"}
@app.post("/process-queue")
async def process_queue():
resultados = await processar_em_lote()
return {"message": "Processamento concluído", "resultados": resultados}
@app.get("/get-status")
async def get_status():
files = []
for nome, status in status_arquivos.items():
files.append({
"nome": nome,
"status": status,
"mensagem": "---" if status == "Concluído" else status
})
is_processing = not fila_processamento.empty()
return JSONResponse(content={"is_processing": is_processing, "files": files})
@app.post("/clear-all")
async def clear_all():
limpar_arquivos_processados()
for f in os.listdir(UPLOAD_DIR):
os.remove(os.path.join(UPLOAD_DIR, f))
return {"message": "Fila e arquivos limpos"}
@app.get("/export-excel")
async def export_excel():
async with AsyncSessionLocal() as session:
result = await session.execute(select(Fatura))
faturas = result.scalars().all()
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)
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=relatorio_faturas.xlsx"}
)

View File

@@ -1,80 +0,0 @@
# 📄 models.py
from sqlalchemy import Column, String, Integer, Float, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID
import uuid
from datetime import datetime
from database import Base
class ParametrosFormula(Base):
__tablename__ = 'parametros_formula'
__table_args__ = {'schema': 'faturas', 'extend_existing': True}
id = Column(Integer, primary_key=True, autoincrement=True)
nome = Column(String)
formula = Column(Text)
class Fatura(Base):
__tablename__ = "faturas"
__table_args__ = {'schema': 'faturas'}
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
nome = Column(String)
classificacao_tarifaria = Column("classificacao_tarifaria", String)
unidade_consumidora = Column("unidade_consumidora", String)
referencia = Column(String)
valor_total = Column(Float)
pis_aliq = Column("pis_aliq", Float)
pis_valor = Column("pis_valor", Float)
pis_base = Column("pis_base", Float)
icms_aliq = Column("icms_aliq", Float)
icms_valor = Column("icms_valor", Float)
icms_base = Column("icms_base", Float)
cofins_aliq = Column("cofins_aliq", Float)
cofins_valor = Column("cofins_valor", Float)
cofins_base = Column("cofins_base", Float)
consumo = Column("consumo", Float)
tarifa = Column("tarifa", Float)
nota_fiscal = Column(String)
data_processamento = Column(DateTime, default=datetime.utcnow)
arquivo_pdf = Column("arquivo_pdf", String)
cidade = Column(String)
estado = Column(String)
distribuidora = Column(String)
link_arquivo = Column("link_arquivo", String)
class LogProcessamento(Base):
__tablename__ = "logs_processamento"
__table_args__ = {'schema': 'faturas'}
id = Column(Integer, primary_key=True, autoincrement=True)
nome_arquivo = Column(String)
status = Column(String)
mensagem = Column(Text)
acao = Column(String) # nova coluna existente no banco
data_log = Column("data_log", DateTime, default=datetime.utcnow)
class AliquotaUF(Base):
__tablename__ = "aliquotas_uf"
__table_args__ = {'schema': 'faturas'}
id = Column(Integer, primary_key=True, autoincrement=True)
uf = Column(String)
exercicio = Column(String)
aliquota = Column(Float)
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)

View File

@@ -1,93 +0,0 @@
import logging
import os
import shutil
import asyncio
from sqlalchemy.future import select
from utils import extrair_dados_pdf
from database import AsyncSessionLocal
from models import Fatura, LogProcessamento
logger = logging.getLogger(__name__)
UPLOADS_DIR = os.path.join("app", "uploads")
TEMP_DIR = os.path.join(UPLOADS_DIR, "temp")
fila_processamento = asyncio.Queue()
status_arquivos = {}
def remover_arquivo_temp(caminho_pdf):
try:
if os.path.exists(caminho_pdf) and TEMP_DIR in caminho_pdf:
os.remove(caminho_pdf)
logger.info(f"Arquivo temporário removido: {os.path.basename(caminho_pdf)}")
except Exception as e:
logger.warning(f"Falha ao remover arquivo temporário: {e}")
def salvar_em_uploads(caminho_pdf_temp, nome_original, nota_fiscal):
try:
extensao = os.path.splitext(nome_original)[1].lower()
nome_destino = f"{nota_fiscal}{extensao}"
destino_final = os.path.join(UPLOADS_DIR, nome_destino)
shutil.copy2(caminho_pdf_temp, destino_final)
return destino_final
except Exception as e:
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 with AsyncSessionLocal() as session:
try:
dados = extrair_dados_pdf(caminho_pdf_temp)
dados['arquivo_pdf'] = nome_original
existente_result = await session.execute(
select(Fatura).filter_by(nota_fiscal=dados['nota_fiscal'], unidade_consumidora=dados['unidade_consumidora'])
)
if existente_result.scalar_one_or_none():
remover_arquivo_temp(caminho_pdf_temp)
return {"status": "Duplicado", "dados": dados}
caminho_final = salvar_em_uploads(caminho_pdf_temp, nome_original, dados['nota_fiscal'])
dados['link_arquivo'] = caminho_final
fatura = Fatura(**dados)
session.add(fatura)
session.add(LogProcessamento(
status="Sucesso",
mensagem="Fatura processada com sucesso",
nome_arquivo=nome_original,
acao="PROCESSAMENTO"
))
await session.commit()
remover_arquivo_temp(caminho_pdf_temp)
return {"status": "Concluído", "dados": dados}
except Exception as e:
await session.rollback()
session.add(LogProcessamento(
status="Erro",
mensagem=str(e),
nome_arquivo=nome_original,
acao="PROCESSAMENTO"
))
await session.commit()
logger.error(f"Erro ao processar fatura: {e}", exc_info=True)
remover_arquivo_temp(caminho_pdf_temp)
return {"status": "Erro", "mensagem": str(e)}
async def processar_em_lote():
resultados = []
while not fila_processamento.empty():
item = await fila_processamento.get()
resultado = await process_single_file(item['caminho_pdf'], item['nome_original'])
status_arquivos[item['nome_original']] = resultado.get("status", "Erro")
resultados.append(resultado)
return resultados
def limpar_arquivos_processados():
status_arquivos.clear()
while not fila_processamento.empty():
fila_processamento.get_nowait()

View File

@@ -6,4 +6,6 @@ asyncpg==0.29.0
python-multipart==0.0.6
openpyxl==3.1.2
pandas==2.2.2
PyMuPDF==1.22.5
PyMuPDF==1.22.5
httpx==0.27.0
xlsxwriter==3.2.0

View File

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

View File

@@ -1,83 +0,0 @@
# parametros.py
from fastapi import APIRouter, HTTPException, Depends
from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_session
from models import AliquotaUF, ParametrosFormula, SelicMensal
from typing import List
from pydantic import BaseModel
import datetime
router = APIRouter()
# === Schemas ===
class AliquotaUFSchema(BaseModel):
uf: str
exercicio: int
aliquota: float
class Config:
orm_mode = True
class ParametrosFormulaSchema(BaseModel):
nome: str
formula: str
campos: str
class Config:
orm_mode = True
class SelicMensalSchema(BaseModel):
mes: str # 'YYYY-MM'
fator: float
class Config:
orm_mode = True
# === Rotas ===
@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()
if existente:
existente.aliquota = aliq.aliquota
else:
novo = AliquotaUF(**aliq.dict())
db.add(novo)
db.commit()
return {"status": "ok"}
@router.get("/parametros/formulas", response_model=List[ParametrosFormulaSchema])
def listar_formulas(db: AsyncSession = Depends(get_session)):
return db.query(ParametrosFormula).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()
if existente:
existente.formula = form.formula
existente.campos = form.campos
else:
novo = ParametrosFormula(**form.dict())
db.add(novo)
db.commit()
return {"status": "ok"}
@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()
@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
else:
novo = SelicMensal(**selic.dict())
db.add(novo)
db.commit()
return {"status": "ok"}

View File

@@ -1,107 +0,0 @@
{% extends "index.html" %}
{% 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>
<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>
</div>
<div style="flex: 1;">
<h4>Valor Médio de Tributos com ICMS</h4>
<canvas id="graficoValor"></canvas>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></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 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 }
},
scales: {
y: {
beginAtZero: true,
title: { display: true, text: 'R$' }
}
}
}
});
</script>
{% endblock %}

View File

@@ -1,26 +0,0 @@
{% extends "index.html" %}
{% block title %}Parâmetros de Cálculo{% endblock %}
{% block content %}
<h1>⚙️ Parâmetros</h1>
<form method="post">
<label for="aliquota_icms">Alíquota de ICMS (%):</label><br>
<input type="number" step="0.01" name="aliquota_icms" id="aliquota_icms" value="{{ parametros.aliquota_icms or '' }}" required/><br><br>
<label for="formula_pis">Fórmula PIS:</label><br>
<input type="text" name="formula_pis" id="formula_pis" value="{{ parametros.formula_pis or '' }}" required/><br><br>
<label for="formula_cofins">Fórmula COFINS:</label><br>
<input type="text" name="formula_cofins" id="formula_cofins" value="{{ parametros.formula_cofins or '' }}" required/><br><br>
<button type="submit" style="padding: 10px 20px; background: #2563eb; color: white; border: none; border-radius: 4px; cursor: pointer;">
Salvar Parâmetros
</button>
</form>
{% if mensagem %}
<div style="margin-top: 20px; background: #e0f7fa; padding: 10px; border-left: 4px solid #2563eb;">
{{ mensagem }}
</div>
{% endif %}
{% endblock %}

View File

@@ -1,38 +0,0 @@
{% extends "index.html" %}
{% block title %}Relatórios{% endblock %}
{% 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>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #2563eb; color: white;">
<th style="padding: 10px;">Cliente</th>
<th>Data</th>
<th>Valor Total</th>
<th>ICMS na Base</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% 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>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -1,184 +0,0 @@
{% extends "index.html" %}
{% block title %}Upload de Faturas{% endblock %}
{% block content %}
<h1 style="font-size: 1.5rem; margin-bottom: 1rem;">📤 Upload de Faturas</h1>
<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>
<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-success" onclick="baixarPlanilha()">📅 Abrir Planilha</button>
<button class="btn btn-success" onclick="gerarRelatorio()">📊 Gerar Relatório</button>
</div>
<table>
<thead>
<tr>
<th>Arquivo</th>
<th>Status</th>
<th>Mensagem</th>
</tr>
</thead>
<tbody id="file-table">
<tr><td colspan="3" style="text-align: center; color: #aaa;">Nenhum arquivo selecionado.</td></tr>
</tbody>
</table>
<script>
let arquivos = [];
let statusInterval = null;
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 => `
<tr>
<td>${file.nome}</td>
<td class="${file.status === 'Concluido' ? 'status-ok' : file.status === 'Erro' ? 'status-error' : 'status-warn'}">${file.status}</td>
<td>${file.mensagem || '---'}</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));
try {
await fetch("/upload-files", { method: "POST", body: formData });
await fetch("/process-queue", { method: "POST" });
arquivos = [];
statusInterval = setInterval(updateStatus, 1000);
} catch (err) {
alert("Erro ao processar faturas.");
} finally {
setTimeout(() => {
btn.innerText = "Processar Faturas";
btn.disabled = false;
}, 1500);
}
}
async function updateStatus() {
const res = await fetch("/get-status");
const data = await res.json();
renderTable(data.files);
if (!data.is_processing && statusInterval) {
clearInterval(statusInterval);
}
}
function limpar() {
fetch("/clear-all", { method: "POST" });
arquivos = [];
renderTable();
}
function baixarPlanilha() {
window.open('/export-excel', '_blank');
}
function gerarRelatorio() {
window.open('/generate-report', '_blank');
}
const dropZone = document.getElementById('upload-box');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
handleFiles(e.dataTransfer.files);
});
window.addEventListener('DOMContentLoaded', updateStatus);
</script>
<style>
.upload-box {
background: #fff;
border: 2px dashed #ccc;
padding: 2rem;
text-align: center;
border-radius: 10px;
margin-bottom: 2rem;
}
.upload-box.dragover {
background-color: #eef2ff;
border-color: #4361ee;
}
.buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 2rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease-in-out;
}
.btn:hover { opacity: 0.9; }
.btn-primary { background-color: #4361ee; color: white; }
.btn-success { background-color: #198754; color: white; }
.btn-danger { background-color: #dc3545; color: white; }
table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 0 10px rgba(0,0,0,0.05);
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background-color: #f2f2f2;
font-size: 0.85rem;
color: #555;
text-transform: uppercase;
letter-spacing: 1px;
}
.status-ok { color: #198754; }
.status-error { color: #dc3545; }
.status-warn { color: #ffc107; }
</style>
{% endblock %}

Binary file not shown.