From 02b517a658f64d1d7c75d15656103e4ce215868f Mon Sep 17 00:00:00 2001 From: iscarelli Date: Wed, 10 Jun 2026 11:58:39 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20navega=C3=A7=C3=A3o=20anterior/pr=C3=B3?= =?UTF-8?q?ximo=20no=20detalhe=20+=20fix=20de=20peso=20nominal=20nos=20rel?= =?UTF-8?q?at=C3=B3rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navegação: - Botões anterior/próximo nas telas de detalhe de rolo e filamento, na ordem da listagem (sem wrap-around; desabilita nas pontas). Rolos navegam só entre itens do mesmo estado (ativos/finalizados). - Helpers spool_neighbors/filament_neighbors em database.py. - Strings novas em EN/ES. Relatórios (fix): - Rolo sem pesagem agora conta como cheio (= peso nominal) em report_by_material, report_by_location e report_low_stock, consistente com o resto do app. Antes contava 0g e caía falsamente em "Estoque Baixo". Testes: tests/test_reports.py e tests/test_navigation.py (119 passando). Remove CORRECAO.md (runbook de migração já utilizado). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++ CORRECAO.md | 183 -------------------------------- VERSION | 2 +- database.py | 49 +++++++-- routes/filaments.py | 4 +- routes/spools.py | 4 +- templates/filaments/detail.html | 14 ++- templates/spools/detail.html | 14 ++- tests/test_navigation.py | 52 +++++++++ tests/test_reports.py | 45 ++++++++ translations.py | 11 ++ 11 files changed, 191 insertions(+), 195 deletions(-) delete mode 100644 CORRECAO.md create mode 100644 tests/test_navigation.py create mode 100644 tests/test_reports.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c0dae54..9d17b06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ Versioning follows [SemVer](https://semver.org/): **MAJOR.MINOR.PATCH** --- +## [1.35.0] — 2026-06-10 + +### Added +- **Navegação anterior/próximo nas telas de detalhe.** No topo das páginas de detalhe de rolo (`/spools/`) e de filamento (`/filaments/`), ao lado do botão voltar, há agora dois botões para ir ao item anterior e ao próximo, seguindo a mesma ordem da listagem. Nas pontas o botão correspondente fica desabilitado (sem dar a volta). A navegação de rolos respeita o estado (ativos/finalizados) do rolo atual. + +### Fixed +- **Rolo recém-cadastrado deixava de contar o peso nominal nos relatórios.** Um rolo ainda não pesado é tratado como cheio (= peso nominal) no resto do app, mas três relatórios o contavam como 0g: ele aparecia falsamente em "Estoque Baixo" (inclusive no card do dashboard) e zerava os totais por material/local. Os relatórios `por material`, `por local` e `estoque baixo` agora usam o fallback para o peso nominal, consistente com as demais telas. + ## [1.34.0] — 2026-06-10 ### Added diff --git a/CORRECAO.md b/CORRECAO.md deleted file mode 100644 index fe6db9a..0000000 --- a/CORRECAO.md +++ /dev/null @@ -1,183 +0,0 @@ -# Correção de preços corrompidos (bug pré-v1.33.0) - -## Causa - -O handler de `submit` do JavaScript em `spools/form.html` convertia o formato BR -"21,60" para "21.60" antes de enviar. A função `_parse_price` então removia o ponto -como separador de milhar e gravava 2160.0 em vez de 21.60. - -Corrigido na v1.33.0: handler removido. Novos lançamentos gravam corretamente. -Os registros já gravados com valores errados precisam ser corrigidos manualmente. - ---- - -## Script de correção - -```python -#!/usr/bin/env python3 -""" -fix_prices.py — corrige preços corrompidos pelo bug do decimal (pré v1.33.0) - -O bug: "21,60" → JS convertia para "21.60" → _parse_price removia o ponto -como milhar → gravava 2160.0 em vez de 21.60. - -Heurística de detecção: preços com casas decimais .0 (número inteiro) e -valor >= 100 são suspeitos. O script lista todos para revisão antes de -aplicar qualquer correção. - -Uso: - python fix_prices.py --db /opt/spool-control/data/spool.db # listar - python fix_prices.py --db /opt/spool-control/data/spool.db --apply # corrigir -""" - -import sqlite3 -import argparse - -def detect_corrupted(conn): - cur = conn.execute(""" - SELECT s.id, s.name, f.brand, f.material, f.family, - s.purchase_price - FROM spools s - JOIN filaments f ON s.filament_id = f.id - WHERE s.purchase_price IS NOT NULL - AND s.purchase_price > 0 - ORDER BY s.id - """) - suspects = [] - for row in cur.fetchall(): - sid, name, brand, material, family, price = row - if price == int(price) and price >= 100: - suspects.append({ - "id": sid, - "spool": f"{name or '—'} ({material} — {brand} / {family})", - "atual": price, - "div_100": price / 100, - "div_1000": price / 1000, - }) - return suspects - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--db", required=True, help="Caminho para spool.db") - parser.add_argument("--apply", action="store_true", - help="Aplica as correções (sem isso só lista)") - args = parser.parse_args() - - conn = sqlite3.connect(args.db) - conn.row_factory = sqlite3.Row - - suspects = detect_corrupted(conn) - - if not suspects: - print("Nenhum preço suspeito encontrado.") - conn.close() - return - - print(f"{'ID':>4} {'Spool':<45} {'Atual':>12} {'÷100':>10} {'÷1000':>10}") - print("-" * 90) - for s in suspects: - print(f"{s['id']:>4} {s['spool']:<45} " - f"{s['atual']:>12.2f} {s['div_100']:>10.2f} {s['div_1000']:>10.2f}") - - if not args.apply: - print(f"\n{len(suspects)} spool(s) suspeito(s). Revise acima.") - print("Para corrigir interativamente, rode com --apply") - conn.close() - return - - print(f"\nCorreção interativa — pressione Enter para pular, d/D para ÷100, m/M para ÷1000,") - print("ou digite o valor correto manualmente (ex: 97.00).") - print() - - updates = [] - for s in suspects: - resp = input( - f"[{s['id']}] {s['spool']}\n" - f" Atual: {s['atual']:.2f} [d] ÷100 = {s['div_100']:.2f} " - f"[m] ÷1000 = {s['div_1000']:.2f}\n" - f" Ação (Enter=pular / d / m / valor): " - ).strip() - - if resp == "": - print(" → pulado") - elif resp.lower() == "d": - updates.append((s["div_100"], s["id"])) - print(f" → será corrigido para {s['div_100']:.2f}") - elif resp.lower() == "m": - updates.append((s["div_1000"], s["id"])) - print(f" → será corrigido para {s['div_1000']:.2f}") - else: - try: - val = float(resp) - updates.append((val, s["id"])) - print(f" → será corrigido para {val:.2f}") - except ValueError: - print(" → valor inválido, pulado") - print() - - if not updates: - print("Nenhuma alteração a aplicar.") - conn.close() - return - - print(f"\nAplicando {len(updates)} correção(ões)...") - with conn: - for val, sid in updates: - conn.execute( - "UPDATE spools SET purchase_price = ? WHERE id = ?", - (val, sid) - ) - print("Concluído. Recomendado rodar VACUUM/checkpoint:") - print(" sqlite3 spool.db 'PRAGMA wal_checkpoint(TRUNCATE); VACUUM;'") - conn.close() - -if __name__ == "__main__": - main() -``` - ---- - -## Como usar em produção - -### 1. Copiar o script para o LXC - -```bash -scp -i ~/.ssh/claude_proxmox CORRECAO.md claude@10.1.0.16:/tmp/fix_prices.py -# ou extraia o bloco Python acima para um arquivo fix_prices.py local, depois: -scp -i ~/.ssh/claude_proxmox fix_prices.py claude@10.1.0.16:/tmp/ -ssh -i ~/.ssh/claude_proxmox claude@10.1.0.16 \ - "sudo /usr/sbin/pct push 117 /tmp/fix_prices.py /tmp/fix_prices.py" -``` - -### 2. Listar suspeitos (só leitura, sem alterar nada) - -```bash -ssh -i ~/.ssh/claude_proxmox claude@10.1.0.16 \ - "sudo /usr/sbin/pct exec 117 -- python3 /tmp/fix_prices.py \ - --db /opt/spool-control/data/spool.db" -``` - -### 3. Corrigir interativamente - -```bash -ssh -i ~/.ssh/claude_proxmox claude@10.1.0.16 -t \ - "sudo /usr/sbin/pct exec 117 -- python3 /tmp/fix_prices.py \ - --db /opt/spool-control/data/spool.db --apply" -``` - -Para cada spool suspeito o script mostra o valor atual e as duas sugestões de correção: - -| Tecla | Ação | -|---|---| -| Enter | Pula (não altera) | -| `d` | Divide por 100 — caso mais comum ("97,00" → 9700 → corrige para 97.00) | -| `m` | Divide por 1000 — se o original tinha separador de milhar ("1.234,56" → 1234560 → corrige para 1234.56) | -| valor | Digite o número correto manualmente (ex: `97.00`) | - -### 4. Checkpoint após a correção (opcional mas recomendado) - -```bash -ssh -i ~/.ssh/claude_proxmox claude@10.1.0.16 \ - "sudo /usr/sbin/pct exec 117 -- sqlite3 /opt/spool-control/data/spool.db \ - 'PRAGMA wal_checkpoint(TRUNCATE); VACUUM;'" -``` diff --git a/VERSION b/VERSION index 2b17ffd..2aeaa11 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.34.0 +1.35.0 diff --git a/database.py b/database.py index 92746b7..010de2f 100644 --- a/database.py +++ b/database.py @@ -639,6 +639,26 @@ def get_filament(filament_id): """, (filament_id,)).fetchone() +def _neighbors(ids, current_id): + """(prev_id, next_id) de current_id numa lista ordenada de ids — None nas pontas.""" + try: + i = ids.index(current_id) + except ValueError: + return (None, None) + prev_id = ids[i - 1] if i > 0 else None + next_id = ids[i + 1] if i < len(ids) - 1 else None + return (prev_id, next_id) + + +def filament_neighbors(filament_id): + """(prev_id, next_id) na mesma ordem da listagem de filamentos.""" + with closing(get_db()) as db: + ids = [r["id"] for r in db.execute( + "SELECT id FROM filaments ORDER BY material, brand, family" + ).fetchall()] + return _neighbors(ids, filament_id) + + def create_filament(brand, material, family, color_hex="", color_name="", diameter_mm=1.75, notes=""): with closing(get_db()) as db: db.execute( @@ -717,6 +737,21 @@ def list_spools_for_filament(filament_id): ).fetchall() +def spool_neighbors(spool_id): + """(prev_id, next_id) na mesma ordem da listagem de spools. Navega entre spools + do mesmo estado (ativos/finalizados) do atual, p/ casar com a lista visível.""" + with closing(get_db()) as db: + row = db.execute("SELECT active FROM spools WHERE id=?", (spool_id,)).fetchone() + if row is None: + return (None, None) + where = "WHERE s.active=1" if row["active"] else "WHERE s.active=0" + ids = [r["id"] for r in db.execute( + f"SELECT s.id FROM spools s JOIN filaments f ON f.id=s.filament_id " + f"{where} ORDER BY f.material, f.brand, f.family, s.id" + ).fetchall()] + return _neighbors(ids, spool_id) + + def create_spool(filament_id, spool_model_id, custom_tare_g, nominal_weight_g, location, purchase_date, purchase_price, notes): with closing(get_db()) as db: @@ -811,7 +846,7 @@ def report_by_material(): ) SELECT f.material, COUNT(DISTINCT s.id) AS spool_count, - COALESCE(SUM(l.net_weight_g), 0) AS total_net_g, + COALESCE(SUM(COALESCE(l.net_weight_g, s.nominal_weight_g)), 0) AS total_net_g, COALESCE(SUM(s.nominal_weight_g), 0) AS total_nominal_g FROM spools s JOIN filaments f ON f.id = s.filament_id @@ -832,7 +867,7 @@ def report_by_location(): ) SELECT COALESCE(NULLIF(s.location,''), '(sem local)') AS location, COUNT(DISTINCT s.id) AS spool_count, - COALESCE(SUM(l.net_weight_g), 0) AS total_net_g + COALESCE(SUM(COALESCE(l.net_weight_g, s.nominal_weight_g)), 0) AS total_net_g FROM spools s LEFT JOIN latest l ON l.spool_id = s.id WHERE s.active = 1 @@ -851,17 +886,17 @@ def report_low_stock(threshold_g=200, threshold_pct=20): ) SELECT s.id, s.location, s.nominal_weight_g, f.brand, f.material, f.family, f.color_hex, - COALESCE(l.net_weight_g, 0) AS current_net_g, + COALESCE(l.net_weight_g, s.nominal_weight_g) AS current_net_g, CASE WHEN s.nominal_weight_g > 0 - THEN CAST(COALESCE(l.net_weight_g,0)*100.0/s.nominal_weight_g AS INTEGER) + THEN CAST(COALESCE(l.net_weight_g, s.nominal_weight_g)*100.0/s.nominal_weight_g AS INTEGER) ELSE 0 END AS pct_remaining FROM spools s JOIN filaments f ON f.id = s.filament_id LEFT JOIN latest l ON l.spool_id = s.id WHERE s.active = 1 - AND (COALESCE(l.net_weight_g,0) < ? OR - (s.nominal_weight_g > 0 AND COALESCE(l.net_weight_g,0)*100.0/s.nominal_weight_g < ?)) - ORDER BY COALESCE(l.net_weight_g, 0) ASC + AND (COALESCE(l.net_weight_g, s.nominal_weight_g) < ? OR + (s.nominal_weight_g > 0 AND COALESCE(l.net_weight_g, s.nominal_weight_g)*100.0/s.nominal_weight_g < ?)) + ORDER BY COALESCE(l.net_weight_g, s.nominal_weight_g) ASC """, (threshold_g, threshold_pct)).fetchall() diff --git a/routes/filaments.py b/routes/filaments.py index 995fe06..24078ba 100644 --- a/routes/filaments.py +++ b/routes/filaments.py @@ -72,7 +72,9 @@ def filaments_detail(filament_id): if not filament: abort(404) spools = db.list_spools_for_filament(filament_id) - return render_template("filaments/detail.html", filament=filament, spools=spools) + prev_id, next_id = db.filament_neighbors(filament_id) + return render_template("filaments/detail.html", filament=filament, spools=spools, + prev_id=prev_id, next_id=next_id) @app.route("/filaments//edit", methods=["GET", "POST"]) diff --git a/routes/spools.py b/routes/spools.py index 74bf936..7d46929 100644 --- a/routes/spools.py +++ b/routes/spools.py @@ -82,8 +82,10 @@ def spools_detail(spool_id): logged_in = True in_queue = db.is_in_queue(spool_id) queue_prompt = request.args.get("queue_prompt") == "1" + prev_id, next_id = db.spool_neighbors(spool_id) return render_template("spools/detail.html", spool=spool, readings=readings, - logged_in=logged_in, in_queue=in_queue, queue_prompt=queue_prompt) + logged_in=logged_in, in_queue=in_queue, queue_prompt=queue_prompt, + prev_id=prev_id, next_id=next_id) @app.route("/spools//edit", methods=["GET", "POST"]) diff --git a/templates/filaments/detail.html b/templates/filaments/detail.html index 4b0a580..0de5cce 100644 --- a/templates/filaments/detail.html +++ b/templates/filaments/detail.html @@ -24,7 +24,19 @@ {% set fill = filament.color_hex if filament.color_hex else '#6c757d' %}
- +
+ + {% if prev_id %} + + {% else %} + + {% endif %} + {% if next_id %} + + {% else %} + + {% endif %} +
{% if filament.brand_logo %} {{ filament.brand }} diff --git a/templates/spools/detail.html b/templates/spools/detail.html index beba38e..0357f8c 100644 --- a/templates/spools/detail.html +++ b/templates/spools/detail.html @@ -10,7 +10,19 @@ {% set fill = spool.color_hex if spool.color_hex else '#6c757d' %}
{% if logged_in %} - +
+ + {% if prev_id %} + + {% else %} + + {% endif %} + {% if next_id %} + + {% else %} + + {% endif %} +
{% endif %} {{ donut(pct, fill, tip=pct|int|string + _('% disponível')) }}

SP-{{ '%04d'|format(spool.id) }}

diff --git a/tests/test_navigation.py b/tests/test_navigation.py new file mode 100644 index 0000000..06e4ca4 --- /dev/null +++ b/tests/test_navigation.py @@ -0,0 +1,52 @@ +"""Navegação anterior/próximo nas telas de detalhe (spool e filamento). + +Os helpers de vizinhança seguem a mesma ordem da listagem e devolvem None nas +pontas (sem wrap-around). +""" + + +def _make_filament(db, brand="Marca", material="PLA", family="Basic"): + return db.create_filament(brand, material, family, "#ff0000") + + +def _make_spool(db, fid, nominal=1000.0): + return db.create_spool( + filament_id=fid, spool_model_id=None, custom_tare_g=200.0, + nominal_weight_g=nominal, location="A1", purchase_date="", + purchase_price=None, notes="", + ) + + +def test_filament_neighbors_order_and_ends(db): + # Ordem da listagem: material, brand, family. + a = _make_filament(db, "Aaa", "ABS", "X") # 1º + b = _make_filament(db, "Bbb", "PETG", "Y") # 2º + c = _make_filament(db, "Ccc", "PLA", "Z") # 3º + assert db.filament_neighbors(a) == (None, b) + assert db.filament_neighbors(b) == (a, c) + assert db.filament_neighbors(c) == (b, None) + + +def test_filament_neighbors_unknown_id(db): + _make_filament(db) + assert db.filament_neighbors(9999) == (None, None) + + +def test_spool_neighbors_order_and_ends(db): + fa = _make_filament(db, "Aaa", "ABS", "X") + fb = _make_filament(db, "Bbb", "PETG", "Y") + s1 = _make_spool(db, fa) + s2 = _make_spool(db, fb) + assert db.spool_neighbors(s1) == (None, s2) + assert db.spool_neighbors(s2) == (s1, None) + + +def test_spool_neighbors_separates_active_from_finalized(db): + fa = _make_filament(db, "Aaa", "ABS", "X") + fb = _make_filament(db, "Bbb", "PETG", "Y") + active = _make_spool(db, fa) + finalized = _make_spool(db, fb) + db.deactivate_spool(finalized) + # Cada um navega só dentro do próprio estado — sem vizinhos cruzados. + assert db.spool_neighbors(active) == (None, None) + assert db.spool_neighbors(finalized) == (None, None) diff --git a/tests/test_reports.py b/tests/test_reports.py new file mode 100644 index 0000000..44bac39 --- /dev/null +++ b/tests/test_reports.py @@ -0,0 +1,45 @@ +"""Relatórios x rolo recém-cadastrado (sem pesagem). + +Convenção do app: um rolo ainda não pesado é tratado como CHEIO (= nominal_weight_g). +Estes testes blindam a regressão em que os relatórios contavam o rolo novo como 0g — +caindo falsamente em "Estoque Baixo" e zerando os totais por material/local. +""" + + +def _make_spool(db, nominal=1000.0, tare=200.0): + fid = db.create_filament("MarcaTeste", "PLA", "Basic", "#ff0000") + return db.create_spool( + filament_id=fid, spool_model_id=None, custom_tare_g=tare, + nominal_weight_g=nominal, location="A1", purchase_date="", + purchase_price=None, notes="", + ) + + +def test_new_spool_not_flagged_low_stock(db): + """Rolo novo (nunca pesado, nominal 1000) não pode aparecer em Estoque Baixo.""" + sid = _make_spool(db, nominal=1000) + low = db.report_low_stock(threshold_g=200, threshold_pct=20) + assert sid not in [r["id"] for r in low] + + +def test_weighed_low_spool_is_flagged(db): + """Controle: um rolo de fato baixo continua sendo sinalizado (com peso correto).""" + sid = _make_spool(db, nominal=1000, tare=200) + db.add_weight_reading(sid, gross_weight_g=300, tare_weight_g=200) # net = 100g + low = {r["id"]: r for r in db.report_low_stock(threshold_g=200, threshold_pct=20)} + assert sid in low + assert low[sid]["current_net_g"] == 100 + + +def test_by_material_counts_nominal_for_unweighed(db): + """Total por material soma o nominal do rolo novo (não 0).""" + _make_spool(db, nominal=1000) + rows = {r["material"]: r for r in db.report_by_material()} + assert rows["PLA"]["total_net_g"] == 1000 + + +def test_by_location_counts_nominal_for_unweighed(db): + """Total por local soma o nominal do rolo novo (não 0).""" + _make_spool(db, nominal=1000) + rows = {r["location"]: r for r in db.report_by_location()} + assert rows["A1"]["total_net_g"] == 1000 diff --git a/translations.py b/translations.py index 3b39a2f..aee94d9 100644 --- a/translations.py +++ b/translations.py @@ -1121,6 +1121,17 @@ _ES["SP-0001 ou 1"] = "SP-0001 o 1" _ES["ex: 1150"] = "ej: 1150" +_EN["Voltar à lista"] = "Back to list" +_EN["Rolo anterior"] = "Previous spool" +_EN["Próximo rolo"] = "Next spool" +_EN["Filamento anterior"] = "Previous filament" +_EN["Próximo filamento"] = "Next filament" +_ES["Voltar à lista"] = "Volver a la lista" +_ES["Rolo anterior"] = "Bobina anterior" +_ES["Próximo rolo"] = "Bobina siguiente" +_ES["Filamento anterior"] = "Filamento anterior" +_ES["Próximo filamento"] = "Filamento siguiente" + _TABLES = {"en": _EN, "es": _ES, "pt": _PT}