Enable tenant-controlled auto-post for sales/purchase/treasury via Posting Engine, block unbalanced voucher lines on FE and BE, and expose international posting controls in settings. Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
11 KiB
Python
337 lines
11 KiB
Python
"""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,
|
|
)
|
|
voucher_info = {
|
|
"voucher_id": str(voucher.id),
|
|
"voucher_number": voucher.voucher_number,
|
|
}
|
|
elif entity.module == "purchase" and entity.doc_type == "return":
|
|
voucher = await PurchaseInventoryAccountingService(db).post_purchase_return(
|
|
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,
|
|
}
|
|
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
|