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>
365 lines
13 KiB
Python
365 lines
13 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,
|
||
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
|