Compare commits
10 Commits
3cfd3d3452
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b64068cfb6 | |||
| 98c6cf2363 | |||
| f6c8943d4e | |||
| 5a9fb44bdb | |||
| e6c0155758 | |||
| fb08efed1d | |||
| bc05faafea | |||
| 4d2fcff4a8 | |||
| 950eb2a826 | |||
| bcf9861e97 |
@@ -29,14 +29,19 @@ def extrair_dados(texto_final):
|
||||
|
||||
nota_fiscal = extrair_seguro(r'NOTA FISCAL Nº\s*(\d+)', texto_final)
|
||||
|
||||
# --- Unidade Consumidora (UC): 8–12 dígitos, SEM hífen ---
|
||||
uc = extrair_seguro([
|
||||
r'(\d{7,10}-\d)',
|
||||
r'UNIDADE\s+CONSUMIDORA\s*[:\-]?\s*(\d{6,})',
|
||||
r'(\d{6,})\s+FAZENDA',
|
||||
r'(\d{6,})\s+AVENIDA',
|
||||
r'(\d{6,})\s+RUA'
|
||||
r'UNIDADE\s*CONSUMIDORA\D*?(\d{8,12})',
|
||||
r'\bUC\D*?(\d{8,12})',
|
||||
r'INSTALA[ÇC][ÃA]O\D*?(\d{8,12})',
|
||||
], texto_final)
|
||||
|
||||
# fallback: maior sequência "solta" de 8–10 dígitos sem hífen
|
||||
if not uc:
|
||||
seqs = re.findall(r'(?<!\d)(\d{8,10})(?![\d-])', texto_final)
|
||||
if seqs:
|
||||
uc = max(seqs, key=len)
|
||||
|
||||
logging.debug("TEXTO PDF:\n" + texto_final)
|
||||
|
||||
referencia = extrair_seguro([
|
||||
|
||||
440
app/main.py
440
app/main.py
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
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.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -24,39 +24,44 @@ from fastapi.responses import FileResponse
|
||||
from app.models import Fatura, SelicMensal, ParametrosFormula
|
||||
from datetime import date
|
||||
from app.utils import avaliar_formula
|
||||
from app.routes import clientes
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_session
|
||||
from fastapi import Query
|
||||
from sqlalchemy import select as sqla_select
|
||||
from app.models import AliquotaUF
|
||||
import pandas as pd
|
||||
from fastapi.responses import Response
|
||||
|
||||
|
||||
app = FastAPI()
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
app.state.templates = templates
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
UPLOAD_DIR = "uploads/temp"
|
||||
UPLOAD_DIR = os.path.join("app", "uploads", "temp")
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
def _parse_referencia(ref: str):
|
||||
"""Aceita 'JAN/2024', '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}
|
||||
ref = (ref or "").strip().upper()
|
||||
|
||||
if "/" in ref:
|
||||
a, b = [p.strip() for p in ref.split("/", 1)]
|
||||
# mês pode vir 'JAN' ou '01'
|
||||
mes = meses.get(a, None)
|
||||
if mes is None:
|
||||
mes = int(re.sub(r"\D", "", a) or 1)
|
||||
ano = int(re.sub(r"\D", "", b) or 0)
|
||||
# ano 2 dígitos -> 2000+
|
||||
if ano < 100:
|
||||
ano += 2000
|
||||
else:
|
||||
# '202401' ou '2024-01'
|
||||
num = re.sub(r"\D", "", ref)
|
||||
if len(num) >= 6:
|
||||
ano, mes = int(num[:4]), int(num[4:6])
|
||||
elif len(num) == 4: # '2024'
|
||||
elif len(num) == 4:
|
||||
ano, mes = int(num), 1
|
||||
else:
|
||||
ano, mes = date.today().year, 1
|
||||
ano, mes = 0, 0
|
||||
return ano, mes
|
||||
|
||||
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
|
||||
if isinstance(v, str):
|
||||
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:
|
||||
return float(eval(expr, {"__builtins__": {}}, {}))
|
||||
@@ -111,10 +136,14 @@ async def dashboard(request: Request, cliente: str | None = None):
|
||||
async with AsyncSessionLocal() as session:
|
||||
print("DBG /: abrindo sessão", flush=True)
|
||||
|
||||
r = await session.execute(text(
|
||||
"SELECT DISTINCT nome FROM faturas.faturas ORDER BY nome"
|
||||
))
|
||||
clientes = [c for c, in r.fetchall()]
|
||||
r = await session.execute(text("""
|
||||
SELECT id, nome_fantasia
|
||||
FROM faturas.clientes
|
||||
WHERE ativo = TRUE
|
||||
ORDER BY nome_fantasia
|
||||
"""))
|
||||
clientes = [{"id": id_, "nome": nome} for id_, nome in r.fetchall()]
|
||||
|
||||
print(f"DBG /: clientes={len(clientes)}", flush=True)
|
||||
|
||||
# Fórmulas
|
||||
@@ -133,7 +162,7 @@ async def dashboard(request: Request, cliente: str | None = None):
|
||||
sql = "SELECT * FROM faturas.faturas"
|
||||
params = {}
|
||||
if cliente:
|
||||
sql += " WHERE nome = :cliente"
|
||||
sql += " WHERE cliente_id = :cliente"
|
||||
params["cliente"] = cliente
|
||||
print("DBG /: SQL faturas ->", sql, params, flush=True)
|
||||
|
||||
@@ -252,21 +281,58 @@ def upload_page(request: Request):
|
||||
})
|
||||
|
||||
@app.get("/relatorios", response_class=HTMLResponse)
|
||||
def relatorios_page(request: Request):
|
||||
return templates.TemplateResponse("relatorios.html", {"request": request})
|
||||
async def relatorios_page(request: Request, cliente: str | None = Query(None)):
|
||||
async with AsyncSessionLocal() as session:
|
||||
# Carregar clientes ativos para o combo
|
||||
r_cli = await session.execute(text("""
|
||||
SELECT id, nome_fantasia
|
||||
FROM faturas.clientes
|
||||
WHERE ativo = TRUE
|
||||
ORDER BY nome_fantasia
|
||||
"""))
|
||||
clientes = [{"id": str(row.id), "nome": row.nome_fantasia} for row in r_cli]
|
||||
|
||||
# Carregar faturas (todas ou filtradas por cliente)
|
||||
if cliente:
|
||||
r_fat = await session.execute(text("""
|
||||
SELECT *
|
||||
FROM faturas.faturas
|
||||
WHERE cliente_id = :cid
|
||||
ORDER BY data_processamento DESC
|
||||
"""), {"cid": cliente})
|
||||
else:
|
||||
r_fat = await session.execute(text("""
|
||||
SELECT *
|
||||
FROM faturas.faturas
|
||||
ORDER BY data_processamento DESC
|
||||
"""))
|
||||
|
||||
faturas = r_fat.mappings().all()
|
||||
|
||||
return templates.TemplateResponse("relatorios.html", {
|
||||
"request": request,
|
||||
"clientes": clientes,
|
||||
"cliente_selecionado": cliente or "",
|
||||
"faturas": faturas
|
||||
})
|
||||
|
||||
@app.post("/upload-files")
|
||||
async def upload_files(files: list[UploadFile] = File(...)):
|
||||
async def upload_files(
|
||||
cliente_id: str = Form(...),
|
||||
files: list[UploadFile] = File(...)
|
||||
):
|
||||
for file in files:
|
||||
temp_path = os.path.join(UPLOAD_DIR, f"{uuid.uuid4()}_{file.filename}")
|
||||
with open(temp_path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
await fila_processamento.put({
|
||||
"caminho_pdf": temp_path,
|
||||
"nome_original": file.filename
|
||||
"nome_original": file.filename,
|
||||
"cliente_id": cliente_id
|
||||
})
|
||||
return {"message": "Arquivos enviados para fila"}
|
||||
|
||||
|
||||
@app.post("/process-queue")
|
||||
async def process_queue():
|
||||
resultados = await processar_em_lote()
|
||||
@@ -305,120 +371,189 @@ async def clear_all():
|
||||
return {"message": "Fila e arquivos limpos"}
|
||||
|
||||
@app.get("/export-excel")
|
||||
async def export_excel():
|
||||
import pandas as pd
|
||||
async def export_excel(
|
||||
tipo: str = Query("geral", pattern="^(geral|exclusao_icms|aliquota_icms)$"),
|
||||
cliente: str | None = Query(None)
|
||||
):
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 1. Coletar faturas e tabela SELIC
|
||||
faturas_result = await session.execute(select(Fatura))
|
||||
faturas = faturas_result.scalars().all()
|
||||
# 1) Faturas
|
||||
stmt = select(Fatura)
|
||||
if cliente:
|
||||
stmt = stmt.where(Fatura.cliente_id == cliente)
|
||||
faturas = (await session.execute(stmt)).scalars().all()
|
||||
|
||||
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
|
||||
}
|
||||
# 2) Mapa de alíquotas cadastradas (UF/ano)
|
||||
aliq_rows = (await session.execute(select(AliquotaUF))).scalars().all()
|
||||
aliq_map = {(r.uf.upper(), int(r.exercicio)): float(r.aliq_icms) for r in aliq_rows}
|
||||
|
||||
dados = []
|
||||
for f in faturas:
|
||||
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}"
|
||||
if tipo == "aliquota_icms":
|
||||
for f in faturas:
|
||||
uf = (f.estado or "").strip().upper()
|
||||
ano, _ = _parse_referencia(f.referencia or "")
|
||||
aliq_nf = float(f.icms_aliq or 0.0)
|
||||
aliq_cad = aliq_map.get((uf, ano))
|
||||
diff_pp = (aliq_nf - aliq_cad) if aliq_cad is not None else None
|
||||
confere = (abs(diff_pp) < 1e-6) if diff_pp is not None else None
|
||||
|
||||
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({
|
||||
"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,
|
||||
"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,
|
||||
|
||||
# novas colunas
|
||||
"ICMS (%) (UF/Ref)": aliq_cad,
|
||||
"Dif. ICMS (pp)": diff_pp,
|
||||
"ICMS confere?": "SIM" if confere else ("N/D" if confere is None else "NÃO"),
|
||||
|
||||
"Base ICMS (R$)": f.icms_base,
|
||||
"PIS (%)": f.pis_aliq,
|
||||
"PIS (R$)": f.pis_valor,
|
||||
"Base PIS": f.pis_base,
|
||||
"Base PIS (R$)": f.pis_base,
|
||||
"COFINS (%)": f.cofins_aliq,
|
||||
"COFINS (R$)": f.cofins_valor,
|
||||
"Base COFINS": f.cofins_base,
|
||||
"Base COFINS (R$)": f.cofins_base,
|
||||
"Consumo (kWh)": f.consumo,
|
||||
"Tarifa": f.tarifa,
|
||||
"Cidade": f.cidade,
|
||||
"Estado": f.estado,
|
||||
"Distribuidora": f.distribuidora,
|
||||
"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,
|
||||
"Arquivo PDF": f.arquivo_pdf,
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Erro ao processar fatura {f.nota_fiscal}: {e}")
|
||||
filename = "relatorio_geral.xlsx"
|
||||
|
||||
df = pd.DataFrame(dados)
|
||||
# 3) Excel em memória
|
||||
output = BytesIO()
|
||||
df = pd.DataFrame(dados)
|
||||
|
||||
output = BytesIO()
|
||||
with pd.ExcelWriter(output, engine="xlsxwriter") as writer:
|
||||
df.to_excel(writer, index=False, sheet_name="Faturas Corrigidas")
|
||||
# força "Arquivo PDF" a ser a última coluna
|
||||
if "Arquivo PDF" in df.columns:
|
||||
cols = [c for c in df.columns if c != "Arquivo PDF"] + ["Arquivo PDF"]
|
||||
df = df[cols]
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(output, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={
|
||||
"Content-Disposition": "attachment; filename=faturas_corrigidas.xlsx"
|
||||
})
|
||||
|
||||
# converte colunas numéricas (percentuais, R$, etc.)
|
||||
percent_cols = ["ICMS (%)", "ICMS (%) (UF/Ref)", "Dif. ICMS (pp)", "PIS (%)", "COFINS (%)"]
|
||||
money_cols = ["Valor Total", "ICMS (R$)", "PIS (R$)", "COFINS (R$)",
|
||||
"Base ICMS (R$)", "Base PIS (R$)", "Base COFINS (R$)"]
|
||||
other_dec6 = ["Tarifa", "Consumo (kWh)"]
|
||||
|
||||
from decimal import Decimal
|
||||
for col in percent_cols + money_cols + other_dec6:
|
||||
if col in df.columns:
|
||||
df[col] = df[col].map(lambda x: float(x) if isinstance(x, Decimal) else x)
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
# --- gera o XLSX ---
|
||||
with pd.ExcelWriter(output, engine="xlsxwriter") as writer:
|
||||
df.to_excel(writer, index=False, sheet_name="Relatório")
|
||||
wb = writer.book
|
||||
ws = writer.sheets["Relatório"]
|
||||
|
||||
fmt_dec6 = wb.add_format({"num_format": "0.000000"})
|
||||
fmt_money6 = wb.add_format({"num_format": "#,##0.000000"})
|
||||
fmt_money2 = wb.add_format({"num_format": "#,##0.00"})
|
||||
|
||||
for col in percent_cols:
|
||||
if col in df.columns:
|
||||
i = df.columns.get_loc(col)
|
||||
ws.set_column(i, i, 14, fmt_dec6)
|
||||
|
||||
for col in money_cols:
|
||||
if col in df.columns:
|
||||
i = df.columns.get_loc(col)
|
||||
ws.set_column(i, i, 14, fmt_money6) # ou fmt_money2 se quiser 2 casas
|
||||
|
||||
for col in other_dec6:
|
||||
if col in df.columns:
|
||||
i = df.columns.get_loc(col)
|
||||
ws.set_column(i, i, 14, fmt_dec6)
|
||||
|
||||
# IMPORTANTE: só aqui, FORA do with
|
||||
output.seek(0)
|
||||
data = output.getvalue()
|
||||
|
||||
return Response(
|
||||
content=data,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": str(len(data)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
from app.parametros import router as parametros_router
|
||||
app.include_router(parametros_router)
|
||||
app.include_router(clientes.router)
|
||||
|
||||
def is_homolog():
|
||||
return os.getenv("APP_ENV", "dev") == "homolog"
|
||||
@@ -471,3 +606,98 @@ async def limpar_erros():
|
||||
caminho = os.path.join(pasta, nome)
|
||||
if os.path.exists(caminho):
|
||||
os.remove(caminho)
|
||||
|
||||
@app.get("/api/clientes")
|
||||
async def listar_clientes(db: AsyncSession = Depends(get_session)):
|
||||
sql = text("""
|
||||
SELECT id, nome_fantasia, cnpj, ativo
|
||||
FROM faturas.clientes
|
||||
WHERE ativo = TRUE
|
||||
ORDER BY nome_fantasia
|
||||
""")
|
||||
res = await db.execute(sql)
|
||||
rows = res.mappings().all()
|
||||
return [
|
||||
{
|
||||
"id": str(r["id"]),
|
||||
"nome_fantasia": r["nome_fantasia"],
|
||||
"cnpj": r["cnpj"],
|
||||
"ativo": bool(r["ativo"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@app.get("/api/relatorios")
|
||||
async def api_relatorios(
|
||||
cliente: str | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=5, le=200),
|
||||
db: AsyncSession = Depends(get_session),
|
||||
):
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
where = "WHERE cliente_id = :cliente" if cliente else ""
|
||||
params = {"limit": page_size, "offset": offset}
|
||||
if cliente:
|
||||
params["cliente"] = cliente
|
||||
|
||||
# ❗ Inclua 'estado' no SELECT
|
||||
sql = text(f"""
|
||||
SELECT id, nome, unidade_consumidora, referencia, nota_fiscal,
|
||||
valor_total, icms_aliq, icms_valor, pis_aliq, pis_valor,
|
||||
cofins_aliq, cofins_valor, distribuidora, data_processamento,
|
||||
estado
|
||||
FROM faturas.faturas
|
||||
{where}
|
||||
ORDER BY data_processamento DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
count_sql = text(f"SELECT COUNT(*) AS total FROM faturas.faturas {where}")
|
||||
|
||||
rows = (await db.execute(sql, params)).mappings().all()
|
||||
total = (await db.execute(count_sql, params)).scalar_one()
|
||||
|
||||
# 🔹 Carrega mapa de alíquotas UF/ano
|
||||
aliq_rows = (await db.execute(select(AliquotaUF))).scalars().all()
|
||||
aliq_map = {(r.uf.upper(), int(r.exercicio)): float(r.aliq_icms) for r in aliq_rows}
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
uf = (r["estado"] or "").strip().upper()
|
||||
ano, _mes = _parse_referencia(r["referencia"] or "")
|
||||
aliq_nf = float(r["icms_aliq"] or 0.0)
|
||||
aliq_cad = aliq_map.get((uf, ano))
|
||||
diff_pp = (aliq_nf - aliq_cad) if aliq_cad is not None else None
|
||||
ok = (abs(diff_pp) < 1e-6) if diff_pp is not None else None
|
||||
|
||||
items.append({
|
||||
"id": str(r["id"]),
|
||||
"nome": r["nome"],
|
||||
"unidade_consumidora": r["unidade_consumidora"],
|
||||
"referencia": r["referencia"],
|
||||
"nota_fiscal": r["nota_fiscal"],
|
||||
"valor_total": float(r["valor_total"]) if r["valor_total"] is not None else None,
|
||||
"icms_aliq": aliq_nf,
|
||||
"icms_valor": r["icms_valor"],
|
||||
"pis_aliq": r["pis_aliq"],
|
||||
"pis_valor": r["pis_valor"],
|
||||
"cofins_aliq": r["cofins_aliq"],
|
||||
"cofins_valor": r["cofins_valor"],
|
||||
"distribuidora": r["distribuidora"],
|
||||
"data_processamento": r["data_processamento"].isoformat() if r["data_processamento"] else None,
|
||||
# novos
|
||||
"estado": uf,
|
||||
"exercicio": ano,
|
||||
"aliq_cadastral": aliq_cad,
|
||||
"aliq_diff_pp": round(diff_pp, 4) if diff_pp is not None else None,
|
||||
"aliq_ok": ok,
|
||||
})
|
||||
|
||||
return {"items": items, "total": total, "page": page, "page_size": page_size}
|
||||
|
||||
async def _carregar_aliquota_map(session):
|
||||
rows = (await session.execute(
|
||||
text("SELECT uf, exercicio, aliq_icms FROM faturas.aliquotas_uf")
|
||||
)).mappings().all()
|
||||
# (UF, ANO) -> float
|
||||
return {(r["uf"].upper(), int(r["exercicio"])): float(r["aliq_icms"]) for r in rows}
|
||||
@@ -1,5 +1,5 @@
|
||||
# 📄 models.py
|
||||
from sqlalchemy import Column, String, Integer, Float, DateTime, Text
|
||||
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
@@ -26,22 +26,22 @@ class Fatura(Base):
|
||||
classificacao_tarifaria = Column("classificacao_tarifaria", String)
|
||||
unidade_consumidora = Column("unidade_consumidora", String)
|
||||
referencia = Column(String)
|
||||
valor_total = Column(Float)
|
||||
valor_total = Column(Numeric(18, 6, asdecimal=True))
|
||||
|
||||
pis_aliq = Column("pis_aliq", Float)
|
||||
pis_valor = Column("pis_valor", Float)
|
||||
pis_base = Column("pis_base", Float)
|
||||
pis_aliq = Column(Numeric(8, 6, asdecimal=True))
|
||||
pis_valor = Column(Numeric(18, 6, asdecimal=True))
|
||||
pis_base = Column(Numeric(18, 6, asdecimal=True))
|
||||
|
||||
icms_aliq = Column("icms_aliq", Float)
|
||||
icms_valor = Column("icms_valor", Float)
|
||||
icms_base = Column("icms_base", Float)
|
||||
icms_aliq = Column(Numeric(8, 6, asdecimal=True))
|
||||
icms_valor = Column(Numeric(18, 6, asdecimal=True))
|
||||
icms_base = Column(Numeric(18, 6, asdecimal=True))
|
||||
|
||||
cofins_aliq = Column("cofins_aliq", Float)
|
||||
cofins_valor = Column("cofins_valor", Float)
|
||||
cofins_base = Column("cofins_base", Float)
|
||||
cofins_aliq = Column(Numeric(8, 6, asdecimal=True))
|
||||
cofins_valor = Column(Numeric(18, 6, asdecimal=True))
|
||||
cofins_base = Column(Numeric(18, 6, asdecimal=True))
|
||||
|
||||
consumo = Column("consumo", Float)
|
||||
tarifa = Column("tarifa", Float)
|
||||
consumo = Column(Numeric(14, 6, asdecimal=True))
|
||||
tarifa = Column(Numeric(12, 6, asdecimal=True))
|
||||
|
||||
nota_fiscal = Column(String)
|
||||
data_processamento = Column(DateTime, default=datetime.utcnow)
|
||||
@@ -50,6 +50,8 @@ class Fatura(Base):
|
||||
estado = Column(String)
|
||||
distribuidora = Column(String)
|
||||
link_arquivo = Column("link_arquivo", String)
|
||||
cliente_id = Column(UUID(as_uuid=True), ForeignKey("faturas.clientes.id"), nullable=False)
|
||||
|
||||
|
||||
|
||||
class LogProcessamento(Base):
|
||||
@@ -70,7 +72,7 @@ class AliquotaUF(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
uf = Column(String)
|
||||
exercicio = Column(String)
|
||||
exercicio = Column(Integer)
|
||||
aliq_icms = Column(Numeric(6, 4))
|
||||
|
||||
class SelicMensal(Base):
|
||||
@@ -80,3 +82,15 @@ class SelicMensal(Base):
|
||||
ano = Column(Integer, primary_key=True)
|
||||
mes = Column(Integer, primary_key=True)
|
||||
percentual = Column(Numeric(6, 4))
|
||||
|
||||
class Cliente(Base):
|
||||
__tablename__ = "clientes"
|
||||
__table_args__ = {"schema": "faturas"}
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
nome_fantasia = Column(String, nullable=False)
|
||||
cnpj = Column(String(14), unique=True)
|
||||
ativo = Column(Boolean, default=True)
|
||||
data_criacao = Column(DateTime, default=datetime.utcnow)
|
||||
data_atualizacao = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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 app.database import get_session
|
||||
from app.models import AliquotaUF, ParametrosFormula, SelicMensal
|
||||
@@ -9,7 +9,7 @@ import datetime
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.future import select
|
||||
from app.database import AsyncSessionLocal
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import RedirectResponse, JSONResponse
|
||||
from app.models import Fatura
|
||||
from fastapi import Body
|
||||
from app.database import engine
|
||||
@@ -21,8 +21,13 @@ import csv
|
||||
from fastapi.responses import StreamingResponse
|
||||
import pandas as pd
|
||||
from io import BytesIO
|
||||
from sqlalchemy import select
|
||||
from decimal import Decimal
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
router = APIRouter()
|
||||
|
||||
# === Schemas ===
|
||||
@@ -51,41 +56,57 @@ class SelicMensalSchema(BaseModel):
|
||||
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()
|
||||
async def parametros_page(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
# Fórmulas
|
||||
result_formula = await session.execute(
|
||||
text("SELECT id, nome, formula, ativo FROM faturas.parametros_formula ORDER BY id DESC")
|
||||
)
|
||||
formulas = [dict(row) for row in result_formula.mappings()]
|
||||
|
||||
# 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()
|
||||
# SELIC (dados + última competência)
|
||||
result_selic = await session.execute(
|
||||
text("SELECT ano, mes, percentual FROM faturas.selic_mensal ORDER BY ano DESC, mes DESC")
|
||||
)
|
||||
selic_dados = [dict(row) for row in result_selic.mappings()]
|
||||
ultima_data_selic = (
|
||||
f"{selic_dados[0]['mes']:02d}/{selic_dados[0]['ano']}" if selic_dados else None
|
||||
)
|
||||
|
||||
# Pega última data
|
||||
ultima_data_selic = "-"
|
||||
if selic_dados:
|
||||
ultima = selic_dados[0]
|
||||
ultima_data_selic = f"{ultima.mes:02d}/{ultima.ano}"
|
||||
# Alíquotas por UF
|
||||
result_aliquotas = await session.execute(
|
||||
text("""
|
||||
SELECT uf,
|
||||
exercicio,
|
||||
aliq_icms AS aliquota
|
||||
FROM faturas.aliquotas_uf
|
||||
ORDER BY uf ASC, exercicio DESC
|
||||
""")
|
||||
)
|
||||
aliquotas_uf = [dict(row) for row in result_aliquotas.mappings()]
|
||||
|
||||
# Campos 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", {
|
||||
# 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,
|
||||
"lista_parametros": parametros,
|
||||
"parametros": {},
|
||||
"campos_fatura": campos,
|
||||
"selic_dados": selic_dados,
|
||||
"ultima_data_selic": ultima_data_selic
|
||||
})
|
||||
"parametros": None, # evita erro no Jinja
|
||||
"formulas": formulas, # <-- usado no template
|
||||
"selic_dados": selic_dados, # <-- usado no template
|
||||
"aliquotas_uf": aliquotas_uf, # se precisar em JS
|
||||
"ultima_data_selic": ultima_data_selic,
|
||||
"data_maxima": None,
|
||||
"campos_fatura": campos_fatura,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.post("/parametros/editar/{param_id}")
|
||||
async def editar_parametro(param_id: int, request: Request):
|
||||
@@ -93,12 +114,35 @@ async def editar_parametro(param_id: int, request: Request):
|
||||
async with AsyncSessionLocal() as session:
|
||||
param = await session.get(ParametrosFormula, param_id)
|
||||
if param:
|
||||
param.tipo = data.get("tipo", param.tipo)
|
||||
param.nome = data.get("nome", param.nome)
|
||||
param.formula = data.get("formula", param.formula)
|
||||
param.ativo = data.get("ativo", param.ativo)
|
||||
await session.commit()
|
||||
return {"success": True}
|
||||
return {"success": False, "error": "Não encontrado"}
|
||||
|
||||
@router.post("/parametros/ativar/{param_id}")
|
||||
async def ativar_parametro(param_id: int, request: Request):
|
||||
data = await request.json()
|
||||
ativo = bool(data.get("ativo", True))
|
||||
async with AsyncSessionLocal() as session:
|
||||
param = await session.get(ParametrosFormula, param_id)
|
||||
if not param:
|
||||
return JSONResponse(status_code=404, content={"error": "Parâmetro não encontrado"})
|
||||
param.ativo = ativo
|
||||
await session.commit()
|
||||
return {"success": True}
|
||||
|
||||
@router.get("/parametros/delete/{param_id}")
|
||||
async def deletar_parametro(param_id: int):
|
||||
async with AsyncSessionLocal() as session:
|
||||
param = await session.get(ParametrosFormula, param_id)
|
||||
if not param:
|
||||
return RedirectResponse("/parametros?erro=1&msg=Parâmetro não encontrado", status_code=303)
|
||||
await session.delete(param)
|
||||
await session.commit()
|
||||
return RedirectResponse("/parametros?ok=1&msg=Parâmetro removido", status_code=303)
|
||||
|
||||
@router.post("/parametros/testar")
|
||||
async def testar_formula(db: AsyncSession = Depends(get_session), data: dict = Body(...)):
|
||||
formula = data.get("formula")
|
||||
@@ -116,10 +160,18 @@ async def testar_formula(db: AsyncSession = Depends(get_session), data: dict = B
|
||||
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.get("/parametros/aliquotas")
|
||||
async def listar_aliquotas(uf: str | None = None, db: AsyncSession = Depends(get_session)):
|
||||
stmt = select(AliquotaUF).order_by(AliquotaUF.uf, AliquotaUF.exercicio.desc())
|
||||
if uf:
|
||||
stmt = stmt.where(AliquotaUF.uf == uf)
|
||||
|
||||
rows = (await db.execute(stmt)).scalars().all()
|
||||
return [
|
||||
{"uf": r.uf, "exercicio": int(r.exercicio), "aliquota": float(r.aliq_icms)}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/parametros/aliquotas")
|
||||
async def adicionar_aliquota(aliq: AliquotaUFSchema, db: AsyncSession = Depends(get_session)):
|
||||
@@ -140,21 +192,21 @@ async def adicionar_aliquota(aliq: AliquotaUFSchema, db: AsyncSession = Depends(
|
||||
|
||||
@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))
|
||||
result = await db.execute(select(ParametrosFormula).order_by(ParametrosFormula.nome))
|
||||
return result.scalars().all()
|
||||
|
||||
@router.post("/parametros/formulas")
|
||||
async def salvar_formula(form: ParametrosFormulaSchema, db: AsyncSession = Depends(get_session)):
|
||||
result = await db.execute(
|
||||
select(ParametrosFormula).filter_by(tipo=form.tipo)
|
||||
select(ParametrosFormula).filter_by(nome=form.nome)
|
||||
)
|
||||
existente = result.scalar_one_or_none()
|
||||
|
||||
if existente:
|
||||
existente.formula = form.formula
|
||||
existente.campos = form.campos
|
||||
existente.ativo = form.ativo
|
||||
else:
|
||||
novo = ParametrosFormula(**form.dict())
|
||||
novo = ParametrosFormula(nome=form.nome, formula=form.formula, ativo=form.ativo)
|
||||
db.add(novo)
|
||||
|
||||
await db.commit()
|
||||
@@ -226,5 +278,88 @@ def baixar_template_excel():
|
||||
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}
|
||||
|
||||
@@ -56,7 +56,7 @@ def salvar_em_uploads(caminho_pdf_temp, nome_original, nota_fiscal):
|
||||
logger.error(f"Erro ao salvar em uploads: {e}")
|
||||
return caminho_pdf_temp
|
||||
|
||||
async def process_single_file(caminho_pdf_temp: str, nome_original: str):
|
||||
async def process_single_file(caminho_pdf_temp: str, nome_original: str, cliente_id: str | None = None):
|
||||
inicio = time.perf_counter()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
@@ -64,6 +64,41 @@ async def process_single_file(caminho_pdf_temp: str, nome_original: str):
|
||||
dados = extrair_dados_pdf(caminho_pdf_temp)
|
||||
dados['arquivo_pdf'] = nome_original
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
_Q6 = Decimal("0.000000")
|
||||
|
||||
def _to_percent_6(x):
|
||||
"""Converte para percent (se vier em fração) e quantiza em 6 casas."""
|
||||
if x is None:
|
||||
return None
|
||||
try:
|
||||
v = Decimal(str(x))
|
||||
except Exception:
|
||||
return None
|
||||
# se vier em fração (ex.: 0.012872), vira 1.2872… (percentual)
|
||||
if Decimal("0") < v <= Decimal("1"):
|
||||
v = v * Decimal("100")
|
||||
return v.quantize(_Q6, rounding=ROUND_HALF_UP)
|
||||
|
||||
def _to_dec6(x):
|
||||
"""Apenas 6 casas, sem % (use para tarifa, bases, etc.)."""
|
||||
if x is None:
|
||||
return None
|
||||
try:
|
||||
v = Decimal(str(x))
|
||||
except Exception:
|
||||
return None
|
||||
return v.quantize(_Q6, rounding=ROUND_HALF_UP)
|
||||
|
||||
dados['icms_aliq'] = _to_percent_6(dados.get('icms_aliq'))
|
||||
dados['pis_aliq'] = _to_percent_6(dados.get('pis_aliq'))
|
||||
dados['cofins_aliq'] = _to_percent_6(dados.get('cofins_aliq'))
|
||||
|
||||
# tarifa NÃO é percentual: apenas 6 casas
|
||||
dados['tarifa'] = _to_dec6(dados.get('tarifa'))
|
||||
|
||||
|
||||
# Verifica se a fatura já existe
|
||||
existente_result = await session.execute(
|
||||
select(Fatura).filter_by(
|
||||
@@ -89,7 +124,10 @@ async def process_single_file(caminho_pdf_temp: str, nome_original: str):
|
||||
dados['link_arquivo'] = caminho_final
|
||||
|
||||
# Salva fatura
|
||||
fatura = Fatura(**dados)
|
||||
dados['cliente_id'] = cliente_id
|
||||
if cliente_id:
|
||||
dados['cliente_id'] = cliente_id
|
||||
fatura = Fatura(**dados)
|
||||
session.add(fatura)
|
||||
|
||||
await session.commit()
|
||||
@@ -125,15 +163,36 @@ async def processar_em_lote():
|
||||
while not fila_processamento.empty():
|
||||
item = await fila_processamento.get()
|
||||
try:
|
||||
resultado = await process_single_file(item['caminho_pdf'], item['nome_original'])
|
||||
resultado = await process_single_file(
|
||||
item['caminho_pdf'],
|
||||
item['nome_original'],
|
||||
item.get('cliente_id')
|
||||
)
|
||||
# tentar tamanho/data do TEMP; se não existir mais, tenta do destino final; senão, 0/""
|
||||
temp_path = item['caminho_pdf']
|
||||
dest_path = (resultado.get("dados") or {}).get("link_arquivo", "")
|
||||
|
||||
def _safe_size(p):
|
||||
try:
|
||||
return os.path.getsize(p) // 1024
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def _safe_mtime(p):
|
||||
try:
|
||||
return time.strftime("%d/%m/%Y", time.localtime(os.path.getmtime(p)))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
status_arquivos[item['nome_original']] = {
|
||||
"status": resultado.get("status"),
|
||||
"mensagem": resultado.get("mensagem", ""),
|
||||
"tempo": resultado.get("tempo", "---"),
|
||||
"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'])))
|
||||
"tamanho": _safe_size(temp_path) or _safe_size(dest_path),
|
||||
"data": _safe_mtime(temp_path) or _safe_mtime(dest_path),
|
||||
}
|
||||
|
||||
|
||||
resultados.append(status_arquivos[item['nome_original']])
|
||||
except Exception as e:
|
||||
status_arquivos[item['nome_original']] = {
|
||||
@@ -156,17 +215,21 @@ async def processar_em_lote():
|
||||
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:
|
||||
erros_dir = os.path.join(UPLOADS_DIR, "erros")
|
||||
os.makedirs(erros_dir, exist_ok=True) # <- GARANTE A PASTA
|
||||
|
||||
with open(os.path.join(erros_dir, "erros.txt"), "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(erros_txt))
|
||||
|
||||
# Compacta PDFs com erro
|
||||
with ZipFile(os.path.join(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:
|
||||
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():
|
||||
status_arquivos.clear()
|
||||
@@ -192,6 +255,6 @@ async def garantir_selic_para_competencia(session, ano, mes):
|
||||
|
||||
if dados:
|
||||
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)
|
||||
await session.commit()
|
||||
72
app/routes/clientes.py
Normal file
72
app/routes/clientes.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# app/routes/clientes.py
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, or_
|
||||
from app.database import get_session
|
||||
from app.models import Cliente
|
||||
from pydantic import BaseModel
|
||||
from uuid import UUID
|
||||
import uuid
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/clientes")
|
||||
async def clientes_page(request: Request):
|
||||
return request.app.state.templates.TemplateResponse("clientes.html", {"request": request})
|
||||
|
||||
class ClienteIn(BaseModel):
|
||||
nome_fantasia: str
|
||||
cnpj: str | None = None
|
||||
ativo: bool = True
|
||||
|
||||
@router.get("/api/clientes")
|
||||
async def listar(
|
||||
busca: str = Query(default="", description="Filtro por nome ou CNPJ"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
stmt = select(Cliente).order_by(Cliente.nome_fantasia)
|
||||
if busca:
|
||||
pattern = f"%{busca}%"
|
||||
stmt = select(Cliente).where(
|
||||
or_(
|
||||
Cliente.nome_fantasia.ilike(pattern),
|
||||
Cliente.cnpj.ilike(pattern),
|
||||
)
|
||||
).order_by(Cliente.nome_fantasia)
|
||||
|
||||
res = await session.execute(stmt)
|
||||
clientes = res.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": str(c.id),
|
||||
"nome_fantasia": c.nome_fantasia,
|
||||
"cnpj": c.cnpj,
|
||||
"ativo": c.ativo,
|
||||
}
|
||||
for c in clientes
|
||||
]
|
||||
|
||||
@router.post("/api/clientes")
|
||||
async def criar_cliente(body: ClienteIn, session: AsyncSession = Depends(get_session)):
|
||||
cliente = Cliente(**body.dict())
|
||||
session.add(cliente)
|
||||
await session.commit()
|
||||
return {"id": str(cliente.id)}
|
||||
|
||||
@router.put("/api/clientes/{id}")
|
||||
async def editar_cliente(id: UUID, body: ClienteIn, session: AsyncSession = Depends(get_session)):
|
||||
await session.execute(
|
||||
Cliente.__table__.update().where(Cliente.id == id).values(**body.dict())
|
||||
)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@router.delete("/api/clientes/{id}")
|
||||
async def excluir(id: uuid.UUID, session: AsyncSession = Depends(get_session)):
|
||||
obj = await session.get(Cliente, id)
|
||||
if not obj:
|
||||
raise HTTPException(404, "Cliente não encontrado")
|
||||
await session.delete(obj)
|
||||
await session.commit()
|
||||
return {"ok": True}
|
||||
502
app/templates/clientes.html
Normal file
502
app/templates/clientes.html
Normal file
@@ -0,0 +1,502 @@
|
||||
{% extends "index.html" %}
|
||||
{% block title %}Clientes{% endblock %}
|
||||
{% block content %}
|
||||
<h1>🧾 Clientes</h1>
|
||||
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin:16px 0;">
|
||||
<input id="busca" type="text" placeholder="Pesquisar por nome/CNPJ…"
|
||||
style="padding:.6rem;border:1px solid #ddd;border-radius:10px;min-width:280px;">
|
||||
<button id="btnNovo" class="btn btn-primary" type="button">Novo Cliente</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<div class="tbl-wrap">
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:45%;">Cliente</th>
|
||||
<th style="width:25%;">CNPJ</th>
|
||||
<th style="width:15%;">Status</th>
|
||||
<th style="width:15%; text-align:right;">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody-clientes">
|
||||
<tr><td colspan="4" class="muted">Nenhum cliente encontrado.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div id="modal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-backdrop" onclick="fecharModal()"></div>
|
||||
<div class="modal-card">
|
||||
<div id="status_bar" class="status-bar on" aria-hidden="true"></div>
|
||||
<div class="modal-header">
|
||||
<h3 id="modal-titulo">Novo Cliente</h3>
|
||||
<button type="button" class="btn btn-secondary" onclick="fecharModal()">✕</button>
|
||||
</div>
|
||||
|
||||
<form id="form-modal" onsubmit="return salvarModal(event)">
|
||||
<input type="hidden" id="cli_id">
|
||||
|
||||
<div class="modal-body form-grid">
|
||||
<div class="form-group">
|
||||
<label>Nome fantasia *</label>
|
||||
<input id="cli_nome" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>CNPJ</label>
|
||||
<input id="cli_cnpj"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
placeholder="00.000.000/0000-00">
|
||||
</div>
|
||||
|
||||
<div class="form-group status-inline" id="grp_status">
|
||||
<div style="flex:1">
|
||||
<label>Status</label>
|
||||
<select id="cli_ativo" onchange="setStatusUI(this.value === 'true')">
|
||||
<option value="true">Ativo</option>
|
||||
<option value="false">Inativo</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="fecharModal()">Cancelar</button>
|
||||
<button type="submit" class="btn btn-primary">💾 Salvar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tbl-wrap{ background:#fff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb; }
|
||||
.tbl{ width:100%; border-collapse:separate; border-spacing:0; }
|
||||
.tbl thead th{
|
||||
background:#2563eb; color:#fff; padding:12px; text-align:left; font-weight:700;
|
||||
}
|
||||
.tbl thead th:first-child{ border-top-left-radius:12px; }
|
||||
.tbl thead th:last-child{ border-top-right-radius:12px; }
|
||||
.tbl tbody td{ padding:12px; border-top:1px solid #eef2f7; vertical-align:middle; }
|
||||
.tbl tbody tr:nth-child(even){ background:#f8fafc; }
|
||||
.muted{ color:#6b7280; text-align:center; padding:16px; }
|
||||
|
||||
.badge{ display:inline-block; padding:.2rem .6rem; border-radius:999px; font-weight:700; font-size:.78rem; color:#fff; }
|
||||
.on{ background:#16a34a; } .off{ background:#9ca3af; }
|
||||
|
||||
/* Modal */
|
||||
.hidden{ display:none; }
|
||||
.modal{
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex; /* centraliza */
|
||||
align-items: center; /* <-- centraliza vertical */
|
||||
justify-content: center;
|
||||
padding: 6vh 16px; /* respiro e evita colar nas bordas */
|
||||
}
|
||||
.modal-backdrop{
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(15,23,42,.45);
|
||||
z-index: 0;
|
||||
}
|
||||
.modal-card{
|
||||
position: relative;
|
||||
z-index: 1; /* acima do backdrop */
|
||||
width: min(760px, 92vw); /* largura consistente */
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
overflow: hidden; /* barra acompanha cantos */
|
||||
box-shadow: 0 18px 40px rgba(0,0,0,.18);
|
||||
padding: 18px; /* respiro interno */
|
||||
}
|
||||
.modal-header{
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
margin-bottom: 12px; position: relative; z-index: 1;
|
||||
}
|
||||
.modal-header h3{ margin:0; font-size:1.4rem; }
|
||||
|
||||
.modal-body{
|
||||
margin-top: 6px; position: relative; z-index: 1;
|
||||
}
|
||||
|
||||
.modal-footer{
|
||||
display:flex; justify-content:flex-end; gap:.6rem; margin-top:16px;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
|
||||
.form-grid{ display:grid; grid-template-columns:1fr; gap:14px; }
|
||||
@media (min-width: 900px){ .form-grid{ grid-template-columns:1fr 1fr; } }
|
||||
.form-group label{ display:block; margin-bottom:6px; color:#374151; }
|
||||
.form-group input, .form-group select{
|
||||
width:100%; padding:.65rem .8rem; border:1px solid #e5e7eb;
|
||||
border-radius:12px; background:#fff;
|
||||
}
|
||||
.form-group input:focus, .form-group select:focus{
|
||||
outline:none; border-color:#2563eb; box-shadow:0 0 0 3px rgba(37,99,235,.15);
|
||||
}
|
||||
|
||||
.status-inline{ display:flex; align-items:flex-end; gap:12px; }
|
||||
.badge{ display:inline-block; padding:.35rem .7rem; border-radius:999px; font-weight:700; color:#fff; }
|
||||
.badge.on{ background:#16a34a; } /* ativo */
|
||||
.badge.off{ background:#dc2626; } /* inativo */
|
||||
|
||||
.form-group label{ display:block; font-size:.9rem; color:#374151; margin-bottom:4px; }
|
||||
.form-group input, .form-group select{
|
||||
width:100%; padding:.55rem .7rem; border:1px solid #e5e7eb; border-radius:10px; background:#fff;
|
||||
}
|
||||
.hint{ font-size:.78rem; color:#6b7280; margin-top:4px; }
|
||||
|
||||
.btn {
|
||||
padding: .5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: #d1d5db;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: #b91c1c;
|
||||
}
|
||||
|
||||
.status-bar{
|
||||
position:absolute;
|
||||
top:0; left:0;
|
||||
width: 10px; /* espessura da barra */
|
||||
height:100%;
|
||||
background:#16a34a; /* default: ativo */
|
||||
pointer-events:none; /* não intercepta cliques */
|
||||
z-index: 0; /* fica por trás do conteúdo */
|
||||
}
|
||||
|
||||
/* Cores por estado */
|
||||
.status-bar.on { background:#16a34a; } /* ativo (verde) */
|
||||
.status-bar.off { background:#ef4444; } /* inativo (vermelho) */
|
||||
|
||||
.status-ativo {
|
||||
background-color: #16a34a; /* verde */
|
||||
}
|
||||
|
||||
.status-inativo {
|
||||
background-color: #ef4444; /* vermelho */
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
td.acoes { text-align: right; white-space: nowrap; }
|
||||
|
||||
.btn-icon{
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
background: #e5e7eb; /* cinza claro */
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.btn-icon:hover{ background:#d1d5db; }
|
||||
.btn-icon.danger{ background:#dc2626; color:#fff; }
|
||||
.btn-icon.danger:hover{ background:#b91c1c; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const onlyDigits = s => (s||'').replace(/\D/g,'');
|
||||
const elBody = document.getElementById('tbody-clientes');
|
||||
const elBusca = document.getElementById('busca');
|
||||
|
||||
function setStatusUI(isActive){
|
||||
const bar = document.getElementById('status_bar');
|
||||
if(!bar) return;
|
||||
bar.classList.toggle('on', isActive);
|
||||
bar.classList.toggle('off', !isActive);
|
||||
}
|
||||
|
||||
// monta uma linha da tabela
|
||||
function linha(c){
|
||||
return `
|
||||
<tr>
|
||||
<td>${c.nome_fantasia || '-'}</td>
|
||||
<td>${formatCNPJ(c.cnpj || '')}</td>
|
||||
<td>
|
||||
<span class="badge ${c.ativo ? 'on' : 'off'}">
|
||||
${c.ativo ? 'Ativo' : 'Inativo'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="acoes">
|
||||
<button class="btn-icon" title="Editar" aria-label="Editar"
|
||||
onclick='abrirModalEditar(${JSON.stringify(c)})'>
|
||||
✏️
|
||||
</button>
|
||||
<button class="btn-icon danger" title="Excluir" aria-label="Excluir"
|
||||
onclick="removerCliente('${c.id}')">
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// renderiza a lista no tbody (com filtro da busca)
|
||||
function render(lista){
|
||||
const termo = (elBusca.value || '').toLowerCase();
|
||||
const filtrada = lista.filter(c =>
|
||||
(c.nome_fantasia || '').toLowerCase().includes(termo) ||
|
||||
(c.cnpj || '').includes(onlyDigits(termo))
|
||||
);
|
||||
elBody.innerHTML = filtrada.length
|
||||
? filtrada.map(linha).join('')
|
||||
: `<tr><td colspan="4" class="muted">Nenhum cliente encontrado.</td></tr>`;
|
||||
}
|
||||
|
||||
// carrega clientes do backend e renderiza
|
||||
async function carregar(busca = "") {
|
||||
const r = await fetch('/api/clientes');
|
||||
if (!r.ok){ console.error('Falha ao carregar clientes'); return; }
|
||||
const dados = await r.json();
|
||||
window.__clientes = dados; // guarda em memória para o filtro
|
||||
render(dados);
|
||||
}
|
||||
|
||||
// excluir cliente
|
||||
async function carregar(busca = "") {
|
||||
const r = await fetch(`/api/clientes?busca=${encodeURIComponent(busca)}`);
|
||||
if (!r.ok) { console.error('Falha ao carregar clientes'); return; }
|
||||
const dados = await r.json();
|
||||
window.__clientes = dados; // mantém em memória, se quiser
|
||||
render(dados);
|
||||
}
|
||||
|
||||
function abrirModalNovo(){
|
||||
const modal = document.getElementById('modal');
|
||||
const grpStatus = document.getElementById('grp_status');
|
||||
const inpId = document.getElementById('cli_id');
|
||||
const inpNome = document.getElementById('cli_nome');
|
||||
const inpCnpj = document.getElementById('cli_cnpj');
|
||||
const selAtv = document.getElementById('cli_ativo');
|
||||
|
||||
document.getElementById('modal-titulo').textContent = 'Novo Cliente';
|
||||
|
||||
inpId.value = '';
|
||||
inpNome.value = '';
|
||||
inpCnpj.value = ''; // <<< nada de mask.textContent
|
||||
|
||||
selAtv.value = 'true';
|
||||
setStatusUI(true);
|
||||
|
||||
// novo: não mostra o select de status
|
||||
grpStatus.style.display = 'none';
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
setTimeout(()=> inpNome.focus(), 0);
|
||||
}
|
||||
|
||||
function abrirModalEditar(c){
|
||||
const modal = document.getElementById('modal');
|
||||
const grpStatus = document.getElementById('grp_status');
|
||||
const inpId = document.getElementById('cli_id');
|
||||
const inpNome = document.getElementById('cli_nome');
|
||||
const inpCnpj = document.getElementById('cli_cnpj');
|
||||
const selAtv = document.getElementById('cli_ativo');
|
||||
|
||||
document.getElementById('modal-titulo').textContent = 'Editar Cliente';
|
||||
|
||||
inpId.value = c.id || '';
|
||||
inpNome.value = c.nome_fantasia || '';
|
||||
// Preenche já mascarado no próprio input
|
||||
inpCnpj.value = formatCNPJ(c.cnpj || ''); // <<< em vez de mask.textContent
|
||||
|
||||
grpStatus.style.display = ''; // mostra no editar
|
||||
const ativo = !!c.ativo;
|
||||
selAtv.value = ativo ? 'true' : 'false';
|
||||
setStatusUI(ativo);
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
modal.setAttribute('aria-hidden', 'false');
|
||||
setTimeout(()=> inpNome.focus(), 0);
|
||||
}
|
||||
|
||||
function fecharModal(){
|
||||
const modal = document.getElementById('modal');
|
||||
modal.classList.add('hidden');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
// sincroniza barra quando trocar o select (só visível no modo edição)
|
||||
document.getElementById('cli_ativo').addEventListener('change', (e)=>{
|
||||
setStatusUI(e.target.value === 'true');
|
||||
});
|
||||
|
||||
// LIGA o botão "Novo Cliente"
|
||||
document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const btnNovo = document.getElementById('btnNovo');
|
||||
if (btnNovo) btnNovo.addEventListener('click', abrirModalNovo);
|
||||
});
|
||||
|
||||
|
||||
function formatCNPJ(d){ // 14 dígitos -> 00.000.000/0000-00
|
||||
d = onlyDigits(d).slice(0,14);
|
||||
let out = '';
|
||||
if (d.length > 0) out += d.substring(0,2);
|
||||
if (d.length > 2) out += '.' + d.substring(2,5);
|
||||
if (d.length > 5) out += '.' + d.substring(5,8);
|
||||
if (d.length > 8) out += '/' + d.substring(8,12);
|
||||
if (d.length > 12) out += '-' + d.substring(12,14);
|
||||
return out;
|
||||
}
|
||||
|
||||
function maskCNPJ(ev){
|
||||
const el = ev.target;
|
||||
const caret = el.selectionStart;
|
||||
const before = el.value;
|
||||
el.value = formatCNPJ(el.value);
|
||||
// caret simples (bom o suficiente aqui)
|
||||
const diff = el.value.length - before.length;
|
||||
el.selectionStart = el.selectionEnd = Math.max(0, (caret||0) + diff);
|
||||
}
|
||||
|
||||
// valida CNPJ com dígitos verificadores
|
||||
function isValidCNPJ(v){
|
||||
const c = onlyDigits(v);
|
||||
if (c.length !== 14) return false;
|
||||
if (/^(\d)\1{13}$/.test(c)) return false; // todos iguais
|
||||
|
||||
const calc = (base) => {
|
||||
const nums = base.split('').map(n=>parseInt(n,10));
|
||||
const pesos = [];
|
||||
for (let i=0;i<nums.length;i++){
|
||||
pesos.push( (nums.length+1-i) > 9 ? (nums.length+1-i)-8 : (nums.length+1-i) );
|
||||
}
|
||||
let soma = 0;
|
||||
for (let i=0;i<nums.length;i++) soma += nums[i] * pesos[i];
|
||||
const r = soma % 11;
|
||||
return (r < 2) ? 0 : (11 - r);
|
||||
};
|
||||
|
||||
const d1 = calc(c.substring(0,12));
|
||||
const d2 = calc(c.substring(0,12) + d1);
|
||||
return c.endsWith(`${d1}${d2}`);
|
||||
}
|
||||
|
||||
// ligar máscara
|
||||
document.addEventListener('DOMContentLoaded', ()=>{
|
||||
const cnpjEl = document.getElementById('cli_cnpj');
|
||||
if (cnpjEl){
|
||||
cnpjEl.addEventListener('input', maskCNPJ);
|
||||
}
|
||||
});
|
||||
|
||||
async function salvarModal(e){
|
||||
e.preventDefault();
|
||||
|
||||
const btnSalvar = document.querySelector('#form-modal .btn.btn-primary[type="submit"]');
|
||||
if (btnSalvar) btnSalvar.disabled = true;
|
||||
|
||||
try {
|
||||
const nome = document.getElementById('cli_nome').value.trim();
|
||||
const cnpjEl = document.getElementById('cli_cnpj');
|
||||
const ativo = document.getElementById('cli_ativo').value === 'true';
|
||||
const id = document.getElementById('cli_id').value || null;
|
||||
|
||||
const cnpjDigits = onlyDigits(cnpjEl.value);
|
||||
|
||||
if (!nome){
|
||||
alert('Informe o nome fantasia.');
|
||||
document.getElementById('cli_nome').focus();
|
||||
return;
|
||||
}
|
||||
if (cnpjDigits && !isValidCNPJ(cnpjEl.value)){
|
||||
alert('CNPJ inválido.');
|
||||
cnpjEl.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { nome_fantasia: nome, cnpj: cnpjDigits || null, ativo };
|
||||
const url = id ? `/api/clientes/${id}` : '/api/clientes';
|
||||
const method = id ? 'PUT' : 'POST';
|
||||
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (r.status === 409) {
|
||||
const { detail } = await r.json().catch(() => ({ detail: 'CNPJ já cadastrado.' }));
|
||||
alert(detail || 'CNPJ já cadastrado.');
|
||||
return;
|
||||
}
|
||||
if (!r.ok){
|
||||
alert('Erro ao salvar.');
|
||||
return;
|
||||
}
|
||||
|
||||
fecharModal();
|
||||
carregar();
|
||||
} finally {
|
||||
if (btnSalvar) btnSalvar.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Botão Novo Cliente
|
||||
const btnNovo = document.getElementById('btnNovo');
|
||||
if (btnNovo) btnNovo.addEventListener('click', abrirModalNovo);
|
||||
|
||||
// Campo de busca
|
||||
const busca = document.getElementById('busca');
|
||||
const debounce = (fn, wait=250) => {
|
||||
let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); };
|
||||
};
|
||||
|
||||
if (busca) {
|
||||
busca.addEventListener('input', debounce(() => {
|
||||
carregar(busca.value.trim()); // <-- agora consulta o backend a cada digitação (com debounce)
|
||||
}, 250));
|
||||
}
|
||||
|
||||
|
||||
// Máscara no CNPJ do modal
|
||||
const cnpjEl = document.getElementById('cli_cnpj');
|
||||
if (cnpjEl) cnpjEl.addEventListener('input', maskCNPJ);
|
||||
|
||||
// Carregar clientes na tabela ao abrir a página
|
||||
carregar();
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -100,11 +100,11 @@
|
||||
<form method="get" action="/" style="margin: 6px 0 18px">
|
||||
<div class="combo-wrap">
|
||||
<label for="cliente" style="font-size:13px;color:#374151">Selecionar Cliente:</label>
|
||||
<select class="combo" name="cliente" id="cliente">
|
||||
<option value="">Todos</option>
|
||||
{% for c in clientes %}
|
||||
<option value="{{ c }}" {% if c == cliente_atual %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
<select id="cliente" name="cliente" class="combo">
|
||||
<option value="">Todos</option>
|
||||
{% for c in clientes %}
|
||||
<option value="{{ c.id }}" {% if cliente_selecionado == c.id %}selected{% endif %}>{{ c.nome }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -154,6 +154,10 @@
|
||||
<i class="fas fa-tachometer-alt"></i>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a href="/clientes" class="menu-item">
|
||||
<i class="fas fa-building"></i>
|
||||
<span>Clientes</span>
|
||||
</a>
|
||||
<a href="/upload" class="menu-item">
|
||||
<i class="fas fa-upload"></i>
|
||||
<span>Upload</span>
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="aliquota_icms">Alíquota de ICMS (%):</label>
|
||||
<input type="number" step="0.01" name="aliquota_icms" id="aliquota_icms" value="{{ parametros.aliquota_icms or '' }}" />
|
||||
<input type="text" id="aliquota" name="aliquota" inputmode="decimal" pattern="[0-9]+([,][0-9]+)?" placeholder="Ex: 20,7487">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="formula">Fórmula:</label>
|
||||
@@ -32,7 +32,7 @@
|
||||
<div style="margin-bottom: 0.5rem;">
|
||||
<strong>Campos disponíveis:</strong>
|
||||
<div class="campo-badges">
|
||||
{% for campo in campos_fatura %}
|
||||
{% for campo in (campos_fatura or []) %}
|
||||
<span class="badge-campo" onclick="inserirNoEditor('{{ campo }}')">{{ campo }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -61,36 +61,34 @@
|
||||
</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 class="card-list">
|
||||
{% for param in (formulas or []) %}
|
||||
<div class="param-card {{ 'ativo' if param.ativo else 'inativo' }}" id="card-{{ param.id }}">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<input type="text" class="edit-nome" value="{{ param.nome }}" data-id="{{ param.id }}"
|
||||
onkeydown="if(event.key==='Enter'){ event.preventDefault(); salvarInline('{{ param.id }}') }" />
|
||||
<span class="badge-status">{{ 'Ativo ✅' if param.ativo else 'Inativo ❌' }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:gray;">Nenhuma fórmula cadastrada.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<textarea class="edit-formula" data-id="{{ param.id }}" title="{{ param.formula }}">{{ param.formula }}</textarea>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items:center;">
|
||||
<label>
|
||||
<input type="checkbox" class="toggle-ativo" data-id="{{ param.id }}" {% if param.ativo %}checked{% endif %}>
|
||||
Ativo
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-sm btn-secondary btn-testar" data-id="{{ param.id }}">🧪 Testar</button>
|
||||
<button class="btn btn-sm btn-primary" onclick="salvarInline('{{ param.id }}')">💾 Salvar</button>
|
||||
<a href="/parametros/delete/{{ param.id }}" class="btn btn-sm btn-danger" onclick="return confirm('Deseja excluir?')">🗑️ Excluir</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mensagem-info" id="resultado-inline-{{ param.id }}" style="margin-top: 0.5rem; display:none;"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:gray;">Nenhuma fórmula cadastrada.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ABA SELIC -->
|
||||
<div id="selic" class="tab-content">
|
||||
@@ -105,16 +103,16 @@
|
||||
<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>
|
||||
<div class="mensagem-info" style="margin-top:1rem;">Última data coletada da SELIC: <strong>{{ ultima_data_selic or '-' }}</strong></div>
|
||||
</form>
|
||||
<table class="selic-table">
|
||||
<thead><tr><th>Competência</th><th>Fator</th></tr></thead>
|
||||
<tbody>
|
||||
{% for item in selic_dados %}
|
||||
<tr>
|
||||
<tr>
|
||||
<td>{{ "%02d"|format(item.mes) }}/{{ item.ano }}</td>
|
||||
<td>{{ "%.4f"|format(item.percentual) }}</td>
|
||||
</tr>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -124,7 +122,7 @@
|
||||
<!-- ABA ALÍQUOTAS -->
|
||||
<div id="aliquotas" class="tab-content">
|
||||
<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="form-group">
|
||||
<label>UF:</label>
|
||||
@@ -141,7 +139,10 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<!-- Bloco com espaçamento e alinhamento central -->
|
||||
@@ -160,12 +161,35 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="orig-uf" name="original_uf">
|
||||
<input type="hidden" id="orig-exercicio" name="original_exercicio">
|
||||
|
||||
</form>
|
||||
|
||||
<table class="selic-table">
|
||||
<thead><tr><th>UF</th><th>Exercício</th><th>Alíquota</th></tr></thead>
|
||||
<tbody id="tabela-aliquotas"></tbody>
|
||||
</table>
|
||||
<!-- Filtro de UF para a tabela -->
|
||||
<div style="display:flex; align-items:center; gap:12px; margin:14px 0;">
|
||||
<label for="filtro-uf" style="font-weight:600;">Filtrar por UF:</label>
|
||||
<select id="filtro-uf" style="min-width:220px; padding:.5rem .75rem; border:1px solid #ddd; border-radius:8px;">
|
||||
<option value="">Todas</option>
|
||||
{% for uf in ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO'] %}
|
||||
<option value="{{ uf }}">{{ uf }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span id="total-aliquotas" class="muted"></span>
|
||||
</div>
|
||||
|
||||
<table class="selic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>UF</th>
|
||||
<th>Exercício</th>
|
||||
<th>Alíquota</th>
|
||||
<th style="width:140px;">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tabela-aliquotas"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -581,16 +605,39 @@
|
||||
|
||||
// ✅ Carrega tabela de alíquotas
|
||||
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 tbody = document.getElementById("tabela-aliquotas");
|
||||
if (!dados.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="3" style="padding:.6rem;">Nenhum registro.</td></tr>`;
|
||||
} else {
|
||||
tbody.innerHTML = dados.map(a => `
|
||||
<tr><td>${a.uf}</td><td>${a.exercicio}</td><td>${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('');
|
||||
}
|
||||
|
||||
document.getElementById("total-aliquotas").textContent = `Registros: ${dados.length}`;
|
||||
}
|
||||
|
||||
|
||||
// ✅ Eventos após carregar DOM
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById("filtro-uf")?.addEventListener("change", carregarAliquotas);
|
||||
carregarAliquotas();
|
||||
|
||||
// Ativar/desativar checkbox
|
||||
@@ -625,13 +672,11 @@
|
||||
}
|
||||
|
||||
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
|
||||
const aba = new URLSearchParams(window.location.search).get("aba");
|
||||
if (aba === "formulas" || aba === "selic" || aba === "aliquotas") {
|
||||
switchTab(aba);
|
||||
} else {
|
||||
switchTab("formulas"); // padrão
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- Feedback estilo popup -->
|
||||
|
||||
@@ -3,43 +3,227 @@
|
||||
{% block content %}
|
||||
<h1>📊 Relatórios</h1>
|
||||
|
||||
<form method="get" style="margin-bottom: 20px;">
|
||||
<label for="cliente">Filtrar por Cliente:</label>
|
||||
<select name="cliente" id="cliente" onchange="this.form.submit()">
|
||||
<option value="">Todos</option>
|
||||
{% for c in clientes %}
|
||||
<option value="{{ c }}" {% if c == cliente_atual %}selected{% endif %}>{{ c }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
<div style="display:flex; gap:16px; align-items:flex-end; flex-wrap:wrap; margin: 6px 0 18px;">
|
||||
<div class="combo-wrap">
|
||||
<label for="relatorio-cliente" style="font-size:13px;color:#374151">Selecionar Cliente:</label>
|
||||
<select id="relatorio-cliente" class="combo" style="min-width:340px;">
|
||||
<option value="">Todos</option>
|
||||
{% for c in clientes %}
|
||||
<option value="{{ c.id }}">{{ c.nome }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<a href="/export-excel{% if cliente_atual %}?cliente={{ cliente_atual }}{% endif %}" class="btn btn-primary">
|
||||
📥 Baixar Relatório Corrigido (Excel)
|
||||
</a>
|
||||
<div class="combo-wrap">
|
||||
<label for="tipo-relatorio" style="font-size:13px;color:#374151">Tipo de relatório:</label>
|
||||
<select id="tipo-relatorio" class="combo" style="min-width:240px;">
|
||||
<option value="geral">1. Geral</option>
|
||||
<option value="exclusao_icms">2. Exclusão do ICMS</option>
|
||||
<option value="aliquota_icms">3. Alíquota ICMS (%)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="combo-wrap">
|
||||
<label for="page-size" style="font-size:13px;color:#374151">Itens por página:</label>
|
||||
<select id="page-size" class="combo" style="width:140px;">
|
||||
<option>20</option>
|
||||
<option>50</option>
|
||||
<option>100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a id="link-excel" class="btn btn-primary" href="/export-excel">📥 Baixar (Excel)</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr style="background: #2563eb; color: white;">
|
||||
<th style="padding: 10px;">Cliente</th>
|
||||
<th>Data</th>
|
||||
<tr>
|
||||
<th>Cliente</th>
|
||||
<th>UC</th>
|
||||
<th>Referência</th>
|
||||
<th>Nota Fiscal</th>
|
||||
<th>Valor Total</th>
|
||||
<th>ICMS na Base</th>
|
||||
<th>Status</th>
|
||||
<th>ICMS (%)</th>
|
||||
<th>ICMS (R$)</th>
|
||||
<th>PIS (R$)</th>
|
||||
<th>COFINS (R$)</th>
|
||||
<th>Distribuidora</th>
|
||||
<th>Processado em</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody id="relatorios-body">
|
||||
{% for f in faturas %}
|
||||
<tr style="background: {{ loop.cycle('#ffffff', '#f0f4f8') }};">
|
||||
<td style="padding: 10px;">{{ f.nome }}</td>
|
||||
<td>{{ f.data_emissao }}</td>
|
||||
<td>R$ {{ '%.2f'|format(f.valor_total)|replace('.', ',') }}</td>
|
||||
<td>{{ 'Sim' if f.com_icms else 'Não' }}</td>
|
||||
<td>{{ f.status }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ f.nome }}</td>
|
||||
<td class="mono">{{ f.unidade_consumidora }}</td>
|
||||
<td class="mono">{{ f.referencia }}</td>
|
||||
<td class="mono">{{ f.nota_fiscal }}</td>
|
||||
<td>R$ {{ '%.2f'|format((f.valor_total or 0.0))|replace('.', ',') }}</td>
|
||||
<td>{{ '%.2f'|format((f.icms_aliq or 0.0))|replace('.', ',') }}</td>
|
||||
<td>R$ {{ '%.2f'|format((f.icms_valor or 0.0))|replace('.', ',') }}</td>
|
||||
<td>R$ {{ '%.2f'|format((f.pis_valor or 0.0))|replace('.', ',') }}</td>
|
||||
<td>R$ {{ '%.2f'|format((f.cofins_valor or 0.0))|replace('.', ',') }}</td>
|
||||
<td>{{ f.distribuidora or '-' }}</td>
|
||||
<td class="muted">{{ f.data_processamento }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="pager" style="display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:12px;">
|
||||
<div id="range" class="muted">Mostrando 0–0 de 0</div>
|
||||
<div style="display:flex; gap:8px;">
|
||||
<button id="prev" class="btn btn-primary">◀ Anterior</button>
|
||||
<button id="next" class="btn btn-primary">Próxima ▶</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<style>
|
||||
.combo {
|
||||
appearance: none;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 12px 44px 12px 14px;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
color: #111827;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,.06);
|
||||
transition: box-shadow .2s ease, border-color .2s ease, transform .2s ease;
|
||||
}
|
||||
.combo:focus { outline: none; border-color: #2563eb; box-shadow: 0 8px 28px rgba(37,99,235,.18); }
|
||||
.combo-wrap { position: relative; display: inline-flex; align-items: center; gap: 8px; }
|
||||
.combo-wrap:after {
|
||||
content: "▾"; position: absolute; right: 12px; pointer-events: none; color:#6b7280; font-size: 12px;
|
||||
}
|
||||
|
||||
/* tabela no estilo “clientes” */
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 12px 34px rgba(0,0,0,.06);
|
||||
}
|
||||
.table thead th {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
padding: 12px 14px;
|
||||
text-align: left;
|
||||
}
|
||||
.table tbody td {
|
||||
border-top: 1px solid #eef2f7;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
.table tbody tr:nth-child(odd){ background:#fafafa; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; }
|
||||
.muted { color:#6b7280; }
|
||||
|
||||
#pager .btn[disabled]{ opacity:.5; cursor:not-allowed; }
|
||||
</style>
|
||||
<script>
|
||||
let page = 1;
|
||||
let pageSize = 20;
|
||||
let total = 0;
|
||||
|
||||
function updateExcelLink() {
|
||||
const cliente = document.getElementById('relatorio-cliente').value || '';
|
||||
const tipo = document.getElementById('tipo-relatorio').value || 'geral';
|
||||
const params = new URLSearchParams();
|
||||
params.set('tipo', tipo);
|
||||
if (cliente) params.set('cliente', cliente);
|
||||
document.getElementById('link-excel').setAttribute('href', `/export-excel?${params.toString()}`);
|
||||
}
|
||||
|
||||
async function carregarTabela() {
|
||||
const cliente = document.getElementById('relatorio-cliente').value || '';
|
||||
const url = new URL('/api/relatorios', window.location.origin);
|
||||
url.searchParams.set('page', page);
|
||||
url.searchParams.set('page_size', pageSize);
|
||||
if (cliente) url.searchParams.set('cliente', cliente);
|
||||
|
||||
const r = await fetch(url);
|
||||
const data = await r.json();
|
||||
|
||||
total = data.total;
|
||||
renderRows(data.items);
|
||||
updatePager();
|
||||
updateExcelLink();
|
||||
}
|
||||
|
||||
function renderRows(items) {
|
||||
const tbody = document.getElementById('relatorios-body');
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="11" style="padding:14px;">Nenhum registro encontrado.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
const fmtBRL = (v) => (v || v === 0) ? Number(v).toLocaleString('pt-BR',{style:'currency',currency:'BRL'}) : '';
|
||||
const fmtNum = (v) => (v || v === 0) ? Number(v).toLocaleString('pt-BR') : '';
|
||||
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('pt-BR') : '';
|
||||
|
||||
tbody.innerHTML = items.map(f => `
|
||||
<tr>
|
||||
<td>${f.nome || ''}</td>
|
||||
<td class="mono">${f.unidade_consumidora || ''}</td>
|
||||
<td class="mono">${f.referencia || ''}</td>
|
||||
<td class="mono">${f.nota_fiscal || ''}</td>
|
||||
<td>${fmtBRL(f.valor_total)}</td>
|
||||
<td>${fmtNum(f.icms_aliq)}</td>
|
||||
<td>${fmtBRL(f.icms_valor)}</td>
|
||||
<td>${fmtBRL(f.pis_valor)}</td>
|
||||
<td>${fmtBRL(f.cofins_valor)}</td>
|
||||
<td>${f.distribuidora || '-'}</td>
|
||||
<td class="muted">${fmtDate(f.data_processamento)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function updatePager() {
|
||||
const start = total ? (page - 1) * pageSize + 1 : 0;
|
||||
const end = Math.min(page * pageSize, total);
|
||||
document.getElementById('range').textContent = `Mostrando ${start}–${end} de ${total}`;
|
||||
document.getElementById('prev').disabled = page <= 1;
|
||||
document.getElementById('next').disabled = page * pageSize >= total;
|
||||
}
|
||||
|
||||
document.getElementById('prev').addEventListener('click', () => {
|
||||
if (page > 1) { page--; carregarTabela(); }
|
||||
});
|
||||
document.getElementById('next').addEventListener('click', () => {
|
||||
if (page * pageSize < total) { page++; carregarTabela(); }
|
||||
});
|
||||
document.getElementById('page-size').addEventListener('change', (e) => {
|
||||
pageSize = parseInt(e.target.value, 10);
|
||||
page = 1;
|
||||
carregarTabela();
|
||||
// não precisa alterar o link aqui
|
||||
});
|
||||
document.getElementById('relatorio-cliente').addEventListener('change', () => {
|
||||
page = 1;
|
||||
carregarTabela();
|
||||
updateExcelLink();
|
||||
});
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const pre = "{{ cliente_selecionado or '' }}";
|
||||
if (pre) document.getElementById('relatorio-cliente').value = pre;
|
||||
updateExcelLink();
|
||||
carregarTabela();
|
||||
});
|
||||
|
||||
document.getElementById('tipo-relatorio').addEventListener('change', () => {
|
||||
updateExcelLink();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
|
||||
<h1 style="font-size: 1.5rem; margin-bottom: 1rem;">📤 Upload de Faturas</h1>
|
||||
|
||||
<!-- Seletor de Cliente (obrigatório) -->
|
||||
<div style="display:flex; gap:12px; align-items:center; margin: 0 0 14px 0;">
|
||||
<label for="select-cliente" style="font-weight:600;">Cliente:</label>
|
||||
<select id="select-cliente" style="min-width:320px; padding:.6rem .8rem; border:1px solid #ddd; border-radius:10px;">
|
||||
<option value="">— Selecione um cliente —</option>
|
||||
</select>
|
||||
<span id="cliente-aviso" class="muted">Selecione o cliente antes de anexar/ processar.</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="upload-box" id="upload-box">
|
||||
<h3>Arraste faturas em PDF aqui ou clique para selecionar</h3>
|
||||
<p style="color: gray; font-size: 0.9rem;">Apenas PDFs textuais (não escaneados)</p>
|
||||
@@ -22,8 +32,10 @@
|
||||
<a class="btn btn-secondary" href="/erros/log">📄 Ver Log de Erros (.txt)</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!--
|
||||
<button class="btn btn-success" onclick="baixarPlanilha()">📅 Abrir Planilha</button>
|
||||
<button class="btn btn-success" onclick="gerarRelatorio()">📊 Gerar Relatório</button>
|
||||
-->
|
||||
{% if app_env != "producao" %}
|
||||
<button class="btn btn-warning" onclick="limparFaturas()">🧹 Limpar Faturas (Teste)</button>
|
||||
{% endif %}
|
||||
@@ -34,7 +46,6 @@
|
||||
</div>
|
||||
<div id="tabela-wrapper" class="tabela-wrapper"></div>
|
||||
</div>
|
||||
ar
|
||||
<script>
|
||||
let arquivos = [];
|
||||
let statusInterval = null;
|
||||
@@ -43,18 +54,49 @@ ar
|
||||
|
||||
const fileTable = document.getElementById('file-table');
|
||||
|
||||
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;
|
||||
|
||||
// <<< NOVO: carrega clientes ativos no combo
|
||||
async function carregarClientes() {
|
||||
try {
|
||||
const r = await fetch('/api/clientes'); // se quiser só ativos: /api/clientes?ativos=true
|
||||
if (!r.ok) throw new Error('Falha ao carregar clientes');
|
||||
const lista = await r.json();
|
||||
|
||||
const sel = document.getElementById('select-cliente');
|
||||
sel.innerHTML = `<option value="">— Selecione um cliente —</option>` +
|
||||
lista.map(c => `<option value="${c.id}">${c.nome_fantasia}${c.cnpj ? ' — ' + c.cnpj : ''}</option>`).join('');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Não foi possível carregar a lista de clientes.');
|
||||
}
|
||||
}
|
||||
|
||||
function clienteSelecionado() {
|
||||
return (document.getElementById('select-cliente')?.value || '').trim();
|
||||
}
|
||||
|
||||
// <<< AJUSTE: impedir anexar sem cliente
|
||||
function handleFiles(files) {
|
||||
if (!clienteSelecionado()) {
|
||||
alert('Selecione um cliente antes de anexar os arquivos.');
|
||||
return;
|
||||
}
|
||||
if (processado) {
|
||||
document.getElementById("feedback-sucesso").innerText = "";
|
||||
document.getElementById("feedback-erro").innerText = "⚠️ Conclua ou inicie um novo processo antes de adicionar mais arquivos.";
|
||||
document.getElementById("feedback-duplicado").innerText = "";
|
||||
document.getElementById("upload-feedback").classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
arquivos = [...arquivos, ...files];
|
||||
|
||||
// trava o combo após começar a anexar (opcional)
|
||||
document.getElementById('select-cliente').disabled = true;
|
||||
|
||||
renderTable();
|
||||
}
|
||||
|
||||
arquivos = [...arquivos, ...files];
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderTable(statusList = []) {
|
||||
const grupos = ['Aguardando', 'Enviado', 'Erro', 'Duplicado'];
|
||||
@@ -102,7 +144,20 @@ function renderTable(statusList = []) {
|
||||
}
|
||||
|
||||
async function processar(btn) {
|
||||
if (!clienteSelecionado()) {
|
||||
alert("Selecione um cliente antes de processar.");
|
||||
return;
|
||||
}
|
||||
if (arquivos.length === 0) return alert("Nenhum arquivo selecionado.");
|
||||
|
||||
// Confirmação
|
||||
const clienteTxt = document.querySelector('#select-cliente option:checked')?.textContent || '';
|
||||
if (!confirm(`Confirmar processamento de ${arquivos.length} arquivo(s) para o cliente:\n\n${clienteTxt}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clienteId = clienteSelecionado();
|
||||
|
||||
document.getElementById("tabela-wrapper").classList.add("bloqueada");
|
||||
|
||||
if (processamentoFinalizado) {
|
||||
@@ -120,8 +175,9 @@ async function processar(btn) {
|
||||
document.getElementById("barra-progresso").style.width = "0%";
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const file = arquivos[i];
|
||||
const file = arquivos[i]; // <- declare 'file' ANTES de usar
|
||||
const formData = new FormData();
|
||||
formData.append("cliente_id", clienteId); // <- usa o cache do cliente
|
||||
formData.append("files", file);
|
||||
|
||||
// Atualiza status visual antes do envio
|
||||
@@ -129,14 +185,16 @@ async function processar(btn) {
|
||||
nome: file.name,
|
||||
status: "Enviando...",
|
||||
mensagem: `(${i + 1}/${total})`,
|
||||
tempo: "---"
|
||||
tempo: "---",
|
||||
tamanho: (file.size / 1024).toFixed(1) + " KB",
|
||||
data: new Date(file.lastModified).toLocaleDateString()
|
||||
});
|
||||
renderTable(statusList);
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await fetch("/upload-files", { method: "POST", body: formData });
|
||||
let progresso = Math.round(((i + 1) / total) * 100);
|
||||
const progresso = Math.round(((i + 1) / total) * 100);
|
||||
document.getElementById("barra-progresso").style.width = `${progresso}%`;
|
||||
|
||||
statusList[i].status = "Enviado";
|
||||
@@ -147,9 +205,7 @@ async function processar(btn) {
|
||||
}
|
||||
|
||||
renderTable(statusList);
|
||||
|
||||
// Delay de 200ms entre cada envio
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
await new Promise(r => setTimeout(r, 200)); // pequeno delay
|
||||
}
|
||||
|
||||
btn.innerText = "⏳ Iniciando processamento...";
|
||||
@@ -206,23 +262,39 @@ async function processar(btn) {
|
||||
}
|
||||
}
|
||||
|
||||
function limpar() {
|
||||
fetch("/clear-all", { method: "POST" });
|
||||
arquivos = [];
|
||||
processado = false;
|
||||
document.getElementById("file-input").value = null;
|
||||
renderTable();
|
||||
function limpar() {
|
||||
fetch("/clear-all", { method: "POST" });
|
||||
|
||||
// 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;
|
||||
// reset da fila/estado
|
||||
arquivos = [];
|
||||
processado = false;
|
||||
processamentoFinalizado = false;
|
||||
if (statusInterval) { clearInterval(statusInterval); statusInterval = null; }
|
||||
|
||||
// reset dos inputs/visual
|
||||
document.getElementById("file-input").value = null;
|
||||
document.getElementById("tabela-wrapper").classList.remove("bloqueada");
|
||||
document.getElementById("overlay-bloqueio").classList.add("hidden");
|
||||
document.getElementById("barra-progresso").style.width = "0%";
|
||||
document.getElementById("btn-selecionar").disabled = false;
|
||||
|
||||
// 🔓 permitir mudar o cliente novamente
|
||||
const sel = document.getElementById("select-cliente");
|
||||
sel.disabled = false;
|
||||
sel.value = ""; // <- se NÃO quiser limpar a escolha anterior, remova esta linha
|
||||
document.getElementById("cliente-aviso").textContent =
|
||||
"Selecione o cliente antes de anexar/ processar.";
|
||||
|
||||
// limpar feedback
|
||||
document.getElementById("upload-feedback").classList.add("hidden");
|
||||
document.getElementById("feedback-sucesso").innerText = "";
|
||||
document.getElementById("feedback-erro").innerText = "";
|
||||
document.getElementById("feedback-duplicado").innerText = "";
|
||||
|
||||
// limpar tabela
|
||||
renderTable();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function baixarPlanilha() {
|
||||
window.open('/export-excel', '_blank');
|
||||
@@ -233,6 +305,7 @@ async function processar(btn) {
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
carregarClientes();
|
||||
updateStatus();
|
||||
|
||||
const dragOverlay = document.getElementById("drag-overlay");
|
||||
@@ -255,11 +328,15 @@ window.addEventListener('DOMContentLoaded', () => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
window.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dragOverlay.classList.remove("active");
|
||||
dragCounter = 0;
|
||||
handleFiles(e.dataTransfer.files);
|
||||
window.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dragOverlay.classList.remove("active");
|
||||
dragCounter = 0;
|
||||
if (!clienteSelecionado()) {
|
||||
alert('Selecione um cliente antes de anexar os arquivos.');
|
||||
return;
|
||||
}
|
||||
handleFiles(e.dataTransfer.files);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,4 +7,5 @@ python-multipart==0.0.6
|
||||
openpyxl==3.1.2
|
||||
pandas==2.2.2
|
||||
PyMuPDF==1.22.5
|
||||
httpx==0.27.0
|
||||
httpx==0.27.0
|
||||
xlsxwriter==3.2.0
|
||||
Reference in New Issue
Block a user