TorbatYar/backend/services/accounting/app/api/v1/operational.py
Mortezakoohjani 97a363fc53 Wire purchase returns, inventory issues, and GL transfers with auto vouchers.
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>
2026-07-26 19:51:32 +03:30

365 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Operational documents + inventory masters API."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from pydantic import BaseModel, Field
from sqlalchemy import select
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.operational import BusinessDocument, InventoryItem, Warehouse
from app.repositories.base import TenantBaseRepository
from shared.exceptions import NotFoundError
from shared.security import CurrentUser
router = APIRouter()
class BizDocCreate(BaseModel):
module: str = Field(max_length=40)
doc_type: str = Field(max_length=50)
number: str | None = Field(default=None, max_length=50)
doc_date: date
party_name: str | None = None
party_id: UUID | None = None
amount: Decimal = Decimal("0")
status: str = "draft"
description: str | None = None
meta_json: str | None = None
class BizDocUpdate(BaseModel):
party_name: str | None = None
party_id: UUID | None = None
amount: Decimal | None = None
status: str | None = None
description: str | None = None
doc_date: date | None = None
meta_json: str | None = None
class BizDocRead(BaseModel):
id: UUID
tenant_id: UUID
module: str
doc_type: str
number: str
doc_date: date
party_name: str | None
party_id: UUID | None = None
amount: Decimal
status: str
description: str | None
meta_json: str | None = None
model_config = {"from_attributes": True}
class ItemCreate(BaseModel):
code: str
name: str
item_type: str = "goods"
unit: str = "عدد"
quantity_on_hand: Decimal = Decimal("0")
unit_cost: Decimal = Decimal("0")
class ItemRead(BaseModel):
id: UUID
code: str
name: str
item_type: str
unit: str
quantity_on_hand: Decimal
unit_cost: Decimal
is_active: bool
model_config = {"from_attributes": True}
class WarehouseCreate(BaseModel):
code: str
name: str
class WarehouseRead(BaseModel):
id: UUID
code: str
name: str
is_active: bool
model_config = {"from_attributes": True}
class BizDocRepo(TenantBaseRepository[BusinessDocument]):
model = BusinessDocument
class ItemRepo(TenantBaseRepository[InventoryItem]):
model = InventoryItem
class WarehouseRepo(TenantBaseRepository[Warehouse]):
model = Warehouse
@router.get("/documents", response_model=list[BizDocRead])
async def list_documents(
module: str | None = Query(default=None),
doc_type: str | None = Query(default=None),
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
stmt = select(BusinessDocument).where(BusinessDocument.tenant_id == tenant_id)
if module:
stmt = stmt.where(BusinessDocument.module == module)
if doc_type:
stmt = stmt.where(BusinessDocument.doc_type == doc_type)
stmt = stmt.order_by(BusinessDocument.doc_date.desc()).limit(200)
result = await db.execute(stmt)
return list(result.scalars().all())
@router.post("/documents", response_model=BizDocRead, status_code=status.HTTP_201_CREATED)
async def create_document(
body: BizDocCreate,
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, ops_document_type
data = body.model_dump()
svc = DocumentNumberService(db)
data["number"] = await svc.allocate_if_blank(
tenant_id, ops_document_type(body.module, body.doc_type), body.number
)
entity = BusinessDocument(tenant_id=tenant_id, **data)
await BizDocRepo(db).add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.patch("/documents/{doc_id}", response_model=BizDocRead)
async def update_document(
doc_id: UUID,
body: BizDocUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
repo = BizDocRepo(db)
entity = await repo.get(tenant_id, doc_id)
if entity is None:
raise NotFoundError("سند یافت نشد", error_code="document_not_found")
for k, v in body.model_dump(exclude_unset=True).items():
setattr(entity, k, v)
await db.commit()
await db.refresh(entity)
return entity
@router.post("/documents/{doc_id}/confirm", response_model=BizDocRead)
async def confirm_document(
doc_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
"""Confirm ops document; when posting-policy auto_post is on, also post to GL."""
from decimal import Decimal
from app.services.posting_policy_service import PostingPolicyService
from app.services.purchase_inventory_service import PurchaseInventoryAccountingService
from app.services.sales_accounting_service import SalesAccountingService
from app.models.types import SalesDocumentType
from shared.exceptions import AppError
entity = await BizDocRepo(db).get(tenant_id, doc_id)
if entity is None:
raise NotFoundError("سند یافت نشد", error_code="document_not_found")
policy_svc = PostingPolicyService(db)
policy = await policy_svc.get_policy(tenant_id)
should_auto = policy_svc.is_auto_post_enabled(policy, entity.module, entity.doc_type)
voucher_info: dict | None = None
if should_auto:
amount = Decimal(str(entity.amount or 0))
if amount <= 0:
raise AppError(
"برای ثبت خودکار حسابداری مبلغ سند باید بزرگ‌تر از صفر باشد",
status_code=422,
error_code="auto_post_amount_required",
)
try:
if entity.module == "sales" and entity.doc_type in ("invoice", "return"):
doc_type = (
SalesDocumentType.RETURN
if entity.doc_type == "return"
else SalesDocumentType.INVOICE
)
voucher = await SalesAccountingService(db).post_sales_invoice(
tenant_id,
document_type=doc_type,
source_document_id=str(entity.id),
total_amount=amount,
actor_user_id=user.user_id,
)
voucher_info = {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
}
elif entity.module == "purchase" and entity.doc_type in (
"goods_receipt",
"goods-receipts",
):
voucher = await PurchaseInventoryAccountingService(db).post_goods_receipt(
tenant_id,
source_document_id=str(entity.id),
amount=amount,
actor_user_id=user.user_id,
)
voucher_info = {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
}
elif entity.module == "purchase" and entity.doc_type == "invoice":
voucher = await PurchaseInventoryAccountingService(db).post_purchase_invoice(
tenant_id,
source_document_id=str(entity.id),
amount=amount,
actor_user_id=user.user_id,
party_id=entity.party_id,
party_name=entity.party_name,
)
voucher_info = {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
}
elif entity.module == "purchase" and entity.doc_type == "return":
import json as _json
meta: dict = {}
if entity.meta_json:
try:
meta = _json.loads(entity.meta_json)
except Exception:
meta = {}
lines = meta.get("lines") or []
lines_summary = None
if isinstance(lines, list) and lines:
bits = []
for ln in lines[:5]:
if not isinstance(ln, dict):
continue
title = str(ln.get("title") or "")
qty = str(ln.get("qty") or "")
if title:
bits.append(f"{title}×{qty}" if qty else title)
if bits:
lines_summary = "، ".join(bits)
voucher = await PurchaseInventoryAccountingService(db).post_purchase_return(
tenant_id,
source_document_id=str(entity.id),
amount=amount,
actor_user_id=user.user_id,
source_invoice_id=str(meta.get("source_invoice_id") or "") or None,
source_invoice_number=str(meta.get("source_invoice_number") or "") or None,
party_id=entity.party_id,
party_name=entity.party_name or (str(meta.get("party_name") or "") or None),
lines_summary=lines_summary,
)
voucher_info = {
"voucher_id": str(voucher.id),
"voucher_number": voucher.voucher_number,
}
else:
# Policy on but no engine mapping — leave as confirmed only
voucher_info = None
except AppError:
raise
except Exception as exc: # noqa: BLE001
raise AppError(
f"ثبت خودکار حسابداری ناموفق بود: {exc}",
status_code=422,
error_code="auto_post_failed",
) from exc
entity.status = "posted" if voucher_info else "confirmed"
if voucher_info and entity.meta_json:
import json
try:
meta = json.loads(entity.meta_json)
except Exception:
meta = {}
meta["accounting_voucher"] = voucher_info
entity.meta_json = json.dumps(meta, ensure_ascii=False)
elif voucher_info:
import json
entity.meta_json = json.dumps({"accounting_voucher": voucher_info}, ensure_ascii=False)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/items", response_model=list[ItemRead])
async def list_items(
q: str | None = Query(default=None, description="Search code/name"),
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
rows = await ItemRepo(db).list_by_tenant(tenant_id, limit=200)
if q:
needle = q.strip().lower()
rows = [r for r in rows if needle in (r.code or "").lower() or needle in (r.name or "").lower()]
return rows
@router.post("/items", response_model=ItemRead, status_code=status.HTTP_201_CREATED)
async def create_item(
body: ItemCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = InventoryItem(tenant_id=tenant_id, **body.model_dump())
await ItemRepo(db).add(entity)
await db.commit()
await db.refresh(entity)
return entity
@router.get("/warehouses", response_model=list[WarehouseRead])
async def list_warehouses(
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await WarehouseRepo(db).list_by_tenant(tenant_id, limit=100)
@router.post("/warehouses", response_model=WarehouseRead, status_code=status.HTTP_201_CREATED)
async def create_warehouse(
body: WarehouseCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
entity = Warehouse(tenant_id=tenant_id, **body.model_dump())
await WarehouseRepo(db).add(entity)
await db.commit()
await db.refresh(entity)
return entity