import uuid from datetime import datetime, timezone from decimal import Decimal from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.models.catalog import Brand, Material, Unit from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse from app.models.procurement import Project from app.models.project_transfer import ProjectTransfer from app.schemas.project_transfers import ( ProjectTransferCreate, ProjectTransferReceive, ProjectTransferUpdate, ) from app.services.inventory_service import ( _find_inventory, _get_or_create_inventory, canonical_specs_hash, ) from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError async def get_project_transfer_or_404(db: AsyncSession, transfer_id: uuid.UUID) -> ProjectTransfer: stmt = ( select(ProjectTransfer) .where(ProjectTransfer.id == transfer_id) .options( selectinload(ProjectTransfer.from_project), selectinload(ProjectTransfer.to_project), selectinload(ProjectTransfer.from_warehouse), selectinload(ProjectTransfer.to_warehouse), selectinload(ProjectTransfer.material), selectinload(ProjectTransfer.brand), selectinload(ProjectTransfer.unit), ) ) transfer = await db.scalar(stmt) if transfer is None: raise NotFoundError("رکورد انتقال بین کارگاهی یافت نشد", field="transfer_id") return transfer async def create_project_transfer( db: AsyncSession, payload: ProjectTransferCreate, caller_project_id: uuid.UUID | None, caller_role: str, transferred_by: str | None = None, ) -> ProjectTransfer: tx = db.begin_nested() if db.in_transaction() else db.begin() async with tx: # Validate source warehouse from_wh = await db.get(Warehouse, payload.from_warehouse_id) if from_wh is None: raise NotFoundError("انبار مبدأ یافت نشد", field="from_warehouse_id") # RBAC: PM can only send from their own project if caller_role == "pm" and caller_project_id is not None: if from_wh.project_id != caller_project_id: raise ForbiddenError("شما فقط مجاز به انتقال از انبار پروژه خود هستید") from_proj = await db.get(Project, from_wh.project_id) to_proj = await db.get(Project, payload.to_project_id) if to_proj is None: raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id") if from_wh.project_id == payload.to_project_id: raise ValidationError( "پروژه مقصد نمی‌تواند همان پروژه مبدأ باشد (از انتقال بین‌انباری استفاده کنید)", field="to_project_id", ) # Validate material, brand, unit mat = await db.get(Material, payload.material_id) if mat is None: raise NotFoundError("ماده اولیه یافت نشد", field="material_id") if payload.brand_id is not None: brand = await db.get(Brand, payload.brand_id) if brand is None: raise NotFoundError("برند یافت نشد", field="brand_id") unit = await db.get(Unit, payload.unit_id) if unit is None: raise NotFoundError("واحد یافت نشد", field="unit_id") # Requirement 2: Canonical specs hash from inventory_service specs = payload.specs or {} specs_hash = canonical_specs_hash(specs) # Check stock in source warehouse with SELECT ... FOR UPDATE source_item = await _find_inventory( db, payload.from_warehouse_id, payload.material_id, payload.brand_id, specs_hash, for_update=True, ) # Smart fallback: If exact hash or brand didn't match, check candidate rows in this warehouse for this material if source_item is None: candidates_res = await db.execute( select(InventoryItem) .where( InventoryItem.warehouse_id == payload.from_warehouse_id, InventoryItem.material_id == payload.material_id, ) .with_for_update() ) candidates = candidates_res.scalars().all() if len(candidates) == 1: source_item = candidates[0] elif len(candidates) > 1: best_cand = None best_score = -1 p_keys = set(specs.keys()) p_vals = set(str(v).strip() for v in specs.values() if str(v).strip()) for cand in candidates: score = 0 if cand.brand_id == payload.brand_id: score += 10 elif cand.brand_id is None and payload.brand_id is None: score += 10 elif cand.brand_id is None: score += 4 c_specs = cand.specs or {} c_keys = set(c_specs.keys()) c_vals = set(str(v).strip() for v in c_specs.values() if str(v).strip()) for pk in p_keys: if pk in c_keys or f"spec_{pk}" in c_keys or any(pk in ck for ck in c_keys): score += 5 for pv in p_vals: if pv in c_vals or any(pv in str(cv) for cv in c_vals): score += 5 if cand.quantity >= payload.quantity: score += 2 if score > best_score: best_score = score best_cand = cand source_item = best_cand if source_item is None or source_item.quantity < payload.quantity: avail = source_item.quantity if source_item else Decimal("0") raise ValidationError( f"موجودی کافی در انبار مبدأ وجود ندارد (موجودی فعلی: {avail})", field="quantity", ) # Deduct from source warehouse immediately source_item.quantity -= payload.quantity transfer_pair_id = uuid.uuid4() now_dt = payload.transfer_date or datetime.now(timezone.utc) note_text = f"انتقال بین کارگاهی به پروژه {to_proj.name}" if payload.note: note_text += f" — {payload.note}" out_tx = InventoryTransaction( inventory_id=source_item.id, warehouse_id=from_wh.id, transaction_type="TRANSFER_OUT", quantity=-payload.quantity, transfer_pair_id=transfer_pair_id, performed_by=transferred_by, transaction_date=now_dt, note=note_text, ) db.add(out_tx) await db.flush() actual_brand_id = source_item.brand_id actual_specs = source_item.specs if source_item.specs is not None else specs actual_specs_hash = source_item.specs_hash or canonical_specs_hash(actual_specs) transfer = ProjectTransfer( from_project_id=from_wh.project_id, from_warehouse_id=from_wh.id, to_project_id=payload.to_project_id, material_id=payload.material_id, brand_id=actual_brand_id, unit_id=source_item.unit_id if source_item.unit_id else payload.unit_id, quantity=payload.quantity, specs=actual_specs, specs_hash=actual_specs_hash, note=payload.note, status="in_transit", out_transaction_id=out_tx.id, transfer_date=now_dt, transferred_by=transferred_by, ) db.add(transfer) await db.flush() return await get_project_transfer_or_404(db, transfer.id) async def update_project_transfer( db: AsyncSession, transfer_id: uuid.UUID, payload: ProjectTransferUpdate, caller_project_id: uuid.UUID | None, caller_role: str, ) -> ProjectTransfer: tx = db.begin_nested() if db.in_transaction() else db.begin() async with tx: transfer = await db.scalar( select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update() ) if transfer is None: raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id") # RBAC (Requirement 4) if caller_role == "pm" and caller_project_id is not None: if transfer.from_project_id != caller_project_id: raise ForbiddenError("شما فقط مجاز به ویرایش انتقال‌های پروژه مبدأ خود هستید") # Status Lock Guard (Requirement 4) if transfer.status == "received": raise ConflictError( "این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان ویرایش آن وجود ندارد" ) if transfer.status != "in_transit": raise ConflictError("فقط انتقال‌های در راه (in_transit) قابل ویرایش هستند") if payload.to_project_id is not None and payload.to_project_id != transfer.to_project_id: new_to_proj = await db.get(Project, payload.to_project_id) if new_to_proj is None: raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id") if payload.to_project_id == transfer.from_project_id: raise ValidationError("پروژه مقصد نمی‌تواند همان پروژه مبدأ باشد", field="to_project_id") transfer.to_project_id = payload.to_project_id # Quantity adjustment (reconciles source stock difference) if payload.quantity is not None and payload.quantity != transfer.quantity: delta = payload.quantity - transfer.quantity source_item = await _find_inventory( db, transfer.from_warehouse_id, transfer.material_id, transfer.brand_id, transfer.specs_hash, for_update=True, ) if delta > 0: if source_item is None or source_item.quantity < delta: avail = source_item.quantity if source_item else Decimal("0") raise ValidationError( f"موجودی انبار مبدأ برای افزایش انتقال کافی نیست (موجود: {avail})", field="quantity", ) source_item.quantity -= delta else: if source_item is not None: source_item.quantity += abs(delta) transfer.quantity = payload.quantity # Update out_transaction if transfer.out_transaction_id is not None: out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id) if out_tx is not None: out_tx.quantity = -payload.quantity if payload.note is not None: transfer.note = payload.note if payload.transfer_date is not None: transfer.transfer_date = payload.transfer_date if transfer.out_transaction_id is not None: out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id) if out_tx is not None: out_tx.transaction_date = payload.transfer_date return await get_project_transfer_or_404(db, transfer_id) async def delete_project_transfer( db: AsyncSession, transfer_id: uuid.UUID, caller_project_id: uuid.UUID | None, caller_role: str, ) -> dict: tx = db.begin_nested() if db.in_transaction() else db.begin() async with tx: transfer = await db.scalar( select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update() ) if transfer is None: raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id") # RBAC (Requirement 4) if caller_role == "pm" and caller_project_id is not None: if transfer.from_project_id != caller_project_id: raise ForbiddenError("شما فقط مجاز به لغو انتقال‌های پروژه مبدأ خود هستید") # Status Lock Guard (Requirement 4) if transfer.status == "received": raise ConflictError( "این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان لغو یا حذف آن وجود ندارد" ) # Reversibility (Requirement 4): Restore stock to source warehouse source_item = await _find_inventory( db, transfer.from_warehouse_id, transfer.material_id, transfer.brand_id, transfer.specs_hash, for_update=True, ) if source_item is not None: source_item.quantity += transfer.quantity # Delete out_transaction if transfer.out_transaction_id is not None: out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id) if out_tx is not None: await db.delete(out_tx) await db.delete(transfer) return {"ok": True, "message": "انتقال با موفقیت لغو شد و موجودی به انبار مبدأ بازگردانده شد"} async def receive_project_transfer( db: AsyncSession, transfer_id: uuid.UUID, payload: ProjectTransferReceive, caller_project_id: uuid.UUID | None, caller_role: str, received_by: str | None = None, ) -> ProjectTransfer: tx = db.begin_nested() if db.in_transaction() else db.begin() async with tx: transfer = await db.scalar( select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update() ) if transfer is None: raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id") # RBAC (Requirement 4) if caller_role == "pm" and caller_project_id is not None: if transfer.to_project_id != caller_project_id: raise ForbiddenError("شما فقط مجاز به تأیید تحویل در پروژه مقصد خود هستید") # Lock Guard if transfer.status == "received": raise ConflictError("این انتقال قبلاً تحویل گرفته و ثبت انبار شده است") if transfer.status != "in_transit": raise ConflictError("این انتقال در وضعیت در راه نیست") # Requirement 1: 100% full delivery logic (no partial receipt) alloc_sum = sum(Decimal(str(a.quantity)) for a in payload.allocations) if alloc_sum != transfer.quantity: raise ValidationError( f"تخصیص انبارها باید دقیقاً برابر کل مقدار ارسالی ({transfer.quantity}) باشد. مجموع واردشده: {alloc_sum}", field="allocations", ) # Validate destination warehouses belong to destination project wh_ids = [a.warehouse_id for a in payload.allocations] wh_rows = (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars().all() wh_map = {w.id: w for w in wh_rows} for a in payload.allocations: wh = wh_map.get(a.warehouse_id) if wh is None or wh.project_id != transfer.to_project_id: raise ValidationError( "انبار انتخاب‌شده معتبر نیست یا متعلق به پروژه مقصد نمی‌باشد", field="allocations.warehouse_id", ) from_proj = await db.get(Project, transfer.from_project_id) from_proj_name = from_proj.name if from_proj else "پروژه مبدأ" out_tx = ( await db.get(InventoryTransaction, transfer.out_transaction_id) if transfer.out_transaction_id else None ) pair_id = out_tx.transfer_pair_id if (out_tx and out_tx.transfer_pair_id) else uuid.uuid4() now_dt = payload.received_at or datetime.now(timezone.utc) # Add stock to destination warehouse(s) via TRANSFER_IN for a in payload.allocations: dest_item = await _get_or_create_inventory( db, a.warehouse_id, transfer.material_id, transfer.brand_id, transfer.unit_id, transfer.specs, transfer.specs_hash, ) dest_item.quantity += a.quantity note_text = f"رسید انتقال بین کارگاهی از {from_proj_name}" if payload.note: note_text += f" — {payload.note}" in_tx = InventoryTransaction( inventory_id=dest_item.id, warehouse_id=a.warehouse_id, transaction_type="TRANSFER_IN", quantity=a.quantity, transfer_pair_id=pair_id, performed_by=received_by, transaction_date=now_dt, note=note_text, ) db.add(in_tx) transfer.status = "received" transfer.received_at = now_dt transfer.received_by = received_by transfer.to_warehouse_id = payload.allocations[0].warehouse_id transfer.receipt_allocations = [ {"warehouse_id": str(a.warehouse_id), "quantity": str(a.quantity)} for a in payload.allocations ] return await get_project_transfer_or_404(db, transfer_id) async def list_project_transfers( db: AsyncSession, project_id: uuid.UUID | None = None, direction: str = "all", # 'outgoing' | 'incoming' | 'all' status: str | None = None, ) -> list[ProjectTransfer]: stmt = ( select(ProjectTransfer) .options( selectinload(ProjectTransfer.from_project), selectinload(ProjectTransfer.to_project), selectinload(ProjectTransfer.from_warehouse), selectinload(ProjectTransfer.to_warehouse), selectinload(ProjectTransfer.material), selectinload(ProjectTransfer.brand), selectinload(ProjectTransfer.unit), ) ) if project_id is not None: if direction == "outgoing": stmt = stmt.where(ProjectTransfer.from_project_id == project_id) elif direction == "incoming": stmt = stmt.where(ProjectTransfer.to_project_id == project_id) else: stmt = stmt.where( (ProjectTransfer.from_project_id == project_id) | (ProjectTransfer.to_project_id == project_id) ) if status: stmt = stmt.where(ProjectTransfer.status == status) stmt = stmt.order_by(ProjectTransfer.transfer_date.desc()) res = await db.execute(stmt) return list(res.scalars().all())