Compare commits
8
Commits
d69ec7f7a9
...
prod-v8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af092fe4c8 | ||
|
|
b2aba2fcfe | ||
|
|
e560018273 | ||
|
|
856513b6d7 | ||
|
|
b326cf64bf | ||
|
|
da883a28c0 | ||
|
|
b6a1ce2ae7 | ||
|
|
64d80e15f8 |
@@ -432,3 +432,48 @@ async def ensure_audit_table(engine: AsyncEngine) -> None:
|
||||
for stmt in indexes:
|
||||
await conn.execute(text(stmt))
|
||||
logger.info("audit_log 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")
|
||||
|
||||
|
||||
@@ -31,6 +31,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
|
||||
@@ -107,6 +108,7 @@ async def lifespan(_app: FastAPI):
|
||||
await ensure_v26(engine)
|
||||
await ensure_audit_table(engine)
|
||||
await ensure_drop_category_tables(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"
|
||||
)
|
||||
@@ -14,6 +14,7 @@ from app.routers import (
|
||||
material_categories,
|
||||
materials,
|
||||
pm,
|
||||
project_transfers,
|
||||
projects,
|
||||
quote_events,
|
||||
quotes,
|
||||
@@ -50,5 +51,6 @@ ROUTERS = [
|
||||
standard_conversions.router,
|
||||
inventory.router,
|
||||
pm.router,
|
||||
project_transfers.router,
|
||||
users.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"),
|
||||
|
||||
+83
-13
@@ -30,6 +30,10 @@ import type {
|
||||
Supplier,
|
||||
TransactionType,
|
||||
Warehouse,
|
||||
ProjectTransfer,
|
||||
ProjectTransferCreateIn,
|
||||
ProjectTransferUpdateIn,
|
||||
ProjectTransferReceiveIn,
|
||||
} from "./types";
|
||||
|
||||
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
||||
@@ -239,20 +243,32 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
async function getData<T>(path: string): Promise<T> {
|
||||
const res = await request<{ data: T }>(path);
|
||||
return res.data;
|
||||
const res = await request<any>(path);
|
||||
if (res && typeof res === "object" && "data" in res) {
|
||||
return res.data as T;
|
||||
}
|
||||
return res as T;
|
||||
}
|
||||
async function postData<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) });
|
||||
return res.data;
|
||||
const res = await request<any>(path, { method: "POST", body: JSON.stringify(body) });
|
||||
if (res && typeof res === "object" && "data" in res) {
|
||||
return res.data as T;
|
||||
}
|
||||
return res as T;
|
||||
}
|
||||
async function patchData<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||
return res.data;
|
||||
const res = await request<any>(path, { method: "PATCH", body: JSON.stringify(body) });
|
||||
if (res && typeof res === "object" && "data" in res) {
|
||||
return res.data as T;
|
||||
}
|
||||
return res as T;
|
||||
}
|
||||
async function deleteData<T>(path: string): Promise<T> {
|
||||
const res = await request<{ data: T }>(path, { method: "DELETE" });
|
||||
return res.data;
|
||||
const res = await request<any>(path, { method: "DELETE" });
|
||||
if (res && typeof res === "object" && "data" in res) {
|
||||
return res.data as T;
|
||||
}
|
||||
return res as T;
|
||||
}
|
||||
|
||||
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
|
||||
@@ -927,13 +943,28 @@ export const api = {
|
||||
|
||||
if (becomingAccepted) {
|
||||
const acceptedTs = (patch.termsheets || []).filter((t) => t.status === "accepted");
|
||||
// پروژهی واحدِ انتخابشده برای هر قلم (item_decisions.project_id)
|
||||
// پروژهی واحدِ انتخابشده یا تخصیصهای چندپروژهای برای هر قلم
|
||||
const targetProjByLine = new Map<string, string>();
|
||||
const allocationsByLine = new Map<string, { project_id: string; quantity: number }[]>();
|
||||
for (const li of patch.line_items || []) {
|
||||
const tp = li.target_project;
|
||||
if (tp && tp.name) {
|
||||
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
|
||||
if (pid) targetProjByLine.set(li.id, pid);
|
||||
if (li.allocations && li.allocations.length > 0) {
|
||||
const resolvedAllocs: { project_id: string; quantity: number }[] = [];
|
||||
for (const a of li.allocations) {
|
||||
const pid = looksLikeUuid(a.project_id) ? a.project_id! : await projectIdByNameOrCreate(a.project_name);
|
||||
if (pid && a.quantity > 0) {
|
||||
resolvedAllocs.push({ project_id: pid, quantity: Number(a.quantity) });
|
||||
}
|
||||
}
|
||||
if (resolvedAllocs.length) {
|
||||
allocationsByLine.set(li.id, resolvedAllocs);
|
||||
targetProjByLine.set(li.id, resolvedAllocs[0].project_id);
|
||||
}
|
||||
} else {
|
||||
const tp = li.target_project;
|
||||
if (tp && tp.name) {
|
||||
const pid = looksLikeUuid(tp.id) ? tp.id : await projectIdByNameOrCreate(tp.name);
|
||||
if (pid) targetProjByLine.set(li.id, pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -953,11 +984,13 @@ export const api = {
|
||||
const idx = li.accepted_ts_id ? acceptedTs.findIndex((t) => t.id === li.accepted_ts_id) : -1;
|
||||
const chosen = !li.rejected && idx >= 0;
|
||||
const chosenProjId = targetProjByLine.get(li.id);
|
||||
const lineAllocs = allocationsByLine.get(li.id);
|
||||
return {
|
||||
line_item_id: li.id,
|
||||
status: chosen ? "chosen" : "rejected",
|
||||
...(chosen ? { accepted_termsheet_index: idx } : {}),
|
||||
...(chosen && chosenProjId ? { project_id: chosenProjId } : {}),
|
||||
...(chosen && lineAllocs && lineAllocs.length ? { allocations: lineAllocs } : {}),
|
||||
...(li.unit_price != null ? { adjusted_unit_price: li.unit_price } : {}),
|
||||
};
|
||||
}),
|
||||
@@ -1729,4 +1762,41 @@ export const apiV26 = {
|
||||
attributes: [],
|
||||
}),
|
||||
},
|
||||
|
||||
projectTransfers: {
|
||||
list: async (projectId?: string | null, direction: "outgoing" | "incoming" | "all" = "all", status?: string): Promise<ProjectTransfer[]> => {
|
||||
const q = new URLSearchParams();
|
||||
if (projectId) q.set("project_id", projectId);
|
||||
if (direction) q.set("direction", direction);
|
||||
if (status) q.set("status", status);
|
||||
const qs = q.toString() ? `?${q.toString()}` : "";
|
||||
try {
|
||||
const res = await getData<ProjectTransfer[]>(`/inventory/project-transfers${qs}`);
|
||||
return Array.isArray(res) ? res : [];
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch project transfers:", err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
get: async (id: string, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||
getData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
|
||||
create: async (body: ProjectTransferCreateIn, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers?project_id=${projectId}` : `/inventory/project-transfers`, body),
|
||||
update: async (id: string, patch: ProjectTransferUpdateIn, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||
patchData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`, patch),
|
||||
delete: async (id: string, projectId?: string | null): Promise<{ ok: boolean; message: string }> =>
|
||||
deleteData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`),
|
||||
onTheWay: async (projectId?: string | null): Promise<ProjectTransfer[]> => {
|
||||
try {
|
||||
const res = await getData<ProjectTransfer[]>(projectId ? `/pm/transfers/on-the-way?project_id=${projectId}` : `/pm/transfers/on-the-way`);
|
||||
return Array.isArray(res) ? res : [];
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch on-the-way transfers:", err);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
receive: async (id: string, body: ProjectTransferReceiveIn, projectId?: string | null): Promise<ProjectTransfer> =>
|
||||
postData<ProjectTransfer>(projectId ? `/inventory/project-transfers/${id}/receive?project_id=${projectId}` : `/inventory/project-transfers/${id}/receive`, body),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface LineItem {
|
||||
projects?: { id: string; name: string }[]; // پروژههای کاندیدای تخصیصیافته به این قلم (line_item_projects)
|
||||
target_project?: { id: string; name: string } | null; // پروژهی نهایی انتخابشده در هنگام خرید (item_decisions.project_id)
|
||||
target_project_id?: string | null;
|
||||
allocations?: { project_id?: string; project_name: string; quantity: number }[]; // تخصیص چندپروژهای در پذیرش
|
||||
}
|
||||
|
||||
// پیشنهاد خرید (اصلیترین entity)
|
||||
@@ -199,3 +200,52 @@ export interface ConfirmedReceipt {
|
||||
quality_note: string | null;
|
||||
allocations: { warehouse_id: string; warehouse_name: string; quantity: string }[];
|
||||
}
|
||||
|
||||
export interface ProjectTransfer {
|
||||
id: string;
|
||||
from_project: { id: string; name: string };
|
||||
from_warehouse: { id: string; name: string };
|
||||
to_project: { id: string; name: string };
|
||||
to_warehouse?: { id: string; name: string } | null;
|
||||
material: { id: string; name: string };
|
||||
brand?: { id: string; name: string } | null;
|
||||
unit: { id: number; code: string; name_fa?: string | null };
|
||||
quantity: number;
|
||||
specs: Record<string, string>;
|
||||
specs_hash: string;
|
||||
note?: string | null;
|
||||
status: "in_transit" | "received" | "cancelled";
|
||||
transfer_date: string;
|
||||
transferred_by?: string | null;
|
||||
received_at?: string | null;
|
||||
received_by?: string | null;
|
||||
receipt_allocations?: { warehouse_id: string; quantity: string | number }[] | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectTransferCreateIn {
|
||||
from_warehouse_id: string;
|
||||
to_project_id: string;
|
||||
material_id: string;
|
||||
brand_id?: string | null;
|
||||
unit_id: number;
|
||||
quantity: number;
|
||||
specs?: Record<string, string>;
|
||||
note?: string | null;
|
||||
transfer_date?: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectTransferUpdateIn {
|
||||
to_project_id?: string;
|
||||
quantity?: number;
|
||||
note?: string | null;
|
||||
transfer_date?: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectTransferReceiveIn {
|
||||
allocations: { warehouse_id: string; quantity: number }[];
|
||||
received_at?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,12 +23,134 @@ import {
|
||||
import { MilestoneRow } from "../../components/MilestoneRow";
|
||||
import { FileList } from "../../components/FileList";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { Building2, Plus, Trash2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
|
||||
// تبدیل عدد به حروف فارسی جهت نمایش زیر باکس مقدار
|
||||
const ONES = ["", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه"];
|
||||
const TEENS = [
|
||||
"ده",
|
||||
"یازده",
|
||||
"دوازده",
|
||||
"سیزده",
|
||||
"چهارده",
|
||||
"پانزده",
|
||||
"شانزده",
|
||||
"هفده",
|
||||
"هجده",
|
||||
"نوزده",
|
||||
];
|
||||
const TENS = [
|
||||
"",
|
||||
"",
|
||||
"بیست",
|
||||
"سی",
|
||||
"چهل",
|
||||
"پنجاه",
|
||||
"شصت",
|
||||
"هفتاد",
|
||||
"هشتاد",
|
||||
"نود",
|
||||
];
|
||||
const HUNDREDS = [
|
||||
"",
|
||||
"صد",
|
||||
"دویست",
|
||||
"سیصد",
|
||||
"چهارصد",
|
||||
"پانصد",
|
||||
"ششصد",
|
||||
"هفتصد",
|
||||
"هشتصد",
|
||||
"نهصد",
|
||||
];
|
||||
const SCALES = ["", "هزار", "میلیون", "میلیارد", "تریلیون"];
|
||||
const DECIMAL_SCALES: Record<number, string> = {
|
||||
1: "دهم",
|
||||
2: "صدم",
|
||||
3: "هزارم",
|
||||
4: "دههزارم",
|
||||
};
|
||||
|
||||
function convertThreeDigits(n: number): string {
|
||||
if (n === 0) return "";
|
||||
const parts: string[] = [];
|
||||
const h = Math.floor(n / 100);
|
||||
const rem = n % 100;
|
||||
if (h > 0) parts.push(HUNDREDS[h]);
|
||||
if (rem >= 10 && rem <= 19) {
|
||||
parts.push(TEENS[rem - 10]);
|
||||
} else {
|
||||
const t = Math.floor(rem / 10);
|
||||
const u = rem % 10;
|
||||
if (t > 0) parts.push(TENS[t]);
|
||||
if (u > 0) parts.push(ONES[u]);
|
||||
}
|
||||
return parts.join(" و ");
|
||||
}
|
||||
|
||||
function toEnglishDigits(str: string): string {
|
||||
return str
|
||||
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 1776))
|
||||
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 1632));
|
||||
}
|
||||
|
||||
function numberToPersianWords(val: number | string | null | undefined): string {
|
||||
if (val == null || val === "") return "";
|
||||
const numStr = toEnglishDigits(String(val).trim());
|
||||
const num = parseFloat(numStr);
|
||||
if (isNaN(num)) return "";
|
||||
if (num === 0) return "صفر";
|
||||
|
||||
const isNeg = num < 0;
|
||||
const parts = Math.abs(num).toString().split(".");
|
||||
const intStr = parts[0];
|
||||
const decStr = parts[1];
|
||||
|
||||
let intNum = parseInt(intStr, 10);
|
||||
let intResult = "";
|
||||
|
||||
if (intNum === 0) {
|
||||
intResult = "صفر";
|
||||
} else {
|
||||
const chunks: number[] = [];
|
||||
while (intNum > 0) {
|
||||
chunks.push(intNum % 1000);
|
||||
intNum = Math.floor(intNum / 1000);
|
||||
}
|
||||
const chunkWords: string[] = [];
|
||||
for (let i = chunks.length - 1; i >= 0; i--) {
|
||||
const c = chunks[i];
|
||||
if (c > 0) {
|
||||
const w = convertThreeDigits(c);
|
||||
const s = SCALES[i];
|
||||
chunkWords.push(s ? `${w} ${s}` : w);
|
||||
}
|
||||
}
|
||||
intResult = chunkWords.join(" و ");
|
||||
}
|
||||
|
||||
if (decStr && parseInt(decStr, 10) > 0) {
|
||||
const cleanDecStr = decStr.slice(0, 4);
|
||||
const decNum = parseInt(cleanDecStr, 10);
|
||||
const decWord = numberToPersianWords(decNum);
|
||||
const scale = DECIMAL_SCALES[cleanDecStr.length];
|
||||
if (scale) {
|
||||
if (intNum === 0) {
|
||||
return `${isNeg ? "منفی " : ""}${decWord} ${scale}`;
|
||||
}
|
||||
return `${isNeg ? "منفی " : ""}${intResult} و ${decWord} ${scale}`;
|
||||
}
|
||||
return `${isNeg ? "منفی " : ""}${intResult} ممیز ${decWord}`;
|
||||
}
|
||||
|
||||
return isNeg ? `منفی ${intResult}` : intResult;
|
||||
}
|
||||
|
||||
interface CardDraft {
|
||||
domId: string;
|
||||
isNew: boolean;
|
||||
milestones: Milestone[];
|
||||
label: string | null; // null = پیشفرض «گزینه N»
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
export interface AcceptModalProps {
|
||||
@@ -51,6 +173,9 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
const [removedItems, setRemovedItems] = useState<Set<string>>(new Set());
|
||||
const [assignments, setAssignments] = useState<Record<string, string>>({});
|
||||
const [itemProjectsSel, setItemProjectsSel] = useState<Record<string, string>>({});
|
||||
const [itemAllocations, setItemAllocations] = useState<
|
||||
Record<string, { rowId: string; project_name: string; quantity: number | string }[]>
|
||||
>({});
|
||||
const [acceptDate, setAcceptDate] = useState<string | null>(() => new Date().toISOString());
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
@@ -67,17 +192,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
setRemovedItems(rem);
|
||||
setAssignments({});
|
||||
const projSel: Record<string, string> = {};
|
||||
const allocs: Record<string, { rowId: string; project_name: string; quantity: number | string }[]> = {};
|
||||
q.line_items.forEach((li) => {
|
||||
const names = (li.projects || []).map((p) => p.name).filter(Boolean);
|
||||
projSel[li.id] = li.target_project?.name
|
||||
const defaultProj = li.target_project?.name
|
||||
? li.target_project.name
|
||||
: names.length
|
||||
? names[0]
|
||||
: q.projects && q.projects.length
|
||||
? q.projects[0]
|
||||
: q.project || "";
|
||||
projSel[li.id] = defaultProj;
|
||||
if (li.allocations && li.allocations.length) {
|
||||
allocs[li.id] = li.allocations.map((a, idx) => ({
|
||||
rowId: `alloc-${li.id}-${idx}`,
|
||||
project_name: a.project_name,
|
||||
quantity: a.quantity,
|
||||
}));
|
||||
} else {
|
||||
allocs[li.id] = [
|
||||
{
|
||||
rowId: `alloc-${li.id}-0`,
|
||||
project_name: defaultProj,
|
||||
quantity: li.quantity ?? 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
setItemProjectsSel(projSel);
|
||||
setItemAllocations(allocs);
|
||||
setReady(true);
|
||||
}, [q]);
|
||||
|
||||
@@ -137,6 +280,44 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
setAssignments((a) => ({ ...a, [liId]: domId }));
|
||||
};
|
||||
|
||||
const addAllocationRow = (liId: string, totalQty: number) => {
|
||||
setItemAllocations((prev) => {
|
||||
const currentList = prev[liId] || [];
|
||||
const currentSum = currentList.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
|
||||
const remaining = Math.max(0, Math.round((totalQty - currentSum) * 10000) / 10000);
|
||||
const usedProjects = new Set(currentList.map((a) => a.project_name).filter(Boolean));
|
||||
const firstAvailable = projectOptions.find((p) => !usedProjects.has(p)) || "";
|
||||
const newRow = {
|
||||
rowId: `alloc-${liId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
project_name: firstAvailable,
|
||||
quantity: remaining > 0 ? remaining : "",
|
||||
};
|
||||
return { ...prev, [liId]: [...currentList, newRow] };
|
||||
});
|
||||
};
|
||||
|
||||
const removeAllocationRow = (liId: string, rowId: string) => {
|
||||
setItemAllocations((prev) => {
|
||||
const currentList = prev[liId] || [];
|
||||
if (currentList.length <= 1) return prev;
|
||||
return { ...prev, [liId]: currentList.filter((a) => a.rowId !== rowId) };
|
||||
});
|
||||
};
|
||||
|
||||
const updateAllocationRow = (
|
||||
liId: string,
|
||||
rowId: string,
|
||||
patch: Partial<{ project_name: string; quantity: number | string }>,
|
||||
) => {
|
||||
setItemAllocations((prev) => {
|
||||
const currentList = prev[liId] || [];
|
||||
return {
|
||||
...prev,
|
||||
[liId]: currentList.map((a) => (a.rowId === rowId ? { ...a, ...patch } : a)),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
if (!q) return;
|
||||
|
||||
@@ -162,6 +343,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
next.line_items.forEach((li) => {
|
||||
if (removedItems.has(li.id)) li.rejected = true;
|
||||
});
|
||||
|
||||
const active = activeItems(next);
|
||||
if (!active.length) {
|
||||
toast.error("همهی قلمها رد شدهاند؛ حداقل یک قلم لازم است.");
|
||||
@@ -206,13 +388,60 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||
});
|
||||
|
||||
// پروژهی هر قلم (تکی هدف خرید) — فقط target_project تنظیم میشود؛
|
||||
// li.projects و next.projects دستنخورده باقی میمانند تا پروژههای کاندیدای اولیه حفظ شوند
|
||||
for (const li of active) {
|
||||
const allocs = itemAllocations[li.id] || [];
|
||||
const m = materialById(materials, li.material_id);
|
||||
const title = formatMaterialTitle(m);
|
||||
const targetQty = Number(li.quantity ?? 0);
|
||||
|
||||
if (!allocs.length) {
|
||||
toast.error(`برای قلم «${title}» حداقل یک ردیف تخصیص به پروژه لازم است.`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const a of allocs) {
|
||||
if (!a.project_name.trim()) {
|
||||
toast.error(`برای قلم «${title}»، لطفاً پروژهٔ تمام ردیفهای تخصیص را مشخص کنید.`);
|
||||
return;
|
||||
}
|
||||
const qVal = parseFloat(String(a.quantity));
|
||||
if (isNaN(qVal) || qVal <= 0) {
|
||||
toast.error(`برای قلم «${title}»، مقدار تخصیص باید عددی بزرگتر از صفر باشد.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const pNames = allocs.map((a) => a.project_name.trim());
|
||||
if (new Set(pNames).size !== pNames.length) {
|
||||
toast.error(`برای قلم «${title}»، پروژهٔ تکراری در ردیفهای تخصیص انتخاب شده است.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sumQty = allocs.reduce((sum, a) => sum + (parseFloat(String(a.quantity)) || 0), 0);
|
||||
const diff = Math.abs(sumQty - targetQty);
|
||||
if (diff > 0.0001) {
|
||||
toast.error(
|
||||
`مجموع مقادیر تخصیصیافته برای «${title}» (${fmtNum(sumQty)} ${li.unit}) با مقدار کل قلم (${fmtNum(targetQty)} ${li.unit}) برابر نیست.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
next.line_items.forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const name = itemProjectsSel[li.id];
|
||||
if (name === undefined) return;
|
||||
li.target_project = name ? { id: "", name } : null;
|
||||
const allocs = itemAllocations[li.id];
|
||||
if (allocs && allocs.length) {
|
||||
li.allocations = allocs.map((a) => ({
|
||||
project_name: a.project_name.trim(),
|
||||
quantity: parseFloat(String(a.quantity)) || 0,
|
||||
}));
|
||||
li.target_project = { id: "", name: allocs[0].project_name.trim() };
|
||||
} else {
|
||||
const name = itemProjectsSel[li.id];
|
||||
if (name !== undefined) {
|
||||
li.target_project = name ? { id: "", name } : null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@@ -232,7 +461,7 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
<DialogContent className="max-w-3xl p-0 gap-0 overflow-hidden bg-slate-50 border-none shadow-2xl rounded-xl">
|
||||
<div className="flex flex-col max-h-[85vh] w-full">
|
||||
|
||||
{/* هدر ثابت و همتراز */}
|
||||
{/* هدر ثابت */}
|
||||
<div className="px-6 py-4 bg-white border-b border-line shrink-0 z-10 text-right">
|
||||
<DialogHeader className="p-0 text-right">
|
||||
<DialogTitle className="text-right text-lg font-bold text-slate-800">
|
||||
@@ -343,53 +572,196 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* بخش ۳: تخصیص اقلام به پروژه */}
|
||||
{/* بخش ۳: تخصیص اقلام به پروژهها */}
|
||||
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
|
||||
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h3 className="text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژهها</h3>
|
||||
</div>
|
||||
<div className="mb-4 text-[12px] text-muted">
|
||||
برای هر متریال خریداری شده، میتوانید پروژه مربوط به آن را مشخص کنید.
|
||||
مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده میشود. در صورت خرید یک کالا برای چند پروژه، میتوانید آن را به چند سطر تفکیک کنید.
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{allItems
|
||||
.filter((li) => !removedItems.has(li.id))
|
||||
.map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
return (
|
||||
<div key={li.id} className="rounded-lg border border-line p-3 flex flex-col justify-between hover:border-primary/30 transition-colors">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-[13px] font-semibold text-slate-800 truncate">
|
||||
{formatMaterialTitle(m)}
|
||||
</span>
|
||||
<span className="ms-2 text-[11px] text-muted bg-slate-100 px-2 py-0.5 rounded-full whitespace-nowrap">
|
||||
{fmtNum(li.quantity)} {li.unit}
|
||||
</span>
|
||||
|
||||
{projectOptions.length === 0 ? (
|
||||
<div className="text-[12px] text-amber-700 bg-amber-50 border border-amber-200 p-3 rounded-md text-center">
|
||||
⚠️ هیچ پروژهای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژهها، حداقل یک پروژه ایجاد کنید.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{allItems
|
||||
.filter((li) => !removedItems.has(li.id))
|
||||
.map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
const targetQty = Number(li.quantity ?? 0);
|
||||
const allocs = itemAllocations[li.id] || [];
|
||||
const allocSum = allocs.reduce(
|
||||
(sum, a) => sum + (parseFloat(String(a.quantity)) || 0),
|
||||
0
|
||||
);
|
||||
const diff = Math.round((targetQty - allocSum) * 10000) / 10000;
|
||||
const isComplete = Math.abs(diff) < 0.0001;
|
||||
const isOver = diff < -0.0001;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={li.id}
|
||||
className="rounded-xl border border-line bg-slate-50/50 p-4 transition-all hover:border-primary/30"
|
||||
>
|
||||
{/* هدر کارت قلم */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 mb-3 pb-2.5 border-b border-line/60">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-primary shrink-0" />
|
||||
<span className="text-[13.5px] font-bold text-slate-800">
|
||||
{formatMaterialTitle(m)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-medium text-slate-600 bg-white border border-line px-2.5 py-0.5 rounded-full">
|
||||
کل: {fmtNum(targetQty)} {li.unit}
|
||||
</span>
|
||||
|
||||
{isComplete ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-emerald-700 bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 rounded-full">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
تخصیص کامل ({fmtNum(allocSum)} {li.unit})
|
||||
</span>
|
||||
) : isOver ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-red-700 bg-red-50 border border-red-200 px-2.5 py-0.5 rounded-full">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
مازاد تخصیص: {fmtNum(Math.abs(diff))} {li.unit}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-amber-700 bg-amber-50 border border-amber-200 px-2.5 py-0.5 rounded-full">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
باقیمانده: {fmtNum(diff)} {li.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ردیفهای تخصیص — تراز افقی یکپارچه بدون جابجایی دکمهها */}
|
||||
<div className="flex flex-col gap-2.5 mb-3">
|
||||
{allocs.map((alloc) => {
|
||||
const otherUsedProjects = new Set(
|
||||
allocs
|
||||
.filter((a) => a.rowId !== alloc.rowId)
|
||||
.map((a) => a.project_name.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
|
||||
const words = numberToPersianWords(alloc.quantity);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={alloc.rowId}
|
||||
className="flex flex-col sm:flex-row items-start gap-2.5 bg-white p-3 rounded-lg border border-line shadow-xs"
|
||||
>
|
||||
{/* انتخاب پروژه و دکمه حذف موبایل */}
|
||||
<div className="flex items-center gap-2 flex-1 w-full sm:w-auto">
|
||||
<div className="flex-1 min-w-0">
|
||||
<select
|
||||
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all text-slate-700"
|
||||
value={alloc.project_name}
|
||||
onChange={(e) =>
|
||||
updateAllocationRow(li.id, alloc.rowId, {
|
||||
project_name: e.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">انتخاب پروژه...</option>
|
||||
{projectOptions.map((p) => {
|
||||
const isUsed = otherUsedProjects.has(p);
|
||||
return (
|
||||
<option key={p} value={p} disabled={isUsed}>
|
||||
{p} {isUsed ? "(در ردیف دیگر انتخاب شده)" : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* دکمه حذف موبایل */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={allocs.length <= 1}
|
||||
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||||
className="sm:hidden h-9 w-9 flex items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||||
title="حذف این ردیف تخصیص"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* فیلد مقدار بدون اسپینر + واحد ثابت + متن حروفی */}
|
||||
<div className="flex flex-col w-full sm:w-44 shrink-0">
|
||||
<div className="flex items-stretch h-9 rounded-md border border-line bg-panel overflow-hidden focus-within:border-primary focus-within:ring-1 focus-within:ring-primary transition-all">
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
min="0"
|
||||
placeholder="مقدار"
|
||||
className="flex-1 min-w-0 h-full bg-transparent px-2.5 py-1 font-inherit text-[12px] outline-none text-left font-mono [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
style={{ MozAppearance: "textfield" }}
|
||||
dir="ltr"
|
||||
value={alloc.quantity}
|
||||
onChange={(e) =>
|
||||
updateAllocationRow(li.id, alloc.rowId, {
|
||||
quantity: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className="px-2.5 bg-slate-100 border-s border-line text-slate-600 text-[12px] font-medium flex items-center justify-center shrink-0 select-none min-w-[42px]"
|
||||
title={`واحد: ${li.unit}`}
|
||||
>
|
||||
{li.unit}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{words ? (
|
||||
<div className="mt-1 text-[11px] text-slate-500 font-medium px-0.5 leading-tight text-right truncate" title={`${words} ${li.unit}`}>
|
||||
{words} {li.unit}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* دکمه حذف دسکتاپ — تراز شده با لبه بالای دراپداون و اینپوت */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={allocs.length <= 1}
|
||||
onClick={() => removeAllocationRow(li.id, alloc.rowId)}
|
||||
className="hidden sm:flex h-9 w-9 items-center justify-center rounded-md border border-line text-slate-400 hover:text-red-600 hover:bg-red-50 hover:border-red-200 transition-colors disabled:opacity-30 disabled:pointer-events-none shrink-0"
|
||||
title="حذف این ردیف تخصیص"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* فوتر کارت قلم */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addAllocationRow(li.id, targetQty)}
|
||||
disabled={
|
||||
allocs.length >= projectOptions.length && projectOptions.length > 0
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 text-[12px] font-medium text-primary hover:text-primary/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>افزودن ردیف تخصیص (پروژه دیگر)</span>
|
||||
</button>
|
||||
<span className="text-[11px] text-muted">
|
||||
مجموع: {fmtNum(allocSum)} از {fmtNum(targetQty)} {li.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{projectOptions.length ? (
|
||||
<select
|
||||
className="w-full h-9 rounded-md border border-line bg-panel px-2.5 py-1.5 font-inherit text-[12px] outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
value={itemProjectsSel[li.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setItemProjectsSel((s) => ({ ...s, [li.id]: e.target.value }))
|
||||
}
|
||||
>
|
||||
{(itemProjectsSel[li.id] ? [] : [{ name: "" }]).map((_, i) => (
|
||||
<option key={i} value="">
|
||||
انتخاب پروژه...
|
||||
</option>
|
||||
))}
|
||||
{projectOptions.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="text-[12px] text-muted bg-slate-50 p-2 rounded-md text-center">پروژهای در سیستم ثبت نشده است.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* بخش ۴: فاکتورها، پیشفاکتورها و تاریخ */}
|
||||
@@ -441,17 +813,17 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
||||
|
||||
</div>
|
||||
|
||||
{/* فوتر ثابت */}
|
||||
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
|
||||
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
||||
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={confirm} className="w-full sm:w-auto">
|
||||
ثبت معامله بستهشده
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
{/* فوتر ثابت */}
|
||||
<div className="px-6 py-4 bg-white border-t border-line shrink-0 z-10">
|
||||
<DialogFooter className="flex w-full gap-3 sm:justify-end">
|
||||
<Button variant="slate" onClick={onClose} className="w-full sm:w-auto">
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={confirm} className="w-full sm:w-auto">
|
||||
ثبت معامله بستهشده
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -384,7 +384,26 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
||||
li.accepted_ts_id = null;
|
||||
li.target_project = null;
|
||||
li.target_project_id = null;
|
||||
li.allocations = undefined;
|
||||
});
|
||||
|
||||
// ادغام سطرهای تفکیکشده حاصل از تخصیص چندپروژهای (الزام ۲)
|
||||
const mergedMap = new Map<string, LineItem>();
|
||||
for (const li of next.line_items) {
|
||||
const key = `${li.material_id}__${li.unit || ""}__${li.unit_price ?? ""}`;
|
||||
const existing = mergedMap.get(key);
|
||||
if (existing) {
|
||||
const q1 = Number(existing.quantity ?? 0);
|
||||
const q2 = Number(li.quantity ?? 0);
|
||||
existing.quantity = Math.round((q1 + q2) * 10000) / 10000;
|
||||
if (existing.unit_price != null && existing.quantity != null) {
|
||||
existing.total_price = existing.unit_price * existing.quantity;
|
||||
}
|
||||
} else {
|
||||
mergedMap.set(key, { ...li });
|
||||
}
|
||||
}
|
||||
next.line_items = Array.from(mergedMap.values());
|
||||
} else if (next.status === "accepted") {
|
||||
next.arrived_date = null;
|
||||
next.line_items.forEach((li) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAuthStore } from "../../store/auth";
|
||||
import { inventorySubheader } from "./panelMenu";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import { refreshProcurementStore } from "../../hooks";
|
||||
import type { PendingArrival, Warehouse } from "../../lib/types";
|
||||
import type { PendingArrival, Warehouse, ProjectTransfer } from "../../lib/types";
|
||||
import { faNum, fmtDate } from "../../lib/formatters";
|
||||
|
||||
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
|
||||
@@ -25,24 +25,31 @@ export function ArrivalsPage() {
|
||||
const isPM = user?.role === "pm";
|
||||
|
||||
const [items, setItems] = useState<PendingArrival[]>([]);
|
||||
const [incomingTransfers, setIncomingTransfers] = useState<ProjectTransfer[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<string | null>(null); // quoteId
|
||||
const [transferSaving, setTransferSaving] = useState<string | null>(null); // transferId
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [drafts, setDrafts] = useState<Record<string, ItemDraft>>({});
|
||||
const [transferDrafts, setTransferDrafts] = useState<Record<string, AllocRow[]>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const activeProject = isPM ? user?.project_id || undefined : undefined;
|
||||
const [arr, whs] = await Promise.all([
|
||||
const [arr, whs, incoming] = await Promise.all([
|
||||
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM را بررسی میکند
|
||||
apiV26.pm.onTheWay(activeProject),
|
||||
apiV26.warehouses.list(activeProject),
|
||||
apiV26.projectTransfers.onTheWay(activeProject),
|
||||
]);
|
||||
setItems(arr);
|
||||
setWarehouses(whs);
|
||||
setItems(Array.isArray(arr) ? arr : []);
|
||||
setWarehouses(Array.isArray(whs) ? whs : []);
|
||||
setIncomingTransfers(Array.isArray(incoming) ? incoming : []);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
|
||||
setItems([]);
|
||||
setIncomingTransfers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -212,6 +219,53 @@ export function ArrivalsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- transfer helpers ----------
|
||||
|
||||
const transferDraftOf = (t: ProjectTransfer): AllocRow[] => {
|
||||
if (transferDrafts[t.id] && transferDrafts[t.id].length > 0) {
|
||||
return transferDrafts[t.id];
|
||||
}
|
||||
const defaultWh = warehouses.find((w) => String(w.project_id) === String(t.to_project.id))?.id || warehouses[0]?.id || "";
|
||||
return [{ key: 1, qty: String(t.quantity), whId: defaultWh }];
|
||||
};
|
||||
|
||||
const setTransferAlloc = (transferId: string, rows: AllocRow[]) => {
|
||||
setTransferDrafts((prev) => ({ ...prev, [transferId]: rows }));
|
||||
};
|
||||
|
||||
const handleReceiveTransfer = async (transfer: ProjectTransfer, allocs: AllocRow[]) => {
|
||||
const activeProject = isPM ? user?.project_id || undefined : undefined;
|
||||
const allocSum = round4(allocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
|
||||
const targetQty = parseFloat(String(transfer.quantity));
|
||||
|
||||
if (allocSum !== targetQty) {
|
||||
toast.error(`مجموع تخصیص انبارها (${faNum(allocSum)}) باید دقیقاً برابر کل بار ارسالی (${faNum(targetQty)}) باشد.`);
|
||||
return;
|
||||
}
|
||||
if (allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId)) {
|
||||
toast.error("لطفاً برای تمامی ردیفهای دارای مقدار، انبار مقصد را انتخاب کنید.");
|
||||
return;
|
||||
}
|
||||
|
||||
setTransferSaving(transfer.id);
|
||||
try {
|
||||
const payloadAllocs = allocs
|
||||
.filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId)
|
||||
.map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) }));
|
||||
|
||||
await apiV26.projectTransfers.receive(transfer.id, {
|
||||
allocations: payloadAllocs,
|
||||
}, activeProject);
|
||||
|
||||
toast.success("تحویل انتقال بین کارگاهی ۱۰۰٪ تأیید شد و به انبار پروژه وارد گردید ✓");
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در تأیید تحویل انتقال");
|
||||
} finally {
|
||||
setTransferSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin && !isPM) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
@@ -233,19 +287,171 @@ export function ArrivalsPage() {
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
||||
🚚 این بخش به مرحله «رسید» سفارشهای خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
|
||||
اینجا فقط رسید و تقسیم بین انبارها ثبت میشود. <b>رسید جزیی (قلمی و مقداری) مجاز است</b> — هر سفارش را باز
|
||||
کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلمهایی که مقداری برایشان
|
||||
وارد نشود در صف «در راه» میمانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیدهی همان قلم برابر باشد و از
|
||||
ماندهی سفارش بیشتر نشود.
|
||||
🚚 این بخش به مرحله «رسید» سفارشهای خرید و «انتقال بین کارگاهی» متصل است: اقلام ارسالی از سایر پروژهها به صورت ۱۰۰٪ تحویل و بین انبارهای پروژه تخصیص داده میشوند؛ سفارشهای خرید نیز قابلیت رسید کامل یا جزیی دارند.
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
|
||||
) : !orders.length ? (
|
||||
) : !(orders || []).length && !(incomingTransfers || []).length ? (
|
||||
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
|
||||
) : (
|
||||
orders.map(([quoteId, its]) => {
|
||||
<>
|
||||
{/* بخش انتقالهای بین کارگاهی در راه */}
|
||||
{(incomingTransfers || []).length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-[14px] font-bold text-foreground">🔁 انتقالهای بین کارگاهی در راه ({faNum((incomingTransfers || []).length)})</h3>
|
||||
<span className="text-[11.5px] text-muted">— تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژهها</span>
|
||||
</div>
|
||||
|
||||
{(incomingTransfers || []).map((t) => {
|
||||
const draftAllocs = transferDraftOf(t);
|
||||
const allocSum = round4(draftAllocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0));
|
||||
const targetQty = parseFloat(String(t.quantity));
|
||||
const isExactMatch = allocSum === targetQty;
|
||||
const hasEmptyWh = draftAllocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId);
|
||||
const isValid = isExactMatch && !hasEmptyWh && allocSum > 0;
|
||||
const days = daysElapsed(t.transfer_date);
|
||||
const projectWhs = warehouses.filter((w) => String(w.project_id) === String(t.to_project.id));
|
||||
const destWhOptions = projectWhs.length > 0 ? projectWhs : warehouses;
|
||||
|
||||
return (
|
||||
<div key={t.id} className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
|
||||
{/* سربرگ انتقال */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-line bg-panel px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[14px] font-bold text-foreground">🔁 انتقال از {t.from_project?.name || "پروژه مبدأ"}</span>
|
||||
<Badge variant="offered">در راه</Badge>
|
||||
{days !== null && (
|
||||
<Badge variant={days >= 7 ? "offered" : "muted"}>
|
||||
روز گذشته: {faNum(days)} روز
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-[11.5px] text-muted">
|
||||
<span>ارسالکننده: <b className="text-foreground">{t.transferred_by || "—"}</b></span>
|
||||
<span>تاریخ ارسال: <b className="text-foreground">{fmtDate(t.transfer_date)}</b></span>
|
||||
<span>پروژه مقصد: <b className="text-foreground">{t.to_project?.name || "—"}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-[13px] font-bold">{t.material?.name}</span>
|
||||
{t.brand?.name && <Badge variant="outline">{t.brand.name}</Badge>}
|
||||
<Badge variant="outline">
|
||||
مقدار ارسالی: {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""}
|
||||
</Badge>
|
||||
{t.specs && Object.keys(t.specs).length > 0 && (
|
||||
<span className="text-[11.5px] text-muted">
|
||||
مشخصات: {Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{t.note && (
|
||||
<div className="mt-1 text-[11.5px] text-muted">
|
||||
یادداشت ارسال: <span className="text-foreground">{t.note}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 rounded-lg border border-line/70 bg-bg/50 p-3">
|
||||
<div className="text-[12px] font-semibold text-muted mb-2">
|
||||
تخصیص ۱۰۰٪ به انبار(های) پروژه (مجموع باید دقیقاً {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} باشد):
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{draftAllocs.map((a) => (
|
||||
<div key={a.key} className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||||
placeholder="تعداد"
|
||||
value={a.qty}
|
||||
onChange={(e) => {
|
||||
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, qty: e.target.value } : x));
|
||||
setTransferAlloc(t.id, next);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
className="h-8 min-w-[200px] flex-1 rounded-lg border border-line bg-panel px-2 text-xs"
|
||||
value={a.whId}
|
||||
onChange={(e) => {
|
||||
const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, whId: e.target.value } : x));
|
||||
setTransferAlloc(t.id, next);
|
||||
}}
|
||||
>
|
||||
<option value="">— انتخاب انبار پروژه مقصد —</option>
|
||||
{destWhOptions.map((w) => (
|
||||
<option key={w.id} value={w.id}>{w.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{draftAllocs.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 px-2 text-muted hover:text-red transition-colors"
|
||||
onClick={() => {
|
||||
const next = draftAllocs.filter((x) => x.key !== a.key);
|
||||
setTransferAlloc(t.id, next);
|
||||
}}
|
||||
title="حذف این ردیف تخصیص"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[11.5px] text-accent mt-1 hover:bg-accent-bg/40 transition-colors"
|
||||
onClick={() => {
|
||||
const next = [...draftAllocs, { key: Date.now() + Math.random(), qty: "", whId: destWhOptions[0]?.id || "" }];
|
||||
setTransferAlloc(t.id, next);
|
||||
}}
|
||||
>
|
||||
+ افزودن انبار دیگر برای تقسیم بار
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-line/60 pt-2 text-[12px]">
|
||||
<div>
|
||||
{isExactMatch ? (
|
||||
<span className="font-semibold text-emerald-600">
|
||||
✓ تخصیص کامل ۱۰۰٪ ({faNum(allocSum)} از {faNum(targetQty)}) آماده تحویل
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-bold text-rose-600">
|
||||
اختلاف تخصیص: مجموع تخصیص ({faNum(allocSum)}) باید دقیقاً با مقدار کل ({faNum(targetQty)}) برابر باشد.
|
||||
</span>
|
||||
)}
|
||||
{hasEmptyWh && (
|
||||
<span className="ms-2 font-bold text-rose-600">
|
||||
— لطفاً انبار مقصد را انتخاب کنید
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="green"
|
||||
disabled={!isValid || transferSaving === t.id}
|
||||
onClick={() => void handleReceiveTransfer(t, draftAllocs)}
|
||||
>
|
||||
{transferSaving === t.id ? "در حال ثبت تحویل…" : "✓ تأیید تحویل ۱۰۰٪ و ورود به انبار"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* بخش سفارشهای خرید در راه */}
|
||||
{orders.map(([quoteId, its]) => {
|
||||
const first = its[0];
|
||||
const open = expanded.has(quoteId);
|
||||
const hasSubmittable = orderHasSubmittable(quoteId);
|
||||
@@ -401,7 +607,8 @@ export function ArrivalsPage() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</PageLayout>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
@@ -6,21 +6,22 @@ import { Combobox } from "../../components/Combobox";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { inventorySubheader } from "./panelMenu";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import { api, apiV26 } from "../../lib/api";
|
||||
import { notifyDataChanged } from "../../lib/crossTab";
|
||||
import { useInventoryData, unitName, whName } from "./useInventoryData";
|
||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||
import { uniqueUnits } from "../../lib/units";
|
||||
import type { InventoryTransaction, TransactionType } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import type { InventoryTransaction, TransactionType, Project, ProjectTransfer } from "../../lib/types";
|
||||
import { faNum, fmtDate } from "../../lib/formatters";
|
||||
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type FormType = TransactionType | "TRANSFER";
|
||||
type FormType = TransactionType | "TRANSFER" | "PROJECT_TRANSFER";
|
||||
|
||||
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
|
||||
{ t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپشده هنگام ثبت بهصورت خودکار در کاتالوگ سراسری ساخته میشود. ثبت حداقل یک مشخصه فنی الزامی است." },
|
||||
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدلها (برند و مشخصه متفاوت) نمایش داده میشوند. برداشتها در «کنترل روزانه» امروز دیده میشوند." },
|
||||
{ t: "PROJECT_TRANSFER", label: "انتقال بین کارگاهی", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انتقال متریال از انبار این پروژه به یک پروژه دیگر — پس از ثبت، موجودی در انبار مبدأ کسر شده و کالا به عنوان بار در راه برای پروژه مقصد ارسال میشود تا مدیر پروژه مقصد آن را تحویل بگیرد." },
|
||||
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شدهاند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
|
||||
{ t: "TRANSFER", label: "انتقال", dir: "جابهجایی", cls: "mv", color: "#2563eb", desc: "جابهجایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
|
||||
{ t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت ضایعات خرید (مثلاً لولههای سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." },
|
||||
@@ -53,6 +54,22 @@ export function ChangeInventoryPage() {
|
||||
|
||||
const [type, setType] = useState<FormType>("PURCHASE");
|
||||
const [whId, setWhId] = useState("");
|
||||
const [toProjectId, setToProjectId] = useState("");
|
||||
const [allProjects, setAllProjects] = useState<Project[]>([]);
|
||||
const [transfers, setTransfers] = useState<ProjectTransfer[]>([]);
|
||||
const [loadingTransfers, setLoadingTransfers] = useState(false);
|
||||
|
||||
// Edit modal state
|
||||
const [editingTransfer, setEditingTransfer] = useState<ProjectTransfer | null>(null);
|
||||
const [editQty, setEditQty] = useState("");
|
||||
const [editToProjectId, setEditToProjectId] = useState("");
|
||||
const [editNote, setEditNote] = useState("");
|
||||
const [editBusy, setEditBusy] = useState(false);
|
||||
|
||||
// Delete modal state
|
||||
const [deleteConfirmTransfer, setDeleteConfirmTransfer] = useState<ProjectTransfer | null>(null);
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
|
||||
const [fromId, setFromId] = useState("");
|
||||
const [toId, setToId] = useState("");
|
||||
const [matTxt, setMatTxt] = useState("");
|
||||
@@ -194,6 +211,82 @@ export function ChangeInventoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadTransfers = useCallback(async () => {
|
||||
if (!user?.project_id) return;
|
||||
setLoadingTransfers(true);
|
||||
try {
|
||||
const list = await apiV26.projectTransfers.list(user.project_id, "outgoing");
|
||||
setTransfers(Array.isArray(list) ? list : []);
|
||||
} catch (err) {
|
||||
console.error("Failed to load project transfers:", err);
|
||||
setTransfers([]);
|
||||
} finally {
|
||||
setLoadingTransfers(false);
|
||||
}
|
||||
}, [user?.project_id]);
|
||||
|
||||
useEffect(() => {
|
||||
api.projects
|
||||
.list()
|
||||
.then((res) => setAllProjects(Array.isArray(res) ? res : []))
|
||||
.catch(() => setAllProjects([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "PROJECT_TRANSFER") {
|
||||
void loadTransfers();
|
||||
}
|
||||
}, [type, loadTransfers]);
|
||||
|
||||
const whStock = useMemo(() => {
|
||||
if (!whId) return [];
|
||||
return (data.inventory || []).filter((r) => r.warehouse?.id === whId && r.quantity_number > 0);
|
||||
}, [data.inventory, whId]);
|
||||
|
||||
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingTransfer) return;
|
||||
const n = parseNum(editQty);
|
||||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||||
if (!editToProjectId) return toast.error("پروژه مقصد را انتخاب کنید");
|
||||
|
||||
setEditBusy(true);
|
||||
try {
|
||||
await apiV26.projectTransfers.update(editingTransfer.id, {
|
||||
quantity: n,
|
||||
to_project_id: editToProjectId,
|
||||
note: editNote,
|
||||
}, user?.project_id);
|
||||
toast.success("انتقال با موفقیت ویرایش شد ✓");
|
||||
notifyDataChanged("inventory");
|
||||
setEditingTransfer(null);
|
||||
await data.refresh();
|
||||
await loadTransfers();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ویرایش انتقال");
|
||||
} finally {
|
||||
setEditBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deleteConfirmTransfer) return;
|
||||
setDeleteBusy(true);
|
||||
try {
|
||||
await apiV26.projectTransfers.delete(deleteConfirmTransfer.id, user?.project_id);
|
||||
toast.success("انتقال لغو شد و موجودی به انبار مبدأ بازگردانده شد ✓");
|
||||
notifyDataChanged("inventory");
|
||||
setDeleteConfirmTransfer(null);
|
||||
await data.refresh();
|
||||
await loadTransfers();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در لغو انتقال");
|
||||
} finally {
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitPurchase = async () => {
|
||||
const matName = matTxt.trim();
|
||||
const brandName = brandTxt.trim();
|
||||
@@ -234,6 +327,82 @@ export function ChangeInventoryPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const submitProjectTransfer = async () => {
|
||||
const matName = matTxt.trim();
|
||||
const brandName = brandTxt.trim();
|
||||
const unitName = unitTxt.trim();
|
||||
const n = parseNum(qty);
|
||||
if (!whId) return toast.error("انبار مبدأ را انتخاب کنید");
|
||||
if (!toProjectId) return toast.error("پروژه مقصد را انتخاب کنید");
|
||||
if (!matName) return toast.error("نام متریال را وارد کنید");
|
||||
if (!unitName) return toast.error("نام واحد را وارد کنید");
|
||||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
// Find matching inventory in the selected source warehouse
|
||||
const invInWh = (data.inventory || []).filter(
|
||||
(r) => r.warehouse?.id === whId && r.material.name.trim() === matName
|
||||
);
|
||||
const matchedInv = invInWh.find(
|
||||
(r) => (brandName ? r.brand?.name === brandName : !r.brand)
|
||||
) || invInWh[0];
|
||||
|
||||
let uid = matchedInv?.unit?.id ?? unitIdByName(unitName);
|
||||
if (!uid) {
|
||||
const created = await apiV26.catalog.createUnit(unitName);
|
||||
uid = created.id;
|
||||
}
|
||||
|
||||
let bid: string | null = matchedInv?.brand?.id ?? (brandName ? brandIdByName(brandName) : null);
|
||||
if (brandName && !bid) {
|
||||
const created = await apiV26.catalog.createBrand(brandName);
|
||||
bid = created.id;
|
||||
}
|
||||
|
||||
let mid = matchedInv?.material.id;
|
||||
if (!mid) {
|
||||
let m = matByName(matName);
|
||||
if (!m) {
|
||||
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
|
||||
mid = created.id;
|
||||
} else {
|
||||
mid = m.id;
|
||||
}
|
||||
}
|
||||
|
||||
const finalSpecs = matchedInv ? (matchedInv.specs || {}) : specsFromChips();
|
||||
|
||||
await apiV26.projectTransfers.create({
|
||||
from_warehouse_id: whId,
|
||||
to_project_id: toProjectId,
|
||||
material_id: mid,
|
||||
brand_id: bid || null,
|
||||
unit_id: uid,
|
||||
quantity: n,
|
||||
specs: finalSpecs,
|
||||
note,
|
||||
transfer_date: toServerIso(),
|
||||
}, user?.project_id);
|
||||
|
||||
toast.success("انتقال بین کارگاهی ثبت شد و از انبار مبدأ کسر گردید ✓");
|
||||
notifyDataChanged("inventory");
|
||||
setQty("");
|
||||
setSpecsChips([]);
|
||||
setNote("");
|
||||
setMatTxt("");
|
||||
setBrandTxt("");
|
||||
setUnitTxt("");
|
||||
setToProjectId("");
|
||||
await data.refresh();
|
||||
await loadTransfers();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ثبت انتقال بین کارگاهی");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitList = async (t: TransactionType) => {
|
||||
const ops: Record<string, unknown>[] = rows
|
||||
.map((r, i): Record<string, unknown> | null => {
|
||||
@@ -356,6 +525,7 @@ export function ChangeInventoryPage() {
|
||||
const doSubmit = () => {
|
||||
try {
|
||||
if (type === "PURCHASE") void submitPurchase();
|
||||
else if (type === "PROJECT_TRANSFER") void submitProjectTransfer();
|
||||
else if (type === "RETURN") void submitReturn();
|
||||
else if (type === "TRANSFER") void submitTransfer();
|
||||
else if (type === "FOUND") void submitFound();
|
||||
@@ -370,6 +540,9 @@ export function ChangeInventoryPage() {
|
||||
if (type === "PURCHASE") {
|
||||
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
|
||||
}
|
||||
if (type === "PROJECT_TRANSFER") {
|
||||
return !!(whId && toProjectId && matTxt.trim() && unitTxt.trim() && parseNum(qty) > 0);
|
||||
}
|
||||
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||
if (type === "FOUND") {
|
||||
@@ -463,7 +636,47 @@ export function ChangeInventoryPage() {
|
||||
|
||||
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
|
||||
{/* انتخاب انبارها (چیپهای لمسی به جای سلکت خام) */}
|
||||
{!isTransfer ? (
|
||||
{type === "PROJECT_TRANSFER" ? (
|
||||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">از (انبار مبدأ این پروژه) *</label>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{whOpts.map((w) => (
|
||||
<button
|
||||
key={w.id}
|
||||
type="button"
|
||||
onClick={() => { setWhId(w.id); }}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
|
||||
whId === w.id
|
||||
? "bg-rose-600 text-white font-bold shadow-xs"
|
||||
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||||
)}
|
||||
>
|
||||
{w.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">به (پروژه مقصد) *</label>
|
||||
<select
|
||||
className="mt-1 h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm font-medium"
|
||||
value={toProjectId}
|
||||
onChange={(e) => setToProjectId(e.target.value)}
|
||||
>
|
||||
<option value="">— انتخاب پروژه مقصد —</option>
|
||||
{(allProjects || [])
|
||||
.filter((p) => p.id !== user?.project_id)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : !isTransfer ? (
|
||||
<div className="mb-3 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold text-muted">انبار *</label>
|
||||
@@ -561,9 +774,72 @@ export function ChangeInventoryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* فرم خرید / موجودی اولیه */}
|
||||
{type === "PURCHASE" && (
|
||||
{/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */}
|
||||
{(type === "PURCHASE" || type === "PROJECT_TRANSFER") && (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{/* چیپهای انتخاب سریع از موجودیهای انبار مبدأ */}
|
||||
{type === "PROJECT_TRANSFER" && whId && (
|
||||
<div className="field md:col-span-2 rounded-xl border border-rose-500/20 bg-rose-500/5 p-3.5">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
|
||||
<span>📦</span>
|
||||
<span>موجودیهای موجود در {whName(warehouses, whId)} — برای پر کردن خودکار فرم کلیک کنید:</span>
|
||||
</span>
|
||||
<span className="text-[11px] text-muted">{faNum(whStock.length)} قلم کالا</span>
|
||||
</div>
|
||||
{whStock.length === 0 ? (
|
||||
<div className="text-xs text-muted py-1">هیچ کالایی با موجودی مثبت در این انبار یافت نشد.</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{whStock.map((r) => {
|
||||
const isSelected = matTxt === r.material.name && (brandTxt === (r.brand?.name || "") || (!brandTxt && !r.brand));
|
||||
const specsSummary = Object.entries(r.specs || {})
|
||||
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMatTxt(r.material.name);
|
||||
setBrandTxt(r.brand?.name || "");
|
||||
setUnitTxt(unitName(data.lookup.units, r.unit?.id) || "");
|
||||
const chips = Object.entries(r.specs || {})
|
||||
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
|
||||
.filter(Boolean);
|
||||
setSpecsChips(chips);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs text-start transition-all shadow-2xs",
|
||||
isSelected
|
||||
? "border-rose-500 bg-rose-600 text-white font-bold ring-2 ring-rose-500/30"
|
||||
: "border-line bg-panel hover:border-ink/40 text-ink"
|
||||
)}
|
||||
>
|
||||
<span className="font-bold">{r.material.name}</span>
|
||||
<span className={isSelected ? "text-white/80" : "text-muted font-normal"}>
|
||||
({r.brand?.name || "بدون برند"})
|
||||
</span>
|
||||
{specsSummary && (
|
||||
<span className={isSelected ? "text-white/90" : "text-muted text-[11px]"}>
|
||||
[{specsSummary}]
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"rounded-md px-1.5 py-0.5 text-[11.5px] font-black",
|
||||
isSelected ? "bg-white/20 text-white" : "bg-bg text-foreground"
|
||||
)}>
|
||||
{faNum(r.quantity_number)} {unitName(data.lookup.units, r.unit?.id)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field md:col-span-2">
|
||||
<label className="text-xs font-semibold text-muted">متریال *</label>
|
||||
<Combobox
|
||||
@@ -582,13 +858,15 @@ export function ChangeInventoryPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="text-xs font-semibold text-muted">برند *</label>
|
||||
<label className="text-xs font-semibold text-muted">
|
||||
برند {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
|
||||
</label>
|
||||
<Combobox
|
||||
value={brandTxt}
|
||||
items={data.lookup.brands.map((b) => b.name)}
|
||||
allowAdd
|
||||
addSuffix="به عنوان برند جدید"
|
||||
placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"
|
||||
placeholder={type === "PROJECT_TRANSFER" ? "نام برند (اختیاری — برای اقلام بدون برند خالی بگذارید)" : "نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"}
|
||||
onInputChange={setBrandTxt}
|
||||
onChange={(it) => setBrandTxt(it.label)}
|
||||
/>
|
||||
@@ -625,11 +903,13 @@ export function ChangeInventoryPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="field md:col-span-2">
|
||||
<label className="text-xs font-semibold text-muted">مشخصههای فنی *</label>
|
||||
<label className="text-xs font-semibold text-muted">
|
||||
مشخصههای فنی {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
|
||||
</label>
|
||||
<MultiSelectCombobox
|
||||
values={specsChips}
|
||||
onChange={setSpecsChips}
|
||||
placeholder="نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"
|
||||
placeholder={type === "PROJECT_TRANSFER" ? "مشخصات فنی (اختیاری — مثلاً مسی، سایز و…)" : "نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -637,7 +917,7 @@ export function ChangeInventoryPage() {
|
||||
<label className="text-xs font-semibold text-muted">یادداشت</label>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
|
||||
placeholder="شماره فاکتور، تأمینکننده، توضیحات…"
|
||||
placeholder={type === "PROJECT_TRANSFER" ? "توضیحات بار ارسالی، شماره بارنامه، راننده…" : "شماره فاکتور، تأمینکننده، توضیحات…"}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
@@ -702,11 +982,234 @@ export function ChangeInventoryPage() {
|
||||
className="w-full sm:w-auto rounded-lg px-5 py-2.5 text-xs sm:text-[13px] font-bold text-white disabled:opacity-40 shadow-xs transition-transform active:scale-95"
|
||||
style={{ background: meta.color }}
|
||||
>
|
||||
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
|
||||
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "انتقال بین کارگاهی" ? "ثبت انتقال بین کارگاهی و کسر از انبار" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* بخش سوابق انتقالهای بین کارگاهی این پروژه */}
|
||||
{type === "PROJECT_TRANSFER" && (
|
||||
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-line pb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-foreground">📋 سوابق انتقالهای بین کارگاهی این پروژه</h3>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted">
|
||||
مصالح ارسالی به سایر پروژهها؛ تا زمان تأیید تحویل در مقصد میتوانید آنها را ویرایش یا لغو کنید.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadTransfers()}
|
||||
className="rounded-lg border border-line px-3 py-1.5 text-xs font-medium hover:bg-bg transition-colors"
|
||||
>
|
||||
🔄 بهروزرسانی لیست
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingTransfers ? (
|
||||
<div className="py-8 text-center text-xs text-muted">در حال بارگذاری سوابق…</div>
|
||||
) : !(transfers || []).length ? (
|
||||
<div className="py-8 text-center text-xs text-muted">هیچ انتقال بین کارگاهی از این پروژه ثبت نشده است.</div>
|
||||
) : (
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-right text-xs min-w-[650px]">
|
||||
<thead>
|
||||
<tr className="border-b border-line text-muted">
|
||||
<th className="py-2.5 pe-3">تاریخ انتقال</th>
|
||||
<th className="py-2.5 pe-3">متریال / برند</th>
|
||||
<th className="py-2.5 pe-3">مشخصات فنی</th>
|
||||
<th className="py-2.5 pe-3">مقدار</th>
|
||||
<th className="py-2.5 pe-3">مبدأ → مقصد</th>
|
||||
<th className="py-2.5 pe-3">وضعیت</th>
|
||||
<th className="py-2.5 text-center">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/50">
|
||||
{(transfers || []).map((t) => {
|
||||
const isInTransit = t.status === "in_transit";
|
||||
return (
|
||||
<tr key={t.id} className="hover:bg-bg/40">
|
||||
<td className="py-3 pe-3 text-muted">{fmtDate(t.transfer_date)}</td>
|
||||
<td className="py-3 pe-3 font-semibold">
|
||||
<div>{t.material?.name}</div>
|
||||
<div className="text-[11px] text-muted font-normal">{t.brand?.name || "بدون برند"}</div>
|
||||
</td>
|
||||
<td className="py-3 pe-3 text-muted text-[11.5px]">
|
||||
{t.specs && Object.keys(t.specs).length > 0
|
||||
? Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="py-3 pe-3 font-bold text-foreground">
|
||||
{faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code}
|
||||
</td>
|
||||
<td className="py-3 pe-3">
|
||||
<div className="text-[11px] text-muted">از: {t.from_warehouse?.name}</div>
|
||||
<div className="text-[12px] font-medium text-foreground">به: {t.to_project?.name}</div>
|
||||
</td>
|
||||
<td className="py-3 pe-3">
|
||||
{isInTransit ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2.5 py-0.5 text-[11px] font-bold text-amber-700 border border-amber-500/20">
|
||||
🟡 در انتظار تأیید مقصد
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-bold text-emerald-700 border border-emerald-500/20">
|
||||
🟢 تحویلشده در مقصد
|
||||
</span>
|
||||
{t.to_warehouse?.name && (
|
||||
<span className="text-[10.5px] text-muted">انبار: {t.to_warehouse.name}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 text-center">
|
||||
{isInTransit ? (
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingTransfer(t);
|
||||
setEditQty(String(t.quantity));
|
||||
setEditToProjectId(t.to_project?.id || "");
|
||||
setEditNote(t.note || "");
|
||||
}}
|
||||
className="rounded-lg border border-line px-2.5 py-1 text-[11.5px] font-medium hover:bg-bg transition-colors"
|
||||
title="ویرایش انتقال"
|
||||
>
|
||||
✏️ ویرایش
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteConfirmTransfer(t)}
|
||||
className="rounded-lg border border-rose-200 bg-rose-50 px-2.5 py-1 text-[11.5px] font-medium text-rose-700 hover:bg-rose-100 transition-colors"
|
||||
title="لغو انتقال"
|
||||
>
|
||||
✕ لغو
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted font-medium">
|
||||
🔒 قفل
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* مودال ویرایش انتقال */}
|
||||
{editingTransfer && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !editBusy && setEditingTransfer(null)}>
|
||||
<div className="w-full max-w-md rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="mb-2 text-[14px] font-bold">✏️ ویرایش انتقال بین کارگاهی</h3>
|
||||
<p className="mb-3 text-[11.5px] text-muted">
|
||||
تغییر تعداد در انبار مبدأ تعدیل میگردد. پس از تحویل در مقصد، ویرایش غیرفعال خواهد شد.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="rounded-lg border border-line bg-bg/50 p-2.5 text-xs">
|
||||
<div>متریال: <b>{editingTransfer.material?.name}</b></div>
|
||||
<div className="text-muted mt-0.5">انبار مبدأ: {editingTransfer.from_warehouse?.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="text-xs font-semibold text-muted">پروژه مقصد *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm font-medium"
|
||||
value={editToProjectId}
|
||||
onChange={(e) => setEditToProjectId(e.target.value)}
|
||||
>
|
||||
{(allProjects || [])
|
||||
.filter((p) => p.id !== user?.project_id)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="text-xs font-semibold text-muted">
|
||||
تعداد ({editingTransfer.unit?.name_fa || editingTransfer.unit?.code}) *
|
||||
</label>
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm ltr"
|
||||
value={editQty}
|
||||
onChange={(e) => setEditQty(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="text-xs font-semibold text-muted">یادداشت</label>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
|
||||
value={editNote}
|
||||
onChange={(e) => setEditNote(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2 border-t border-line pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
|
||||
onClick={() => setEditingTransfer(null)}
|
||||
disabled={editBusy}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
onClick={() => void handleSaveEdit()}
|
||||
disabled={editBusy}
|
||||
>
|
||||
{editBusy ? "در حال ذخیره…" : "ذخیره تغییرات"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* دیالوگ تأیید لغو انتقال */}
|
||||
{deleteConfirmTransfer && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !deleteBusy && setDeleteConfirmTransfer(null)}>
|
||||
<div className="w-full max-w-sm rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="mb-2 text-[14px] font-bold text-rose-600">⚠️ لغو انتقال بین کارگاهی</h3>
|
||||
<p className="mb-3 text-[12px] leading-6 text-foreground">
|
||||
آیا از لغو این انتقال اطمینان دارید؟ با لغو انتقال، مقدار <b>{faNum(deleteConfirmTransfer.quantity)} {deleteConfirmTransfer.unit?.name_fa || deleteConfirmTransfer.unit?.code}</b> از متریال <b>{deleteConfirmTransfer.material?.name}</b> فوراً به انبار مبدأ ({deleteConfirmTransfer.from_warehouse?.name}) بازگردانده خواهد شد.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2 border-t border-line pt-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
|
||||
onClick={() => setDeleteConfirmTransfer(null)}
|
||||
disabled={deleteBusy}
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-rose-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-rose-700 disabled:opacity-50"
|
||||
onClick={() => void handleConfirmDelete()}
|
||||
disabled={deleteBusy}
|
||||
>
|
||||
{deleteBusy ? "در حال لغو…" : "بله، لغو و بازگشت موجودی"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* مودال تأیید شمارش موجودی */}
|
||||
{countConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
|
||||
@@ -771,7 +1274,11 @@ export function ChangeInventoryPage() {
|
||||
)}
|
||||
|
||||
<div className="text-[11px] text-muted">
|
||||
{isTransfer ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد همزمان ثبت میشود." : `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
|
||||
{type === "PROJECT_TRANSFER"
|
||||
? "انتقال بین کارگاهی: موجودی فوراً از انبار مبدأ کسر شده و پس از تأیید تحویل توسط مدیر پروژه مقصد، وارد انبار مقصد میشود."
|
||||
: isTransfer
|
||||
? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد همزمان ثبت میشود."
|
||||
: `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
|
||||
@@ -551,6 +551,35 @@ CREATE TABLE standard_conversions (
|
||||
CONSTRAINT uq_standard_conversion UNIQUE (from_unit, to_unit)
|
||||
);
|
||||
|
||||
-- Inter-project material transfers
|
||||
CREATE TABLE 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()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_project_transfers_from ON project_transfers(from_project_id, status);
|
||||
CREATE INDEX idx_project_transfers_to ON project_transfers(to_project_id, status);
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- UPDATED_AT TRIGGER (auto-set updated_at on row modification)
|
||||
-- ============================================================================
|
||||
@@ -579,6 +608,10 @@ CREATE TRIGGER trg_quote_attachments_updated_at
|
||||
BEFORE UPDATE ON quote_attachments
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TRIGGER trg_project_transfers_updated_at
|
||||
BEFORE UPDATE ON project_transfers
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- ============================================================================
|
||||
-- AUTOMATION TRIGGERS (v2.5 + v2.6)
|
||||
-- ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user