TorbatYar/backend/services/accounting/app/api/v1/posting.py
Mortezakoohjani 7953e47f8b Ship accounting UX: Rial integers, auto doc numbers, reverse-and-edit, real reports, customers nav.
Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 15:52:06 +03:30

224 lines
7.7 KiB
Python

"""Posting Engine API — Phase 5.2."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.core.security import get_current_user
from app.models.posting import Voucher, VoucherLine
from app.models.types import VoucherStatus
from app.repositories.posting import AuditLogRepository, VoucherRepository
from app.schemas.posting import (
AuditLogRead,
PostRequest,
ReverseRequest,
VoucherCreate,
VoucherRead,
VoucherUpdate,
)
from app.services.posting_engine import PostingEngine
from shared.exceptions import AppError, NotFoundError
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.get("/vouchers", response_model=list[VoucherRead])
async def list_vouchers(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
from sqlalchemy import select
from sqlalchemy.orm import selectinload
stmt = (
select(Voucher)
.options(selectinload(Voucher.lines))
.where(Voucher.tenant_id == tenant_id)
.offset(pagination.offset)
.limit(pagination.page_size)
.order_by(Voucher.created_at.desc())
)
result = await db.execute(stmt)
return list(result.scalars().all())
@router.post("/vouchers", response_model=VoucherRead, status_code=status.HTTP_201_CREATED)
async def create_voucher(
body: VoucherCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
from app.services.document_number_service import DocumentNumberService
repo = VoucherRepository(db)
number = await DocumentNumberService(db).allocate_if_blank(
tenant_id, "voucher", body.voucher_number
)
voucher = Voucher(
tenant_id=tenant_id,
fiscal_period_id=body.fiscal_period_id,
voucher_number=number,
voucher_date=body.voucher_date,
status=VoucherStatus.DRAFT,
description=body.description,
reference_number=body.reference_number,
source_module=body.source_module,
created_by=user.user_id,
)
await repo.add(voucher)
for i, line in enumerate(body.lines, start=1):
db.add(VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=i,
**line.model_dump(),
))
await db.commit()
result = await repo.get_with_lines(tenant_id, voucher.id)
return result
@router.get("/vouchers/{voucher_id}", response_model=VoucherRead)
async def get_voucher(
voucher_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = VoucherRepository(db)
voucher = await repo.get_with_lines(tenant_id, voucher_id)
if voucher is None:
raise NotFoundError("سند یافت نشد", error_code="voucher_not_found")
return voucher
@router.patch("/vouchers/{voucher_id}", response_model=VoucherRead)
async def update_draft_voucher(
voucher_id: UUID,
body: VoucherUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = VoucherRepository(db)
voucher = await repo.get_with_lines(tenant_id, voucher_id)
if voucher is None:
raise NotFoundError("سند یافت نشد", error_code="voucher_not_found")
if voucher.status != VoucherStatus.DRAFT:
raise AppError(
"فقط اسناد پیش‌نویس قابل ویرایش هستند",
status_code=422,
error_code="voucher_not_draft",
)
data = body.model_dump(exclude_unset=True)
lines_data = data.pop("lines", None)
for key, value in data.items():
setattr(voucher, key, value)
if lines_data is not None:
for line in list(voucher.lines):
await db.delete(line)
await db.flush()
for i, line in enumerate(lines_data, start=1):
db.add(
VoucherLine(
tenant_id=tenant_id,
voucher_id=voucher.id,
line_number=i,
**line,
)
)
await db.commit()
return await repo.get_with_lines(tenant_id, voucher_id)
@router.post("/vouchers/{voucher_id}/validate", response_model=VoucherRead)
async def validate_voucher(
voucher_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
engine = PostingEngine(db)
voucher = await engine.validate_voucher(tenant_id, voucher_id)
await db.commit()
return await VoucherRepository(db).get_with_lines(tenant_id, voucher.id)
@router.post("/vouchers/{voucher_id}/post", response_model=VoucherRead)
async def post_voucher(
voucher_id: UUID,
body: PostRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
engine = PostingEngine(db)
voucher = await engine.post_voucher(
tenant_id, voucher_id, actor_user_id=user.user_id, source_module=body.source_module
)
await db.commit()
return await VoucherRepository(db).get_with_lines(tenant_id, voucher.id)
@router.post("/vouchers/{voucher_id}/reverse", response_model=VoucherRead)
async def reverse_voucher(
voucher_id: UUID,
body: ReverseRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
engine = PostingEngine(db)
voucher = await engine.reverse_voucher(
tenant_id, voucher_id, actor_user_id=user.user_id, reversal_date=body.reversal_date
)
await db.commit()
return await VoucherRepository(db).get_with_lines(tenant_id, voucher.id)
@router.post("/vouchers/{voucher_id}/reverse-and-edit", response_model=VoucherRead)
async def reverse_and_edit_voucher(
voucher_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
"""برگشت سند قطعی + ایجاد پیش‌نویس قابل ویرایش از همان محتوا."""
engine = PostingEngine(db)
draft = await engine.reverse_and_reopen_for_edit(
tenant_id, voucher_id, actor_user_id=user.user_id
)
await db.commit()
return await VoucherRepository(db).get_with_lines(tenant_id, draft.id)
@router.post("/vouchers/{voucher_id}/cancel", response_model=VoucherRead)
async def cancel_voucher(
voucher_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
engine = PostingEngine(db)
voucher = await engine.cancel_voucher(tenant_id, voucher_id, actor_user_id=user.user_id)
await db.commit()
return voucher
@router.get("/audit-logs", response_model=list[AuditLogRead])
async def list_audit_logs(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = AuditLogRepository(db)
return await repo.list_by_tenant(tenant_id, limit=100)