Compare commits
54 Commits
e30527a3c2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b64068cfb6 | |||
| 98c6cf2363 | |||
| f6c8943d4e | |||
| 5a9fb44bdb | |||
| e6c0155758 | |||
| fb08efed1d | |||
| bc05faafea | |||
| 4d2fcff4a8 | |||
| 950eb2a826 | |||
| bcf9861e97 | |||
| 3cfd3d3452 | |||
| 7f659a0058 | |||
| 291eec35a8 | |||
| 5cf4fe0af3 | |||
| 60fe5b3995 | |||
| d8db2a60e5 | |||
| b51eeac014 | |||
| f639f306be | |||
| a024125982 | |||
| 5eac7782a1 | |||
| e7c2a64714 | |||
| e9ed45ba21 | |||
| 73d51e5938 | |||
| 06a7f17e8d | |||
| 6f69e6100a | |||
| a522926166 | |||
| b1edae62b5 | |||
| afe6228eec | |||
| 597949191b | |||
| 6a3d44ba62 | |||
| 8a9d452160 | |||
| e8a8401483 | |||
| 86bbe1aa55 | |||
| 6b92420cef | |||
| f21858d100 | |||
| 7ce2fe158d | |||
| da25e719be | |||
| ea05d69760 | |||
| f3c2b08a69 | |||
| d863d7f9e2 | |||
| dc60bb56a3 | |||
| 3ec0d04a56 | |||
|
|
f19d9658f0 | ||
|
|
a4e5966f54 | ||
|
|
928d92bd05 | ||
|
|
cd64877237 | ||
|
|
8a5880e5fa | ||
|
|
6431bcaa82 | ||
|
|
de7382846c | ||
|
|
6ecc8b22a5 | ||
|
|
e9d11223a7 | ||
|
|
72c2ca0aa9 | ||
|
|
cddb37ab91 | ||
|
|
d31a3cc822 |
@@ -17,7 +17,7 @@ steps:
|
|||||||
port: 22
|
port: 22
|
||||||
source: .
|
source: .
|
||||||
target: /home/app_fatura_homolog
|
target: /home/app_fatura_homolog
|
||||||
rm: true
|
rm: false
|
||||||
|
|
||||||
- name: restart homolog container
|
- name: restart homolog container
|
||||||
image: appleboy/drone-ssh
|
image: appleboy/drone-ssh
|
||||||
@@ -27,9 +27,9 @@ steps:
|
|||||||
password: F6tC5tCh29XQRpzp
|
password: F6tC5tCh29XQRpzp
|
||||||
port: 22
|
port: 22
|
||||||
script:
|
script:
|
||||||
- docker rm -f FaturasHomolog || true
|
|
||||||
- cd /home/app_fatura_homolog
|
- cd /home/app_fatura_homolog
|
||||||
- docker compose up -d
|
- docker compose -f docker-compose-homolog.yml down
|
||||||
|
- docker compose -f docker-compose-homolog.yml up -d --build
|
||||||
|
|
||||||
---
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
@@ -63,4 +63,3 @@ steps:
|
|||||||
- docker rm -f Faturas || true
|
- docker rm -f Faturas || true
|
||||||
- cd /home/app_fatura
|
- cd /home/app_fatura
|
||||||
- docker compose up -d
|
- docker compose up -d
|
||||||
|
|
||||||
|
|||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
.vscode/
|
||||||
|
uploads/
|
||||||
BIN
.main.py.swn
BIN
.main.py.swn
Binary file not shown.
BIN
.main.py.swo
BIN
.main.py.swo
Binary file not shown.
BIN
.main.py.swp
BIN
.main.py.swp
Binary file not shown.
@@ -19,4 +19,4 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
EXPOSE 5000
|
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"]
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
# trigger novo build Drone CI
|
# trigger novo build Drone CI
|
||||||
# teste de produção
|
# 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.
Binary file not shown.
Binary file not shown.
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from models import ParametrosFormula, SelicMensal
|
from app.models import ParametrosFormula, SelicMensal
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
import re
|
import re
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
from contextlib import contextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import os
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
# database.py
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
DATABASE_URL = "postgresql+asyncpg://fatura:102030@ic-postgresql-FtOY:5432/producao"
|
|
||||||
|
|
||||||
async_engine = create_async_engine(DATABASE_URL, echo=False, future=True)
|
engine = create_async_engine(DATABASE_URL)
|
||||||
AsyncSessionLocal = sessionmaker(bind=async_engine, class_=AsyncSession, expire_on_commit=False)
|
AsyncSessionLocal = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
async def get_session():
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|||||||
4
app/git-commit.ps1
Normal file
4
app/git-commit.ps1
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
$message = Read-Host "Digite a descrição do commit"
|
||||||
|
git add .
|
||||||
|
git commit -m "$message"
|
||||||
|
git push origin main
|
||||||
@@ -29,14 +29,19 @@ def extrair_dados(texto_final):
|
|||||||
|
|
||||||
nota_fiscal = extrair_seguro(r'NOTA FISCAL Nº\s*(\d+)', texto_final)
|
nota_fiscal = extrair_seguro(r'NOTA FISCAL Nº\s*(\d+)', texto_final)
|
||||||
|
|
||||||
|
# --- Unidade Consumidora (UC): 8–12 dígitos, SEM hífen ---
|
||||||
uc = extrair_seguro([
|
uc = extrair_seguro([
|
||||||
r'(\d{7,10}-\d)',
|
r'UNIDADE\s*CONSUMIDORA\D*?(\d{8,12})',
|
||||||
r'UNIDADE\s+CONSUMIDORA\s*[:\-]?\s*(\d{6,})',
|
r'\bUC\D*?(\d{8,12})',
|
||||||
r'(\d{6,})\s+FAZENDA',
|
r'INSTALA[ÇC][ÃA]O\D*?(\d{8,12})',
|
||||||
r'(\d{6,})\s+AVENIDA',
|
|
||||||
r'(\d{6,})\s+RUA'
|
|
||||||
], texto_final)
|
], texto_final)
|
||||||
|
|
||||||
|
# fallback: maior sequência "solta" de 8–10 dígitos sem hífen
|
||||||
|
if not uc:
|
||||||
|
seqs = re.findall(r'(?<!\d)(\d{8,10})(?![\d-])', texto_final)
|
||||||
|
if seqs:
|
||||||
|
uc = max(seqs, key=len)
|
||||||
|
|
||||||
logging.debug("TEXTO PDF:\n" + texto_final)
|
logging.debug("TEXTO PDF:\n" + texto_final)
|
||||||
|
|
||||||
referencia = extrair_seguro([
|
referencia = extrair_seguro([
|
||||||
|
|||||||
764
app/main.py
764
app/main.py
@@ -1,89 +1,338 @@
|
|||||||
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
from fastapi import FastAPI, Request, UploadFile, File
|
from fastapi import FastAPI, HTTPException, Request, UploadFile, File, Depends, Form
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import os, shutil
|
import os, shutil
|
||||||
|
from sqlalchemy import text
|
||||||
|
from datetime import date
|
||||||
|
import re
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import pandas as pd
|
from app.models import ParametrosFormula
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from models import Fatura
|
from app.models import Fatura
|
||||||
from models import ParametrosFormula
|
from app.processor import (
|
||||||
from fastapi import Form
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from processor import (
|
|
||||||
fila_processamento,
|
fila_processamento,
|
||||||
processar_em_lote,
|
processar_em_lote,
|
||||||
status_arquivos,
|
status_arquivos,
|
||||||
limpar_arquivos_processados
|
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()
|
app = FastAPI()
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.state.templates = templates
|
||||||
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
|
|
||||||
UPLOAD_DIR = "uploads/temp"
|
UPLOAD_DIR = os.path.join("app", "uploads", "temp")
|
||||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
def _parse_referencia(ref: str):
|
||||||
|
"""Aceita 'JAN/2024', '01/2024', '202401' etc. Retorna (ano, mes)."""
|
||||||
|
meses = {'JAN':1,'FEV':2,'MAR':3,'ABR':4,'MAI':5,'JUN':6,'JUL':7,'AGO':8,'SET':9,'OUT':10,'NOV':11,'DEZ':12}
|
||||||
|
ref = (ref or "").strip().upper()
|
||||||
|
if "/" in ref:
|
||||||
|
a, b = [p.strip() for p in ref.split("/", 1)]
|
||||||
|
mes = meses.get(a, None)
|
||||||
|
if mes is None:
|
||||||
|
mes = int(re.sub(r"\D", "", a) or 1)
|
||||||
|
ano = int(re.sub(r"\D", "", b) or 0)
|
||||||
|
if ano < 100:
|
||||||
|
ano += 2000
|
||||||
|
else:
|
||||||
|
num = re.sub(r"\D", "", ref)
|
||||||
|
if len(num) >= 6:
|
||||||
|
ano, mes = int(num[:4]), int(num[4:6])
|
||||||
|
elif len(num) == 4:
|
||||||
|
ano, mes = int(num), 1
|
||||||
|
else:
|
||||||
|
ano, mes = 0, 0
|
||||||
|
return ano, mes
|
||||||
|
|
||||||
|
async def _carregar_selic_map(session):
|
||||||
|
res = await session.execute(text("SELECT ano, mes, percentual FROM faturas.selic_mensal"))
|
||||||
|
rows = res.mappings().all()
|
||||||
|
return {(int(r["ano"]), int(r["mes"])): float(r["percentual"]) for r in rows}
|
||||||
|
|
||||||
|
def _fator_selic_from_map(selic_map: dict, ano_inicio: int, mes_inicio: int, hoje: date) -> float:
|
||||||
|
try:
|
||||||
|
ano, mes = int(ano_inicio), int(mes_inicio)
|
||||||
|
except Exception:
|
||||||
|
return 1.0
|
||||||
|
if ano > hoje.year or (ano == hoje.year and mes > hoje.month):
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
fator = 1.0
|
||||||
|
while (ano < hoje.year) or (ano == hoje.year and mes <= hoje.month):
|
||||||
|
perc = selic_map.get((ano, mes))
|
||||||
|
if perc is not None:
|
||||||
|
fator *= (1 + (perc / 100.0))
|
||||||
|
mes += 1
|
||||||
|
if mes > 12:
|
||||||
|
mes = 1
|
||||||
|
ano += 1
|
||||||
|
return fator
|
||||||
|
|
||||||
|
|
||||||
|
def _avaliar_formula(texto_formula: str | None, contexto: dict) -> float:
|
||||||
|
if not texto_formula:
|
||||||
|
return 0.0
|
||||||
|
expr = str(texto_formula)
|
||||||
|
|
||||||
|
# Substitui nomes de campos por valores numéricos (None -> 0)
|
||||||
|
for campo, valor in contexto.items():
|
||||||
|
v = valor
|
||||||
|
if v is None or v == "":
|
||||||
|
v = 0
|
||||||
|
# aceita vírgula como decimal vindo do banco
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = v.replace(".", "").replace(",", ".") if re.search(r"[0-9],[0-9]", v) else v
|
||||||
|
# nome do campo escapado na regex
|
||||||
|
pat = rf'\b{re.escape(str(campo))}\b'
|
||||||
|
|
||||||
|
# normaliza o valor para número; se não der, vira 0
|
||||||
|
val = v
|
||||||
|
if val is None or val == "":
|
||||||
|
num = 0.0
|
||||||
|
else:
|
||||||
|
if isinstance(val, str):
|
||||||
|
# troca vírgula decimal e remove separador de milhar simples
|
||||||
|
val_norm = val.replace(".", "").replace(",", ".")
|
||||||
|
else:
|
||||||
|
val_norm = val
|
||||||
|
try:
|
||||||
|
num = float(val_norm)
|
||||||
|
except Exception:
|
||||||
|
num = 0.0
|
||||||
|
|
||||||
|
# usa lambda para evitar interpretação de backslashes no replacement
|
||||||
|
expr = re.sub(pat, lambda m: str(num), expr)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
return float(eval(expr, {"__builtins__": {}}, {}))
|
||||||
|
except Exception:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def dashboard(request: Request):
|
async def dashboard(request: Request, cliente: str | None = None):
|
||||||
indicadores = [
|
print("DBG /: inicio", flush=True)
|
||||||
{"titulo": "Total de Faturas", "valor": 124},
|
try:
|
||||||
{"titulo": "Faturas com ICMS", "valor": "63%"},
|
async with AsyncSessionLocal() as session:
|
||||||
{"titulo": "Valor Total", "valor": "R$ 280.000,00"},
|
print("DBG /: abrindo sessão", flush=True)
|
||||||
]
|
|
||||||
|
|
||||||
analise_stf = {
|
r = await session.execute(text("""
|
||||||
"antes": {"percentual_com_icms": 80, "media_valor": 1200},
|
SELECT id, nome_fantasia
|
||||||
"depois": {"percentual_com_icms": 20, "media_valor": 800},
|
FROM faturas.clientes
|
||||||
}
|
WHERE ativo = TRUE
|
||||||
|
ORDER BY nome_fantasia
|
||||||
|
"""))
|
||||||
|
clientes = [{"id": id_, "nome": nome} for id_, nome in r.fetchall()]
|
||||||
|
|
||||||
|
print(f"DBG /: clientes={len(clientes)}", flush=True)
|
||||||
|
|
||||||
|
# Fórmulas
|
||||||
|
fp = await session.execute(text("""
|
||||||
|
SELECT formula FROM faturas.parametros_formula
|
||||||
|
WHERE nome = 'Cálculo PIS sobre ICMS' AND ativo = TRUE LIMIT 1
|
||||||
|
"""))
|
||||||
|
formula_pis = fp.scalar_one_or_none()
|
||||||
|
fc = await session.execute(text("""
|
||||||
|
SELECT formula FROM faturas.parametros_formula
|
||||||
|
WHERE nome = 'Cálculo COFINS sobre ICMS' AND ativo = TRUE LIMIT 1
|
||||||
|
"""))
|
||||||
|
formula_cofins = fc.scalar_one_or_none()
|
||||||
|
print(f"DBG /: tem_formulas pis={bool(formula_pis)} cofins={bool(formula_cofins)}", flush=True)
|
||||||
|
|
||||||
|
sql = "SELECT * FROM faturas.faturas"
|
||||||
|
params = {}
|
||||||
|
if cliente:
|
||||||
|
sql += " WHERE cliente_id = :cliente"
|
||||||
|
params["cliente"] = cliente
|
||||||
|
print("DBG /: SQL faturas ->", sql, params, flush=True)
|
||||||
|
|
||||||
|
ftrs = (await session.execute(text(sql), params)).mappings().all()
|
||||||
|
print(f"DBG /: total_faturas={len(ftrs)}", flush=True)
|
||||||
|
|
||||||
|
# ===== KPIs e Séries para o dashboard =====
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
total_faturas = len(ftrs)
|
||||||
|
qtd_icms_na_base = 0
|
||||||
|
soma_corrigida = 0.0
|
||||||
|
hoje = date.today()
|
||||||
|
selic_map = await _carregar_selic_map(session)
|
||||||
|
|
||||||
|
# Séries e somatórios comerciais
|
||||||
|
serie_mensal = defaultdict(float) # {(ano, mes): valor_corrigido}
|
||||||
|
sum_por_dist = defaultdict(float) # {"distribuidora": valor_corrigido}
|
||||||
|
somatorio_v_total = 0.0
|
||||||
|
contagem_com_icms = 0
|
||||||
|
|
||||||
|
for f in ftrs:
|
||||||
|
ctx = dict(f)
|
||||||
|
|
||||||
|
# PIS/COFINS sobre ICMS
|
||||||
|
v_pis = _avaliar_formula(formula_pis, ctx)
|
||||||
|
v_cof = _avaliar_formula(formula_cofins, ctx)
|
||||||
|
v_total = max(0.0, float(v_pis or 0) + float(v_cof or 0))
|
||||||
|
|
||||||
|
# % de faturas com ICMS na base
|
||||||
|
if (v_pis or 0) > 0:
|
||||||
|
qtd_icms_na_base += 1
|
||||||
|
contagem_com_icms += 1
|
||||||
|
|
||||||
|
# referência -> (ano,mes)
|
||||||
|
try:
|
||||||
|
ano, mes = _parse_referencia(f.get("referencia"))
|
||||||
|
except Exception:
|
||||||
|
ano, mes = hoje.year, hoje.month
|
||||||
|
|
||||||
|
# SELIC
|
||||||
|
fator = _fator_selic_from_map(selic_map, ano, mes, hoje)
|
||||||
|
valor_corrigido = v_total * fator
|
||||||
|
|
||||||
|
soma_corrigida += valor_corrigido
|
||||||
|
somatorio_v_total += v_total
|
||||||
|
|
||||||
|
# séries
|
||||||
|
serie_mensal[(ano, mes)] += valor_corrigido
|
||||||
|
dist = (f.get("distribuidora") or "").strip() or "Não informado"
|
||||||
|
sum_por_dist[dist] += valor_corrigido
|
||||||
|
|
||||||
|
percentual_icms_base = (qtd_icms_na_base / total_faturas * 100.0) if total_faturas else 0.0
|
||||||
|
valor_restituicao_corrigida = soma_corrigida
|
||||||
|
valor_medio_com_icms = (somatorio_v_total / contagem_com_icms) if contagem_com_icms else 0.0
|
||||||
|
|
||||||
|
# total de clientes (distinct já carregado)
|
||||||
|
total_clientes = len(clientes)
|
||||||
|
|
||||||
|
# Série mensal – últimos 12 meses
|
||||||
|
ultimos = []
|
||||||
|
a, m = hoje.year, hoje.month
|
||||||
|
for _ in range(12):
|
||||||
|
ultimos.append((a, m))
|
||||||
|
m -= 1
|
||||||
|
if m == 0:
|
||||||
|
m = 12; a -= 1
|
||||||
|
ultimos.reverse()
|
||||||
|
|
||||||
|
serie_mensal_labels = [f"{mes:02d}/{ano}" for (ano, mes) in ultimos]
|
||||||
|
serie_mensal_valores = [round(serie_mensal.get((ano, mes), 0.0), 2) for (ano, mes) in ultimos]
|
||||||
|
|
||||||
|
# Top 5 distribuidoras
|
||||||
|
top5 = sorted(sum_por_dist.items(), key=lambda kv: kv[1], reverse=True)[:5]
|
||||||
|
top5_labels = [k for k, _ in top5]
|
||||||
|
top5_valores = [round(v, 2) for _, v in top5]
|
||||||
|
|
||||||
|
print("DBG /: calculos OK", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
print("DBG /: render template", flush=True)
|
||||||
|
return templates.TemplateResponse("dashboard.html", {
|
||||||
|
"request": request,
|
||||||
|
"clientes": clientes,
|
||||||
|
"cliente_atual": cliente or "",
|
||||||
|
"total_faturas": total_faturas,
|
||||||
|
"valor_restituicao_corrigida": valor_restituicao_corrigida,
|
||||||
|
"percentual_icms_base": percentual_icms_base,
|
||||||
|
|
||||||
|
# Novos dados para o template
|
||||||
|
"total_clientes": total_clientes,
|
||||||
|
"valor_medio_com_icms": valor_medio_com_icms,
|
||||||
|
"situacao_atual_percent": percentual_icms_base, # para gráfico de alerta
|
||||||
|
"serie_mensal_labels": serie_mensal_labels,
|
||||||
|
"serie_mensal_valores": serie_mensal_valores,
|
||||||
|
"top5_labels": top5_labels,
|
||||||
|
"top5_valores": top5_valores,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print("ERR /:", e, flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
# Página de erro amigável (sem derrubar servidor)
|
||||||
|
return HTMLResponse(
|
||||||
|
f"<pre style='padding:16px;color:#b91c1c;background:#fff1f2'>Falha no dashboard:\n{e}</pre>",
|
||||||
|
status_code=500
|
||||||
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse("dashboard.html", {
|
|
||||||
"request": request,
|
|
||||||
"cliente_atual": "",
|
|
||||||
"clientes": ["Cliente A", "Cliente B"],
|
|
||||||
"indicadores": indicadores,
|
|
||||||
"analise_stf": analise_stf
|
|
||||||
})
|
|
||||||
|
|
||||||
@app.get("/upload", response_class=HTMLResponse)
|
@app.get("/upload", response_class=HTMLResponse)
|
||||||
def upload_page(request: Request):
|
def upload_page(request: Request):
|
||||||
return templates.TemplateResponse("upload.html", {"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)
|
@app.get("/relatorios", response_class=HTMLResponse)
|
||||||
def relatorios_page(request: Request):
|
async def relatorios_page(request: Request, cliente: str | None = Query(None)):
|
||||||
return templates.TemplateResponse("relatorios.html", {"request": request})
|
|
||||||
|
|
||||||
@app.get("/parametros", response_class=HTMLResponse)
|
|
||||||
async def parametros_page(request: Request):
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(select(ParametrosFormula).limit(1))
|
# Carregar clientes ativos para o combo
|
||||||
parametros = result.scalar_one_or_none()
|
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]
|
||||||
|
|
||||||
return templates.TemplateResponse("parametros.html", {
|
# 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,
|
"request": request,
|
||||||
"parametros": parametros or SimpleNamespace(
|
"clientes": clientes,
|
||||||
aliquota_icms=None,
|
"cliente_selecionado": cliente or "",
|
||||||
incluir_icms=True,
|
"faturas": faturas
|
||||||
incluir_pis=True,
|
|
||||||
incluir_cofins=True
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
@app.post("/upload-files")
|
@app.post("/upload-files")
|
||||||
async def upload_files(files: list[UploadFile] = File(...)):
|
async def upload_files(
|
||||||
|
cliente_id: str = Form(...),
|
||||||
|
files: list[UploadFile] = File(...)
|
||||||
|
):
|
||||||
for file in files:
|
for file in files:
|
||||||
temp_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}_{file.filename}")
|
temp_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}_{file.filename}")
|
||||||
with open(temp_path, "wb") as f:
|
with open(temp_path, "wb") as f:
|
||||||
shutil.copyfileobj(file.file, f)
|
shutil.copyfileobj(file.file, f)
|
||||||
await fila_processamento.put({
|
await fila_processamento.put({
|
||||||
"caminho_pdf": temp_path,
|
"caminho_pdf": temp_path,
|
||||||
"nome_original": file.filename
|
"nome_original": file.filename,
|
||||||
|
"cliente_id": cliente_id
|
||||||
})
|
})
|
||||||
return {"message": "Arquivos enviados para fila"}
|
return {"message": "Arquivos enviados para fila"}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/process-queue")
|
@app.post("/process-queue")
|
||||||
async def process_queue():
|
async def process_queue():
|
||||||
resultados = await processar_em_lote()
|
resultados = await processar_em_lote()
|
||||||
@@ -93,14 +342,27 @@ async def process_queue():
|
|||||||
async def get_status():
|
async def get_status():
|
||||||
files = []
|
files = []
|
||||||
for nome, status in status_arquivos.items():
|
for nome, status in status_arquivos.items():
|
||||||
files.append({
|
if isinstance(status, dict):
|
||||||
"nome": nome,
|
files.append({
|
||||||
"status": status,
|
"nome": nome,
|
||||||
"mensagem": "---" if status == "Concluído" else status
|
"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()
|
is_processing = not fila_processamento.empty()
|
||||||
return JSONResponse(content={"is_processing": is_processing, "files": files})
|
return JSONResponse(content={"is_processing": is_processing, "files": files})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/clear-all")
|
@app.post("/clear-all")
|
||||||
async def clear_all():
|
async def clear_all():
|
||||||
limpar_arquivos_processados()
|
limpar_arquivos_processados()
|
||||||
@@ -109,89 +371,333 @@ async def clear_all():
|
|||||||
return {"message": "Fila e arquivos limpos"}
|
return {"message": "Fila e arquivos limpos"}
|
||||||
|
|
||||||
@app.get("/export-excel")
|
@app.get("/export-excel")
|
||||||
async def export_excel():
|
async def export_excel(
|
||||||
async with AsyncSessionLocal() as session:
|
tipo: str = Query("geral", pattern="^(geral|exclusao_icms|aliquota_icms)$"),
|
||||||
result = await session.execute(select(Fatura))
|
cliente: str | None = Query(None)
|
||||||
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"}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.post("/parametros", response_class=HTMLResponse)
|
|
||||||
async def salvar_parametros(
|
|
||||||
request: Request,
|
|
||||||
aliquota_icms: float = Form(...),
|
|
||||||
formula_pis: str = Form(...),
|
|
||||||
formula_cofins: str = Form(...)
|
|
||||||
):
|
):
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
result = await session.execute(select(ParametrosFormula).limit(1))
|
# 1) Faturas
|
||||||
existente = result.scalar_one_or_none()
|
stmt = select(Fatura)
|
||||||
|
if cliente:
|
||||||
|
stmt = stmt.where(Fatura.cliente_id == cliente)
|
||||||
|
faturas = (await session.execute(stmt)).scalars().all()
|
||||||
|
|
||||||
if existente:
|
# 2) Mapa de alíquotas cadastradas (UF/ano)
|
||||||
existente.aliquota_icms = aliquota_icms
|
aliq_rows = (await session.execute(select(AliquotaUF))).scalars().all()
|
||||||
existente.incluir_icms = 1
|
aliq_map = {(r.uf.upper(), int(r.exercicio)): float(r.aliq_icms) for r in aliq_rows}
|
||||||
existente.incluir_pis = 1
|
|
||||||
existente.incluir_cofins = 1
|
|
||||||
existente.formula = f"PIS={formula_pis};COFINS={formula_cofins}"
|
|
||||||
mensagem = "Parâmetros atualizados com sucesso."
|
|
||||||
else:
|
|
||||||
novo = ParametrosFormula(
|
|
||||||
aliquota_icms=aliquota_icms,
|
|
||||||
incluir_icms=1,
|
|
||||||
incluir_pis=1,
|
|
||||||
incluir_cofins=1,
|
|
||||||
formula=f"PIS={formula_pis};COFINS={formula_cofins}"
|
|
||||||
)
|
|
||||||
session.add(novo)
|
|
||||||
mensagem = "Parâmetros salvos com sucesso."
|
|
||||||
|
|
||||||
await session.commit()
|
dados = []
|
||||||
|
|
||||||
parametros = SimpleNamespace(
|
if tipo == "aliquota_icms":
|
||||||
aliquota_icms=aliquota_icms,
|
for f in faturas:
|
||||||
incluir_icms=1,
|
uf = (f.estado or "").strip().upper()
|
||||||
incluir_pis=1,
|
ano, _ = _parse_referencia(f.referencia or "")
|
||||||
incluir_cofins=1,
|
aliq_nf = float(f.icms_aliq or 0.0)
|
||||||
formula_pis=formula_pis,
|
aliq_cad = aliq_map.get((uf, ano))
|
||||||
formula_cofins=formula_cofins
|
diff_pp = (aliq_nf - aliq_cad) if aliq_cad is not None else None
|
||||||
|
confere = (abs(diff_pp) < 1e-6) if diff_pp is not None else None
|
||||||
|
|
||||||
|
dados.append({
|
||||||
|
"Cliente": f.nome,
|
||||||
|
"UF (fatura)": uf,
|
||||||
|
"Exercício (ref)": ano,
|
||||||
|
"Referência": f.referencia,
|
||||||
|
"Nota Fiscal": f.nota_fiscal,
|
||||||
|
"ICMS (%) NF": aliq_nf,
|
||||||
|
|
||||||
|
# novas colunas padronizadas
|
||||||
|
"ICMS (%) (UF/Ref)": aliq_cad,
|
||||||
|
"Dif. ICMS (pp)": diff_pp,
|
||||||
|
"ICMS confere?": "SIM" if confere else ("N/D" if confere is None else "NÃO"),
|
||||||
|
|
||||||
|
"Valor Total": f.valor_total,
|
||||||
|
"Distribuidora": f.distribuidora,
|
||||||
|
"Data Processamento": f.data_processamento,
|
||||||
|
"Arquivo PDF": f.arquivo_pdf,
|
||||||
|
})
|
||||||
|
filename = "relatorio_aliquota_icms.xlsx"
|
||||||
|
|
||||||
|
elif tipo == "exclusao_icms":
|
||||||
|
for f in faturas:
|
||||||
|
uf = (f.estado or "").strip().upper()
|
||||||
|
ano, _ = _parse_referencia(f.referencia or "")
|
||||||
|
aliq_nf = float(f.icms_aliq or 0.0)
|
||||||
|
aliq_cad = aliq_map.get((uf, ano))
|
||||||
|
diff_pp = (aliq_nf - aliq_cad) if aliq_cad is not None else None
|
||||||
|
confere = (abs(diff_pp) < 1e-6) if diff_pp is not None else None
|
||||||
|
|
||||||
|
dados.append({
|
||||||
|
"Cliente": f.nome,
|
||||||
|
"UC": f.unidade_consumidora,
|
||||||
|
"Referência": f.referencia,
|
||||||
|
"Valor Total": f.valor_total,
|
||||||
|
"PIS (%)": f.pis_aliq,
|
||||||
|
"ICMS (%)": f.icms_aliq,
|
||||||
|
"COFINS (%)": f.cofins_aliq,
|
||||||
|
"PIS (R$)": f.pis_valor,
|
||||||
|
"ICMS (R$)": f.icms_valor,
|
||||||
|
"COFINS (R$)": f.cofins_valor,
|
||||||
|
"Base PIS (R$)": f.pis_base,
|
||||||
|
"Base ICMS (R$)": f.icms_base,
|
||||||
|
"Base COFINS (R$)": f.cofins_base,
|
||||||
|
|
||||||
|
# novas colunas
|
||||||
|
"ICMS (%) (UF/Ref)": aliq_cad,
|
||||||
|
"Dif. ICMS (pp)": diff_pp,
|
||||||
|
"ICMS confere?": "SIM" if confere else ("N/D" if confere is None else "NÃO"),
|
||||||
|
|
||||||
|
"Consumo (kWh)": f.consumo,
|
||||||
|
"Tarifa": f.tarifa,
|
||||||
|
"Nota Fiscal": f.nota_fiscal,
|
||||||
|
"Arquivo PDF": f.arquivo_pdf,
|
||||||
|
})
|
||||||
|
filename = "relatorio_exclusao_icms.xlsx"
|
||||||
|
|
||||||
|
else: # geral
|
||||||
|
for f in faturas:
|
||||||
|
uf = (f.estado or "").strip().upper()
|
||||||
|
ano, _ = _parse_referencia(f.referencia or "")
|
||||||
|
aliq_nf = float(f.icms_aliq or 0.0)
|
||||||
|
aliq_cad = aliq_map.get((uf, ano))
|
||||||
|
diff_pp = (aliq_nf - aliq_cad) if aliq_cad is not None else None
|
||||||
|
confere = (abs(diff_pp) < 1e-6) if diff_pp is not None else None
|
||||||
|
|
||||||
|
dados.append({
|
||||||
|
"Cliente": f.nome,
|
||||||
|
"UC": f.unidade_consumidora,
|
||||||
|
"Referência": f.referencia,
|
||||||
|
"Nota Fiscal": f.nota_fiscal,
|
||||||
|
"Valor Total": f.valor_total,
|
||||||
|
"ICMS (%)": f.icms_aliq,
|
||||||
|
"ICMS (R$)": f.icms_valor,
|
||||||
|
|
||||||
|
# novas colunas
|
||||||
|
"ICMS (%) (UF/Ref)": aliq_cad,
|
||||||
|
"Dif. ICMS (pp)": diff_pp,
|
||||||
|
"ICMS confere?": "SIM" if confere else ("N/D" if confere is None else "NÃO"),
|
||||||
|
|
||||||
|
"Base ICMS (R$)": f.icms_base,
|
||||||
|
"PIS (%)": f.pis_aliq,
|
||||||
|
"PIS (R$)": f.pis_valor,
|
||||||
|
"Base PIS (R$)": f.pis_base,
|
||||||
|
"COFINS (%)": f.cofins_aliq,
|
||||||
|
"COFINS (R$)": f.cofins_valor,
|
||||||
|
"Base COFINS (R$)": f.cofins_base,
|
||||||
|
"Consumo (kWh)": f.consumo,
|
||||||
|
"Tarifa": f.tarifa,
|
||||||
|
"Distribuidora": f.distribuidora,
|
||||||
|
"Data Processamento": f.data_processamento,
|
||||||
|
"Arquivo PDF": f.arquivo_pdf,
|
||||||
|
})
|
||||||
|
filename = "relatorio_geral.xlsx"
|
||||||
|
|
||||||
|
# 3) Excel em memória
|
||||||
|
output = BytesIO()
|
||||||
|
df = pd.DataFrame(dados)
|
||||||
|
|
||||||
|
# força "Arquivo PDF" a ser a última coluna
|
||||||
|
if "Arquivo PDF" in df.columns:
|
||||||
|
cols = [c for c in df.columns if c != "Arquivo PDF"] + ["Arquivo PDF"]
|
||||||
|
df = df[cols]
|
||||||
|
|
||||||
|
# converte colunas numéricas (percentuais, R$, etc.)
|
||||||
|
percent_cols = ["ICMS (%)", "ICMS (%) (UF/Ref)", "Dif. ICMS (pp)", "PIS (%)", "COFINS (%)"]
|
||||||
|
money_cols = ["Valor Total", "ICMS (R$)", "PIS (R$)", "COFINS (R$)",
|
||||||
|
"Base ICMS (R$)", "Base PIS (R$)", "Base COFINS (R$)"]
|
||||||
|
other_dec6 = ["Tarifa", "Consumo (kWh)"]
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
for col in percent_cols + money_cols + other_dec6:
|
||||||
|
if col in df.columns:
|
||||||
|
df[col] = df[col].map(lambda x: float(x) if isinstance(x, Decimal) else x)
|
||||||
|
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||||
|
|
||||||
|
# --- gera o XLSX ---
|
||||||
|
with pd.ExcelWriter(output, engine="xlsxwriter") as writer:
|
||||||
|
df.to_excel(writer, index=False, sheet_name="Relatório")
|
||||||
|
wb = writer.book
|
||||||
|
ws = writer.sheets["Relatório"]
|
||||||
|
|
||||||
|
fmt_dec6 = wb.add_format({"num_format": "0.000000"})
|
||||||
|
fmt_money6 = wb.add_format({"num_format": "#,##0.000000"})
|
||||||
|
fmt_money2 = wb.add_format({"num_format": "#,##0.00"})
|
||||||
|
|
||||||
|
for col in percent_cols:
|
||||||
|
if col in df.columns:
|
||||||
|
i = df.columns.get_loc(col)
|
||||||
|
ws.set_column(i, i, 14, fmt_dec6)
|
||||||
|
|
||||||
|
for col in money_cols:
|
||||||
|
if col in df.columns:
|
||||||
|
i = df.columns.get_loc(col)
|
||||||
|
ws.set_column(i, i, 14, fmt_money6) # ou fmt_money2 se quiser 2 casas
|
||||||
|
|
||||||
|
for col in other_dec6:
|
||||||
|
if col in df.columns:
|
||||||
|
i = df.columns.get_loc(col)
|
||||||
|
ws.set_column(i, i, 14, fmt_dec6)
|
||||||
|
|
||||||
|
# IMPORTANTE: só aqui, FORA do with
|
||||||
|
output.seek(0)
|
||||||
|
data = output.getvalue()
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||||
|
"Content-Length": str(len(data)),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
return templates.TemplateResponse("parametros.html", {
|
|
||||||
"request": request,
|
from app.parametros import router as parametros_router
|
||||||
"parametros": parametros,
|
app.include_router(parametros_router)
|
||||||
"mensagem": mensagem
|
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}
|
||||||
@@ -1,23 +1,21 @@
|
|||||||
# 📄 models.py
|
# 📄 models.py
|
||||||
from sqlalchemy import Column, String, Integer, Float, DateTime, Text
|
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from database import Base
|
from app.database import Base
|
||||||
|
from sqlalchemy import Boolean
|
||||||
|
from sqlalchemy import Column, Integer, String, Numeric
|
||||||
|
|
||||||
|
|
||||||
class ParametrosFormula(Base):
|
class ParametrosFormula(Base):
|
||||||
__tablename__ = 'parametros_formula'
|
__tablename__ = "parametros_formula"
|
||||||
__table_args__ = {'schema': 'faturas', 'extend_existing': True}
|
__table_args__ = {"schema": "faturas"}
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True)
|
||||||
nome = Column(String)
|
nome = Column(String(50))
|
||||||
formula = Column(Text)
|
formula = Column(Text)
|
||||||
|
ativo = Column(Boolean, default=True)
|
||||||
# Novos campos
|
|
||||||
aliquota_icms = Column(Float)
|
|
||||||
incluir_icms = Column(Integer) # Use Boolean se preferir
|
|
||||||
incluir_pis = Column(Integer)
|
|
||||||
incluir_cofins = Column(Integer)
|
|
||||||
|
|
||||||
class Fatura(Base):
|
class Fatura(Base):
|
||||||
__tablename__ = "faturas"
|
__tablename__ = "faturas"
|
||||||
@@ -28,22 +26,22 @@ class Fatura(Base):
|
|||||||
classificacao_tarifaria = Column("classificacao_tarifaria", String)
|
classificacao_tarifaria = Column("classificacao_tarifaria", String)
|
||||||
unidade_consumidora = Column("unidade_consumidora", String)
|
unidade_consumidora = Column("unidade_consumidora", String)
|
||||||
referencia = Column(String)
|
referencia = Column(String)
|
||||||
valor_total = Column(Float)
|
valor_total = Column(Numeric(18, 6, asdecimal=True))
|
||||||
|
|
||||||
pis_aliq = Column("pis_aliq", Float)
|
pis_aliq = Column(Numeric(8, 6, asdecimal=True))
|
||||||
pis_valor = Column("pis_valor", Float)
|
pis_valor = Column(Numeric(18, 6, asdecimal=True))
|
||||||
pis_base = Column("pis_base", Float)
|
pis_base = Column(Numeric(18, 6, asdecimal=True))
|
||||||
|
|
||||||
icms_aliq = Column("icms_aliq", Float)
|
icms_aliq = Column(Numeric(8, 6, asdecimal=True))
|
||||||
icms_valor = Column("icms_valor", Float)
|
icms_valor = Column(Numeric(18, 6, asdecimal=True))
|
||||||
icms_base = Column("icms_base", Float)
|
icms_base = Column(Numeric(18, 6, asdecimal=True))
|
||||||
|
|
||||||
cofins_aliq = Column("cofins_aliq", Float)
|
cofins_aliq = Column(Numeric(8, 6, asdecimal=True))
|
||||||
cofins_valor = Column("cofins_valor", Float)
|
cofins_valor = Column(Numeric(18, 6, asdecimal=True))
|
||||||
cofins_base = Column("cofins_base", Float)
|
cofins_base = Column(Numeric(18, 6, asdecimal=True))
|
||||||
|
|
||||||
consumo = Column("consumo", Float)
|
consumo = Column(Numeric(14, 6, asdecimal=True))
|
||||||
tarifa = Column("tarifa", Float)
|
tarifa = Column(Numeric(12, 6, asdecimal=True))
|
||||||
|
|
||||||
nota_fiscal = Column(String)
|
nota_fiscal = Column(String)
|
||||||
data_processamento = Column(DateTime, default=datetime.utcnow)
|
data_processamento = Column(DateTime, default=datetime.utcnow)
|
||||||
@@ -52,6 +50,8 @@ class Fatura(Base):
|
|||||||
estado = Column(String)
|
estado = Column(String)
|
||||||
distribuidora = Column(String)
|
distribuidora = Column(String)
|
||||||
link_arquivo = Column("link_arquivo", String)
|
link_arquivo = Column("link_arquivo", String)
|
||||||
|
cliente_id = Column(UUID(as_uuid=True), ForeignKey("faturas.clientes.id"), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class LogProcessamento(Base):
|
class LogProcessamento(Base):
|
||||||
@@ -72,14 +72,25 @@ class AliquotaUF(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
uf = Column(String)
|
uf = Column(String)
|
||||||
exercicio = Column(String)
|
exercicio = Column(Integer)
|
||||||
aliquota = Column(Float)
|
aliq_icms = Column(Numeric(6, 4))
|
||||||
|
|
||||||
class SelicMensal(Base):
|
class SelicMensal(Base):
|
||||||
__tablename__ = "selic_mensal"
|
__tablename__ = "selic_mensal"
|
||||||
__table_args__ = {'schema': 'faturas'}
|
__table_args__ = {'schema': 'faturas'}
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
ano = Column(Integer, primary_key=True)
|
||||||
ano = Column(Integer)
|
mes = Column(Integer, primary_key=True)
|
||||||
mes = Column(Integer)
|
percentual = Column(Numeric(6, 4))
|
||||||
fator = Column(Float)
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
365
app/parametros.py
Normal file
365
app/parametros.py
Normal 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}
|
||||||
227
app/processor.py
227
app/processor.py
@@ -2,14 +2,22 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import httpx
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from utils import extrair_dados_pdf
|
from app.utils import extrair_dados_pdf
|
||||||
from database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from models import Fatura, LogProcessamento
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
UPLOADS_DIR = os.path.join(os.getcwd(), "uploads")
|
UPLOADS_DIR = os.path.join("app", "uploads")
|
||||||
TEMP_DIR = os.path.join(UPLOADS_DIR, "temp")
|
TEMP_DIR = os.path.join(UPLOADS_DIR, "temp")
|
||||||
|
|
||||||
fila_processamento = asyncio.Queue()
|
fila_processamento = asyncio.Queue()
|
||||||
@@ -24,70 +32,229 @@ def remover_arquivo_temp(caminho_pdf):
|
|||||||
logger.warning(f"Falha ao remover arquivo temporário: {e}")
|
logger.warning(f"Falha ao remover arquivo temporário: {e}")
|
||||||
|
|
||||||
def salvar_em_uploads(caminho_pdf_temp, nome_original, nota_fiscal):
|
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:
|
try:
|
||||||
extensao = os.path.splitext(nome_original)[1].lower()
|
extensao = os.path.splitext(nome_original)[1].lower()
|
||||||
nome_destino = f"{nota_fiscal}{extensao}"
|
nome_destino = f"{nota_fiscal}_{uuid.uuid4().hex[:6]}{extensao}"
|
||||||
destino_final = os.path.join(UPLOADS_DIR, nome_destino)
|
destino_final = os.path.join(UPLOADS_DIR, nome_destino)
|
||||||
shutil.copy2(caminho_pdf_temp, destino_final)
|
shutil.copy2(caminho_pdf_temp, destino_final)
|
||||||
return destino_final
|
return destino_final
|
||||||
except Exception as e:
|
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}")
|
logger.error(f"Erro ao salvar em uploads: {e}")
|
||||||
return caminho_pdf_temp
|
return caminho_pdf_temp
|
||||||
|
|
||||||
async def process_single_file(caminho_pdf_temp: str, nome_original: str):
|
async def process_single_file(caminho_pdf_temp: str, nome_original: str, cliente_id: str | None = None):
|
||||||
|
inicio = time.perf_counter()
|
||||||
|
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
try:
|
try:
|
||||||
dados = extrair_dados_pdf(caminho_pdf_temp)
|
dados = extrair_dados_pdf(caminho_pdf_temp)
|
||||||
dados['arquivo_pdf'] = nome_original
|
dados['arquivo_pdf'] = nome_original
|
||||||
|
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
|
_Q6 = Decimal("0.000000")
|
||||||
|
|
||||||
|
def _to_percent_6(x):
|
||||||
|
"""Converte para percent (se vier em fração) e quantiza em 6 casas."""
|
||||||
|
if x is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
v = Decimal(str(x))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
# se vier em fração (ex.: 0.012872), vira 1.2872… (percentual)
|
||||||
|
if Decimal("0") < v <= Decimal("1"):
|
||||||
|
v = v * Decimal("100")
|
||||||
|
return v.quantize(_Q6, rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
def _to_dec6(x):
|
||||||
|
"""Apenas 6 casas, sem % (use para tarifa, bases, etc.)."""
|
||||||
|
if x is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
v = Decimal(str(x))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return v.quantize(_Q6, rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
dados['icms_aliq'] = _to_percent_6(dados.get('icms_aliq'))
|
||||||
|
dados['pis_aliq'] = _to_percent_6(dados.get('pis_aliq'))
|
||||||
|
dados['cofins_aliq'] = _to_percent_6(dados.get('cofins_aliq'))
|
||||||
|
|
||||||
|
# tarifa NÃO é percentual: apenas 6 casas
|
||||||
|
dados['tarifa'] = _to_dec6(dados.get('tarifa'))
|
||||||
|
|
||||||
|
|
||||||
|
# Verifica se a fatura já existe
|
||||||
existente_result = await session.execute(
|
existente_result = await session.execute(
|
||||||
select(Fatura).filter_by(nota_fiscal=dados['nota_fiscal'], unidade_consumidora=dados['unidade_consumidora'])
|
select(Fatura).filter_by(
|
||||||
|
nota_fiscal=dados['nota_fiscal'],
|
||||||
|
unidade_consumidora=dados['unidade_consumidora']
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if existente_result.scalar_one_or_none():
|
if existente_result.scalar_one_or_none():
|
||||||
|
duracao = round(time.perf_counter() - inicio, 2)
|
||||||
remover_arquivo_temp(caminho_pdf_temp)
|
remover_arquivo_temp(caminho_pdf_temp)
|
||||||
return {"status": "Duplicado", "dados": dados}
|
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'])
|
caminho_final = salvar_em_uploads(caminho_pdf_temp, nome_original, dados['nota_fiscal'])
|
||||||
dados['link_arquivo'] = caminho_final
|
dados['link_arquivo'] = caminho_final
|
||||||
|
|
||||||
|
# Salva fatura
|
||||||
|
dados['cliente_id'] = cliente_id
|
||||||
|
if cliente_id:
|
||||||
|
dados['cliente_id'] = cliente_id
|
||||||
fatura = Fatura(**dados)
|
fatura = Fatura(**dados)
|
||||||
session.add(fatura)
|
session.add(fatura)
|
||||||
|
|
||||||
session.add(LogProcessamento(
|
|
||||||
status="Sucesso",
|
|
||||||
mensagem="Fatura processada com sucesso",
|
|
||||||
nome_arquivo=nome_original,
|
|
||||||
acao="PROCESSAMENTO"
|
|
||||||
))
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
remover_arquivo_temp(caminho_pdf_temp)
|
remover_arquivo_temp(caminho_pdf_temp)
|
||||||
return {"status": "Concluído", "dados": dados}
|
duracao = round(time.perf_counter() - inicio, 2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "Concluído",
|
||||||
|
"dados": dados,
|
||||||
|
"tempo": f"{duracao}s"
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
erro_str = traceback.format_exc()
|
||||||
|
duracao = round(time.perf_counter() - inicio, 2)
|
||||||
await session.rollback()
|
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)
|
remover_arquivo_temp(caminho_pdf_temp)
|
||||||
return {"status": "Erro", "mensagem": str(e)}
|
|
||||||
|
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():
|
async def processar_em_lote():
|
||||||
|
import traceback # para exibir erros
|
||||||
resultados = []
|
resultados = []
|
||||||
while not fila_processamento.empty():
|
while not fila_processamento.empty():
|
||||||
item = await fila_processamento.get()
|
item = await fila_processamento.get()
|
||||||
resultado = await process_single_file(item['caminho_pdf'], item['nome_original'])
|
try:
|
||||||
status_arquivos[item['nome_original']] = resultado.get("status", "Erro")
|
resultado = await process_single_file(
|
||||||
resultados.append(resultado)
|
item['caminho_pdf'],
|
||||||
return resultados
|
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():
|
def limpar_arquivos_processados():
|
||||||
status_arquivos.clear()
|
status_arquivos.clear()
|
||||||
while not fila_processamento.empty():
|
while not fila_processamento.empty():
|
||||||
fila_processamento.get_nowait()
|
fila_processamento.get_nowait()
|
||||||
|
|
||||||
|
async def garantir_selic_para_competencia(session, ano, mes):
|
||||||
|
# Verifica se já existe
|
||||||
|
result = await session.execute(select(SelicMensal).filter_by(ano=ano, mes=mes))
|
||||||
|
existente = result.scalar_one_or_none()
|
||||||
|
if existente:
|
||||||
|
return # já tem
|
||||||
|
|
||||||
|
# Busca na API do Banco Central
|
||||||
|
url = (
|
||||||
|
f"https://api.bcb.gov.br/dados/serie/bcdata.sgs.4390/dados?"
|
||||||
|
f"formato=json&dataInicial=01/{mes:02d}/{ano}&dataFinal=30/{mes:02d}/{ano}"
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
dados = resp.json()
|
||||||
|
|
||||||
|
if dados:
|
||||||
|
percentual = float(dados[0]["valor"].replace(",", "."))
|
||||||
|
novo = SelicMensal(ano=ano, mes=mes, percentual=percentual)
|
||||||
|
session.add(novo)
|
||||||
|
await session.commit()
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
fastapi==0.110.0
|
|
||||||
uvicorn[standard]==0.29.0
|
|
||||||
jinja2==3.1.3
|
|
||||||
sqlalchemy==2.0.30
|
|
||||||
asyncpg==0.29.0
|
|
||||||
python-multipart==0.0.6
|
|
||||||
openpyxl==3.1.2
|
|
||||||
pandas==2.2.2
|
|
||||||
PyMuPDF==1.22.5
|
|
||||||
72
app/routes/clientes.py
Normal file
72
app/routes/clientes.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# app/routes/clientes.py
|
||||||
|
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, or_
|
||||||
|
from app.database import get_session
|
||||||
|
from app.models import Cliente
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from uuid import UUID
|
||||||
|
import uuid
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
@router.get("/clientes")
|
||||||
|
async def clientes_page(request: Request):
|
||||||
|
return request.app.state.templates.TemplateResponse("clientes.html", {"request": request})
|
||||||
|
|
||||||
|
class ClienteIn(BaseModel):
|
||||||
|
nome_fantasia: str
|
||||||
|
cnpj: str | None = None
|
||||||
|
ativo: bool = True
|
||||||
|
|
||||||
|
@router.get("/api/clientes")
|
||||||
|
async def listar(
|
||||||
|
busca: str = Query(default="", description="Filtro por nome ou CNPJ"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
stmt = select(Cliente).order_by(Cliente.nome_fantasia)
|
||||||
|
if busca:
|
||||||
|
pattern = f"%{busca}%"
|
||||||
|
stmt = select(Cliente).where(
|
||||||
|
or_(
|
||||||
|
Cliente.nome_fantasia.ilike(pattern),
|
||||||
|
Cliente.cnpj.ilike(pattern),
|
||||||
|
)
|
||||||
|
).order_by(Cliente.nome_fantasia)
|
||||||
|
|
||||||
|
res = await session.execute(stmt)
|
||||||
|
clientes = res.scalars().all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(c.id),
|
||||||
|
"nome_fantasia": c.nome_fantasia,
|
||||||
|
"cnpj": c.cnpj,
|
||||||
|
"ativo": c.ativo,
|
||||||
|
}
|
||||||
|
for c in clientes
|
||||||
|
]
|
||||||
|
|
||||||
|
@router.post("/api/clientes")
|
||||||
|
async def criar_cliente(body: ClienteIn, session: AsyncSession = Depends(get_session)):
|
||||||
|
cliente = Cliente(**body.dict())
|
||||||
|
session.add(cliente)
|
||||||
|
await session.commit()
|
||||||
|
return {"id": str(cliente.id)}
|
||||||
|
|
||||||
|
@router.put("/api/clientes/{id}")
|
||||||
|
async def editar_cliente(id: UUID, body: ClienteIn, session: AsyncSession = Depends(get_session)):
|
||||||
|
await session.execute(
|
||||||
|
Cliente.__table__.update().where(Cliente.id == id).values(**body.dict())
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.delete("/api/clientes/{id}")
|
||||||
|
async def excluir(id: uuid.UUID, session: AsyncSession = Depends(get_session)):
|
||||||
|
obj = await session.get(Cliente, id)
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(404, "Cliente não encontrado")
|
||||||
|
await session.delete(obj)
|
||||||
|
await session.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
from fastapi import APIRouter, Request
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
import os
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
|
||||||
|
|
||||||
# Conexão com o banco de dados PostgreSQL
|
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/faturas")
|
|
||||||
engine = create_engine(DATABASE_URL)
|
|
||||||
|
|
||||||
@router.get("/dashboard")
|
|
||||||
def dashboard(request: Request, cliente: str = None):
|
|
||||||
with engine.connect() as conn:
|
|
||||||
filtros = ""
|
|
||||||
if cliente:
|
|
||||||
filtros = "WHERE nome = :cliente"
|
|
||||||
|
|
||||||
# Clientes únicos
|
|
||||||
clientes_query = text("SELECT DISTINCT nome FROM faturas ORDER BY nome")
|
|
||||||
clientes = [row[0] for row in conn.execute(clientes_query)]
|
|
||||||
|
|
||||||
# Indicadores
|
|
||||||
indicadores = []
|
|
||||||
|
|
||||||
indicadores.append({
|
|
||||||
"titulo": "Faturas com erro",
|
|
||||||
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE erro IS TRUE {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
|
|
||||||
})
|
|
||||||
|
|
||||||
indicadores.append({
|
|
||||||
"titulo": "Faturas com valor total igual a R$ 0",
|
|
||||||
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE total = 0 {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
|
|
||||||
})
|
|
||||||
|
|
||||||
indicadores.append({
|
|
||||||
"titulo": "Clientes únicos",
|
|
||||||
"valor": conn.execute(text(f"SELECT COUNT(DISTINCT nome) FROM faturas {filtros}"), {"cliente": cliente} if cliente else {}).scalar()
|
|
||||||
})
|
|
||||||
|
|
||||||
indicadores.append({
|
|
||||||
"titulo": "Total de faturas",
|
|
||||||
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas {filtros}"), {"cliente": cliente} if cliente else {}).scalar()
|
|
||||||
})
|
|
||||||
|
|
||||||
indicadores.append({
|
|
||||||
"titulo": "Faturas com campos nulos",
|
|
||||||
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE base_pis IS NULL OR base_cofins IS NULL OR base_icms IS NULL {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
|
|
||||||
})
|
|
||||||
|
|
||||||
indicadores.append({
|
|
||||||
"titulo": "Alíquotas zeradas com valores diferentes de zero",
|
|
||||||
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE (aliq_pis = 0 AND pis > 0) OR (aliq_cofins = 0 AND cofins > 0) {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
|
|
||||||
})
|
|
||||||
|
|
||||||
indicadores.append({
|
|
||||||
"titulo": "Faturas com ICMS incluso após decisão STF",
|
|
||||||
"valor": conn.execute(text(f"SELECT COUNT(*) FROM faturas WHERE data_emissao > '2017-03-15' AND base_pis = base_icms {f'AND nome = :cliente' if cliente else ''}"), {"cliente": cliente} if cliente else {}).scalar()
|
|
||||||
})
|
|
||||||
|
|
||||||
indicadores.append({
|
|
||||||
"titulo": "Valor total processado",
|
|
||||||
"valor": conn.execute(text(f"SELECT ROUND(SUM(total), 2) FROM faturas {filtros}"), {"cliente": cliente} if cliente else {}).scalar() or 0
|
|
||||||
})
|
|
||||||
|
|
||||||
# Análise do STF
|
|
||||||
def media_percentual_icms(data_inicio, data_fim):
|
|
||||||
result = conn.execute(text(f"""
|
|
||||||
SELECT
|
|
||||||
ROUND(AVG(CASE WHEN base_pis = base_icms THEN 100.0 ELSE 0.0 END), 2) AS percentual_com_icms,
|
|
||||||
ROUND(AVG(pis + cofins), 2) AS media_valor
|
|
||||||
FROM faturas
|
|
||||||
WHERE data_emissao BETWEEN :inicio AND :fim
|
|
||||||
{f'AND nome = :cliente' if cliente else ''}
|
|
||||||
"""), {"inicio": data_inicio, "fim": data_fim, "cliente": cliente} if cliente else {"inicio": data_inicio, "fim": data_fim}).mappings().first()
|
|
||||||
return result or {"percentual_com_icms": 0, "media_valor": 0}
|
|
||||||
|
|
||||||
analise_stf = {
|
|
||||||
"antes": media_percentual_icms("2000-01-01", "2017-03-15"),
|
|
||||||
"depois": media_percentual_icms("2017-03-16", "2099-12-31")
|
|
||||||
}
|
|
||||||
|
|
||||||
return templates.TemplateResponse("dashboard.html", {
|
|
||||||
"request": request,
|
|
||||||
"clientes": clientes,
|
|
||||||
"cliente_atual": cliente,
|
|
||||||
"indicadores": indicadores,
|
|
||||||
"analise_stf": analise_stf
|
|
||||||
})
|
|
||||||
140
app/routes/dashboard_unused.py
Normal file
140
app/routes/dashboard_unused.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
import os
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
# Usa o avaliador de fórmulas já existente
|
||||||
|
from app.utils import avaliar_formula
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
# Conexão com o banco (use a mesma DATABASE_URL do restante do app)
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
|
||||||
|
def _parse_referencia(ref: str):
|
||||||
|
"""Aceita 'JAN/2024', '01/2024' ou '202401'. Retorna (ano, mes)."""
|
||||||
|
meses = {'JAN':1,'FEV':2,'MAR':3,'ABR':4,'MAI':5,'JUN':6,'JUL':7,'AGO':8,'SET':9,'OUT':10,'NOV':11,'DEZ':12}
|
||||||
|
ref = (ref or "").strip().upper()
|
||||||
|
if "/" in ref:
|
||||||
|
a, b = ref.split("/")
|
||||||
|
if a.isdigit():
|
||||||
|
mes, ano = int(a), int(b)
|
||||||
|
else:
|
||||||
|
mes, ano = meses.get(a, 1), int(b)
|
||||||
|
else:
|
||||||
|
ano, mes = int(ref[:4]), int(ref[4:]) if len(ref) >= 6 else 1
|
||||||
|
return ano, mes
|
||||||
|
|
||||||
|
def _fator_selic_acumulado(conn, ano_inicio, mes_inicio, hoje):
|
||||||
|
selic = conn.execute(text("""
|
||||||
|
SELECT ano, mes, percentual
|
||||||
|
FROM faturas.selic_mensal
|
||||||
|
""")).mappings().all()
|
||||||
|
selic_map = {(r["ano"], r["mes"]): float(r["percentual"]) for r in selic}
|
||||||
|
|
||||||
|
fator = 1.0
|
||||||
|
ano, mes = int(ano_inicio), int(mes_inicio)
|
||||||
|
while (ano < hoje.year) or (ano == hoje.year and mes <= hoje.month):
|
||||||
|
perc = selic_map.get((ano, mes))
|
||||||
|
if perc is not None:
|
||||||
|
fator *= (1 + perc/100.0)
|
||||||
|
mes += 1
|
||||||
|
if mes > 12:
|
||||||
|
mes = 1; ano += 1
|
||||||
|
return fator
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
def dashboard(request: Request, cliente: str | None = None):
|
||||||
|
with engine.begin() as conn:
|
||||||
|
# Lista de clientes (distinct nome)
|
||||||
|
clientes = [r[0] for r in conn.execute(text("""
|
||||||
|
SELECT DISTINCT nome FROM faturas.faturas ORDER BY nome
|
||||||
|
""")).fetchall()]
|
||||||
|
|
||||||
|
# Carrega fórmulas (ativas)
|
||||||
|
formula_pis = conn.execute(text("""
|
||||||
|
SELECT formula FROM faturas.parametros_formula
|
||||||
|
WHERE nome = 'Cálculo PIS sobre ICMS' AND ativo = TRUE
|
||||||
|
LIMIT 1
|
||||||
|
""")).scalar_one_or_none()
|
||||||
|
|
||||||
|
formula_cofins = conn.execute(text("""
|
||||||
|
SELECT formula FROM faturas.parametros_formula
|
||||||
|
WHERE nome = 'Cálculo COFINS sobre ICMS' AND ativo = TRUE
|
||||||
|
LIMIT 1
|
||||||
|
""")).scalar_one_or_none()
|
||||||
|
|
||||||
|
# Carrega faturas (com filtro opcional de cliente)
|
||||||
|
params = {}
|
||||||
|
sql = "SELECT * FROM faturas.faturas"
|
||||||
|
if cliente:
|
||||||
|
sql += " WHERE nome = :cliente"
|
||||||
|
params["cliente"] = cliente
|
||||||
|
|
||||||
|
faturas = conn.execute(text(sql), params).mappings().all()
|
||||||
|
|
||||||
|
|
||||||
|
total_faturas = len(faturas)
|
||||||
|
|
||||||
|
# Cálculos de restituição e % ICMS na base
|
||||||
|
hoje = date.today()
|
||||||
|
soma_corrigida = 0.0
|
||||||
|
qtd_icms_na_base = 0
|
||||||
|
|
||||||
|
for f in faturas:
|
||||||
|
contexto = dict(f) # usa colunas como variáveis da fórmula
|
||||||
|
# PIS sobre ICMS
|
||||||
|
v_pis_icms = avaliar_formula(formula_pis, contexto) if formula_pis else None
|
||||||
|
# COFINS sobre ICMS
|
||||||
|
v_cofins_icms = avaliar_formula(formula_cofins, contexto) if formula_cofins else None
|
||||||
|
|
||||||
|
# Contagem para % ICMS na base: considera PIS_sobre_ICMS > 0
|
||||||
|
if v_pis_icms and float(v_pis_icms) > 0:
|
||||||
|
qtd_icms_na_base += 1
|
||||||
|
|
||||||
|
# Corrigir pela SELIC desde a referência da fatura
|
||||||
|
try:
|
||||||
|
ano, mes = _parse_referencia(f.get("referencia"))
|
||||||
|
fator = _fator_selic_acumulado(conn, ano, mes, hoje)
|
||||||
|
except Exception:
|
||||||
|
fator = 1.0
|
||||||
|
|
||||||
|
valor_bruto = (float(v_pis_icms) if v_pis_icms else 0.0) + (float(v_cofins_icms) if v_cofins_icms else 0.0)
|
||||||
|
soma_corrigida += valor_bruto * fator
|
||||||
|
|
||||||
|
percentual_icms_base = (qtd_icms_na_base / total_faturas * 100.0) if total_faturas else 0.0
|
||||||
|
valor_restituicao_corrigida = soma_corrigida
|
||||||
|
|
||||||
|
# --- Análise STF (mantida) ---
|
||||||
|
def media_percentual_icms(inicio: str, fim: str):
|
||||||
|
# Aproximação: base PIS = base ICMS => configurado como proxy “com ICMS na base”
|
||||||
|
q = text(f"""
|
||||||
|
SELECT
|
||||||
|
ROUND(AVG(CASE WHEN icms_base IS NOT NULL AND pis_base = icms_base THEN 100.0 ELSE 0.0 END), 2) AS percentual_com_icms,
|
||||||
|
ROUND(AVG(COALESCE(pis_valor,0) + COALESCE(cofins_valor,0)), 2) AS media_valor
|
||||||
|
FROM faturas.faturas
|
||||||
|
WHERE data_processamento::date BETWEEN :inicio AND :fim
|
||||||
|
{ "AND nome = :cliente" if cliente else "" }
|
||||||
|
""")
|
||||||
|
params = {"inicio": inicio, "fim": fim}
|
||||||
|
if cliente: params["cliente"] = cliente
|
||||||
|
r = conn.execute(q, params).mappings().first() or {}
|
||||||
|
return {"percentual_com_icms": r.get("percentual_com_icms", 0), "media_valor": r.get("media_valor", 0)}
|
||||||
|
|
||||||
|
analise_stf = {
|
||||||
|
"antes": media_percentual_icms("2000-01-01", "2017-03-15"),
|
||||||
|
"depois": media_percentual_icms("2017-03-16", "2099-12-31")
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates.TemplateResponse("dashboard.html", {
|
||||||
|
"request": request,
|
||||||
|
"clientes": clientes,
|
||||||
|
"cliente_atual": cliente or "",
|
||||||
|
"total_faturas": total_faturas,
|
||||||
|
"valor_restituicao_corrigida": valor_restituicao_corrigida,
|
||||||
|
"percentual_icms_base": percentual_icms_base,
|
||||||
|
"analise_stf": analise_stf
|
||||||
|
})
|
||||||
@@ -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"}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# startup.py
|
# startup.py
|
||||||
import logging
|
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):
|
async def executar_rotinas_iniciais(db):
|
||||||
try:
|
try:
|
||||||
|
|||||||
502
app/templates/clientes.html
Normal file
502
app/templates/clientes.html
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
{% extends "index.html" %}
|
||||||
|
{% block title %}Clientes{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>🧾 Clientes</h1>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin:16px 0;">
|
||||||
|
<input id="busca" type="text" placeholder="Pesquisar por nome/CNPJ…"
|
||||||
|
style="padding:.6rem;border:1px solid #ddd;border-radius:10px;min-width:280px;">
|
||||||
|
<button id="btnNovo" class="btn btn-primary" type="button">Novo Cliente</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabela -->
|
||||||
|
<div class="tbl-wrap">
|
||||||
|
<table class="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:45%;">Cliente</th>
|
||||||
|
<th style="width:25%;">CNPJ</th>
|
||||||
|
<th style="width:15%;">Status</th>
|
||||||
|
<th style="width:15%; text-align:right;">Ações</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbody-clientes">
|
||||||
|
<tr><td colspan="4" class="muted">Nenhum cliente encontrado.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div id="modal" class="modal hidden" aria-hidden="true">
|
||||||
|
<div class="modal-backdrop" onclick="fecharModal()"></div>
|
||||||
|
<div class="modal-card">
|
||||||
|
<div id="status_bar" class="status-bar on" aria-hidden="true"></div>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modal-titulo">Novo Cliente</h3>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="fecharModal()">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="form-modal" onsubmit="return salvarModal(event)">
|
||||||
|
<input type="hidden" id="cli_id">
|
||||||
|
|
||||||
|
<div class="modal-body form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Nome fantasia *</label>
|
||||||
|
<input id="cli_nome" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>CNPJ</label>
|
||||||
|
<input id="cli_cnpj"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="00.000.000/0000-00">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group status-inline" id="grp_status">
|
||||||
|
<div style="flex:1">
|
||||||
|
<label>Status</label>
|
||||||
|
<select id="cli_ativo" onchange="setStatusUI(this.value === 'true')">
|
||||||
|
<option value="true">Ativo</option>
|
||||||
|
<option value="false">Inativo</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="fecharModal()">Cancelar</button>
|
||||||
|
<button type="submit" class="btn btn-primary">💾 Salvar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tbl-wrap{ background:#fff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb; }
|
||||||
|
.tbl{ width:100%; border-collapse:separate; border-spacing:0; }
|
||||||
|
.tbl thead th{
|
||||||
|
background:#2563eb; color:#fff; padding:12px; text-align:left; font-weight:700;
|
||||||
|
}
|
||||||
|
.tbl thead th:first-child{ border-top-left-radius:12px; }
|
||||||
|
.tbl thead th:last-child{ border-top-right-radius:12px; }
|
||||||
|
.tbl tbody td{ padding:12px; border-top:1px solid #eef2f7; vertical-align:middle; }
|
||||||
|
.tbl tbody tr:nth-child(even){ background:#f8fafc; }
|
||||||
|
.muted{ color:#6b7280; text-align:center; padding:16px; }
|
||||||
|
|
||||||
|
.badge{ display:inline-block; padding:.2rem .6rem; border-radius:999px; font-weight:700; font-size:.78rem; color:#fff; }
|
||||||
|
.on{ background:#16a34a; } .off{ background:#9ca3af; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.hidden{ display:none; }
|
||||||
|
.modal{
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex; /* centraliza */
|
||||||
|
align-items: center; /* <-- centraliza vertical */
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6vh 16px; /* respiro e evita colar nas bordas */
|
||||||
|
}
|
||||||
|
.modal-backdrop{
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15,23,42,.45);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.modal-card{
|
||||||
|
position: relative;
|
||||||
|
z-index: 1; /* acima do backdrop */
|
||||||
|
width: min(760px, 92vw); /* largura consistente */
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden; /* barra acompanha cantos */
|
||||||
|
box-shadow: 0 18px 40px rgba(0,0,0,.18);
|
||||||
|
padding: 18px; /* respiro interno */
|
||||||
|
}
|
||||||
|
.modal-header{
|
||||||
|
display:flex; justify-content:space-between; align-items:center;
|
||||||
|
margin-bottom: 12px; position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
.modal-header h3{ margin:0; font-size:1.4rem; }
|
||||||
|
|
||||||
|
.modal-body{
|
||||||
|
margin-top: 6px; position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer{
|
||||||
|
display:flex; justify-content:flex-end; gap:.6rem; margin-top:16px;
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid{ display:grid; grid-template-columns:1fr; gap:14px; }
|
||||||
|
@media (min-width: 900px){ .form-grid{ grid-template-columns:1fr 1fr; } }
|
||||||
|
.form-group label{ display:block; margin-bottom:6px; color:#374151; }
|
||||||
|
.form-group input, .form-group select{
|
||||||
|
width:100%; padding:.65rem .8rem; border:1px solid #e5e7eb;
|
||||||
|
border-radius:12px; background:#fff;
|
||||||
|
}
|
||||||
|
.form-group input:focus, .form-group select:focus{
|
||||||
|
outline:none; border-color:#2563eb; box-shadow:0 0 0 3px rgba(37,99,235,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inline{ display:flex; align-items:flex-end; gap:12px; }
|
||||||
|
.badge{ display:inline-block; padding:.35rem .7rem; border-radius:999px; font-weight:700; color:#fff; }
|
||||||
|
.badge.on{ background:#16a34a; } /* ativo */
|
||||||
|
.badge.off{ background:#dc2626; } /* inativo */
|
||||||
|
|
||||||
|
.form-group label{ display:block; font-size:.9rem; color:#374151; margin-bottom:4px; }
|
||||||
|
.form-group input, .form-group select{
|
||||||
|
width:100%; padding:.55rem .7rem; border:1px solid #e5e7eb; border-radius:10px; background:#fff;
|
||||||
|
}
|
||||||
|
.hint{ font-size:.78rem; color:#6b7280; margin-top:4px; }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #2563eb;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #dc2626;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar{
|
||||||
|
position:absolute;
|
||||||
|
top:0; left:0;
|
||||||
|
width: 10px; /* espessura da barra */
|
||||||
|
height:100%;
|
||||||
|
background:#16a34a; /* default: ativo */
|
||||||
|
pointer-events:none; /* não intercepta cliques */
|
||||||
|
z-index: 0; /* fica por trás do conteúdo */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cores por estado */
|
||||||
|
.status-bar.on { background:#16a34a; } /* ativo (verde) */
|
||||||
|
.status-bar.off { background:#ef4444; } /* inativo (vermelho) */
|
||||||
|
|
||||||
|
.status-ativo {
|
||||||
|
background-color: #16a34a; /* verde */
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-inativo {
|
||||||
|
background-color: #ef4444; /* vermelho */
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
td.acoes { text-align: right; white-space: nowrap; }
|
||||||
|
|
||||||
|
.btn-icon{
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
background: #e5e7eb; /* cinza claro */
|
||||||
|
color: #374151;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.btn-icon:hover{ background:#d1d5db; }
|
||||||
|
.btn-icon.danger{ background:#dc2626; color:#fff; }
|
||||||
|
.btn-icon.danger:hover{ background:#b91c1c; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const onlyDigits = s => (s||'').replace(/\D/g,'');
|
||||||
|
const elBody = document.getElementById('tbody-clientes');
|
||||||
|
const elBusca = document.getElementById('busca');
|
||||||
|
|
||||||
|
function setStatusUI(isActive){
|
||||||
|
const bar = document.getElementById('status_bar');
|
||||||
|
if(!bar) return;
|
||||||
|
bar.classList.toggle('on', isActive);
|
||||||
|
bar.classList.toggle('off', !isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
// monta uma linha da tabela
|
||||||
|
function linha(c){
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td>${c.nome_fantasia || '-'}</td>
|
||||||
|
<td>${formatCNPJ(c.cnpj || '')}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge ${c.ativo ? 'on' : 'off'}">
|
||||||
|
${c.ativo ? 'Ativo' : 'Inativo'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="acoes">
|
||||||
|
<button class="btn-icon" title="Editar" aria-label="Editar"
|
||||||
|
onclick='abrirModalEditar(${JSON.stringify(c)})'>
|
||||||
|
✏️
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon danger" title="Excluir" aria-label="Excluir"
|
||||||
|
onclick="removerCliente('${c.id}')">
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderiza a lista no tbody (com filtro da busca)
|
||||||
|
function render(lista){
|
||||||
|
const termo = (elBusca.value || '').toLowerCase();
|
||||||
|
const filtrada = lista.filter(c =>
|
||||||
|
(c.nome_fantasia || '').toLowerCase().includes(termo) ||
|
||||||
|
(c.cnpj || '').includes(onlyDigits(termo))
|
||||||
|
);
|
||||||
|
elBody.innerHTML = filtrada.length
|
||||||
|
? filtrada.map(linha).join('')
|
||||||
|
: `<tr><td colspan="4" class="muted">Nenhum cliente encontrado.</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// carrega clientes do backend e renderiza
|
||||||
|
async function carregar(busca = "") {
|
||||||
|
const r = await fetch('/api/clientes');
|
||||||
|
if (!r.ok){ console.error('Falha ao carregar clientes'); return; }
|
||||||
|
const dados = await r.json();
|
||||||
|
window.__clientes = dados; // guarda em memória para o filtro
|
||||||
|
render(dados);
|
||||||
|
}
|
||||||
|
|
||||||
|
// excluir cliente
|
||||||
|
async function carregar(busca = "") {
|
||||||
|
const r = await fetch(`/api/clientes?busca=${encodeURIComponent(busca)}`);
|
||||||
|
if (!r.ok) { console.error('Falha ao carregar clientes'); return; }
|
||||||
|
const dados = await r.json();
|
||||||
|
window.__clientes = dados; // mantém em memória, se quiser
|
||||||
|
render(dados);
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirModalNovo(){
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
const grpStatus = document.getElementById('grp_status');
|
||||||
|
const inpId = document.getElementById('cli_id');
|
||||||
|
const inpNome = document.getElementById('cli_nome');
|
||||||
|
const inpCnpj = document.getElementById('cli_cnpj');
|
||||||
|
const selAtv = document.getElementById('cli_ativo');
|
||||||
|
|
||||||
|
document.getElementById('modal-titulo').textContent = 'Novo Cliente';
|
||||||
|
|
||||||
|
inpId.value = '';
|
||||||
|
inpNome.value = '';
|
||||||
|
inpCnpj.value = ''; // <<< nada de mask.textContent
|
||||||
|
|
||||||
|
selAtv.value = 'true';
|
||||||
|
setStatusUI(true);
|
||||||
|
|
||||||
|
// novo: não mostra o select de status
|
||||||
|
grpStatus.style.display = 'none';
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
|
setTimeout(()=> inpNome.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function abrirModalEditar(c){
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
const grpStatus = document.getElementById('grp_status');
|
||||||
|
const inpId = document.getElementById('cli_id');
|
||||||
|
const inpNome = document.getElementById('cli_nome');
|
||||||
|
const inpCnpj = document.getElementById('cli_cnpj');
|
||||||
|
const selAtv = document.getElementById('cli_ativo');
|
||||||
|
|
||||||
|
document.getElementById('modal-titulo').textContent = 'Editar Cliente';
|
||||||
|
|
||||||
|
inpId.value = c.id || '';
|
||||||
|
inpNome.value = c.nome_fantasia || '';
|
||||||
|
// Preenche já mascarado no próprio input
|
||||||
|
inpCnpj.value = formatCNPJ(c.cnpj || ''); // <<< em vez de mask.textContent
|
||||||
|
|
||||||
|
grpStatus.style.display = ''; // mostra no editar
|
||||||
|
const ativo = !!c.ativo;
|
||||||
|
selAtv.value = ativo ? 'true' : 'false';
|
||||||
|
setStatusUI(ativo);
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
|
setTimeout(()=> inpNome.focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fecharModal(){
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// sincroniza barra quando trocar o select (só visível no modo edição)
|
||||||
|
document.getElementById('cli_ativo').addEventListener('change', (e)=>{
|
||||||
|
setStatusUI(e.target.value === 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
// LIGA o botão "Novo Cliente"
|
||||||
|
document.addEventListener('DOMContentLoaded', ()=>{
|
||||||
|
const btnNovo = document.getElementById('btnNovo');
|
||||||
|
if (btnNovo) btnNovo.addEventListener('click', abrirModalNovo);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function formatCNPJ(d){ // 14 dígitos -> 00.000.000/0000-00
|
||||||
|
d = onlyDigits(d).slice(0,14);
|
||||||
|
let out = '';
|
||||||
|
if (d.length > 0) out += d.substring(0,2);
|
||||||
|
if (d.length > 2) out += '.' + d.substring(2,5);
|
||||||
|
if (d.length > 5) out += '.' + d.substring(5,8);
|
||||||
|
if (d.length > 8) out += '/' + d.substring(8,12);
|
||||||
|
if (d.length > 12) out += '-' + d.substring(12,14);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskCNPJ(ev){
|
||||||
|
const el = ev.target;
|
||||||
|
const caret = el.selectionStart;
|
||||||
|
const before = el.value;
|
||||||
|
el.value = formatCNPJ(el.value);
|
||||||
|
// caret simples (bom o suficiente aqui)
|
||||||
|
const diff = el.value.length - before.length;
|
||||||
|
el.selectionStart = el.selectionEnd = Math.max(0, (caret||0) + diff);
|
||||||
|
}
|
||||||
|
|
||||||
|
// valida CNPJ com dígitos verificadores
|
||||||
|
function isValidCNPJ(v){
|
||||||
|
const c = onlyDigits(v);
|
||||||
|
if (c.length !== 14) return false;
|
||||||
|
if (/^(\d)\1{13}$/.test(c)) return false; // todos iguais
|
||||||
|
|
||||||
|
const calc = (base) => {
|
||||||
|
const nums = base.split('').map(n=>parseInt(n,10));
|
||||||
|
const pesos = [];
|
||||||
|
for (let i=0;i<nums.length;i++){
|
||||||
|
pesos.push( (nums.length+1-i) > 9 ? (nums.length+1-i)-8 : (nums.length+1-i) );
|
||||||
|
}
|
||||||
|
let soma = 0;
|
||||||
|
for (let i=0;i<nums.length;i++) soma += nums[i] * pesos[i];
|
||||||
|
const r = soma % 11;
|
||||||
|
return (r < 2) ? 0 : (11 - r);
|
||||||
|
};
|
||||||
|
|
||||||
|
const d1 = calc(c.substring(0,12));
|
||||||
|
const d2 = calc(c.substring(0,12) + d1);
|
||||||
|
return c.endsWith(`${d1}${d2}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ligar máscara
|
||||||
|
document.addEventListener('DOMContentLoaded', ()=>{
|
||||||
|
const cnpjEl = document.getElementById('cli_cnpj');
|
||||||
|
if (cnpjEl){
|
||||||
|
cnpjEl.addEventListener('input', maskCNPJ);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function salvarModal(e){
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const btnSalvar = document.querySelector('#form-modal .btn.btn-primary[type="submit"]');
|
||||||
|
if (btnSalvar) btnSalvar.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const nome = document.getElementById('cli_nome').value.trim();
|
||||||
|
const cnpjEl = document.getElementById('cli_cnpj');
|
||||||
|
const ativo = document.getElementById('cli_ativo').value === 'true';
|
||||||
|
const id = document.getElementById('cli_id').value || null;
|
||||||
|
|
||||||
|
const cnpjDigits = onlyDigits(cnpjEl.value);
|
||||||
|
|
||||||
|
if (!nome){
|
||||||
|
alert('Informe o nome fantasia.');
|
||||||
|
document.getElementById('cli_nome').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cnpjDigits && !isValidCNPJ(cnpjEl.value)){
|
||||||
|
alert('CNPJ inválido.');
|
||||||
|
cnpjEl.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = { nome_fantasia: nome, cnpj: cnpjDigits || null, ativo };
|
||||||
|
const url = id ? `/api/clientes/${id}` : '/api/clientes';
|
||||||
|
const method = id ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (r.status === 409) {
|
||||||
|
const { detail } = await r.json().catch(() => ({ detail: 'CNPJ já cadastrado.' }));
|
||||||
|
alert(detail || 'CNPJ já cadastrado.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!r.ok){
|
||||||
|
alert('Erro ao salvar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fecharModal();
|
||||||
|
carregar();
|
||||||
|
} finally {
|
||||||
|
if (btnSalvar) btnSalvar.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Botão Novo Cliente
|
||||||
|
const btnNovo = document.getElementById('btnNovo');
|
||||||
|
if (btnNovo) btnNovo.addEventListener('click', abrirModalNovo);
|
||||||
|
|
||||||
|
// Campo de busca
|
||||||
|
const busca = document.getElementById('busca');
|
||||||
|
const debounce = (fn, wait=250) => {
|
||||||
|
let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (busca) {
|
||||||
|
busca.addEventListener('input', debounce(() => {
|
||||||
|
carregar(busca.value.trim()); // <-- agora consulta o backend a cada digitação (com debounce)
|
||||||
|
}, 250));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Máscara no CNPJ do modal
|
||||||
|
const cnpjEl = document.getElementById('cli_cnpj');
|
||||||
|
if (cnpjEl) cnpjEl.addEventListener('input', maskCNPJ);
|
||||||
|
|
||||||
|
// Carregar clientes na tabela ao abrir a página
|
||||||
|
carregar();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,106 +2,254 @@
|
|||||||
{% block title %}Dashboard{% endblock %}
|
{% block title %}Dashboard{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h1 style="display: flex; align-items: center; gap: 10px;">
|
<div id="loading" class="loading-backdrop">
|
||||||
<i class="fas fa-chart-line"></i> Dashboard de Faturas
|
<div class="spinner"></div>
|
||||||
</h1>
|
<div class="loading-msg">Carregando dados…</div>
|
||||||
|
|
||||||
<form method="get" style="margin: 20px 0;">
|
|
||||||
<label for="cliente">Selecionar Cliente:</label>
|
|
||||||
<select name="cliente" id="cliente" onchange="this.form.submit()">
|
|
||||||
<option value="">Todos</option>
|
|
||||||
{% for c in clientes %}
|
|
||||||
<option value="{{ c }}" {% if c == cliente_atual %}selected{% endif %}>{{ c }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Cards -->
|
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 20px; margin-bottom: 30px;">
|
|
||||||
{% for indicador in indicadores %}
|
|
||||||
<div style="
|
|
||||||
flex: 1 1 220px;
|
|
||||||
background: #2563eb;
|
|
||||||
color: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
|
|
||||||
">
|
|
||||||
<strong>{{ indicador.titulo }}</strong>
|
|
||||||
<div style="font-size: 1.6rem; font-weight: bold; margin-top: 10px;">
|
|
||||||
{{ indicador.valor }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 style="margin-bottom: 20px;"><i class="fas fa-chart-bar"></i> Análise da Decisão do STF (RE 574.706 – 15/03/2017)</h2>
|
|
||||||
|
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 20px;">
|
<style>
|
||||||
<div style="flex: 1;">
|
/* ---- Combobox estilizado ---- */
|
||||||
<h4>% de Faturas com ICMS na Base PIS/COFINS</h4>
|
.combo {
|
||||||
<canvas id="graficoICMS"></canvas>
|
appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 44px 12px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #111827;
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,.06);
|
||||||
|
transition: box-shadow .2s ease, border-color .2s ease, transform .2s ease;
|
||||||
|
}
|
||||||
|
.combo:focus { outline: none; border-color: #2563eb; box-shadow: 0 8px 28px rgba(37,99,235,.18); }
|
||||||
|
.combo-wrap { position: relative; display: inline-flex; align-items: center; gap: 8px; }
|
||||||
|
.combo-wrap:after {
|
||||||
|
content: "▾"; position: absolute; right: 12px; pointer-events: none; color:#6b7280; font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Cards ---- */
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(12, 1fr); gap: 18px; margin: 22px 0 32px; }
|
||||||
|
.card {
|
||||||
|
grid-column: span 12;
|
||||||
|
display: grid; grid-template-columns: 82px 1fr; align-items: start;
|
||||||
|
background: #1f2937; /* cinza escuro */
|
||||||
|
color: #f9fafb; /* texto claro */
|
||||||
|
border-radius: 18px; padding: 18px;
|
||||||
|
box-shadow: 0 12px 34px rgba(0,0,0,.08);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
transition: transform .18s ease, box-shadow .18s ease;
|
||||||
|
animation: pop .35s ease both;
|
||||||
|
}
|
||||||
|
.card:hover { transform: translateY(-2px); box-shadow: 0 18px 44px rgba(0,0,0,.1); }
|
||||||
|
@keyframes pop { from{ transform: scale(.98); opacity:.0 } to{ transform: scale(1); opacity:1 } }
|
||||||
|
|
||||||
|
.card .icon {
|
||||||
|
width: 72px; height: 72px; border-radius: 16px;
|
||||||
|
display: grid; place-items: center; font-size: 38px; color: #fff;
|
||||||
|
box-shadow: inset 0 0 40px rgba(255,255,255,.2);
|
||||||
|
}
|
||||||
|
.icon.blue { background: linear-gradient(135deg, #2563eb, #3b82f6); }
|
||||||
|
.icon.green { background: linear-gradient(135deg, #059669, #10b981); }
|
||||||
|
.icon.amber { background: linear-gradient(135deg, #d97706, #f59e0b); }
|
||||||
|
|
||||||
|
.metrics { padding-left: 16px; }
|
||||||
|
.value { font-size: 30px; font-weight: 800; color: #f9fafb; text-align: right; }
|
||||||
|
.label { margin-top: 6px; font-size: 13px; color: #d1d5db; text-align: right; }
|
||||||
|
|
||||||
|
/* Responsivo */
|
||||||
|
@media (min-width: 640px) { .card { grid-column: span 6; } }
|
||||||
|
@media (min-width: 1024px){ .card { grid-column: span 4; } }
|
||||||
|
|
||||||
|
.loading-backdrop{
|
||||||
|
position:fixed; inset:0; z-index:9999;
|
||||||
|
background:rgba(17,24,39,.55); backdrop-filter: blur(2px);
|
||||||
|
display:flex; align-items:center; justify-content:center; gap:12px;
|
||||||
|
transition:opacity .25s ease; opacity:1; pointer-events:auto;
|
||||||
|
}
|
||||||
|
.loading-backdrop.hide{ opacity:0; pointer-events:none; }
|
||||||
|
.spinner{
|
||||||
|
width:40px; height:40px; border:4px solid rgba(255,255,255,.3);
|
||||||
|
border-top-color:#60a5fa; border-radius:50%; animation:spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin{ to{ transform:rotate(360deg) } }
|
||||||
|
.loading-msg{ color:#fff; font-weight:600; }
|
||||||
|
|
||||||
|
/* Card simples para gráficos */
|
||||||
|
.panel{
|
||||||
|
background:#1f2937; /* mesmo fundo dos cards */
|
||||||
|
color:#f9fafb;
|
||||||
|
border-radius:18px;
|
||||||
|
padding:16px 18px 22px;
|
||||||
|
box-shadow:0 12px 34px rgba(0,0,0,.08);
|
||||||
|
margin-top:10px;
|
||||||
|
}
|
||||||
|
.panel-title{
|
||||||
|
margin:0 0 10px 0;
|
||||||
|
font-weight:700;
|
||||||
|
display:flex;align-items:center;gap:10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h1 style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
|
||||||
|
<i class="fas fa-chart-line"></i> Dashboard de Faturas
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<form method="get" action="/" style="margin: 6px 0 18px">
|
||||||
|
<div class="combo-wrap">
|
||||||
|
<label for="cliente" style="font-size:13px;color:#374151">Selecionar Cliente:</label>
|
||||||
|
<select id="cliente" name="cliente" class="combo">
|
||||||
|
<option value="">Todos</option>
|
||||||
|
{% for c in clientes %}
|
||||||
|
<option value="{{ c.id }}" {% if cliente_selecionado == c.id %}selected{% endif %}>{{ c.nome }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex: 1;">
|
</form>
|
||||||
<h4>Valor Médio de Tributos com ICMS</h4>
|
|
||||||
<canvas id="graficoValor"></canvas>
|
<script>
|
||||||
|
document.getElementById('cliente').addEventListener('change', function () {
|
||||||
|
const u = new URL(window.location);
|
||||||
|
if (this.value) u.searchParams.set('cliente', this.value);
|
||||||
|
else u.searchParams.delete('cliente');
|
||||||
|
u.pathname = "/"; // garante que fica na raiz
|
||||||
|
window.location = u.toString();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Cards -->
|
||||||
|
<div class="cards">
|
||||||
|
<!-- Total de Clientes -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon" style="background: linear-gradient(135deg,#7c3aed,#a78bfa)"><i class="fas fa-users"></i></div>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="value">{{ '{:,}'.format(total_clientes or 0).replace(',', '.') }}</div>
|
||||||
|
<div class="label">Total de clientes</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Total de Faturas -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon blue"><i class="fas fa-file-invoice"></i></div>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="value">{{ '{:,}'.format(total_faturas or 0).replace(',', '.') }}</div>
|
||||||
|
<div class="label">Total de faturas processadas</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Restituição Corrigida -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon green"><i class="fas fa-hand-holding-usd"></i></div>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="value">R$ {{ '{:,.2f}'.format(valor_restituicao_corrigida or 0).replace(',', 'X').replace('.', ',').replace('X', '.') }}</div>
|
||||||
|
<div class="label">Restituição corrigida (PIS+COFINS sobre ICMS)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- % ICMS na Base -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon amber"><i class="fas fa-percentage"></i></div>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="value">{{ '{:.1f}%'.format(percentual_icms_base or 0) }}</div>
|
||||||
|
<div class="label">% de faturas com ICMS na base do PIS/COFINS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Valor médio por fatura com ICMS na base -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="icon" style="background: linear-gradient(135deg,#ef4444,#f97316)">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
</div>
|
||||||
|
<div class="metrics">
|
||||||
|
<div class="value">R$ {{ '{:,.2f}'.format(valor_medio_com_icms or 0).replace(',', 'X').replace('.', ',').replace('X', '.') }}</div>
|
||||||
|
<div class="label">Valor médio (PIS+COFINS sobre ICMS) por fatura</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Evolução mensal (card) -->
|
||||||
|
<div class="panel">
|
||||||
|
<h2 class="panel-title">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
Evolução mensal do valor passível de recuperação
|
||||||
|
</h2>
|
||||||
|
<canvas id="graficoEvolucao" style="max-height:360px"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
|
||||||
<script>
|
<script>
|
||||||
const ctx1 = document.getElementById('graficoICMS').getContext('2d');
|
const ctxE = document.getElementById('graficoEvolucao').getContext('2d');
|
||||||
new Chart(ctx1, {
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: ['Antes da Decisão', 'Depois da Decisão'],
|
|
||||||
datasets: [{
|
|
||||||
label: '% com ICMS na Base',
|
|
||||||
data: {{ [analise_stf.antes.percentual_com_icms, analise_stf.depois.percentual_com_icms] | tojson }},
|
|
||||||
backgroundColor: ['#f39c12', '#e74c3c']
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
plugins: {
|
|
||||||
legend: { display: true },
|
|
||||||
title: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
title: { display: true, text: '%' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const ctx2 = document.getElementById('graficoValor').getContext('2d');
|
const evoLabels = {{ serie_mensal_labels | tojson }};
|
||||||
new Chart(ctx2, {
|
const evoValores = {{ serie_mensal_valores | tojson }};
|
||||||
type: 'bar',
|
|
||||||
data: {
|
new Chart(ctxE, {
|
||||||
labels: ['Antes da Decisão', 'Depois da Decisão'],
|
type: 'line',
|
||||||
datasets: [{
|
data: {
|
||||||
label: 'Valor Médio de PIS/COFINS com ICMS',
|
labels: evoLabels,
|
||||||
data: {{ [analise_stf.antes.media_valor, analise_stf.depois.media_valor] | tojson }},
|
datasets: [{
|
||||||
backgroundColor: ['#2980b9', '#27ae60']
|
label: 'Valor corrigido (R$)',
|
||||||
}]
|
data: evoValores,
|
||||||
},
|
fill: false,
|
||||||
options: {
|
tension: 0.25,
|
||||||
responsive: true,
|
borderWidth: 3,
|
||||||
plugins: {
|
pointRadius: 4,
|
||||||
legend: { display: true },
|
pointHoverRadius: 5
|
||||||
title: { display: false }
|
}]
|
||||||
},
|
},
|
||||||
scales: {
|
options: {
|
||||||
y: {
|
responsive: true,
|
||||||
beginAtZero: true,
|
plugins: {
|
||||||
title: { display: true, text: 'R$' }
|
legend: {
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true, // legenda com “linha”, não retângulo
|
||||||
|
pointStyle: 'line'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
datalabels: {
|
||||||
|
align: 'top',
|
||||||
|
anchor: 'end',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
font: { weight: 600, size: 11 },
|
||||||
|
formatter: (v) => 'R$ ' + Number(v).toLocaleString('pt-BR', {
|
||||||
|
minimumFractionDigits: 2, maximumFractionDigits: 2
|
||||||
|
})
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => {
|
||||||
|
const v = ctx.parsed.y ?? 0;
|
||||||
|
return 'R$ ' + Number(v).toLocaleString('pt-BR', {
|
||||||
|
minimumFractionDigits: 2, maximumFractionDigits: 2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false } }, // remove linhas do fundo
|
||||||
|
y: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { callback: v => 'R$ ' + Number(v).toLocaleString('pt-BR') }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
plugins: [ChartDataLabels] // ativa o plugin de rótulos
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mostra overlay ao iniciar; esconde quando tudo carregar
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const el = document.getElementById('loading');
|
||||||
|
// garante visível até 'load'
|
||||||
|
el.classList.remove('hide');
|
||||||
|
});
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const el = document.getElementById('loading');
|
||||||
|
el.classList.add('hide');
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -154,6 +154,10 @@
|
|||||||
<i class="fas fa-tachometer-alt"></i>
|
<i class="fas fa-tachometer-alt"></i>
|
||||||
<span>Dashboard</span>
|
<span>Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/clientes" class="menu-item">
|
||||||
|
<i class="fas fa-building"></i>
|
||||||
|
<span>Clientes</span>
|
||||||
|
</a>
|
||||||
<a href="/upload" class="menu-item">
|
<a href="/upload" class="menu-item">
|
||||||
<i class="fas fa-upload"></i>
|
<i class="fas fa-upload"></i>
|
||||||
<span>Upload</span>
|
<span>Upload</span>
|
||||||
|
|||||||
@@ -1,32 +1,804 @@
|
|||||||
{% extends "index.html" %}
|
{% extends "index.html" %}
|
||||||
{% block title %}Parâmetros de Cálculo{% endblock %}
|
{% block title %}Parâmetros de Cálculo{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>⚙️ Parâmetros</h1>
|
|
||||||
|
|
||||||
<form method="post">
|
<h1 style="font-size: 1.6rem; margin-bottom: 1rem; display:flex; align-items:center; gap:0.5rem;">
|
||||||
<label for="tipo">Tipo:</label><br>
|
⚙️ Parâmetros de Cálculo
|
||||||
<input type="text" name="tipo" id="tipo" value="{{ parametros.tipo or '' }}" required/><br><br>
|
</h1>
|
||||||
|
|
||||||
<label for="formula">Fórmula:</label><br>
|
<div class="tabs">
|
||||||
<input type="text" name="formula" id="formula" value="{{ parametros.formula or '' }}" required/><br><br>
|
<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>
|
||||||
|
|
||||||
<label for="aliquota_icms">Alíquota de ICMS (%):</label><br>
|
<!-- ABA FÓRMULAS -->
|
||||||
<input type="number" step="0.01" name="aliquota_icms" id="aliquota_icms" value="{{ parametros.aliquota_icms or '' }}" /><br><br>
|
<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>
|
||||||
|
|
||||||
<label for="incluir_icms">Incluir ICMS:</label><br>
|
<div class="form-group">
|
||||||
<input type="checkbox" name="incluir_icms" id="incluir_icms" value="1" {% if parametros.incluir_icms %}checked{% endif %}><br><br>
|
<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>
|
||||||
|
|
||||||
<label for="ativo">Ativo:</label><br>
|
<div style="margin-bottom: 0.5rem;">
|
||||||
<input type="checkbox" name="ativo" id="ativo" value="1" {% if parametros.ativo %}checked{% endif %}><br><br>
|
<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" style="padding: 10px 20px; background: #2563eb; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
<button type="submit" class="btn btn-primary">💾 Salvar Parâmetro</button>
|
||||||
Salvar Parâmetros
|
<button type="button" class="btn btn-primary pulse" onclick="limparFormulario()">🔁 Novo Parâmetro</button>
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% if mensagem %}
|
</form>
|
||||||
<div style="margin-top: 20px; background: #e0f7fa; padding: 10px; border-left: 4px solid #2563eb;">
|
<hr style="margin-top: 2rem; margin-bottom: 1rem;">
|
||||||
{{ mensagem }}
|
<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>
|
||||||
{% endif %}
|
</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 %}
|
{% endblock %}
|
||||||
@@ -3,36 +3,227 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>📊 Relatórios</h1>
|
<h1>📊 Relatórios</h1>
|
||||||
|
|
||||||
<form method="get" style="margin-bottom: 20px;">
|
<div style="display:flex; gap:16px; align-items:flex-end; flex-wrap:wrap; margin: 6px 0 18px;">
|
||||||
<label for="cliente">Filtrar por Cliente:</label>
|
<div class="combo-wrap">
|
||||||
<select name="cliente" id="cliente" onchange="this.form.submit()">
|
<label for="relatorio-cliente" style="font-size:13px;color:#374151">Selecionar Cliente:</label>
|
||||||
<option value="">Todos</option>
|
<select id="relatorio-cliente" class="combo" style="min-width:340px;">
|
||||||
{% for c in clientes %}
|
<option value="">Todos</option>
|
||||||
<option value="{{ c }}" {% if c == cliente_atual %}selected{% endif %}>{{ c }}</option>
|
{% for c in clientes %}
|
||||||
{% endfor %}
|
<option value="{{ c.id }}">{{ c.nome }}</option>
|
||||||
</select>
|
{% endfor %}
|
||||||
</form>
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table style="width: 100%; border-collapse: collapse;">
|
<div class="combo-wrap">
|
||||||
|
<label for="tipo-relatorio" style="font-size:13px;color:#374151">Tipo de relatório:</label>
|
||||||
|
<select id="tipo-relatorio" class="combo" style="min-width:240px;">
|
||||||
|
<option value="geral">1. Geral</option>
|
||||||
|
<option value="exclusao_icms">2. Exclusão do ICMS</option>
|
||||||
|
<option value="aliquota_icms">3. Alíquota ICMS (%)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="combo-wrap">
|
||||||
|
<label for="page-size" style="font-size:13px;color:#374151">Itens por página:</label>
|
||||||
|
<select id="page-size" class="combo" style="width:140px;">
|
||||||
|
<option>20</option>
|
||||||
|
<option>50</option>
|
||||||
|
<option>100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a id="link-excel" class="btn btn-primary" href="/export-excel">📥 Baixar (Excel)</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style="background: #2563eb; color: white;">
|
<tr>
|
||||||
<th style="padding: 10px;">Cliente</th>
|
<th>Cliente</th>
|
||||||
<th>Data</th>
|
<th>UC</th>
|
||||||
|
<th>Referência</th>
|
||||||
|
<th>Nota Fiscal</th>
|
||||||
<th>Valor Total</th>
|
<th>Valor Total</th>
|
||||||
<th>ICMS na Base</th>
|
<th>ICMS (%)</th>
|
||||||
<th>Status</th>
|
<th>ICMS (R$)</th>
|
||||||
|
<th>PIS (R$)</th>
|
||||||
|
<th>COFINS (R$)</th>
|
||||||
|
<th>Distribuidora</th>
|
||||||
|
<th>Processado em</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="relatorios-body">
|
||||||
{% for f in faturas %}
|
{% for f in faturas %}
|
||||||
<tr style="background: {{ loop.cycle('#ffffff', '#f0f4f8') }};">
|
<tr>
|
||||||
<td style="padding: 10px;">{{ f.nome }}</td>
|
<td>{{ f.nome }}</td>
|
||||||
<td>{{ f.data_emissao }}</td>
|
<td class="mono">{{ f.unidade_consumidora }}</td>
|
||||||
<td>R$ {{ '%.2f'|format(f.valor_total)|replace('.', ',') }}</td>
|
<td class="mono">{{ f.referencia }}</td>
|
||||||
<td>{{ 'Sim' if f.com_icms else 'Não' }}</td>
|
<td class="mono">{{ f.nota_fiscal }}</td>
|
||||||
<td>{{ f.status }}</td>
|
<td>R$ {{ '%.2f'|format((f.valor_total or 0.0))|replace('.', ',') }}</td>
|
||||||
</tr>
|
<td>{{ '%.2f'|format((f.icms_aliq or 0.0))|replace('.', ',') }}</td>
|
||||||
|
<td>R$ {{ '%.2f'|format((f.icms_valor or 0.0))|replace('.', ',') }}</td>
|
||||||
|
<td>R$ {{ '%.2f'|format((f.pis_valor or 0.0))|replace('.', ',') }}</td>
|
||||||
|
<td>R$ {{ '%.2f'|format((f.cofins_valor or 0.0))|replace('.', ',') }}</td>
|
||||||
|
<td>{{ f.distribuidora or '-' }}</td>
|
||||||
|
<td class="muted">{{ f.data_processamento }}</td>
|
||||||
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div id="pager" style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:12px;">
|
||||||
|
<div id="range" class="muted">Mostrando 0–0 de 0</div>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<button id="prev" class="btn btn-primary">◀ Anterior</button>
|
||||||
|
<button id="next" class="btn btn-primary">Próxima ▶</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.combo {
|
||||||
|
appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 44px 12px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #111827;
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,.06);
|
||||||
|
transition: box-shadow .2s ease, border-color .2s ease, transform .2s ease;
|
||||||
|
}
|
||||||
|
.combo:focus { outline: none; border-color: #2563eb; box-shadow: 0 8px 28px rgba(37,99,235,.18); }
|
||||||
|
.combo-wrap { position: relative; display: inline-flex; align-items: center; gap: 8px; }
|
||||||
|
.combo-wrap:after {
|
||||||
|
content: "▾"; position: absolute; right: 12px; pointer-events: none; color:#6b7280; font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* tabela no estilo “clientes” */
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 12px 34px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
.table thead th {
|
||||||
|
background: #2563eb;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
padding: 12px 14px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.table tbody td {
|
||||||
|
border-top: 1px solid #eef2f7;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.table tbody tr:nth-child(odd){ background:#fafafa; }
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
|
||||||
|
.muted { color:#6b7280; }
|
||||||
|
|
||||||
|
#pager .btn[disabled]{ opacity:.5; cursor:not-allowed; }
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
let page = 1;
|
||||||
|
let pageSize = 20;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
function updateExcelLink() {
|
||||||
|
const cliente = document.getElementById('relatorio-cliente').value || '';
|
||||||
|
const tipo = document.getElementById('tipo-relatorio').value || 'geral';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('tipo', tipo);
|
||||||
|
if (cliente) params.set('cliente', cliente);
|
||||||
|
document.getElementById('link-excel').setAttribute('href', `/export-excel?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function carregarTabela() {
|
||||||
|
const cliente = document.getElementById('relatorio-cliente').value || '';
|
||||||
|
const url = new URL('/api/relatorios', window.location.origin);
|
||||||
|
url.searchParams.set('page', page);
|
||||||
|
url.searchParams.set('page_size', pageSize);
|
||||||
|
if (cliente) url.searchParams.set('cliente', cliente);
|
||||||
|
|
||||||
|
const r = await fetch(url);
|
||||||
|
const data = await r.json();
|
||||||
|
|
||||||
|
total = data.total;
|
||||||
|
renderRows(data.items);
|
||||||
|
updatePager();
|
||||||
|
updateExcelLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRows(items) {
|
||||||
|
const tbody = document.getElementById('relatorios-body');
|
||||||
|
if (!items.length) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="11" style="padding:14px;">Nenhum registro encontrado.</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fmtBRL = (v) => (v || v === 0) ? Number(v).toLocaleString('pt-BR',{style:'currency',currency:'BRL'}) : '';
|
||||||
|
const fmtNum = (v) => (v || v === 0) ? Number(v).toLocaleString('pt-BR') : '';
|
||||||
|
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('pt-BR') : '';
|
||||||
|
|
||||||
|
tbody.innerHTML = items.map(f => `
|
||||||
|
<tr>
|
||||||
|
<td>${f.nome || ''}</td>
|
||||||
|
<td class="mono">${f.unidade_consumidora || ''}</td>
|
||||||
|
<td class="mono">${f.referencia || ''}</td>
|
||||||
|
<td class="mono">${f.nota_fiscal || ''}</td>
|
||||||
|
<td>${fmtBRL(f.valor_total)}</td>
|
||||||
|
<td>${fmtNum(f.icms_aliq)}</td>
|
||||||
|
<td>${fmtBRL(f.icms_valor)}</td>
|
||||||
|
<td>${fmtBRL(f.pis_valor)}</td>
|
||||||
|
<td>${fmtBRL(f.cofins_valor)}</td>
|
||||||
|
<td>${f.distribuidora || '-'}</td>
|
||||||
|
<td class="muted">${fmtDate(f.data_processamento)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePager() {
|
||||||
|
const start = total ? (page - 1) * pageSize + 1 : 0;
|
||||||
|
const end = Math.min(page * pageSize, total);
|
||||||
|
document.getElementById('range').textContent = `Mostrando ${start}–${end} de ${total}`;
|
||||||
|
document.getElementById('prev').disabled = page <= 1;
|
||||||
|
document.getElementById('next').disabled = page * pageSize >= total;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('prev').addEventListener('click', () => {
|
||||||
|
if (page > 1) { page--; carregarTabela(); }
|
||||||
|
});
|
||||||
|
document.getElementById('next').addEventListener('click', () => {
|
||||||
|
if (page * pageSize < total) { page++; carregarTabela(); }
|
||||||
|
});
|
||||||
|
document.getElementById('page-size').addEventListener('change', (e) => {
|
||||||
|
pageSize = parseInt(e.target.value, 10);
|
||||||
|
page = 1;
|
||||||
|
carregarTabela();
|
||||||
|
// não precisa alterar o link aqui
|
||||||
|
});
|
||||||
|
document.getElementById('relatorio-cliente').addEventListener('change', () => {
|
||||||
|
page = 1;
|
||||||
|
carregarTabela();
|
||||||
|
updateExcelLink();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const pre = "{{ cliente_selecionado or '' }}";
|
||||||
|
if (pre) document.getElementById('relatorio-cliente').value = pre;
|
||||||
|
updateExcelLink();
|
||||||
|
carregarTabela();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('tipo-relatorio').addEventListener('change', () => {
|
||||||
|
updateExcelLink();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,80 +4,254 @@
|
|||||||
|
|
||||||
<h1 style="font-size: 1.5rem; margin-bottom: 1rem;">📤 Upload de Faturas</h1>
|
<h1 style="font-size: 1.5rem; margin-bottom: 1rem;">📤 Upload de Faturas</h1>
|
||||||
|
|
||||||
|
<!-- Seletor de Cliente (obrigatório) -->
|
||||||
|
<div style="display:flex; gap:12px; align-items:center; margin: 0 0 14px 0;">
|
||||||
|
<label for="select-cliente" style="font-weight:600;">Cliente:</label>
|
||||||
|
<select id="select-cliente" style="min-width:320px; padding:.6rem .8rem; border:1px solid #ddd; border-radius:10px;">
|
||||||
|
<option value="">— Selecione um cliente —</option>
|
||||||
|
</select>
|
||||||
|
<span id="cliente-aviso" class="muted">Selecione o cliente antes de anexar/ processar.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="upload-box" id="upload-box">
|
<div class="upload-box" id="upload-box">
|
||||||
<h3>Arraste faturas em PDF aqui ou clique para selecionar</h3>
|
<h3>Arraste faturas em PDF aqui ou clique para selecionar</h3>
|
||||||
<p style="color: gray; font-size: 0.9rem;">Apenas PDFs textuais (não escaneados)</p>
|
<p style="color: gray; font-size: 0.9rem;">Apenas PDFs textuais (não escaneados)</p>
|
||||||
<br />
|
<br />
|
||||||
<button class="btn btn-primary" onclick="document.getElementById('file-input').click()">Selecionar Arquivos</button>
|
<button class="btn btn-primary" id="btn-selecionar" onclick="document.getElementById('file-input').click()">Selecionar Arquivos</button>
|
||||||
<input type="file" id="file-input" accept=".pdf" multiple onchange="handleFiles(this.files)" style="display:none;" />
|
<input type="file" id="file-input" accept=".pdf" multiple onchange="handleFiles(this.files)" style="display:none;" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button class="btn btn-primary" onclick="processar(this)">Processar Faturas</button>
|
<button class="btn btn-primary" onclick="processar(this)">Processar Faturas</button>
|
||||||
<button class="btn btn-danger" onclick="limpar()">Limpar Tudo</button>
|
<button class="btn btn-primary pulse" onclick="limpar()" style="font-weight: bold;">🔁 Novo Processo</button>
|
||||||
|
{% if status_resultados|selectattr("status", "equalto", "Erro")|list %}
|
||||||
|
<div style="margin-top: 2rem;">
|
||||||
|
<a class="btn btn-danger" href="/erros/download">⬇️ Baixar Faturas com Erro (.zip)</a>
|
||||||
|
<a class="btn btn-secondary" href="/erros/log">📄 Ver Log de Erros (.txt)</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!--
|
||||||
<button class="btn btn-success" onclick="baixarPlanilha()">📅 Abrir Planilha</button>
|
<button class="btn btn-success" onclick="baixarPlanilha()">📅 Abrir Planilha</button>
|
||||||
<button class="btn btn-success" onclick="gerarRelatorio()">📊 Gerar Relatório</button>
|
<button class="btn btn-success" onclick="gerarRelatorio()">📊 Gerar Relatório</button>
|
||||||
|
-->
|
||||||
|
{% if app_env != "producao" %}
|
||||||
|
<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>
|
</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>
|
<script>
|
||||||
let arquivos = [];
|
let arquivos = [];
|
||||||
let statusInterval = null;
|
let statusInterval = null;
|
||||||
|
let processado = false;
|
||||||
|
let processamentoFinalizado = false;
|
||||||
|
|
||||||
const fileTable = document.getElementById('file-table');
|
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) {
|
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];
|
arquivos = [...arquivos, ...files];
|
||||||
|
|
||||||
|
// trava o combo após começar a anexar (opcional)
|
||||||
|
document.getElementById('select-cliente').disabled = true;
|
||||||
|
|
||||||
renderTable();
|
renderTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(statusList = []) {
|
|
||||||
const rows = statusList.length ? statusList : arquivos.map(file => ({ nome: file.name, status: 'Aguardando', mensagem: '' }));
|
function renderTable(statusList = []) {
|
||||||
fileTable.innerHTML = rows.length
|
const grupos = ['Aguardando', 'Enviado', 'Erro', 'Duplicado'];
|
||||||
? rows.map(file => `
|
const dados = statusList.length ? statusList : arquivos.map(file => ({
|
||||||
<tr>
|
nome: file.name,
|
||||||
<td>${file.nome}</td>
|
status: 'Aguardando',
|
||||||
<td class="${file.status === 'Concluido' ? 'status-ok' : file.status === 'Erro' ? 'status-error' : 'status-warn'}">${file.status}</td>
|
mensagem: '',
|
||||||
<td>${file.mensagem || '---'}</td>
|
tempo: '---',
|
||||||
</tr>
|
tamanho: (file.size / 1024).toFixed(1) + " KB",
|
||||||
`).join('')
|
data: new Date(file.lastModified).toLocaleDateString()
|
||||||
: '<tr><td colspan="3" style="text-align:center; color: #aaa;">Nenhum arquivo selecionado.</td></tr>';
|
}));
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processar(btn) {
|
const clienteId = clienteSelecionado();
|
||||||
if (arquivos.length === 0) return alert("Nenhum arquivo selecionado.");
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerText = "⏳ Processando...";
|
|
||||||
|
|
||||||
|
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();
|
const formData = new FormData();
|
||||||
arquivos.forEach(file => formData.append("files", file));
|
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 {
|
try {
|
||||||
await fetch("/upload-files", { method: "POST", body: formData });
|
await fetch("/upload-files", { method: "POST", body: formData });
|
||||||
await fetch("/process-queue", { method: "POST" });
|
const progresso = Math.round(((i + 1) / total) * 100);
|
||||||
arquivos = [];
|
document.getElementById("barra-progresso").style.width = `${progresso}%`;
|
||||||
statusInterval = setInterval(updateStatus, 1000);
|
|
||||||
|
statusList[i].status = "Enviado";
|
||||||
|
statusList[i].tempo = `${((performance.now() - start) / 1000).toFixed(2)}s`;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Erro ao processar faturas.");
|
statusList[i].status = "Erro";
|
||||||
} finally {
|
statusList[i].mensagem = err.message;
|
||||||
setTimeout(() => {
|
|
||||||
btn.innerText = "Processar Faturas";
|
|
||||||
btn.disabled = false;
|
|
||||||
}, 1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function updateStatus() {
|
||||||
const res = await fetch("/get-status");
|
const res = await fetch("/get-status");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -88,11 +262,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function limpar() {
|
function limpar() {
|
||||||
fetch("/clear-all", { method: "POST" });
|
fetch("/clear-all", { method: "POST" });
|
||||||
arquivos = [];
|
|
||||||
renderTable();
|
// 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() {
|
function baixarPlanilha() {
|
||||||
window.open('/export-excel', '_blank');
|
window.open('/export-excel', '_blank');
|
||||||
@@ -102,21 +304,68 @@
|
|||||||
window.open('/generate-report', '_blank');
|
window.open('/generate-report', '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
const dropZone = document.getElementById('upload-box');
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
dropZone.addEventListener('dragover', (e) => {
|
carregarClientes();
|
||||||
e.preventDefault();
|
updateStatus();
|
||||||
dropZone.classList.add('dragover');
|
|
||||||
});
|
const dragOverlay = document.getElementById("drag-overlay");
|
||||||
dropZone.addEventListener('dragleave', () => {
|
let dragCounter = 0;
|
||||||
dropZone.classList.remove('dragover');
|
|
||||||
});
|
window.addEventListener("dragenter", e => {
|
||||||
dropZone.addEventListener('drop', (e) => {
|
dragCounter++;
|
||||||
e.preventDefault();
|
dragOverlay.classList.add("active");
|
||||||
dropZone.classList.remove('dragover');
|
|
||||||
handleFiles(e.dataTransfer.files);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('DOMContentLoaded', updateStatus);
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -179,6 +428,207 @@
|
|||||||
.status-ok { color: #198754; }
|
.status-ok { color: #198754; }
|
||||||
.status-error { color: #dc3545; }
|
.status-error { color: #dc3545; }
|
||||||
.status-warn { color: #ffc107; }
|
.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>
|
</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 %}
|
{% endblock %}
|
||||||
14
app/utils.py
14
app/utils.py
@@ -3,10 +3,10 @@ import fitz
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from models import Fatura, LogProcessamento
|
from app.models import Fatura, LogProcessamento
|
||||||
from calculos import calcular_campos_dinamicos
|
from app.calculos import calcular_campos_dinamicos
|
||||||
from layouts.equatorial_go import extrair_dados as extrair_dados_equatorial
|
from app.layouts.equatorial_go import extrair_dados as extrair_dados_equatorial
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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}")
|
logger.error(f"Erro ao adicionar fatura no banco: {e}")
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def avaliar_formula(formula: str, contexto: dict):
|
||||||
|
try:
|
||||||
|
return eval(formula, {}, contexto)
|
||||||
|
except Exception as e:
|
||||||
|
return None
|
||||||
|
|||||||
92
calculos.py
92
calculos.py
@@ -1,92 +0,0 @@
|
|||||||
# calculos.py
|
|
||||||
|
|
||||||
from decimal import Decimal
|
|
||||||
from datetime import datetime
|
|
||||||
from models import ParametrosFormula, SelicMensal
|
|
||||||
from sqlalchemy.future import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
import re
|
|
||||||
|
|
||||||
def mes_para_numero(mes: str) -> int:
|
|
||||||
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
|
|
||||||
}
|
|
||||||
if mes.isdigit():
|
|
||||||
return int(mes)
|
|
||||||
return meses.get(mes.upper(), 1)
|
|
||||||
|
|
||||||
|
|
||||||
async def calcular_valor_corrigido(valor_original, competencia: str, session: AsyncSession) -> Decimal:
|
|
||||||
try:
|
|
||||||
mes, ano = competencia.split('/')
|
|
||||||
data_inicio = datetime(int(ano), mes_para_numero(mes), 1)
|
|
||||||
|
|
||||||
query = select(SelicMensal).where(
|
|
||||||
(SelicMensal.ano > data_inicio.year) |
|
|
||||||
((SelicMensal.ano == data_inicio.year) & (SelicMensal.mes >= data_inicio.month))
|
|
||||||
)
|
|
||||||
resultados = await session.execute(query)
|
|
||||||
fatores = resultados.scalars().all()
|
|
||||||
|
|
||||||
fator = Decimal('1.00')
|
|
||||||
for row in fatores:
|
|
||||||
fator *= Decimal(row.fator)
|
|
||||||
|
|
||||||
return Decimal(valor_original) * fator
|
|
||||||
except Exception:
|
|
||||||
return Decimal(valor_original)
|
|
||||||
|
|
||||||
|
|
||||||
async def aplicar_formula(nome: str, contexto: dict, session: AsyncSession) -> Decimal:
|
|
||||||
try:
|
|
||||||
result = await session.execute(
|
|
||||||
select(ParametrosFormula).where(ParametrosFormula.nome == nome)
|
|
||||||
)
|
|
||||||
formula = result.scalar_one_or_none()
|
|
||||||
if not formula:
|
|
||||||
return Decimal('0.00')
|
|
||||||
|
|
||||||
texto_formula = formula.formula # nome correto do campo
|
|
||||||
|
|
||||||
for campo, valor in contexto.items():
|
|
||||||
texto_formula = re.sub(rf'\b{campo}\b', str(valor).replace(',', '.'), texto_formula)
|
|
||||||
|
|
||||||
resultado = eval(texto_formula, {"__builtins__": {}}, {})
|
|
||||||
return Decimal(str(resultado))
|
|
||||||
except Exception:
|
|
||||||
return Decimal('0.00')
|
|
||||||
|
|
||||||
|
|
||||||
async def calcular_campos_dinamicos(fatura: dict, session: AsyncSession) -> dict:
|
|
||||||
try:
|
|
||||||
result = await session.execute(select(ParametrosFormula))
|
|
||||||
parametros = result.scalars().all()
|
|
||||||
|
|
||||||
for param in parametros:
|
|
||||||
try:
|
|
||||||
texto_formula = param.formula
|
|
||||||
for campo, valor in fatura.items():
|
|
||||||
texto_formula = re.sub(rf'\b{campo}\b', str(valor).replace(',', '.'), texto_formula)
|
|
||||||
valor_resultado = eval(texto_formula, {"__builtins__": {}}, {})
|
|
||||||
fatura[param.nome] = round(Decimal(valor_resultado), 2)
|
|
||||||
except:
|
|
||||||
fatura[param.nome] = None
|
|
||||||
|
|
||||||
return fatura
|
|
||||||
except Exception as e:
|
|
||||||
raise RuntimeError(f"Erro ao calcular campos dinâmicos: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
def calcular_pis_sobre_icms(base_pis, valor_icms, aliq_pis):
|
|
||||||
try:
|
|
||||||
return (Decimal(base_pis) - (Decimal(base_pis) - Decimal(valor_icms))) * Decimal(aliq_pis)
|
|
||||||
except:
|
|
||||||
return Decimal('0.00')
|
|
||||||
|
|
||||||
|
|
||||||
def calcular_cofins_sobre_icms(base_cofins, valor_icms, aliq_cofins):
|
|
||||||
try:
|
|
||||||
return (Decimal(base_cofins) - (Decimal(base_cofins) - Decimal(valor_icms))) * Decimal(aliq_cofins)
|
|
||||||
except:
|
|
||||||
return Decimal('0.00')
|
|
||||||
29
corrigir-estrutura.ps1
Normal file
29
corrigir-estrutura.ps1
Normal 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."
|
||||||
15
database.py
15
database.py
@@ -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
14
deploy-homolog.ps1
Normal 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
18
deploy-prod.ps1
Normal 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
BIN
deploy.log
Normal file
Binary file not shown.
BIN
drone-debug.log
Normal file
BIN
drone-debug.log
Normal file
Binary file not shown.
Binary file not shown.
@@ -1,132 +0,0 @@
|
|||||||
# app/layouts/equatorial_go.py
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
import fitz
|
|
||||||
import logging
|
|
||||||
|
|
||||||
def converter_valor(valor_str):
|
|
||||||
try:
|
|
||||||
if not valor_str:
|
|
||||||
return 0.0
|
|
||||||
valor_limpo = str(valor_str).replace('.', '').replace(',', '.')
|
|
||||||
valor_limpo = re.sub(r'[^\d.]', '', valor_limpo)
|
|
||||||
return float(valor_limpo) if valor_limpo else 0.0
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
def extrair_dados(texto_final):
|
|
||||||
import logging
|
|
||||||
logging.debug("\n========== INÍCIO DO TEXTO EXTRAÍDO ==========\n" + texto_final + "\n========== FIM ==========")
|
|
||||||
|
|
||||||
def extrair_seguro(patterns, texto_busca, flags=re.IGNORECASE | re.MULTILINE):
|
|
||||||
if not isinstance(patterns, list): patterns = [patterns]
|
|
||||||
for pattern in patterns:
|
|
||||||
match = re.search(pattern, texto_busca, flags)
|
|
||||||
if match:
|
|
||||||
for group in match.groups():
|
|
||||||
if group: return group.strip()
|
|
||||||
return ""
|
|
||||||
|
|
||||||
nota_fiscal = extrair_seguro(r'NOTA FISCAL Nº\s*(\d+)', texto_final)
|
|
||||||
|
|
||||||
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'
|
|
||||||
], texto_final)
|
|
||||||
|
|
||||||
logging.debug("TEXTO PDF:\n" + texto_final)
|
|
||||||
|
|
||||||
referencia = extrair_seguro([
|
|
||||||
r'\b([A-Z]{3}/\d{4})\b',
|
|
||||||
r'\b([A-Z]{3}\s*/\s*\d{4})\b',
|
|
||||||
r'\b([A-Z]{3}\d{4})\b',
|
|
||||||
r'(\d{2}/\d{4})'
|
|
||||||
], texto_final.upper())
|
|
||||||
|
|
||||||
# Limpeza prévia para evitar falhas por quebra de linha ou espaços
|
|
||||||
texto_limpo = texto_final.replace('\n', '').replace('\r', '').replace(' ', '')
|
|
||||||
|
|
||||||
if any(padrao in texto_limpo for padrao in ['R$***********0,00', 'R$*********0,00', 'R$*0,00']):
|
|
||||||
valor_total = 0.0
|
|
||||||
else:
|
|
||||||
match_valor_total = re.search(r'R\$[*\s]*([\d\.\s]*,\d{2})', texto_final)
|
|
||||||
valor_total = converter_valor(match_valor_total.group(1)) if match_valor_total else None
|
|
||||||
|
|
||||||
match_nome = re.search(r'(?<=\n)([A-Z\s]{10,})(?=\s+CNPJ/CPF:)', texto_final)
|
|
||||||
nome = match_nome.group(1).replace('\n', ' ').strip() if match_nome else "NÃO IDENTIFICADO"
|
|
||||||
|
|
||||||
# Remove qualquer excesso após o nome verdadeiro
|
|
||||||
nome = re.split(r'\b(FAZENDA|RUA|AVENIDA|SETOR|CEP|CNPJ|CPF)\b', nome, maxsplit=1, flags=re.IGNORECASE)[0].strip()
|
|
||||||
|
|
||||||
match_cidade_estado = re.search(r'CEP:\s*\d{8}\s+(.*?)\s+([A-Z]{2})\s+BRASIL', texto_final)
|
|
||||||
cidade = match_cidade_estado.group(1).strip() if match_cidade_estado else "NÃO IDENTIFICADA"
|
|
||||||
estado = match_cidade_estado.group(2).strip() if match_cidade_estado else "NÃO IDENTIFICADO"
|
|
||||||
|
|
||||||
match_class = re.search(r'Classificação:\s*(.*)', texto_final, re.IGNORECASE)
|
|
||||||
classificacao = match_class.group(1).strip() if match_class else "NÃO IDENTIFICADA"
|
|
||||||
|
|
||||||
def extrair_tributo_linhas_separadas(nome_tributo):
|
|
||||||
linhas = texto_final.split('\n')
|
|
||||||
for i, linha in enumerate(linhas):
|
|
||||||
if nome_tributo in linha.upper():
|
|
||||||
aliq = base = valor = 0.0
|
|
||||||
if i + 1 < len(linhas):
|
|
||||||
aliq_match = re.search(r'([\d,]+)%', linhas[i + 1])
|
|
||||||
if aliq_match:
|
|
||||||
aliq = converter_valor(aliq_match.group(1)) / 100
|
|
||||||
if i + 2 < len(linhas):
|
|
||||||
base = converter_valor(linhas[i + 2].strip())
|
|
||||||
if i + 3 < len(linhas):
|
|
||||||
valor = converter_valor(linhas[i + 3].strip())
|
|
||||||
return base, aliq, valor
|
|
||||||
return 0.0, 0.0, 0.0
|
|
||||||
|
|
||||||
pis_base, pis_aliq, pis_valor = extrair_tributo_linhas_separadas('PIS/PASEP')
|
|
||||||
icms_base, icms_aliq, icms_valor = extrair_tributo_linhas_separadas('ICMS')
|
|
||||||
cofins_base, cofins_aliq, cofins_valor = extrair_tributo_linhas_separadas('COFINS')
|
|
||||||
|
|
||||||
match_consumo = re.search(r'CONSUMO\s+\d+\s+([\d.,]+)', texto_final)
|
|
||||||
consumo = converter_valor(match_consumo.group(1)) if match_consumo else 0.0
|
|
||||||
|
|
||||||
match_tarifa = re.search(r'CONSUMO KWH \+ ICMS/PIS/COFINS\s+([\d.,]+)', texto_final) \
|
|
||||||
or re.search(r'CUSTO DISP\s+([\d.,]+)', texto_final)
|
|
||||||
tarifa = converter_valor(match_tarifa.group(1)) if match_tarifa else 0.0
|
|
||||||
|
|
||||||
dados = {
|
|
||||||
'classificacao_tarifaria': classificacao,
|
|
||||||
'nome': nome,
|
|
||||||
'unidade_consumidora': uc,
|
|
||||||
'cidade': cidade,
|
|
||||||
'estado': estado,
|
|
||||||
'referencia': referencia,
|
|
||||||
'valor_total': valor_total,
|
|
||||||
'pis_aliq': pis_aliq,
|
|
||||||
'icms_aliq': icms_aliq,
|
|
||||||
'cofins_aliq': cofins_aliq,
|
|
||||||
'pis_valor': pis_valor,
|
|
||||||
'icms_valor': icms_valor,
|
|
||||||
'cofins_valor': cofins_valor,
|
|
||||||
'pis_base': pis_base,
|
|
||||||
'icms_base': icms_base,
|
|
||||||
'cofins_base': cofins_base,
|
|
||||||
'consumo': consumo,
|
|
||||||
'tarifa': tarifa,
|
|
||||||
'nota_fiscal': nota_fiscal,
|
|
||||||
'data_processamento': datetime.now(),
|
|
||||||
'distribuidora': 'Equatorial Goiás'
|
|
||||||
}
|
|
||||||
|
|
||||||
campos_obrigatorios = ['nome', 'unidade_consumidora', 'referencia', 'nota_fiscal']
|
|
||||||
faltantes = [campo for campo in campos_obrigatorios if not dados.get(campo)]
|
|
||||||
if valor_total is None and not any(p in texto_limpo for p in ['R$***********0,00', 'R$*********0,00', 'R$*0,00']):
|
|
||||||
faltantes.append('valor_total')
|
|
||||||
|
|
||||||
if faltantes:
|
|
||||||
raise ValueError(f"Campos obrigatórios faltantes: {', '.join(faltantes)}")
|
|
||||||
|
|
||||||
return dados
|
|
||||||
|
|
||||||
|
|
||||||
136
main.py
136
main.py
@@ -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"}
|
|
||||||
)
|
|
||||||
Binary file not shown.
Binary file not shown.
80
models.py
80
models.py
@@ -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)
|
|
||||||
Binary file not shown.
93
processor.py
93
processor.py
@@ -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()
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
Relatório de Processamento - 21/07/2025 21:05:12
|
|
||||||
==================================================
|
|
||||||
|
|
||||||
Arquivo: 2020017770587.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020017828307.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020016069139.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020017302737.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020012045591.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020012935068.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020012935072.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020012935076.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020013384559.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020011592594.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020017828322.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020017828326.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020018729075.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020019161184.pdf
|
|
||||||
Status: Erro
|
|
||||||
Mensagem: Erro ao processar PDF: Campos obrigatórios faltantes: valor_total
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020022723459.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020025522861.pdf
|
|
||||||
Status: Erro
|
|
||||||
Mensagem: Erro ao processar PDF: Campos obrigatórios faltantes: valor_total
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020028540785.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020028589110.pdf
|
|
||||||
Status: Duplicado
|
|
||||||
Mensagem: Fatura já processada anteriormente.
|
|
||||||
--------------------------------------------------
|
|
||||||
Arquivo: 2020028649330.pdf
|
|
||||||
Status: Erro
|
|
||||||
Mensagem: Erro ao processar PDF: Campos obrigatórios faltantes: valor_total
|
|
||||||
--------------------------------------------------
|
|
||||||
@@ -7,3 +7,5 @@ python-multipart==0.0.6
|
|||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
pandas==2.2.2
|
pandas==2.2.2
|
||||||
PyMuPDF==1.22.5
|
PyMuPDF==1.22.5
|
||||||
|
httpx==0.27.0
|
||||||
|
xlsxwriter==3.2.0
|
||||||
@@ -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
|
|
||||||
})
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
from fastapi import APIRouter
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.future import select
|
|
||||||
from models import Fatura
|
|
||||||
from database import AsyncSessionLocal
|
|
||||||
import pandas as pd
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
@router.get("/export-excel")
|
|
||||||
async def exportar_excel():
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
result = await session.execute(select(Fatura))
|
|
||||||
faturas = result.scalars().all()
|
|
||||||
|
|
||||||
# Converte os objetos para lista de dicionários
|
|
||||||
data = [f.__dict__ for f in faturas]
|
|
||||||
for row in data:
|
|
||||||
row.pop('_sa_instance_state', None) # remove campo interno do SQLAlchemy
|
|
||||||
|
|
||||||
df = pd.DataFrame(data)
|
|
||||||
|
|
||||||
# Converte para Excel em memória
|
|
||||||
buffer = BytesIO()
|
|
||||||
with pd.ExcelWriter(buffer, engine='xlsxwriter') as writer:
|
|
||||||
df.to_excel(writer, index=False, sheet_name='Faturas')
|
|
||||||
|
|
||||||
buffer.seek(0)
|
|
||||||
return StreamingResponse(buffer, media_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
headers={"Content-Disposition": "attachment; filename=faturas.xlsx"})
|
|
||||||
@@ -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"}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
# app/relatorios.py
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from fastapi.responses import StreamingResponse
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.future import select
|
|
||||||
from database import get_session
|
|
||||||
from models import Fatura, ParametrosFormula, AliquotaUF
|
|
||||||
from io import BytesIO
|
|
||||||
import pandas as pd
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def calcular_pis_cofins_corretos(base, icms, aliquota):
|
|
||||||
try:
|
|
||||||
return round((base - (base - icms)) * aliquota, 5)
|
|
||||||
except:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/relatorio-exclusao-icms")
|
|
||||||
async def relatorio_exclusao_icms(cliente: str = Query(None), db: AsyncSession = Depends(get_session)):
|
|
||||||
faturas = db.query(Fatura).all()
|
|
||||||
dados = []
|
|
||||||
for f in faturas:
|
|
||||||
if f.base_pis == f.base_icms == f.base_cofins:
|
|
||||||
pis_corr = calcular_pis_cofins_corretos(f.base_pis, f.valor_icms, f.aliq_pis)
|
|
||||||
cofins_corr = calcular_pis_cofins_corretos(f.base_cofins, f.valor_icms, f.aliq_cofins)
|
|
||||||
dados.append({
|
|
||||||
"Classificacao": f.classificacao,
|
|
||||||
"Nome": f.nome,
|
|
||||||
"UC": f.uc,
|
|
||||||
"Competencia": f.referencia,
|
|
||||||
"Valor Total": f.valor_total,
|
|
||||||
"Alíquota PIS": f.aliq_pis,
|
|
||||||
"Alíquota ICMS": f.aliq_icms,
|
|
||||||
"Alíquota COFINS": f.aliq_cofins,
|
|
||||||
"Valor PIS": f.valor_pis,
|
|
||||||
"Valor ICMS": f.valor_icms,
|
|
||||||
"Valor COFINS": f.valor_cofins,
|
|
||||||
"Base PIS": f.base_pis,
|
|
||||||
"Base ICMS": f.base_icms,
|
|
||||||
"PIS Corrigido": pis_corr,
|
|
||||||
"COFINS Corrigido": cofins_corr,
|
|
||||||
"Arquivo": f.arquivo
|
|
||||||
})
|
|
||||||
|
|
||||||
df = pd.DataFrame(dados)
|
|
||||||
excel_file = BytesIO()
|
|
||||||
df.to_excel(excel_file, index=False)
|
|
||||||
excel_file.seek(0)
|
|
||||||
return StreamingResponse(excel_file, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": "attachment; filename=relatorio_exclusao_icms.xlsx"})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/relatorio-aliquota-incorreta")
|
|
||||||
async def relatorio_icms_errado(cliente: str = Query(None), db: AsyncSession = Depends(get_session)):
|
|
||||||
result = await db.execute(select(Fatura))
|
|
||||||
faturas = result.scalars().all()
|
|
||||||
dados = []
|
|
||||||
for f in faturas:
|
|
||||||
aliq_registrada = db.query(AliquotaUF).filter_by(uf=f.estado, exercicio=f.referencia[-4:]).first()
|
|
||||||
if aliq_registrada and abs(f.aliq_icms - aliq_registrada.aliquota) > 0.001:
|
|
||||||
icms_corr = round((f.base_icms * aliq_registrada.aliquota), 5)
|
|
||||||
dados.append({
|
|
||||||
"Classificacao": f.classificacao,
|
|
||||||
"Nome": f.nome,
|
|
||||||
"UC": f.uc,
|
|
||||||
"Competencia": f.referencia,
|
|
||||||
"Valor Total": f.valor_total,
|
|
||||||
"Alíquota ICMS (Fatura)": f.aliq_icms,
|
|
||||||
"Alíquota ICMS (Correta)": aliq_registrada.aliquota,
|
|
||||||
"Base ICMS": f.base_icms,
|
|
||||||
"Valor ICMS": f.valor_icms,
|
|
||||||
"ICMS Corrigido": icms_corr,
|
|
||||||
"Arquivo": f.arquivo
|
|
||||||
})
|
|
||||||
|
|
||||||
df = pd.DataFrame(dados)
|
|
||||||
excel_file = BytesIO()
|
|
||||||
df.to_excel(excel_file, index=False)
|
|
||||||
excel_file.seek(0)
|
|
||||||
return StreamingResponse(excel_file, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": "attachment; filename=relatorio_icms_errado.xlsx"})
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# routes/selic.py
|
|
||||||
import requests
|
|
||||||
from fastapi import APIRouter, Query, Depends
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from sqlalchemy import text
|
|
||||||
from database import get_session
|
|
||||||
from models import SelicMensal
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
BCB_API_URL = "https://api.bcb.gov.br/dados/serie/bcdata.sgs.4390/dados"
|
|
||||||
|
|
||||||
# 🔁 Função reutilizável para startup ou API
|
|
||||||
async def atualizar_selic_com_base_na_competencia(db: AsyncSession, a_partir_de: str = None):
|
|
||||||
result = await db.execute(text("SELECT MIN(referencia_competencia) FROM faturas.faturas"))
|
|
||||||
menor_comp = result.scalar()
|
|
||||||
if not menor_comp:
|
|
||||||
return {"message": "Nenhuma fatura encontrada na base."}
|
|
||||||
|
|
||||||
inicio = datetime.strptime(a_partir_de, "%m/%Y") if a_partir_de else datetime.strptime(menor_comp, "%m/%Y")
|
|
||||||
|
|
||||||
result_ultima = await db.execute(text("SELECT MAX(mes) FROM faturas.selic_mensal"))
|
|
||||||
ultima = result_ultima.scalar()
|
|
||||||
fim = datetime.today() if not ultima else max(datetime.today(), ultima + timedelta(days=31))
|
|
||||||
|
|
||||||
resultados = []
|
|
||||||
atual = inicio
|
|
||||||
while atual <= fim:
|
|
||||||
mes_ref = atual.replace(day=1)
|
|
||||||
|
|
||||||
existe = await db.execute(
|
|
||||||
text("SELECT 1 FROM faturas.selic_mensal WHERE mes = :mes"),
|
|
||||||
{"mes": mes_ref}
|
|
||||||
)
|
|
||||||
if existe.scalar():
|
|
||||||
atual += timedelta(days=32)
|
|
||||||
atual = atual.replace(day=1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
url = f"{BCB_API_URL}?formato=json&dataInicial={mes_ref.strftime('%d/%m/%Y')}&dataFinal={mes_ref.strftime('%d/%m/%Y')}"
|
|
||||||
r = requests.get(url, timeout=10)
|
|
||||||
if not r.ok:
|
|
||||||
atual += timedelta(days=32)
|
|
||||||
atual = atual.replace(day=1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
dados = r.json()
|
|
||||||
if dados:
|
|
||||||
valor = float(dados[0]['valor'].replace(',', '.')) / 100
|
|
||||||
db.add(SelicMensal(mes=mes_ref, fator=valor))
|
|
||||||
resultados.append({"mes": mes_ref.strftime("%m/%Y"), "fator": valor})
|
|
||||||
|
|
||||||
atual += timedelta(days=32)
|
|
||||||
atual = atual.replace(day=1)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
return {"message": f"Fatores SELIC atualizados com sucesso.", "novos_registros": resultados}
|
|
||||||
|
|
||||||
# 🛠️ Rota opcional reutilizando a função
|
|
||||||
@router.post("/atualizar-selic")
|
|
||||||
async def atualizar_selic(
|
|
||||||
db: AsyncSession = Depends(get_session),
|
|
||||||
a_partir_de: str = Query(None, description="Opcional: formato MM/AAAA para forçar atualização a partir de determinada data")
|
|
||||||
):
|
|
||||||
return await atualizar_selic_com_base_na_competencia(db=db, a_partir_de=a_partir_de)
|
|
||||||
10
startup.py
10
startup.py
@@ -1,10 +0,0 @@
|
|||||||
# startup.py
|
|
||||||
import logging
|
|
||||||
from routes.selic import atualizar_selic_com_base_na_competencia
|
|
||||||
|
|
||||||
async def executar_rotinas_iniciais(db):
|
|
||||||
try:
|
|
||||||
await atualizar_selic_com_base_na_competencia(db)
|
|
||||||
logging.info("✅ Tabela SELIC atualizada com sucesso na inicialização.")
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Erro ao atualizar SELIC na inicialização: {str(e)}")
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-upload-cloud" viewBox="0 0 24 24">
|
|
||||||
<path d="M16 16l-4-4-4 4"></path>
|
|
||||||
<path d="M12 12v9"></path>
|
|
||||||
<path d="M20.39 18.39A5.5 5.5 0 0 0 18 9h-1.26A8 8 0 1 0 4 16.3"></path>
|
|
||||||
<path d="M16 16l-4-4-4 4"></path>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 397 B |
@@ -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 %}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="pt-BR">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
||||||
<title>{% block title %}ProcessaWatt{% endblock %}</title>
|
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>⚡️</text></svg>">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--primary: #2563eb;
|
|
||||||
--primary-dark: #1e40af;
|
|
||||||
--sidebar-width: 250px;
|
|
||||||
--sidebar-collapsed: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: 'Montserrat', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #f8fafc;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: var(--sidebar-collapsed);
|
|
||||||
height: 100vh;
|
|
||||||
background: linear-gradient(to bottom, var(--primary), var(--primary-dark));
|
|
||||||
position: fixed;
|
|
||||||
transition: all 0.3s;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.expanded {
|
|
||||||
width: var(--sidebar-width);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-container {
|
|
||||||
height: 80px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 20px;
|
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
|
||||||
color: white;
|
|
||||||
gap: 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--primary);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.expanded .app-name {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
padding-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 50px;
|
|
||||||
color: rgba(255,255,255,0.9);
|
|
||||||
text-decoration: none;
|
|
||||||
padding-left: 20px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item:hover {
|
|
||||||
background: rgba(255,255,255,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item i {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
width: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-item span {
|
|
||||||
margin-left: 15px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.expanded .menu-item span {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
margin-left: var(--sidebar-collapsed);
|
|
||||||
padding: 30px;
|
|
||||||
flex-grow: 1;
|
|
||||||
transition: margin-left 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.expanded ~ .main-content {
|
|
||||||
margin-left: var(--sidebar-width);
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-section {
|
|
||||||
margin: 20px 0;
|
|
||||||
padding: 15px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.indicator {
|
|
||||||
padding: 10px;
|
|
||||||
background: #e5e7eb;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
margin: 30px 0;
|
|
||||||
border-top: 1px solid #eee;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="sidebar" id="sidebar">
|
|
||||||
<div class="logo-container" onclick="toggleSidebar()">
|
|
||||||
<div class="logo-icon">⚡️</div>
|
|
||||||
<div class="app-name">ProcessaWatt</div>
|
|
||||||
</div>
|
|
||||||
<div class="menu">
|
|
||||||
<a href="/" class="menu-item">
|
|
||||||
<i class="fas fa-tachometer-alt"></i>
|
|
||||||
<span>Dashboard</span>
|
|
||||||
</a>
|
|
||||||
<a href="/upload" class="menu-item">
|
|
||||||
<i class="fas fa-upload"></i>
|
|
||||||
<span>Upload</span>
|
|
||||||
</a>
|
|
||||||
<a href="/relatorios" class="menu-item">
|
|
||||||
<i class="fas fa-chart-bar"></i>
|
|
||||||
<span>Relatórios</span>
|
|
||||||
</a>
|
|
||||||
<a href="/parametros" class="menu-item">
|
|
||||||
<i class="fas fa-cog"></i>
|
|
||||||
<span>Parâmetros</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="main-content">
|
|
||||||
{% block content %}
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function toggleSidebar() {
|
|
||||||
document.getElementById('sidebar').classList.toggle('expanded');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="pt-BR">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
||||||
<title>Processador de Faturas</title>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600&display=swap" rel="stylesheet"/>
|
|
||||||
<style>
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; font-family: 'Montserrat', sans-serif; }
|
|
||||||
body { background-color: #f7f9fc; padding: 2rem; color: #333; }
|
|
||||||
|
|
||||||
.nav { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
|
|
||||||
.nav h1 { font-size: 1.5rem; color: #4361ee; }
|
|
||||||
.nav ul { display: flex; list-style: none; gap: 1.5rem; }
|
|
||||||
.nav li a { text-decoration: none; color: #333; font-weight: 600; }
|
|
||||||
.nav li a:hover { color: #4361ee; }
|
|
||||||
|
|
||||||
.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; }
|
|
||||||
.btn-secondary { background-color: #6c757d; 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
#file-input { display: none; }
|
|
||||||
|
|
||||||
.status-ok { color: #198754; }
|
|
||||||
.status-error { color: #dc3545; }
|
|
||||||
.status-warn { color: #ffc107; }
|
|
||||||
|
|
||||||
footer {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 3rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: #aaa;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<nav class="nav">
|
|
||||||
<h1>📄 Processador de Faturas</h1>
|
|
||||||
<ul>
|
|
||||||
<li><a href="/">Upload</a></li>
|
|
||||||
<li><a href="/dashboard">Dashboard</a></li>
|
|
||||||
<li><a href="/relatorios">Relatórios</a></li>
|
|
||||||
<li><a href="/parametros">Parâmetros</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<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)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="buttons">
|
|
||||||
<button class="btn btn-primary" onclick="processar()">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>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
Sistema desenvolvido para análise tributária de faturas (PIS/COFINS/ICMS) com correção SELIC.
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<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() {
|
|
||||||
if (arquivos.length === 0) return alert("Nenhum arquivo selecionado.");
|
|
||||||
const formData = new FormData();
|
|
||||||
arquivos.forEach(file => formData.append("files", file));
|
|
||||||
|
|
||||||
await fetch("/upload-files", { method: "POST", body: formData });
|
|
||||||
await fetch("/process-queue", { method: "POST" });
|
|
||||||
arquivos = [];
|
|
||||||
statusInterval = setInterval(updateStatus, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
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('/download-spreadsheet', '_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>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
DATABASE_URL = "postgresql+asyncpg://fatura:102030@ic-postgresql-FtOY:5432/app_faturas"
|
|
||||||
|
|
||||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
|
||||||
|
|
||||||
async def testar_conexao():
|
|
||||||
try:
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
result = await conn.execute(text("SELECT 1"))
|
|
||||||
row = await result.fetchone()
|
|
||||||
print("Resultado:", row)
|
|
||||||
except Exception as e:
|
|
||||||
print("Erro ao conectar no banco:", e)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(testar_conexao())
|
|
||||||
|
|
||||||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
65
utils.py
65
utils.py
@@ -1,65 +0,0 @@
|
|||||||
import os
|
|
||||||
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 sqlalchemy.future import select
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def extrair_dados_pdf(caminho_pdf):
|
|
||||||
try:
|
|
||||||
with fitz.open(caminho_pdf) as doc:
|
|
||||||
texto_final = ""
|
|
||||||
for page in doc:
|
|
||||||
blocos = page.get_text("blocks")
|
|
||||||
blocos.sort(key=lambda b: (b[1], b[0]))
|
|
||||||
for b in blocos:
|
|
||||||
texto_final += b[4] + "\n"
|
|
||||||
|
|
||||||
if not texto_final.strip():
|
|
||||||
raise ValueError("PDF não contém texto legível")
|
|
||||||
|
|
||||||
dados_extraidos = extrair_dados_equatorial(texto_final)
|
|
||||||
return dados_extraidos
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise ValueError(f"Erro ao processar PDF: {str(e)}")
|
|
||||||
|
|
||||||
async def nota_ja_existente(nota_fiscal, uc):
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Fatura).filter_by(nota_fiscal=nota_fiscal, unidade_consumidora=uc)
|
|
||||||
)
|
|
||||||
return result.scalar_one_or_none() is not None
|
|
||||||
|
|
||||||
async def adicionar_fatura(dados, caminho_pdf):
|
|
||||||
async with AsyncSessionLocal() as session:
|
|
||||||
try:
|
|
||||||
dados_calculados = await calcular_campos_dinamicos(dados, session)
|
|
||||||
fatura = Fatura(**dados_calculados)
|
|
||||||
fatura.arquivo = os.path.basename(caminho_pdf)
|
|
||||||
fatura.link_arquivo = os.path.abspath(caminho_pdf)
|
|
||||||
fatura.data_processamento = datetime.now()
|
|
||||||
|
|
||||||
session.add(fatura)
|
|
||||||
|
|
||||||
log = LogProcessamento(
|
|
||||||
status="PROCESSAMENTO",
|
|
||||||
mensagem=f"Fatura adicionada com sucesso: {fatura.nota_fiscal} - {fatura.nome}",
|
|
||||||
nome_arquivo=os.path.basename(caminho_pdf)
|
|
||||||
)
|
|
||||||
session.add(log)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
logger.info(log.mensagem)
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Erro ao adicionar fatura no banco: {e}")
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
Binary file not shown.
Reference in New Issue
Block a user