feat(arrivals): in-transit goods dashboard - quote cards, per-item receive and warehouse allocation, days elapsed, admin access, received<=ordered limit
This commit is contained in:
+91
-70
@@ -1,15 +1,16 @@
|
||||
"""PM arrival flow (v2.6) — on-the-way list + confirm arrival + warehouse split.
|
||||
|
||||
Flow: proc manager purchases (item_decisions.status='chosen' + project_id)
|
||||
→ item appears on the PM dash as on-the-way → PM confirms each delivery
|
||||
→ item appears on the arrivals dash as on-the-way → the PM (or the admin /
|
||||
procurement manager, who is the same person) confirms each delivery
|
||||
(POST /pm/receipts) and splits it across warehouses (free count, typically
|
||||
3) → inventory posts PURCHASE transactions (idempotent via
|
||||
receipt_allocation_id UNIQUE) → the procurement dashboard flips to arrived
|
||||
once every chosen item is fully received.
|
||||
|
||||
Rules enforced here:
|
||||
- Only the PM of the item's purchased project can confirm (role 'pm' +
|
||||
projects.pm_user_id).
|
||||
- Callers: role 'pm' (scoped to their single project via projects.pm_user_id)
|
||||
or 'admin' / 'procurement_manager' (all projects — full visibility).
|
||||
- Partial deliveries allowed (1 item : N receipts); three derived states:
|
||||
on-the-way (sum=0) / partially received / arrived (sum >= ordered).
|
||||
- SUM(allocations) == received_qty; every warehouse belongs to the
|
||||
@@ -18,9 +19,9 @@ Rules enforced here:
|
||||
the canonical unit for auto-posted purchases.
|
||||
- quote.status flips to 'arrived' when ALL chosen items are fully received.
|
||||
|
||||
Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm +
|
||||
X-Project-Id) when no Authorization header — same pattern as the inventory
|
||||
router.
|
||||
Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm|admin +
|
||||
X-Project-Id for pm) when no Authorization header — same pattern as the
|
||||
inventory router.
|
||||
"""
|
||||
import base64
|
||||
import uuid
|
||||
@@ -42,6 +43,7 @@ from app.models.procurement import (
|
||||
LineItem,
|
||||
Project,
|
||||
Quote,
|
||||
Supplier,
|
||||
)
|
||||
from app.schemas.procurement import PMReceiptCreate
|
||||
from app.services.inventory_service import canonical_specs_hash
|
||||
@@ -54,33 +56,43 @@ NULL_BRAND_SENTINEL = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PMScope:
|
||||
class ArrivalScope:
|
||||
role: str # "pm" | "admin" (admin = admin or procurement_manager)
|
||||
user_id: uuid.UUID | None
|
||||
project_id: uuid.UUID | None
|
||||
project_id: uuid.UUID | None # PM's single project; None for admin
|
||||
display_name: str
|
||||
|
||||
|
||||
async def pm_scope(request: Request, db: AsyncSession = Depends(get_db)) -> PMScope:
|
||||
"""Resolve the PM caller — Casdoor JWT first, dev headers as fallback."""
|
||||
async def arrivals_scope(request: Request, db: AsyncSession = Depends(get_db)) -> ArrivalScope:
|
||||
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
|
||||
|
||||
PM → scoped to his own project. Admin / procurement manager → all projects.
|
||||
"""
|
||||
if request.headers.get("authorization"):
|
||||
ctx: UserContext | None = await get_optional_user(request, db)
|
||||
if ctx is not None:
|
||||
if ctx.role != "pm":
|
||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه است")
|
||||
if ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
return PMScope(user_id=ctx.user.id, project_id=ctx.project_id, display_name=ctx.display_name)
|
||||
# Dev fallback: X-User-Role: pm + X-Project-Id
|
||||
if ctx.role == "pm":
|
||||
if ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=ctx.project_id, display_name=ctx.display_name)
|
||||
if ctx.role in ("admin", "procurement_manager"):
|
||||
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=None, display_name=ctx.display_name)
|
||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
|
||||
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to admin)
|
||||
role = (request.headers.get("x-user-role") or "").strip().lower()
|
||||
if role != "pm":
|
||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه است — هدر X-User-Role: pm را بفرستید")
|
||||
raw_project = request.headers.get("x-project-id")
|
||||
if not raw_project:
|
||||
raise ForbiddenError("هدر X-Project-Id (پروژه مسئول) الزامی است")
|
||||
try:
|
||||
project_id = uuid.UUID(str(raw_project))
|
||||
except ValueError:
|
||||
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
|
||||
if role == "procurement_manager":
|
||||
role = "admin"
|
||||
if role not in ("pm", "admin"):
|
||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید")
|
||||
project_id = None
|
||||
if role == "pm":
|
||||
raw_project = request.headers.get("x-project-id")
|
||||
if not raw_project:
|
||||
raise ForbiddenError("هدر X-Project-Id (پروژه مسئول) الزامی است")
|
||||
try:
|
||||
project_id = uuid.UUID(str(raw_project))
|
||||
except ValueError:
|
||||
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
|
||||
actor = request.headers.get("x-user-name")
|
||||
b64 = request.headers.get("x-user-name-b64")
|
||||
if b64:
|
||||
@@ -95,7 +107,7 @@ async def pm_scope(request: Request, db: AsyncSession = Depends(get_db)) -> PMSc
|
||||
dev = User(casdoor_id="dev", first_name="کاربر", last_name="آزمایشی", role="pm")
|
||||
db.add(dev)
|
||||
await db.flush()
|
||||
return PMScope(user_id=dev.id, project_id=project_id, display_name=actor or "کاربر آزمایشی")
|
||||
return ArrivalScope(role=role, user_id=dev.id, project_id=project_id, display_name=actor or "کاربر آزمایشی")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -126,32 +138,30 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /pm/on-the-way — chosen items not fully received (PM's project)
|
||||
# GET /pm/on-the-way — chosen items not fully received
|
||||
# (PM: own project — Admin: all projects)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/on-the-way")
|
||||
async def pm_on_the_way(
|
||||
ctx: PMScope = Depends(pm_scope),
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
decisions = (
|
||||
(
|
||||
await db.execute(
|
||||
select(ItemDecision, LineItem, Quote, Material)
|
||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||
.join(Quote, Quote.id == LineItem.quote_id)
|
||||
.join(Material, Material.id == LineItem.material_id)
|
||||
.where(
|
||||
ItemDecision.status == "chosen",
|
||||
ItemDecision.project_id == ctx.project_id,
|
||||
)
|
||||
.order_by(ItemDecision.decided_at.desc().nulls_last())
|
||||
)
|
||||
)
|
||||
.all()
|
||||
stmt = (
|
||||
select(ItemDecision, LineItem, Quote, Material, Supplier, Project)
|
||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||
.join(Quote, Quote.id == LineItem.quote_id)
|
||||
.join(Material, Material.id == LineItem.material_id)
|
||||
.join(Supplier, Supplier.id == Quote.supplier_id)
|
||||
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
||||
.where(ItemDecision.status == "chosen")
|
||||
)
|
||||
if ctx.role == "pm":
|
||||
if ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
|
||||
|
||||
received_map = await _receipted_quantities(db, [d.ItemDecision.id for d in decisions])
|
||||
|
||||
# unit / category lookups for display
|
||||
@@ -170,13 +180,17 @@ async def pm_on_the_way(
|
||||
cats = {m_id: c for m_id, c in rows}
|
||||
|
||||
out = []
|
||||
for d, li, q, m in decisions:
|
||||
for d, li, q, m, sup, proj in decisions:
|
||||
received = received_map.get(d.id)
|
||||
out.append({
|
||||
"item_decision_id": str(d.id),
|
||||
"line_item_id": str(li.id),
|
||||
"quote_id": str(q.id),
|
||||
"quote_status": q.status,
|
||||
"quote_date": to_iso(q.quote_date),
|
||||
"supplier": {"id": str(sup.id), "name": sup.name},
|
||||
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||
"decided_at": to_iso(d.decided_at),
|
||||
"material": {"id": str(m.id), "name": m.name,
|
||||
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
|
||||
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
||||
@@ -190,29 +204,36 @@ async def pm_on_the_way(
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /pm/receipts — confirm arrival + split into warehouses + post PURCHASE
|
||||
# (PM: own project only — Admin: any project)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/receipts", status_code=201)
|
||||
async def pm_confirm_receipt(
|
||||
payload: PMReceiptCreate,
|
||||
ctx: PMScope = Depends(pm_scope),
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
|
||||
async with db.begin():
|
||||
# --- decision belongs to the PM's project ---
|
||||
# --- decision must be chosen ---
|
||||
decision = await db.get(ItemDecision, payload.item_decision_id)
|
||||
if decision is None or decision.status != "chosen":
|
||||
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
||||
if decision.project_id != ctx.project_id:
|
||||
if ctx.role == "pm" and decision.project_id != ctx.project_id:
|
||||
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
|
||||
|
||||
li = await db.get(LineItem, decision.line_item_id)
|
||||
quote = await db.get(Quote, li.quote_id)
|
||||
material = await db.get(Material, li.material_id)
|
||||
|
||||
# --- limit: total received (previous receipts + this one) must never
|
||||
# exceed the ordered quantity ---
|
||||
already_received = (await _receipted_quantities(db, [decision.id])).get(decision.id) or Decimal("0")
|
||||
if already_received + payload.received_qty > li.quantity:
|
||||
raise ValidationError(
|
||||
"تعداد کالای بیشتری نسبت به کالای رسیده انتخاب کردید",
|
||||
field="received_qty",
|
||||
)
|
||||
|
||||
# --- allocations sum + warehouse ownership ---
|
||||
total_alloc = sum((a.quantity for a in payload.allocations), Decimal("0"))
|
||||
if total_alloc != payload.received_qty:
|
||||
@@ -226,8 +247,8 @@ async def pm_confirm_receipt(
|
||||
wh = warehouses.get(a.warehouse_id)
|
||||
if wh is None:
|
||||
raise NotFoundError("انبار یافت نشد", field="allocations.warehouse_id")
|
||||
if wh.project_id != ctx.project_id:
|
||||
raise ForbiddenError("انبار انتخابشده متعلق به پروژه شما نیست", field="allocations.warehouse_id")
|
||||
if wh.project_id != decision.project_id:
|
||||
raise ForbiddenError("انبار انتخابشده متعلق به پروژه این قلم نیست", field="allocations.warehouse_id")
|
||||
|
||||
# --- create the receipt (1:N partial deliveries allowed) ---
|
||||
receipt = ItemReceipt(
|
||||
@@ -378,29 +399,27 @@ async def pm_confirm_receipt(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /pm/receipts — delivery history for the PM's project
|
||||
# GET /pm/receipts — delivery history (PM: own project — Admin: all projects)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/receipts")
|
||||
async def pm_receipts(
|
||||
ctx: PMScope = Depends(pm_scope),
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
rows = (
|
||||
(
|
||||
await db.execute(
|
||||
select(ItemReceipt, ItemDecision, LineItem, Material)
|
||||
.join(ItemDecision, ItemDecision.id == ItemReceipt.item_decision_id)
|
||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||
.join(Material, Material.id == LineItem.material_id)
|
||||
.where(ItemDecision.project_id == ctx.project_id)
|
||||
.order_by(ItemReceipt.received_at.desc())
|
||||
)
|
||||
)
|
||||
.all()
|
||||
stmt = (
|
||||
select(ItemReceipt, ItemDecision, LineItem, Material, Quote, Project)
|
||||
.join(ItemDecision, ItemDecision.id == ItemReceipt.item_decision_id)
|
||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||
.join(Material, Material.id == LineItem.material_id)
|
||||
.join(Quote, Quote.id == LineItem.quote_id)
|
||||
.join(Project, Project.id == ItemDecision.project_id)
|
||||
)
|
||||
if ctx.role == "pm":
|
||||
if ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
|
||||
receipts = [r.ItemReceipt for r in rows]
|
||||
alloc_rows = (
|
||||
(
|
||||
@@ -420,7 +439,9 @@ async def pm_receipts(
|
||||
return {"data": [
|
||||
{
|
||||
"receipt_id": str(r.ItemReceipt.id),
|
||||
"quote_id": str(r.Quote.id),
|
||||
"line_item_id": str(r.LineItem.id),
|
||||
"project": {"id": str(r.Project.id), "name": r.Project.name},
|
||||
"material": {"id": str(r.Material.id), "name": r.Material.name},
|
||||
"received_at": to_iso(r.ItemReceipt.received_at),
|
||||
"received_qty": str(r.ItemReceipt.received_qty),
|
||||
|
||||
Reference in New Issue
Block a user