Wire DocumentNumberSequence allocator, ledger-based subsidiary/detail reports, and posted-voucher reopen flow without mock ops forms. Co-authored-by: Cursor <cursoragent@cursor.com>
472 lines
14 KiB
Python
472 lines
14 KiB
Python
"""Treasury API — Phase 5.4."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, status
|
|
from pydantic import BaseModel
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_db, require_tenant
|
|
from app.core.security import get_current_user
|
|
from app.models.treasury import Bank, BankAccount, CashBox
|
|
from app.repositories.base import TenantBaseRepository
|
|
from app.services.treasury_service import TreasuryService
|
|
from shared.security import CurrentUser
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class CashBoxCreate(BaseModel):
|
|
code: str
|
|
name: str
|
|
account_id: UUID | None = None
|
|
opening_balance: Decimal = Decimal("0")
|
|
|
|
|
|
class CashBoxRead(BaseModel):
|
|
id: UUID
|
|
tenant_id: UUID
|
|
code: str
|
|
name: str
|
|
current_balance: Decimal
|
|
is_active: bool
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class CashReceiptRequest(BaseModel):
|
|
cash_box_id: UUID
|
|
amount: Decimal
|
|
debit_account_id: UUID
|
|
credit_account_id: UUID
|
|
description: str | None = None
|
|
transaction_date: date | None = None
|
|
|
|
|
|
class BankCreate(BaseModel):
|
|
code: str
|
|
name: str
|
|
|
|
|
|
class BankAccountCreate(BaseModel):
|
|
bank_id: UUID
|
|
account_number: str
|
|
iban: str | None = None
|
|
|
|
|
|
class CashBoxRepo(TenantBaseRepository[CashBox]):
|
|
model = CashBox
|
|
|
|
|
|
class BankRepo(TenantBaseRepository[Bank]):
|
|
model = Bank
|
|
|
|
|
|
class BankAccountRepo(TenantBaseRepository[BankAccount]):
|
|
model = BankAccount
|
|
|
|
|
|
@router.post("/cash-boxes", response_model=CashBoxRead, status_code=status.HTTP_201_CREATED)
|
|
async def create_cash_box(
|
|
body: CashBoxCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
repo = CashBoxRepo(db)
|
|
entity = CashBox(
|
|
tenant_id=tenant_id,
|
|
code=body.code,
|
|
name=body.name,
|
|
account_id=body.account_id,
|
|
opening_balance=body.opening_balance,
|
|
current_balance=body.opening_balance,
|
|
)
|
|
await repo.add(entity)
|
|
await db.commit()
|
|
await db.refresh(entity)
|
|
return entity
|
|
|
|
|
|
@router.get("/cash-boxes", response_model=list[CashBoxRead])
|
|
async def list_cash_boxes(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
repo = CashBoxRepo(db)
|
|
return await repo.list_by_tenant(tenant_id, limit=100)
|
|
|
|
|
|
@router.post("/cash-receipts")
|
|
async def record_cash_receipt(
|
|
body: CashReceiptRequest,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
svc = TreasuryService(db)
|
|
tx = await svc.record_cash_receipt(
|
|
tenant_id, body.cash_box_id, body.amount,
|
|
debit_account_id=body.debit_account_id,
|
|
credit_account_id=body.credit_account_id,
|
|
actor_user_id=user.user_id,
|
|
description=body.description,
|
|
transaction_date=body.transaction_date,
|
|
)
|
|
await db.commit()
|
|
return {"id": str(tx.id), "voucher_id": str(tx.voucher_id)}
|
|
|
|
|
|
@router.post("/banks", status_code=status.HTTP_201_CREATED)
|
|
async def create_bank(
|
|
body: BankCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
repo = BankRepo(db)
|
|
entity = Bank(tenant_id=tenant_id, code=body.code, name=body.name)
|
|
await repo.add(entity)
|
|
await db.commit()
|
|
return {"id": str(entity.id)}
|
|
|
|
|
|
@router.post("/bank-accounts", status_code=status.HTTP_201_CREATED)
|
|
async def create_bank_account(
|
|
body: BankAccountCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
repo = BankAccountRepo(db)
|
|
entity = BankAccount(
|
|
tenant_id=tenant_id,
|
|
bank_id=body.bank_id,
|
|
account_number=body.account_number,
|
|
iban=body.iban,
|
|
)
|
|
await repo.add(entity)
|
|
await db.commit()
|
|
return {"id": str(entity.id)}
|
|
|
|
|
|
@router.get("/banks")
|
|
async def list_banks(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
banks = await BankRepo(db).list_by_tenant(tenant_id, limit=100)
|
|
return [{"id": str(b.id), "code": b.code, "name": b.name, "is_active": b.is_active} for b in banks]
|
|
|
|
|
|
@router.get("/bank-accounts")
|
|
async def list_bank_accounts(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
accounts = await BankAccountRepo(db).list_by_tenant(tenant_id, limit=100)
|
|
return [
|
|
{
|
|
"id": str(a.id),
|
|
"bank_id": str(a.bank_id),
|
|
"account_number": a.account_number,
|
|
"iban": a.iban,
|
|
"is_active": a.is_active,
|
|
}
|
|
for a in accounts
|
|
]
|
|
|
|
|
|
@router.patch("/cash-boxes/{cash_box_id}")
|
|
async def update_cash_box(
|
|
cash_box_id: UUID,
|
|
body: dict,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
from shared.exceptions import NotFoundError
|
|
|
|
repo = CashBoxRepo(db)
|
|
entity = await repo.get(tenant_id, cash_box_id)
|
|
if entity is None:
|
|
raise NotFoundError("صندوق یافت نشد", error_code="cash_box_not_found")
|
|
if "name" in body and body["name"] is not None:
|
|
entity.name = body["name"]
|
|
if "is_active" in body and body["is_active"] is not None:
|
|
entity.is_active = bool(body["is_active"])
|
|
await db.commit()
|
|
await db.refresh(entity)
|
|
return CashBoxRead.model_validate(entity)
|
|
|
|
|
|
# ── Cheques / transfers / receipts / payments / reconciliation ───────────────
|
|
|
|
from app.models.treasury import Cheque, PaymentVoucher, ReceiptVoucher, Reconciliation, Transfer
|
|
from app.models.types import ChequeStatus, ReconciliationStatus
|
|
|
|
|
|
class ChequeRepo(TenantBaseRepository[Cheque]):
|
|
model = Cheque
|
|
|
|
|
|
class TransferRepo(TenantBaseRepository[Transfer]):
|
|
model = Transfer
|
|
|
|
|
|
class ReceiptRepo(TenantBaseRepository[ReceiptVoucher]):
|
|
model = ReceiptVoucher
|
|
|
|
|
|
class PaymentRepo(TenantBaseRepository[PaymentVoucher]):
|
|
model = PaymentVoucher
|
|
|
|
|
|
class ReconciliationRepo(TenantBaseRepository[Reconciliation]):
|
|
model = Reconciliation
|
|
|
|
|
|
class ChequeCreate(BaseModel):
|
|
cheque_number: str
|
|
amount: Decimal
|
|
issue_date: date
|
|
due_date: date | None = None
|
|
is_incoming: bool = True
|
|
payee: str | None = None
|
|
bank_account_id: UUID | None = None
|
|
status: ChequeStatus = ChequeStatus.RECEIVED
|
|
|
|
|
|
class TransferCreate(BaseModel):
|
|
transfer_date: date
|
|
amount: Decimal
|
|
from_type: str
|
|
from_id: UUID
|
|
to_type: str
|
|
to_id: UUID
|
|
description: str | None = None
|
|
|
|
|
|
class ReceiptCreate(BaseModel):
|
|
receipt_number: str | None = None
|
|
receipt_date: date
|
|
amount: Decimal
|
|
payment_method: str = "cash"
|
|
cash_box_id: UUID | None = None
|
|
bank_account_id: UUID | None = None
|
|
description: str | None = None
|
|
|
|
|
|
class PaymentCreate(BaseModel):
|
|
payment_number: str | None = None
|
|
payment_date: date
|
|
amount: Decimal
|
|
payment_method: str = "cash"
|
|
cash_box_id: UUID | None = None
|
|
bank_account_id: UUID | None = None
|
|
description: str | None = None
|
|
|
|
|
|
class ReconciliationCreate(BaseModel):
|
|
bank_account_id: UUID
|
|
statement_date: date
|
|
statement_balance: Decimal
|
|
book_balance: Decimal
|
|
|
|
|
|
@router.get("/cheques")
|
|
async def list_cheques(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
rows = await ChequeRepo(db).list_by_tenant(tenant_id, limit=200)
|
|
return [
|
|
{
|
|
"id": str(c.id),
|
|
"cheque_number": c.cheque_number,
|
|
"amount": str(c.amount),
|
|
"issue_date": c.issue_date.isoformat(),
|
|
"due_date": c.due_date.isoformat() if c.due_date else None,
|
|
"status": c.status.value,
|
|
"is_incoming": c.is_incoming,
|
|
"payee": c.payee,
|
|
}
|
|
for c in rows
|
|
]
|
|
|
|
|
|
@router.post("/cheques", status_code=status.HTTP_201_CREATED)
|
|
async def create_cheque(
|
|
body: ChequeCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
entity = Cheque(tenant_id=tenant_id, **body.model_dump())
|
|
await ChequeRepo(db).add(entity)
|
|
await db.commit()
|
|
return {"id": str(entity.id), "cheque_number": entity.cheque_number}
|
|
|
|
|
|
@router.get("/transfers")
|
|
async def list_transfers(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
rows = await TransferRepo(db).list_by_tenant(tenant_id, limit=200)
|
|
return [
|
|
{
|
|
"id": str(t.id),
|
|
"transfer_date": t.transfer_date.isoformat(),
|
|
"amount": str(t.amount),
|
|
"from_type": t.from_type,
|
|
"from_id": str(t.from_id),
|
|
"to_type": t.to_type,
|
|
"to_id": str(t.to_id),
|
|
"description": t.description,
|
|
}
|
|
for t in rows
|
|
]
|
|
|
|
|
|
@router.post("/transfers", status_code=status.HTTP_201_CREATED)
|
|
async def create_transfer(
|
|
body: TransferCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
entity = Transfer(tenant_id=tenant_id, **body.model_dump())
|
|
await TransferRepo(db).add(entity)
|
|
await db.commit()
|
|
return {"id": str(entity.id)}
|
|
|
|
|
|
@router.get("/receipts")
|
|
async def list_receipts(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
rows = await ReceiptRepo(db).list_by_tenant(tenant_id, limit=200)
|
|
return [
|
|
{
|
|
"id": str(r.id),
|
|
"receipt_number": r.receipt_number,
|
|
"receipt_date": r.receipt_date.isoformat(),
|
|
"amount": str(r.amount),
|
|
"payment_method": r.payment_method,
|
|
"description": r.description,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post("/receipts", status_code=status.HTTP_201_CREATED)
|
|
async def create_receipt(
|
|
body: ReceiptCreate,
|
|
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
|
|
|
|
data = body.model_dump()
|
|
data["receipt_number"] = await DocumentNumberService(db).allocate_if_blank(
|
|
tenant_id, "receipt", body.receipt_number
|
|
)
|
|
entity = ReceiptVoucher(tenant_id=tenant_id, **data)
|
|
await ReceiptRepo(db).add(entity)
|
|
await db.commit()
|
|
return {"id": str(entity.id), "receipt_number": entity.receipt_number}
|
|
|
|
|
|
@router.get("/payments")
|
|
async def list_payments(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
rows = await PaymentRepo(db).list_by_tenant(tenant_id, limit=200)
|
|
return [
|
|
{
|
|
"id": str(p.id),
|
|
"payment_number": p.payment_number,
|
|
"payment_date": p.payment_date.isoformat(),
|
|
"amount": str(p.amount),
|
|
"payment_method": p.payment_method,
|
|
"description": p.description,
|
|
}
|
|
for p in rows
|
|
]
|
|
|
|
|
|
@router.post("/payments", status_code=status.HTTP_201_CREATED)
|
|
async def create_payment(
|
|
body: PaymentCreate,
|
|
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
|
|
|
|
data = body.model_dump()
|
|
data["payment_number"] = await DocumentNumberService(db).allocate_if_blank(
|
|
tenant_id, "payment", body.payment_number
|
|
)
|
|
entity = PaymentVoucher(tenant_id=tenant_id, **data)
|
|
await PaymentRepo(db).add(entity)
|
|
await db.commit()
|
|
return {"id": str(entity.id), "payment_number": entity.payment_number}
|
|
|
|
|
|
@router.get("/reconciliations")
|
|
async def list_reconciliations(
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
rows = await ReconciliationRepo(db).list_by_tenant(tenant_id, limit=100)
|
|
return [
|
|
{
|
|
"id": str(r.id),
|
|
"bank_account_id": str(r.bank_account_id),
|
|
"statement_date": r.statement_date.isoformat(),
|
|
"statement_balance": str(r.statement_balance),
|
|
"book_balance": str(r.book_balance),
|
|
"difference": str(r.difference),
|
|
"status": r.status.value,
|
|
}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
@router.post("/reconciliations", status_code=status.HTTP_201_CREATED)
|
|
async def create_reconciliation(
|
|
body: ReconciliationCreate,
|
|
tenant_id: UUID = Depends(require_tenant),
|
|
db: AsyncSession = Depends(get_db),
|
|
_user: CurrentUser = Depends(get_current_user),
|
|
):
|
|
diff = body.statement_balance - body.book_balance
|
|
entity = Reconciliation(
|
|
tenant_id=tenant_id,
|
|
bank_account_id=body.bank_account_id,
|
|
statement_date=body.statement_date,
|
|
statement_balance=body.statement_balance,
|
|
book_balance=body.book_balance,
|
|
difference=diff,
|
|
status=ReconciliationStatus.PENDING,
|
|
)
|
|
await ReconciliationRepo(db).add(entity)
|
|
await db.commit()
|
|
return {"id": str(entity.id), "difference": str(diff)}
|