Commit inicial - upload de todos os arquivos da pasta
This commit is contained in:
+876
@@ -0,0 +1,876 @@
|
||||
"""Acesso ao SQLite: esquema, migrações leves e helpers de consulta.
|
||||
|
||||
Estende o esquema original (uploaded_files -> detected_documents -> fiscal_documents)
|
||||
com autenticação (users), lotes de importação (import_batches) e os campos de
|
||||
legibilidade usados pela extração por IA.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_batches (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
confirmed_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS uploaded_files (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
batch_id INTEGER REFERENCES import_batches(id) ON DELETE SET NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
detected_count INTEGER NOT NULL DEFAULT 0,
|
||||
message TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS detected_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
upload_id INTEGER NOT NULL REFERENCES uploaded_files(id) ON DELETE CASCADE,
|
||||
batch_id INTEGER REFERENCES import_batches(id) ON DELETE SET NULL,
|
||||
source_file_name TEXT NOT NULL,
|
||||
source_page INTEGER,
|
||||
source_location TEXT NOT NULL,
|
||||
raw_text TEXT NOT NULL DEFAULT '',
|
||||
mes INTEGER,
|
||||
ano INTEGER,
|
||||
supplier_name TEXT,
|
||||
total_paid REAL,
|
||||
confidence TEXT NOT NULL DEFAULT 'low',
|
||||
field_confidence_json TEXT NOT NULL DEFAULT '{}',
|
||||
legible INTEGER NOT NULL DEFAULT 1,
|
||||
uncertain_fields TEXT NOT NULL DEFAULT '[]',
|
||||
extractor TEXT NOT NULL DEFAULT 'local',
|
||||
status TEXT NOT NULL DEFAULT 'staged',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categoria (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
categoria TEXT NOT NULL,
|
||||
palavra_chave TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fiscal_documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
detected_document_id INTEGER REFERENCES detected_documents(id) ON DELETE SET NULL,
|
||||
source_file_name TEXT NOT NULL DEFAULT '',
|
||||
source_location TEXT NOT NULL DEFAULT '',
|
||||
mes INTEGER NOT NULL,
|
||||
ano INTEGER NOT NULL,
|
||||
supplier_name TEXT NOT NULL,
|
||||
total_paid REAL NOT NULL,
|
||||
confidence TEXT NOT NULL DEFAULT 'high',
|
||||
categoria_id INTEGER REFERENCES categoria(id),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fiscal_competencia ON fiscal_documents(ano, mes);
|
||||
CREATE INDEX IF NOT EXISTS idx_detected_batch ON detected_documents(batch_id, status);
|
||||
"""
|
||||
|
||||
_RESERVED_CATEGORIA_SEED = (
|
||||
"INSERT OR IGNORE INTO categoria (id, categoria, palavra_chave) VALUES (1, 'Não Encontrado', '')"
|
||||
)
|
||||
|
||||
|
||||
def connect(db_path: Path | None = None) -> sqlite3.Connection:
|
||||
settings = get_settings()
|
||||
settings.ensure_storage()
|
||||
conn = sqlite3.connect(db_path or settings.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def _table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||
return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
|
||||
|
||||
|
||||
def _rebuild_fiscal_documents(conn: sqlite3.Connection, old_columns: set[str]) -> None:
|
||||
"""Recria `fiscal_documents` com `mes`/`ano` no lugar de `purchase_date`.
|
||||
|
||||
SQLite não suporta `DROP COLUMN` em todas as versões-alvo, então o rebuild
|
||||
(tabela nova + `INSERT ... SELECT` + `DROP` + `RENAME`) é a técnica
|
||||
portável recomendada pela própria documentação do SQLite.
|
||||
"""
|
||||
categoria_expr = "categoria_id" if "categoria_id" in old_columns else "NULL"
|
||||
conn.execute("DROP TABLE IF EXISTS fiscal_documents_new")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE fiscal_documents_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
detected_document_id INTEGER REFERENCES detected_documents(id) ON DELETE SET NULL,
|
||||
source_file_name TEXT NOT NULL DEFAULT '',
|
||||
source_location TEXT NOT NULL DEFAULT '',
|
||||
mes INTEGER NOT NULL,
|
||||
ano INTEGER NOT NULL,
|
||||
supplier_name TEXT NOT NULL,
|
||||
total_paid REAL NOT NULL,
|
||||
confidence TEXT NOT NULL DEFAULT 'high',
|
||||
categoria_id INTEGER REFERENCES categoria(id),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO fiscal_documents_new (
|
||||
id, detected_document_id, source_file_name, source_location,
|
||||
mes, ano, supplier_name, total_paid, confidence, categoria_id,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT id, detected_document_id, source_file_name, source_location,
|
||||
CAST(substr(purchase_date, 6, 2) AS INTEGER),
|
||||
CAST(substr(purchase_date, 1, 4) AS INTEGER),
|
||||
supplier_name, total_paid, confidence, {categoria_expr},
|
||||
created_at, updated_at
|
||||
FROM fiscal_documents
|
||||
"""
|
||||
)
|
||||
conn.execute("DROP TABLE fiscal_documents")
|
||||
conn.execute("ALTER TABLE fiscal_documents_new RENAME TO fiscal_documents")
|
||||
|
||||
|
||||
def _rebuild_detected_documents(conn: sqlite3.Connection) -> None:
|
||||
"""Mesmo rebuild que `_rebuild_fiscal_documents`, mas `mes`/`ano` ficam
|
||||
nullable — mesmo comportamento opcional que `purchase_date` tinha."""
|
||||
conn.execute("DROP TABLE IF EXISTS detected_documents_new")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE detected_documents_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
upload_id INTEGER NOT NULL REFERENCES uploaded_files(id) ON DELETE CASCADE,
|
||||
batch_id INTEGER REFERENCES import_batches(id) ON DELETE SET NULL,
|
||||
source_file_name TEXT NOT NULL,
|
||||
source_page INTEGER,
|
||||
source_location TEXT NOT NULL,
|
||||
raw_text TEXT NOT NULL DEFAULT '',
|
||||
mes INTEGER,
|
||||
ano INTEGER,
|
||||
supplier_name TEXT,
|
||||
total_paid REAL,
|
||||
confidence TEXT NOT NULL DEFAULT 'low',
|
||||
field_confidence_json TEXT NOT NULL DEFAULT '{}',
|
||||
legible INTEGER NOT NULL DEFAULT 1,
|
||||
uncertain_fields TEXT NOT NULL DEFAULT '[]',
|
||||
extractor TEXT NOT NULL DEFAULT 'local',
|
||||
status TEXT NOT NULL DEFAULT 'staged',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO detected_documents_new (
|
||||
id, upload_id, batch_id, source_file_name, source_page, source_location,
|
||||
raw_text, mes, ano, supplier_name, total_paid, confidence,
|
||||
field_confidence_json, legible, uncertain_fields, extractor, status,
|
||||
created_at, updated_at
|
||||
)
|
||||
SELECT id, upload_id, batch_id, source_file_name, source_page, source_location,
|
||||
raw_text,
|
||||
CASE WHEN purchase_date IS NOT NULL THEN CAST(substr(purchase_date, 6, 2) AS INTEGER) END,
|
||||
CASE WHEN purchase_date IS NOT NULL THEN CAST(substr(purchase_date, 1, 4) AS INTEGER) END,
|
||||
supplier_name, total_paid, confidence,
|
||||
field_confidence_json, legible, uncertain_fields, extractor, status,
|
||||
created_at, updated_at
|
||||
FROM detected_documents
|
||||
"""
|
||||
)
|
||||
conn.execute("DROP TABLE detected_documents")
|
||||
conn.execute("ALTER TABLE detected_documents_new RENAME TO detected_documents")
|
||||
|
||||
|
||||
def _migrate_competencia(conn: sqlite3.Connection) -> None:
|
||||
"""Migra `fiscal_documents`/`detected_documents` de `purchase_date` para
|
||||
`mes`/`ano`, uma única vez. Guardado por `PRAGMA table_info`: só roda se
|
||||
`purchase_date` ainda existir (tabela de instalação anterior a esta
|
||||
mudança); em uma instalação nova, ou já migrada, é um no-op."""
|
||||
fiscal_columns = _table_columns(conn, "fiscal_documents")
|
||||
detected_columns = _table_columns(conn, "detected_documents")
|
||||
needs_fiscal = "purchase_date" in fiscal_columns
|
||||
needs_detected = "purchase_date" in detected_columns
|
||||
if not needs_fiscal and not needs_detected:
|
||||
return
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
if needs_fiscal:
|
||||
_rebuild_fiscal_documents(conn, fiscal_columns)
|
||||
if needs_detected:
|
||||
_rebuild_detected_documents(conn)
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
else:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def init_db(conn: sqlite3.Connection) -> None:
|
||||
_migrate_competencia(conn)
|
||||
conn.executescript(SCHEMA)
|
||||
columns = {row["name"] for row in conn.execute("PRAGMA table_info(fiscal_documents)")}
|
||||
if "categoria_id" not in columns:
|
||||
conn.execute("ALTER TABLE fiscal_documents ADD COLUMN categoria_id INTEGER REFERENCES categoria(id)")
|
||||
conn.execute(_RESERVED_CATEGORIA_SEED)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session(db_path: Path | None = None) -> Iterator[sqlite3.Connection]:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
init_db(conn)
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Usuários
|
||||
# --------------------------------------------------------------------------- #
|
||||
def count_users(conn: sqlite3.Connection) -> int:
|
||||
return int(conn.execute("SELECT COUNT(*) FROM users").fetchone()[0])
|
||||
|
||||
|
||||
def get_user(conn: sqlite3.Connection, username: str) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM users WHERE username = ?", (username,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def create_user(conn: sqlite3.Connection, username: str, password_hash: str) -> int:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
(username, password_hash),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_password(conn: sqlite3.Connection, username: str, password_hash: str) -> None:
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash = ? WHERE username = ?",
|
||||
(password_hash, username),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Lotes de importação
|
||||
# --------------------------------------------------------------------------- #
|
||||
def create_batch(conn: sqlite3.Connection) -> int:
|
||||
cur = conn.execute("INSERT INTO import_batches (status) VALUES ('open')")
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_batch(conn: sqlite3.Connection, batch_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM import_batches WHERE id = ?", (batch_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def set_batch_status(conn: sqlite3.Connection, batch_id: int, status: str) -> None:
|
||||
if status == "confirmed":
|
||||
conn.execute(
|
||||
"UPDATE import_batches SET status = ?, confirmed_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(status, batch_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE import_batches SET status = ? WHERE id = ?", (status, batch_id)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Uploads
|
||||
# --------------------------------------------------------------------------- #
|
||||
def insert_upload(
|
||||
conn: sqlite3.Connection,
|
||||
batch_id: int,
|
||||
original_name: str,
|
||||
stored_path: Path,
|
||||
content_type: str,
|
||||
size_bytes: int,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> int:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO uploaded_files (batch_id, original_name, stored_path, content_type, size_bytes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(batch_id, original_name, str(stored_path), content_type, size_bytes),
|
||||
)
|
||||
if commit:
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_upload_status(
|
||||
conn: sqlite3.Connection,
|
||||
upload_id: int,
|
||||
status: str,
|
||||
detected_count: int,
|
||||
message: str | None = None,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE uploaded_files
|
||||
SET status = ?, detected_count = ?, message = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(status, detected_count, message, upload_id),
|
||||
)
|
||||
if commit:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_upload(conn: sqlite3.Connection, upload_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM uploaded_files WHERE id = ?", (upload_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Documentos detectados (staging)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def insert_detected(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
upload_id: int,
|
||||
batch_id: int,
|
||||
source_file_name: str,
|
||||
source_page: int | None,
|
||||
source_location: str,
|
||||
raw_text: str,
|
||||
mes: int | None,
|
||||
ano: int | None,
|
||||
supplier_name: str | None,
|
||||
total_paid: float | None,
|
||||
confidence: str,
|
||||
field_confidence: dict[str, str],
|
||||
legible: bool,
|
||||
uncertain_fields: list[str],
|
||||
extractor: str,
|
||||
commit: bool = True,
|
||||
) -> int:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO detected_documents (
|
||||
upload_id, batch_id, source_file_name, source_page, source_location,
|
||||
raw_text, mes, ano, supplier_name, total_paid, confidence,
|
||||
field_confidence_json, legible, uncertain_fields, extractor, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'staged')
|
||||
""",
|
||||
(
|
||||
upload_id,
|
||||
batch_id,
|
||||
source_file_name,
|
||||
source_page,
|
||||
source_location,
|
||||
raw_text,
|
||||
mes,
|
||||
ano,
|
||||
supplier_name,
|
||||
total_paid,
|
||||
confidence,
|
||||
json.dumps(field_confidence, ensure_ascii=False),
|
||||
1 if legible else 0,
|
||||
json.dumps(uncertain_fields, ensure_ascii=False),
|
||||
extractor,
|
||||
),
|
||||
)
|
||||
if commit:
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_detected(conn: sqlite3.Connection, detected_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM detected_documents WHERE id = ?", (detected_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def staged_documents(conn: sqlite3.Connection, batch_id: int) -> list[sqlite3.Row]:
|
||||
return list(
|
||||
conn.execute(
|
||||
"""
|
||||
SELECT * FROM detected_documents
|
||||
WHERE batch_id = ? AND status = 'staged'
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(batch_id,),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def batch_summary(conn: sqlite3.Connection, batch_id: int) -> dict[str, Any]:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS n,
|
||||
COALESCE(SUM(total_paid), 0) AS total,
|
||||
SUM(CASE WHEN legible = 0 THEN 1 ELSE 0 END) AS ilegiveis,
|
||||
SUM(CASE WHEN supplier_name IS NULL OR TRIM(supplier_name) = ''
|
||||
OR total_paid IS NULL OR mes IS NULL OR ano IS NULL THEN 1 ELSE 0 END) AS incompletos
|
||||
FROM detected_documents
|
||||
WHERE batch_id = ? AND status = 'staged'
|
||||
""",
|
||||
(batch_id,),
|
||||
).fetchone()
|
||||
# "pendentes" = tudo que impede importar (ilegível OU faltando fornecedor/valor/competência).
|
||||
ilegiveis = int(row["ilegiveis"] or 0)
|
||||
incompletos = int(row["incompletos"] or 0)
|
||||
pendentes = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM detected_documents
|
||||
WHERE batch_id = ? AND status = 'staged'
|
||||
AND (legible = 0 OR supplier_name IS NULL OR TRIM(supplier_name) = '' OR total_paid IS NULL
|
||||
OR mes IS NULL OR ano IS NULL)
|
||||
""",
|
||||
(batch_id,),
|
||||
).fetchone()[0]
|
||||
return {
|
||||
"count": int(row["n"]),
|
||||
"total": float(row["total"]),
|
||||
"ilegiveis": ilegiveis,
|
||||
"incompletos": incompletos,
|
||||
"pendentes": int(pendentes),
|
||||
}
|
||||
|
||||
|
||||
def update_staged(
|
||||
conn: sqlite3.Connection,
|
||||
detected_id: int,
|
||||
*,
|
||||
mes: int | None,
|
||||
ano: int | None,
|
||||
supplier_name: str,
|
||||
total_paid: float,
|
||||
legible: bool = True,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE detected_documents
|
||||
SET mes = ?, ano = ?, supplier_name = ?, total_paid = ?,
|
||||
legible = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'staged'
|
||||
""",
|
||||
(mes, ano, supplier_name, total_paid, 1 if legible else 0, detected_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def discard_staged(conn: sqlite3.Connection, detected_id: int) -> None:
|
||||
conn.execute(
|
||||
"UPDATE detected_documents SET status = 'discarded', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(detected_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def confirm_batch(conn: sqlite3.Connection, batch_id: int) -> int:
|
||||
"""Promove todos os detectados 'staged' do lote para fiscal_documents."""
|
||||
# Import local para evitar ciclo: categorization.py importa este módulo no topo.
|
||||
from .categorization import categorize_supplier
|
||||
|
||||
rows = staged_documents(conn, batch_id)
|
||||
inserted = 0
|
||||
for row in rows:
|
||||
mes, ano = row["mes"], row["ano"]
|
||||
if (
|
||||
mes is None or ano is None or not (1 <= mes <= 12)
|
||||
or not row["supplier_name"] or row["total_paid"] is None
|
||||
):
|
||||
# Sem os campos essenciais (competência, fornecedor, valor) não confirma — permanece staged.
|
||||
continue
|
||||
categoria_id = categorize_supplier(row["supplier_name"], conn, batch_id)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO fiscal_documents (
|
||||
detected_document_id, source_file_name, source_location,
|
||||
mes, ano, supplier_name, total_paid, confidence, categoria_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row["id"],
|
||||
row["source_file_name"],
|
||||
row["source_location"],
|
||||
mes,
|
||||
ano,
|
||||
row["supplier_name"],
|
||||
float(row["total_paid"]),
|
||||
row["confidence"],
|
||||
categoria_id,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE detected_documents SET status = 'confirmed', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(row["id"],),
|
||||
)
|
||||
inserted += 1
|
||||
set_batch_status(conn, batch_id, "confirmed")
|
||||
conn.commit()
|
||||
return inserted
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CRUD de fiscal_documents
|
||||
# --------------------------------------------------------------------------- #
|
||||
def create_fiscal(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
mes: int,
|
||||
ano: int,
|
||||
supplier_name: str,
|
||||
total_paid: float,
|
||||
source_file_name: str = "lançamento manual",
|
||||
source_location: str = "manual",
|
||||
confidence: str = "high",
|
||||
detected_document_id: int | None = None,
|
||||
categoria_id: int | None = None,
|
||||
) -> int:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO fiscal_documents (
|
||||
detected_document_id, source_file_name, source_location,
|
||||
mes, ano, supplier_name, total_paid, confidence, categoria_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
detected_document_id,
|
||||
source_file_name,
|
||||
source_location,
|
||||
mes,
|
||||
ano,
|
||||
supplier_name,
|
||||
total_paid,
|
||||
confidence,
|
||||
categoria_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_fiscal(conn: sqlite3.Connection, doc_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM fiscal_documents WHERE id = ?", (doc_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def update_fiscal(
|
||||
conn: sqlite3.Connection,
|
||||
doc_id: int,
|
||||
*,
|
||||
mes: int,
|
||||
ano: int,
|
||||
supplier_name: str,
|
||||
total_paid: float,
|
||||
categoria_id: int | None = None,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE fiscal_documents
|
||||
SET mes = ?, ano = ?, supplier_name = ?, total_paid = ?, categoria_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(mes, ano, supplier_name, total_paid, categoria_id, doc_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_fiscal(conn: sqlite3.Connection, doc_id: int) -> None:
|
||||
conn.execute("DELETE FROM fiscal_documents WHERE id = ?", (doc_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _category_clause(category: str | None) -> tuple[str | None, Any]:
|
||||
"""Traduz o filtro de categoria (id, 'none' para Sem categoria, ou None/''
|
||||
para nenhum filtro) numa clausula SQL + parâmetro."""
|
||||
if not category:
|
||||
return None, None
|
||||
if category == "none":
|
||||
return "categoria_id IS NULL", None
|
||||
try:
|
||||
return "categoria_id = ?", int(category)
|
||||
except ValueError:
|
||||
return None, None
|
||||
|
||||
|
||||
def _fiscal_where(
|
||||
*,
|
||||
start: tuple[int, int] | None = None,
|
||||
end: tuple[int, int] | None = None,
|
||||
supplier: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> tuple[str, list[Any]]:
|
||||
"""`start`/`end` são tuplas `(mes, ano)` delimitando o intervalo de
|
||||
competência (inclusive), comparadas via a chave `ano * 12 + mes`."""
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if start:
|
||||
start_mes, start_ano = start
|
||||
clauses.append("(ano * 12 + mes) >= ?")
|
||||
params.append(start_ano * 12 + start_mes)
|
||||
if end:
|
||||
end_mes, end_ano = end
|
||||
clauses.append("(ano * 12 + mes) <= ?")
|
||||
params.append(end_ano * 12 + end_mes)
|
||||
if supplier:
|
||||
clauses.append("supplier_name LIKE ?")
|
||||
params.append(f"%{supplier}%")
|
||||
cat_clause, cat_param = _category_clause(category)
|
||||
if cat_clause:
|
||||
clauses.append(cat_clause)
|
||||
if cat_param is not None:
|
||||
params.append(cat_param)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
return where, params
|
||||
|
||||
|
||||
FISCAL_SORT_COLUMNS = {
|
||||
"competencia": "f.ano, f.mes",
|
||||
"supplier_name": "f.supplier_name COLLATE NOCASE",
|
||||
"categoria": "categoria_nome COLLATE NOCASE",
|
||||
"total_paid": "f.total_paid",
|
||||
}
|
||||
|
||||
|
||||
def list_fiscal(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
start: tuple[int, int] | None = None,
|
||||
end: tuple[int, int] | None = None,
|
||||
supplier: str | None = None,
|
||||
category: str | None = None,
|
||||
sort: str | None = None,
|
||||
order: str = "desc",
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[sqlite3.Row]:
|
||||
where, params = _fiscal_where(start=start, end=end, supplier=supplier, category=category)
|
||||
limit_sql = ""
|
||||
if limit is not None:
|
||||
limit_sql = "LIMIT ? OFFSET ?"
|
||||
params = [*params, limit, offset]
|
||||
sort_col = FISCAL_SORT_COLUMNS.get(sort or "competencia", FISCAL_SORT_COLUMNS["competencia"])
|
||||
direction = "ASC" if (order or "").lower() == "asc" else "DESC"
|
||||
order_sql = ", ".join(f"{col} {direction}" for col in sort_col.split(", ")) + f", f.id {direction}"
|
||||
if sort_col != FISCAL_SORT_COLUMNS["competencia"]:
|
||||
order_sql += ", f.ano DESC, f.mes DESC"
|
||||
return list(
|
||||
conn.execute(
|
||||
f"""
|
||||
SELECT f.*, c.categoria AS categoria_nome
|
||||
FROM fiscal_documents f
|
||||
LEFT JOIN categoria c ON c.id = f.categoria_id
|
||||
{where}
|
||||
ORDER BY {order_sql}
|
||||
{limit_sql}
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def fiscal_summary(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
start: tuple[int, int] | None = None,
|
||||
end: tuple[int, int] | None = None,
|
||||
supplier: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> tuple[int, float]:
|
||||
"""Retorna (quantidade, soma de total_paid) para o filtro informado, sem paginação."""
|
||||
where, params = _fiscal_where(start=start, end=end, supplier=supplier, category=category)
|
||||
row = conn.execute(
|
||||
f"SELECT COUNT(*) AS n, COALESCE(SUM(total_paid), 0) AS total FROM fiscal_documents {where}",
|
||||
params,
|
||||
).fetchone()
|
||||
return row["n"], row["total"]
|
||||
|
||||
|
||||
def monthly_totals(conn: sqlite3.Connection, category: str | None = None) -> list[sqlite3.Row]:
|
||||
cat_clause, cat_param = _category_clause(category)
|
||||
where = f"WHERE {cat_clause}" if cat_clause else ""
|
||||
params = [cat_param] if cat_param is not None else []
|
||||
return list(
|
||||
conn.execute(
|
||||
f"""
|
||||
SELECT ano, mes, SUM(total_paid) AS total, COUNT(*) AS count
|
||||
FROM fiscal_documents
|
||||
{where}
|
||||
GROUP BY ano, mes
|
||||
ORDER BY ano ASC, mes ASC
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def supplier_totals(conn: sqlite3.Connection, limit: int = 10, category: str | None = None) -> list[sqlite3.Row]:
|
||||
cat_clause, cat_param = _category_clause(category)
|
||||
where = f"WHERE {cat_clause}" if cat_clause else ""
|
||||
params: list[Any] = [cat_param] if cat_param is not None else []
|
||||
params.append(limit)
|
||||
return list(
|
||||
conn.execute(
|
||||
f"""
|
||||
SELECT supplier_name, SUM(total_paid) AS total, COUNT(*) AS count
|
||||
FROM fiscal_documents
|
||||
{where}
|
||||
GROUP BY supplier_name
|
||||
ORDER BY total DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def category_totals(
|
||||
conn: sqlite3.Connection,
|
||||
start: tuple[int, int] | None = None,
|
||||
end: tuple[int, int] | None = None,
|
||||
) -> list[sqlite3.Row]:
|
||||
"""Totais agrupados por categoria (LEFT JOIN, inclui 'Não Encontrado'), mais
|
||||
um grupo 'Sem categoria' à parte para categoria_id IS NULL (legado).
|
||||
|
||||
`start`/`end` são tuplas `(mes, ano)` delimitando o intervalo de competência.
|
||||
"""
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if start:
|
||||
start_mes, start_ano = start
|
||||
clauses.append("(f.ano * 12 + f.mes) >= ?")
|
||||
params.append(start_ano * 12 + start_mes)
|
||||
if end:
|
||||
end_mes, end_ano = end
|
||||
clauses.append("(f.ano * 12 + f.mes) <= ?")
|
||||
params.append(end_ano * 12 + end_mes)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
return list(
|
||||
conn.execute(
|
||||
f"""
|
||||
SELECT COALESCE(c.categoria, 'Sem categoria') AS categoria,
|
||||
f.categoria_id AS categoria_id,
|
||||
SUM(f.total_paid) AS total, COUNT(*) AS count
|
||||
FROM fiscal_documents f
|
||||
LEFT JOIN categoria c ON c.id = f.categoria_id
|
||||
{where}
|
||||
GROUP BY COALESCE(c.categoria, 'Sem categoria'), f.categoria_id
|
||||
ORDER BY total DESC
|
||||
""",
|
||||
params,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def overall_totals(conn: sqlite3.Connection, category: str | None = None) -> dict[str, Any]:
|
||||
cat_clause, cat_param = _category_clause(category)
|
||||
where = f"WHERE {cat_clause}" if cat_clause else ""
|
||||
params = [cat_param] if cat_param is not None else []
|
||||
row = conn.execute(
|
||||
f"SELECT COUNT(*) AS n, COALESCE(SUM(total_paid), 0) AS total FROM fiscal_documents {where}",
|
||||
params,
|
||||
).fetchone()
|
||||
n = int(row["n"])
|
||||
total = float(row["total"])
|
||||
return {"count": n, "total": total, "avg": (total / n) if n else 0.0}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CRUD de categoria
|
||||
# --------------------------------------------------------------------------- #
|
||||
RESERVED_CATEGORIA_ID = 1
|
||||
|
||||
|
||||
def create_categoria(conn: sqlite3.Connection, categoria: str, palavra_chave: str) -> int:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO categoria (categoria, palavra_chave) VALUES (?, ?)",
|
||||
(categoria, palavra_chave),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def get_categoria(conn: sqlite3.Connection, categoria_id: int) -> sqlite3.Row | None:
|
||||
return conn.execute(
|
||||
"SELECT * FROM categoria WHERE id = ?", (categoria_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def list_categorias(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return list(conn.execute("SELECT * FROM categoria ORDER BY id ASC"))
|
||||
|
||||
|
||||
def list_categorias_por_nome(conn: sqlite3.Connection) -> list[sqlite3.Row]:
|
||||
return list(conn.execute("SELECT * FROM categoria ORDER BY categoria ASC"))
|
||||
|
||||
|
||||
def update_categoria(conn: sqlite3.Connection, categoria_id: int, categoria: str, palavra_chave: str) -> None:
|
||||
conn.execute(
|
||||
"UPDATE categoria SET categoria = ?, palavra_chave = ? WHERE id = ?",
|
||||
(categoria, palavra_chave, categoria_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_categoria(conn: sqlite3.Connection, categoria_id: int) -> bool:
|
||||
"""Exclui uma categoria, reatribuindo documentos referenciados para a
|
||||
categoria reservada (id=1). Rejeita a exclusão da própria linha reservada."""
|
||||
if categoria_id == RESERVED_CATEGORIA_ID:
|
||||
return False
|
||||
conn.execute(
|
||||
"UPDATE fiscal_documents SET categoria_id = ? WHERE categoria_id = ?",
|
||||
(RESERVED_CATEGORIA_ID, categoria_id),
|
||||
)
|
||||
conn.execute("DELETE FROM categoria WHERE id = ?", (categoria_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
|
||||
def find_categoria_by_keyword(conn: sqlite3.Connection, supplier_name: str) -> sqlite3.Row | None:
|
||||
"""Primeira categoria (id ASC, excluindo a reservada id=1) cuja palavra_chave
|
||||
é substring case-insensitive de supplier_name."""
|
||||
supplier = (supplier_name or "").upper()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM categoria WHERE id != ? ORDER BY id ASC",
|
||||
(RESERVED_CATEGORIA_ID,),
|
||||
)
|
||||
for row in rows:
|
||||
keyword = (row["palavra_chave"] or "").strip()
|
||||
if keyword and keyword.upper() in supplier:
|
||||
return row
|
||||
return None
|
||||
Reference in New Issue
Block a user