From 4d2fcff4a80ecc9b271d5bb8c99700e4b4d04189 Mon Sep 17 00:00:00 2001 From: "ewerton.almeida" Date: Mon, 11 Aug 2025 18:45:57 -0300 Subject: [PATCH] =?UTF-8?q?Cadastro=20da=20al=C3=ADquota=20do=20ICMS=20cor?= =?UTF-8?q?reta.=20Inclus=C3=A3o=20da=20nova=20al=C3=ADquota=20e=20compara?= =?UTF-8?q?=C3=A7=C3=A3o=20em=20todos=20os=20relat=C3=B3rios.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/main.py | 157 +++++++++++++++++++++++----------- app/models.py | 2 +- app/parametros.py | 127 +++++++++++++++++++++++++-- app/templates/parametros.html | 148 ++++++++++++++++++++++++++++++-- app/templates/upload.html | 2 + 5 files changed, 368 insertions(+), 68 deletions(-) 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 @@
-
+
@@ -141,7 +142,10 @@
- +
@@ -160,12 +164,35 @@
+ + + + - - - -
UFExercícioAlíquota
+ +
+ + + +
+ + + + + + + + + + + +
UFExercícioAlíquotaAçõ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 %}