import uuid from typing import Annotated, Any from fastapi import APIRouter, File, Form, UploadFile from pydantic import BaseModel, ConfigDict, Field from app.dependencies import Context, Csrf, Db from app.imports import service from app.imports.tasks import enqueue_process_statement router = APIRouter(prefix="/imports", tags=["imports"]) class ReviewBody(BaseModel): account_id: uuid.UUID confirmed_without_full_reconciliation: bool = False reason: str | None = None class DecisionBody(BaseModel): model_config = ConfigDict(populate_by_name=True) decision: str duplicate_operation_id: uuid.UUID | None = Field(default=None, alias="duplicateOfOperationId") class ConfirmBody(BaseModel): model_config = ConfigDict(populate_by_name=True) confirmed_without_full_reconciliation: bool = Field( default=False, alias="confirmedWithoutFullReconciliation" ) reason: str | None = None def summary(document: Any) -> dict: return { "id": document.id, "fileName": document.original_filename, "status": document.status, "bankAdapter": document.adapter_id or "detecting", "accountId": document.account_id, "accountName": None, "periodFrom": None, "periodTo": None, "needsAccountMapping": document.needs_account_mapping, "errorCode": document.error_code, "createdAt": document.created_at, } @router.get("") def imports(db: Db, context: Context) -> list[dict]: return [summary(item) for item in service.list_documents(db, space_id=context.space.id)] @router.post("", status_code=202) async def upload( file: Annotated[UploadFile, File()], db: Db, context: Context, _csrf: Csrf, account_id: Annotated[uuid.UUID | None, Form(alias="accountId")] = None, ) -> dict: from app.config import get_settings raw = await file.read(get_settings().max_upload_bytes + 1) document, created = service.create_document( db, space_id=context.space.id, user=context.user, filename=file.filename or "statement", raw=raw, account_id=account_id, ) if created: db.commit() try: enqueue_process_statement(document.id, context.space.id) except Exception as exc: from app.db.session import set_space_context from app.errors import AppError set_space_context(db, context.space.id) document = service._document(db, space_id=context.space.id, document_id=document.id) document.status = "failed" document.error_code = "IMPORT_QUEUE_FAILED" db.commit() raise AppError( "Import queue is unavailable", code="IMPORT_QUEUE_FAILED", status_code=503, ) from exc return summary(document) @router.get("/{document_id}") def detail(document_id: uuid.UUID, db: Db, context: Context) -> dict: raw = service.document_detail(db, space_id=context.space.id, document_id=document_id) document = service._document(db, space_id=context.space.id, document_id=document_id) base = summary(document) reconciliation = raw.get("reconciliation") or {} rows = [ { "id": item["id"], "externalId": item["external_id"], "occurredOn": item["occurred_on"], "amountKopecks": item["amount_kopecks"], "direction": item["direction"], "description": item["description"], "payeeHint": None, "warnings": (item["warnings"] or {}).get("codes", []), "decision": "pending" if item["critical"] else item["review_decision"], "duplicateOfOperationId": item["duplicate_operation_id"], } for item in raw["records"] ] blockers = [ {"code": "IMPORT_FIELD_UNRESOLVED", "message": None, "rowId": str(item["id"])} for item in raw["records"] if item["critical"] ] status = ( "matched" if reconciliation.get("full") else "confirmed_without" if reconciliation.get("confirmed_without_full_reconciliation") else "not_available" ) return { **base, "failureReason": raw["error_code"], "blockers": blockers, "warnings": [], "reconciliation": { "status": status, "openingBalanceKopecks": reconciliation.get("opening_balance_kopecks"), "closingBalanceKopecks": reconciliation.get("closing_balance_kopecks"), "computedClosingKopecks": None, "reason": reconciliation.get("reason"), }, "rows": rows, "totals": { "total": len(rows), "create": sum(row["decision"] == "create" for row in rows), "discard": sum(row["decision"] == "discard" for row in rows), "duplicate": sum(row["decision"] == "duplicate" for row in rows), "transfer": sum(row["decision"] == "transfer" for row in rows), }, } @router.post("/{document_id}/review") def review(document_id: uuid.UUID, body: ReviewBody, db: Db, context: Context, _csrf: Csrf) -> dict: document = service.set_review_confirmation( db, space_id=context.space.id, document_id=document_id, **body.model_dump(), ) return summary(document) @router.patch("/{document_id}/rows/{row_id}") def decide_row( document_id: uuid.UUID, row_id: uuid.UUID, body: DecisionBody, db: Db, context: Context, _csrf: Csrf, ) -> dict: service.decide_source_record( db, space_id=context.space.id, document_id=document_id, source_record_id=row_id, user=context.user, **body.model_dump(by_alias=False), ) return detail(document_id, db, context) @router.post("/{document_id}/confirm") def confirm( document_id: uuid.UUID, body: ConfirmBody, db: Db, context: Context, _csrf: Csrf, ) -> dict: document = service._document(db, space_id=context.space.id, document_id=document_id) if body.confirmed_without_full_reconciliation and document.account_id: service.set_review_confirmation( db, space_id=context.space.id, document_id=document_id, account_id=document.account_id, confirmed_without_full_reconciliation=True, reason=body.reason, ) document = service.confirm_document( db, space_id=context.space.id, document_id=document_id, user=context.user ) return detail(document_id, db, context) @router.post("/{document_id}/reparse", status_code=202) def reparse(document_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict: document = service.prepare_reparse( db, space_id=context.space.id, document_id=document_id, user=context.user ) db.commit() from app.db.session import set_space_context try: enqueue_process_statement(document.id, context.space.id) except Exception as exc: from app.errors import AppError set_space_context(db, context.space.id) document = service._document(db, space_id=context.space.id, document_id=document.id) document.status = "failed" document.error_code = "IMPORT_QUEUE_FAILED" db.commit() raise AppError( "Import queue is unavailable", code="IMPORT_QUEUE_FAILED", status_code=503, ) from exc set_space_context(db, context.space.id) return detail(document_id, db, context) @router.post("/{document_id}/revert") def revert(document_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict: service.revert_document( db, space_id=context.space.id, document_id=document_id, user=context.user ) return detail(document_id, db, context) @router.delete("/{document_id}/source") def delete_source(document_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict: document = service.delete_source_file( db, space_id=context.space.id, document_id=document_id, user=context.user ) return summary(document)