"""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