Compare commits

...

10 Commits

Author SHA1 Message Date
b64068cfb6 Busca correta da unidade cosumidora na fatura
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-14 11:22:38 -03:00
98c6cf2363 Correção arredondamento dasalíquotas e valor taxa
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-14 08:44:41 -03:00
f6c8943d4e Seis casas decimais para estes campos: "ICMS (%)", "ICMS (%) (UF/Ref)", "Dif. ICMS (pp)", "PIS (%)", "COFINS (%)", "Consumo (kWh)"
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-13 23:10:11 -03:00
5a9fb44bdb Inclusão da coluna Arquivo PDF nas exportações em excel
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-13 22:37:09 -03:00
e6c0155758 Quatro casas decimais ao gerar o excel para os campos ICMS (%), ICMS (%) (UF/Ref), Dif. ICMS (pp), PIS (%), COFINS (%)
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-13 21:51:49 -03:00
fb08efed1d Ajustado abas tela parâmetro
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-11 23:02:55 -03:00
bc05faafea Correção erro em parametros.html
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-11 20:54:47 -03:00
4d2fcff4a8 Cadastro da alíquota do ICMS correta. Inclusão da nova alíquota e comparação em todos os relatórios.
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-11 18:45:57 -03:00
950eb2a826 Criação da tela de clientes e relatórios
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-11 13:14:54 -03:00
bcf9861e97 Criação da tela de clientes.
All checks were successful
continuous-integration/drone/push Build is passing
2025-08-09 19:51:14 -03:00
13 changed files with 1704 additions and 292 deletions

View File

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

View File

@@ -1,6 +1,6 @@
import asyncio import asyncio
import uuid import uuid
from fastapi import FastAPI, HTTPException, Request, UploadFile, File from fastapi import FastAPI, HTTPException, Request, UploadFile, File, Depends, Form
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -24,39 +24,44 @@ from fastapi.responses import FileResponse
from app.models import Fatura, SelicMensal, ParametrosFormula from app.models import Fatura, SelicMensal, ParametrosFormula
from datetime import date from datetime import date
from app.utils import avaliar_formula from app.utils import avaliar_formula
from app.routes import clientes
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_session
from fastapi import Query
from sqlalchemy import select as sqla_select
from app.models import AliquotaUF
import pandas as pd
from fastapi.responses import Response
app = FastAPI() app = FastAPI()
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
app.state.templates = templates
app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/static", StaticFiles(directory="app/static"), name="static")
UPLOAD_DIR = "uploads/temp" UPLOAD_DIR = os.path.join("app", "uploads", "temp")
os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(UPLOAD_DIR, exist_ok=True)
def _parse_referencia(ref: str): def _parse_referencia(ref: str):
"""Aceita 'JAN/2024', 'JAN/24', '01/2024', '01/24', '202401'. Retorna (ano, mes).""" """Aceita 'JAN/2024', '01/2024', '202401' etc. Retorna (ano, mes)."""
meses = {'JAN':1,'FEV':2,'MAR':3,'ABR':4,'MAI':5,'JUN':6,'JUL':7,'AGO':8,'SET':9,'OUT':10,'NOV':11,'DEZ':12} meses = {'JAN':1,'FEV':2,'MAR':3,'ABR':4,'MAI':5,'JUN':6,'JUL':7,'AGO':8,'SET':9,'OUT':10,'NOV':11,'DEZ':12}
ref = (ref or "").strip().upper() ref = (ref or "").strip().upper()
if "/" in ref: if "/" in ref:
a, b = [p.strip() for p in ref.split("/", 1)] a, b = [p.strip() for p in ref.split("/", 1)]
# mês pode vir 'JAN' ou '01'
mes = meses.get(a, None) mes = meses.get(a, None)
if mes is None: if mes is None:
mes = int(re.sub(r"\D", "", a) or 1) mes = int(re.sub(r"\D", "", a) or 1)
ano = int(re.sub(r"\D", "", b) or 0) ano = int(re.sub(r"\D", "", b) or 0)
# ano 2 dígitos -> 2000+
if ano < 100: if ano < 100:
ano += 2000 ano += 2000
else: else:
# '202401' ou '2024-01'
num = re.sub(r"\D", "", ref) num = re.sub(r"\D", "", ref)
if len(num) >= 6: if len(num) >= 6:
ano, mes = int(num[:4]), int(num[4:6]) ano, mes = int(num[:4]), int(num[4:6])
elif len(num) == 4: # '2024' elif len(num) == 4:
ano, mes = int(num), 1 ano, mes = int(num), 1
else: else:
ano, mes = date.today().year, 1 ano, mes = 0, 0
return ano, mes return ano, mes
async def _carregar_selic_map(session): async def _carregar_selic_map(session):
@@ -97,7 +102,27 @@ def _avaliar_formula(texto_formula: str | None, contexto: dict) -> float:
# aceita vírgula como decimal vindo do banco # aceita vírgula como decimal vindo do banco
if isinstance(v, str): if isinstance(v, str):
v = v.replace(".", "").replace(",", ".") if re.search(r"[0-9],[0-9]", v) else v v = v.replace(".", "").replace(",", ".") if re.search(r"[0-9],[0-9]", v) else v
expr = re.sub(rf'\b{re.escape(str(campo))}\b', str(v), expr) # nome do campo escapado na regex
pat = rf'\b{re.escape(str(campo))}\b'
# normaliza o valor para número; se não der, vira 0
val = v
if val is None or val == "":
num = 0.0
else:
if isinstance(val, str):
# troca vírgula decimal e remove separador de milhar simples
val_norm = val.replace(".", "").replace(",", ".")
else:
val_norm = val
try:
num = float(val_norm)
except Exception:
num = 0.0
# usa lambda para evitar interpretação de backslashes no replacement
expr = re.sub(pat, lambda m: str(num), expr)
try: try:
return float(eval(expr, {"__builtins__": {}}, {})) return float(eval(expr, {"__builtins__": {}}, {}))
@@ -111,10 +136,14 @@ async def dashboard(request: Request, cliente: str | None = None):
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
print("DBG /: abrindo sessão", flush=True) print("DBG /: abrindo sessão", flush=True)
r = await session.execute(text( r = await session.execute(text("""
"SELECT DISTINCT nome FROM faturas.faturas ORDER BY nome" SELECT id, nome_fantasia
)) FROM faturas.clientes
clientes = [c for c, in r.fetchall()] WHERE ativo = TRUE
ORDER BY nome_fantasia
"""))
clientes = [{"id": id_, "nome": nome} for id_, nome in r.fetchall()]
print(f"DBG /: clientes={len(clientes)}", flush=True) print(f"DBG /: clientes={len(clientes)}", flush=True)
# Fórmulas # Fórmulas
@@ -133,7 +162,7 @@ async def dashboard(request: Request, cliente: str | None = None):
sql = "SELECT * FROM faturas.faturas" sql = "SELECT * FROM faturas.faturas"
params = {} params = {}
if cliente: if cliente:
sql += " WHERE nome = :cliente" sql += " WHERE cliente_id = :cliente"
params["cliente"] = cliente params["cliente"] = cliente
print("DBG /: SQL faturas ->", sql, params, flush=True) print("DBG /: SQL faturas ->", sql, params, flush=True)
@@ -252,21 +281,58 @@ def upload_page(request: Request):
}) })
@app.get("/relatorios", response_class=HTMLResponse) @app.get("/relatorios", response_class=HTMLResponse)
def relatorios_page(request: Request): async def relatorios_page(request: Request, cliente: str | None = Query(None)):
return templates.TemplateResponse("relatorios.html", {"request": request}) async with AsyncSessionLocal() as session:
# Carregar clientes ativos para o combo
r_cli = await session.execute(text("""
SELECT id, nome_fantasia
FROM faturas.clientes
WHERE ativo = TRUE
ORDER BY nome_fantasia
"""))
clientes = [{"id": str(row.id), "nome": row.nome_fantasia} for row in r_cli]
# Carregar faturas (todas ou filtradas por cliente)
if cliente:
r_fat = await session.execute(text("""
SELECT *
FROM faturas.faturas
WHERE cliente_id = :cid
ORDER BY data_processamento DESC
"""), {"cid": cliente})
else:
r_fat = await session.execute(text("""
SELECT *
FROM faturas.faturas
ORDER BY data_processamento DESC
"""))
faturas = r_fat.mappings().all()
return templates.TemplateResponse("relatorios.html", {
"request": request,
"clientes": clientes,
"cliente_selecionado": cliente or "",
"faturas": faturas
})
@app.post("/upload-files") @app.post("/upload-files")
async def upload_files(files: list[UploadFile] = File(...)): async def upload_files(
cliente_id: str = Form(...),
files: list[UploadFile] = File(...)
):
for file in files: for file in files:
temp_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}_{file.filename}") temp_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}_{file.filename}")
with open(temp_path, "wb") as f: with open(temp_path, "wb") as f:
shutil.copyfileobj(file.file, f) shutil.copyfileobj(file.file, f)
await fila_processamento.put({ await fila_processamento.put({
"caminho_pdf": temp_path, "caminho_pdf": temp_path,
"nome_original": file.filename "nome_original": file.filename,
"cliente_id": cliente_id
}) })
return {"message": "Arquivos enviados para fila"} return {"message": "Arquivos enviados para fila"}
@app.post("/process-queue") @app.post("/process-queue")
async def process_queue(): async def process_queue():
resultados = await processar_em_lote() resultados = await processar_em_lote()
@@ -305,120 +371,189 @@ async def clear_all():
return {"message": "Fila e arquivos limpos"} return {"message": "Fila e arquivos limpos"}
@app.get("/export-excel") @app.get("/export-excel")
async def export_excel(): async def export_excel(
import pandas as pd tipo: str = Query("geral", pattern="^(geral|exclusao_icms|aliquota_icms)$"),
cliente: str | None = Query(None)
):
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
# 1. Coletar faturas e tabela SELIC # 1) Faturas
faturas_result = await session.execute(select(Fatura)) stmt = select(Fatura)
faturas = faturas_result.scalars().all() if cliente:
stmt = stmt.where(Fatura.cliente_id == cliente)
faturas = (await session.execute(stmt)).scalars().all()
selic_result = await session.execute(select(SelicMensal)) # 2) Mapa de alíquotas cadastradas (UF/ano)
selic_tabela = selic_result.scalars().all() aliq_rows = (await session.execute(select(AliquotaUF))).scalars().all()
aliq_map = {(r.uf.upper(), int(r.exercicio)): float(r.aliq_icms) for r in aliq_rows}
# 2. Criar mapa {(ano, mes): percentual}
selic_map = {(s.ano, s.mes): float(s.percentual) for s in selic_tabela}
hoje = date.today()
def calcular_fator_selic(ano_inicio, mes_inicio):
fator = 1.0
ano, mes = ano_inicio, mes_inicio
while (ano < hoje.year) or (ano == hoje.year and mes <= hoje.month):
percentual = selic_map.get((ano, mes))
if percentual:
fator *= (1 + percentual / 100)
mes += 1
if mes > 12:
mes = 1
ano += 1
return fator
# 3. Buscar fórmulas exatas por nome
formula_pis_result = await session.execute(
select(ParametrosFormula.formula).where(
ParametrosFormula.nome == "Cálculo PIS sobre ICMS",
ParametrosFormula.ativo == True
).limit(1)
)
formula_cofins_result = await session.execute(
select(ParametrosFormula.formula).where(
ParametrosFormula.nome == "Cálculo COFINS sobre ICMS",
ParametrosFormula.ativo == True
).limit(1)
)
formula_pis = formula_pis_result.scalar_one_or_none()
formula_cofins = formula_cofins_result.scalar_one_or_none()
# 4. Montar dados
mes_map = {
'JAN': 1, 'FEV': 2, 'MAR': 3, 'ABR': 4, 'MAI': 5, 'JUN': 6,
'JUL': 7, 'AGO': 8, 'SET': 9, 'OUT': 10, 'NOV': 11, 'DEZ': 12
}
dados = [] dados = []
if tipo == "aliquota_icms":
for f in faturas: for f in faturas:
try: uf = (f.estado or "").strip().upper()
if "/" in f.referencia: ano, _ = _parse_referencia(f.referencia or "")
mes_str, ano_str = f.referencia.split("/") aliq_nf = float(f.icms_aliq or 0.0)
mes = mes_map.get(mes_str.strip().upper()) aliq_cad = aliq_map.get((uf, ano))
ano = int(ano_str) diff_pp = (aliq_nf - aliq_cad) if aliq_cad is not None else None
if not mes or not ano: confere = (abs(diff_pp) < 1e-6) if diff_pp is not None else None
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, "Cliente": f.nome,
"UF (fatura)": uf,
"Exercício (ref)": ano,
"Referência": f.referencia,
"Nota Fiscal": f.nota_fiscal,
"ICMS (%) NF": aliq_nf,
# novas colunas padronizadas
"ICMS (%) (UF/Ref)": aliq_cad,
"Dif. ICMS (pp)": diff_pp,
"ICMS confere?": "SIM" if confere else ("N/D" if confere is None else "NÃO"),
"Valor Total": f.valor_total,
"Distribuidora": f.distribuidora,
"Data Processamento": f.data_processamento,
"Arquivo PDF": f.arquivo_pdf,
})
filename = "relatorio_aliquota_icms.xlsx"
elif tipo == "exclusao_icms":
for f in faturas:
uf = (f.estado or "").strip().upper()
ano, _ = _parse_referencia(f.referencia or "")
aliq_nf = float(f.icms_aliq or 0.0)
aliq_cad = aliq_map.get((uf, ano))
diff_pp = (aliq_nf - aliq_cad) if aliq_cad is not None else None
confere = (abs(diff_pp) < 1e-6) if diff_pp is not None else None
dados.append({
"Cliente": f.nome,
"UC": f.unidade_consumidora,
"Referência": f.referencia,
"Valor Total": f.valor_total,
"PIS (%)": f.pis_aliq,
"ICMS (%)": f.icms_aliq,
"COFINS (%)": f.cofins_aliq,
"PIS (R$)": f.pis_valor,
"ICMS (R$)": f.icms_valor,
"COFINS (R$)": f.cofins_valor,
"Base PIS (R$)": f.pis_base,
"Base ICMS (R$)": f.icms_base,
"Base COFINS (R$)": f.cofins_base,
# novas colunas
"ICMS (%) (UF/Ref)": aliq_cad,
"Dif. ICMS (pp)": diff_pp,
"ICMS confere?": "SIM" if confere else ("N/D" if confere is None else "NÃO"),
"Consumo (kWh)": f.consumo,
"Tarifa": f.tarifa,
"Nota Fiscal": f.nota_fiscal,
"Arquivo PDF": f.arquivo_pdf,
})
filename = "relatorio_exclusao_icms.xlsx"
else: # geral
for f in faturas:
uf = (f.estado or "").strip().upper()
ano, _ = _parse_referencia(f.referencia or "")
aliq_nf = float(f.icms_aliq or 0.0)
aliq_cad = aliq_map.get((uf, ano))
diff_pp = (aliq_nf - aliq_cad) if aliq_cad is not None else None
confere = (abs(diff_pp) < 1e-6) if diff_pp is not None else None
dados.append({
"Cliente": f.nome,
"UC": f.unidade_consumidora, "UC": f.unidade_consumidora,
"Referência": f.referencia, "Referência": f.referencia,
"Nota Fiscal": f.nota_fiscal, "Nota Fiscal": f.nota_fiscal,
"Valor Total": f.valor_total, "Valor Total": f.valor_total,
"ICMS (%)": f.icms_aliq, "ICMS (%)": f.icms_aliq,
"ICMS (R$)": f.icms_valor, "ICMS (R$)": f.icms_valor,
"Base ICMS": f.icms_base,
# novas colunas
"ICMS (%) (UF/Ref)": aliq_cad,
"Dif. ICMS (pp)": diff_pp,
"ICMS confere?": "SIM" if confere else ("N/D" if confere is None else "NÃO"),
"Base ICMS (R$)": f.icms_base,
"PIS (%)": f.pis_aliq, "PIS (%)": f.pis_aliq,
"PIS (R$)": f.pis_valor, "PIS (R$)": f.pis_valor,
"Base PIS": f.pis_base, "Base PIS (R$)": f.pis_base,
"COFINS (%)": f.cofins_aliq, "COFINS (%)": f.cofins_aliq,
"COFINS (R$)": f.cofins_valor, "COFINS (R$)": f.cofins_valor,
"Base COFINS": f.cofins_base, "Base COFINS (R$)": f.cofins_base,
"Consumo (kWh)": f.consumo, "Consumo (kWh)": f.consumo,
"Tarifa": f.tarifa, "Tarifa": f.tarifa,
"Cidade": f.cidade,
"Estado": f.estado,
"Distribuidora": f.distribuidora, "Distribuidora": f.distribuidora,
"Data Processamento": f.data_processamento, "Data Processamento": f.data_processamento,
"Fator SELIC acumulado": fator, "Arquivo PDF": f.arquivo_pdf,
"Período SELIC usado": periodo,
"PIS sobre ICMS": valor_pis_icms,
"Valor Corrigido PIS (ICMS)": valor_pis_icms * fator if valor_pis_icms else None,
"COFINS sobre ICMS": valor_cofins_icms,
"Valor Corrigido COFINS (ICMS)": valor_cofins_icms * fator if valor_cofins_icms else None,
}) })
except Exception as e: filename = "relatorio_geral.xlsx"
print(f"Erro ao processar fatura {f.nota_fiscal}: {e}")
# 3) Excel em memória
output = BytesIO()
df = pd.DataFrame(dados) df = pd.DataFrame(dados)
output = BytesIO() # força "Arquivo PDF" a ser a última coluna
with pd.ExcelWriter(output, engine="xlsxwriter") as writer: if "Arquivo PDF" in df.columns:
df.to_excel(writer, index=False, sheet_name="Faturas Corrigidas") cols = [c for c in df.columns if c != "Arquivo PDF"] + ["Arquivo PDF"]
df = df[cols]
# converte colunas numéricas (percentuais, R$, etc.)
percent_cols = ["ICMS (%)", "ICMS (%) (UF/Ref)", "Dif. ICMS (pp)", "PIS (%)", "COFINS (%)"]
money_cols = ["Valor Total", "ICMS (R$)", "PIS (R$)", "COFINS (R$)",
"Base ICMS (R$)", "Base PIS (R$)", "Base COFINS (R$)"]
other_dec6 = ["Tarifa", "Consumo (kWh)"]
from decimal import Decimal
for col in percent_cols + money_cols + other_dec6:
if col in df.columns:
df[col] = df[col].map(lambda x: float(x) if isinstance(x, Decimal) else x)
df[col] = pd.to_numeric(df[col], errors="coerce")
# --- gera o XLSX ---
with pd.ExcelWriter(output, engine="xlsxwriter") as writer:
df.to_excel(writer, index=False, sheet_name="Relatório")
wb = writer.book
ws = writer.sheets["Relatório"]
fmt_dec6 = wb.add_format({"num_format": "0.000000"})
fmt_money6 = wb.add_format({"num_format": "#,##0.000000"})
fmt_money2 = wb.add_format({"num_format": "#,##0.00"})
for col in percent_cols:
if col in df.columns:
i = df.columns.get_loc(col)
ws.set_column(i, i, 14, fmt_dec6)
for col in money_cols:
if col in df.columns:
i = df.columns.get_loc(col)
ws.set_column(i, i, 14, fmt_money6) # ou fmt_money2 se quiser 2 casas
for col in other_dec6:
if col in df.columns:
i = df.columns.get_loc(col)
ws.set_column(i, i, 14, fmt_dec6)
# IMPORTANTE: só aqui, FORA do with
output.seek(0) output.seek(0)
return StreamingResponse(output, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={ data = output.getvalue()
"Content-Disposition": "attachment; filename=faturas_corrigidas.xlsx"
}) return Response(
content=data,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(len(data)),
},
)
from app.parametros import router as parametros_router from app.parametros import router as parametros_router
app.include_router(parametros_router) app.include_router(parametros_router)
app.include_router(clientes.router)
def is_homolog(): def is_homolog():
return os.getenv("APP_ENV", "dev") == "homolog" return os.getenv("APP_ENV", "dev") == "homolog"
@@ -471,3 +606,98 @@ async def limpar_erros():
caminho = os.path.join(pasta, nome) caminho = os.path.join(pasta, nome)
if os.path.exists(caminho): if os.path.exists(caminho):
os.remove(caminho) os.remove(caminho)
@app.get("/api/clientes")
async def listar_clientes(db: AsyncSession = Depends(get_session)):
sql = text("""
SELECT id, nome_fantasia, cnpj, ativo
FROM faturas.clientes
WHERE ativo = TRUE
ORDER BY nome_fantasia
""")
res = await db.execute(sql)
rows = res.mappings().all()
return [
{
"id": str(r["id"]),
"nome_fantasia": r["nome_fantasia"],
"cnpj": r["cnpj"],
"ativo": bool(r["ativo"]),
}
for r in rows
]
@app.get("/api/relatorios")
async def api_relatorios(
cliente: str | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=5, le=200),
db: AsyncSession = Depends(get_session),
):
offset = (page - 1) * page_size
where = "WHERE cliente_id = :cliente" if cliente else ""
params = {"limit": page_size, "offset": offset}
if cliente:
params["cliente"] = cliente
# ❗ Inclua 'estado' no SELECT
sql = text(f"""
SELECT id, nome, unidade_consumidora, referencia, nota_fiscal,
valor_total, icms_aliq, icms_valor, pis_aliq, pis_valor,
cofins_aliq, cofins_valor, distribuidora, data_processamento,
estado
FROM faturas.faturas
{where}
ORDER BY data_processamento DESC
LIMIT :limit OFFSET :offset
""")
count_sql = text(f"SELECT COUNT(*) AS total FROM faturas.faturas {where}")
rows = (await db.execute(sql, params)).mappings().all()
total = (await db.execute(count_sql, params)).scalar_one()
# 🔹 Carrega mapa de alíquotas UF/ano
aliq_rows = (await db.execute(select(AliquotaUF))).scalars().all()
aliq_map = {(r.uf.upper(), int(r.exercicio)): float(r.aliq_icms) for r in aliq_rows}
items = []
for r in rows:
uf = (r["estado"] or "").strip().upper()
ano, _mes = _parse_referencia(r["referencia"] or "")
aliq_nf = float(r["icms_aliq"] or 0.0)
aliq_cad = aliq_map.get((uf, ano))
diff_pp = (aliq_nf - aliq_cad) if aliq_cad is not None else None
ok = (abs(diff_pp) < 1e-6) if diff_pp is not None else None
items.append({
"id": str(r["id"]),
"nome": r["nome"],
"unidade_consumidora": r["unidade_consumidora"],
"referencia": r["referencia"],
"nota_fiscal": r["nota_fiscal"],
"valor_total": float(r["valor_total"]) if r["valor_total"] is not None else None,
"icms_aliq": aliq_nf,
"icms_valor": r["icms_valor"],
"pis_aliq": r["pis_aliq"],
"pis_valor": r["pis_valor"],
"cofins_aliq": r["cofins_aliq"],
"cofins_valor": r["cofins_valor"],
"distribuidora": r["distribuidora"],
"data_processamento": r["data_processamento"].isoformat() if r["data_processamento"] else None,
# novos
"estado": uf,
"exercicio": ano,
"aliq_cadastral": aliq_cad,
"aliq_diff_pp": round(diff_pp, 4) if diff_pp is not None else None,
"aliq_ok": ok,
})
return {"items": items, "total": total, "page": page, "page_size": page_size}
async def _carregar_aliquota_map(session):
rows = (await session.execute(
text("SELECT uf, exercicio, aliq_icms FROM faturas.aliquotas_uf")
)).mappings().all()
# (UF, ANO) -> float
return {(r["uf"].upper(), int(r["exercicio"])): float(r["aliq_icms"]) for r in rows}

View File

@@ -1,5 +1,5 @@
# 📄 models.py # 📄 models.py
from sqlalchemy import Column, String, Integer, Float, DateTime, Text from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -26,22 +26,22 @@ class Fatura(Base):
classificacao_tarifaria = Column("classificacao_tarifaria", String) classificacao_tarifaria = Column("classificacao_tarifaria", String)
unidade_consumidora = Column("unidade_consumidora", String) unidade_consumidora = Column("unidade_consumidora", String)
referencia = Column(String) referencia = Column(String)
valor_total = Column(Float) valor_total = Column(Numeric(18, 6, asdecimal=True))
pis_aliq = Column("pis_aliq", Float) pis_aliq = Column(Numeric(8, 6, asdecimal=True))
pis_valor = Column("pis_valor", Float) pis_valor = Column(Numeric(18, 6, asdecimal=True))
pis_base = Column("pis_base", Float) pis_base = Column(Numeric(18, 6, asdecimal=True))
icms_aliq = Column("icms_aliq", Float) icms_aliq = Column(Numeric(8, 6, asdecimal=True))
icms_valor = Column("icms_valor", Float) icms_valor = Column(Numeric(18, 6, asdecimal=True))
icms_base = Column("icms_base", Float) icms_base = Column(Numeric(18, 6, asdecimal=True))
cofins_aliq = Column("cofins_aliq", Float) cofins_aliq = Column(Numeric(8, 6, asdecimal=True))
cofins_valor = Column("cofins_valor", Float) cofins_valor = Column(Numeric(18, 6, asdecimal=True))
cofins_base = Column("cofins_base", Float) cofins_base = Column(Numeric(18, 6, asdecimal=True))
consumo = Column("consumo", Float) consumo = Column(Numeric(14, 6, asdecimal=True))
tarifa = Column("tarifa", Float) tarifa = Column(Numeric(12, 6, asdecimal=True))
nota_fiscal = Column(String) nota_fiscal = Column(String)
data_processamento = Column(DateTime, default=datetime.utcnow) data_processamento = Column(DateTime, default=datetime.utcnow)
@@ -50,6 +50,8 @@ class Fatura(Base):
estado = Column(String) estado = Column(String)
distribuidora = Column(String) distribuidora = Column(String)
link_arquivo = Column("link_arquivo", String) link_arquivo = Column("link_arquivo", String)
cliente_id = Column(UUID(as_uuid=True), ForeignKey("faturas.clientes.id"), nullable=False)
class LogProcessamento(Base): class LogProcessamento(Base):
@@ -70,7 +72,7 @@ class AliquotaUF(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
uf = Column(String) uf = Column(String)
exercicio = Column(String) exercicio = Column(Integer)
aliq_icms = Column(Numeric(6, 4)) aliq_icms = Column(Numeric(6, 4))
class SelicMensal(Base): class SelicMensal(Base):
@@ -80,3 +82,15 @@ class SelicMensal(Base):
ano = Column(Integer, primary_key=True) ano = Column(Integer, primary_key=True)
mes = Column(Integer, primary_key=True) mes = Column(Integer, primary_key=True)
percentual = Column(Numeric(6, 4)) percentual = Column(Numeric(6, 4))
class Cliente(Base):
__tablename__ = "clientes"
__table_args__ = {"schema": "faturas"}
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
nome_fantasia = Column(String, nullable=False)
cnpj = Column(String(14), unique=True)
ativo = Column(Boolean, default=True)
data_criacao = Column(DateTime, default=datetime.utcnow)
data_atualizacao = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

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

View File

@@ -56,7 +56,7 @@ def salvar_em_uploads(caminho_pdf_temp, nome_original, nota_fiscal):
logger.error(f"Erro ao salvar em uploads: {e}") logger.error(f"Erro ao salvar em uploads: {e}")
return caminho_pdf_temp return caminho_pdf_temp
async def process_single_file(caminho_pdf_temp: str, nome_original: str): async def process_single_file(caminho_pdf_temp: str, nome_original: str, cliente_id: str | None = None):
inicio = time.perf_counter() inicio = time.perf_counter()
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
@@ -64,6 +64,41 @@ async def process_single_file(caminho_pdf_temp: str, nome_original: str):
dados = extrair_dados_pdf(caminho_pdf_temp) dados = extrair_dados_pdf(caminho_pdf_temp)
dados['arquivo_pdf'] = nome_original dados['arquivo_pdf'] = nome_original
from decimal import Decimal, ROUND_HALF_UP
_Q6 = Decimal("0.000000")
def _to_percent_6(x):
"""Converte para percent (se vier em fração) e quantiza em 6 casas."""
if x is None:
return None
try:
v = Decimal(str(x))
except Exception:
return None
# se vier em fração (ex.: 0.012872), vira 1.2872… (percentual)
if Decimal("0") < v <= Decimal("1"):
v = v * Decimal("100")
return v.quantize(_Q6, rounding=ROUND_HALF_UP)
def _to_dec6(x):
"""Apenas 6 casas, sem % (use para tarifa, bases, etc.)."""
if x is None:
return None
try:
v = Decimal(str(x))
except Exception:
return None
return v.quantize(_Q6, rounding=ROUND_HALF_UP)
dados['icms_aliq'] = _to_percent_6(dados.get('icms_aliq'))
dados['pis_aliq'] = _to_percent_6(dados.get('pis_aliq'))
dados['cofins_aliq'] = _to_percent_6(dados.get('cofins_aliq'))
# tarifa NÃO é percentual: apenas 6 casas
dados['tarifa'] = _to_dec6(dados.get('tarifa'))
# Verifica se a fatura já existe # Verifica se a fatura já existe
existente_result = await session.execute( existente_result = await session.execute(
select(Fatura).filter_by( select(Fatura).filter_by(
@@ -89,6 +124,9 @@ async def process_single_file(caminho_pdf_temp: str, nome_original: str):
dados['link_arquivo'] = caminho_final dados['link_arquivo'] = caminho_final
# Salva fatura # Salva fatura
dados['cliente_id'] = cliente_id
if cliente_id:
dados['cliente_id'] = cliente_id
fatura = Fatura(**dados) fatura = Fatura(**dados)
session.add(fatura) session.add(fatura)
@@ -125,15 +163,36 @@ async def processar_em_lote():
while not fila_processamento.empty(): while not fila_processamento.empty():
item = await fila_processamento.get() item = await fila_processamento.get()
try: try:
resultado = await process_single_file(item['caminho_pdf'], item['nome_original']) resultado = await process_single_file(
item['caminho_pdf'],
item['nome_original'],
item.get('cliente_id')
)
# tentar tamanho/data do TEMP; se não existir mais, tenta do destino final; senão, 0/""
temp_path = item['caminho_pdf']
dest_path = (resultado.get("dados") or {}).get("link_arquivo", "")
def _safe_size(p):
try:
return os.path.getsize(p) // 1024
except Exception:
return 0
def _safe_mtime(p):
try:
return time.strftime("%d/%m/%Y", time.localtime(os.path.getmtime(p)))
except Exception:
return ""
status_arquivos[item['nome_original']] = { status_arquivos[item['nome_original']] = {
"status": resultado.get("status"), "status": resultado.get("status"),
"mensagem": resultado.get("mensagem", ""), "mensagem": resultado.get("mensagem", ""),
"tempo": resultado.get("tempo", "---"), "tempo": resultado.get("tempo", "---"),
"tamanho": os.path.getsize(item['caminho_pdf']) // 1024, # tamanho em KB "tamanho": _safe_size(temp_path) or _safe_size(dest_path),
"data": time.strftime("%d/%m/%Y", time.localtime(os.path.getmtime(item['caminho_pdf']))) "data": _safe_mtime(temp_path) or _safe_mtime(dest_path),
} }
resultados.append(status_arquivos[item['nome_original']]) resultados.append(status_arquivos[item['nome_original']])
except Exception as e: except Exception as e:
status_arquivos[item['nome_original']] = { status_arquivos[item['nome_original']] = {
@@ -156,16 +215,20 @@ async def processar_em_lote():
erros_txt.append(f"{nome} - {status.get('mensagem', 'Erro desconhecido')}") erros_txt.append(f"{nome} - {status.get('mensagem', 'Erro desconhecido')}")
if erros_txt: if erros_txt:
with open(os.path.join(UPLOADS_DIR, "erros", "erros.txt"), "w", encoding="utf-8") as f: erros_dir = os.path.join(UPLOADS_DIR, "erros")
os.makedirs(erros_dir, exist_ok=True) # <- GARANTE A PASTA
with open(os.path.join(erros_dir, "erros.txt"), "w", encoding="utf-8") as f:
f.write("\n".join(erros_txt)) f.write("\n".join(erros_txt))
# Compacta PDFs com erro # Compacta PDFs com erro
with ZipFile(os.path.join(UPLOADS_DIR, "erros", "faturas_erro.zip"), "w") as zipf: with ZipFile(os.path.join(erros_dir, "faturas_erro.zip"), "w") as zipf:
for nome in status_arquivos: for nome in status_arquivos:
if status_arquivos[nome]['status'] == 'Erro': if status_arquivos[nome]['status'] == 'Erro':
caminho = os.path.join(UPLOADS_DIR, "temp", nome) caminho = os.path.join(UPLOADS_DIR, "temp", nome)
if os.path.exists(caminho): if os.path.exists(caminho):
zipf.write(caminho, arcname=nome) zipf.write(caminho, arcname=nome)
return resultados return resultados
def limpar_arquivos_processados(): def limpar_arquivos_processados():
@@ -192,6 +255,6 @@ async def garantir_selic_para_competencia(session, ano, mes):
if dados: if dados:
percentual = float(dados[0]["valor"].replace(",", ".")) percentual = float(dados[0]["valor"].replace(",", "."))
novo = SelicMensal(ano=ano, mes=mes, fator=percentual) novo = SelicMensal(ano=ano, mes=mes, percentual=percentual)
session.add(novo) session.add(novo)
await session.commit() await session.commit()

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

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

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

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

View File

@@ -100,10 +100,10 @@
<form method="get" action="/" style="margin: 6px 0 18px"> <form method="get" action="/" style="margin: 6px 0 18px">
<div class="combo-wrap"> <div class="combo-wrap">
<label for="cliente" style="font-size:13px;color:#374151">Selecionar Cliente:</label> <label for="cliente" style="font-size:13px;color:#374151">Selecionar Cliente:</label>
<select class="combo" name="cliente" id="cliente"> <select id="cliente" name="cliente" class="combo">
<option value="">Todos</option> <option value="">Todos</option>
{% for c in clientes %} {% for c in clientes %}
<option value="{{ c }}" {% if c == cliente_atual %}selected{% endif %}>{{ c }}</option> <option value="{{ c.id }}" {% if cliente_selecionado == c.id %}selected{% endif %}>{{ c.nome }}</option>
{% endfor %} {% endfor %}
</select> </select>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,16 @@
<h1 style="font-size: 1.5rem; margin-bottom: 1rem;">📤 Upload de Faturas</h1> <h1 style="font-size: 1.5rem; margin-bottom: 1rem;">📤 Upload de Faturas</h1>
<!-- Seletor de Cliente (obrigatório) -->
<div style="display:flex; gap:12px; align-items:center; margin: 0 0 14px 0;">
<label for="select-cliente" style="font-weight:600;">Cliente:</label>
<select id="select-cliente" style="min-width:320px; padding:.6rem .8rem; border:1px solid #ddd; border-radius:10px;">
<option value="">— Selecione um cliente —</option>
</select>
<span id="cliente-aviso" class="muted">Selecione o cliente antes de anexar/ processar.</span>
</div>
<div class="upload-box" id="upload-box"> <div class="upload-box" id="upload-box">
<h3>Arraste faturas em PDF aqui ou clique para selecionar</h3> <h3>Arraste faturas em PDF aqui ou clique para selecionar</h3>
<p style="color: gray; font-size: 0.9rem;">Apenas PDFs textuais (não escaneados)</p> <p style="color: gray; font-size: 0.9rem;">Apenas PDFs textuais (não escaneados)</p>
@@ -22,8 +32,10 @@
<a class="btn btn-secondary" href="/erros/log">📄 Ver Log de Erros (.txt)</a> <a class="btn btn-secondary" href="/erros/log">📄 Ver Log de Erros (.txt)</a>
</div> </div>
{% endif %} {% endif %}
<!--
<button class="btn btn-success" onclick="baixarPlanilha()">📅 Abrir Planilha</button> <button class="btn btn-success" onclick="baixarPlanilha()">📅 Abrir Planilha</button>
<button class="btn btn-success" onclick="gerarRelatorio()">📊 Gerar Relatório</button> <button class="btn btn-success" onclick="gerarRelatorio()">📊 Gerar Relatório</button>
-->
{% if app_env != "producao" %} {% if app_env != "producao" %}
<button class="btn btn-warning" onclick="limparFaturas()">🧹 Limpar Faturas (Teste)</button> <button class="btn btn-warning" onclick="limparFaturas()">🧹 Limpar Faturas (Teste)</button>
{% endif %} {% endif %}
@@ -34,7 +46,6 @@
</div> </div>
<div id="tabela-wrapper" class="tabela-wrapper"></div> <div id="tabela-wrapper" class="tabela-wrapper"></div>
</div> </div>
ar
<script> <script>
let arquivos = []; let arquivos = [];
let statusInterval = null; let statusInterval = null;
@@ -43,7 +54,33 @@ ar
const fileTable = document.getElementById('file-table'); const fileTable = document.getElementById('file-table');
function handleFiles(files) {
// <<< NOVO: carrega clientes ativos no combo
async function carregarClientes() {
try {
const r = await fetch('/api/clientes'); // se quiser só ativos: /api/clientes?ativos=true
if (!r.ok) throw new Error('Falha ao carregar clientes');
const lista = await r.json();
const sel = document.getElementById('select-cliente');
sel.innerHTML = `<option value="">— Selecione um cliente —</option>` +
lista.map(c => `<option value="${c.id}">${c.nome_fantasia}${c.cnpj ? ' — ' + c.cnpj : ''}</option>`).join('');
} catch (e) {
console.error(e);
alert('Não foi possível carregar a lista de clientes.');
}
}
function clienteSelecionado() {
return (document.getElementById('select-cliente')?.value || '').trim();
}
// <<< AJUSTE: impedir anexar sem cliente
function handleFiles(files) {
if (!clienteSelecionado()) {
alert('Selecione um cliente antes de anexar os arquivos.');
return;
}
if (processado) { if (processado) {
document.getElementById("feedback-sucesso").innerText = ""; document.getElementById("feedback-sucesso").innerText = "";
document.getElementById("feedback-erro").innerText = "⚠️ Conclua ou inicie um novo processo antes de adicionar mais arquivos."; document.getElementById("feedback-erro").innerText = "⚠️ Conclua ou inicie um novo processo antes de adicionar mais arquivos.";
@@ -53,8 +90,13 @@ function handleFiles(files) {
} }
arquivos = [...arquivos, ...files]; arquivos = [...arquivos, ...files];
// trava o combo após começar a anexar (opcional)
document.getElementById('select-cliente').disabled = true;
renderTable(); renderTable();
} }
function renderTable(statusList = []) { function renderTable(statusList = []) {
const grupos = ['Aguardando', 'Enviado', 'Erro', 'Duplicado']; const grupos = ['Aguardando', 'Enviado', 'Erro', 'Duplicado'];
@@ -102,7 +144,20 @@ function renderTable(statusList = []) {
} }
async function processar(btn) { async function processar(btn) {
if (!clienteSelecionado()) {
alert("Selecione um cliente antes de processar.");
return;
}
if (arquivos.length === 0) return alert("Nenhum arquivo selecionado."); if (arquivos.length === 0) return alert("Nenhum arquivo selecionado.");
// Confirmação
const clienteTxt = document.querySelector('#select-cliente option:checked')?.textContent || '';
if (!confirm(`Confirmar processamento de ${arquivos.length} arquivo(s) para o cliente:\n\n${clienteTxt}`)) {
return;
}
const clienteId = clienteSelecionado();
document.getElementById("tabela-wrapper").classList.add("bloqueada"); document.getElementById("tabela-wrapper").classList.add("bloqueada");
if (processamentoFinalizado) { if (processamentoFinalizado) {
@@ -120,8 +175,9 @@ async function processar(btn) {
document.getElementById("barra-progresso").style.width = "0%"; document.getElementById("barra-progresso").style.width = "0%";
for (let i = 0; i < total; i++) { for (let i = 0; i < total; i++) {
const file = arquivos[i]; const file = arquivos[i]; // <- declare 'file' ANTES de usar
const formData = new FormData(); const formData = new FormData();
formData.append("cliente_id", clienteId); // <- usa o cache do cliente
formData.append("files", file); formData.append("files", file);
// Atualiza status visual antes do envio // Atualiza status visual antes do envio
@@ -129,14 +185,16 @@ async function processar(btn) {
nome: file.name, nome: file.name,
status: "Enviando...", status: "Enviando...",
mensagem: `(${i + 1}/${total})`, mensagem: `(${i + 1}/${total})`,
tempo: "---" tempo: "---",
tamanho: (file.size / 1024).toFixed(1) + " KB",
data: new Date(file.lastModified).toLocaleDateString()
}); });
renderTable(statusList); renderTable(statusList);
const start = performance.now(); const start = performance.now();
try { try {
await fetch("/upload-files", { method: "POST", body: formData }); await fetch("/upload-files", { method: "POST", body: formData });
let progresso = Math.round(((i + 1) / total) * 100); const progresso = Math.round(((i + 1) / total) * 100);
document.getElementById("barra-progresso").style.width = `${progresso}%`; document.getElementById("barra-progresso").style.width = `${progresso}%`;
statusList[i].status = "Enviado"; statusList[i].status = "Enviado";
@@ -147,9 +205,7 @@ async function processar(btn) {
} }
renderTable(statusList); renderTable(statusList);
await new Promise(r => setTimeout(r, 200)); // pequeno delay
// Delay de 200ms entre cada envio
await new Promise(r => setTimeout(r, 200));
} }
btn.innerText = "⏳ Iniciando processamento..."; btn.innerText = "⏳ Iniciando processamento...";
@@ -206,23 +262,39 @@ async function processar(btn) {
} }
} }
function limpar() { function limpar() {
fetch("/clear-all", { method: "POST" }); fetch("/clear-all", { method: "POST" });
// reset da fila/estado
arquivos = []; arquivos = [];
processado = false; processado = false;
document.getElementById("file-input").value = null; processamentoFinalizado = false;
renderTable(); if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
// limpa feedback visual também // reset dos inputs/visual
document.getElementById("file-input").value = null;
document.getElementById("tabela-wrapper").classList.remove("bloqueada");
document.getElementById("overlay-bloqueio").classList.add("hidden");
document.getElementById("barra-progresso").style.width = "0%";
document.getElementById("btn-selecionar").disabled = false;
// 🔓 permitir mudar o cliente novamente
const sel = document.getElementById("select-cliente");
sel.disabled = false;
sel.value = ""; // <- se NÃO quiser limpar a escolha anterior, remova esta linha
document.getElementById("cliente-aviso").textContent =
"Selecione o cliente antes de anexar/ processar.";
// limpar feedback
document.getElementById("upload-feedback").classList.add("hidden"); document.getElementById("upload-feedback").classList.add("hidden");
document.getElementById("feedback-sucesso").innerText = ""; document.getElementById("feedback-sucesso").innerText = "";
document.getElementById("feedback-erro").innerText = ""; document.getElementById("feedback-erro").innerText = "";
document.getElementById("feedback-duplicado").innerText = ""; document.getElementById("feedback-duplicado").innerText = "";
document.getElementById("tabela-wrapper").classList.remove("bloqueada");
processamentoFinalizado = false;
document.getElementById("btn-selecionar").disabled = false;
} // limpar tabela
renderTable();
}
function baixarPlanilha() { function baixarPlanilha() {
window.open('/export-excel', '_blank'); window.open('/export-excel', '_blank');
@@ -233,6 +305,7 @@ async function processar(btn) {
} }
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
carregarClientes();
updateStatus(); updateStatus();
const dragOverlay = document.getElementById("drag-overlay"); const dragOverlay = document.getElementById("drag-overlay");
@@ -255,10 +328,14 @@ window.addEventListener('DOMContentLoaded', () => {
e.preventDefault(); e.preventDefault();
}); });
window.addEventListener("drop", e => { window.addEventListener("drop", e => {
e.preventDefault(); e.preventDefault();
dragOverlay.classList.remove("active"); dragOverlay.classList.remove("active");
dragCounter = 0; dragCounter = 0;
if (!clienteSelecionado()) {
alert('Selecione um cliente antes de anexar os arquivos.');
return;
}
handleFiles(e.dataTransfer.files); handleFiles(e.dataTransfer.files);
}); });
}); });

View File

@@ -8,3 +8,4 @@ openpyxl==3.1.2
pandas==2.2.2 pandas==2.2.2
PyMuPDF==1.22.5 PyMuPDF==1.22.5
httpx==0.27.0 httpx==0.27.0
xlsxwriter==3.2.0