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:
ali_mohaghegh
2026-08-27 15:04:25 +03:30
parent f48f3752af
commit a191147eca
4 changed files with 402 additions and 179 deletions
+91 -70
View File
@@ -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),