diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 5dacd28..38dcc82 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -436,3 +436,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") + diff --git a/backend/app/main.py b/backend/app/main.py index adf54bb..42d9f97 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ceb57d8..54296b2 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/project_transfer.py b/backend/app/models/project_transfer.py new file mode 100644 index 0000000..cca9779 --- /dev/null +++ b/backend/app/models/project_transfer.py @@ -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" + ) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index edbe584..3b5c631 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -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, ] diff --git a/backend/app/routers/project_transfers.py b/backend/app/routers/project_transfers.py new file mode 100644 index 0000000..d297584 --- /dev/null +++ b/backend/app/routers/project_transfers.py @@ -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)} diff --git a/backend/app/schemas/procurement.py b/backend/app/schemas/procurement.py index 2d6f0b6..372131b 100644 --- a/backend/app/schemas/procurement.py +++ b/backend/app/schemas/procurement.py @@ -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): diff --git a/backend/app/schemas/project_transfers.py b/backend/app/schemas/project_transfers.py new file mode 100644 index 0000000..ac45b95 --- /dev/null +++ b/backend/app/schemas/project_transfers.py @@ -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 diff --git a/backend/app/services/project_transfer_service.py b/backend/app/services/project_transfer_service.py new file mode 100644 index 0000000..da002cf --- /dev/null +++ b/backend/app/services/project_transfer_service.py @@ -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()) diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index f776986..8b18746 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -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: diff --git a/backend/tools/seed.py b/backend/tools/seed.py index 0e97c7c..35c7237 100644 --- a/backend/tools/seed.py +++ b/backend/tools/seed.py @@ -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"), diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6627259..3a2b722 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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(path: string, init?: RequestInit): Promise { } async function getData(path: string): Promise { - const res = await request<{ data: T }>(path); - return res.data; + const res = await request(path); + if (res && typeof res === "object" && "data" in res) { + return res.data as T; + } + return res as T; } async function postData(path: string, body: unknown): Promise { - const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) }); - return res.data; + const res = await request(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(path: string, body: unknown): Promise { - const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) }); - return res.data; + const res = await request(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(path: string): Promise { - const res = await request<{ data: T }>(path, { method: "DELETE" }); - return res.data; + const res = await request(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(); + const allocationsByLine = new Map(); 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 => { + 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(`/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 => + getData(projectId ? `/inventory/project-transfers/${id}?project_id=${projectId}` : `/inventory/project-transfers/${id}`), + create: async (body: ProjectTransferCreateIn, projectId?: string | null): Promise => + postData(projectId ? `/inventory/project-transfers?project_id=${projectId}` : `/inventory/project-transfers`, body), + update: async (id: string, patch: ProjectTransferUpdateIn, projectId?: string | null): Promise => + patchData(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 => { + try { + const res = await getData(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 => + postData(projectId ? `/inventory/project-transfers/${id}/receive?project_id=${projectId}` : `/inventory/project-transfers/${id}/receive`, body), + }, }; + diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index c1e7a21..e776b93 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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) @@ -200,3 +201,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; + 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; + 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; +} + diff --git a/frontend/src/pages/Dashboard/AcceptModal.tsx b/frontend/src/pages/Dashboard/AcceptModal.tsx index df384e0..5072acf 100644 --- a/frontend/src/pages/Dashboard/AcceptModal.tsx +++ b/frontend/src/pages/Dashboard/AcceptModal.tsx @@ -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 = { + 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>(new Set()); const [assignments, setAssignments] = useState>({}); const [itemProjectsSel, setItemProjectsSel] = useState>({}); + const [itemAllocations, setItemAllocations] = useState< + Record + >({}); const [acceptDate, setAcceptDate] = useState(() => new Date().toISOString()); const [ready, setReady] = useState(false); @@ -67,17 +192,35 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) { setRemovedItems(rem); setAssignments({}); const projSel: Record = {}; + const allocs: Record = {}; 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) {
- {/* هدر ثابت و هم‌تراز */} + {/* هدر ثابت */}
@@ -343,53 +572,196 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
- {/* بخش ۳: تخصیص اقلام به پروژه */} + {/* بخش ۳: تخصیص اقلام به پروژه‌ها */}
-

۳. تخصیص اقلام به پروژه‌ها

+
+

۳. تخصیص اقلام به پروژه‌ها

+
- برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید. + مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده می‌شود. در صورت خرید یک کالا برای چند پروژه، می‌توانید آن را به چند سطر تفکیک کنید.
-
- {allItems - .filter((li) => !removedItems.has(li.id)) - .map((li) => { - const m = materialById(materials, li.material_id); - return ( -
-
- - {formatMaterialTitle(m)} - - - {fmtNum(li.quantity)} {li.unit} - + + {projectOptions.length === 0 ? ( +
+ ⚠️ هیچ پروژه‌ای در سیستم ثبت نشده است. لطفاً ابتدا از بخش پروژه‌ها، حداقل یک پروژه ایجاد کنید. +
+ ) : ( +
+ {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 ( +
+ {/* هدر کارت قلم */} +
+
+ + + {formatMaterialTitle(m)} + +
+ +
+ + کل: {fmtNum(targetQty)} {li.unit} + + + {isComplete ? ( + + + تخصیص کامل ({fmtNum(allocSum)} {li.unit}) + + ) : isOver ? ( + + + مازاد تخصیص: {fmtNum(Math.abs(diff))} {li.unit} + + ) : ( + + + باقیمانده: {fmtNum(diff)} {li.unit} + + )} +
+
+ + {/* ردیف‌های تخصیص — تراز افقی یکپارچه بدون جابجایی دکمه‌ها */} +
+ {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 ( +
+ {/* انتخاب پروژه و دکمه حذف موبایل */} +
+
+ +
+ + {/* دکمه حذف موبایل */} + +
+ + {/* فیلد مقدار بدون اسپینر + واحد ثابت + متن حروفی */} +
+
+ + updateAllocationRow(li.id, alloc.rowId, { + quantity: e.target.value, + }) + } + /> +
+ {li.unit} +
+
+ + {words ? ( +
+ {words} {li.unit} +
+ ) : null} +
+ + {/* دکمه حذف دسکتاپ — تراز شده با لبه بالای دراپ‌داون و اینپوت */} + +
+ ); + })} +
+ + {/* فوتر کارت قلم */} +
+ + + مجموع: {fmtNum(allocSum)} از {fmtNum(targetQty)} {li.unit} + +
- {projectOptions.length ? ( - - ) : ( -
پروژه‌ای در سیستم ثبت نشده است.
- )} -
- ); - })} -
+ ); + })} +
+ )}
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */} @@ -441,17 +813,17 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
- {/* فوتر ثابت */} -
- - - - -
+ {/* فوتر ثابت */} +
+ + + + +
diff --git a/frontend/src/pages/Dashboard/EditModal.tsx b/frontend/src/pages/Dashboard/EditModal.tsx index 6b10617..7ae13b1 100644 --- a/frontend/src/pages/Dashboard/EditModal.tsx +++ b/frontend/src/pages/Dashboard/EditModal.tsx @@ -438,7 +438,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(); + 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) => { diff --git a/frontend/src/pages/Inventory/ArrivalsPage.tsx b/frontend/src/pages/Inventory/ArrivalsPage.tsx index bddd4bb..b8a0ad7 100644 --- a/frontend/src/pages/Inventory/ArrivalsPage.tsx +++ b/frontend/src/pages/Inventory/ArrivalsPage.tsx @@ -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([]); + const [incomingTransfers, setIncomingTransfers] = useState([]); const [warehouses, setWarehouses] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(null); // quoteId + const [transferSaving, setTransferSaving] = useState(null); // transferId const [expanded, setExpanded] = useState>(new Set()); const [drafts, setDrafts] = useState>({}); + const [transferDrafts, setTransferDrafts] = useState>({}); 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 (
@@ -233,19 +287,171 @@ export function ArrivalsPage() { />
- 🚚 این بخش به مرحله «رسید» سفارش‌های خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و - اینجا فقط رسید و تقسیم بین انبارها ثبت می‌شود. رسید جزیی (قلمی و مقداری) مجاز است — هر سفارش را باز - کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلم‌هایی که مقداری برایشان - وارد نشود در صف «در راه» می‌مانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیده‌ی همان قلم برابر باشد و از - مانده‌ی سفارش بیشتر نشود. + 🚚 این بخش به مرحله «رسید» سفارش‌های خرید و «انتقال بین کارگاهی» متصل است: اقلام ارسالی از سایر پروژه‌ها به صورت ۱۰۰٪ تحویل و بین انبارهای پروژه تخصیص داده می‌شوند؛ سفارش‌های خرید نیز قابلیت رسید کامل یا جزیی دارند.
{loading ? (
در حال بارگذاری…
- ) : !orders.length ? ( + ) : !(orders || []).length && !(incomingTransfers || []).length ? ( ) : ( - orders.map(([quoteId, its]) => { + <> + {/* بخش انتقال‌های بین کارگاهی در راه */} + {(incomingTransfers || []).length > 0 && ( +
+
+

🔁 انتقال‌های بین کارگاهی در راه ({faNum((incomingTransfers || []).length)})

+ — تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژه‌ها +
+ + {(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 ( +
+ {/* سربرگ انتقال */} +
+
+ 🔁 انتقال از {t.from_project?.name || "پروژه مبدأ"} + در راه + {days !== null && ( + = 7 ? "offered" : "muted"}> + روز گذشته: {faNum(days)} روز + + )} +
+
+ ارسال‌کننده: {t.transferred_by || "—"} + تاریخ ارسال: {fmtDate(t.transfer_date)} + پروژه مقصد: {t.to_project?.name || "—"} +
+
+ +
+
+ {t.material?.name} + {t.brand?.name && {t.brand.name}} + + مقدار ارسالی: {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} + + {t.specs && Object.keys(t.specs).length > 0 && ( + + مشخصات: {Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")} + + )} +
+ + {t.note && ( +
+ یادداشت ارسال: {t.note} +
+ )} + +
+
+ تخصیص ۱۰۰٪ به انبار(های) پروژه (مجموع باید دقیقاً {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} باشد): +
+ +
+ {draftAllocs.map((a) => ( +
+ { + const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, qty: e.target.value } : x)); + setTransferAlloc(t.id, next); + }} + /> + + {draftAllocs.length > 1 && ( + + )} +
+ ))} + + +
+ +
+
+ {isExactMatch ? ( + + ✓ تخصیص کامل ۱۰۰٪ ({faNum(allocSum)} از {faNum(targetQty)}) آماده تحویل + + ) : ( + + اختلاف تخصیص: مجموع تخصیص ({faNum(allocSum)}) باید دقیقاً با مقدار کل ({faNum(targetQty)}) برابر باشد. + + )} + {hasEmptyWh && ( + + — لطفاً انبار مقصد را انتخاب کنید + + )} +
+ + +
+
+
+
+ ); + })} +
+ )} + + {/* بخش سفارش‌های خرید در راه */} + {orders.map(([quoteId, its]) => { const first = its[0]; const open = expanded.has(quoteId); const hasSubmittable = orderHasSubmittable(quoteId); @@ -401,7 +607,8 @@ export function ArrivalsPage() { )}
); - }) + })} + )} diff --git a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx index 93a6d7e..b05a7d6 100644 --- a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx +++ b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx @@ -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("PURCHASE"); const [whId, setWhId] = useState(""); + const [toProjectId, setToProjectId] = useState(""); + const [allProjects, setAllProjects] = useState([]); + const [transfers, setTransfers] = useState([]); + const [loadingTransfers, setLoadingTransfers] = useState(false); + + // Edit modal state + const [editingTransfer, setEditingTransfer] = useState(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(null); + const [deleteBusy, setDeleteBusy] = useState(false); + const [fromId, setFromId] = useState(""); const [toId, setToId] = useState(""); const [matTxt, setMatTxt] = useState(""); @@ -206,6 +223,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(); @@ -246,6 +339,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) => { if (t === "WITHDRAWAL" && !deliveredTo.trim()) { return toast.error("نام تحویل‌گیرنده اقلام الزامی است"); @@ -382,6 +551,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(); @@ -399,6 +569,9 @@ export function ChangeInventoryPage() { if (type === "WITHDRAWAL") { return !!(whId && deliveredTo.trim() && rows.some((r) => parseNum(r.qty) > 0)); } + 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") { @@ -492,7 +665,47 @@ export function ChangeInventoryPage() {
{/* انتخاب انبارها (چیپ‌های لمسی به جای سلکت خام) */} - {!isTransfer ? ( + {type === "PROJECT_TRANSFER" ? ( +
+
+ +
+ {whOpts.map((w) => ( + + ))} +
+
+
+ + +
+
+ ) : !isTransfer ? (
@@ -619,9 +832,72 @@ export function ChangeInventoryPage() {
)} - {/* فرم خرید / موجودی اولیه */} - {type === "PURCHASE" && ( + {/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */} + {(type === "PURCHASE" || type === "PROJECT_TRANSFER") && (
+ {/* چیپ‌های انتخاب سریع از موجودی‌های انبار مبدأ */} + {type === "PROJECT_TRANSFER" && whId && ( +
+
+ + 📦 + موجودی‌های موجود در {whName(warehouses, whId)} — برای پر کردن خودکار فرم کلیک کنید: + + {faNum(whStock.length)} قلم کالا +
+ {whStock.length === 0 ? ( +
هیچ کالایی با موجودی مثبت در این انبار یافت نشد.
+ ) : ( +
+ {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 ( + + ); + })} +
+ )} +
+ )} +
- + b.name)} allowAdd addSuffix="به عنوان برند جدید" - placeholder="نام برند — از فهرست انتخاب یا نام جدید تایپ کنید" + placeholder={type === "PROJECT_TRANSFER" ? "نام برند (اختیاری — برای اقلام بدون برند خالی بگذارید)" : "نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"} onInputChange={setBrandTxt} onChange={(it) => setBrandTxt(it.label)} /> @@ -683,11 +961,13 @@ export function ChangeInventoryPage() { />
- +
@@ -695,7 +975,7 @@ export function ChangeInventoryPage() {