d582f15681
- Implemented tests for import polling functionality, ensuring correct behavior for active and terminal import statuses. - Created tests for the login page, validating user interactions and error handling. - Added unit tests for money utility functions, covering parsing, formatting, and summation of monetary values. - Developed tests for query keys, ensuring proper scoping and invalidation roots. chore: setup testing environment and configuration - Added setup file for testing with Vitest and Testing Library. - Configured TypeScript and Vite for the frontend project. - Created Docker and Nginx configurations for local development and production environments. - Established a PostgreSQL initialization script for setting up application roles. docs: add README and environment configuration for infra - Included README for the infra directory, detailing local development setup and service descriptions. - Created an example environment configuration file for local development with placeholders for sensitive information.
249 lines
8.0 KiB
Python
249 lines
8.0 KiB
Python
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)
|