1 Commits
Author SHA1 Message Date
Behrad cff39542b0 Update ERP configuration and line item handling 2026-09-28 00:28:34 +03:30
38 changed files with 658 additions and 4372 deletions
-49
View File
@@ -247,7 +247,6 @@ _V26_DDL = [
physical_qty NUMERIC(18,6),
transfer_pair_id UUID,
performed_by TEXT,
delivered_to TEXT,
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -259,9 +258,6 @@ _V26_DDL = [
"""
CREATE INDEX IF NOT EXISTS idx_inventory_tx_warehouse_date ON inventory_transactions (warehouse_id, transaction_date)
""",
"""
ALTER TABLE inventory_transactions ADD COLUMN IF NOT EXISTS delivered_to TEXT
""",
# suppliers.categories JSONB (v2.4 runtime) is replaced by suppliers.category_id — drop it.
"""
ALTER TABLE suppliers DROP COLUMN IF EXISTS categories
@@ -436,48 +432,3 @@ 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")
-2
View File
@@ -31,7 +31,6 @@ 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
@@ -108,7 +107,6 @@ 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
-3
View File
@@ -32,8 +32,6 @@ from app.models.procurement import (
TermsheetScheduleStep,
)
from app.models.project_transfer import ProjectTransfer
__all__ = [
"Base",
"Unit",
@@ -62,5 +60,4 @@ __all__ = [
"ItemDecision",
"ItemReceipt",
"QuoteAttachment",
"ProjectTransfer",
]
-1
View File
@@ -156,7 +156,6 @@ class InventoryTransaction(Base):
# NULL for a standalone entry; links TRANSFER_OUT to its TRANSFER_IN row.
transfer_pair_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True))
performed_by: Mapped[str | None] = mapped_column(Text) # user display name (dev placeholder)
delivered_to: Mapped[str | None] = mapped_column(Text)
transaction_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
-87
View File
@@ -1,87 +0,0 @@
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import (
DateTime,
ForeignKey,
Numeric,
Text,
func,
text,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
from app.models.catalog import _timestamptz, _uuid_pk
class ProjectTransfer(Base):
"""project_transfers — Inter-project (inter-site) material transfer ledger.
Records the movement of materials from a source project warehouse to a
destination project. Stock is deducted from the source warehouse immediately
upon creation (status = 'in_transit') via out_transaction_id.
When the destination project PM confirms receipt in 'Arrivals', stock is
added to the destination warehouse (status = 'received').
While 'in_transit', the transfer can be edited or cancelled (restoring source stock).
Once 'received', the transfer is permanently locked from edit or cancellation.
"""
__tablename__ = "project_transfers"
__mapper_args__ = {"eager_defaults": True}
id: Mapped[uuid.UUID] = _uuid_pk()
from_project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
)
from_warehouse_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("warehouses.id", ondelete="RESTRICT"), nullable=False
)
to_project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="RESTRICT"), nullable=False
)
material_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("materials.id", ondelete="RESTRICT"), nullable=False
)
brand_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("brands.id", ondelete="SET NULL")
)
unit_id: Mapped[int] = mapped_column(
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
)
quantity: Mapped[Decimal] = mapped_column(Numeric(18, 6), nullable=False)
specs: Mapped[dict] = mapped_column(JSONB, nullable=False, server_default=text("'{}'::jsonb"))
specs_hash: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'default'"))
note: Mapped[str | None] = mapped_column(Text)
status: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'in_transit'"))
out_transaction_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("inventory_transactions.id", ondelete="SET NULL")
)
transfer_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
transferred_by: Mapped[str | None] = mapped_column(Text)
received_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
received_by: Mapped[str | None] = mapped_column(Text)
to_warehouse_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("warehouses.id", ondelete="SET NULL")
)
receipt_allocations: Mapped[list | None] = mapped_column(JSONB)
created_at: Mapped[datetime] = _timestamptz()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
from_project: Mapped["Project"] = relationship("Project", foreign_keys=[from_project_id], lazy="raise")
to_project: Mapped["Project"] = relationship("Project", foreign_keys=[to_project_id], lazy="raise")
from_warehouse: Mapped["Warehouse"] = relationship("Warehouse", foreign_keys=[from_warehouse_id], lazy="raise")
to_warehouse: Mapped["Warehouse | None"] = relationship("Warehouse", foreign_keys=[to_warehouse_id], lazy="raise")
material: Mapped["Material"] = relationship("Material", foreign_keys=[material_id], lazy="raise")
brand: Mapped["Brand | None"] = relationship("Brand", foreign_keys=[brand_id], lazy="raise")
unit: Mapped["Unit"] = relationship("Unit", foreign_keys=[unit_id], lazy="raise")
out_transaction: Mapped["InventoryTransaction | None"] = relationship(
"InventoryTransaction", foreign_keys=[out_transaction_id], lazy="raise"
)
-2
View File
@@ -14,7 +14,6 @@ from app.routers import (
material_categories,
materials,
pm,
project_transfers,
projects,
quote_events,
quotes,
@@ -51,6 +50,5 @@ ROUTERS = [
standard_conversions.router,
inventory.router,
pm.router,
project_transfers.router,
users.router,
]
+2 -13
View File
@@ -12,7 +12,6 @@ import base64
import uuid
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from fastapi import APIRouter, Depends, Query, Request
@@ -198,7 +197,6 @@ def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
"performed_by": tx.performed_by,
"delivered_to": tx.delivered_to,
"transaction_date": to_iso(tx.transaction_date),
"note": tx.note,
}
@@ -399,18 +397,9 @@ async def list_transactions(
if transaction_type is not None:
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
if date_from is not None:
# asyncpg تایپ سخت‌گیرانه دارد: رشته باید به datetime آگاه تبدیل شود.
# دیتابیس با TZ=UTC اجرا می‌شود، پس «YYYY-MM-DD» یعنی نیمه‌شب UTC
# (همان معناشناسی کست قدیمیِ رشته در PostgreSQL).
stmt = stmt.where(
InventoryTransaction.transaction_date
>= datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
)
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
if date_to is not None:
stmt = stmt.where(
InventoryTransaction.transaction_date
< datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc) + timedelta(days=1)
)
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
items, meta = await paginate(stmt, page, page_size, db)
-206
View File
@@ -1,206 +0,0 @@
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)}
-1
View File
@@ -64,7 +64,6 @@ class InventoryTransactionCreate(APIModel):
unit_id: int | None = None
specs: dict | None = None
note: str | None = None
delivered_to: str | None = None
transaction_date: datetime | None = None
@model_validator(mode="after")
-6
View File
@@ -135,11 +135,6 @@ 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"]
@@ -147,7 +142,6 @@ class ItemDecisionIn(APIModel):
adjusted_unit_price: Decimal | None = Field(default=None, gt=0)
adjusted_notes: str | None = None
project_id: UUID | None = Field(default=None, description="The ONE purchased project (v2.6 — required when the offer spans several projects)")
allocations: list[ProjectAllocationIn] = Field(default_factory=list, description="Multi-project split allocations for this line item")
class ItemProjectsIn(APIModel):
-74
View File
@@ -1,74 +0,0 @@
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
@@ -290,7 +290,6 @@ async def apply_transaction(
transaction_type=t,
quantity=quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
note=payload.note,
)
@@ -320,7 +319,6 @@ async def apply_transaction(
transaction_type=t,
quantity=-quantity,
performed_by=performed_by,
delivered_to=payload.delivered_to,
transaction_date=payload.transaction_date,
note=payload.note,
)
@@ -1,471 +0,0 @@
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.catalog import Brand, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import Project
from app.models.project_transfer import ProjectTransfer
from app.schemas.project_transfers import (
ProjectTransferCreate,
ProjectTransferReceive,
ProjectTransferUpdate,
)
from app.services.inventory_service import (
_find_inventory,
_get_or_create_inventory,
canonical_specs_hash,
)
from app.utils.errors import ConflictError, ForbiddenError, NotFoundError, ValidationError
async def get_project_transfer_or_404(db: AsyncSession, transfer_id: uuid.UUID) -> ProjectTransfer:
stmt = (
select(ProjectTransfer)
.where(ProjectTransfer.id == transfer_id)
.options(
selectinload(ProjectTransfer.from_project),
selectinload(ProjectTransfer.to_project),
selectinload(ProjectTransfer.from_warehouse),
selectinload(ProjectTransfer.to_warehouse),
selectinload(ProjectTransfer.material),
selectinload(ProjectTransfer.brand),
selectinload(ProjectTransfer.unit),
)
)
transfer = await db.scalar(stmt)
if transfer is None:
raise NotFoundError("رکورد انتقال بین کارگاهی یافت نشد", field="transfer_id")
return transfer
async def create_project_transfer(
db: AsyncSession,
payload: ProjectTransferCreate,
caller_project_id: uuid.UUID | None,
caller_role: str,
transferred_by: str | None = None,
) -> ProjectTransfer:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
# Validate source warehouse
from_wh = await db.get(Warehouse, payload.from_warehouse_id)
if from_wh is None:
raise NotFoundError("انبار مبدأ یافت نشد", field="from_warehouse_id")
# RBAC: PM can only send from their own project
if caller_role == "pm" and caller_project_id is not None:
if from_wh.project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به انتقال از انبار پروژه خود هستید")
from_proj = await db.get(Project, from_wh.project_id)
to_proj = await db.get(Project, payload.to_project_id)
if to_proj is None:
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
if from_wh.project_id == payload.to_project_id:
raise ValidationError(
"پروژه مقصد نمی‌تواند همان پروژه مبدأ باشد (از انتقال بین‌انباری استفاده کنید)",
field="to_project_id",
)
# Validate material, brand, unit
mat = await db.get(Material, payload.material_id)
if mat is None:
raise NotFoundError("ماده اولیه یافت نشد", field="material_id")
if payload.brand_id is not None:
brand = await db.get(Brand, payload.brand_id)
if brand is None:
raise NotFoundError("برند یافت نشد", field="brand_id")
unit = await db.get(Unit, payload.unit_id)
if unit is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
# Requirement 2: Canonical specs hash from inventory_service
specs = payload.specs or {}
specs_hash = canonical_specs_hash(specs)
# Check stock in source warehouse with SELECT ... FOR UPDATE
source_item = await _find_inventory(
db,
payload.from_warehouse_id,
payload.material_id,
payload.brand_id,
specs_hash,
for_update=True,
)
# Smart fallback: If exact hash or brand didn't match, check candidate rows in this warehouse for this material
if source_item is None:
candidates_res = await db.execute(
select(InventoryItem)
.where(
InventoryItem.warehouse_id == payload.from_warehouse_id,
InventoryItem.material_id == payload.material_id,
)
.with_for_update()
)
candidates = candidates_res.scalars().all()
if len(candidates) == 1:
source_item = candidates[0]
elif len(candidates) > 1:
best_cand = None
best_score = -1
p_keys = set(specs.keys())
p_vals = set(str(v).strip() for v in specs.values() if str(v).strip())
for cand in candidates:
score = 0
if cand.brand_id == payload.brand_id:
score += 10
elif cand.brand_id is None and payload.brand_id is None:
score += 10
elif cand.brand_id is None:
score += 4
c_specs = cand.specs or {}
c_keys = set(c_specs.keys())
c_vals = set(str(v).strip() for v in c_specs.values() if str(v).strip())
for pk in p_keys:
if pk in c_keys or f"spec_{pk}" in c_keys or any(pk in ck for ck in c_keys):
score += 5
for pv in p_vals:
if pv in c_vals or any(pv in str(cv) for cv in c_vals):
score += 5
if cand.quantity >= payload.quantity:
score += 2
if score > best_score:
best_score = score
best_cand = cand
source_item = best_cand
if source_item is None or source_item.quantity < payload.quantity:
avail = source_item.quantity if source_item else Decimal("0")
raise ValidationError(
f"موجودی کافی در انبار مبدأ وجود ندارد (موجودی فعلی: {avail})",
field="quantity",
)
# Deduct from source warehouse immediately
source_item.quantity -= payload.quantity
transfer_pair_id = uuid.uuid4()
now_dt = payload.transfer_date or datetime.now(timezone.utc)
note_text = f"انتقال بین کارگاهی به پروژه {to_proj.name}"
if payload.note:
note_text += f" — {payload.note}"
out_tx = InventoryTransaction(
inventory_id=source_item.id,
warehouse_id=from_wh.id,
transaction_type="TRANSFER_OUT",
quantity=-payload.quantity,
transfer_pair_id=transfer_pair_id,
performed_by=transferred_by,
transaction_date=now_dt,
note=note_text,
)
db.add(out_tx)
await db.flush()
actual_brand_id = source_item.brand_id
actual_specs = source_item.specs if source_item.specs is not None else specs
actual_specs_hash = source_item.specs_hash or canonical_specs_hash(actual_specs)
transfer = ProjectTransfer(
from_project_id=from_wh.project_id,
from_warehouse_id=from_wh.id,
to_project_id=payload.to_project_id,
material_id=payload.material_id,
brand_id=actual_brand_id,
unit_id=source_item.unit_id if source_item.unit_id else payload.unit_id,
quantity=payload.quantity,
specs=actual_specs,
specs_hash=actual_specs_hash,
note=payload.note,
status="in_transit",
out_transaction_id=out_tx.id,
transfer_date=now_dt,
transferred_by=transferred_by,
)
db.add(transfer)
await db.flush()
return await get_project_transfer_or_404(db, transfer.id)
async def update_project_transfer(
db: AsyncSession,
transfer_id: uuid.UUID,
payload: ProjectTransferUpdate,
caller_project_id: uuid.UUID | None,
caller_role: str,
) -> ProjectTransfer:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
transfer = await db.scalar(
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
)
if transfer is None:
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
# RBAC (Requirement 4)
if caller_role == "pm" and caller_project_id is not None:
if transfer.from_project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به ویرایش انتقال‌های پروژه مبدأ خود هستید")
# Status Lock Guard (Requirement 4)
if transfer.status == "received":
raise ConflictError(
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان ویرایش آن وجود ندارد"
)
if transfer.status != "in_transit":
raise ConflictError("فقط انتقال‌های در راه (in_transit) قابل ویرایش هستند")
if payload.to_project_id is not None and payload.to_project_id != transfer.to_project_id:
new_to_proj = await db.get(Project, payload.to_project_id)
if new_to_proj is None:
raise NotFoundError("پروژه مقصد یافت نشد", field="to_project_id")
if payload.to_project_id == transfer.from_project_id:
raise ValidationError("پروژه مقصد نمی‌تواند همان پروژه مبدأ باشد", field="to_project_id")
transfer.to_project_id = payload.to_project_id
# Quantity adjustment (reconciles source stock difference)
if payload.quantity is not None and payload.quantity != transfer.quantity:
delta = payload.quantity - transfer.quantity
source_item = await _find_inventory(
db,
transfer.from_warehouse_id,
transfer.material_id,
transfer.brand_id,
transfer.specs_hash,
for_update=True,
)
if delta > 0:
if source_item is None or source_item.quantity < delta:
avail = source_item.quantity if source_item else Decimal("0")
raise ValidationError(
f"موجودی انبار مبدأ برای افزایش انتقال کافی نیست (موجود: {avail})",
field="quantity",
)
source_item.quantity -= delta
else:
if source_item is not None:
source_item.quantity += abs(delta)
transfer.quantity = payload.quantity
# Update out_transaction
if transfer.out_transaction_id is not None:
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
if out_tx is not None:
out_tx.quantity = -payload.quantity
if payload.note is not None:
transfer.note = payload.note
if payload.transfer_date is not None:
transfer.transfer_date = payload.transfer_date
if transfer.out_transaction_id is not None:
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
if out_tx is not None:
out_tx.transaction_date = payload.transfer_date
return await get_project_transfer_or_404(db, transfer_id)
async def delete_project_transfer(
db: AsyncSession,
transfer_id: uuid.UUID,
caller_project_id: uuid.UUID | None,
caller_role: str,
) -> dict:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
transfer = await db.scalar(
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
)
if transfer is None:
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
# RBAC (Requirement 4)
if caller_role == "pm" and caller_project_id is not None:
if transfer.from_project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به لغو انتقال‌های پروژه مبدأ خود هستید")
# Status Lock Guard (Requirement 4)
if transfer.status == "received":
raise ConflictError(
"این انتقال در پروژه مقصد تحویل و ثبت انبار شده است و امکان لغو یا حذف آن وجود ندارد"
)
# Reversibility (Requirement 4): Restore stock to source warehouse
source_item = await _find_inventory(
db,
transfer.from_warehouse_id,
transfer.material_id,
transfer.brand_id,
transfer.specs_hash,
for_update=True,
)
if source_item is not None:
source_item.quantity += transfer.quantity
# Delete out_transaction
if transfer.out_transaction_id is not None:
out_tx = await db.get(InventoryTransaction, transfer.out_transaction_id)
if out_tx is not None:
await db.delete(out_tx)
await db.delete(transfer)
return {"ok": True, "message": "انتقال با موفقیت لغو شد و موجودی به انبار مبدأ بازگردانده شد"}
async def receive_project_transfer(
db: AsyncSession,
transfer_id: uuid.UUID,
payload: ProjectTransferReceive,
caller_project_id: uuid.UUID | None,
caller_role: str,
received_by: str | None = None,
) -> ProjectTransfer:
tx = db.begin_nested() if db.in_transaction() else db.begin()
async with tx:
transfer = await db.scalar(
select(ProjectTransfer).where(ProjectTransfer.id == transfer_id).with_for_update()
)
if transfer is None:
raise NotFoundError("رکورد انتقال یافت نشد", field="transfer_id")
# RBAC (Requirement 4)
if caller_role == "pm" and caller_project_id is not None:
if transfer.to_project_id != caller_project_id:
raise ForbiddenError("شما فقط مجاز به تأیید تحویل در پروژه مقصد خود هستید")
# Lock Guard
if transfer.status == "received":
raise ConflictError("این انتقال قبلاً تحویل گرفته و ثبت انبار شده است")
if transfer.status != "in_transit":
raise ConflictError("این انتقال در وضعیت در راه نیست")
# Requirement 1: 100% full delivery logic (no partial receipt)
alloc_sum = sum(Decimal(str(a.quantity)) for a in payload.allocations)
if alloc_sum != transfer.quantity:
raise ValidationError(
f"تخصیص انبارها باید دقیقاً برابر کل مقدار ارسالی ({transfer.quantity}) باشد. مجموع واردشده: {alloc_sum}",
field="allocations",
)
# Validate destination warehouses belong to destination project
wh_ids = [a.warehouse_id for a in payload.allocations]
wh_rows = (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars().all()
wh_map = {w.id: w for w in wh_rows}
for a in payload.allocations:
wh = wh_map.get(a.warehouse_id)
if wh is None or wh.project_id != transfer.to_project_id:
raise ValidationError(
"انبار انتخاب‌شده معتبر نیست یا متعلق به پروژه مقصد نمی‌باشد",
field="allocations.warehouse_id",
)
from_proj = await db.get(Project, transfer.from_project_id)
from_proj_name = from_proj.name if from_proj else "پروژه مبدأ"
out_tx = (
await db.get(InventoryTransaction, transfer.out_transaction_id)
if transfer.out_transaction_id
else None
)
pair_id = out_tx.transfer_pair_id if (out_tx and out_tx.transfer_pair_id) else uuid.uuid4()
now_dt = payload.received_at or datetime.now(timezone.utc)
# Add stock to destination warehouse(s) via TRANSFER_IN
for a in payload.allocations:
dest_item = await _get_or_create_inventory(
db,
a.warehouse_id,
transfer.material_id,
transfer.brand_id,
transfer.unit_id,
transfer.specs,
transfer.specs_hash,
)
dest_item.quantity += a.quantity
note_text = f"رسید انتقال بین کارگاهی از {from_proj_name}"
if payload.note:
note_text += f" — {payload.note}"
in_tx = InventoryTransaction(
inventory_id=dest_item.id,
warehouse_id=a.warehouse_id,
transaction_type="TRANSFER_IN",
quantity=a.quantity,
transfer_pair_id=pair_id,
performed_by=received_by,
transaction_date=now_dt,
note=note_text,
)
db.add(in_tx)
transfer.status = "received"
transfer.received_at = now_dt
transfer.received_by = received_by
transfer.to_warehouse_id = payload.allocations[0].warehouse_id
transfer.receipt_allocations = [
{"warehouse_id": str(a.warehouse_id), "quantity": str(a.quantity)}
for a in payload.allocations
]
return await get_project_transfer_or_404(db, transfer_id)
async def list_project_transfers(
db: AsyncSession,
project_id: uuid.UUID | None = None,
direction: str = "all", # 'outgoing' | 'incoming' | 'all'
status: str | None = None,
) -> list[ProjectTransfer]:
stmt = (
select(ProjectTransfer)
.options(
selectinload(ProjectTransfer.from_project),
selectinload(ProjectTransfer.to_project),
selectinload(ProjectTransfer.from_warehouse),
selectinload(ProjectTransfer.to_warehouse),
selectinload(ProjectTransfer.material),
selectinload(ProjectTransfer.brand),
selectinload(ProjectTransfer.unit),
)
)
if project_id is not None:
if direction == "outgoing":
stmt = stmt.where(ProjectTransfer.from_project_id == project_id)
elif direction == "incoming":
stmt = stmt.where(ProjectTransfer.to_project_id == project_id)
else:
stmt = stmt.where(
(ProjectTransfer.from_project_id == project_id)
| (ProjectTransfer.to_project_id == project_id)
)
if status:
stmt = stmt.where(ProjectTransfer.status == status)
stmt = stmt.order_by(ProjectTransfer.transfer_date.desc())
res = await db.execute(stmt)
return list(res.scalars().all())
-197
View File
@@ -7,7 +7,6 @@ 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
@@ -19,7 +18,6 @@ from app.models.procurement import (
ItemTermsheet,
LineItem,
LineItemProjects,
LineItemSpec,
Project,
Quote,
QuoteEvent,
@@ -420,78 +418,6 @@ 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
@@ -528,8 +454,6 @@ 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
@@ -633,125 +557,6 @@ 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,
@@ -805,8 +610,6 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
if decision.project_id is not None:
if await db.get(Project, decision.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
# Requirement 4: sync QuoteProjects
await _ensure_quote_project(db, quote.id, decision.project_id)
chosen_ts_ids.add(termsheet_id)
else: # rejected
if decision is None:
-1
View File
@@ -128,7 +128,6 @@ 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"),
+27 -106
View File
@@ -6,8 +6,6 @@ services:
environment:
POSTGRES_DB: procurement
POSTGRES_USER: postgres
# Local dev default; production overrides via .env (required by the
# prod overlay) — never commit real passwords.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports:
@@ -15,7 +13,7 @@ services:
networks:
- default
- shared # monitoring stack scrapes it (postgres-exporter)
- shared
volumes:
- postgres_data:/var/lib/postgresql
@@ -31,15 +29,7 @@ services:
restart: unless-stopped
mem_limit: 1g
# ==========================================================================
# Traefik v3 — public reverse proxy (replaces nginx as the edge)
# App is served on http://localhost (port 80) instead of :3000.
# Production: see docker-compose.prod.yml (real domains + HTTPS via
# Let's Encrypt, HTTP→HTTPS redirect).
# ==========================================================================
traefik:
# v3.6+ required: Traefik ≤3.3 fails against Docker Engine 29.x
# ("Error response from daemon" — Docker API version mismatch).
image: traefik:v3.6
container_name: procurement-traefik
restart: unless-stopped
@@ -47,8 +37,6 @@ services:
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
# Containers are on several networks; Traefik must use the dedicated
# proxy network IP (otherwise it may pick the default-network IP → 504).
- --providers.docker.network=traefik_network
- --providers.file.filename=/etc/traefik/dynamic.yml
- --entrypoints.web.address=:80
@@ -62,8 +50,6 @@ services:
- "80:80"
- "443:443"
volumes:
# NOTE: mounted read-write — Docker Desktop rejects read-only
# /var/run/docker.sock mounts ("Error response from daemon").
- /var/run/docker.sock:/var/run/docker.sock
- ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro
- traefik_certs:/certs
@@ -81,24 +67,21 @@ services:
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
ports:
- "9000:9000" # S3 API
- "9001:9001" # Web console
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
labels:
- traefik.enable=true
# S3 API
- traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)
- traefik.http.routers.minio-api.entrypoints=web
- traefik.http.routers.minio-api.service=minio-api-svc
- traefik.http.services.minio-api-svc.loadbalancer.server.port=9000
# Web console
- traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`)
- traefik.http.routers.minio-console.entrypoints=web
- traefik.http.routers.minio-console.service=minio-console-svc
- traefik.http.services.minio-console-svc.loadbalancer.server.port=9001
- "traefik.enable=true"
- "traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`)"
- "traefik.http.routers.minio-api.entrypoints=web"
- "traefik.http.services.minio-api-svc.loadbalancer.server.port=9000"
- "traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`)"
- "traefik.http.routers.minio-console.entrypoints=web"
- "traefik.http.routers.minio-console.service=minio-console-svc"
- "traefik.http.services.minio-console-svc.loadbalancer.server.port=9001"
networks:
- traefik_network
@@ -107,39 +90,20 @@ services:
restart: unless-stopped
mem_limit: 512m
# MinIO Client (mc): waits for the server, creates the bucket, exits.
# Use for ad-hoc ops too: docker compose run --rm minio-init ...
minio-init:
image: minio/mc:latest
container_name: procurement-minio-init
depends_on:
minio:
condition: service_started
environment:
MINIO_ENDPOINT: minio:9000
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
MINIO_BUCKET: procurement-files
entrypoint: >
/bin/sh -c "
until mc alias set local http://$${MINIO_ENDPOINT} $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do
echo 'waiting for minio server...'; sleep 2;
done;
mc mb --ignore-existing local/$${MINIO_BUCKET};
echo bucket $${MINIO_BUCKET} ready;
"
entrypoint: ["/bin/sh", "-c", "until mc alias set local http://$${MINIO_ENDPOINT}$${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do echo 'waiting for minio server...'; sleep 2; done; mc mb --ignore-existing local/$${MINIO_BUCKET}; echo bucket ready;"]
restart: "no"
# ==========================================================================
# Scheduled logical backups of BOTH databases (procurement + casdoor).
# Writes pg_dump -Fc archives to a bind mount (default ./backups, point
# BACKUP_DIR at /srv/erp/backups on the VPS) and prunes archives older
# than BACKUP_KEEP_DAYS. Runs on the same postgres:18 image already pulled.
# ==========================================================================
db-backup:
image: postgres:18
container_name: procurement-db-backup
@@ -152,33 +116,9 @@ services:
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
volumes:
- ${BACKUP_DIR:-./backups}:/backups
entrypoint: >
/bin/sh -c "
echo 'db-backup: dumping every $${BACKUP_INTERVAL_SECONDS}s, keeping $${BACKUP_KEEP_DAYS} days';
while true; do
STAMP=$$(date +%Y%m%d-%H%M%S);
pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump &&
echo \"db-backup: procurement -> /backups/procurement-$${STAMP}.dump\";
pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump &&
echo \"db-backup: casdoor -> /backups/casdoor-$${STAMP}.dump\";
find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete;
sleep $${BACKUP_INTERVAL_SECONDS};
done
"
entrypoint: ["/bin/sh", "-c", "while true; do STAMP=$$(date +%Y%m%d-%H%M%S); pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump; pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump; find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete; sleep $${BACKUP_INTERVAL_SECONDS}; done"]
restart: unless-stopped
mem_limit: 256m
# ==========================================================================
# v2.6 — Casdoor (OIDC identity provider)
# Console: http://localhost:8080 (admin / 123)
# Application "inventory-app" (org "inventory", client inventory-app-client)
# is created by backend/tools/seed_casdoor.py — run it once after the
# first boot:
# docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py
#
# Casdoor uses the SAME postgres instance as the app (database `casdoor`,
# created by sql/02-casdoor-db.sql on a fresh volume).
# ==========================================================================
casdoor:
image: casbin/casdoor:latest
container_name: procurement-casdoor
@@ -195,10 +135,10 @@ services:
- "8080:8000"
labels:
- traefik.enable=true
- traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:-auth.localhost}`)
- traefik.http.routers.casdoor.entrypoints=web
- traefik.http.services.casdoor.loadbalancer.server.port=8000
- "traefik.enable=true"
- "traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:-auth.localhost}`)"
- "traefik.http.routers.casdoor.entrypoints=web"
- "traefik.http.services.casdoor.loadbalancer.server.port=8000"
networks:
- traefik_network
@@ -220,14 +160,9 @@ services:
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
PROCUREMENT_MINIO_BUCKET: procurement-files
PROCUREMENT_MINIO_SECURE: "false"
# "dev" enables the local defaults; "prod" turns on the fail-fast
# startup checks (no wildcard CORS, no default credentials).
PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev}
PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Comma-separated browser origins (JSON-free .env friendly).
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173}
# Casdoor: endpoint is the in-network service URL; the issuer must match
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173,http://192.168.1.104}
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
@@ -235,10 +170,8 @@ services:
PROCUREMENT_CASDOOR_ORGANIZATION: inventory
PROCUREMENT_CASDOOR_APP_NAME: inventory-app
PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client
# Console admin used by POST /users/sync (identity mirroring).
PROCUREMENT_CASDOOR_ADMIN_USER: admin
PROCUREMENT_CASDOOR_ADMIN_PASSWORD: ${CASDOOR_ADMIN_PASSWORD:-123}
# Observability (Phase 2) — see monitoring/README.md
PROCUREMENT_LOG_FORMAT: ${LOG_FORMAT:-text}
PROCUREMENT_SENTRY_DSN: ${SENTRY_DSN_BACKEND:-}
PROCUREMENT_OPENPANEL_API_URL: ${OPENPANEL_API_URL:-}
@@ -251,7 +184,7 @@ services:
networks:
- traefik_network
- default
- shared # monitoring stack scrapes api:8000/metrics
- shared
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3)\""]
@@ -261,10 +194,10 @@ services:
start_period: 40s
labels:
- traefik.enable=true
- traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`)
- traefik.http.routers.api.entrypoints=web
- traefik.http.services.api.loadbalancer.server.port=8000
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`)"
- "traefik.http.routers.api.entrypoints=web"
- "traefik.http.services.api.loadbalancer.server.port=8000"
depends_on:
postgres:
@@ -279,27 +212,19 @@ services:
build:
context: ./frontend
args:
# Developer Mode panel on the login page — dev-only aid. Production
# builds MUST leave this false (identity comes from Casdoor tokens).
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Registry overridable for networks where npmmirror is unreachable.
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
# Observability (build-time baked; empty = disabled)
VITE_OPENPANEL_URL: ${OPENPANEL_PUBLIC_URL:-}
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
container_name: procurement-frontend
# از entrypoint پیش‌فرض image nginx رد می‌شویم: آن اسکریپت `apk manifest`
# اجرا می‌کند و اگر دسترسی به مخزن بسته‌ها نباشد بی‌نهایت معلق می‌ماند
# (سرویس بالا نمی‌آید و traefik خطای 502 می‌دهد). کانفیگ ما مستقل است و
# نیازی به آن اسکریپت‌ها ندارد.
entrypoint: ["nginx", "-g", "daemon off;"]
labels:
- traefik.enable=true
- traefik.http.routers.frontend.rule=Host(`${APP_HOST:-localhost}`)
- traefik.http.routers.frontend.entrypoints=web
- traefik.http.services.frontend.loadbalancer.server.port=80
- "traefik.enable=true"
- "traefik.http.routers.frontend.rule=PathPrefix(`/`)"
- "traefik.http.routers.frontend.entrypoints=web"
- "traefik.http.services.frontend.loadbalancer.server.port=80"
networks:
- traefik_network
@@ -312,13 +237,9 @@ services:
mem_limit: 128m
networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
traefik_network:
name: traefik_network
driver: bridge
# Fixed-name network shared with the monitoring stack (monitoring compose
# joins it as external) — carries /metrics scrapes and postgres-exporter.
shared:
name: erp_shared
driver: bridge
-94
View File
@@ -1,94 +0,0 @@
# گزارش گپ‌های دفتر موجودی (Inventory Ledger Gaps)
بررسی کامل منطق تراکنش‌های انبار (بک‌اند + فرانت‌اند) در جریان بازطراحی صفحه «تغییر موجودی».
**این گزارش فقط فهرست گپ‌هاست برای تصمیم‌گیری معماری — به‌جز مورد ۱، هیچ‌کدام در این نسخه اصلاح نشده‌اند.**
وضعیت‌ها: 🔴 ریسک بالای داده · 🟡 ریسک متوسط · 🔵 بهبود
---
## ۱. ✅ اصلاح‌شده در همین نسخه (پیش‌نیاز فیچر برگشت)
**`GET /inventory/transactions?date_from=&date_to=` → HTTP 500**
(`backend/app/routers/inventory.py` — list_transactions)
ستون `transaction_date` (timestamptz) با رشته‌ی خام مقایسه می‌شد. درایور asyncpg تایپ سخت‌گیرانه دارد و این مقایسه با خطای `operator does not exist: timestamp with time zone >= character varying` کرش می‌کرد. هیچ‌جای UI قدیمی این فیلترها را صدا نمی‌زد، برای همین مخفی مانده بود.
**اصلاح:** پارامترهای `YYYY-MM-DD` به datetime آگاه از UTC تبدیل می‌شوند (`YYYY-MM-DD 00:00 UTC` تا `+1 روز`) — همان معناشناسی کستِ قدیمی رشته در PostgreSQL با `timezone = Etc/UTC`. ⚠️ نکته‌ی مرتبط: مورد ۱۲.
---
## ۲. 🔴 سقف برگشت و اتصال REF فقط در فرانت است
بک‌اند `RETURN` را دقیقاً مثل `PURCHASE` (افزایش ساده) می‌پذیرد (`inventory_service.py:268-299`) — نه به برداشت خاصی وصل است و نه سقفی دارد. با فراخوانی مستقیم API می‌توان موجودی یک انبار را بی‌نهایت تورم داد.
UI جدید این را کنترل می‌کند (تگ `[REF:<tx-id>]` در note + محاسبه‌ی مانده + سقف ورودی)، اما **خود دفتر از سمت سرور محافظت نمی‌شود.**
**پیشنهاد برای تصمیم‌گیری:** اعتبارسنجی سمت سرور — برگشت حتماً `ref_transaction_id` بگیرد و سرویس مانده را با قفل ردیف چک کند؛ یا جدول attribution جداگانه.
## ۳. 🔴 TRANSFER_IN مستقل بدون جفت پذیرفته می‌شود
`POST /inventory/transactions` با نوع `TRANSFER_IN` بدون هیچ TRANSFER_OUT متناظری پذیرفته می‌شود (سرویس آن را افزایش عمومی حساب می‌کند). یعنی از مسیر API می‌توان «ورودی انتقال» جعلی ثبت کرد. فقط جفتِ واقعیِ ساخته‌شده توسط خود سرویس `transfer_pair_id` دارد.
**پیشنهاد:** TRANSFER_IN از مسیر عمومی بسته شود (فقط از طریق جفت‌سازی داخلی سرویس ثبت شود).
## ۴. 🟡 create انتقال بین‌کارگاهی «ردیف انبار» نمی‌گیرد
`POST /inventory/project-transfers` به‌جای `inventory_row_id` فقط material/brand/unit/specs می‌گیرد؛ اگر match دقیق پیدا نشود، سرویس با fallback امتیازدهی «بهترین کاندیدای همان متریال» را کسر می‌کند (`project_transfer_service.py:105-151`). اگر یک متریال با چند ردیف specs نزدیک به هم باشد، ممکن است از ردیفی غیر از قصد کاربر کسر شود.
**پیشنهاد:** پذیرش `inventory_row_id` اختیاری در payload و کسر دقیق از همان ردیف.
## ۵. 🟡 Audit Trail ناقص است
فقط `POST /inventory/transactions` رکورد audit می‌نویسد (`inventory.py:491-500`). این‌ها audit ندارند:
- ساخت / ویرایش (PATCH) / لغو (DELETE) انتقال بین‌کارگاهی
- تأیید تحویل PM (`/pm/receipts` و دریافت انتقال)
- CRUD انبارها و تغییرات کاربران
**پیشنهاد:** گسترش `audit_record` به این مسیرها (helper موجود آماده است و هرگز raise نمی‌کند).
## ۶. 🟡 فیلدهای الزامی کلیدی فقط در UI اعمال می‌شوند
- `delivered_to` (تحویل‌گیرنده برداشت) در UI اجباری است؛ API بدون آن 201 می‌دهد.
- ظرفیت‌سنجی مشابه برای «توضیح» (note) شمارش موجودی و… .
## ۷. 🟡 رفتار postSeq قدیمی هنگام خطای وسط بچ
در بخش‌های لیستی ساده (برداشت/ضایعات/اتلاف/مفقودی/انتقال داخلی) اگر ثبت ردیف kام شکست بخورد، ردیف‌های ۱..k-1 **موفق ثبت‌شده** در جدول باقی می‌مانند → retry کاربر = ثبت تکراری. (در انتقال بین‌کارگاهی و برگشتِ این نسخه، ردیف‌های موفق از لیست حذف می‌شوند — الگوی درست.)
**پیشنهاد:** انتقال الگوی «حذف ردیف موفق + خطای inline» به postSeq.
## ۸. 🟡 برگشت‌های قدیمی بدون REF قابل انتساب دقیق نیستند
برگشت‌های ثبت‌شده قبل از این نسخه هیچ اشاره‌ای به برداشت مرجع ندارند. UI با تخصیص FIFO بین برداشت‌های هم‌ردیف (متریال+برند+مشخصات+واحد) جبران می‌کند، ولی این حدس است و در دو حالت خطا دارد:
- برگشت قدیمی به انباری غیر از انبار مبدأ → قابل انتساب نیست و از مانده کم نمی‌شود.
- تخصیص FIFO صفحات اولِ واکشی ممکن است برگشتِ متعلق به برداشتِ صفحه‌های بعد را جابه‌جا کند.
**پیشنهاد (داده):** اسکریپت یک‌باره‌ی backfill — تطبیق برگشت‌های قدیمی با برداشت‌های همان ردیف (قدیمی‌ترین برداشتِ باقیمانده) و درج REF در note.
## ۹. 🔵 معناشناسی تاریخ فیلترها = UTC (نه تهران)
فیلتر `date_from/date_to` (بعد از اصلاح مورد ۱) مرز روز را در UTC می‌برد، در حالی که UI جلالی و ساعت کاربر +03:30 است. برداشت ساعت ۰۰:۰۰–۰۳:۳۰ تهران در «دیروز» UTC می‌افتد. این معناشناسی قدیمی حفظ شد؛ اگر روزِ تهران ملاک است، باید offset منطقه (یا پارامتر `tz` از کلاینت) اضافه شود.
## ۱۰. 🔵 GET /inventory/{id}/transactions enrichment ناقص
پاسخ این endpoint فیلدهای material/brand/specs/unit را ندارد (فقط GET لیست کامل enrich می‌کند). UI فعلی از آن استفاده نمی‌کند ولی برای دیباگ/گزارش‌ها گمراه‌کننده است.
## ۱۱. 🔵 سقف ۱۰۰ رکوردی واکشی‌های لیست
`GET /inventory` و lookup ها در `useInventoryData` با `page_size=100` واکشی می‌شوند؛ انبار با بیش از ۱۰۰ رکورد موجودی در UI کامل نمایش داده نمی‌شود (محدودیت قدیمی، در لیست‌های جدید این صفحه هم از همان دیتا می‌خواند). لیست برگشت و مفقودی‌ها صفحه‌بندی/واکشی کامل دارند.
## ۱۲. 🔵 لیست انتقال‌های بین‌کارگاهی بدون صفحه‌بندی
`GET /inventory/project-transfers` و `/pm/transfers/on-the-way` همه‌ی ردیف‌ها را بدون pagination برمی‌گردانند؛ با رشد داده بار پاسخ زیاد می‌شود.
---
## چیزهایی که بررسی شد و درست است ✅
- **Immutable Ledger در سطح API:** برای تراکنش‌ها هیچ endpoint ویرایش/حذفی وجود ندارد (فقط POST و GET). تنها حذفِ موجود، لغو انتقال بین‌کارگاهی است که تراکنش خروجی خودش را بازمی‌گرداند و موجودی را ترمیم می‌کند.
- **سقف FOUND در بک‌اند اعمال می‌شود:** بیش از «Σ|LOSS| − ΣFOUND» همان ردیف → 422 (تست شد).
- **STOCK_COUNT فقط روی رکورد موجود** و با ثبت اختلاف علامت‌دار صحیح.
- **TRANSFER_OUT جفتی:** کسر مبدأ و ایجاد TRANSFER_IN مقصد با `transfer_pair_id` مشترک و قفل ردیف؛ موجودی منفی نمی‌شود.
- **دریافت انتقال بین‌کارگاهی:** مجموع allocations باید دقیقاً برابر مقدار انتقال باشد و انبارها به پروژه مقصد تعلق داشته باشند.
- **هویت ردیف انبار** با `specs_hash` (SHA256 مرتب‌شده‌ی specs) و sentinel برند NULL یکتاست — ریسک ادغام رکوردهای متفاوت وجود ندارد.
+4 -16
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type RefObject } from "react";
import { useEffect, useMemo, useState } from "react";
import { normalize } from "../lib/normalize";
import { cn } from "../lib/utils";
import { useClickOutside } from "./useClickOutside";
@@ -17,7 +17,6 @@ export interface ComboboxProps {
onChange?: (item: ComboboxItem) => void;
items: ComboboxSource; // آرایه از string یا {id, name}
allowAdd?: boolean; // دکمه‌ی "+ افزودن به عنوان جدید"
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
addSuffix?: string; // متن بعد از +
placeholder?: string;
className?: string;
@@ -26,8 +25,6 @@ export interface ComboboxProps {
onAdd?: (val: string) => void;
onInputChange?: (val: string) => void;
onBlur?: () => void;
// برای فوکوس برنامه‌ای (مثل بازگشت فوکوس پس از ثبت پیوسته)
inputRef?: RefObject<HTMLInputElement | null>;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
// فقط کلیک یا تایپِ صریح کاربر بازش می‌کند.
openOnFocus?: boolean;
@@ -47,7 +44,6 @@ export function Combobox({
onChange,
items,
allowAdd = true,
addPrefix,
addSuffix = "به عنوان گزینه جدید",
placeholder,
className,
@@ -56,7 +52,6 @@ export function Combobox({
onAdd,
onInputChange,
onBlur,
inputRef,
openOnFocus = true,
}: ComboboxProps) {
const [text, setText] = useState(value ?? "");
@@ -130,7 +125,6 @@ export function Combobox({
<div ref={ref} className={cn("relative", className)}>
<input
type="text"
ref={inputRef}
className={cn(
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
inputClassName,
@@ -188,15 +182,9 @@ export function Combobox({
onMouseEnter={() => setHighlight(filtered.length)}
>
<span className="font-bold">+</span>
{addPrefix ? (
<span>
{addPrefix} «<b>{text.trim()}</b>»
</span>
) : (
<span>
«<b>{text.trim()}</b>» {addSuffix}
</span>
)}
<span>
«<b>{text.trim()}</b>» {addSuffix}
</span>
</button>
)}
</div>
+4 -6
View File
@@ -8,14 +8,13 @@ export interface NumberInputProps {
placeholder?: string;
className?: string;
inputClassName?: string;
align?: "right" | "center" | "left"; // تنظیم جهت متن و اعداد (پیش‌فرض: راست‌چین)
showWords?: boolean; // نمایش معادل فارسی زیر input
align?: "right" | "center" | "left";
showWords?: boolean;
inputMode?: "numeric" | "decimal" | undefined;
maxDecimals?: number; // حداکثر رقم اعشار
maxDecimals?: number;
disabled?: boolean;
}
// ورودی عددی با فرمت‌دهی خودکار (groupCommas) و نمایش کلمات فارسی
export function NumberInput({
value,
onChange,
@@ -28,7 +27,6 @@ export function NumberInput({
maxDecimals,
disabled = false,
}: NumberInputProps) {
// فرمت با رعایت حداکثر اعشار (گرد کردن)
const formatValue = (n: number): string => {
let v = n;
if (maxDecimals != null) {
@@ -75,7 +73,7 @@ export function NumberInput({
type="text"
disabled={disabled}
className={cn(
"num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-xs transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
"num-input h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-xs font-normal text-ink shadow-xs transition-colors placeholder:text-xs placeholder:font-normal placeholder:text-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-60",
alignClass,
inputClassName,
)}
+15 -92
View File
@@ -30,10 +30,6 @@ 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";
@@ -243,32 +239,20 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
async function getData<T>(path: string): Promise<T> {
const res = await request<any>(path);
if (res && typeof res === "object" && "data" in res) {
return res.data as T;
}
return res as T;
const res = await request<{ data: T }>(path);
return res.data;
}
async function postData<T>(path: string, body: unknown): Promise<T> {
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;
const res = await request<{ data: T }>(path, { method: "POST", body: JSON.stringify(body) });
return res.data;
}
async function patchData<T>(path: string, body: unknown): Promise<T> {
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;
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
return res.data;
}
async function deleteData<T>(path: string): Promise<T> {
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;
const res = await request<{ data: T }>(path, { method: "DELETE" });
return res.data;
}
// آپلود چندبخشی (multipart) — بدون هدر Content-Type دستی تا مرورگر
@@ -943,28 +927,13 @@ 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 || []) {
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);
}
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);
}
}
@@ -984,13 +953,11 @@ 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 } : {}),
};
}),
@@ -1702,23 +1669,16 @@ export const apiV26 = {
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
return data.map(rawInventory);
},
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<InventoryTransaction[]> => {
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
const pid = params.project_id ?? activeProjectId() ?? undefined;
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
},
// همان transactions با meta.total برای صفحه‌بندی «نمایش بیشتر»
transactionsPaged: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<{ rows: InventoryTransaction[]; total: number }> => {
const pid = params.project_id ?? activeProjectId() ?? undefined;
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
const res = await request<{ data: InventoryTransaction[]; meta?: { total?: number } }>(`/inventory/transactions?${qs}`);
return { rows: res.data ?? [], total: res.meta?.total ?? res.data?.length ?? 0 };
},
createTransaction: async (body: {
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
material_id: string; brand_id?: string | null; quantity?: number | null;
physical_qty?: number | null; unit_id?: number | null; specs?: Record<string, string> | null;
note?: string | null; delivered_to?: string | null; transaction_date?: string | null;
note?: string | null; transaction_date?: string | null;
}): Promise<InventoryTransaction[]> => {
return postData<InventoryTransaction[]>(`/inventory/transactions${projectQuery()}`, body);
},
@@ -1769,41 +1729,4 @@ 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),
},
};
-51
View File
@@ -73,7 +73,6 @@ 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)
@@ -164,7 +163,6 @@ export interface InventoryTransaction {
physical_qty?: number | null;
transfer_pair_id?: string | null;
performed_by?: string | null;
delivered_to?: string | null;
transaction_date: string;
note?: string | null;
material?: { id: string; name: string } | null;
@@ -201,52 +199,3 @@ 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;
}
+62 -434
View File
@@ -23,134 +23,12 @@ 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;
label: string | null; // null = پیش‌فرض «گزینه N»
}
export interface AcceptModalProps {
@@ -173,9 +51,6 @@ 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);
@@ -192,35 +67,17 @@ 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);
const defaultProj = li.target_project?.name
projSel[li.id] = 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]);
@@ -280,44 +137,6 @@ 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;
@@ -343,7 +162,6 @@ 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("همه‌ی قلم‌ها رد شده‌اند؛ حداقل یک قلم لازم است.");
@@ -388,60 +206,13 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
});
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;
}
}
// پروژه‌ی هر قلم (تکی هدف خرید) — فقط target_project تنظیم می‌شود؛
// li.projects و next.projects دست‌نخورده باقی می‌مانند تا پروژه‌های کاندیدای اولیه حفظ شوند
next.line_items.forEach((li) => {
if (li.rejected) return;
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 name = itemProjectsSel[li.id];
if (name === undefined) return;
li.target_project = name ? { id: "", name } : null;
});
const now = new Date().toISOString();
@@ -461,7 +232,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">
@@ -572,196 +343,53 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
</div>
</section>
{/* بخش ۳: تخصیص اقلام به پروژه‌ها */}
{/* بخش ۳: تخصیص اقلام به پروژه */}
<section className="bg-white rounded-xl shadow-sm border border-line p-4 md:p-5">
<div className="flex items-center justify-between mb-1">
<h3 className="text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
</div>
<h3 className="mb-1 text-[14px] font-bold text-slate-800">۳. تخصیص اقلام به پروژه‌ها</h3>
<div className="mb-4 text-[12px] text-muted">
مشخص کنید چه مقداری از هر قلم به کدام پروژه اختصاص داده می‌شود. در صورت خرید یک کالا برای چند پروژه، می‌توانید آن را به چند سطر تفکیک کنید.
برای هر متریال خریداری شده، می‌توانید پروژه مربوط به آن را مشخص کنید.
</div>
{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 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>
</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>
</section>
{/* بخش ۴: فاکتورها، پیش‌فاکتورها و تاریخ */}
@@ -813,17 +441,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>
@@ -438,26 +438,7 @@ 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) => {
+12 -219
View File
@@ -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, ProjectTransfer } from "../../lib/types";
import type { PendingArrival, Warehouse } from "../../lib/types";
import { faNum, fmtDate } from "../../lib/formatters";
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
@@ -25,31 +25,24 @@ 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, incoming] = await Promise.all([
const [arr, whs] = await Promise.all([
// پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند
apiV26.pm.onTheWay(activeProject),
apiV26.warehouses.list(activeProject),
apiV26.projectTransfers.onTheWay(activeProject),
]);
setItems(Array.isArray(arr) ? arr : []);
setWarehouses(Array.isArray(whs) ? whs : []);
setIncomingTransfers(Array.isArray(incoming) ? incoming : []);
setItems(arr);
setWarehouses(whs);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
setItems([]);
setIncomingTransfers([]);
} finally {
setLoading(false);
}
@@ -219,53 +212,6 @@ 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">
@@ -287,171 +233,19 @@ 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 && !(incomingTransfers || []).length ? (
) : !orders.length ? (
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
) : (
<>
{/* بخش انتقال‌های بین کارگاهی در راه */}
{(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]) => {
orders.map(([quoteId, its]) => {
const first = its[0];
const open = expanded.has(quoteId);
const hasSubmittable = orderHasSubmittable(quoteId);
@@ -607,8 +401,7 @@ export function ArrivalsPage() {
)}
</div>
);
})}
</>
})
)}
</PageLayout>
</div>
File diff suppressed because it is too large Load Diff
@@ -129,10 +129,9 @@ export function DailyControlPage() {
const wGroups = useMemo(() => {
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
withdrawals.forEach((t) => {
const gKey = `${t.inventory_id}__${t.delivered_to || ""}`;
const g = gmap.get(gKey);
const g = gmap.get(t.inventory_id);
if (!g) {
gmap.set(gKey, { recId: gKey, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
} else {
g.q += Math.abs(t.quantity_number || 0);
if (t.transaction_date > g.last) g.last = t.transaction_date;
@@ -325,12 +324,6 @@ export function DailyControlPage() {
whName(data.warehouses, g.tx.warehouse_id)
].filter(Boolean).join(" · ")}
</span>
{g.tx.delivered_to && (
<span className="inline-flex items-center gap-1 mt-1.5 text-[11px] font-semibold text-blue-700 bg-blue-50/80 border border-blue-200/60 rounded px-1.5 py-0.5 w-fit">
<span className="text-slate-500 font-normal">تحویل به:</span>
<span>{g.tx.delivered_to}</span>
</span>
)}
</span>
<span className="text-xs font-bold text-rose-600 tabular-nums">
{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}
@@ -1,145 +0,0 @@
// ============================================================
// جدول «پیدا شده» — مفقودی‌های باقی‌مانده‌ی انبار (LOSS − FOUND)
// با انتخاب انبار همه‌ی مفقودی‌ها می‌آیند و نام متریال فقط فیلتر است.
// ============================================================
import type { InventoryTransaction } from "../../../lib/types";
import { faNum } from "../../../lib/formatters";
import { cn } from "../../../lib/utils";
import { specsLine, type RowDraft } from "./StockTable";
export interface LossItem { tx: InventoryTransaction; remaining: number; }
interface FoundTableProps {
items: LossItem[];
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
unitNameFn: (id: number | null | undefined) => string;
emptyMsg: string;
loading?: boolean;
}
function parseLocal(s: string): number {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
}
export function FoundTable({ items, rows, setRows, unitNameFn, emptyMsg, loading }: FoundTableProps) {
if (loading) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری مفقودی‌ها…</div>;
}
if (emptyMsg) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
}
return (
<>
{/* دسکتاپ */}
<div className="hidden sm:block">
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
<table className="w-full text-[12.5px] min-w-[620px]">
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
<tr>
<th className="py-2.5 px-3">متریال</th>
<th className="py-2.5 px-3">مشخصه‌ها</th>
<th className="py-2.5 px-3">برند</th>
<th className="py-2.5 px-3">واحد</th>
<th className="py-2.5 px-3">مانده مفقودی</th>
<th className="py-2.5 px-3">تعداد پیداشده</th>
<th className="py-2.5 px-3">یادداشت</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{items.map(({ tx, remaining }, i) => {
const over = parseLocal(rows[i]?.qty || "") > remaining;
return (
<tr key={tx.id + "|" + i} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
<td className="py-2 px-3 font-semibold text-ink">{tx.material?.name || "—"}</td>
<td className="py-2 px-3 text-muted">{specsLine(tx.specs)}</td>
<td className="py-2 px-3">{tx.brand?.name || "—"}</td>
<td className="py-2 px-3">{unitNameFn(tx.unit?.id)}</td>
<td className="py-2 px-3 font-bold text-rose-600">{faNum(remaining)}</td>
<td className="py-2 px-3">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="۰"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>}
</td>
<td className="py-2 px-3">
<input
type="text"
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
placeholder="توضیح"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* موبایل: کارت‌ها */}
<div className="sm:hidden">
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
{items.map(({ tx, remaining }, i) => {
const over = parseLocal(rows[i]?.qty || "") > remaining;
return (
<div key={tx.id + "|" + i} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13.5px] font-bold text-ink">{tx.material?.name || "—"}</div>
<div className="mt-0.5 text-[11.5px] text-muted">
{tx.brand?.name || "بدون برند"} · {unitNameFn(tx.unit?.id)}
</div>
{tx.specs && Object.keys(tx.specs).length > 0 && (
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(tx.specs)}</div>
)}
</div>
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
<div className="text-[10px] text-muted">مانده مفقودی</div>
<div className="text-[14px] font-black text-rose-600">{faNum(remaining)}</div>
</div>
</div>
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="تعداد پیداشده"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="توضیح"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</div>
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده مفقودی!</div>}
</div>
);
})}
</div>
</div>
</>
);
}
@@ -1,132 +0,0 @@
// ============================================================
// لیست اقلام «انتقال بین کارگاهی» — همان الگوی برداشت:
// انتخاب انبار مبدأ → لیست همه‌ی اقلام موجود → ورودی تعداد هر ردیف.
// پروژه مقصد و یادداشت بار، بالای لیست برای کل اقلام انتخاب می‌شوند.
// ردیف‌های ناموفق هنگام ثبت متوالی، با پیام خطا در لیست می‌مانند و
// ردیف‌های موفق حذف می‌شوند (ورودی‌ها کلید-محورند تا جابه‌جا نشوند).
// ============================================================
import { faNum } from "../../../lib/formatters";
import { cn } from "../../../lib/utils";
import { specsLine, type StockRecord } from "./StockTable";
interface ProjectTransferListProps {
records: StockRecord[];
qtyOf: (id: string) => string;
onQty: (id: string, v: string) => void;
unitNameFn: (id: number | null | undefined) => string;
/** پیام خطای هر ردیف ناموفق (کلید = id رکورد انبار) */
errors: Record<string, string>;
emptyMsg: string;
}
function parseNum(s: string): number {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
}
export function ProjectTransferList({ records, qtyOf, onQty, unitNameFn, errors, emptyMsg }: ProjectTransferListProps) {
if (emptyMsg) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
}
return (
<>
{/* دسکتاپ */}
<div className="hidden sm:block">
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
<table className="w-full text-[12.5px] min-w-[600px]">
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
<tr>
<th className="py-2.5 px-3">متریال</th>
<th className="py-2.5 px-3">مشخصه‌ها</th>
<th className="py-2.5 px-3">برند</th>
<th className="py-2.5 px-3">واحد</th>
<th className="py-2.5 px-3">موجودی فعلی</th>
<th className="py-2.5 px-3">تعداد انتقال</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{records.map((r) => {
const err = errors[r.id];
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
return (
<tr key={r.id} className={cn("hover:bg-bg/40 transition-colors", (over || err) && "bg-rose-50/40")}>
<td className="px-3 py-2 font-bold text-ink">{r.material?.name || "—"}</td>
<td className="px-3 py-2 text-muted">{specsLine(r.specs)}</td>
<td className="px-3 py-2">{r.brand?.name || "—"}</td>
<td className="px-3 py-2">{unitNameFn(r.unit?.id)}</td>
<td className="px-3 py-2 font-bold text-ink">{faNum(r.quantity_number)}</td>
<td className="px-3 py-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="۰"
value={qtyOf(r.id)}
onChange={(e) => onQty(r.id, e.target.value)}
/>
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از موجودی!</div>}
{err && <div className="mt-0.5 text-[10px] font-bold text-rose-700">{err}</div>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
<span>پس از ثبت، موجودی همان لحظه از انبار مبدأ کسر و کالا «در راه» پروژه مقصد می‌شود.</span>
<span>{faNum(records.length)} قلم کالا</span>
</div>
</div>
{/* موبایل: کارت‌ها */}
<div className="sm:hidden">
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
{records.map((r) => {
const err = errors[r.id];
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
return (
<div key={r.id} className={cn("rounded-xl border bg-panel p-3", (over || err) ? "border-red-400" : "border-line")}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
<div className="mt-0.5 text-[11.5px] text-muted">
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
</div>
{r.specs && Object.keys(r.specs).length > 0 && (
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
)}
</div>
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
<div className="text-[10px] text-muted">موجودی</div>
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
</div>
</div>
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"mt-2 h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="تعداد انتقال"
value={qtyOf(r.id)}
onChange={(e) => onQty(r.id, e.target.value)}
/>
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از موجودی!</div>}
{err && <div className="mt-1 text-[11px] font-bold text-rose-700">{err}</div>}
</div>
);
})}
</div>
</div>
</>
);
}
@@ -1,88 +0,0 @@
// ============================================================
// جدول «اقلام ثبت‌شده در این نشست» برای موجودی اولیه
// بازخورد دیداری برای جلوگیری از ثبت تکراری — فقط حافظه‌ی همان صفحه.
// ============================================================
import { faNum } from "../../../lib/formatters";
import { specsLine } from "./StockTable";
export interface PurchaseSessionEntry {
material: string;
brand: string;
specs: Record<string, string>;
qty: number;
unitLabel: string;
time: string; // HH:MM آماده‌ی نمایش
whName: string;
}
interface PurchaseSessionLogProps {
entries: PurchaseSessionEntry[];
onClear: () => void;
}
export function PurchaseSessionLog({ entries, onClear }: PurchaseSessionLogProps) {
if (!entries.length) return null;
return (
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4">
<div className="mb-2 flex items-center justify-between gap-2 border-b border-line pb-2">
<h3 className="text-[13px] font-bold">
🧾 اقلام ثبت‌شده در این نشست <span className="text-muted font-normal">({faNum(entries.length)} قلم)</span>
</h3>
<button
type="button"
onClick={onClear}
className="rounded-lg border border-line px-2.5 py-1.5 text-[11.5px] text-muted hover:bg-bg"
>
پاک کردن لیست
</button>
</div>
{/* دسکتاپ: جدول فشرده */}
<div className="hidden sm:block">
<div className="max-h-56 overflow-y-auto rounded-lg border border-line">
<table className="w-full text-[12px]">
<thead className="sticky top-0 bg-panel text-right text-muted shadow-2xs">
<tr>
<th className="py-2 px-3">متریال</th>
<th className="py-2 px-3">برند</th>
<th className="py-2 px-3">مشخصه‌ها</th>
<th className="py-2 px-3">تعداد</th>
<th className="py-2 px-3">ساعت</th>
<th className="py-2 px-3">انبار</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{[...entries].reverse().map((e, i) => (
<tr key={i}>
<td className="py-1.5 px-3 font-semibold text-ink">{e.material}</td>
<td className="py-1.5 px-3 text-muted">{e.brand || "—"}</td>
<td className="py-1.5 px-3 text-muted">{specsLine(e.specs)}</td>
<td className="py-1.5 px-3 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></td>
<td className="py-1.5 px-3 text-muted">{e.time}</td>
<td className="py-1.5 px-3 text-muted">{e.whName}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* موبایل: ردیف‌های فشرده */}
<div className="sm:hidden max-h-64 space-y-1.5 overflow-y-auto">
{[...entries].reverse().map((e, i) => (
<div key={i} className="rounded-lg border border-line/70 bg-bg/40 px-3 py-2 text-[12px]">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-bold text-ink">{e.material}</span>
<span className="shrink-0 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></span>
</div>
<div className="mt-0.5 text-[11px] text-muted">
{e.brand || "بدون برند"} · {specsLine(e.specs)} · {e.time} · {e.whName}
</div>
</div>
))}
</div>
</div>
);
}
@@ -1,54 +0,0 @@
// ============================================================
// نوار فیلتر بازه‌ی روز بخش «برگشت»
// پیش‌فرض‌های لمسی (امروز/دیروز/۷ روز/۳۰ روز) + بازه‌ی دلخواه با تقویم جلالی.
// چیپ‌ها در موبایل افقی اسکرول‌پذیرند (no-scrollbar) با ارتفاع تاچ.
// ============================================================
import { JalaliDatePicker } from "../../../components/JalaliDatePicker";
import { cn } from "../../../lib/utils";
import { DATE_PRESETS, type DatePreset } from "./returnLogic";
interface ReturnFilterBarProps {
preset: DatePreset;
onPreset: (p: DatePreset) => void;
customFrom: string | null;
customTo: string | null;
onCustomFrom: (iso: string | null) => void;
onCustomTo: (iso: string | null) => void;
}
export function ReturnFilterBar({ preset, onPreset, customFrom, customTo, onCustomFrom, onCustomTo }: ReturnFilterBarProps) {
return (
<div className="space-y-2">
<div className="no-scrollbar -mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
{DATE_PRESETS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => onPreset(p.id)}
className={cn(
"h-11 shrink-0 whitespace-nowrap rounded-lg px-3.5 text-[12.5px] transition-colors",
preset === p.id
? "bg-accent font-bold text-white shadow-xs"
: "border border-line bg-panel text-ink hover:bg-bg",
)}
>
{p.label}
</button>
))}
</div>
{preset === "custom" && (
<div className="grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-[11px] font-semibold text-muted">از تاریخ</label>
<JalaliDatePicker value={customFrom} onChange={onCustomFrom} placeholder="از تاریخ…" />
</div>
<div>
<label className="mb-1 block text-[11px] font-semibold text-muted">تا تاریخ</label>
<JalaliDatePicker value={customTo} onChange={onCustomTo} placeholder="تا تاریخ…" />
</div>
</div>
)}
</div>
);
}
@@ -1,164 +0,0 @@
// ============================================================
// جدول برداشت‌های بازه برای بخش «برگشت»
// هر ردیف = یک تراکنش برداشت واقعی (Immutable Ledger) با مانده قابل
// برگشت محاسبه‌شده؛ ورودی مقدار برگشت به همان مانده سقف دارد.
// ورودی‌ها کلید-محورند (کلید = tx-id) تا حذف ردیف‌های موفق، بقیه را
// به‌هم نریزد.
// ============================================================
import type { InventoryTransaction } from "../../../lib/types";
import { faNum, fmtDateTime } from "../../../lib/formatters";
import { cn } from "../../../lib/utils";
import { specsLine } from "./StockTable";
interface ReturnTableProps {
withdrawals: InventoryTransaction[];
qtyOf: (id: string) => string;
noteOf: (id: string) => string;
onQty: (id: string, v: string) => void;
onNote: (id: string, v: string) => void;
remainders: Map<string, number>;
unitNameFn: (id: number | null | undefined) => string;
emptyMsg: string;
loading?: boolean;
}
function parseNum(s: string): number {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
}
export function ReturnTable({ withdrawals, qtyOf, noteOf, onQty, onNote, remainders, unitNameFn, emptyMsg, loading }: ReturnTableProps) {
if (loading) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری برداشت‌ها…</div>;
}
if (emptyMsg) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
}
return (
<>
{/* دسکتاپ */}
<div className="hidden sm:block">
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
<table className="w-full text-[12.5px] min-w-[760px]">
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
<tr>
<th className="py-2.5 px-3">تاریخ</th>
<th className="py-2.5 px-3">متریال</th>
<th className="py-2.5 px-3">مشخصه‌ها</th>
<th className="py-2.5 px-3">برند</th>
<th className="py-2.5 px-3">واحد</th>
<th className="py-2.5 px-3">برداشت</th>
<th className="py-2.5 px-3">مانده قابل برگشت</th>
<th className="py-2.5 px-3">مقدار برگشت / توضیح</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{withdrawals.map((w) => {
const cap = remainders.get(w.id) ?? 0;
const q = parseNum(qtyOf(w.id));
const over = q > cap + 1e-9;
return (
<tr key={w.id} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
<td className="whitespace-nowrap px-3 py-2 text-muted">{fmtDateTime(w.transaction_date)}</td>
<td className="px-3 py-2 font-bold text-ink">{w.material?.name || "—"}</td>
<td className="px-3 py-2 text-muted">{specsLine(w.specs)}</td>
<td className="px-3 py-2">{w.brand?.name || "—"}</td>
<td className="px-3 py-2">{unitNameFn(w.unit?.id)}</td>
<td className="px-3 py-2 font-bold">{faNum(Math.abs(w.quantity_number || 0))}</td>
<td className={cn("px-3 py-2 font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-8 w-20 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="۰"
value={qtyOf(w.id)}
onChange={(e) => onQty(w.id, e.target.value)}
/>
<input
type="text"
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
placeholder="توضیح (اختیاری)"
value={noteOf(w.id)}
onChange={(e) => onNote(w.id, e.target.value)}
/>
</div>
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-2 text-[11px] text-muted">
«مانده قابل برگشت» = مقدار برداشت − برگشت‌های ثبت‌شده همان ردیف. سقف ورودی همین مانده است.
</div>
</div>
{/* موبایل: کارت‌ها */}
<div className="sm:hidden">
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
{withdrawals.map((w) => {
const cap = remainders.get(w.id) ?? 0;
const q = parseNum(qtyOf(w.id));
const over = q > cap + 1e-9;
return (
<div key={w.id} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13.5px] font-bold text-ink">{w.material?.name || "—"}</div>
<div className="mt-0.5 text-[11.5px] text-muted">
{w.brand?.name || "بدون برند"} · {unitNameFn(w.unit?.id)}
</div>
{w.specs && Object.keys(w.specs).length > 0 && (
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(w.specs)}</div>
)}
</div>
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
<div className="text-[10px] text-muted">مانده قابل برگشت</div>
<div className={cn("text-[14px] font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</div>
</div>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
<span>{fmtDateTime(w.transaction_date)}</span>
<span>· برداشت: <b className="text-ink">{faNum(Math.abs(w.quantity_number || 0))}</b></span>
{w.delivered_to && <span>· تحویل به: <b className="text-ink">{w.delivered_to}</b></span>}
</div>
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className={cn(
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line",
)}
placeholder="مقدار برگشت"
value={qtyOf(w.id)}
onChange={(e) => onQty(w.id, e.target.value)}
/>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="توضیح (اختیاری)"
value={noteOf(w.id)}
onChange={(e) => onNote(w.id, e.target.value)}
/>
</div>
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
</div>
);
})}
</div>
</div>
</>
);
}
@@ -1,72 +0,0 @@
// ============================================================
// ویرایشگر مشخصه‌های فنی Key-Value (موجودی اولیه)
// خروجی: jsonb استاندارد {عنوان: مقدار} هم‌خوان با specs_hash بک‌اند.
// مشخصات کاملاً اختیاری است؛ ردیف بدون عنوان دور ریخته می‌شود.
// ============================================================
import { faNum } from "../../../lib/formatters";
export interface SpecKvRow { key: string; value: string; }
export function specsKvToObject(rows: SpecKvRow[]): Record<string, string> {
const out: Record<string, string> = {};
for (const r of rows) {
const k = r.key.trim();
if (k) out[k] = r.value.trim();
}
return out;
}
interface SpecsKvEditorProps {
rows: SpecKvRow[];
onChange: (rows: SpecKvRow[]) => void;
disabled?: boolean;
}
export function SpecsKvEditor({ rows, onChange, disabled }: SpecsKvEditorProps) {
const update = (i: number, patch: Partial<SpecKvRow>) =>
onChange(rows.map((r, ri) => (ri === i ? { ...r, ...patch } : r)));
return (
<div className="space-y-2">
{rows.map((r, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
disabled={disabled}
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="عنوان مشخصه (مثلاً ضخامت)"
value={r.key}
onChange={(e) => update(i, { key: e.target.value })}
/>
<span className="shrink-0 text-[12px] text-muted">:</span>
<input
type="text"
disabled={disabled}
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="مقدار (مثلاً ۲ میلی‌متر)"
value={r.value}
onChange={(e) => update(i, { value: e.target.value })}
/>
<button
type="button"
disabled={disabled}
onClick={() => onChange(rows.filter((_, ri) => ri !== i))}
className="shrink-0 rounded-lg border border-line px-2.5 py-2 text-[13px] text-rose-600 hover:bg-rose-50 disabled:opacity-40"
title="حذف این مشخصه"
>
✕
</button>
</div>
))}
<button
type="button"
disabled={disabled}
onClick={() => onChange([...rows, { key: "", value: "" }])}
className="rounded-lg border border-dashed border-line px-3 py-2 text-[12px] font-medium text-muted hover:bg-bg disabled:opacity-40"
>
+ افزودن مشخصه {rows.length > 0 && `(${faNum(rows.length)})`}
</button>
</div>
);
}
@@ -1,154 +0,0 @@
// ============================================================
// هسته‌ی نمایش مشترک لیست متریال‌های انبار برای بخش‌های لیست‌محور
// (برداشت / ضایعات / اتلاف / مفقودی / انتقال داخلی / شمارش موجودی)
//
// دسکتاپ (sm+): جدول فشرده با هدر چسبان و ارتفاع بهینه.
// موبایل (<640px): کارت‌های عمودی لمسی با فیلد بزرگ ورود مقدار.
// هر بخش ستون‌های نمایش یکسان را از اینجا می‌گیرد و فقط رفتار ورودی
// (تعداد ساده یا شمارش‌شده) را با isCount انتخاب می‌کند.
// ============================================================
import { faNum } from "../../../lib/formatters";
export interface StockRecord {
id: string;
material: { id: string; name: string };
brand: { id: string; name: string } | null;
unit: { id: number; code: string; name_fa: string | null } | null;
quantity_number: number;
specs: Record<string, string>;
}
export interface RowDraft { key: string; qty: string; note: string; }
export function specsLine(specs: Record<string, string> | undefined): string {
return (
Object.entries(specs || {})
.map(([k, v]) => `${k}: ${v}`)
.join(" · ") || "—"
);
}
interface StockTableProps {
records: StockRecord[];
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
/** شمارش موجودی: placeholder «شمارش‌شده» و پیش‌نمایش مقدار فعلی هنگام خالی بودن */
isCount?: boolean;
/** نمایش ستون متریال در دسکتاپ — وقتی فیلتر نام فعال است می‌توان پنهان کرد */
showMaterial?: boolean;
emptyMsg: string;
unitNameFn: (id: number | null | undefined) => string;
}
export function StockTable({ records, rows, setRows, isCount = false, showMaterial = true, emptyMsg, unitNameFn }: StockTableProps) {
if (emptyMsg) {
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
}
const qtyPlaceholder = isCount ? "شمارش‌شده" : "۰";
return (
<>
{/* دسکتاپ: جدول فشرده با هدر چسبان */}
<div className="hidden sm:block">
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
<table className="w-full text-[12.5px] min-w-[560px]">
<thead className="sticky top-0 z-10 border-b border-line bg-panel shadow-2xs">
<tr className="text-right text-muted">
{showMaterial && <th className="py-2.5 px-3">متریال</th>}
<th className="py-2.5 px-3">مشخصه‌ها</th>
<th className="py-2.5 px-3">برند</th>
<th className="py-2.5 px-3">واحد</th>
<th className="py-2.5 px-3">موجودی فعلی</th>
<th className="py-2.5 px-3">{isCount ? "شمارش‌شده" : "تعداد"}</th>
<th className="py-2.5 px-3">یادداشت</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{records.map((r, i) => (
<tr key={r.id} className="hover:bg-bg/40 transition-colors">
{showMaterial && (
<td className="py-2 px-3 font-bold text-ink">{r.material?.name || "—"}</td>
)}
<td className="py-2 px-3 text-muted">{specsLine(r.specs)}</td>
<td className="py-2 px-3">{r.brand?.name || "—"}</td>
<td className="py-2 px-3">{unitNameFn(r.unit?.id)}</td>
<td className="py-2 px-3 font-bold text-ink">{faNum(r.quantity_number)}</td>
<td className="py-2 px-3">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr focus:border-accent"
placeholder={qtyPlaceholder}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-2 px-3">
<input
type="text"
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-2 flex items-center justify-between text-[11px] text-muted">
<span>ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</span>
<span>{faNum(records.length)} قلم کالا</span>
</div>
</div>
{/* موبایل: کارت‌های عمودی لمسی */}
<div className="sm:hidden">
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
{records.map((r, i) => (
<div key={r.id} className="rounded-xl border border-line bg-panel p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
<div className="mt-0.5 text-[11.5px] text-muted">
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
</div>
{r.specs && Object.keys(r.specs).length > 0 && (
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.specs)}</div>
)}
</div>
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
<div className="text-[10px] text-muted">موجودی</div>
<div className="text-[14px] font-black text-ink">{faNum(r.quantity_number)}</div>
</div>
</div>
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
<input
type="text"
inputMode="decimal"
pattern="[0-9]*"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[15px] font-bold ltr focus:border-accent"
placeholder={qtyPlaceholder}
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
<input
type="text"
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="توضیح (اختیاری)"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</div>
</div>
))}
</div>
<div className="mt-2 text-[11px] text-muted">{faNum(records.length)} قلم کالا — برای هر قلم، مقدار را وارد کنید.</div>
</div>
</>
);
}
@@ -1,244 +0,0 @@
// ============================================================
// منطق بخش «برگشت» — واکشی برداشت‌ها/برگشت‌ها و محاسبه مانده قابل برگشت
//
// اصل تغییرناپذیری دفتر (Immutable Ledger): هیچ تراکنشی ویرایش/حذف نمی‌شود؛
// مانده هر برداشت = مقدار برداشت − مجموع برگشت‌های متصل به آن.
// اتصال دو راه دارد:
// ۱) برگشت‌های جدید note شان [REF:<tx-id>] دارد → اتصال دقیق.
// ۲) برگشت‌های قدیمی بدون REF (ثبت‌شده در همان انبار مبدأ) → تخصیص FIFO
// بین برداشت‌های همان ردیف انبار (متریال+برند+مشخصات+واحد).
// همه‌ی جمع/تفریق‌ها با round4 گرد می‌شوند تا خطای اعشار جاوااسکریپت
// (مثل 0.30000000000000004) در مانده‌ها و سقف‌ها نشت نکند.
// ============================================================
import { apiV26 } from "../../../lib/api";
import type { InventoryTransaction } from "../../../lib/types";
/** گرد کردن به ۴ رقم اعشار — مرجع واحد برای همه‌ی محاسبات مانده */
export function round4(n: number): number {
return Math.round(n * 10000) / 10000;
}
// ------------------------------------------------------------
// تاریخ — کلید روز میلادی محلی و بازه‌های پیش‌فرض
// ------------------------------------------------------------
export function gregKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function addDays(d: Date, n: number): Date {
const x = new Date(d);
x.setDate(x.getDate() + n);
return x;
}
export type DatePreset = "today" | "yesterday" | "7d" | "30d" | "custom";
export const DATE_PRESETS: { id: DatePreset; label: string }[] = [
{ id: "today", label: "امروز" },
{ id: "yesterday", label: "دیروز" },
{ id: "7d", label: "۷ روز اخیر" },
{ id: "30d", label: "۳۰ روز اخیر" },
{ id: "custom", label: "بازه دلخواه" },
];
/** بازه‌ی سرور-ساید برای هر پیش‌فرض؛ date_to روزِ (inclusive بک‌اند: < date_to + 24h) */
export function presetRange(
preset: DatePreset,
customFrom?: string,
customTo?: string,
): { date_from: string; date_to: string } {
const today = new Date();
switch (preset) {
case "today":
return { date_from: gregKey(today), date_to: gregKey(today) };
case "yesterday": {
const y = gregKey(addDays(today, -1));
return { date_from: y, date_to: y };
}
case "7d":
return { date_from: gregKey(addDays(today, -6)), date_to: gregKey(today) };
case "30d":
return { date_from: gregKey(addDays(today, -29)), date_to: gregKey(today) };
case "custom":
return {
date_from: customFrom || gregKey(today),
date_to: customTo || customFrom || gregKey(today),
};
}
}
// ------------------------------------------------------------
// تگ رفرنس [REF:<uuid>]
// ------------------------------------------------------------
const REF_RE = /\[REF:([0-9a-fA-F-]{36})\]/;
export function parseRef(note?: string | null): string | null {
if (!note) return null;
const m = note.match(REF_RE);
return m ? m[1] : null;
}
/** یادداشت نهایی برگشت = یادداشت کاربر + تگ رفرنس برداشت */
export function buildReturnNote(userNote: string | undefined, withdrawalTxId: string): string {
const tag = `[REF:${withdrawalTxId}]`;
const note = (userNote || "").trim();
return note ? `${note} ${tag}` : tag;
}
// ------------------------------------------------------------
// واکشی صفحه‌بندی‌شده (هر صفحه ۱۰۰ — سقف بک‌اند)
// ------------------------------------------------------------
const MAX_PAGES = 10; // سقف محافظه‌کارانه: ۱۰۰۰ رکورد
export async function fetchWithdrawalsPage(
sourceWhId: string,
dateFrom: string,
dateTo: string,
page: number,
): Promise<{ rows: InventoryTransaction[]; total: number }> {
return apiV26.inventory.transactionsPaged({
warehouse_id: sourceWhId,
transaction_type: "WITHDRAWAL",
date_from: dateFrom,
date_to: dateTo,
sort: "transaction_date",
page,
});
}
/** همه‌ی برگشت‌های پروژه از ابتدای بازه (برای تطبیق REF) — صفحه‌به‌صفحه تا سقف */
export async function fetchProjectReturns(
dateFrom: string,
): Promise<InventoryTransaction[]> {
const out: InventoryTransaction[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const { rows, total } = await apiV26.inventory.transactionsPaged({
transaction_type: "RETURN",
date_from: dateFrom,
sort: "transaction_date",
page,
});
out.push(...rows);
if (out.length >= total || rows.length === 0) break;
}
return out;
}
/** همه‌ی تراکنش‌های یک نوع در یک انبار (برای محاسبه مانده مفقودی‌ها) */
export async function fetchAllTypeInWarehouse(
warehouseId: string,
transactionType: string,
): Promise<InventoryTransaction[]> {
const out: InventoryTransaction[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const { rows, total } = await apiV26.inventory.transactionsPaged({
warehouse_id: warehouseId,
transaction_type: transactionType,
sort: "transaction_date",
page,
});
out.push(...rows);
if (out.length >= total || rows.length === 0) break;
}
return out;
}
// ------------------------------------------------------------
// مانده قابل برگشت
// ------------------------------------------------------------
/** کلید ردیف انبار: متریال + برند + مشخصات (مرتب‌شده) + واحد — هم‌ارز specs_hash بک‌اند */
function rowKey(t: InventoryTransaction): string {
const specs = Object.keys(t.specs || {})
.sort()
.map((k) => `${k}=${String((t.specs || {})[k])}`)
.join("&");
return [t.material?.id || "", t.brand?.id || "", specs, t.unit?.id ?? ""].join("|");
}
/**
* مانده قابل برگشت هر برداشت را حساب می‌کند.
* - برگشت‌های REF-دار: مستقیماً از برداشت مقصد کم می‌شوند (حتی اگر خارج از بازه/انبار ثبت شده باشند).
* - برگشت‌های REF-دار که برداشت مقصدشان در لیست نیست: نادیده گرفته می‌شوند (به برداشت خودشان تعلق دارند).
* - برگشت‌های قدیمی بدون REF (همان انبار مبدأ): FIFO بین برداشت‌های هم‌ردیف تخصیص می‌یابند.
*/
export function computeRemainders(
withdrawals: InventoryTransaction[],
returns: InventoryTransaction[],
sourceWhId: string,
): Map<string, number> {
const rem = new Map<string, number>();
for (const w of withdrawals) rem.set(w.id, round4(Math.abs(w.quantity_number || 0)));
const inList = new Set(withdrawals.map((w) => w.id));
const legacy: InventoryTransaction[] = [];
for (const r of returns) {
const qty = round4(Math.abs(r.quantity_number || 0));
if (qty <= 0) continue;
const ref = parseRef(r.note);
if (ref) {
if (inList.has(ref)) {
rem.set(ref, round4((rem.get(ref) ?? 0) - qty));
}
// ref خارج از لیست → به برداشت خودش (خارج از بازه) تعلق دارد؛ نادیده
} else {
legacy.push(r);
}
}
// تخصیص FIFO برگشت‌های قدیمیِ هم‌انبار مبدأ بین برداشت‌های هم‌ردیف (قدیمی→جدید)
const buckets = new Map<string, InventoryTransaction[]>();
for (const w of withdrawals) {
const k = rowKey(w);
const list = buckets.get(k);
if (list) list.push(w);
else buckets.set(k, [w]);
}
for (const list of buckets.values()) {
list.sort(
(a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime(),
);
}
for (const r of legacy) {
if (r.warehouse_id !== sourceWhId) continue; // برگشت قدیمی به انبار دیگر قابل انتساب نیست
const list = buckets.get(rowKey(r));
if (!list) continue;
let q = round4(Math.abs(r.quantity_number || 0));
for (const w of list) {
if (q <= 0) break;
const cur = rem.get(w.id) ?? 0;
if (cur <= 0) continue;
const take = Math.min(cur, q);
rem.set(w.id, round4(cur - take));
q = round4(q - take);
}
}
// کف صفر (محافظت در برابر داده‌های ناسازگار قدیمی)
for (const [k, v] of rem) if (v < 0) rem.set(k, 0);
return rem;
}
/** مانده مفقودیِ باقی‌مانده هر LOSS = Σ|LOSS| − ΣFOUND روی همان ردیف انبار */
export function computeLossRemainders(
losses: InventoryTransaction[],
founds: InventoryTransaction[],
): { tx: InventoryTransaction; remaining: number }[] {
const foundByKey = new Map<string, number>();
for (const f of founds) {
const k = rowKey(f);
foundByKey.set(k, round4((foundByKey.get(k) || 0) + Math.abs(f.quantity_number || 0)));
}
return losses
.map((tx) => {
const loss = Math.abs(tx.quantity_number || 0);
const found = foundByKey.get(rowKey(tx)) || 0;
return { tx, remaining: Math.max(0, round4(loss - found)) };
})
.filter((x) => x.remaining > 0);
}
+48 -16
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Trash2, Plus } from "lucide-react";
import { Trash2, Plus, Copy } from "lucide-react";
import type { Material } from "../../lib/types";
import { normalize } from "../../lib/normalize";
import { useMaterials } from "../../hooks";
@@ -35,7 +35,6 @@ export interface Step2Props {
onRemember: (rec: MaterialRec) => void;
}
// برچسب انسانی یک قلم (برای termsheet، درگ‌اندروپ و بازبینی)
export function itemRowLabel(it: ItemDraft): string {
const parts = [it.cat, it.brand, it.specs.join(" - ")].filter(Boolean);
const name = parts.length ? parts.join(" - ") : "(قلم بدون‌نام)";
@@ -43,7 +42,6 @@ export function itemRowLabel(it: ItemDraft): string {
return qtyPart ? `${name} -- ${qtyPart}` : name;
}
// مقادیر مجزای name/brand/spec در کاتالوگ سراسری
export function materialOptions(
materials: Material[],
_topic: string,
@@ -87,7 +85,6 @@ export function materialOptions(
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
}
// مرحله ۲ — مدیریت اقلام سفارش
export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember }: Step2Props) {
const materials = useMaterials();
@@ -151,6 +148,22 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
]);
};
// قابلیت تکرار قلم در ردیف بعد با کپی عمیق فیلدها و شناسه یکتا
const duplicate = (index: number) => {
const target = items[index];
if (!target) return;
const clonedItem: ItemDraft = {
...target,
tempId: "it" + Date.now() + Math.random().toString(36).slice(2, 6),
specs: [...target.specs],
};
const nextItems = [...items];
nextItems.splice(index + 1, 0, clonedItem);
onItemsChange(nextItems);
};
const rememberField = (field: "i-cat" | "i-brand" | "i-spec", it: ItemDraft) => {
if (!selectedTopic) return;
let rec: MaterialRec;
@@ -169,6 +182,8 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
onRemember(rec);
};
const commonInputClass = "!h-9 !text-xs font-normal text-right placeholder:text-xs placeholder:font-normal";
return (
<div className="space-y-4">
{/* هدر */}
@@ -208,17 +223,31 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
{it.cat || "قلم جدید"} {it.brand ? `• ${it.brand}` : ""}
</span>
</div>
{items.length > 1 && (
{/* اکشن‌های کارت: کپی و حذف */}
<div className="flex items-center gap-1">
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
title="حذف قلم"
onClick={() => remove(it.tempId)}
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-muted hover:text-ink hover:bg-line/50 transition-colors"
title="تکرار این قلم"
onClick={() => duplicate(idx)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
<Copy className="h-3.5 w-3.5" />
<span className="text-[11px]">کپی</span>
</button>
)}
{items.length > 1 && (
<button
type="button"
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs text-rose-500 hover:bg-rose-500/10 transition-colors"
title="حذف قلم"
onClick={() => remove(it.tempId)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="text-[11px]">حذف</span>
</button>
)}
</div>
</div>
{/* ردیف ۱: متریال و برند */}
@@ -234,7 +263,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="نام متریال"
onInputChange={(v) => patch(it.tempId, { cat: v })}
onBlur={() => rememberField("i-cat", it)}
inputClassName="!text-xs !h-9 text-right"
inputClassName={commonInputClass}
className="min-w-0"
/>
</div>
@@ -250,7 +279,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="برند"
onInputChange={(v) => patch(it.tempId, { brand: v })}
onBlur={() => rememberField("i-brand", it)}
inputClassName="!text-xs !h-9 text-right"
inputClassName={commonInputClass}
className="min-w-0"
/>
</div>
@@ -284,6 +313,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
}}
placeholder="مقدار"
className="!text-right text-right"
inputClassName={commonInputClass}
/>
</div>
@@ -309,13 +339,13 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
})
.catch(() => {});
}}
inputClassName="!text-xs !h-9 text-right"
inputClassName={commonInputClass}
className="min-w-0"
/>
</div>
</div>
{/* ردیف ۴: قیمت هر واحد (راست‌چین شده) */}
{/* ردیف ۴: قیمت هر واحد */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right">
{it.unit ? `قیمت هر ${it.unit} (تومان)` : "قیمت هر واحد (تومان)"}
@@ -331,10 +361,11 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="قیمت هر واحد"
showWords
className="!text-right text-right"
inputClassName={commonInputClass}
/>
</div>
{/* ردیف ۵: قیمت کل قلم (راست‌چین شده) */}
{/* ردیف ۵: قیمت کل قلم */}
<div className="flex flex-col gap-1 min-w-0 border-t border-line/60 pt-2.5">
<label className="text-[10.5px] font-semibold text-muted text-right">قیمت کل این قلم (تومان)</label>
<NumberInput
@@ -349,6 +380,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
placeholder="قیمت کل قلم"
showWords
className="!text-right text-right"
inputClassName={commonInputClass}
/>
</div>
</div>
-9
View File
@@ -786,12 +786,3 @@ button:disabled {
padding: 0.45rem;
}
}
/* اسکرول افقی بدون نوار اسکرول (چیپ‌های لمسی موبایل) */
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
-34
View File
@@ -455,7 +455,6 @@ CREATE TABLE inventory_transactions (
physical_qty NUMERIC(18,6),
transfer_pair_id UUID,
performed_by TEXT,
delivered_to TEXT,
transaction_date TIMESTAMPTZ NOT NULL DEFAULT now(),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -552,35 +551,6 @@ 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)
-- ============================================================================
@@ -609,10 +579,6 @@ 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)
-- ============================================================================
BIN
View File
Binary file not shown.