from datetime import UTC, datetime from pathlib import Path from typing import Any from sqlalchemy.orm import Session from src.ingest._common import _reader_raw, _upsert from src.ingest.transactions import consolidate_transactions, map_b4w_to_transaction from src.models.b4w import B4wDb, B4wExport, Category, CategoryDb def reader_b4w(path: Path) -> list[B4wExport]: raw = _reader_raw(path) return list(map(B4wExport.model_validate, raw)) def upsert_category( session: Session, category: Category | None, do_commit=True ) -> CategoryDb | None: if category is None: return None parent = upsert_category(session, category.parent, do_commit=do_commit) parent_id = parent.id if parent else None matching_rows = session.query(CategoryDb).filter_by( name=category.name, parent_id=parent_id ) active = matching_rows.filter(CategoryDb.valid_to.is_(None)).one_or_none() if active is not None: return active parameters = {"name": category.name, "parent": parent} parameters_existing = {**parameters, "valid_to": None} parameters_new: dict[str, Any] = { **parameters, "valid_from": None, "valid_to": None, "last_action": "created", } historical = matching_rows.filter(CategoryDb.valid_to.is_not(None)).first() if historical is not None: parameters_new["valid_from"] = _utc_datetime(None) parameters_new["last_action"] = "changed" return _upsert( session, CategoryDb, parameters_existing, parameters_new, do_update=False, do_commit=do_commit, ) def _utc_datetime(value: datetime | None) -> datetime: if value is None: return datetime.now(UTC) if value.tzinfo is None or value.utcoffset() is None: return value.replace(tzinfo=UTC) return value.astimezone(UTC) def _ensure_after_valid_from(category: CategoryDb, effective_at: datetime) -> None: if category.valid_from is None: return valid_from = _utc_datetime(category.valid_from) if effective_at <= valid_from: raise ValueError("The action time must be later than valid_from") def change_category( session: Session, category_id: int, replacement: Category, effective_at: datetime | None = None, do_commit: bool = True, ) -> CategoryDb: """Close an active category version and start its replacement version.""" current = ( session.query(CategoryDb).filter_by(id=category_id, valid_to=None).one_or_none() ) if current is None: raise ValueError(f"No active category found with id {category_id}") action_time = _utc_datetime(effective_at) _ensure_after_valid_from(current, action_time) parent = upsert_category(session, replacement.parent, do_commit=False) parent_id = parent.id if parent else None if current.name == replacement.name and current.parent_id == parent_id: return current replacement_active = ( session.query(CategoryDb) .filter_by(name=replacement.name, parent_id=parent_id, valid_to=None) .one_or_none() ) current.valid_to = action_time current.last_action = "changed" session.flush() if replacement_active is None: replacement_active = CategoryDb( name=replacement.name, parent=parent, valid_from=action_time, valid_to=None, last_action="changed", ) session.add(replacement_active) session.flush() if do_commit: session.commit() return replacement_active def retire_category( session: Session, category_id: int, effective_at: datetime | None = None, do_commit: bool = True, ) -> CategoryDb: """Close an active category version without creating a replacement.""" category = ( session.query(CategoryDb).filter_by(id=category_id, valid_to=None).one_or_none() ) if category is None: raise ValueError(f"No active category found with id {category_id}") action_time = _utc_datetime(effective_at) _ensure_after_valid_from(category, action_time) category.valid_to = action_time category.last_action = "retired" session.flush() if do_commit: session.commit() return category def upsert_b4w( session: Session, data: B4wExport, do_update=False, do_commit=True ) -> B4wDb | None: category_db = upsert_category(session, data.category) parameters_existing = {"hash_id": data.hash_id} parameters_new = { "id_b4w": data.id, "hash_id": data.hash_id, "account_iban": data.account_iban, "account_currency": data.account_currency.value, "date_valid": data.date_valid, "date_book": data.date_book, "amount": data.amount_signed, "amount_currency": data.amount_currency.value, "text": data.reference, "other_name": data.other_name, "other_iban": data.other_iban, "category": category_db, "notes": data.notes, } return _upsert( session, B4wDb, parameters_existing, parameters_new, do_update=do_update, do_commit=do_commit, ) def ingest_b4w(path: Path, session: Session): data = reader_b4w(path) for row in data: upsert_b4w(session, row, do_commit=False) consolidate_transactions( session, source="b4w", StagingDb=B4wDb, mapper=map_b4w_to_transaction, do_commit=False, ) session.commit()