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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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)}
|
||||
@@ -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):
|
||||
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user