diff --git a/.gitignore b/.gitignore index b7faf40..98e71be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,207 +1,36 @@ -# Byte-compiled / optimized / DLL files +# Python __pycache__/ -*.py[codz] +*.py[cod] *$py.class -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ +# Virtualenvs +.venv/ venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site +env/ -# mypy +# Tool caches +.pytest_cache/ .mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: .ruff_cache/ +.coverage +htmlcov/ + +# OS / editors +.DS_Store +Thumbs.db +.vscode/ +.idea/ -# PyPI configuration file -.pypirc +# Local runtime artifacts +*.log +*.tmp +*.sqlite +*.sqlite3 -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore +# Project metadata generated in client folders +**/.metadata/ -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ +# Build artifacts +build/ +dist/ +*.egg-info/ diff --git a/README.md b/README.md index a61f580..6c12d1b 100644 --- a/README.md +++ b/README.md @@ -314,3 +314,35 @@ App 3 reemplaza a App 2 cuando cumpla todo esto: - No hardcodear rutas — usar siempre `get_setting('network_drive')` de `settings.py` - Para SQLite con múltiples hilos: usar `threading.Lock()` como en `StateDB` de `gmail_utils.py` - Para PDFs firmados digitalmente: usar `pymupdf`, no `PyPDF2` + +--- + +## Estado implementación App 3 (v1 inicial) + +Se agregó una primera versión ejecutable en `app3/` que reutiliza lógica de App 1 y App 2: + +- Reusa `CRXMLManager` (App 2) para parsing XML y datos de factura. +- Reusa `extract_clave_and_cedula` (App 1) para asociar PDFs con clave numérica. +- Reusa `settings.py` y `client_profiles.py` (App 1) para resolver rutas y sesión cliente. +- Implementa `clasificacion.sqlite` y movimiento seguro con SHA256 antes de borrar origen. +- Implementa `catalogo_cuentas.json` con guardado atómico. + +### Ejecutar App 3 v1 + +```bash +python -m app3.main +``` + +### Dependencias (App 3 v1) + +Instalar dependencias base: + +```bash +pip install -r requirements.txt +``` + +Para desarrollo y pruebas: + +```bash +pip install -r requirements-dev.txt +``` diff --git a/app3/__init__.py b/app3/__init__.py new file mode 100644 index 0000000..8cfd728 --- /dev/null +++ b/app3/__init__.py @@ -0,0 +1 @@ +"""App 3 package.""" diff --git a/app3/bootstrap.py b/app3/bootstrap.py new file mode 100644 index 0000000..cb58449 --- /dev/null +++ b/app3/bootstrap.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +def bootstrap_legacy_paths() -> None: + """Add legacy app folders to sys.path so App 3 can reuse App1/App2 modules.""" + repo_root = Path(__file__).resolve().parent.parent + legacy_paths = [repo_root / "APP 1", repo_root / "APP 2"] + for path in legacy_paths: + path_str = str(path) + if path.exists() and path_str not in sys.path: + sys.path.insert(0, path_str) diff --git a/app3/config.py b/app3/config.py new file mode 100644 index 0000000..040ede5 --- /dev/null +++ b/app3/config.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pathlib import Path + +from .bootstrap import bootstrap_legacy_paths + +bootstrap_legacy_paths() + +from facturacion_system.core.settings import get_setting # noqa: E402 + + +def network_drive() -> Path: + return Path(str(get_setting("network_drive", "Z:/DATA"))) + + +def client_root(year: int) -> Path: + return network_drive() / f"PF-{year}" / "CLIENTES" + + +def metadata_dir(client_folder: Path) -> Path: + path = client_folder / ".metadata" + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/app3/core/catalog.py b/app3/core/catalog.py new file mode 100644 index 0000000..4f4f4d6 --- /dev/null +++ b/app3/core/catalog.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +DEFAULT_CATALOG = { + "INGRESOS": {"FACTURAS ELECTRONICAS": {}, "TIQUETES ELECTRONICOS": {}}, + "COMPRAS": {"COMPRAS DE CONTADO": {}, "COMPRAS DE CREDITO": {}}, + "GASTOS": { + "GASTOS ESPECIFICOS": {"ALQUILER": {}, "HONORARIOS PROFESIONALES": {}}, + "GASTOS GENERALES": {"ELECTRICIDAD": {}, "PAPELERIA Y UTILES DE OFICINA": {}}, + }, +} + + +class CatalogManager: + def __init__(self, metadata_dir: Path) -> None: + self.path = metadata_dir / "catalogo_cuentas.json" + + def load(self) -> dict: + if self.path.exists(): + try: + raw_text = self.path.read_text(encoding="utf-8").strip() + if not raw_text: + raise ValueError("archivo vacío") + data = json.loads(raw_text) + if isinstance(data, dict): + return data + raise ValueError("catálogo inválido") + except Exception: + # Respaldo y recuperación automática a default. + backup = self.path.with_suffix(".invalid.json") + try: + if self.path.exists(): + self.path.replace(backup) + except Exception: + pass + self.save(DEFAULT_CATALOG) + return DEFAULT_CATALOG + self.save(DEFAULT_CATALOG) + return DEFAULT_CATALOG + + def save(self, data: dict) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + tmp.replace(self.path) diff --git a/app3/core/classifier.py b/app3/core/classifier.py new file mode 100644 index 0000000..c982b17 --- /dev/null +++ b/app3/core/classifier.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import hashlib +import shutil +import sqlite3 +from datetime import datetime +from pathlib import Path + +from .models import FacturaRecord + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +class ClassificationDB: + def __init__(self, metadata_dir: Path) -> None: + self.path = metadata_dir / "clasificacion.sqlite" + self.path.parent.mkdir(parents=True, exist_ok=True) + self._ensure() + + def _ensure(self) -> None: + with sqlite3.connect(self.path) as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS clasificaciones ( + clave_numerica TEXT PRIMARY KEY, + estado TEXT, + categoria TEXT, + subcategoria TEXT, + proveedor TEXT, + ruta_origen TEXT, + ruta_destino TEXT, + sha256 TEXT, + fecha_clasificacion TEXT, + clasificado_por TEXT + ) + """ + ) + + def get_estado(self, clave: str) -> str | None: + with sqlite3.connect(self.path) as conn: + row = conn.execute("SELECT estado FROM clasificaciones WHERE clave_numerica=?", (clave,)).fetchone() + return row[0] if row else None + + def upsert(self, **kwargs: str) -> None: + keys = [ + "clave_numerica", + "estado", + "categoria", + "subcategoria", + "proveedor", + "ruta_origen", + "ruta_destino", + "sha256", + "fecha_clasificacion", + "clasificado_por", + ] + payload = {k: kwargs.get(k, "") for k in keys} + with sqlite3.connect(self.path) as conn: + conn.execute( + """ + INSERT INTO clasificaciones(clave_numerica, estado, categoria, subcategoria, proveedor, + ruta_origen, ruta_destino, sha256, fecha_clasificacion, clasificado_por) + VALUES(:clave_numerica, :estado, :categoria, :subcategoria, :proveedor, + :ruta_origen, :ruta_destino, :sha256, :fecha_clasificacion, :clasificado_por) + ON CONFLICT(clave_numerica) DO UPDATE SET + estado=excluded.estado, + categoria=excluded.categoria, + subcategoria=excluded.subcategoria, + proveedor=excluded.proveedor, + ruta_origen=excluded.ruta_origen, + ruta_destino=excluded.ruta_destino, + sha256=excluded.sha256, + fecha_clasificacion=excluded.fecha_clasificacion, + clasificado_por=excluded.clasificado_por + """, + payload, + ) + + +def classify_record( + record: FacturaRecord, + client_folder: Path, + db: ClassificationDB, + categoria: str, + subcategoria: str, + proveedor: str, + user: str = "local", +) -> Path | None: + if record.pdf_path is None: + db.upsert( + clave_numerica=record.clave, + estado="pendiente_pdf", + categoria=categoria, + subcategoria=subcategoria, + proveedor=proveedor, + ruta_origen=str(record.xml_path or ""), + ruta_destino="", + sha256="", + fecha_clasificacion=datetime.now().isoformat(timespec="seconds"), + clasificado_por=user, + ) + return None + + dest_folder = client_folder / categoria / subcategoria / proveedor + dest_folder.mkdir(parents=True, exist_ok=True) + original = record.pdf_path + target = dest_folder / original.name + + if target.exists(): + suffix = sha256_file(original)[:8] + target = dest_folder / f"{original.stem}__{suffix}{original.suffix}" + + source_hash = sha256_file(original) + shutil.copy2(original, target) + copy_hash = sha256_file(target) + if source_hash != copy_hash: + target.unlink(missing_ok=True) + raise RuntimeError("Falló validación SHA256; no se borró el original.") + + original.unlink() + + db.upsert( + clave_numerica=record.clave, + estado="clasificado", + categoria=categoria, + subcategoria=subcategoria, + proveedor=proveedor, + ruta_origen=str(original), + ruta_destino=str(target), + sha256=source_hash, + fecha_clasificacion=datetime.now().isoformat(timespec="seconds"), + clasificado_por=user, + ) + return target diff --git a/app3/core/factura_index.py b/app3/core/factura_index.py new file mode 100644 index 0000000..1839d36 --- /dev/null +++ b/app3/core/factura_index.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +from app3.bootstrap import bootstrap_legacy_paths +from .models import FacturaRecord + +bootstrap_legacy_paths() + +from facturacion.xml_manager import CRXMLManager # noqa: E402 +from facturacion_system.core.pdf_classifier import extract_clave_and_cedula # noqa: E402 + + +class FacturaIndexer: + def __init__(self) -> None: + self.xml_manager = CRXMLManager() + self.parse_errors: list[str] = [] + + def load_period(self, client_folder: Path, from_date: str = "", to_date: str = "") -> list[FacturaRecord]: + self.parse_errors = [] + from_dt = self._parse_ui_date(from_date) + to_dt = self._parse_ui_date(to_date) + records: dict[str, FacturaRecord] = {} + xml_root = client_folder / "XML" + pdf_root = client_folder / "PDF" + + if xml_root.exists(): + for xml_file in xml_root.rglob("*.xml"): + try: + parsed = self.xml_manager.parse_xml_file(xml_file) + except Exception as exc: # noqa: BLE001 - tolerar XML corrupto y continuar + self.parse_errors.append(f"{xml_file.name}: {exc}") + continue + clave = str(parsed.get("clave_numerica") or "").strip() + if len(clave) != 50: + continue + fecha = str(parsed.get("fecha_emision") or "") + if not self._in_range(fecha, from_dt, to_dt): + continue + records[clave] = FacturaRecord( + clave=clave, + fecha_emision=fecha, + emisor_nombre=str(parsed.get("emisor_nombre") or ""), + emisor_cedula=str(parsed.get("emisor_cedula") or ""), + tipo_documento=str(parsed.get("tipo_documento") or ""), + total_comprobante=str(parsed.get("total_comprobante") or ""), + xml_path=xml_file, + estado="pendiente", + ) + + if pdf_root.exists(): + for pdf_file in pdf_root.rglob("*.pdf"): + clave, _ced = extract_clave_and_cedula(pdf_file.read_bytes(), original_filename=pdf_file.name) + if not clave: + continue + if clave in records: + records[clave].pdf_path = pdf_file + else: + records[clave] = FacturaRecord(clave=clave, pdf_path=pdf_file, estado="sin_xml") + + for record in records.values(): + if record.pdf_path and record.xml_path: + record.estado = "pendiente" + elif record.xml_path and not record.pdf_path: + record.estado = "pendiente_pdf" + elif record.pdf_path and not record.xml_path: + record.estado = "sin_xml" + + return sorted(records.values(), key=lambda r: (r.fecha_emision, r.clave)) + + + @staticmethod + def _parse_ui_date(value: str): + text = (value or "").strip() + if not text: + return None + for fmt in ("%d/%m/%Y", "%Y-%m-%d"): + try: + return datetime.strptime(text, fmt).date() + except ValueError: + continue + return None + + @staticmethod + def _in_range(fecha_emision: str, from_dt, to_dt) -> bool: + if not from_dt and not to_dt: + return True + try: + fecha = datetime.strptime((fecha_emision or "").strip(), "%d/%m/%Y").date() + except ValueError: + return False + if from_dt and fecha < from_dt: + return False + if to_dt and fecha > to_dt: + return False + return True diff --git a/app3/core/models.py b/app3/core/models.py new file mode 100644 index 0000000..c92fda5 --- /dev/null +++ b/app3/core/models.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(slots=True) +class FacturaRecord: + clave: str + fecha_emision: str = "" + emisor_nombre: str = "" + emisor_cedula: str = "" + tipo_documento: str = "" + total_comprobante: str = "" + xml_path: Path | None = None + pdf_path: Path | None = None + estado: str = "pendiente" diff --git a/app3/core/session.py b/app3/core/session.py new file mode 100644 index 0000000..6d833f8 --- /dev/null +++ b/app3/core/session.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +from app3.bootstrap import bootstrap_legacy_paths +from app3.config import client_root + +bootstrap_legacy_paths() + +from facturacion_system.core.client_profiles import load_profiles # noqa: E402 +from facturacion_system.core.settings import get_setting # noqa: E402 +from facturacion.xml_manager import CRXMLManager # noqa: E402 + + +@dataclass(slots=True) +class ClientSession: + cedula: str + nombre: str + folder: Path + year: int + + +def _digits(text: str) -> str: + return re.sub(r"\D", "", text or "") + + +def resolve_client_session(cedula: str, year: int | None = None) -> ClientSession: + clean = _digits(cedula) + if len(clean) < 9: + raise ValueError("La cédula no parece válida.") + + if year is None: + year = int(get_setting("fiscal_year")) + + manager = CRXMLManager() + nombre = manager.resolve_party_name(clean, "") + if not nombre: + raise ValueError("No se encontró contribuyente en cache/API Hacienda.") + + base = client_root(year) + expected = base / nombre + if expected.exists(): + return ClientSession(cedula=clean, nombre=nombre, folder=expected, year=year) + + profiles = load_profiles() + for key, value in profiles.items(): + if key.startswith("__email__:"): + continue + folder_name = key.strip() + profile_ced = _digits(str((value or {}).get("cedula", ""))) if isinstance(value, dict) else "" + if profile_ced == clean: + folder = base / folder_name + if folder.exists(): + return ClientSession(cedula=clean, nombre=nombre, folder=folder, year=year) + + raise FileNotFoundError(f"No existe carpeta de cliente para '{nombre}' en {base}") diff --git a/app3/gui/main_window.py b/app3/gui/main_window.py new file mode 100644 index 0000000..2f73eda --- /dev/null +++ b/app3/gui/main_window.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import tkinter as tk +from tkinter import messagebox, ttk + +from app3.config import metadata_dir +from app3.core.catalog import CatalogManager +from app3.core.classifier import ClassificationDB, classify_record +from app3.core.factura_index import FacturaIndexer +from app3.core.models import FacturaRecord +from app3.core.session import resolve_client_session + + +class App3Window(tk.Tk): + def __init__(self) -> None: + super().__init__() + self.title("App 3 - Clasificador Contable (v1)") + self.geometry("1200x720") + + self.session = None + self.records: list[FacturaRecord] = [] + self.selected: FacturaRecord | None = None + + self._build() + + def _build(self) -> None: + top = ttk.Frame(self) + top.pack(fill="x", padx=8, pady=8) + + ttk.Label(top, text="Cédula:").pack(side="left") + self.cedula_var = tk.StringVar() + ttk.Entry(top, textvariable=self.cedula_var, width=20).pack(side="left", padx=6) + ttk.Button(top, text="Cargar cliente", command=self.load_client).pack(side="left", padx=6) + ttk.Label(top, text="Desde (DD/MM/AAAA):").pack(side="left", padx=(14, 4)) + self.from_var = tk.StringVar() + ttk.Entry(top, textvariable=self.from_var, width=12).pack(side="left") + ttk.Label(top, text="Hasta:").pack(side="left", padx=(10, 4)) + self.to_var = tk.StringVar() + ttk.Entry(top, textvariable=self.to_var, width=12).pack(side="left") + + body = ttk.PanedWindow(self, orient="horizontal") + body.pack(fill="both", expand=True, padx=8, pady=8) + + left = ttk.Frame(body) + right = ttk.Frame(body) + body.add(left, weight=1) + body.add(right, weight=1) + + cols = ("estado", "fecha", "emisor", "total", "clave") + self.tree = ttk.Treeview(left, columns=cols, show="headings") + for c in cols: + self.tree.heading(c, text=c.upper()) + self.tree.pack(fill="both", expand=True) + self.tree.bind("<>", self.on_select) + + self.info_var = tk.StringVar(value="Selecciona una factura") + ttk.Label(right, textvariable=self.info_var, wraplength=500, justify="left").pack(anchor="w", pady=(0, 10)) + + ttk.Label(right, text="Categoría").pack(anchor="w") + self.categoria_var = tk.StringVar(value="COMPRAS") + self.categoria_cb = ttk.Combobox(right, textvariable=self.categoria_var, values=["COMPRAS", "GASTOS", "INGRESOS"], state="readonly") + self.categoria_cb.pack(fill="x", pady=3) + + ttk.Label(right, text="Subcategoría").pack(anchor="w") + self.subcategoria_var = tk.StringVar(value="COMPRAS DE CONTADO") + ttk.Entry(right, textvariable=self.subcategoria_var).pack(fill="x", pady=3) + + ttk.Label(right, text="Proveedor").pack(anchor="w") + self.proveedor_var = tk.StringVar() + ttk.Entry(right, textvariable=self.proveedor_var).pack(fill="x", pady=3) + + ttk.Button(right, text="Clasificar", command=self.classify_selected).pack(anchor="w", pady=8) + + def load_client(self) -> None: + try: + self.session = resolve_client_session(self.cedula_var.get()) + mdir = metadata_dir(self.session.folder) + catalog = CatalogManager(mdir).load() + self.categoria_cb.configure(values=sorted(catalog.keys())) + + self.db = ClassificationDB(mdir) + indexer = FacturaIndexer() + self.records = indexer.load_period( + self.session.folder, + from_date=self.from_var.get(), + to_date=self.to_var.get(), + ) + self.refresh_tree() + if indexer.parse_errors: + sample = "\n".join(indexer.parse_errors[:5]) + messagebox.showwarning( + "Cliente cargado con advertencias", + f"Cliente: {self.session.folder.name}\nFacturas cargadas: {len(self.records)}\n" + f"XML con error (se omitieron): {len(indexer.parse_errors)}\n\n{sample}", + ) + else: + messagebox.showinfo("Sesión", f"Cliente cargado: {self.session.folder.name}\nFacturas: {len(self.records)}") + except Exception as exc: + messagebox.showerror("Error", str(exc)) + + def refresh_tree(self) -> None: + self.tree.delete(*self.tree.get_children()) + for idx, r in enumerate(self.records): + estado = self.db.get_estado(r.clave) or r.estado + self.tree.insert("", "end", iid=str(idx), values=(estado, r.fecha_emision, r.emisor_nombre, r.total_comprobante, r.clave)) + + def on_select(self, _event=None) -> None: + selected = self.tree.selection() + if not selected: + return + idx = int(selected[0]) + self.selected = self.records[idx] + proveedor = self.selected.emisor_nombre or "PROVEEDOR" + self.proveedor_var.set(proveedor) + self.info_var.set( + f"Clave: {self.selected.clave}\n" + f"XML: {self.selected.xml_path or 'N/A'}\n" + f"PDF: {self.selected.pdf_path or 'N/A'}\n" + f"Tipo: {self.selected.tipo_documento}\n" + ) + + def classify_selected(self) -> None: + if not self.session or not self.selected: + messagebox.showwarning("Atención", "Carga cliente y selecciona una factura") + return + try: + classify_record( + self.selected, + self.session.folder, + self.db, + self.categoria_var.get().strip().upper(), + self.subcategoria_var.get().strip().upper(), + self.proveedor_var.get().strip().upper(), + ) + self.refresh_tree() + messagebox.showinfo("Listo", "Factura clasificada") + except Exception as exc: + messagebox.showerror("Error", str(exc)) diff --git a/app3/main.py b/app3/main.py new file mode 100644 index 0000000..7067a1d --- /dev/null +++ b/app3/main.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from app3.bootstrap import bootstrap_legacy_paths + +bootstrap_legacy_paths() + +from app3.gui.main_window import App3Window # noqa: E402 + + +def main() -> None: + app = App3Window() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a266747 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4d5f2a9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +# Core UI +customtkinter>=5.2 + +# XML/report processing reused from APP 2 +pandas>=2.0 +openpyxl>=3.1 + +# PDF and Hacienda integrations reused from APP 1 +pdfplumber>=0.11 +requests>=2.31 + +# Security layer reused from APP 1 +cryptography>=42.0 +keyring>=24.0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..86bf78a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/tests/test_app3_core.py b/tests/test_app3_core.py new file mode 100644 index 0000000..a784d93 --- /dev/null +++ b/tests/test_app3_core.py @@ -0,0 +1,39 @@ +from pathlib import Path + +from app3.core.catalog import CatalogManager +from app3.core.classifier import ClassificationDB, classify_record +from app3.core.models import FacturaRecord + + +def test_catalog_atomic_save_load(tmp_path: Path): + manager = CatalogManager(tmp_path) + data = {"COMPRAS": {"CONTADO": {}}} + manager.save(data) + loaded = manager.load() + assert loaded == data + + +def test_classify_record_moves_pdf_and_registers(tmp_path: Path): + client = tmp_path / "CLIENTE" + pdf = client / "PDF" / "a.pdf" + pdf.parent.mkdir(parents=True) + pdf.write_bytes(b"fake-pdf") + + record = FacturaRecord(clave="5" * 50, pdf_path=pdf) + db = ClassificationDB(client / ".metadata") + + target = classify_record(record, client, db, "COMPRAS", "CONTADO", "PROVEEDOR") + assert target is not None + assert target.exists() + assert not pdf.exists() + assert db.get_estado(record.clave) == "clasificado" + + +def test_catalog_recovers_from_invalid_json(tmp_path: Path): + manager = CatalogManager(tmp_path) + broken = tmp_path / "catalogo_cuentas.json" + broken.write_text("", encoding="utf-8") + loaded = manager.load() + assert isinstance(loaded, dict) + assert "COMPRAS" in loaded + assert (tmp_path / "catalogo_cuentas.invalid.json").exists()