RHF was caching 0/0 via useMemo on in-place form arrays; MoneyInput now drives Controller. Voucher list exposes source filters for manual vs automatic GL docs. Co-authored-by: Cursor <cursoragent@cursor.com>
288 lines
10 KiB
Python
288 lines
10 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()
|
|
|
|
|
|
def _assert_lines_balanced(lines: list) -> None:
|
|
from decimal import Decimal
|
|
|
|
if len(lines) < 2:
|
|
raise AppError(
|
|
"حداقل دو ردیف برای سند لازم است",
|
|
status_code=422,
|
|
error_code="voucher_lines_min",
|
|
)
|
|
total_debit = Decimal("0")
|
|
total_credit = Decimal("0")
|
|
for i, line in enumerate(lines, start=1):
|
|
data = line.model_dump() if hasattr(line, "model_dump") else dict(line)
|
|
debit = Decimal(str(data.get("debit") or 0))
|
|
credit = Decimal(str(data.get("credit") or 0))
|
|
if debit > 0 and credit > 0:
|
|
raise AppError(
|
|
f"ردیف {i}: نمیتوان همزمان بدهکار و بستانکار داشت",
|
|
status_code=422,
|
|
error_code="voucher_line_both_sides",
|
|
)
|
|
if debit == 0 and credit == 0:
|
|
raise AppError(
|
|
f"ردیف {i}: یکی از بدهکار یا بستانکار باید مقدار داشته باشد",
|
|
status_code=422,
|
|
error_code="voucher_line_empty",
|
|
)
|
|
total_debit += debit
|
|
total_credit += credit
|
|
if total_debit != total_credit:
|
|
diff = abs(total_debit - total_credit)
|
|
raise AppError(
|
|
f"سند نامتوازن است — جمع بدهکار {total_debit} و بستانکار {total_credit} "
|
|
f"(اختلاف {diff}). ثبت مجاز نیست.",
|
|
status_code=422,
|
|
error_code="voucher_unbalanced",
|
|
)
|
|
|
|
|
|
@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),
|
|
status: str | None = None,
|
|
source: str | None = None,
|
|
):
|
|
"""List all vouchers including automatic/system-posted (sales/purchase/treasury/...)."""
|
|
from sqlalchemy import or_, select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
stmt = (
|
|
select(Voucher)
|
|
.options(selectinload(Voucher.lines))
|
|
.where(Voucher.tenant_id == tenant_id)
|
|
)
|
|
if status:
|
|
try:
|
|
stmt = stmt.where(Voucher.status == VoucherStatus(status))
|
|
except ValueError:
|
|
pass
|
|
if source == "manual":
|
|
stmt = stmt.where(
|
|
or_(Voucher.source_module.is_(None), Voucher.source_module.in_(["", "manual"]))
|
|
)
|
|
elif source == "auto":
|
|
stmt = stmt.where(
|
|
Voucher.source_module.is_not(None),
|
|
Voucher.source_module.notin_(["", "manual"]),
|
|
)
|
|
elif source:
|
|
stmt = stmt.where(Voucher.source_module == source)
|
|
|
|
stmt = (
|
|
stmt.order_by(Voucher.created_at.desc())
|
|
.offset(pagination.offset)
|
|
.limit(pagination.page_size)
|
|
)
|
|
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
|
|
|
|
_assert_lines_balanced(body.lines)
|
|
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)
|
|
if lines_data is not None:
|
|
_assert_lines_balanced(lines_data)
|
|
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)
|