Compare commits

..

1 Commits

Author SHA1 Message Date
867be98943 feat: adiciona suporte ao .env e tasks.json para deploy local e remoto
All checks were successful
continuous-integration/drone/push Build is passing
2025-07-28 20:48:03 -03:00
94 changed files with 7991 additions and 1725 deletions

View File

@@ -19,6 +19,7 @@ steps:
target: /home/app_fatura_homolog target: /home/app_fatura_homolog
rm: false rm: false
- name: restart homolog container - name: restart homolog container
image: appleboy/drone-ssh image: appleboy/drone-ssh
settings: settings:
@@ -27,9 +28,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 -f docker-compose-homolog.yml down - docker compose -f docker-compose-homolog.yml up -d
- docker compose -f docker-compose-homolog.yml up -d --build
--- ---
kind: pipeline kind: pipeline

1
.gitignore vendored
View File

@@ -3,4 +3,3 @@ __pycache__/
*.pyc *.pyc
.venv/ .venv/
.vscode/ .vscode/
uploads/

BIN
.main.py.swn Normal file

Binary file not shown.

BIN
.main.py.swo Normal file

Binary file not shown.

BIN
.main.py.swp Normal file

Binary file not shown.

0
= Normal file
View File

0
CACHED Normal file
View File

View File

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

0
[internal] Normal file
View File

5834
app.log Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
from decimal import Decimal from decimal import Decimal
from datetime import datetime from datetime import datetime
from app.models import ParametrosFormula, SelicMensal from 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

View File

@@ -3,16 +3,16 @@ from sqlalchemy.orm import sessionmaker, declarative_base
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dotenv import load_dotenv from dotenv import load_dotenv
import os import os
from collections.abc import AsyncGenerator
load_dotenv() load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL") DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_async_engine(DATABASE_URL) async_engine = create_async_engine(DATABASE_URL, echo=False, future=True)
AsyncSessionLocal = sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False) AsyncSessionLocal = sessionmaker(bind=async_engine, class_=AsyncSession, expire_on_commit=False)
Base = declarative_base() Base = declarative_base()
async def get_session() -> AsyncGenerator[AsyncSession, None]: @asynccontextmanager
async def get_session():
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
yield session yield session

View File

@@ -1,35 +1,28 @@
import asyncio
import uuid import uuid
from fastapi import FastAPI, HTTPException, Request, UploadFile, File from fastapi import FastAPI, Request, UploadFile, File
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 fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from io import BytesIO from io import BytesIO
import pandas as pd import pandas as pd
from app.models import ParametrosFormula
from sqlalchemy.future import select from sqlalchemy.future import select
from app.database import AsyncSessionLocal from database import AsyncSessionLocal
from app.models import Fatura from models import Fatura
from app.processor import ( from models import ParametrosFormula
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 app.parametros import router as parametros_router
from fastapi.responses import FileResponse
from app.models import Fatura, SelicMensal, ParametrosFormula
from datetime import date
from app.utils import avaliar_formula
app = FastAPI() app = FastAPI()
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/static", StaticFiles(directory="static"), name="static")
UPLOAD_DIR = "uploads/temp" UPLOAD_DIR = "uploads/temp"
os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(UPLOAD_DIR, exist_ok=True)
@@ -57,16 +50,28 @@ def dashboard(request: Request):
@app.get("/upload", response_class=HTMLResponse) @app.get("/upload", response_class=HTMLResponse)
def upload_page(request: Request): def upload_page(request: Request):
app_env = os.getenv("APP_ENV", "dev") # Captura variável de ambiente return templates.TemplateResponse("upload.html", {"request": request})
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): def relatorios_page(request: Request):
return templates.TemplateResponse("relatorios.html", {"request": request}) return templates.TemplateResponse("relatorios.html", {"request": request})
@app.get("/parametros", response_class=HTMLResponse)
async def parametros_page(request: Request):
async with AsyncSessionLocal() as session:
result = await session.execute(select(ParametrosFormula).limit(1))
parametros = result.scalar_one_or_none()
return templates.TemplateResponse("parametros.html", {
"request": request,
"parametros": parametros or SimpleNamespace(
aliquota_icms=None,
incluir_icms=True,
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(files: list[UploadFile] = File(...)):
for file in files: for file in files:
@@ -88,27 +93,14 @@ 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():
if isinstance(status, dict):
files.append({
"nome": nome,
"status": status.get("status", "Erro"),
"mensagem": status.get("mensagem", "---"),
"tempo": status.get("tempo", "---"),
"tamanho": f"{status.get('tamanho', 0)} KB",
"data": status.get("data", "")
})
else:
files.append({ files.append({
"nome": nome, "nome": nome,
"status": status, "status": status,
"mensagem": "---" if status == "Concluído" else 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()
@@ -119,72 +111,11 @@ async def clear_all():
@app.get("/export-excel") @app.get("/export-excel")
async def export_excel(): async def export_excel():
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
# 1. Coletar faturas e tabela SELIC result = await session.execute(select(Fatura))
faturas_result = await session.execute(select(Fatura)) faturas = result.scalars().all()
faturas = faturas_result.scalars().all()
selic_result = await session.execute(select(SelicMensal))
selic_tabela = selic_result.scalars().all()
# 2. Criar mapa {(ano, mes): percentual}
selic_map = {(s.ano, s.mes): float(s.percentual) for s in selic_tabela}
hoje = date.today()
def calcular_fator_selic(ano_inicio, mes_inicio):
fator = 1.0
ano, mes = ano_inicio, mes_inicio
while (ano < hoje.year) or (ano == hoje.year and mes <= hoje.month):
percentual = selic_map.get((ano, mes))
if percentual:
fator *= (1 + percentual / 100)
mes += 1
if mes > 12:
mes = 1
ano += 1
return fator
# 3. Buscar fórmulas exatas por nome
formula_pis_result = await session.execute(
select(ParametrosFormula.formula).where(
ParametrosFormula.nome == "Cálculo PIS sobre ICMS",
ParametrosFormula.ativo == True
).limit(1)
)
formula_cofins_result = await session.execute(
select(ParametrosFormula.formula).where(
ParametrosFormula.nome == "Cálculo COFINS sobre ICMS",
ParametrosFormula.ativo == True
).limit(1)
)
formula_pis = formula_pis_result.scalar_one_or_none()
formula_cofins = formula_cofins_result.scalar_one_or_none()
# 4. Montar dados
mes_map = {
'JAN': 1, 'FEV': 2, 'MAR': 3, 'ABR': 4, 'MAI': 5, 'JUN': 6,
'JUL': 7, 'AGO': 8, 'SET': 9, 'OUT': 10, 'NOV': 11, 'DEZ': 12
}
dados = [] dados = []
for f in faturas: for f in faturas:
try:
if "/" in f.referencia:
mes_str, ano_str = f.referencia.split("/")
mes = mes_map.get(mes_str.strip().upper())
ano = int(ano_str)
if not mes or not ano:
raise ValueError("Mês ou ano inválido")
else:
ano = int(f.referencia[:4])
mes = int(f.referencia[4:])
fator = calcular_fator_selic(ano, mes)
periodo = f"{mes:02d}/{ano} à {hoje.month:02d}/{hoje.year}"
contexto = f.__dict__
valor_pis_icms = avaliar_formula(formula_pis, contexto) if formula_pis else None
valor_cofins_icms = avaliar_formula(formula_cofins, contexto) if formula_cofins else None
dados.append({ dados.append({
"Nome": f.nome, "Nome": f.nome,
"UC": f.unidade_consumidora, "UC": f.unidade_consumidora,
@@ -206,79 +137,61 @@ async def export_excel():
"Estado": f.estado, "Estado": f.estado,
"Distribuidora": f.distribuidora, "Distribuidora": f.distribuidora,
"Data Processamento": f.data_processamento, "Data Processamento": f.data_processamento,
"Fator SELIC acumulado": fator,
"Período SELIC usado": periodo,
"PIS sobre ICMS": valor_pis_icms,
"Valor Corrigido PIS (ICMS)": valor_pis_icms * fator if valor_pis_icms else None,
"COFINS sobre ICMS": valor_cofins_icms,
"Valor Corrigido COFINS (ICMS)": valor_cofins_icms * fator if valor_cofins_icms else None,
}) })
except Exception as e:
print(f"Erro ao processar fatura {f.nota_fiscal}: {e}")
df = pd.DataFrame(dados) df = pd.DataFrame(dados)
output = BytesIO() output = BytesIO()
with pd.ExcelWriter(output, engine="xlsxwriter") as writer: df.to_excel(output, index=False, sheet_name="Faturas")
df.to_excel(writer, index=False, sheet_name="Faturas Corrigidas")
output.seek(0) output.seek(0)
return StreamingResponse(output, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={
"Content-Disposition": "attachment; filename=faturas_corrigidas.xlsx"
})
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=relatorio_faturas.xlsx"}
)
from app.parametros import router as parametros_router @app.post("/parametros", response_class=HTMLResponse)
app.include_router(parametros_router) async def salvar_parametros(
request: Request,
def is_homolog(): aliquota_icms: float = Form(...),
return os.getenv("APP_ENV", "dev") == "homolog" formula_pis: str = Form(...),
formula_cofins: str = Form(...)
@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: async with AsyncSessionLocal() as session:
print("🧪 Limpando faturas do banco...") result = await session.execute(select(ParametrosFormula).limit(1))
await session.execute(text("DELETE FROM faturas.faturas")) existente = result.scalar_one_or_none()
if existente:
existente.aliquota_icms = aliquota_icms
existente.incluir_icms = 1
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() await session.commit()
upload_path = os.path.join("app", "uploads") parametros = SimpleNamespace(
for nome in os.listdir(upload_path): aliquota_icms=aliquota_icms,
caminho = os.path.join(upload_path, nome) incluir_icms=1,
if os.path.isfile(caminho): incluir_pis=1,
os.remove(caminho) incluir_cofins=1,
formula_pis=formula_pis,
formula_cofins=formula_cofins
)
return {"message": "Faturas e arquivos apagados com sucesso."} return templates.TemplateResponse("parametros.html", {
"request": request,
@app.get("/erros/download") "parametros": parametros,
async def download_erros(): "mensagem": mensagem
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)

View File

@@ -3,19 +3,21 @@ from sqlalchemy import Column, String, Integer, Float, DateTime, Text
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 app.database import Base from 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"} __table_args__ = {'schema': 'faturas', 'extend_existing': True}
id = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True, autoincrement=True)
nome = Column(String(50)) nome = Column(String)
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"
@@ -71,12 +73,13 @@ 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(String)
aliq_icms = Column(Numeric(6, 4)) aliquota = Column(Float)
class SelicMensal(Base): class SelicMensal(Base):
__tablename__ = "selic_mensal" __tablename__ = "selic_mensal"
__table_args__ = {'schema': 'faturas'} __table_args__ = {'schema': 'faturas'}
ano = Column(Integer, primary_key=True) id = Column(Integer, primary_key=True, autoincrement=True)
mes = Column(Integer, primary_key=True) ano = Column(Integer)
percentual = Column(Numeric(6, 4)) mes = Column(Integer)
fator = Column(Float)

View File

@@ -1,230 +0,0 @@
# parametros.py
from fastapi import APIRouter, Request, Depends, Form
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
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
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 ===
templates = Jinja2Templates(directory="app/templates")
@router.get("/parametros")
async def parametros_page(request: Request):
async with AsyncSessionLocal() as session:
# Consulta das fórmulas
result = await session.execute(select(ParametrosFormula))
parametros = result.scalars().all()
# Consulta da tabela selic_mensal
selic_result = await session.execute(
select(SelicMensal).order_by(SelicMensal.ano.desc(), SelicMensal.mes.desc())
)
selic_dados = selic_result.scalars().all()
# Pega última data
ultima_data_selic = "-"
if selic_dados:
ultima = selic_dados[0]
ultima_data_selic = f"{ultima.mes:02d}/{ultima.ano}"
# Campos numéricos da fatura
campos = [
col.name for col in Fatura.__table__.columns
if col.type.__class__.__name__ in ['Integer', 'Float', 'Numeric']
]
return templates.TemplateResponse("parametros.html", {
"request": request,
"lista_parametros": parametros,
"parametros": {},
"campos_fatura": campos,
"selic_dados": selic_dados,
"ultima_data_selic": ultima_data_selic
})
@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.tipo = data.get("tipo", param.tipo)
param.formula = data.get("formula", param.formula)
await session.commit()
return {"success": True}
return {"success": False, "error": "Não encontrado"}
@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", response_model=List[AliquotaUFSchema])
async def listar_aliquotas(db: AsyncSession = Depends(get_session)):
result = await db.execute(select(AliquotaUF).order_by(AliquotaUF.uf, AliquotaUF.exercicio))
return result.scalars().all()
@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.tipo))
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(tipo=form.tipo)
)
existente = result.scalar_one_or_none()
if existente:
existente.formula = form.formula
existente.campos = form.campos
else:
novo = ParametrosFormula(**form.dict())
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"}
)

View File

@@ -2,22 +2,14 @@ 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 app.utils import extrair_dados_pdf from utils import extrair_dados_pdf
from app.database import AsyncSessionLocal from database import AsyncSessionLocal
from app.models import Fatura, LogProcessamento from 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("app", "uploads") UPLOADS_DIR = os.path.join(os.getcwd(), "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()
@@ -32,166 +24,70 @@ 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}_{uuid.uuid4().hex[:6]}{extensao}" nome_destino = f"{nota_fiscal}{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):
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
# Verifica se a fatura já existe
existente_result = await session.execute( existente_result = await session.execute(
select(Fatura).filter_by( select(Fatura).filter_by(nota_fiscal=dados['nota_fiscal'], unidade_consumidora=dados['unidade_consumidora'])
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 { return {"status": "Duplicado", "dados": dados}
"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
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)
duracao = round(time.perf_counter() - inicio, 2) return {"status": "Concluído", "dados": dados}
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()
try:
resultado = await process_single_file(item['caminho_pdf'], item['nome_original']) resultado = await process_single_file(item['caminho_pdf'], item['nome_original'])
status_arquivos[item['nome_original']] = { status_arquivos[item['nome_original']] = resultado.get("status", "Erro")
"status": resultado.get("status"), resultados.append(resultado)
"mensagem": resultado.get("mensagem", ""),
"tempo": resultado.get("tempo", "---"),
"tamanho": os.path.getsize(item['caminho_pdf']) // 1024, # tamanho em KB
"data": time.strftime("%d/%m/%Y", time.localtime(os.path.getmtime(item['caminho_pdf'])))
}
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:
with open(os.path.join(UPLOADS_DIR, "erros", "erros.txt"), "w", encoding="utf-8") as f:
f.write("\n".join(erros_txt))
# Compacta PDFs com erro
with ZipFile(os.path.join(UPLOADS_DIR, "erros", "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 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, fator=percentual)
session.add(novo)
await session.commit()

9
app/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
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

83
app/routes/parametros.py Executable file
View File

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

View File

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

View File

@@ -1,679 +1,32 @@
{% 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>
<h1 style="font-size: 1.6rem; margin-bottom: 1rem; display:flex; align-items:center; gap:0.5rem;"> <form method="post">
⚙️ Parâmetros de Cálculo <label for="tipo">Tipo:</label><br>
</h1> <input type="text" name="tipo" id="tipo" value="{{ parametros.tipo or '' }}" required/><br><br>
<div class="tabs"> <label for="formula">Fórmula:</label><br>
<button class="tab active" onclick="switchTab('formulas')">📄 Fórmulas</button> <input type="text" name="formula" id="formula" value="{{ parametros.formula or '' }}" required/><br><br>
<button class="tab" onclick="switchTab('selic')">📊 Gestão SELIC</button>
<button class="tab" onclick="switchTab('aliquotas')">🧾 Cadastro de Alíquotas por Estado</button>
</div>
<!-- ABA FÓRMULAS --> <label for="aliquota_icms">Alíquota de ICMS (%):</label><br>
<div id="formulas" class="tab-content active"> <input type="number" step="0.01" name="aliquota_icms" id="aliquota_icms" value="{{ parametros.aliquota_icms or '' }}" /><br><br>
<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="number" step="0.01" name="aliquota_icms" id="aliquota_icms" value="{{ parametros.aliquota_icms or '' }}" />
</div>
</div>
<div class="form-group"> <label for="incluir_icms">Incluir ICMS:</label><br>
<label for="formula">Fórmula:</label> <input type="checkbox" name="incluir_icms" id="incluir_icms" value="1" {% if parametros.incluir_icms %}checked{% endif %}><br><br>
<div class="editor-box">
<div style="margin-bottom: 0.5rem;">
<strong>Campos disponíveis:</strong>
<div class="campo-badges">
{% for campo in campos_fatura %}
<span class="badge-campo" onclick="inserirNoEditor('{{ campo }}')">{{ campo }}</span>
{% endfor %}
</div>
</div>
<div style="margin-bottom: 0.5rem;"> <label for="ativo">Ativo:</label><br>
<strong>Operadores:</strong> <input type="checkbox" name="ativo" id="ativo" value="1" {% if parametros.ativo %}checked{% endif %}><br><br>
<div class="campo-badges">
{% for op in ['+', '-', '*', '/', '(', ')'] %}
<span class="badge-operador" onclick="inserirNoEditor('{{ op }}')">{{ op }}</span>
{% endfor %}
</div>
</div>
<textarea name="formula" id="formula" rows="3" required>{{ parametros.formula or '' }}</textarea>
<div class="actions-inline">
<button type="button" class="btn btn-secondary" onclick="testarFormula()">🧪 Testar Fórmula</button>
<span class="exemplo">Ex: (pis_base - (pis_base - icms_valor)) * pis_aliq</span>
</div>
<div id="resultado-teste" class="mensagem-info" style="display:none;"></div>
</div>
</div>
<button type="submit" class="btn btn-primary">💾 Salvar Parâmetro</button> <button type="submit" style="padding: 10px 20px; background: #2563eb; color: white; border: none; border-radius: 4px; cursor: pointer;">
<button type="button" class="btn btn-primary pulse" onclick="limparFormulario()">🔁 Novo Parâmetro</button> Salvar Parâmetros
</button>
</form>
<hr style="margin-top: 2rem; margin-bottom: 1rem;">
<h3 style="margin-top: 2rem;">📋 Fórmulas Salvas</h3>
<div class="card-list">
{% for param in lista_parametros %}
<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>
<!-- botão de testar e salvar -->
<div style="display: flex; justify-content: space-between; align-items:center;">
<label>
<input type="checkbox" class="toggle-ativo" data-id="{{ param.id }}" {% if param.ativo %}checked{% endif %}>
Ativo
</label>
<div class="actions">
<button type="button" class="btn btn-sm btn-secondary btn-testar" data-id="{{ param.id }}">🧪 Testar</button>
<button class="btn btn-sm btn-primary" onclick="salvarInline('{{ param.id }}')">💾 Salvar</button>
<a href="/parametros/delete/{{ param.id }}" class="btn btn-sm btn-danger" onclick="return confirm('Deseja excluir?')">🗑️ Excluir</a>
</div>
</div>
<div class="mensagem-info" id="resultado-inline-{{ param.id }}" style="margin-top: 0.5rem; display:none;"></div>
</div>
{% 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 }}</strong></div>
</form>
<table class="selic-table">
<thead><tr><th>Competência</th><th>Fator</th></tr></thead>
<tbody>
{% for item in selic_dados %}
<tr>
<td>{{ "%02d"|format(item.mes) }}/{{ item.ano }}</td>
<td>{{ "%.4f"|format(item.percentual) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- ABA ALÍQUOTAS -->
<div id="aliquotas" class="tab-content">
<div class="formulario-box">
<form onsubmit="return salvarAliquota(this)">
<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 name="aliquota" type="number" step="0.0001" 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>
</form> </form>
<table class="selic-table"> {% if mensagem %}
<thead><tr><th>UF</th><th>Exercício</th><th>Alíquota</th></tr></thead> <div style="margin-top: 20px; background: #e0f7fa; padding: 10px; border-left: 4px solid #2563eb;">
<tbody id="tabela-aliquotas"></tbody> {{ mensagem }}
</table>
</div> </div>
</div> {% endif %}
<!-- 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 res = await fetch("/parametros/aliquotas");
const dados = await res.json();
const tbody = document.getElementById("tabela-aliquotas");
tbody.innerHTML = dados.map(a => `
<tr><td>${a.uf}</td><td>${a.exercicio}</td><td>${a.aliquota.toFixed(4)}%</td></tr>
`).join('');
}
// ✅ Eventos após carregar DOM
document.addEventListener('DOMContentLoaded', () => {
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 abaUrl = new URLSearchParams(window.location.search).get("aba");
if (abaUrl === "selic") {
document.querySelector(".tab.active")?.classList.remove("active");
document.querySelector(".tab-content.active")?.classList.remove("active");
document.querySelector(".tab:nth-child(2)").classList.add("active"); // Ativa o botão da aba
document.getElementById("selic").classList.add("active"); // Ativa o conteúdo da aba
}
});
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.");
}
});
}
</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 %}

View File

@@ -8,192 +8,74 @@
<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" id="btn-selecionar" onclick="document.getElementById('file-input').click()">Selecionar Arquivos</button> <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;" /> <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-primary pulse" onclick="limpar()" style="font-weight: bold;">🔁 Novo Processo</button> <button class="btn btn-danger" onclick="limpar()">Limpar Tudo</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>
<div id="overlay-bloqueio" class="overlay-bloqueio hidden">
⏳ Tabela bloqueada até finalizar o processo <table>
<div id="barra-progresso" class="barra-processamento"></div> <thead>
</div> <tr>
<div id="tabela-wrapper" class="tabela-wrapper"></div> <th>Arquivo</th>
</div> <th>Status</th>
ar <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');
function handleFiles(files) { function handleFiles(files) {
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];
renderTable(); renderTable();
} }
function renderTable(statusList = []) { function renderTable(statusList = []) {
const grupos = ['Aguardando', 'Enviado', 'Erro', 'Duplicado']; const rows = statusList.length ? statusList : arquivos.map(file => ({ nome: file.name, status: 'Aguardando', mensagem: '' }));
const dados = statusList.length ? statusList : arquivos.map(file => ({ fileTable.innerHTML = rows.length
nome: file.name, ? rows.map(file => `
status: 'Aguardando',
mensagem: '',
tempo: '---',
tamanho: (file.size / 1024).toFixed(1) + " KB",
data: new Date(file.lastModified).toLocaleDateString()
}));
const htmlGrupos = grupos.map(grupo => {
const rows = dados.filter(f => f.status === grupo);
if (!rows.length) return '';
const linhas = rows.map(file => `
<tr> <tr>
<td>${file.nome}<br><small>${file.tamanho}${file.data}</small></td> <td>${file.nome}</td>
<td class="${grupo === 'Concluído' ? 'status-ok' : <td class="${file.status === 'Concluido' ? 'status-ok' : file.status === 'Erro' ? 'status-error' : 'status-warn'}">${file.status}</td>
grupo === 'Erro' ? 'status-error' : <td>${file.mensagem || '---'}</td>
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> </tr>
`).join(''); `).join('')
: '<tr><td colspan="3" style="text-align:center; color: #aaa;">Nenhum arquivo selecionado.</td></tr>';
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) { async function processar(btn) {
if (arquivos.length === 0) return alert("Nenhum arquivo selecionado."); if (arquivos.length === 0) return alert("Nenhum arquivo selecionado.");
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.disabled = true;
btn.innerText = "⏳ Enviando arquivos..."; btn.innerText = "⏳ Processando...";
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];
const formData = new FormData(); const formData = new FormData();
formData.append("files", file); arquivos.forEach(file => formData.append("files", file));
// Atualiza status visual antes do envio
statusList.push({
nome: file.name,
status: "Enviando...",
mensagem: `(${i + 1}/${total})`,
tempo: "---"
});
renderTable(statusList);
const start = performance.now();
try { try {
await fetch("/upload-files", { method: "POST", body: formData }); await fetch("/upload-files", { method: "POST", body: formData });
let progresso = Math.round(((i + 1) / total) * 100); await fetch("/process-queue", { method: "POST" });
document.getElementById("barra-progresso").style.width = `${progresso}%`; arquivos = [];
statusList[i].status = "Enviado";
statusList[i].tempo = `${((performance.now() - start) / 1000).toFixed(2)}s`;
} catch (err) {
statusList[i].status = "Erro";
statusList[i].mensagem = err.message;
}
renderTable(statusList);
// Delay de 200ms entre cada envio
await new Promise(r => setTimeout(r, 200));
}
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); statusInterval = setInterval(updateStatus, 1000);
} catch (err) {
// Mensagem final após pequeno delay alert("Erro ao processar faturas.");
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 { } finally {
processado = true; setTimeout(() => {
document.getElementById("btn-selecionar").disabled = true;
btn.innerText = "Processar Faturas"; btn.innerText = "Processar Faturas";
btn.disabled = false; btn.disabled = false;
}, 1500);
} }
} }
async function updateStatus() { async function updateStatus() {
@@ -209,19 +91,7 @@ async function processar(btn) {
function limpar() { function limpar() {
fetch("/clear-all", { method: "POST" }); fetch("/clear-all", { method: "POST" });
arquivos = []; arquivos = [];
processado = false;
document.getElementById("file-input").value = null;
renderTable(); renderTable();
// limpa feedback visual também
document.getElementById("upload-feedback").classList.add("hidden");
document.getElementById("feedback-sucesso").innerText = "";
document.getElementById("feedback-erro").innerText = "";
document.getElementById("feedback-duplicado").innerText = "";
document.getElementById("tabela-wrapper").classList.remove("bloqueada");
processamentoFinalizado = false;
document.getElementById("btn-selecionar").disabled = false;
} }
function baixarPlanilha() { function baixarPlanilha() {
@@ -232,63 +102,21 @@ async function processar(btn) {
window.open('/generate-report', '_blank'); window.open('/generate-report', '_blank');
} }
window.addEventListener('DOMContentLoaded', () => { const dropZone = document.getElementById('upload-box');
updateStatus(); dropZone.addEventListener('dragover', (e) => {
const dragOverlay = document.getElementById("drag-overlay");
let dragCounter = 0;
window.addEventListener("dragenter", e => {
dragCounter++;
dragOverlay.classList.add("active");
});
window.addEventListener("dragleave", e => {
dragCounter--;
if (dragCounter <= 0) {
dragOverlay.classList.remove("active");
dragCounter = 0;
}
});
window.addEventListener("dragover", e => {
e.preventDefault(); e.preventDefault();
dropZone.classList.add('dragover');
}); });
dropZone.addEventListener('dragleave', () => {
window.addEventListener("drop", e => { dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
dragOverlay.classList.remove("active"); dropZone.classList.remove('dragover');
dragCounter = 0;
handleFiles(e.dataTransfer.files); 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%";
}
window.addEventListener('DOMContentLoaded', updateStatus);
</script> </script>
<style> <style>
@@ -351,207 +179,6 @@ function fecharFeedback() {
.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 %}

View File

@@ -3,10 +3,10 @@ import fitz
import logging import logging
import re import re
from datetime import datetime from datetime import datetime
from app.database import AsyncSessionLocal from database import AsyncSessionLocal
from app.models import Fatura, LogProcessamento from models import Fatura, LogProcessamento
from app.calculos import calcular_campos_dinamicos from calculos import calcular_campos_dinamicos
from app.layouts.equatorial_go import extrair_dados as extrair_dados_equatorial from 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,9 +63,3 @@ 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

BIN
build.log

Binary file not shown.

92
calculos.py Executable file
View File

@@ -0,0 +1,92 @@
# 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')

View File

@@ -1,29 +0,0 @@
# 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 Executable file
View File

@@ -0,0 +1,15 @@
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

View File

@@ -1,14 +1,4 @@
# deploy-homolog.ps1 $message = Read-Host "Digite a descrição do commit para homologação"
$source = "$PSScriptRoot\app\*" git add .
$destination = "\\216.22.5.141\home\app_fatura_homolog\app" git commit -m "$message"
git push origin main
# (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."

Binary file not shown.

BIN
drone Executable file

Binary file not shown.

Binary file not shown.

BIN
dummy.txt

Binary file not shown.

0
exporting Normal file
View File

132
layouts/equatorial_go.py Executable file
View File

@@ -0,0 +1,132 @@
# 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 Normal file
View File

@@ -0,0 +1,136 @@
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 Executable file
View File

@@ -0,0 +1,80 @@
# 📄 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)

0
naming Normal file
View File

BIN
planilha_faturas.xlsx Normal file

Binary file not shown.

93
processor.py Normal file
View File

@@ -0,0 +1,93 @@
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()

0
reading Normal file
View File

View File

@@ -0,0 +1,79 @@
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
--------------------------------------------------

View File

@@ -7,4 +7,3 @@ 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

0
resolving Normal file
View File

90
routes/dashboard.py Executable file
View File

@@ -0,0 +1,90 @@
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
})

32
routes/export.py Executable file
View File

@@ -0,0 +1,32 @@
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"})

83
routes/parametros.py Executable file
View File

@@ -0,0 +1,83 @@
# 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"}

84
routes/relatorios.py Executable file
View File

@@ -0,0 +1,84 @@
# 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"})

66
routes/selic.py Executable file
View File

@@ -0,0 +1,66 @@
# 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 Executable file
View File

@@ -0,0 +1,10 @@
# 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)}")

6
static/cloud-upload.svg Normal file
View File

@@ -0,0 +1,6 @@
<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>

After

Width:  |  Height:  |  Size: 397 B

107
templates/dashboard.html Executable file
View File

@@ -0,0 +1,107 @@
{% 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 %}

184
templates/index.html Normal file
View File

@@ -0,0 +1,184 @@
<!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>

201
templates/index2.html Executable file
View File

@@ -0,0 +1,201 @@
<!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>

26
templates/parametros.html Executable file
View File

@@ -0,0 +1,26 @@
{% 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 %}

38
templates/relatorios.html Executable file
View File

@@ -0,0 +1,38 @@
{% 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 %}

184
templates/upload.html Executable file
View File

@@ -0,0 +1,184 @@
{% 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 %}

20
testar_fatura.py Executable file
View File

@@ -0,0 +1,20 @@
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())

0
transferring Normal file
View File

1
trigger.txt Normal file
View File

@@ -0,0 +1 @@
# Trigger test Mon Jul 28 07:10:34 PM -03 2025

BIN
uploads/temp/2020016069139.pdf Executable file

Binary file not shown.

65
utils.py Executable file
View File

@@ -0,0 +1,65 @@
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

0
writing Normal file
View File

BIN
~$planilha_faturas.xlsx Executable file

Binary file not shown.