358 lines
12 KiB
Python
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)
|