merge: integrate prod-v8 (inter-project transfers + multi-project allocation)
Conflict resolved in ChangeInventoryPage.tsx (canSubmit): kept both the WITHDRAWAL branch (prod-v9: delivered-to validation) and the PROJECT_TRANSFER branch (prod-v8: inter-project transfer validation). All other files auto-merged; both feature sets fully preserved.
This commit is contained in:
@@ -14,6 +14,7 @@ from app.routers import (
|
||||
material_categories,
|
||||
materials,
|
||||
pm,
|
||||
project_transfers,
|
||||
projects,
|
||||
quote_events,
|
||||
quotes,
|
||||
@@ -50,5 +51,6 @@ ROUTERS = [
|
||||
standard_conversions.router,
|
||||
inventory.router,
|
||||
pm.router,
|
||||
project_transfers.router,
|
||||
users.router,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.routers.pm import ArrivalScope, arrivals_scope
|
||||
from app.schemas.project_transfers import (
|
||||
ProjectTransferCreate,
|
||||
ProjectTransferOut,
|
||||
ProjectTransferReceive,
|
||||
ProjectTransferUpdate,
|
||||
)
|
||||
from app.services import project_transfer_service
|
||||
from app.utils.errors import ForbiddenError
|
||||
|
||||
router = APIRouter(tags=["project-transfers"])
|
||||
|
||||
|
||||
def _to_transfer_out(t) -> dict:
|
||||
return {
|
||||
"id": t.id,
|
||||
"from_project": (
|
||||
{"id": t.from_project.id, "name": t.from_project.name}
|
||||
if t.from_project
|
||||
else {"id": t.from_project_id, "name": ""}
|
||||
),
|
||||
"from_warehouse": (
|
||||
{"id": t.from_warehouse.id, "name": t.from_warehouse.name}
|
||||
if t.from_warehouse
|
||||
else {"id": t.from_warehouse_id, "name": ""}
|
||||
),
|
||||
"to_project": (
|
||||
{"id": t.to_project.id, "name": t.to_project.name}
|
||||
if t.to_project
|
||||
else {"id": t.to_project_id, "name": ""}
|
||||
),
|
||||
"to_warehouse": (
|
||||
{"id": t.to_warehouse.id, "name": t.to_warehouse.name}
|
||||
if t.to_warehouse
|
||||
else None
|
||||
),
|
||||
"material": (
|
||||
{"id": t.material.id, "name": t.material.name}
|
||||
if t.material
|
||||
else {"id": t.material_id, "name": ""}
|
||||
),
|
||||
"brand": (
|
||||
{"id": t.brand.id, "name": t.brand.name}
|
||||
if t.brand
|
||||
else None
|
||||
),
|
||||
"unit": (
|
||||
{"id": t.unit.id, "code": t.unit.code, "name_fa": t.unit.name_fa}
|
||||
if t.unit
|
||||
else {"id": t.unit_id, "code": "", "name_fa": None}
|
||||
),
|
||||
"quantity": t.quantity,
|
||||
"specs": t.specs or {},
|
||||
"specs_hash": t.specs_hash,
|
||||
"note": t.note,
|
||||
"status": t.status,
|
||||
"transfer_date": t.transfer_date,
|
||||
"transferred_by": t.transferred_by,
|
||||
"received_at": t.received_at,
|
||||
"received_by": t.received_by,
|
||||
"receipt_allocations": t.receipt_allocations,
|
||||
"created_at": t.created_at,
|
||||
"updated_at": t.updated_at,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /inventory/project-transfers — Create inter-project transfer
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/inventory/project-transfers", status_code=201)
|
||||
async def create_transfer(
|
||||
payload: ProjectTransferCreate,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
transfer = await project_transfer_service.create_project_transfer(
|
||||
db=db,
|
||||
payload=payload,
|
||||
caller_project_id=ctx.project_id,
|
||||
caller_role=ctx.role,
|
||||
transferred_by=ctx.display_name,
|
||||
)
|
||||
return {"data": _to_transfer_out(transfer)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /inventory/project-transfers — List transfers with direction and filters
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/inventory/project-transfers")
|
||||
async def list_transfers(
|
||||
project_id: uuid.UUID | None = Query(default=None),
|
||||
direction: Literal["outgoing", "incoming", "all"] = Query(default="all"),
|
||||
status: str | None = Query(default=None),
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
target_pid = project_id or ctx.project_id
|
||||
transfers = await project_transfer_service.list_project_transfers(
|
||||
db=db,
|
||||
project_id=target_pid,
|
||||
direction=direction,
|
||||
status=status,
|
||||
)
|
||||
return {"data": [_to_transfer_out(t) for t in transfers]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /inventory/project-transfers/{id} — Single transfer details
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/inventory/project-transfers/{transfer_id}")
|
||||
async def get_transfer(
|
||||
transfer_id: uuid.UUID,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
transfer = await project_transfer_service.get_project_transfer_or_404(db, transfer_id)
|
||||
if ctx.role == "pm" and ctx.project_id is not None:
|
||||
if transfer.from_project_id != ctx.project_id and transfer.to_project_id != ctx.project_id:
|
||||
raise ForbiddenError("شما به این رکورد انتقال دسترسی ندارید")
|
||||
return {"data": _to_transfer_out(transfer)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /inventory/project-transfers/{id} — Edit in-transit transfer (source PM only)
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.patch("/inventory/project-transfers/{transfer_id}")
|
||||
async def update_transfer(
|
||||
transfer_id: uuid.UUID,
|
||||
payload: ProjectTransferUpdate,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
transfer = await project_transfer_service.update_project_transfer(
|
||||
db=db,
|
||||
transfer_id=transfer_id,
|
||||
payload=payload,
|
||||
caller_project_id=ctx.project_id,
|
||||
caller_role=ctx.role,
|
||||
)
|
||||
return {"data": _to_transfer_out(transfer)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /inventory/project-transfers/{id} — Cancel in-transit transfer (source PM only)
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.delete("/inventory/project-transfers/{transfer_id}")
|
||||
async def delete_transfer(
|
||||
transfer_id: uuid.UUID,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
res = await project_transfer_service.delete_project_transfer(
|
||||
db=db,
|
||||
transfer_id=transfer_id,
|
||||
caller_project_id=ctx.project_id,
|
||||
caller_role=ctx.role,
|
||||
)
|
||||
return {"data": res}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /pm/transfers/on-the-way — Incoming in-transit transfers for active project
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.get("/pm/transfers/on-the-way")
|
||||
async def pm_incoming_transfers(
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if ctx.role == "pm" and ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید")
|
||||
|
||||
transfers = await project_transfer_service.list_project_transfers(
|
||||
db=db,
|
||||
project_id=ctx.project_id,
|
||||
direction="incoming",
|
||||
status="in_transit",
|
||||
)
|
||||
return {"data": [_to_transfer_out(t) for t in transfers]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /inventory/project-transfers/{id}/receive — Destination PM confirms arrival
|
||||
# ---------------------------------------------------------------------------
|
||||
@router.post("/inventory/project-transfers/{transfer_id}/receive")
|
||||
async def receive_transfer(
|
||||
transfer_id: uuid.UUID,
|
||||
payload: ProjectTransferReceive,
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
transfer = await project_transfer_service.receive_project_transfer(
|
||||
db=db,
|
||||
transfer_id=transfer_id,
|
||||
payload=payload,
|
||||
caller_project_id=ctx.project_id,
|
||||
caller_role=ctx.role,
|
||||
received_by=ctx.display_name,
|
||||
)
|
||||
return {"data": _to_transfer_out(transfer)}
|
||||
Reference in New Issue
Block a user