Goods receipts and warehouse receipts now capture party vs warehouse separately; purchase returns link to invoices and reduce AP. Co-authored-by: Cursor <cursoragent@cursor.com>
717 lines
24 KiB
Python
717 lines
24 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
|
|
counter_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
|
|
counter_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,
|
|
"voucher_id": str(t.voucher_id) if t.voucher_id else None,
|
|
}
|
|
for t in rows
|
|
]
|
|
|
|
|
|
async def _resolve_transfer_gl_account(
|
|
db: AsyncSession,
|
|
tenant_id: UUID,
|
|
side_type: str,
|
|
side_id: UUID,
|
|
) -> UUID:
|
|
from shared.exceptions import AppError
|
|
|
|
if side_type == "account":
|
|
return side_id
|
|
if side_type == "cash":
|
|
box = await CashBoxRepo(db).get(tenant_id, side_id)
|
|
if box is None or box.account_id is None:
|
|
raise AppError(
|
|
"صندوق باید به حساب دفتر کل متصل باشد",
|
|
status_code=422,
|
|
error_code="cash_box_account_missing",
|
|
)
|
|
return box.account_id
|
|
if side_type == "bank":
|
|
bank = await BankAccountRepo(db).get(tenant_id, side_id)
|
|
if bank is None or bank.account_id is None:
|
|
raise AppError(
|
|
"حساب بانکی باید به حساب دفتر کل متصل باشد",
|
|
status_code=422,
|
|
error_code="bank_account_gl_missing",
|
|
)
|
|
return bank.account_id
|
|
raise AppError(
|
|
f"نوع مبدأ/مقصد نامعتبر است: {side_type} (account|cash|bank)",
|
|
status_code=422,
|
|
error_code="transfer_type_invalid",
|
|
)
|
|
|
|
|
|
@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),
|
|
):
|
|
"""Transfer between cash/bank/any GL account — auto-posts Dr to / Cr from."""
|
|
from app.services.posting_policy_service import PostingPolicyService
|
|
from app.services.source_posting_guard import SourcePostingGuard
|
|
|
|
entity = Transfer(tenant_id=tenant_id, **body.model_dump())
|
|
await TransferRepo(db).add(entity)
|
|
|
|
from_gl = await _resolve_transfer_gl_account(db, tenant_id, body.from_type, body.from_id)
|
|
to_gl = await _resolve_transfer_gl_account(db, tenant_id, body.to_type, body.to_id)
|
|
|
|
policy = await PostingPolicyService(db).get_policy(tenant_id)
|
|
voucher_id = None
|
|
voucher_number = None
|
|
# Transfers always need a GL document when accounts resolve — honor policy flag if present.
|
|
auto = bool((policy.get("auto_post") or {}).get("treasury_transfer", True))
|
|
if auto:
|
|
guard = SourcePostingGuard(db)
|
|
await guard.assert_can_post(
|
|
tenant_id,
|
|
source_module="treasury",
|
|
source_document_type="transfer",
|
|
source_document_id=str(entity.id),
|
|
)
|
|
svc = TreasuryService(db)
|
|
voucher = await svc.post_gl_transfer(
|
|
tenant_id,
|
|
amount=body.amount,
|
|
from_account_id=from_gl,
|
|
to_account_id=to_gl,
|
|
actor_user_id=user.user_id,
|
|
description=body.description or "انتقال بین حسابها",
|
|
transaction_date=body.transfer_date,
|
|
reference=str(entity.id),
|
|
)
|
|
entity.voucher_id = voucher.id
|
|
voucher_id = str(voucher.id)
|
|
voucher_number = voucher.voucher_number
|
|
await guard.mark_posted(
|
|
tenant_id,
|
|
voucher_id=voucher.id,
|
|
source_module="treasury",
|
|
source_document_type="transfer",
|
|
source_document_id=str(entity.id),
|
|
)
|
|
# Update cash balances when sides are cash boxes
|
|
if body.from_type == "cash":
|
|
box = await CashBoxRepo(db).get(tenant_id, body.from_id)
|
|
if box is not None:
|
|
box.current_balance -= body.amount
|
|
if body.to_type == "cash":
|
|
box = await CashBoxRepo(db).get(tenant_id, body.to_id)
|
|
if box is not None:
|
|
box.current_balance += body.amount
|
|
|
|
await db.commit()
|
|
return {
|
|
"id": str(entity.id),
|
|
"voucher_id": voucher_id,
|
|
"voucher_number": voucher_number,
|
|
}
|
|
|
|
|
|
@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,
|
|
"voucher_id": str(r.voucher_id) if r.voucher_id else None,
|
|
}
|
|
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
|
|
from app.services.posting_policy_service import PostingPolicyService
|
|
from app.services.source_posting_guard import SourcePostingGuard
|
|
from shared.exceptions import AppError
|
|
|
|
data = body.model_dump()
|
|
counter_account_id = data.pop("counter_account_id", None)
|
|
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)
|
|
|
|
policy = await PostingPolicyService(db).get_policy(tenant_id)
|
|
voucher_id = None
|
|
voucher_number = None
|
|
if policy.get("auto_post", {}).get("treasury_receipt", True):
|
|
if counter_account_id is None:
|
|
raise AppError(
|
|
"برای ثبت خودکار سند خزانه، حساب مقابل (طرف بستانکار) الزامی است",
|
|
status_code=422,
|
|
error_code="counter_account_required",
|
|
)
|
|
treasury_account_id = await _resolve_treasury_gl_account(
|
|
db, tenant_id, body.payment_method, body.cash_box_id, body.bank_account_id
|
|
)
|
|
guard = SourcePostingGuard(db)
|
|
await guard.assert_can_post(
|
|
tenant_id,
|
|
source_module="treasury",
|
|
source_document_type="receipt",
|
|
source_document_id=str(entity.id),
|
|
)
|
|
svc = TreasuryService(db)
|
|
voucher = await svc.post_receipt_payment_voucher(
|
|
tenant_id,
|
|
amount=body.amount,
|
|
treasury_account_id=treasury_account_id,
|
|
counter_account_id=counter_account_id,
|
|
is_receipt=True,
|
|
actor_user_id=user.user_id,
|
|
description=body.description or f"دریافت {entity.receipt_number}",
|
|
transaction_date=body.receipt_date,
|
|
reference=entity.receipt_number,
|
|
cash_box_id=body.cash_box_id,
|
|
)
|
|
entity.voucher_id = voucher.id
|
|
voucher_id = str(voucher.id)
|
|
voucher_number = voucher.voucher_number
|
|
await guard.mark_posted(
|
|
tenant_id,
|
|
voucher_id=voucher.id,
|
|
source_module="treasury",
|
|
source_document_type="receipt",
|
|
source_document_id=str(entity.id),
|
|
)
|
|
|
|
await db.commit()
|
|
return {
|
|
"id": str(entity.id),
|
|
"receipt_number": entity.receipt_number,
|
|
"voucher_id": voucher_id,
|
|
"voucher_number": voucher_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,
|
|
"voucher_id": str(p.voucher_id) if p.voucher_id else None,
|
|
}
|
|
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
|
|
from app.services.posting_policy_service import PostingPolicyService
|
|
from app.services.source_posting_guard import SourcePostingGuard
|
|
from shared.exceptions import AppError
|
|
|
|
data = body.model_dump()
|
|
counter_account_id = data.pop("counter_account_id", None)
|
|
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)
|
|
|
|
policy = await PostingPolicyService(db).get_policy(tenant_id)
|
|
voucher_id = None
|
|
voucher_number = None
|
|
if policy.get("auto_post", {}).get("treasury_payment", True):
|
|
if counter_account_id is None:
|
|
raise AppError(
|
|
"برای ثبت خودکار سند خزانه، حساب مقابل (طرف بدهکار) الزامی است",
|
|
status_code=422,
|
|
error_code="counter_account_required",
|
|
)
|
|
treasury_account_id = await _resolve_treasury_gl_account(
|
|
db, tenant_id, body.payment_method, body.cash_box_id, body.bank_account_id
|
|
)
|
|
guard = SourcePostingGuard(db)
|
|
await guard.assert_can_post(
|
|
tenant_id,
|
|
source_module="treasury",
|
|
source_document_type="payment",
|
|
source_document_id=str(entity.id),
|
|
)
|
|
svc = TreasuryService(db)
|
|
voucher = await svc.post_receipt_payment_voucher(
|
|
tenant_id,
|
|
amount=body.amount,
|
|
treasury_account_id=treasury_account_id,
|
|
counter_account_id=counter_account_id,
|
|
is_receipt=False,
|
|
actor_user_id=user.user_id,
|
|
description=body.description or f"پرداخت {entity.payment_number}",
|
|
transaction_date=body.payment_date,
|
|
reference=entity.payment_number,
|
|
cash_box_id=body.cash_box_id,
|
|
)
|
|
entity.voucher_id = voucher.id
|
|
voucher_id = str(voucher.id)
|
|
voucher_number = voucher.voucher_number
|
|
await guard.mark_posted(
|
|
tenant_id,
|
|
voucher_id=voucher.id,
|
|
source_module="treasury",
|
|
source_document_type="payment",
|
|
source_document_id=str(entity.id),
|
|
)
|
|
|
|
await db.commit()
|
|
return {
|
|
"id": str(entity.id),
|
|
"payment_number": entity.payment_number,
|
|
"voucher_id": voucher_id,
|
|
"voucher_number": voucher_number,
|
|
}
|
|
|
|
|
|
async def _resolve_treasury_gl_account(
|
|
db: AsyncSession,
|
|
tenant_id: UUID,
|
|
payment_method: str,
|
|
cash_box_id: UUID | None,
|
|
bank_account_id: UUID | None,
|
|
) -> UUID:
|
|
from shared.exceptions import AppError
|
|
|
|
if payment_method == "cash" or cash_box_id:
|
|
if cash_box_id is None:
|
|
raise AppError(
|
|
"برای دریافت/پرداخت نقدی انتخاب صندوق الزامی است",
|
|
status_code=422,
|
|
error_code="cash_box_required",
|
|
)
|
|
box = await CashBoxRepo(db).get(tenant_id, cash_box_id)
|
|
if box is None or box.account_id is None:
|
|
raise AppError(
|
|
"صندوق باید به حساب دفتر کل متصل باشد",
|
|
status_code=422,
|
|
error_code="cash_box_account_missing",
|
|
)
|
|
return box.account_id
|
|
|
|
if bank_account_id is None:
|
|
raise AppError(
|
|
"برای حواله بانکی انتخاب حساب بانکی الزامی است",
|
|
status_code=422,
|
|
error_code="bank_account_required",
|
|
)
|
|
bank = await BankAccountRepo(db).get(tenant_id, bank_account_id)
|
|
if bank is None or bank.account_id is None:
|
|
raise AppError(
|
|
"حساب بانکی باید به حساب دفتر کل متصل باشد",
|
|
status_code=422,
|
|
error_code="bank_account_gl_missing",
|
|
)
|
|
return bank.account_id
|
|
|
|
|
|
@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)}
|