Files
finance/backend/app/imports/router.py
T

358 lines
12 KiB
Python

import uuid
from typing import Annotated, Any
from fastapi import APIRouter, File, Form, Query, 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
class HoldResolveBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
action: str
reason: str
account_id: uuid.UUID | None = Field(default=None, alias="accountId")
def summary(document: Any, *, unresolved_hold_count: int = 0) -> 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,
"unresolvedHoldCount": unresolved_hold_count,
"errorCode": document.error_code,
"createdAt": document.created_at,
}
@router.get("")
def imports(
db: Db,
context: Context,
month: Annotated[str | None, Query()] = None,
) -> list[dict]:
month_filter = service.parse_month_filter(month)
documents = service.list_documents(db, space_id=context.space.id)
hold_counts = service.unresolved_hold_counts(
db,
space_id=context.space.id,
document_ids=[item.id for item in documents],
month=month_filter,
)
result = []
for item in documents:
hold_count = hold_counts.get(item.id, 0)
if month_filter is not None and item.status == "confirmed" and hold_count == 0:
continue
result.append(summary(item, unresolved_hold_count=hold_count))
return result
@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,
unresolved_hold_count=len(
service.unresolved_holds(db, space_id=context.space.id, document=document)
),
)
reconciliation = raw.get("reconciliation") or {}
rows = []
pending_candidate_row_ids: set[uuid.UUID] = set()
for item in raw["records"]:
hold = item.get("hold_resolution")
candidates = [
{
"id": candidate["id"],
"kind": candidate["kind"],
"confidence": candidate["confidence"],
"resolution": candidate["resolution"],
"existingOperationId": candidate["existing_operation_id"],
}
for candidate in item.get("candidates", [])
]
has_pending_candidate = any(
candidate["resolution"] == "pending" for candidate in candidates
)
if has_pending_candidate:
pending_candidate_row_ids.add(item["id"])
rows.append(
{
"id": item["id"],
"externalId": item["external_id"],
"occurredOn": item["occurred_on"],
"amountKopecks": item["amount_kopecks"],
"direction": item["direction"],
"bankStatus": item.get("bank_status", "posted"),
"description": item["description"],
"payeeHint": None,
"warnings": (item["warnings"] or {}).get("codes", []),
"decision": "pending"
if item["critical"] or has_pending_candidate
else item["review_decision"],
"duplicateOfOperationId": item["duplicate_operation_id"],
"candidates": candidates,
"holdResolution": None
if hold is None
else {
"resolution": hold["resolution"],
"resolvingSourceRecordId": hold["resolving_source_record_id"],
"operationId": hold["operation_id"],
"resolvedByUserId": hold["resolved_by_user_id"],
"reason": hold["reason"],
"resolvedAt": hold["resolved_at"],
},
}
)
blockers = [
{"code": "IMPORT_FIELD_UNRESOLVED", "message": None, "rowId": str(item["id"])}
for item in raw["records"]
if item["critical"]
]
blockers.extend(
{
"code": "IMPORT_DECISION_REQUIRED",
"message": "Possible duplicate or transfer requires a decision",
"rowId": str(item["id"]),
}
for item in raw["records"]
if item["id"] in pending_candidate_row_ids
)
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": reconciliation.get("computed_closing_kopecks"),
"declaredInflowKopecks": reconciliation.get("declared_inflow_kopecks"),
"declaredOutflowKopecks": reconciliation.get("declared_outflow_kopecks"),
"declaredHoldKopecks": reconciliation.get("declared_hold_kopecks"),
"parsedPostedInflowKopecks": reconciliation.get("parsed_posted_inflow_kopecks"),
"parsedPostedOutflowKopecks": reconciliation.get("parsed_posted_outflow_kopecks"),
"parsedHoldTotalKopecks": reconciliation.get("parsed_hold_total_kopecks"),
"postedTotalsMatched": reconciliation.get("posted_totals_matched"),
"holdTotalsMatched": reconciliation.get("hold_totals_matched"),
"balanceMatched": reconciliation.get("balance_matched"),
"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),
"savingsTransfer": sum(row["decision"] == "savings_transfer" for row in rows),
"pending": sum(row["decision"] == "pending" 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,
unresolved_hold_count=len(
service.unresolved_holds(db, space_id=context.space.id, document=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}/rows/{row_id}/resolve")
def resolve_hold(
document_id: uuid.UUID,
row_id: uuid.UUID,
body: HoldResolveBody,
db: Db,
context: Context,
_csrf: Csrf,
) -> dict:
service.resolve_hold(
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)