Merge prod-v8 (multi-project allocation, inter-project transfers) into the all-branches integration

Conflicts (all keep-both):
- init_db.py: v7 added ensure_bot_drafts_table, v8 added
  ensure_project_transfers_table at the same anchor — both functions and
  both startup calls kept
- main.py: same, both ensure calls kept
- ChangeInventoryPage.canSubmit: v9's WITHDRAWAL branch and v8's
  PROJECT_TRANSFER branch — both validation branches kept
This commit is contained in:
MMD
2026-10-01 15:05:09 +03:30
18 changed files with 2454 additions and 102 deletions
+44
View File
@@ -470,3 +470,47 @@ async def ensure_bot_drafts_table(engine: AsyncEngine) -> None:
await conn.execute(text(stmt))
logger.info("bot_drafts table ensured")
async def ensure_project_transfers_table(engine: AsyncEngine) -> None:
"""Create (if missing) the project_transfers table for inter-site transfers. Safe on any volume."""
ddl = """
CREATE TABLE IF NOT EXISTS project_transfers (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
from_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
from_warehouse_id UUID NOT NULL REFERENCES warehouses(id) ON DELETE RESTRICT,
to_project_id UUID NOT NULL REFERENCES projects(id) ON DELETE RESTRICT,
material_id UUID NOT NULL REFERENCES materials(id) ON DELETE RESTRICT,
brand_id UUID REFERENCES brands(id) ON DELETE SET NULL,
unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
quantity NUMERIC(18,6) NOT NULL CHECK (quantity > 0),
specs JSONB NOT NULL DEFAULT '{}'::jsonb,
specs_hash TEXT NOT NULL DEFAULT 'default',
note TEXT,
status TEXT NOT NULL DEFAULT 'in_transit' CHECK (status IN ('in_transit', 'received', 'cancelled')),
out_transaction_id UUID REFERENCES inventory_transactions(id) ON DELETE SET NULL,
transfer_date TIMESTAMPTZ NOT NULL DEFAULT now(),
transferred_by TEXT,
received_at TIMESTAMPTZ,
received_by TEXT,
to_warehouse_id UUID REFERENCES warehouses(id) ON DELETE SET NULL,
receipt_allocations JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
indexes = (
"CREATE INDEX IF NOT EXISTS idx_project_transfers_from ON project_transfers(from_project_id, status)",
"CREATE INDEX IF NOT EXISTS idx_project_transfers_to ON project_transfers(to_project_id, status)",
"DROP TRIGGER IF EXISTS trg_project_transfers_updated_at ON project_transfers",
"""
CREATE TRIGGER trg_project_transfers_updated_at
BEFORE UPDATE ON project_transfers
FOR EACH ROW EXECUTE FUNCTION set_updated_at()
""",
)
async with engine.begin() as conn:
await conn.execute(text(ddl))
for stmt in indexes:
await conn.execute(text(stmt))
logger.info("project_transfers table ensured")
+2
View File
@@ -32,6 +32,7 @@ from app.db.init_db import (
ensure_dashboard_tables,
ensure_material_topic_column,
ensure_quote_projects,
ensure_project_transfers_table,
ensure_v26,
)
from app.observability import RequestContextMiddleware, setup_logging
@@ -109,6 +110,7 @@ async def lifespan(_app: FastAPI):
await ensure_audit_table(engine)
await ensure_drop_category_tables(engine)
await ensure_bot_drafts_table(engine)
await ensure_project_transfers_table(engine)
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
try:
yield
+3
View File
@@ -32,6 +32,8 @@ from app.models.procurement import (
TermsheetScheduleStep,
)
from app.models.project_transfer import ProjectTransfer
__all__ = [
"Base",
"Unit",
@@ -60,4 +62,5 @@ __all__ = [
"ItemDecision",
"ItemReceipt",
"QuoteAttachment",
"ProjectTransfer",
]
+87
View File
@@ -0,0 +1,87 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
DateTime,
ForeignKey,
Numeric,
Text,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.catalog import _timestamptz, _uuid_pk
class ProjectTransfer(Base):
"""project_transfers — Inter-project (inter-site) material transfer ledger.
Records the movement of materials from a source project warehouse to a
destination project. Stock is deducted from the source warehouse immediately
upon creation (status = 'in_transit') via out_transaction_id.
When the destination project PM confirms receipt in 'Arrivals', stock is
added to the destination warehouse (status = 'received').
While 'in_transit', the transfer can be edited or cancelled (restoring source stock).
Once 'received', the transfer is permanently locked from edit or cancellation.
"""
__tablename__ = "project_transfers"
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
from_project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
)
from_warehouse_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False
)
to_project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
)
material_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False
)
brand_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("brands.id", ondelete="SET NULL")
)
unit_id: Mapped[int] = mapped_column(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
)
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
specs: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
specs_hash: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'default'"))
note: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'in_transit'"))
out_transaction_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("inventory_transactions.id", ondelete="SET NULL")
)
transfer_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
transferred_by: Mapped[str | None] = mapped_column(Text)
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
received_by: Mapped[str | None] = mapped_column(Text)
to_warehouse_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("warehouses.id", ondelete="SET NULL")
)
receipt_allocations: Mapped[list | None] = mapped_column(JSONB)
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
from_project: Mapped["Project"] = relationship("Project", foreign_keys=[from_project_id], lazy="raise")
to_project: Mapped["Project"] = relationship("Project", foreign_keys=[to_project_id], lazy="raise")
from_warehouse: Mapped["Warehouse"] = relationship("Warehouse", foreign_keys=[from_warehouse_id], lazy="raise")
to_warehouse: Mapped["Warehouse | None"] = relationship("Warehouse", foreign_keys=[to_warehouse_id], lazy="raise")
material: Mapped["Material"] = relationship("Material", foreign_keys=[material_id], lazy="raise")
brand: Mapped["Brand | None"] = relationship("Brand", foreign_keys=[brand_id], lazy="raise")
unit: Mapped["Unit"] = relationship("Unit", foreign_keys=[unit_id], lazy="raise")
out_transaction: Mapped["InventoryTransaction | None"] = relationship(
"InventoryTransaction", foreign_keys=[out_transaction_id], lazy="raise"
)
+2
View File
@@ -15,6 +15,7 @@ from app.routers import (
material_categories,
materials,
pm,
project_transfers,
projects,
quote_events,
quotes,
@@ -51,6 +52,7 @@ ROUTERS = [
standard_conversions.router,
inventory.router,
pm.router,
project_transfers.router,
users.router,
drafts.router,
]
+206
View File
@@ -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)}
+6
View File
@@ -135,6 +135,11 @@ class AcceptTermsheetIn(APIModel):
schedule_steps: list[ScheduleStepIn] = Field(min_length=1)
class ProjectAllocationIn(APIModel):
project_id: UUID
quantity: Decimal = Field(gt=0, description="Allocated quantity for this project")
class ItemDecisionIn(APIModel):
line_item_id: UUID
status: Literal["chosen", "rejected"]
@@ -142,6 +147,7 @@ class ItemDecisionIn(APIModel):
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item")
class ItemProjectsIn(APIModel):
+74
View File
@@ -0,0 +1,74 @@
from datetime import datetime
from decimal import Decimal
from uuid import UUID
from pydantic import Field
from app.schemas.common import APIModel
class WarehouseAllocationIn(APIModel):
warehouse_id: UUID
quantity: Decimal = Field(gt=0)
class ProjectTransferCreate(APIModel):
from_warehouse_id: UUID
to_project_id: UUID
material_id: UUID
brand_id: UUID | None = None
unit_id: int
quantity: Decimal = Field(gt=0)
specs: dict = Field(default_factory=dict)
note: str | None = None
transfer_date: datetime | None = None
class ProjectTransferUpdate(APIModel):
to_project_id: UUID | None = None
quantity: Decimal | None = Field(default=None, gt=0)
note: str | None = None
transfer_date: datetime | None = None
class ProjectTransferReceive(APIModel):
allocations: list[WarehouseAllocationIn] = Field(
min_length=1,
description="Warehouse allocations in destination project (sum must equal transfer quantity)",
)
received_at: datetime | None = None
note: str | None = None
class IdNameOut(APIModel):
id: UUID
name: str
class UnitBriefOut(APIModel):
id: int
code: str
name_fa: str | None = None
class ProjectTransferOut(APIModel):
id: UUID
from_project: IdNameOut
from_warehouse: IdNameOut
to_project: IdNameOut
to_warehouse: IdNameOut | None = None
material: IdNameOut
brand: IdNameOut | None = None
unit: UnitBriefOut
quantity: Decimal
specs: dict
specs_hash: str
note: str | None = None
status: str
transfer_date: datetime
transferred_by: str | None = None
received_at: datetime | None = None
received_by: str | None = None
receipt_allocations: list[dict] | None = None
created_at: datetime
updated_at: datetime
@@ -0,0 +1,471 @@
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,
)
# Smart fallback: If exact hash or brand didn't match, check candidate rows in this warehouse for this material
if source_item is None:
candidates_res = await db.execute(
select(InventoryItem)
.where(
InventoryItem.warehouse_id == payload.from_warehouse_id,
InventoryItem.material_id == payload.material_id,
)
.with_for_update()
)
candidates = candidates_res.scalars().all()
if len(candidates) == 1:
source_item = candidates[0]
elif len(candidates) > 1:
best_cand = None
best_score = -1
p_keys = set(specs.keys())
p_vals = set(str(v).strip() for v in specs.values() if str(v).strip())
for cand in candidates:
score = 0
if cand.brand_id == payload.brand_id:
score += 10
elif cand.brand_id is None and payload.brand_id is None:
score += 10
elif cand.brand_id is None:
score += 4
c_specs = cand.specs or {}
c_keys = set(c_specs.keys())
c_vals = set(str(v).strip() for v in c_specs.values() if str(v).strip())
for pk in p_keys:
if pk in c_keys or f"spec_{pk}" in c_keys or any(pk in ck for ck in c_keys):
score += 5
for pv in p_vals:
if pv in c_vals or any(pv in str(cv) for cv in c_vals):
score += 5
if cand.quantity >= payload.quantity:
score += 2
if score > best_score:
best_score = score
best_cand = cand
source_item = best_cand
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()
actual_brand_id = source_item.brand_id
actual_specs = source_item.specs if source_item.specs is not None else specs
actual_specs_hash = source_item.specs_hash or canonical_specs_hash(actual_specs)
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=actual_brand_id,
unit_id=source_item.unit_id if source_item.unit_id else payload.unit_id,
quantity=payload.quantity,
specs=actual_specs,
specs_hash=actual_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())
+197
View File
@@ -7,6 +7,7 @@ same transaction.
import asyncio
import logging
from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import UUID
from sqlalchemy import delete, func, select, update
@@ -18,6 +19,7 @@ from app.models.procurement import (
ItemTermsheet,
LineItem,
LineItemProjects,
LineItemSpec,
Project,
Quote,
QuoteEvent,
@@ -418,6 +420,78 @@ async def replace_quote_children(
async def _ensure_quote_project(db: AsyncSession, quote_id: UUID, project_id: UUID) -> None:
"""Ensure (quote_id, project_id) exists in quote_projects junction."""
exists = await db.scalar(
select(QuoteProjects).where(
QuoteProjects.quote_id == quote_id,
QuoteProjects.project_id == project_id,
)
)
if not exists:
db.add(QuoteProjects(quote_id=quote_id, project_id=project_id))
async def _merge_split_line_items_on_reopen(db: AsyncSession, quote_id: UUID) -> None:
"""Consolidate split line items when reverting a quote back to offered.
Line items sharing the exact same material, brand_material, unit, notes,
and identical specs are merged into a single consolidated row whose quantity
is the sum of all split rows. Secondary split rows are safely deleted.
"""
line_items = (
await db.execute(
select(LineItem).where(LineItem.quote_id == quote_id).order_by(LineItem.created_at.asc(), LineItem.id.asc())
)
).scalars().all()
if len(line_items) <= 1:
return
# Load all specs for these line items to compare canonical signatures
specs_rows = (
await db.execute(
select(LineItemSpec).where(LineItemSpec.line_item_id.in_([li.id for li in line_items]))
)
).scalars().all()
specs_by_li: dict[UUID, list[tuple]] = {}
for sp in specs_rows:
sig = (
str(sp.attribute_definition_id),
str(sp.value_number) if sp.value_number is not None else "",
(sp.value_string or "").strip(),
str(sp.value_boolean) if sp.value_boolean is not None else "",
str(sp.value_date) if sp.value_date is not None else "",
)
specs_by_li.setdefault(sp.line_item_id, []).append(sig)
# Group line items by identity
groups: dict[tuple, list[LineItem]] = {}
for li in line_items:
specs_sig = tuple(sorted(specs_by_li.get(li.id, [])))
key = (
li.material_id,
li.brand_material_id,
li.unit_id,
(li.notes or "").strip(),
specs_sig,
)
groups.setdefault(key, []).append(li)
for key, items in groups.items():
if len(items) > 1:
primary = items[0]
total_qty = sum(Decimal(str(it.quantity)) for it in items)
primary.quantity = total_qty
secondary_ids = [it.id for it in items[1:]]
await db.execute(delete(ItemDecision).where(ItemDecision.line_item_id.in_(secondary_ids)))
await db.execute(delete(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItemProjects).where(LineItemProjects.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItemSpec).where(LineItemSpec.line_item_id.in_(secondary_ids)))
await db.execute(delete(LineItem).where(LineItem.id.in_(secondary_ids)))
await db.flush()
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
"""Backward transitions only; reverts bookkeeping when going back to offered."""
current = quote.status
@@ -454,6 +528,8 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
)
.execution_options(synchronize_session=False)
)
# Requirement 2: Merge split line items back into single rows
await _merge_split_line_items_on_reopen(db, quote.id)
# LineItemProjects (candidate projects) are preserved untouched!
elif new_status == "accepted": # arrived -> accepted
quote.arrived_date = None
@@ -557,6 +633,125 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
"برای قلم انتخاب‌شده adjusted_unit_price الزامی است", field="item_decisions"
)
termsheet_id = ts_ids_by_index[index]
li_row = await db.scalar(
select(LineItem).where(LineItem.id == decision_in.line_item_id).with_for_update()
)
if li_row is None:
raise NotFoundError("خط آیتم یافت نشد", field="item_decisions.line_item_id")
allocations = decision_in.allocations or []
if allocations:
# Requirement 3: Validate no duplicate projects within allocations of this item
alloc_pids = [a.project_id for a in allocations]
if len(alloc_pids) != len(set(alloc_pids)):
raise ValidationError(
"پروژه تکراری برای یک قلم کالا در ردیف‌های تخصیص انتخاب شده است",
field="item_decisions.allocations",
)
# Requirement 5: Decimal precision check for sum of allocation quantities
alloc_sum = sum(Decimal(str(a.quantity)) for a in allocations)
target_qty = Decimal(str(li_row.quantity))
if alloc_sum != target_qty:
raise ValidationError(
f"مجموع مقادیر تخصیص‌یافته ({alloc_sum}) با مقدار کل قلم ({target_qty}) برابر نیست",
field="item_decisions.allocations",
)
# Validate all projects exist
for a in allocations:
if await db.get(Project, a.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.allocations.project_id")
if len(allocations) >= 2:
# 1. Update the original line item to the first allocation quantity
first_alloc = allocations[0]
li_row.quantity = first_alloc.quantity
decision_in.project_id = first_alloc.project_id
# Query all existing item_termsheets and line_item_specs for copying
orig_termsheets = (
await db.execute(
select(ItemTermsheet).where(ItemTermsheet.line_item_id == li_row.id)
)
).scalars().all()
orig_specs = (
await db.execute(
select(LineItemSpec).where(LineItemSpec.line_item_id == li_row.id)
)
).scalars().all()
# 2. For each subsequent allocation, create a new LineItem and copy specs + ALL termsheets
for split_alloc in allocations[1:]:
new_li = LineItem(
quote_id=quote.id,
material_id=li_row.material_id,
brand_material_id=li_row.brand_material_id,
unit_id=li_row.unit_id,
quantity=split_alloc.quantity,
notes=li_row.notes,
)
db.add(new_li)
await db.flush()
# Copy LineItemSpec
for sp in orig_specs:
db.add(
LineItemSpec(
line_item_id=new_li.id,
attribute_definition_id=sp.attribute_definition_id,
value_number=sp.value_number,
value_string=sp.value_string,
value_boolean=sp.value_boolean,
value_date=sp.value_date,
unit_id_at_time=sp.unit_id_at_time,
)
)
# Requirement 1: Copy ALL payment options (ItemTermsheet)
chosen_new_it: ItemTermsheet | None = None
for it_orig in orig_termsheets:
price = (
decision_in.adjusted_unit_price
if (it_orig.termsheet_id == termsheet_id and decision_in.adjusted_unit_price is not None)
else it_orig.unit_price
)
copied_it = ItemTermsheet(
line_item_id=new_li.id,
termsheet_id=it_orig.termsheet_id,
unit_price=price,
min_order_qty=it_orig.min_order_qty,
)
db.add(copied_it)
if it_orig.termsheet_id == termsheet_id:
chosen_new_it = copied_it
if chosen_new_it is None:
chosen_new_it = ItemTermsheet(
line_item_id=new_li.id,
termsheet_id=termsheet_id,
unit_price=decision_in.adjusted_unit_price,
)
db.add(chosen_new_it)
await db.flush()
# Create ItemDecision for the split line item
new_decision = ItemDecision(
line_item_id=new_li.id,
status="chosen",
chosen_item_termsheet_id=chosen_new_it.id,
adjusted_unit_price=decision_in.adjusted_unit_price,
adjusted_notes=decision_in.adjusted_notes,
project_id=split_alloc.project_id,
decided_at=utcnow(),
)
db.add(new_decision)
# Requirement 4: sync QuoteProjects & LineItemProjects
await _ensure_quote_project(db, quote.id, split_alloc.project_id)
db.add(LineItemProjects(line_item_id=new_li.id, project_id=split_alloc.project_id))
elif len(allocations) == 1:
decision_in.project_id = allocations[0].project_id
it = await db.scalar(
select(ItemTermsheet).where(
ItemTermsheet.line_item_id == decision_in.line_item_id,
@@ -610,6 +805,8 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
if decision.project_id is not None:
if await db.get(Project, decision.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
# Requirement 4: sync QuoteProjects
await _ensure_quote_project(db, quote.id, decision.project_id)
chosen_ts_ids.add(termsheet_id)
else: # rejected
if decision is None:
+1
View File
@@ -128,6 +128,7 @@ async def get_category(db, slug: str) -> MaterialCategory:
USER_PLAN = [
# (casdoor username, first, last, role)
("ali.mohaghegh", "علی", "محقق", "admin"),
("razaei", "علی", "رضایی", "admin"),
("mousavi", "حسین", "موسوی", "procurement_manager"),
("mohaghegh", "محمد", "محقق", "pm"),