diff --git a/app/main.py b/app/main.py
index 3f41161..f2390ce 100644
--- a/app/main.py
+++ b/app/main.py
@@ -29,6 +29,8 @@ 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
app = FastAPI()
@@ -40,29 +42,25 @@ 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):
@@ -373,46 +371,59 @@ async def clear_all():
@app.get("/export-excel")
async def export_excel(
- tipo: str = Query("geral", regex="^(geral|exclusao_icms|aliquota_icms)$"),
+ tipo: str = Query("geral", pattern="^(geral|exclusao_icms|aliquota_icms)$"),
cliente: str | None = Query(None)
):
- import pandas as pd
-
- # 1) Carregar faturas (com filtro por cliente, se houver)
async with AsyncSessionLocal() as session:
+ # 1) Faturas
stmt = select(Fatura)
if cliente:
- # filtra por cliente_id (UUID em string)
stmt = stmt.where(Fatura.cliente_id == cliente)
- faturas_result = await session.execute(stmt)
- faturas = faturas_result.scalars().all()
+ faturas = (await session.execute(stmt)).scalars().all()
+
+ # 2) Mapa de alíquotas cadastradas (UF/ano)
+ aliq_rows = (await session.execute(select(AliquotaUF))).scalars().all()
+ aliq_map = {(r.uf.upper(), int(r.exercicio)): float(r.aliq_icms) for r in aliq_rows}
- # 2) Montar dados conforme 'tipo'
dados = []
if tipo == "aliquota_icms":
- # Campos: (Cliente, UC, Referência, Valor Total, ICMS (%), ICMS (R$),
- # Base ICMS (R$), Consumo (kWh), Tarifa, Nota Fiscal)
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,
+ "UF (fatura)": uf,
+ "Exercício (ref)": ano,
"Referência": f.referencia,
- "Valor Total": f.valor_total,
- "ICMS (%)": f.icms_aliq,
- "ICMS (R$)": f.icms_valor,
- "Base ICMS (R$)": f.icms_base,
- "Consumo (kWh)": f.consumo,
- "Tarifa": f.tarifa,
"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,
})
filename = "relatorio_aliquota_icms.xlsx"
elif tipo == "exclusao_icms":
- # Campos: (Cliente, UC, Referência, Valor Total, PIS (%), ICMS (%), COFINS (%),
- # PIS (R$), ICMS (R$), COFINS (R$), Base PIS (R$), Base ICMS (R$),
- # Base COFINS (R$), Consumo (kWh), Tarifa, Nota Fiscal)
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,
@@ -427,17 +438,27 @@ async def export_excel(
"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,
})
filename = "relatorio_exclusao_icms.xlsx"
- else: # "geral" (mantém seu relatório atual — sem fórmulas SELIC)
- # Se quiser manter exatamente o que já tinha com SELIC e fórmulas,
- # você pode copiar sua lógica anterior aqui. Abaixo deixo um "geral"
- # simplificado com as colunas principais.
+ 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,
@@ -446,6 +467,12 @@ async def export_excel(
"Valor Total": f.valor_total,
"ICMS (%)": f.icms_aliq,
"ICMS (R$)": f.icms_valor,
+
+ # novas colunas
+ "ICMS (%) (UF/Ref)": aliq_cad,
+ "Dif. ICMS (pp)": diff_pp,
+ "ICMS confere?": "SIM" if confere else ("N/D" if confere is None else "NÃO"),
+
"Base ICMS (R$)": f.icms_base,
"PIS (%)": f.pis_aliq,
"PIS (R$)": f.pis_valor,
@@ -460,15 +487,13 @@ async def export_excel(
})
filename = "relatorio_geral.xlsx"
- # 3) Gerar excel em memória
- from io import BytesIO
+ # 3) Excel em memória
output = BytesIO()
df = pd.DataFrame(dados)
with pd.ExcelWriter(output, engine="xlsxwriter") as writer:
df.to_excel(writer, index=False, sheet_name="Relatório")
output.seek(0)
- # 4) Responder
return StreamingResponse(
output,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
@@ -566,10 +591,12 @@ async def api_relatorios(
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
+ cofins_aliq, cofins_valor, distribuidora, data_processamento,
+ estado
FROM faturas.faturas
{where}
ORDER BY data_processamento DESC
@@ -580,21 +607,47 @@ async def api_relatorios(
rows = (await db.execute(sql, params)).mappings().all()
total = (await db.execute(count_sql, params)).scalar_one()
- items = [{
- "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": r["icms_aliq"],
- "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,
- } for r in rows]
+ # 🔹 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}
- return {"items": items, "total": total, "page": page, "page_size": page_size}
\ No newline at end of file
+ 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}
\ No newline at end of file
diff --git a/app/models.py b/app/models.py
index 98e2278..f38fe16 100755
--- a/app/models.py
+++ b/app/models.py
@@ -72,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):
diff --git a/app/parametros.py b/app/parametros.py
index 37f5203..fa8f9af 100644
--- a/app/parametros.py
+++ b/app/parametros.py
@@ -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,6 +21,8 @@ import csv
from fastapi.responses import StreamingResponse
import pandas as pd
from io import BytesIO
+from sqlalchemy import select
+from decimal import Decimal
router = APIRouter()
@@ -100,6 +102,28 @@ async def editar_parametro(param_id: int, request: Request):
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")
@@ -117,10 +141,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)):
@@ -227,5 +259,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}
diff --git a/app/templates/parametros.html b/app/templates/parametros.html
index 940f472..2a97d55 100755
--- a/app/templates/parametros.html
+++ b/app/templates/parametros.html
@@ -22,7 +22,7 @@
-
+
@@ -62,6 +62,7 @@
📋 Fórmulas Salvas
+ {% if
{% for param in lista_parametros %}
@@ -124,7 +125,7 @@
+
+
+
+
-
- | UF | Exercício | Alíquota |
-
-
+
+
+
+
+
+
+
+
+
+
+ | UF |
+ Exercício |
+ Alíquota |
+ Ações |
+
+
+
+
@@ -581,16 +608,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 = `
| Nenhum registro. |
`;
+ } else {
tbody.innerHTML = dados.map(a => `
-
| ${a.uf} | ${a.exercicio} | ${a.aliquota.toFixed(4)}% |
+
+ | ${a.uf} |
+ ${a.exercicio} |
+ ${Number(a.aliquota).toLocaleString('pt-BR', {minimumFractionDigits:4, maximumFractionDigits:4})}% |
+
+
+
+ |
+
`).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
@@ -657,6 +707,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();
+}
+
diff --git a/app/templates/upload.html b/app/templates/upload.html
index f516085..089b71d 100755
--- a/app/templates/upload.html
+++ b/app/templates/upload.html
@@ -32,8 +32,10 @@
📄 Ver Log de Erros (.txt)
{% endif %}
+
{% if app_env != "producao" %}
{% endif %}