433 lines
18 KiB
Python
433 lines
18 KiB
Python
"""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
|
|
(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).
|
|
- 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
|
|
decision's project; quantity converts to the inventory row's unit via the
|
|
material's canonical unit (material_units) — inventory rows are stored in
|
|
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.
|
|
"""
|
|
import base64
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from decimal import Decimal
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth import UserContext, get_optional_user
|
|
from app.db.engine import get_db
|
|
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit
|
|
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
|
from app.models.procurement import (
|
|
ItemDecision,
|
|
ItemReceipt,
|
|
ItemReceiptAllocation,
|
|
LineItem,
|
|
Project,
|
|
Quote,
|
|
)
|
|
from app.schemas.procurement import PMReceiptCreate
|
|
from app.services.inventory_service import canonical_specs_hash
|
|
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
|
|
from app.utils.serialization import to_iso
|
|
|
|
router = APIRouter(prefix="/pm", tags=["pm"])
|
|
|
|
NULL_BRAND_SENTINEL = "00000000-0000-0000-0000-000000000000"
|
|
|
|
|
|
@dataclass
|
|
class PMScope:
|
|
user_id: uuid.UUID | None
|
|
project_id: uuid.UUID | None
|
|
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."""
|
|
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
|
|
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")
|
|
actor = request.headers.get("x-user-name")
|
|
b64 = request.headers.get("x-user-name-b64")
|
|
if b64:
|
|
try:
|
|
actor = base64.b64decode(b64.encode()).decode("utf-8")
|
|
except Exception:
|
|
pass
|
|
# Dev fallback needs a real users row (item_receipts.confirmed_by FK).
|
|
from app.models.procurement import User
|
|
dev = await db.scalar(select(User).where(User.casdoor_id == "dev"))
|
|
if dev is None:
|
|
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 "کاربر آزمایشی")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Derived arrival state helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
async def _receipted_quantities(db: AsyncSession, decision_ids: list) -> dict:
|
|
"""decision_id -> sum of confirmed received_qty (in line-item unit)."""
|
|
if not decision_ids:
|
|
return {}
|
|
rows = (
|
|
await db.execute(
|
|
select(ItemReceipt.item_decision_id, func.sum(ItemReceipt.received_qty))
|
|
.where(ItemReceipt.item_decision_id.in_(decision_ids))
|
|
.group_by(ItemReceipt.item_decision_id)
|
|
)
|
|
).all()
|
|
return {r[0]: r[1] for r in rows}
|
|
|
|
|
|
def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
|
|
received = received or Decimal("0")
|
|
if received <= 0:
|
|
return "on_the_way"
|
|
if received < ordered:
|
|
return "partially_received"
|
|
return "arrived"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /pm/on-the-way — chosen items not fully received (PM's project)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/on-the-way")
|
|
async def pm_on_the_way(
|
|
ctx: PMScope = Depends(pm_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()
|
|
)
|
|
received_map = await _receipted_quantities(db, [d.ItemDecision.id for d in decisions])
|
|
|
|
# unit / category lookups for display
|
|
unit_ids = {d.LineItem.unit_id for d in decisions}
|
|
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
|
mat_ids = {d.Material.id for d in decisions}
|
|
cats = {}
|
|
if mat_ids:
|
|
rows = (
|
|
await db.execute(
|
|
select(Material.id, MaterialCategory)
|
|
.join(MaterialCategory, MaterialCategory.id == Material.category_id)
|
|
.where(Material.id.in_(mat_ids))
|
|
)
|
|
).all()
|
|
cats = {m_id: c for m_id, c in rows}
|
|
|
|
out = []
|
|
for d, li, q, m 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,
|
|
"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,
|
|
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
|
"ordered_qty": str(li.quantity),
|
|
"received_qty": str(received) if received is not None else "0",
|
|
"state": arrival_state(li.quantity, received),
|
|
})
|
|
return {"data": out}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /pm/receipts — confirm arrival + split into warehouses + post PURCHASE
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.post("/receipts", status_code=201)
|
|
async def pm_confirm_receipt(
|
|
payload: PMReceiptCreate,
|
|
ctx: PMScope = Depends(pm_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 = 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:
|
|
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)
|
|
|
|
# --- allocations sum + warehouse ownership ---
|
|
total_alloc = sum((a.quantity for a in payload.allocations), Decimal("0"))
|
|
if total_alloc != payload.received_qty:
|
|
raise ValidationError(
|
|
f"مجموع تخصیص انبارها ({total_alloc}) باید با مقدار رسید ({payload.received_qty}) برابر باشد",
|
|
field="allocations",
|
|
)
|
|
wh_ids = {a.warehouse_id for a in payload.allocations}
|
|
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
|
|
for a in payload.allocations:
|
|
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")
|
|
|
|
# --- create the receipt (1:N partial deliveries allowed) ---
|
|
receipt = ItemReceipt(
|
|
item_decision_id=decision.id,
|
|
confirmed_by=ctx.user_id,
|
|
received_at=payload.received_at,
|
|
received_qty=payload.received_qty,
|
|
on_time=payload.on_time,
|
|
quality_note=payload.quality_note,
|
|
)
|
|
db.add(receipt)
|
|
await db.flush()
|
|
|
|
# --- unit conversion: line-item unit -> material canonical unit ---
|
|
canonical_unit_id = material.canonical_unit_id
|
|
factor = Decimal("1")
|
|
if li.unit_id != canonical_unit_id:
|
|
from app.models.catalog import MaterialUnit
|
|
mu = await db.scalar(
|
|
select(MaterialUnit).where(
|
|
MaterialUnit.material_id == material.id,
|
|
MaterialUnit.unit_id == li.unit_id,
|
|
MaterialUnit.brand_material_id.is_(None),
|
|
)
|
|
)
|
|
if mu is None:
|
|
raise ValidationError(
|
|
"واحد قلم به واحد مرجع متریال قابل تبدیل نیست — ابتدا material_units تعریف کنید",
|
|
field="allocations.quantity",
|
|
)
|
|
factor = mu.conversion_factor
|
|
|
|
# --- brand + specs for the inventory row ---
|
|
brand_id = None
|
|
if li.brand_material_id:
|
|
bm = await db.get(BrandMaterial, li.brand_material_id)
|
|
brand_id = bm.brand_id if bm else None
|
|
specs = {}
|
|
from app.models.catalog import MaterialAttributeValue
|
|
mavs = (
|
|
await db.execute(
|
|
select(MaterialAttributeValue).where(MaterialAttributeValue.material_id == material.id)
|
|
)
|
|
).scalars().all()
|
|
for mav in mavs:
|
|
from app.models.catalog import AttributeDefinition
|
|
ad = await db.get(AttributeDefinition, mav.attribute_definition_id)
|
|
if ad:
|
|
key = ad.attribute_key
|
|
if mav.value_string is not None:
|
|
specs[key] = mav.value_string
|
|
elif mav.value_number is not None:
|
|
specs[key] = str(mav.value_number)
|
|
elif mav.value_boolean is not None:
|
|
specs[key] = mav.value_boolean
|
|
elif mav.value_date is not None:
|
|
specs[key] = mav.value_date.isoformat()
|
|
specs_hash = canonical_specs_hash(specs)
|
|
|
|
# --- post one PURCHASE transaction per allocation (idempotent) ---
|
|
posted = []
|
|
for a in payload.allocations:
|
|
qty_canonical = a.quantity * factor
|
|
inv = await db.scalar(
|
|
select(InventoryItem).where(
|
|
InventoryItem.warehouse_id == a.warehouse_id,
|
|
InventoryItem.material_id == material.id,
|
|
InventoryItem.brand_id.is_(None) if brand_id is None else InventoryItem.brand_id == brand_id,
|
|
InventoryItem.specs_hash == specs_hash,
|
|
).with_for_update()
|
|
)
|
|
if inv is None:
|
|
inv = InventoryItem(
|
|
warehouse_id=a.warehouse_id,
|
|
material_id=material.id,
|
|
brand_id=brand_id,
|
|
quantity=Decimal("0"),
|
|
unit_id=canonical_unit_id,
|
|
specs=specs,
|
|
specs_hash=specs_hash,
|
|
)
|
|
db.add(inv)
|
|
await db.flush()
|
|
else:
|
|
if inv.unit_id != canonical_unit_id:
|
|
raise ValidationError(
|
|
f"واحد موجودی این ردیف ({inv.unit_id}) با واحد مرجع متریال متفاوت است",
|
|
field="allocations.quantity",
|
|
)
|
|
inv.quantity += qty_canonical
|
|
|
|
tx = InventoryTransaction(
|
|
inventory_id=inv.id,
|
|
warehouse_id=a.warehouse_id,
|
|
receipt_allocation_id=None, # set after allocation flush below
|
|
transaction_type="PURCHASE",
|
|
quantity=qty_canonical,
|
|
performed_by=ctx.display_name,
|
|
note=f"رسید {receipt.id}",
|
|
)
|
|
db.add(tx)
|
|
await db.flush()
|
|
posted.append(tx)
|
|
|
|
# --- link the transactions to the allocations (idempotency key) ---
|
|
for a, tx in zip(payload.allocations, posted):
|
|
alloc = ItemReceiptAllocation(
|
|
receipt_id=receipt.id,
|
|
warehouse_id=a.warehouse_id,
|
|
quantity=a.quantity,
|
|
)
|
|
db.add(alloc)
|
|
await db.flush()
|
|
tx.receipt_allocation_id = alloc.id
|
|
|
|
# --- auto-flip the quote to arrived when all chosen items are in ---
|
|
chosen_ids = (
|
|
(
|
|
await db.execute(
|
|
select(ItemDecision.id).where(
|
|
ItemDecision.line_item_id.in_(
|
|
select(LineItem.id).where(LineItem.quote_id == quote.id)
|
|
),
|
|
ItemDecision.status == "chosen",
|
|
)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
received_map = await _receipted_quantities(db, chosen_ids)
|
|
all_arrived = True
|
|
for d_id in chosen_ids:
|
|
d = await db.get(ItemDecision, d_id)
|
|
li_d = await db.get(LineItem, d.line_item_id)
|
|
if arrival_state(li_d.quantity, received_map.get(d_id)) != "arrived":
|
|
all_arrived = False
|
|
break
|
|
if all_arrived and quote.status not in ("arrived", "rejected", "expired"):
|
|
quote.status = "arrived"
|
|
quote.arrived_date = payload.received_at
|
|
|
|
return {"data": {
|
|
"receipt_id": str(receipt.id),
|
|
"state": "arrived",
|
|
"posted_transactions": [str(tx.id) for tx in posted],
|
|
}}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /pm/receipts — delivery history for the PM's project
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/receipts")
|
|
async def pm_receipts(
|
|
ctx: PMScope = Depends(pm_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()
|
|
)
|
|
receipts = [r.ItemReceipt for r in rows]
|
|
alloc_rows = (
|
|
(
|
|
await db.execute(
|
|
select(ItemReceiptAllocation, Warehouse)
|
|
.join(Warehouse, Warehouse.id == ItemReceiptAllocation.warehouse_id)
|
|
.where(ItemReceiptAllocation.receipt_id.in_([r.id for r in receipts]) if receipts else False)
|
|
)
|
|
)
|
|
.all()
|
|
)
|
|
allocs_by_receipt: dict = {}
|
|
for alloc, wh in alloc_rows:
|
|
allocs_by_receipt.setdefault(str(alloc.receipt_id), []).append(
|
|
{"warehouse_id": str(wh.id), "warehouse_name": wh.name, "quantity": str(alloc.quantity)}
|
|
)
|
|
return {"data": [
|
|
{
|
|
"receipt_id": str(r.ItemReceipt.id),
|
|
"line_item_id": str(r.LineItem.id),
|
|
"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),
|
|
"on_time": r.ItemReceipt.on_time,
|
|
"quality_note": r.ItemReceipt.quality_note,
|
|
"allocations": allocs_by_receipt.get(str(r.ItemReceipt.id), []),
|
|
}
|
|
for r in rows
|
|
]}
|