- Add ProjectTransfer database model and migration - Implement project transfer service with immediate stock deduction on creation - Support 100% full delivery receipt logic across destination warehouses - Enforce canonical_specs_hash matching for cross-project stock merge - Add RBAC guards and status lock on received transfers (409 Conflict) - Register API router for transfers with on-the-way integration
418 lines
17 KiB
Python
418 lines
17 KiB
Python
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,
|
|
)
|
|
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()
|
|
|
|
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=payload.brand_id,
|
|
unit_id=payload.unit_id,
|
|
quantity=payload.quantity,
|
|
specs=specs,
|
|
specs_hash=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())
|