Compare commits
6
Commits
prod_v2
...
65758b5e78
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65758b5e78 | ||
|
|
b1edf6eed3 | ||
|
|
a181a8753d | ||
|
|
8adfd2267f | ||
|
|
5a9ef408ff | ||
|
|
43ff048899 |
@@ -345,6 +345,18 @@ BASE_UNITS: list[dict] = [
|
|||||||
{"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
|
{"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
|
||||||
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False},
|
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False},
|
||||||
{"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False},
|
{"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False},
|
||||||
|
{"code": "g", "name_fa": "گرم", "dimension": "mass", "si_factor": 0.001, "is_base": False},
|
||||||
|
{"code": "kpiece", "name_fa": "هزار عدد", "dimension": "count", "si_factor": 1000, "is_base": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
# تبدیلهای ثابت جهانی («تبدیلهای ثابت — همه متریالها») — ضرایب استاندارد و
|
||||||
|
# فراگیر که در هر استارتآپ بهصورت idempotent ذخیره میشوند. کلید یکتا:
|
||||||
|
# (from_unit, to_unit)؛ مقدار فیلدها، نام نمایشی فارسی واحد است — همان
|
||||||
|
# قالبی که فرانتاند در standard_conversions مینویسد.
|
||||||
|
STANDARD_CONVERSIONS: list[dict] = [
|
||||||
|
{"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000},
|
||||||
|
{"from_unit": "کیلوگرم", "to_unit": "گرم", "factor": 1000},
|
||||||
|
{"from_unit": "هزار عدد", "to_unit": "عدد", "factor": 1000},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -379,16 +391,24 @@ async def ensure_v26(engine: AsyncEngine) -> None:
|
|||||||
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
|
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
|
||||||
u,
|
u,
|
||||||
)
|
)
|
||||||
# v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync).
|
# Seed the global fixed conversions (idempotent — unique key from/to unit).
|
||||||
|
for c in STANDARD_CONVERSIONS:
|
||||||
|
await run_statement(
|
||||||
|
"INSERT INTO standard_conversions (from_unit, to_unit, factor) "
|
||||||
|
"VALUES (:from_unit, :to_unit, :factor) "
|
||||||
|
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
|
||||||
|
c,
|
||||||
|
)
|
||||||
|
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
|
||||||
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
|
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
|
||||||
await run_statement("""
|
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
|
||||||
CREATE TRIGGER trg_supplier_materials_derive_category
|
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
|
||||||
AFTER INSERT ON supplier_materials
|
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
|
||||||
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
|
|
||||||
""")
|
|
||||||
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
|
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
|
||||||
await run_statement("""
|
await run_statement("""
|
||||||
CREATE TRIGGER trg_item_decisions_sync_price
|
CREATE TRIGGER trg_item_decisions_sync_price
|
||||||
AFTER INSERT OR UPDATE OF status ON item_decisions
|
AFTER INSERT OR UPDATE OF status ON item_decisions
|
||||||
FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price()
|
FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price()
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ def _fa_validation_msg(msg: str) -> str:
|
|||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def validation_error_handler(_request: Request, exc: RequestValidationError):
|
async def validation_error_handler(_request: Request, exc: RequestValidationError):
|
||||||
|
logger.warning("RequestValidationError on %s: %s", _request.url.path, exc.errors())
|
||||||
first = exc.errors()[0]
|
first = exc.errors()[0]
|
||||||
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
|
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
|
||||||
field = ".".join(loc) if loc else None
|
field = ".".join(loc) if loc else None
|
||||||
|
|||||||
@@ -130,14 +130,11 @@ class CategoryAttribute(Base):
|
|||||||
|
|
||||||
class Material(Base):
|
class Material(Base):
|
||||||
__tablename__ = "materials"
|
__tablename__ = "materials"
|
||||||
# v2.5/2.6: name is GLOBALLY unique; category_id is nullable + derived.
|
# v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
|
||||||
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
|
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
|
||||||
__mapper_args__ = {"eager_defaults": True}
|
__mapper_args__ = {"eager_defaults": True}
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||||
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
ForeignKey("material_categories.id", ondelete="RESTRICT")
|
|
||||||
)
|
|
||||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
|
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
|
||||||
canonical_unit_id: Mapped[int] = mapped_column(
|
canonical_unit_id: Mapped[int] = mapped_column(
|
||||||
@@ -149,7 +146,6 @@ class Material(Base):
|
|||||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||||
)
|
)
|
||||||
|
|
||||||
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
|
|
||||||
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
|
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
|
||||||
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
||||||
back_populates="material", lazy="raise", passive_deletes=True
|
back_populates="material", lazy="raise", passive_deletes=True
|
||||||
|
|||||||
@@ -271,12 +271,13 @@ async def update_warehouse(
|
|||||||
wh.name = payload.name
|
wh.name = payload.name
|
||||||
if payload.description is not None:
|
if payload.description is not None:
|
||||||
wh.description = payload.description
|
wh.description = payload.description
|
||||||
|
# پروژهی انبار تغییرناپذیر است — انبار برای همیشه به پروژهی والدش تعلق
|
||||||
|
# دارد و فقط با حذف و ساخت دوباره میتواند جابهجا شود.
|
||||||
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id):
|
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id):
|
||||||
if await db.get(Project, payload.project_id) is None:
|
raise ValidationError(
|
||||||
raise NotFoundError("پروژه مقصد یافت نشد", field="project_id")
|
"پروژهی انبار قابل تغییر نیست — انبار برای همیشه به پروژهی والدش تعلق دارد",
|
||||||
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
|
field="project_id",
|
||||||
raise ForbiddenError("PM فقط میتواند انبار را در پروژه خودش جابهجا کند", field="project_id")
|
)
|
||||||
wh.project_id = payload.project_id
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
|
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ async def update_line_item(
|
|||||||
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
|
effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id
|
||||||
await ensure_brand_material_belong(db, effective_bm, new_material_id)
|
await ensure_brand_material_belong(db, effective_bm, new_material_id)
|
||||||
|
|
||||||
|
old_material_id = li.material_id
|
||||||
if payload.material_id is not None:
|
if payload.material_id is not None:
|
||||||
li.material_id = payload.material_id
|
li.material_id = payload.material_id
|
||||||
# v2.5: keep the supplier<->material catalog edge in sync.
|
# v2.5: keep the supplier<->material catalog edge in sync.
|
||||||
@@ -121,6 +122,11 @@ async def update_line_item(
|
|||||||
if payload.notes is not None:
|
if payload.notes is not None:
|
||||||
li.notes = payload.notes
|
li.notes = payload.notes
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
if payload.material_id is not None and payload.material_id != old_material_id:
|
||||||
|
from app.services.quote_service import cleanup_orphan_materials
|
||||||
|
await cleanup_orphan_materials(db, [old_material_id])
|
||||||
|
|
||||||
return {"data": (await serialize_line_items(db, [li], set()))[0]}
|
return {"data": (await serialize_line_items(db, [li], set()))[0]}
|
||||||
|
|
||||||
|
|
||||||
@@ -133,6 +139,13 @@ async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(ge
|
|||||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
|
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
|
||||||
if decision is not None and decision.status == "chosen":
|
if decision is not None and decision.status == "chosen":
|
||||||
raise ConflictError("خط آیتم انتخابشده قابل حذف نیست")
|
raise ConflictError("خط آیتم انتخابشده قابل حذف نیست")
|
||||||
|
old_material_id = li.material_id
|
||||||
await db.delete(li) # specs / links / decision / receipt cascade via DDL
|
await db.delete(li) # specs / links / decision / receipt cascade via DDL
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
if old_material_id:
|
||||||
|
from app.services.quote_service import cleanup_orphan_materials
|
||||||
|
await cleanup_orphan_materials(db, [old_material_id])
|
||||||
|
|
||||||
return {"data": {"id": str(line_item_id), "deleted": True}}
|
return {"data": {"id": str(line_item_id), "deleted": True}}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from app.models.catalog import (
|
|||||||
MaterialUnit,
|
MaterialUnit,
|
||||||
Unit,
|
Unit,
|
||||||
)
|
)
|
||||||
from app.models.procurement import LineItem
|
from app.models.procurement import LineItem, Supplier, SupplierMaterial
|
||||||
from app.schemas.catalog import (
|
from app.schemas.catalog import (
|
||||||
MaterialAttributeValuesPut,
|
MaterialAttributeValuesPut,
|
||||||
MaterialCreate,
|
MaterialCreate,
|
||||||
@@ -108,7 +108,16 @@ async def list_materials(
|
|||||||
):
|
):
|
||||||
stmt = select(Material)
|
stmt = select(Material)
|
||||||
if category_id:
|
if category_id:
|
||||||
stmt = stmt.where(Material.category_id == category_id)
|
sub = (
|
||||||
|
select(1)
|
||||||
|
.select_from(SupplierMaterial)
|
||||||
|
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
||||||
|
.where(
|
||||||
|
SupplierMaterial.material_id == Material.id,
|
||||||
|
Supplier.category_id == category_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stmt = stmt.where(exists(sub))
|
||||||
if brand_id:
|
if brand_id:
|
||||||
# WHERE EXISTS brand_materials with that brand_id
|
# WHERE EXISTS brand_materials with that brand_id
|
||||||
sub = select(1).where(
|
sub = select(1).where(
|
||||||
|
|||||||
@@ -195,17 +195,11 @@ async def pm_on_the_way(
|
|||||||
# unit / category lookups for display
|
# unit / category lookups for display
|
||||||
unit_ids = {d.LineItem.unit_id for d in decisions}
|
unit_ids = {d.LineItem.unit_id for d in decisions}
|
||||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||||
mat_ids = {d.Material.id for d in decisions}
|
sup_cat_ids = {d.Supplier.category_id for d in decisions if d.Supplier.category_id}
|
||||||
cats = {}
|
cats = {}
|
||||||
if mat_ids:
|
if sup_cat_ids:
|
||||||
rows = (
|
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(sup_cat_ids)))).scalars().all()
|
||||||
await db.execute(
|
cats = {c.id: c for c in rows}
|
||||||
select(Material.id, MaterialCategory)
|
|
||||||
.join(MaterialCategory, MaterialCategory.id == Material.category_id)
|
|
||||||
.where(Material.id.in_(mat_ids))
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
cats = {m_id: c for m_id, c in rows}
|
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
for d, li, q, m, sup, proj in decisions:
|
for d, li, q, m, sup, proj in decisions:
|
||||||
@@ -220,7 +214,7 @@ async def pm_on_the_way(
|
|||||||
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||||
"decided_at": to_iso(d.decided_at),
|
"decided_at": to_iso(d.decided_at),
|
||||||
"material": {"id": str(m.id), "name": m.name,
|
"material": {"id": str(m.id), "name": m.name,
|
||||||
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
|
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(sup.category_id)) else None},
|
||||||
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
||||||
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
||||||
"ordered_qty": str(li.quantity),
|
"ordered_qty": str(li.quantity),
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material
|
from app.models.catalog import Material
|
||||||
from app.models.procurement import ItemDecision, LineItem, Project, Quote, Supplier, Termsheet
|
from app.models.procurement import (
|
||||||
|
ItemDecision,
|
||||||
|
LineItem,
|
||||||
|
LineItemProjects,
|
||||||
|
Project,
|
||||||
|
Quote,
|
||||||
|
QuoteProjects,
|
||||||
|
Supplier,
|
||||||
|
Termsheet,
|
||||||
|
)
|
||||||
from app.schemas.procurement import (
|
from app.schemas.procurement import (
|
||||||
AcceptQuoteIn,
|
AcceptQuoteIn,
|
||||||
ArriveItemsIn,
|
ArriveItemsIn,
|
||||||
@@ -39,9 +48,8 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
|
|||||||
def _quote_category_exists(category_id) -> exists:
|
def _quote_category_exists(category_id) -> exists:
|
||||||
return exists(
|
return exists(
|
||||||
select(1).where(
|
select(1).where(
|
||||||
LineItem.quote_id == Quote.id,
|
Supplier.id == Quote.supplier_id,
|
||||||
Material.id == LineItem.material_id,
|
Supplier.category_id == category_id,
|
||||||
Material.category_id == category_id,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -112,7 +120,23 @@ async def list_quotes(
|
|||||||
if supplier_id:
|
if supplier_id:
|
||||||
stmt = stmt.where(Quote.supplier_id == supplier_id)
|
stmt = stmt.where(Quote.supplier_id == supplier_id)
|
||||||
if project_id:
|
if project_id:
|
||||||
stmt = stmt.where(Quote.project_id == project_id)
|
# any relevant link: legacy mirror, quote-level junction, line-item
|
||||||
|
# candidates, or an accepted (decision) target — NOT just the mirror
|
||||||
|
stmt = stmt.where(
|
||||||
|
Quote.id.in_(
|
||||||
|
select(Quote.id)
|
||||||
|
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
|
||||||
|
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
|
||||||
|
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
|
||||||
|
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
|
||||||
|
.where(
|
||||||
|
(Quote.project_id == project_id)
|
||||||
|
| (QuoteProjects.project_id == project_id)
|
||||||
|
| (LineItemProjects.project_id == project_id)
|
||||||
|
| (ItemDecision.project_id == project_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
if category_id:
|
if category_id:
|
||||||
stmt = stmt.where(_quote_category_exists(category_id))
|
stmt = stmt.where(_quote_category_exists(category_id))
|
||||||
if quote_date_from is not None:
|
if quote_date_from is not None:
|
||||||
|
|||||||
@@ -58,11 +58,7 @@ async def list_supplier_materials(
|
|||||||
items, meta = await paginate(stmt, page, page_size, db)
|
items, meta = await paginate(stmt, page, page_size, db)
|
||||||
mat_ids = {l.material_id for l in items}
|
mat_ids = {l.material_id for l in items}
|
||||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||||
cat_ids = {m.category_id for m in materials.values() if m.category_id}
|
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
|
||||||
cats = {}
|
|
||||||
if cat_ids:
|
|
||||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
|
||||||
return {"data": [link_to_dict(l, materials.get(l.material_id), cats.get(materials.get(l.material_id).category_id if l.material_id in materials else None)) for l in items], "meta": meta}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
||||||
@@ -72,8 +68,7 @@ async def create_supplier_material(
|
|||||||
async with db.begin():
|
async with db.begin():
|
||||||
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
||||||
material = await db.get(Material, payload.material_id)
|
material = await db.get(Material, payload.material_id)
|
||||||
category = await db.get(MaterialCategory, material.category_id) if material and material.category_id else None
|
return {"data": link_to_dict(link, material, None)}
|
||||||
return {"data": link_to_dict(link, material, category)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ class MaterialAttributeIn(APIModel):
|
|||||||
|
|
||||||
|
|
||||||
class MaterialCreate(APIModel):
|
class MaterialCreate(APIModel):
|
||||||
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
|
|
||||||
name: str = Field(min_length=1)
|
name: str = Field(min_length=1)
|
||||||
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
|
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
|
||||||
canonical_unit_id: int
|
canonical_unit_id: int
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ class WarehouseCreate(APIModel):
|
|||||||
|
|
||||||
|
|
||||||
class WarehouseUpdate(APIModel):
|
class WarehouseUpdate(APIModel):
|
||||||
|
"""Rename/re-describe a warehouse.
|
||||||
|
|
||||||
|
`project_id` is accepted only to be rejected: a warehouse belongs
|
||||||
|
permanently to its parent project and cannot be reassigned (the router
|
||||||
|
answers 400 unless the value equals the current project).
|
||||||
|
"""
|
||||||
|
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
project_id: UUID | None = None
|
project_id: UUID | None = None
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class SupplierUpdate(APIModel):
|
|||||||
|
|
||||||
|
|
||||||
class SupplierMaterialCreate(APIModel):
|
class SupplierMaterialCreate(APIModel):
|
||||||
"""Link a global material to a supplier (derives materials.category_id)."""
|
"""Link a global material to a supplier."""
|
||||||
|
|
||||||
material_id: UUID
|
material_id: UUID
|
||||||
|
|
||||||
|
|||||||
@@ -206,11 +206,6 @@ async def _load_material_maps(db: AsyncSession, materials: list[Material], expan
|
|||||||
material_ids = [m.id for m in materials]
|
material_ids = [m.id for m in materials]
|
||||||
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
|
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
|
||||||
|
|
||||||
if "category" in expand:
|
|
||||||
cat_ids = {m.category_id for m in materials}
|
|
||||||
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
|
|
||||||
maps["categories"] = {c.id: c for c in rows}
|
|
||||||
|
|
||||||
unit_ids: set[int] = set()
|
unit_ids: set[int] = set()
|
||||||
if "canonical_unit" in expand:
|
if "canonical_unit" in expand:
|
||||||
unit_ids |= {m.canonical_unit_id for m in materials}
|
unit_ids |= {m.canonical_unit_id for m in materials}
|
||||||
@@ -258,9 +253,9 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
|
|||||||
"updated_at": to_iso(m.updated_at),
|
"updated_at": to_iso(m.updated_at),
|
||||||
}
|
}
|
||||||
if "category" in expand:
|
if "category" in expand:
|
||||||
d["category"] = category_brief(maps["categories"].get(m.category_id))
|
d["category"] = None
|
||||||
else:
|
else:
|
||||||
d["category_id"] = str(m.category_id)
|
d["category_id"] = None
|
||||||
if "canonical_unit" in expand:
|
if "canonical_unit" in expand:
|
||||||
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
|
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
|
||||||
else:
|
else:
|
||||||
@@ -326,9 +321,6 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
|
|||||||
material_ids = {li.material_id for li in line_items}
|
material_ids = {li.material_id for li in line_items}
|
||||||
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
|
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
|
||||||
data.materials = {m.id: m for m in materials}
|
data.materials = {m.id: m for m in materials}
|
||||||
cat_ids = {m.category_id for m in materials}
|
|
||||||
cats = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
|
|
||||||
data.categories = {c.id: c for c in cats}
|
|
||||||
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
|
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
|
||||||
brand_ids: set[uuid.UUID] = set()
|
brand_ids: set[uuid.UUID] = set()
|
||||||
for bm in bms:
|
for bm in bms:
|
||||||
@@ -450,7 +442,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
|
|||||||
d["material"] = {
|
d["material"] = {
|
||||||
"id": str(m.id),
|
"id": str(m.id),
|
||||||
"name": m.name,
|
"name": m.name,
|
||||||
"category": category_brief(data.categories.get(m.category_id)),
|
"category": None,
|
||||||
"brands": [
|
"brands": [
|
||||||
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
|
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
|
||||||
for bm in data.material_brands.get(m.id, [])
|
for bm in data.material_brands.get(m.id, [])
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from app.models.catalog import (
|
|||||||
BrandMaterial,
|
BrandMaterial,
|
||||||
Material,
|
Material,
|
||||||
MaterialAttributeValue,
|
MaterialAttributeValue,
|
||||||
MaterialCategory,
|
|
||||||
Unit,
|
Unit,
|
||||||
)
|
)
|
||||||
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
|
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
|
||||||
@@ -110,9 +109,7 @@ async def validate_attributes(
|
|||||||
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
|
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
|
||||||
"""Create a material + brand_materials rows + EAV values in ONE transaction.
|
"""Create a material + brand_materials rows + EAV values in ONE transaction.
|
||||||
|
|
||||||
v2.5/2.6: category_id is OPTIONAL — a global material may be created
|
v2.6/v2.7: materials are global and category-free. name is GLOBALLY unique.
|
||||||
without a category (it gets derived automatically from the first linked
|
|
||||||
supplier). name is GLOBALLY unique.
|
|
||||||
"""
|
"""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
duplicate = await db.scalar(
|
duplicate = await db.scalar(
|
||||||
@@ -121,14 +118,10 @@ async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material
|
|||||||
if duplicate is not None:
|
if duplicate is not None:
|
||||||
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
|
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
|
||||||
|
|
||||||
if payload.category_id is not None:
|
|
||||||
if await db.get(MaterialCategory, payload.category_id) is None:
|
|
||||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
|
||||||
if not await db.get(Unit, payload.canonical_unit_id):
|
if not await db.get(Unit, payload.canonical_unit_id):
|
||||||
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
|
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
|
||||||
|
|
||||||
material = Material(
|
material = Material(
|
||||||
category_id=payload.category_id,
|
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
topic=payload.topic,
|
topic=payload.topic,
|
||||||
canonical_unit_id=payload.canonical_unit_id,
|
canonical_unit_id=payload.canonical_unit_id,
|
||||||
|
|||||||
@@ -290,12 +290,78 @@ async def quote_has_receipts(db: AsyncSession, quote_id: UUID) -> bool:
|
|||||||
return (count or 0) > 0
|
return (count or 0) > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_orphan_materials(db: AsyncSession, material_ids) -> list[UUID]:
|
||||||
|
"""Check candidate material_ids and purge any that have zero references.
|
||||||
|
|
||||||
|
A material is considered an orphan if:
|
||||||
|
1. It is not referenced by ANY line_items across all quotes.
|
||||||
|
2. It is not referenced by ANY inventory stock items in warehouses.
|
||||||
|
|
||||||
|
If count == 0:
|
||||||
|
- Auxiliary records in supplier_materials, brand_materials, material_units,
|
||||||
|
and material_attribute_values are cleaned up.
|
||||||
|
- The material row is deleted from the central materials table.
|
||||||
|
|
||||||
|
Returns the list of purged material UUIDs.
|
||||||
|
"""
|
||||||
|
purged: list[UUID] = []
|
||||||
|
unique_ids = {m for m in material_ids if m is not None}
|
||||||
|
if not unique_ids:
|
||||||
|
return purged
|
||||||
|
|
||||||
|
from app.models.catalog import (
|
||||||
|
BrandMaterial,
|
||||||
|
Material,
|
||||||
|
MaterialAttributeValue,
|
||||||
|
MaterialUnit,
|
||||||
|
)
|
||||||
|
from app.models.inventory import InventoryItem
|
||||||
|
from app.models.procurement import LineItem, SupplierMaterial
|
||||||
|
|
||||||
|
for mat_id in unique_ids:
|
||||||
|
# Check active usage in line_items across the whole system
|
||||||
|
line_item_count = await db.scalar(
|
||||||
|
select(func.count(LineItem.id)).where(LineItem.material_id == mat_id)
|
||||||
|
)
|
||||||
|
if (line_item_count or 0) > 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check active usage in warehouse inventory
|
||||||
|
inv_count = await db.scalar(
|
||||||
|
select(func.count(InventoryItem.id)).where(InventoryItem.material_id == mat_id)
|
||||||
|
)
|
||||||
|
if (inv_count or 0) > 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Material is not used in ANY line item and not in ANY warehouse inventory: pure orphan!
|
||||||
|
await db.execute(
|
||||||
|
delete(SupplierMaterial).where(SupplierMaterial.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(BrandMaterial).where(BrandMaterial.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(MaterialUnit).where(MaterialUnit.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(MaterialAttributeValue).where(MaterialAttributeValue.material_id == mat_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(Material).where(Material.id == mat_id)
|
||||||
|
)
|
||||||
|
purged.append(mat_id)
|
||||||
|
logger.info("Purged orphan material %s from central catalog", mat_id)
|
||||||
|
|
||||||
|
return purged
|
||||||
|
|
||||||
|
|
||||||
async def replace_quote_children(
|
async def replace_quote_children(
|
||||||
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
|
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
|
||||||
) -> Quote:
|
) -> Quote:
|
||||||
"""Full replacement of a quote's line items + termsheets (edit modal).
|
"""Full replacement of a quote's line items + termsheets (edit modal).
|
||||||
|
|
||||||
Only allowed while the quote is still offered and has no warehouse receipts.
|
Only allowed while the quote is still offered and has no warehouse receipts.
|
||||||
|
Automatically purges any materials detached during this replacement if they become orphans.
|
||||||
"""
|
"""
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
quote = await get_quote_or_404(db, quote_id)
|
quote = await get_quote_or_404(db, quote_id)
|
||||||
@@ -309,6 +375,11 @@ async def replace_quote_children(
|
|||||||
"این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
|
"این پیشنهاد بهدلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.",
|
||||||
field="status",
|
field="status",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 1. Pre-update snapshot: record previous material_ids assigned to this quote's items
|
||||||
|
old_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
|
||||||
|
old_material_ids = {li.material_id for li in old_items if li.material_id is not None}
|
||||||
|
|
||||||
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
|
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
|
||||||
await db.execute(
|
await db.execute(
|
||||||
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
|
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
|
||||||
@@ -317,10 +388,21 @@ async def replace_quote_children(
|
|||||||
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
|
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
|
||||||
)
|
)
|
||||||
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
|
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# 2. Identify detached materials: previous materials no longer present in the updated line items
|
||||||
|
new_material_ids = {li.material_id for li in payload.line_items if li.material_id is not None}
|
||||||
|
detached_material_ids = old_material_ids - new_material_ids
|
||||||
|
|
||||||
|
# 3. Clean up any detached materials that are now orphans (0 usage)
|
||||||
|
if detached_material_ids:
|
||||||
|
await cleanup_orphan_materials(db, detached_material_ids)
|
||||||
|
|
||||||
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
|
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
|
||||||
return quote
|
return quote
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
|
||||||
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
"""Backward transitions only; reverts bookkeeping when going back to offered."""
|
||||||
current = quote.status
|
current = quote.status
|
||||||
@@ -346,7 +428,7 @@ async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: s
|
|||||||
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
|
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(ItemDecision)
|
update(ItemDecision)
|
||||||
.where(ItemDecision.line_item_id.in_(li_ids), ItemDecision.status != "pending")
|
.where(ItemDecision.line_item_id.in_(li_ids))
|
||||||
.values(
|
.values(
|
||||||
status="pending",
|
status="pending",
|
||||||
decided_at=None,
|
decided_at=None,
|
||||||
@@ -524,21 +606,7 @@ async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn,
|
|||||||
decision.adjusted_notes = decision_in.adjusted_notes
|
decision.adjusted_notes = decision_in.adjusted_notes
|
||||||
decision.decided_at = utcnow()
|
decision.decided_at = utcnow()
|
||||||
|
|
||||||
# --- per-material project allocation (تخصیص پروژه به هر قلم) ---
|
# LineItemProjects records in PostgreSQL remain 100% untouched. Only item_decisions.project_id is written.
|
||||||
for entry in payload.item_projects:
|
|
||||||
if entry.line_item_id not in li_ids:
|
|
||||||
raise NotFoundError(
|
|
||||||
"خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_projects"
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
delete(LineItemProjects)
|
|
||||||
.where(LineItemProjects.line_item_id == entry.line_item_id)
|
|
||||||
.execution_options(synchronize_session=False)
|
|
||||||
)
|
|
||||||
for pid in entry.project_ids:
|
|
||||||
if await db.get(Project, pid) is None:
|
|
||||||
raise NotFoundError("پروژه یافت نشد", field="item_projects.project_ids")
|
|
||||||
db.add(LineItemProjects(line_item_id=entry.line_item_id, project_id=pid))
|
|
||||||
|
|
||||||
# termsheets referenced by at least one chosen item -> accepted
|
# termsheets referenced by at least one chosen item -> accepted
|
||||||
if chosen_ts_ids:
|
if chosen_ts_ids:
|
||||||
@@ -735,30 +803,49 @@ async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
|
|||||||
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
|
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
|
||||||
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
|
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
|
||||||
|
|
||||||
# clean up attachments in MinIO before cascade
|
# collect candidate material_ids for orphan cleanup before deleting quote
|
||||||
|
candidate_material_ids = [li.material_id for li in line_items if li.material_id is not None]
|
||||||
|
|
||||||
|
# collect attachment keys before deleting quote row
|
||||||
from app.models.files import QuoteAttachment
|
from app.models.files import QuoteAttachment
|
||||||
from app.services import storage
|
from app.services import storage
|
||||||
|
|
||||||
attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all()
|
attachments = (await db.execute(select(QuoteAttachment).where(QuoteAttachment.quote_id == quote.id))).scalars().all()
|
||||||
for att in attachments:
|
att_keys = [att.object_key for att in attachments]
|
||||||
try:
|
quote_prefix = f"{quote.id}/"
|
||||||
await storage.remove_object(att.object_key)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to remove MinIO object %s for attachment %s", att.object_key, att.id)
|
|
||||||
|
|
||||||
await db.delete(quote)
|
await db.delete(quote)
|
||||||
return {
|
await db.flush()
|
||||||
"id": str(quote.id),
|
|
||||||
"deleted": True,
|
# Purge any materials that no longer have any references in the system
|
||||||
"cascaded": {
|
purged_material_ids = await cleanup_orphan_materials(db, candidate_material_ids)
|
||||||
"line_items": len(line_items),
|
|
||||||
"termsheets": len(termsheets),
|
# Post-commit: clean up attachments in MinIO (best-effort; DB is already committed)
|
||||||
"events": len(events),
|
for key in att_keys:
|
||||||
"decisions": len(decisions),
|
try:
|
||||||
"receipts": len(receipts),
|
await storage.remove_object(key)
|
||||||
"attachments": len(attachments),
|
except Exception:
|
||||||
},
|
logger.warning("Failed to remove MinIO object %s for quote %s", key, quote_id)
|
||||||
}
|
try:
|
||||||
|
await storage.remove_prefix(quote_prefix)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to remove MinIO prefix %s for quote %s", quote_prefix, quote_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(quote_id),
|
||||||
|
"deleted": True,
|
||||||
|
"cascaded": {
|
||||||
|
"line_items": len(line_items),
|
||||||
|
"termsheets": len(termsheets),
|
||||||
|
"events": len(events),
|
||||||
|
"decisions": len(decisions),
|
||||||
|
"receipts": len(receipts),
|
||||||
|
"attachments": len(attachments),
|
||||||
|
"purged_orphan_materials": len(purged_material_ids),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -816,37 +903,57 @@ async def quote_stats(
|
|||||||
|
|
||||||
|
|
||||||
def _quote_has_category(category_id: UUID):
|
def _quote_has_category(category_id: UUID):
|
||||||
"""EXISTS: quote has a line item whose material belongs to category_id."""
|
"""EXISTS: quote's supplier belongs to category_id."""
|
||||||
from app.models.catalog import Material
|
|
||||||
|
|
||||||
sub = (
|
sub = (
|
||||||
select(1)
|
select(1)
|
||||||
.where(
|
.where(
|
||||||
LineItem.quote_id == Quote.id,
|
Supplier.id == Quote.supplier_id,
|
||||||
Material.id == LineItem.material_id,
|
Supplier.category_id == category_id,
|
||||||
Material.category_id == category_id,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return func.exists(sub)
|
return func.exists(sub)
|
||||||
|
|
||||||
|
|
||||||
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
||||||
"""Counts + total value per quote status for one project.
|
"""Counts + total value per quote status for ONE project, isolated per project.
|
||||||
|
|
||||||
total_value: SUM(quantity × unit_price) over line items using the chosen
|
A quote lands in this project's «accepted»/«arrived» bucket ONLY if at
|
||||||
termsheet link first, else the first link. Rejected line items are
|
least one non-rejected line item's decision targets this project
|
||||||
excluded from value sums (contract P1-14).
|
(item_decisions.project_id). A quote merely linked to the project as a
|
||||||
|
candidate (quote-level link, line-item candidates, or accepted for a
|
||||||
|
DIFFERENT project) stays in «offered» — this project never accepted it.
|
||||||
|
|
||||||
|
total_value: SUM(quantity × unit_price) over the bucket's line items,
|
||||||
|
using the chosen termsheet link first, else the first link. Rejected
|
||||||
|
line items are excluded from value sums (contract P1-14).
|
||||||
"""
|
"""
|
||||||
project = await db.get(Project, project_id)
|
project = await db.get(Project, project_id)
|
||||||
if project is None:
|
if project is None:
|
||||||
raise NotFoundError("پروژه یافت نشد")
|
raise NotFoundError("پروژه یافت نشد")
|
||||||
|
# the route passes the raw path string — normalize for the Python-side
|
||||||
|
# UUID comparisons below (decision.project_id == project_id etc.)
|
||||||
|
project_id = UUID(str(project_id))
|
||||||
|
|
||||||
quotes = (await db.execute(select(Quote).where(Quote.project_id == project_id))).scalars().all()
|
# --- every quote RELEVANT to this project (any kind of link) ---
|
||||||
if not quotes:
|
quote_id_rows = await db.execute(
|
||||||
|
select(Quote.id)
|
||||||
|
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
|
||||||
|
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
|
||||||
|
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
|
||||||
|
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
|
||||||
|
.where(
|
||||||
|
(Quote.project_id == project_id)
|
||||||
|
| (QuoteProjects.project_id == project_id)
|
||||||
|
| (LineItemProjects.project_id == project_id)
|
||||||
|
| (ItemDecision.project_id == project_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
quote_ids = [row[0] for row in quote_id_rows.all()]
|
||||||
|
if not quote_ids:
|
||||||
empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
|
empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
|
||||||
return {"project_id": str(project_id), "total_quotes": 0, **empty}
|
return {"project_id": str(project_id), "total_quotes": 0, **empty}
|
||||||
|
|
||||||
quote_ids = [q.id for q in quotes]
|
quotes = (await db.execute(select(Quote).where(Quote.id.in_(quote_ids)))).scalars().all()
|
||||||
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
|
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
|
||||||
li_ids = [li.id for li in line_items]
|
li_ids = [li.id for li in line_items]
|
||||||
|
|
||||||
@@ -854,6 +961,18 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
|||||||
d.line_item_id: d
|
d.line_item_id: d
|
||||||
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
||||||
} if li_ids else {}
|
} if li_ids else {}
|
||||||
|
# line items explicitly linked to this project as candidates
|
||||||
|
candidate_li_ids = set(
|
||||||
|
(
|
||||||
|
await db.execute(
|
||||||
|
select(LineItemProjects.line_item_id).where(
|
||||||
|
LineItemProjects.line_item_id.in_(li_ids),
|
||||||
|
LineItemProjects.project_id == project_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
) if li_ids else set()
|
||||||
|
|
||||||
item_termsheets = (
|
item_termsheets = (
|
||||||
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
||||||
) if li_ids else []
|
) if li_ids else []
|
||||||
@@ -862,12 +981,56 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
|||||||
for it in item_termsheets:
|
for it in item_termsheets:
|
||||||
its_by_line.setdefault(it.line_item_id, []).append(it)
|
its_by_line.setdefault(it.line_item_id, []).append(it)
|
||||||
|
|
||||||
|
def _items_of(quote: Quote) -> list[LineItem]:
|
||||||
|
return [li for li in line_items if li.quote_id == quote.id]
|
||||||
|
|
||||||
|
def _accepted_for_project(quote: Quote) -> bool:
|
||||||
|
"""≥1 non-rejected line item whose decision targets THIS project."""
|
||||||
|
return any(
|
||||||
|
(decision := decisions.get(li.id)) is not None
|
||||||
|
and decision.status == "chosen"
|
||||||
|
and decision.project_id == project_id
|
||||||
|
for li in _items_of(quote)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _has_any_target(quote: Quote) -> bool:
|
||||||
|
"""Whether any decision of this quote carries a target project at all
|
||||||
|
(legacy quotes accepted before the decision-project era have none —
|
||||||
|
they keep the old global-status classification)."""
|
||||||
|
return any(
|
||||||
|
(decisions.get(li.id) is not None and decisions[li.id].project_id is not None)
|
||||||
|
for li in _items_of(quote)
|
||||||
|
)
|
||||||
|
|
||||||
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
|
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
|
||||||
for q in quotes:
|
for q in quotes:
|
||||||
per_status[q.status]["count"] += 1
|
if q.status in ("accepted", "arrived") and _has_any_target(q) and not _accepted_for_project(q):
|
||||||
|
per_status["offered"]["count"] += 1 # linked, but never accepted for THIS project
|
||||||
|
else:
|
||||||
|
per_status[q.status]["count"] += 1
|
||||||
|
|
||||||
for li in line_items:
|
for li in line_items:
|
||||||
|
quote = next(q for q in quotes if q.id == li.quote_id)
|
||||||
decision = decisions.get(li.id)
|
decision = decisions.get(li.id)
|
||||||
|
|
||||||
|
# --- which status bucket does this line item's value belong to? ---
|
||||||
|
accepted_here = (
|
||||||
|
decision is not None
|
||||||
|
and decision.status == "chosen"
|
||||||
|
and decision.project_id == project_id
|
||||||
|
)
|
||||||
|
if quote.status in ("accepted", "arrived") and _has_any_target(quote):
|
||||||
|
if accepted_here:
|
||||||
|
bucket = quote.status
|
||||||
|
elif li.id in candidate_li_ids:
|
||||||
|
# the quote is still just a proposal for this project — the
|
||||||
|
# item's candidate value stays in the offered bucket
|
||||||
|
bucket = "offered"
|
||||||
|
else:
|
||||||
|
continue # this item targets another project entirely
|
||||||
|
else:
|
||||||
|
bucket = quote.status
|
||||||
|
|
||||||
if decision is not None and decision.status == "rejected":
|
if decision is not None and decision.status == "rejected":
|
||||||
continue # rejected items excluded from value sums
|
continue # rejected items excluded from value sums
|
||||||
if decision is not None and decision.chosen_item_termsheet_id is not None:
|
if decision is not None and decision.chosen_item_termsheet_id is not None:
|
||||||
@@ -877,8 +1040,7 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
|||||||
price_it = links[0] if links else None
|
price_it = links[0] if links else None
|
||||||
if price_it is None:
|
if price_it is None:
|
||||||
continue
|
continue
|
||||||
quote = next(q for q in quotes if q.id == li.quote_id)
|
per_status[bucket]["total_value"] += float(price_it.unit_price * li.quantity)
|
||||||
per_status[quote.status]["total_value"] += float(price_it.unit_price * li.quantity)
|
|
||||||
|
|
||||||
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
|
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
|
||||||
for status, values in per_status.items():
|
for status, values in per_status.items():
|
||||||
|
|||||||
@@ -66,3 +66,22 @@ async def remove_object(key: str) -> None:
|
|||||||
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
|
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Could not remove MinIO object %r — ignoring", key)
|
logger.warning("Could not remove MinIO object %r — ignoring", key)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_prefix(prefix: str) -> None:
|
||||||
|
"""Delete all objects matching a prefix in the bucket (best-effort)."""
|
||||||
|
client = get_client()
|
||||||
|
|
||||||
|
def _sync_remove_prefix():
|
||||||
|
try:
|
||||||
|
objects = client.list_objects(settings.MINIO_BUCKET, prefix=prefix, recursive=True)
|
||||||
|
for obj in objects:
|
||||||
|
try:
|
||||||
|
client.remove_object(settings.MINIO_BUCKET, obj.object_name)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not remove MinIO object %r under prefix %r", obj.object_name, prefix)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Could not list MinIO objects under prefix %r — ignoring", prefix)
|
||||||
|
|
||||||
|
await asyncio.to_thread(_sync_remove_prefix)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
"""Supplier ↔ material link service (v2.5) — the direct edge of the new flow.
|
"""Supplier ↔ material link service (v2.6/v2.7) — the direct edge of the catalog flow.
|
||||||
|
|
||||||
Linking a material to a supplier derives materials.category_id from the
|
Suppliers can supply any global material across categories.
|
||||||
supplier's category when the material has none (first-link-wins). The same
|
|
||||||
rule is enforced by the DB trigger trg_supplier_materials_derive_category;
|
|
||||||
this service applies it in the application layer so the ORM state is
|
|
||||||
consistent inside the same transaction.
|
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -17,7 +13,7 @@ from app.utils.errors import NotFoundError, ValidationError
|
|||||||
async def link_supplier_material(
|
async def link_supplier_material(
|
||||||
db: AsyncSession, supplier_id, material_id, is_active: bool = True
|
db: AsyncSession, supplier_id, material_id, is_active: bool = True
|
||||||
) -> SupplierMaterial:
|
) -> SupplierMaterial:
|
||||||
"""Upsert a supplier<->material link (idempotent) + derive category (first-link-wins).
|
"""Upsert a supplier<->material link (idempotent).
|
||||||
|
|
||||||
Caller is responsible for the surrounding transaction.
|
Caller is responsible for the surrounding transaction.
|
||||||
"""
|
"""
|
||||||
@@ -42,15 +38,11 @@ async def link_supplier_material(
|
|||||||
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
|
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
|
||||||
db.add(link)
|
db.add(link)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
# First-link-wins: only when the material has no category yet.
|
|
||||||
if material.category_id is None:
|
|
||||||
material.category_id = supplier.category_id
|
|
||||||
return link
|
return link
|
||||||
|
|
||||||
|
|
||||||
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
|
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
|
||||||
"""Remove the link. The material's category is NEVER auto-cleared (documented rule)."""
|
"""Remove the link."""
|
||||||
link = await db.scalar(
|
link = await db.scalar(
|
||||||
select(SupplierMaterial).where(
|
select(SupplierMaterial).where(
|
||||||
SupplierMaterial.supplier_id == supplier_id,
|
SupplierMaterial.supplier_id == supplier_id,
|
||||||
|
|||||||
@@ -261,9 +261,7 @@ async def run() -> None:
|
|||||||
material_by_name: dict[str, Material] = {}
|
material_by_name: dict[str, Material] = {}
|
||||||
brand_material_by_material: dict[str, object] = {}
|
brand_material_by_material: dict[str, object] = {}
|
||||||
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
||||||
cat = await get_category(db, cat_slug)
|
|
||||||
material = Material(
|
material = Material(
|
||||||
category_id=cat.id,
|
|
||||||
name=name,
|
name=name,
|
||||||
canonical_unit_id=unit_ids[unit_code],
|
canonical_unit_id=unit_ids[unit_code],
|
||||||
status="active",
|
status="active",
|
||||||
@@ -289,8 +287,10 @@ async def run() -> None:
|
|||||||
await db.flush()
|
await db.flush()
|
||||||
suppliers.append(sup)
|
suppliers.append(sup)
|
||||||
# last-quoted prices: link each supplier to 2 materials of its category
|
# last-quoted prices: link each supplier to 2 materials of its category
|
||||||
|
sup_cat_slug = {name: cat_slug for name, cat_slug, *_ in SUPPLIERS}
|
||||||
for sup in suppliers:
|
for sup in suppliers:
|
||||||
cat_materials = [m for m in material_by_name.values() if m.category_id == sup.category_id]
|
target_slug = sup_cat_slug.get(sup.name)
|
||||||
|
cat_materials = [material_by_name[mat_name] for mat_name, c_slug, *_ in MATERIALS if c_slug == target_slug]
|
||||||
for m in cat_materials[:2]:
|
for m in cat_materials[:2]:
|
||||||
db.add(SupplierMaterial(
|
db.add(SupplierMaterial(
|
||||||
supplier_id=sup.id, material_id=m.id, is_active=True,
|
supplier_id=sup.id, material_id=m.id, is_active=True,
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export interface ComboboxProps {
|
|||||||
onAdd?: (val: string) => void;
|
onAdd?: (val: string) => void;
|
||||||
onInputChange?: (val: string) => void;
|
onInputChange?: (val: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||||
|
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
||||||
|
openOnFocus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toItem(x: ComboboxSourceItem): ComboboxItem {
|
function toItem(x: ComboboxSourceItem): ComboboxItem {
|
||||||
@@ -49,6 +52,7 @@ export function Combobox({
|
|||||||
onAdd,
|
onAdd,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
openOnFocus = true,
|
||||||
}: ComboboxProps) {
|
}: ComboboxProps) {
|
||||||
const [text, setText] = useState(value ?? "");
|
const [text, setText] = useState(value ?? "");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -132,9 +136,13 @@ export function Combobox({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setText(e.target.value);
|
setText(e.target.value);
|
||||||
setHighlight(-1);
|
setHighlight(-1);
|
||||||
|
if (!open) setOpen(true);
|
||||||
onInputChange?.(e.target.value);
|
onInputChange?.(e.target.value);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => {
|
||||||
|
if (openOnFocus) setOpen(true);
|
||||||
|
}}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
jalaliStrToIso,
|
jalaliStrToIso,
|
||||||
jalaliToGregorian,
|
jalaliToGregorian,
|
||||||
MONTHS,
|
MONTHS,
|
||||||
|
toAsciiDigits,
|
||||||
toFa,
|
toFa,
|
||||||
WEEKDAYS,
|
WEEKDAYS,
|
||||||
} from "../lib/jalali";
|
} from "../lib/jalali";
|
||||||
@@ -29,7 +30,7 @@ function todayJalali(): [number, number, number] {
|
|||||||
|
|
||||||
function isoToJalali(iso: string | null): [number, number, number] | null {
|
function isoToJalali(iso: string | null): [number, number, number] | null {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
if (m) {
|
if (m) {
|
||||||
const gy = parseInt(m[1], 10);
|
const gy = parseInt(m[1], 10);
|
||||||
const gm = parseInt(m[2], 10);
|
const gm = parseInt(m[2], 10);
|
||||||
@@ -78,6 +79,10 @@ export function JalaliDatePicker({
|
|||||||
}: JalaliDatePickerProps) {
|
}: JalaliDatePickerProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
const [curJ, setCurJ] = useState<[number, number, number]>(() => isoToJalali(value) ?? todayJalali());
|
||||||
|
const initialTime = timeOf(value);
|
||||||
|
const [hourText, setHourText] = useState<string>(() => faNum(String(initialTime.h).padStart(2, "0")));
|
||||||
|
const [minuteText, setMinuteText] = useState<string>(() => faNum(String(initialTime.m).padStart(2, "0")));
|
||||||
|
|
||||||
const [text, setText] = useState<string>(() => {
|
const [text, setText] = useState<string>(() => {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
const j = isoToJalaliStr(value);
|
const j = isoToJalaliStr(value);
|
||||||
@@ -92,19 +97,28 @@ export function JalaliDatePicker({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
setText("");
|
setText("");
|
||||||
|
const now = nowTime();
|
||||||
|
setHourText(faNum(String(now.h).padStart(2, "0")));
|
||||||
|
setMinuteText(faNum(String(now.m).padStart(2, "0")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const j = isoToJalaliStr(value);
|
const j = isoToJalaliStr(value);
|
||||||
|
const t = timeOf(value);
|
||||||
if (!withTime) {
|
if (!withTime) {
|
||||||
setText(j);
|
setText(j);
|
||||||
} else {
|
} else {
|
||||||
const d = new Date(value);
|
const hh = String(t.h).padStart(2, "0");
|
||||||
if (Number.isNaN(d.getTime())) {
|
const mm = String(t.m).padStart(2, "0");
|
||||||
setText(j);
|
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||||
} else {
|
|
||||||
const hh = String(d.getHours()).padStart(2, "0");
|
// Only update local input texts if numeric values differ, to avoid wiping in-progress typing
|
||||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
const curH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||||
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
if (curH !== t.h) {
|
||||||
|
setHourText(faNum(hh));
|
||||||
|
}
|
||||||
|
const curM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||||
|
if (curM !== t.m) {
|
||||||
|
setMinuteText(faNum(mm));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const jParts = isoToJalali(value);
|
const jParts = isoToJalali(value);
|
||||||
@@ -116,14 +130,13 @@ export function JalaliDatePicker({
|
|||||||
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
// تاریخ انتخابشده به صورت محلی (نه UTC) برای مقایسه با سلولهای تقویم
|
||||||
function selectedLocalDate(): string | null {
|
function selectedLocalDate(): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
const selectedIso = selectedLocalDate();
|
const selectedIso = selectedLocalDate();
|
||||||
const time = timeOf(value);
|
|
||||||
|
|
||||||
const grid = useMemo(() => {
|
const grid = useMemo(() => {
|
||||||
const [jy, jm] = curJ;
|
const [jy, jm] = curJ;
|
||||||
@@ -163,36 +176,108 @@ export function JalaliDatePicker({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const selectDay = (jy: number, jm: number, jd: number) => {
|
const selectDay = (jy: number, jm: number, jd: number) => {
|
||||||
const val = withTime ? buildIso(jy, jm, jd, time.h, time.m) : buildDateOnly(jy, jm, jd);
|
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||||
|
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||||
|
const validH = Math.min(23, Math.max(0, currentH));
|
||||||
|
const validM = Math.min(59, Math.max(0, currentM));
|
||||||
|
const val = withTime ? buildIso(jy, jm, jd, validH, validM) : buildDateOnly(jy, jm, jd);
|
||||||
onChange(val);
|
onChange(val);
|
||||||
setText(
|
setText(
|
||||||
`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(time.h).padStart(2, "0") + ":" + String(time.m).padStart(2, "0")) : ""}`
|
`${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}${withTime ? " " + faNum(String(validH).padStart(2, "0") + ":" + String(validM).padStart(2, "0")) : ""}`
|
||||||
);
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTimePart = (part: "h" | "m", raw: string) => {
|
const handleHourChange = (raw: string) => {
|
||||||
const n = parseInt(raw.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10);
|
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
||||||
if (Number.isNaN(n)) return;
|
if (digits.length === 3) {
|
||||||
const h = part === "h" ? Math.min(23, Math.max(0, n)) : time.h;
|
digits = digits.slice(-1);
|
||||||
const m = part === "m" ? Math.min(59, Math.max(0, n)) : time.m;
|
} else if (digits.length > 3) {
|
||||||
|
digits = digits.slice(0, 2);
|
||||||
|
}
|
||||||
|
if (!digits) {
|
||||||
|
setHourText("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = parseInt(digits, 10);
|
||||||
|
const clamped = Math.min(23, Math.max(0, n));
|
||||||
|
const textToShow = digits.length === 2 && n > 23 ? String(clamped) : digits;
|
||||||
|
setHourText(faNum(textToShow));
|
||||||
|
|
||||||
const [jy, jm, jd] = curJ;
|
const [jy, jm, jd] = curJ;
|
||||||
onChange(buildIso(jy, jm, jd, h, m));
|
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||||
// بهروزرسانی نمایش
|
const validM = Math.min(59, Math.max(0, currentM));
|
||||||
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, h, m));
|
onChange(buildIso(jy, jm, jd, clamped, validM));
|
||||||
setText(`${j} ${faNum(String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"))}`);
|
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
|
||||||
|
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHourBlur = () => {
|
||||||
|
const digits = toAsciiDigits(hourText).replace(/\D/g, "");
|
||||||
|
const n = digits ? parseInt(digits, 10) : 0;
|
||||||
|
const clamped = Math.min(23, Math.max(0, n));
|
||||||
|
setHourText(faNum(String(clamped).padStart(2, "0")));
|
||||||
|
|
||||||
|
const [jy, jm, jd] = curJ;
|
||||||
|
const currentM = parseInt(toAsciiDigits(minuteText) || "0", 10);
|
||||||
|
const validM = Math.min(59, Math.max(0, currentM));
|
||||||
|
onChange(buildIso(jy, jm, jd, clamped, validM));
|
||||||
|
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, clamped, validM));
|
||||||
|
setText(`${j} ${faNum(String(clamped).padStart(2, "0") + ":" + String(validM).padStart(2, "0"))}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinuteChange = (raw: string) => {
|
||||||
|
let digits = toAsciiDigits(raw).replace(/\D/g, "");
|
||||||
|
if (digits.length === 3) {
|
||||||
|
digits = digits.slice(-1);
|
||||||
|
} else if (digits.length > 3) {
|
||||||
|
digits = digits.slice(0, 2);
|
||||||
|
}
|
||||||
|
if (!digits) {
|
||||||
|
setMinuteText("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = parseInt(digits, 10);
|
||||||
|
const clamped = Math.min(59, Math.max(0, n));
|
||||||
|
const textToShow = digits.length === 2 && n > 59 ? String(clamped) : digits;
|
||||||
|
setMinuteText(faNum(textToShow));
|
||||||
|
|
||||||
|
const [jy, jm, jd] = curJ;
|
||||||
|
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||||
|
const validH = Math.min(23, Math.max(0, currentH));
|
||||||
|
onChange(buildIso(jy, jm, jd, validH, clamped));
|
||||||
|
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
|
||||||
|
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMinuteBlur = () => {
|
||||||
|
const digits = toAsciiDigits(minuteText).replace(/\D/g, "");
|
||||||
|
const n = digits ? parseInt(digits, 10) : 0;
|
||||||
|
const clamped = Math.min(59, Math.max(0, n));
|
||||||
|
setMinuteText(faNum(String(clamped).padStart(2, "0")));
|
||||||
|
|
||||||
|
const [jy, jm, jd] = curJ;
|
||||||
|
const currentH = parseInt(toAsciiDigits(hourText) || "0", 10);
|
||||||
|
const validH = Math.min(23, Math.max(0, currentH));
|
||||||
|
onChange(buildIso(jy, jm, jd, validH, clamped));
|
||||||
|
const j = value ? isoToJalaliStr(value) : isoToJalaliStr(buildIso(jy, jm, jd, validH, clamped));
|
||||||
|
setText(`${j} ${faNum(String(validH).padStart(2, "0") + ":" + String(clamped).padStart(2, "0"))}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const pickToday = () => {
|
const pickToday = () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const [jy, jm, jd] = todayJalali();
|
const [jy, jm, jd] = todayJalali();
|
||||||
|
const nh = now.getHours();
|
||||||
|
const nm = now.getMinutes();
|
||||||
|
setHourText(faNum(String(nh).padStart(2, "0")));
|
||||||
|
setMinuteText(faNum(String(nm).padStart(2, "0")));
|
||||||
const val = withTime
|
const val = withTime
|
||||||
? buildIso(jy, jm, jd, now.getHours(), now.getMinutes())
|
? buildIso(jy, jm, jd, nh, nm)
|
||||||
: buildDateOnly(jy, jm, jd);
|
: buildDateOnly(jy, jm, jd);
|
||||||
onChange(val);
|
onChange(val);
|
||||||
setText(
|
setText(
|
||||||
withTime
|
withTime
|
||||||
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(now.getHours()).padStart(2, "0") + ":" + String(now.getMinutes()).padStart(2, "0"))}`
|
? `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))} ${faNum(String(nh).padStart(2, "0") + ":" + String(nm).padStart(2, "0"))}`
|
||||||
: `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
|
: `${toFa(jy)}/${toFa(String(jm).padStart(2, "0"))}/${toFa(String(jd).padStart(2, "0"))}`,
|
||||||
);
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -201,17 +286,24 @@ export function JalaliDatePicker({
|
|||||||
const clear = () => {
|
const clear = () => {
|
||||||
onChange(null);
|
onChange(null);
|
||||||
setText("");
|
setText("");
|
||||||
|
const now = nowTime();
|
||||||
|
setHourText(faNum(String(now.h).padStart(2, "0")));
|
||||||
|
setMinuteText(faNum(String(now.m).padStart(2, "0")));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onInputChange = (v: string) => {
|
const onInputChange = (v: string) => {
|
||||||
setText(v);
|
setText(v);
|
||||||
|
if (!v.trim()) {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const iso = jalaliStrToIso(v);
|
const iso = jalaliStrToIso(v);
|
||||||
if (iso) {
|
if (iso) {
|
||||||
if (!withTime) {
|
if (!withTime) {
|
||||||
const nums = String(v)
|
const nums = String(v)
|
||||||
.split(/[/\-.]/)
|
.split(/[/\-.]/)
|
||||||
.map((x) => parseInt(String(x).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))), 10));
|
.map((x) => parseInt(toAsciiDigits(x), 10));
|
||||||
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
|
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
|
||||||
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
|
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
|
||||||
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`);
|
onChange(`${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`);
|
||||||
@@ -226,6 +318,51 @@ export function JalaliDatePicker({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInputBlur = () => {
|
||||||
|
if (!text.trim()) {
|
||||||
|
onChange(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const iso = jalaliStrToIso(text);
|
||||||
|
if (iso) {
|
||||||
|
if (!withTime) {
|
||||||
|
const nums = String(text)
|
||||||
|
.split(/[/\-.]/)
|
||||||
|
.map((x) => parseInt(toAsciiDigits(x), 10));
|
||||||
|
if (nums.length >= 3 && !nums.some((n) => Number.isNaN(n))) {
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(nums[0], nums[1], nums[2]);
|
||||||
|
const dOnly = `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||||
|
onChange(dOnly);
|
||||||
|
setText(isoToJalaliStr(dOnly));
|
||||||
|
} else {
|
||||||
|
onChange(iso.slice(0, 10));
|
||||||
|
setText(isoToJalaliStr(iso));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onChange(iso);
|
||||||
|
const j = isoToJalaliStr(iso);
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (value) {
|
||||||
|
const j = isoToJalaliStr(value);
|
||||||
|
if (!withTime) {
|
||||||
|
setText(j);
|
||||||
|
} else {
|
||||||
|
const d = new Date(value);
|
||||||
|
if (!Number.isNaN(d.getTime())) {
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
setText(`${j} ${faNum(hh + ":" + mm)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={cn("relative", className)}>
|
<div ref={ref} className={cn("relative", className)}>
|
||||||
<input
|
<input
|
||||||
@@ -238,6 +375,7 @@ export function JalaliDatePicker({
|
|||||||
onFocus={() => !disabled && setOpen(true)}
|
onFocus={() => !disabled && setOpen(true)}
|
||||||
onClick={() => !disabled && setOpen(true)}
|
onClick={() => !disabled && setOpen(true)}
|
||||||
onChange={(e) => onInputChange(e.target.value)}
|
onChange={(e) => onInputChange(e.target.value)}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
/>
|
/>
|
||||||
{open && !disabled && (
|
{open && !disabled && (
|
||||||
<div
|
<div
|
||||||
@@ -248,6 +386,7 @@ export function JalaliDatePicker({
|
|||||||
withTime ? "w-80 p-3" : "w-64 p-2",
|
withTime ? "w-80 p-3" : "w-64 p-2",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* 1. Header: Month / Year Navigation */}
|
||||||
<div className="mb-1.5 flex items-center justify-between gap-1">
|
<div className="mb-1.5 flex items-center justify-between gap-1">
|
||||||
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
<button type="button" className="cursor-pointer rounded px-1.5 text-sm hover:bg-slate-bg" onClick={() => nav("y+1")}>
|
||||||
«
|
«
|
||||||
@@ -265,6 +404,8 @@ export function JalaliDatePicker({
|
|||||||
»
|
»
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Day of Week Header & Days Grid */}
|
||||||
<div className="grid grid-cols-7 gap-0.5 text-center">
|
<div className="grid grid-cols-7 gap-0.5 text-center">
|
||||||
{WEEKDAYS.map((w) => (
|
{WEEKDAYS.map((w) => (
|
||||||
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
<div key={w} className="py-0.5 text-[10px] font-bold text-muted">
|
||||||
@@ -291,33 +432,40 @@ export function JalaliDatePicker({
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Time Inputs Row (Positioned BELOW the calendar grid) — Hour on left, Minute on right */}
|
||||||
{withTime && (
|
{withTime && (
|
||||||
<div className="mt-2 flex items-center justify-center gap-4 border-t border-line pt-2.5">
|
<div className="mt-2 flex items-center justify-center gap-3 rounded-md border border-line/70 bg-slate-bg/40 py-1.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
|
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
|
||||||
value={faNum(String(time.h).padStart(2, "0"))}
|
value={minuteText}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
maxLength={2}
|
onFocus={(e) => e.target.select()}
|
||||||
onChange={(e) => setTimePart("h", e.target.value)}
|
onChange={(e) => handleMinuteChange(e.target.value)}
|
||||||
|
onBlur={handleMinuteBlur}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<span className="text-muted">:</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
<span className="text-[11.5px] font-semibold text-muted">دقیقه</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-bold text-muted">:</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="num-input h-8 w-14 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-10 max-md:w-16"
|
className="num-input h-7 w-12 rounded-md border border-line bg-panel px-1 text-center text-[13px] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 max-md:h-9 max-md:w-14"
|
||||||
value={faNum(String(time.m).padStart(2, "0"))}
|
value={hourText}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
maxLength={2}
|
onFocus={(e) => e.target.select()}
|
||||||
onChange={(e) => setTimePart("m", e.target.value)}
|
onChange={(e) => handleHourChange(e.target.value)}
|
||||||
|
onBlur={handleHourBlur}
|
||||||
/>
|
/>
|
||||||
|
<span className="text-[11.5px] font-semibold text-muted">ساعت</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* 4. Action Buttons Footer */}
|
||||||
<div className="mt-2 flex justify-between border-t border-line pt-2">
|
<div className="mt-2 flex justify-between border-t border-line pt-2">
|
||||||
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
|
<button type="button" className="cursor-pointer rounded px-2 py-0.5 text-[11.5px] text-accent hover:bg-slate-bg" onClick={pickToday}>
|
||||||
امروز
|
امروز
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function useAddMaterial() {
|
|||||||
const invalidate = useInvalidateStore();
|
const invalidate = useInvalidateStore();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (m: Material) => api.materials.create(m),
|
mutationFn: (m: Material) => api.materials.create(m),
|
||||||
onMutate: (m) => addMaterial(m),
|
onSuccess: (created) => addMaterial(created),
|
||||||
onSettled: () => invalidate(),
|
onSettled: () => invalidate(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+121
-38
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import { normalize } from "./normalize";
|
import { normalize } from "./normalize";
|
||||||
import { buildTopics } from "./topics";
|
import { buildTopics } from "./topics";
|
||||||
|
import { jalaliToGregorian, toAsciiDigits } from "./jalali";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import type {
|
import type {
|
||||||
Attachment,
|
Attachment,
|
||||||
@@ -33,7 +34,7 @@ import type {
|
|||||||
Warehouse,
|
Warehouse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
const BASE_URL = (import.meta.env?.VITE_API_BASE_URL as string | undefined)?.trim() || "/api/v1";
|
||||||
const PAGE_SIZE = 100;
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
// حذف کلیدهای خالی/undefined از پارامترهای کوئری — URLSearchParams مقدار
|
// حذف کلیدهای خالی/undefined از پارامترهای کوئری — URLSearchParams مقدار
|
||||||
@@ -135,22 +136,42 @@ const FIELD_FA: Record<string, string> = {
|
|||||||
expiry_date: "تاریخ انقضا",
|
expiry_date: "تاریخ انقضا",
|
||||||
accepted_date: "تاریخ پذیرش",
|
accepted_date: "تاریخ پذیرش",
|
||||||
arrived_date: "تاریخ رسید",
|
arrived_date: "تاریخ رسید",
|
||||||
|
due_date: "تاریخ سررسید",
|
||||||
|
schedule_steps: "مراحل پرداخت",
|
||||||
|
milestones: "مراحل پرداخت",
|
||||||
|
amount: "مبلغ",
|
||||||
|
step_no: "شماره مرحله",
|
||||||
};
|
};
|
||||||
|
|
||||||
const faField = (field: string): string => {
|
export const faField = (field: string): string => {
|
||||||
const last = field.split(".").filter(Boolean).pop() || field;
|
const parts = field.split(".").filter((p) => p && !/^\d+$/.test(p));
|
||||||
|
const last = parts.pop() || field;
|
||||||
return FIELD_FA[last] || FIELD_FA[field] || field;
|
return FIELD_FA[last] || FIELD_FA[field] || field;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ترجمهی خطای HTTP بر اساس کد وضعیت
|
// ترجمهی خطای HTTP بر اساس کد وضعیت — پیامهای معنادار بکاند حفظ میشوند
|
||||||
const faHttpError = (status: number, fallback: string): string => {
|
export const faHttpError = (status: number, fallback: string): string => {
|
||||||
if (status === 413) return "فایل انتخابشده از حد مجاز (۵ مگابایت) بزرگتر است";
|
if (status === 413) return "فایل انتخابشده از حد مجاز (۵ مگابایت) بزرگتر است";
|
||||||
if (status === 404) return "مورد درخواستی یافت نشد";
|
if (status === 404 && (!fallback || fallback.startsWith("خطای سرور"))) return "مورد درخواستی یافت نشد";
|
||||||
if (status === 409) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
|
if (status === 409 && (!fallback || fallback.startsWith("خطای سرور"))) return "تعارض داده — رکورد تکراری یا وضعیت نامعتبر";
|
||||||
if (status === 400) return "دادهی ارسالشده نامعتبر است";
|
if (status === 400 && (!fallback || fallback.startsWith("خطای سرور"))) return "دادهی ارسالشده نامعتبر است";
|
||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
code?: string;
|
||||||
|
field?: string;
|
||||||
|
|
||||||
|
constructor(message: string, status: number, code?: string, field?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
this.field = field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// HTTP helpers — envelope بکاند: {"data": ..., "meta": ...}
|
// HTTP helpers — envelope بکاند: {"data": ..., "meta": ...}
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -163,27 +184,43 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
headers: authHeaders(init),
|
headers: authHeaders(init),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟");
|
throw new ApiError("اتصال به سرور برقرار نشد — آیا بکاند در دسترس است؟", 0);
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = `خطای سرور (${res.status})`;
|
let msg = `خطای سرور (${res.status})`;
|
||||||
|
let errCode: string | undefined;
|
||||||
|
let errField: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (body?.error?.message) {
|
if (body?.error) {
|
||||||
msg = body.error.message;
|
errCode = body.error.code;
|
||||||
|
errField = body.error.field;
|
||||||
|
const rawMsg = body.error.message || "";
|
||||||
|
const fName = errField ? faField(errField) : "";
|
||||||
|
if (fName && rawMsg) {
|
||||||
|
msg = `دادهی «${fName}» نامعتبر است: ${rawMsg}`;
|
||||||
|
} else if (rawMsg) {
|
||||||
|
msg = rawMsg;
|
||||||
|
}
|
||||||
} else if (Array.isArray(body?.detail)) {
|
} else if (Array.isArray(body?.detail)) {
|
||||||
// FastAPI 422 — آرایهی خطاهای اعتبارسنجی با پیام انگلیسی
|
// FastAPI 422 — آرایهی خطاهای اعتبارسنجی با پیام انگلیسی
|
||||||
const first = body.detail[0];
|
const first = body.detail[0];
|
||||||
const field = first?.loc?.length ? String(first.loc[first.loc.length - 1]) : "";
|
const loc = first?.loc || [];
|
||||||
|
const filteredLoc = loc.filter((p: unknown) => p !== "body" && p !== "query" && p !== "path");
|
||||||
|
errField = filteredLoc.join(".");
|
||||||
|
const fieldName = filteredLoc.length ? String(filteredLoc[filteredLoc.length - 1]) : "";
|
||||||
const fa = first?.msg ? VALIDATION_FA(String(first.msg)) : "دادهی ارسالشده نامعتبر است";
|
const fa = first?.msg ? VALIDATION_FA(String(first.msg)) : "دادهی ارسالشده نامعتبر است";
|
||||||
msg = field ? `دادهی «${faField(field)}» نامعتبر است: ${fa}` : fa;
|
const fName = fieldName ? faField(fieldName) : "";
|
||||||
|
msg = fName ? `دادهی «${fName}» نامعتبر است: ${fa}` : fa;
|
||||||
} else if (body?.detail) {
|
} else if (body?.detail) {
|
||||||
msg = String(body.detail);
|
msg = String(body.detail);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* بدنۀ خطا JSON نیست */
|
/* بدنۀ خطا JSON نیست */
|
||||||
}
|
}
|
||||||
throw new Error(faHttpError(res.status, msg));
|
const finalMsg = faHttpError(res.status, msg);
|
||||||
|
throw new ApiError(finalMsg, res.status, errCode, errField);
|
||||||
}
|
}
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T;
|
||||||
}
|
}
|
||||||
@@ -668,23 +705,66 @@ function adaptAttachment(raw: RawAttachment): Attachment {
|
|||||||
|
|
||||||
function toDateOnly(v: string | null | undefined): string | null {
|
function toDateOnly(v: string | null | undefined): string | null {
|
||||||
if (!v) return null;
|
if (!v) return null;
|
||||||
const str = String(v).trim();
|
const str = toAsciiDigits(v).trim();
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
|
if (!str) return null;
|
||||||
// datetime کامل → تاریخ به وقت محلی (نه UTC که یک روز عقب میافتد)
|
|
||||||
|
// ۱. فرمت جلالی با اسلش، خط تیره یا نقطه (سالهای ۱۳۰۰ تا ۱۵۰۰)
|
||||||
|
const jm = str.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})/);
|
||||||
|
if (jm) {
|
||||||
|
const jy = parseInt(jm[1], 10);
|
||||||
|
const jmon = parseInt(jm[2], 10);
|
||||||
|
const jd = parseInt(jm[3], 10);
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(jy, jmon, jd);
|
||||||
|
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۲. فرمت میلادی استاندارد YYYY-MM-DD
|
||||||
|
const gm = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (gm && parseInt(gm[1], 10) >= 1900) return str;
|
||||||
|
|
||||||
|
// ۳. رشتهی datetime کامل یا مقادیر دیگر
|
||||||
const d = new Date(str);
|
const d = new Date(str);
|
||||||
if (Number.isNaN(d.getTime())) return null;
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
if (d.getFullYear() < 1900 && d.getFullYear() >= 1300) {
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||||||
|
return `${gy}-${String(gm).padStart(2, "0")}-${String(gd).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toDateTime(v: string | null | undefined): string | null {
|
function toDateTime(v: string | null | undefined): string | null {
|
||||||
const d = toDateOnly(v);
|
if (!v) return null;
|
||||||
if (!d) return null;
|
const str = toAsciiDigits(v).trim();
|
||||||
return /^\d{4}-\d{2}-\d{2}$/.test(v as string) ? d + "T00:00:00Z" : (v as string);
|
if (!str) return null;
|
||||||
|
|
||||||
|
// ۱. تاریخ جلالی با زمان اختیاری
|
||||||
|
const jm = str.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
||||||
|
if (jm) {
|
||||||
|
const jy = parseInt(jm[1], 10);
|
||||||
|
const jmon = parseInt(jm[2], 10);
|
||||||
|
const jd = parseInt(jm[3], 10);
|
||||||
|
const h = jm[4] != null ? parseInt(jm[4], 10) : 0;
|
||||||
|
const m = jm[5] != null ? parseInt(jm[5], 10) : 0;
|
||||||
|
const s = jm[6] != null ? parseInt(jm[6], 10) : 0;
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(jy, jmon, jd);
|
||||||
|
return new Date(gy, gm - 1, gd, h, m, s).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۲. اگر میلادی استاندارد ISO است
|
||||||
|
const d = new Date(str);
|
||||||
|
if (!Number.isNaN(d.getTime()) && d.getFullYear() >= 1900) {
|
||||||
|
return /^\d{4}-\d{2}-\d{2}$/.test(str) ? str + "T00:00:00Z" : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOnly = toDateOnly(str);
|
||||||
|
return dateOnly ? dateOnly + "T00:00:00Z" : null;
|
||||||
}
|
}
|
||||||
function todayIso(): string {
|
function todayIso(): string {
|
||||||
return new Date().toISOString().slice(0, 10);
|
return new Date().toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
function looksLikeUuid(v: string): boolean {
|
export function looksLikeUuid(v: string | null | undefined): boolean {
|
||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
if (!v) return false;
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(v).trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -777,6 +857,13 @@ export const api = {
|
|||||||
// مقدار پیشفرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده»)
|
// مقدار پیشفرض/واحد جادویی؛ واحد خالی یعنی خطای صریح «واحد مشخص نشده»)
|
||||||
// پروژههای هر قلم هم به id واقعی ترجمه میشوند (در صورت نبودن ساخته میشوند)
|
// پروژههای هر قلم هم به id واقعی ترجمه میشوند (در صورت نبودن ساخته میشوند)
|
||||||
for (let i = 0; i < lineItems.length; i++) {
|
for (let i = 0; i < lineItems.length; i++) {
|
||||||
|
let matId = realMaterialId(lineItems[i].material_id);
|
||||||
|
if (!looksLikeUuid(matId)) {
|
||||||
|
const rawMats = await getData<RawMaterial[]>(`/materials?page_size=100`);
|
||||||
|
const found = rawMats.find((rm) => rm.id === matId) || rawMats[0];
|
||||||
|
if (found) matId = found.id;
|
||||||
|
}
|
||||||
|
lineItems[i].material_id = matId;
|
||||||
lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit);
|
lineItems[i].unit_id = await unitIdByNameOrCreate(lookup, q.line_items[i].unit);
|
||||||
for (const p of q.line_items[i].projects || []) {
|
for (const p of q.line_items[i].projects || []) {
|
||||||
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
|
const pid = looksLikeUuid(p.id) ? p.id : await projectIdByNameOrCreate(p.name);
|
||||||
@@ -870,7 +957,6 @@ export const api = {
|
|||||||
if (pid) targetProjByLine.set(li.id, pid);
|
if (pid) targetProjByLine.set(li.id, pid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const allProjectIds = [...new Set([...targetProjByLine.values()])];
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
accepted_termsheets: acceptedTs.map((t) => ({
|
accepted_termsheets: acceptedTs.map((t) => ({
|
||||||
@@ -899,11 +985,7 @@ export const api = {
|
|||||||
item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ میشوند
|
item_projects: [], // کاندیداهای قبلی در line_item_projects کاملاً حفظ میشوند
|
||||||
};
|
};
|
||||||
await patchData(`/quotes/${id}/accept`, payload);
|
await patchData(`/quotes/${id}/accept`, payload);
|
||||||
// پروژههای سطح پیشنهاد با اتحاد پروژههای اقلام همگام میشوند
|
return { ...current, ...patch, projects: current.projects } as Quote;
|
||||||
if (allProjectIds.length) {
|
|
||||||
await patchData(`/quotes/${id}`, { project_ids: allProjectIds });
|
|
||||||
}
|
|
||||||
return { ...current, ...patch } as Quote;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- ویرایش ساده: اقلام/termsheetها (جایگزینی کامل) + فیلدهای metadata ---
|
// --- ویرایش ساده: اقلام/termsheetها (جایگزینی کامل) + فیلدهای metadata ---
|
||||||
@@ -1026,12 +1108,6 @@ export const api = {
|
|||||||
},
|
},
|
||||||
create: async (m: Material): Promise<Material> => {
|
create: async (m: Material): Promise<Material> => {
|
||||||
const lookup = await fetchLookup();
|
const lookup = await fetchLookup();
|
||||||
// v2.5: category_id اختیاری است — اگر نامِ تایپشده یکی از ۸ دستهی ثابت
|
|
||||||
// نباشد، متریال بهصورت سراسری (بدون دسته) ساخته میشود و دستهاش بعداً
|
|
||||||
// از اولین تأمینکنندهی مرتبط استنتاج میشود (first-link-wins).
|
|
||||||
// دستهی rogue دیگر هرگز ساخته نمیشود — فقط تطبیق با ۸ دستهی موجود؛
|
|
||||||
// در صورت نبود تطبیق، خطا نمیدهیم و category_id را null میفرستیم.
|
|
||||||
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
|
|
||||||
// واحد مرجع — بدون مقدار پیشفرض؛ متریال بدون واحدِ صریح ساخته نمیشود
|
// واحد مرجع — بدون مقدار پیشفرض؛ متریال بدون واحدِ صریح ساخته نمیشود
|
||||||
// (بکاند canonical_unit_id را الزامی میداند)
|
// (بکاند canonical_unit_id را الزامی میداند)
|
||||||
const unitName = (m.base_unit || "").trim();
|
const unitName = (m.base_unit || "").trim();
|
||||||
@@ -1056,7 +1132,6 @@ export const api = {
|
|||||||
if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
|
if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
|
||||||
}
|
}
|
||||||
const raw = await postData<RawMaterial>("/materials", {
|
const raw = await postData<RawMaterial>("/materials", {
|
||||||
category_id: categoryId ?? null,
|
|
||||||
name,
|
name,
|
||||||
topic: m.topic || null,
|
topic: m.topic || null,
|
||||||
canonical_unit_id: unitId,
|
canonical_unit_id: unitId,
|
||||||
@@ -1259,6 +1334,10 @@ async function fetchProjects(): Promise<RawProject[]> {
|
|||||||
async function projectIdByNameOrCreate(name: string | null | undefined): Promise<string | null> {
|
async function projectIdByNameOrCreate(name: string | null | undefined): Promise<string | null> {
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
const projects = await fetchProjects();
|
const projects = await fetchProjects();
|
||||||
|
if (looksLikeUuid(name)) {
|
||||||
|
const byId = projects.find((x) => x.id.toLowerCase() === name.toLowerCase());
|
||||||
|
if (byId) return byId.id;
|
||||||
|
}
|
||||||
const pid = projectIdByName(projects, name);
|
const pid = projectIdByName(projects, name);
|
||||||
if (pid) return pid;
|
if (pid) return pid;
|
||||||
const raw = await postData<RawProject>("/projects", { name });
|
const raw = await postData<RawProject>("/projects", { name });
|
||||||
@@ -1390,8 +1469,12 @@ async function specDefinitionId(spec: string): Promise<string | null> {
|
|||||||
|
|
||||||
// utility برای ساخت id موقت جدید (ویزارد — قبل از ذخیره در بکاند)
|
// utility برای ساخت id موقت جدید (ویزارد — قبل از ذخیره در بکاند)
|
||||||
export function nextId(prefix: string, items: { id: string }[]): string {
|
export function nextId(prefix: string, items: { id: string }[]): string {
|
||||||
|
const regex = new RegExp(`^${prefix}(\\d+)$`);
|
||||||
const max = items
|
const max = items
|
||||||
.map((x) => parseInt(String(x.id).replace(/\D/g, ""), 10))
|
.map((x) => {
|
||||||
|
const m = String(x.id).match(regex);
|
||||||
|
return m ? parseInt(m[1], 10) : 0;
|
||||||
|
})
|
||||||
.filter((n) => !Number.isNaN(n))
|
.filter((n) => !Number.isNaN(n))
|
||||||
.reduce((a, b) => Math.max(a, b), 0);
|
.reduce((a, b) => Math.max(a, b), 0);
|
||||||
return prefix + (max + 1);
|
return prefix + (max + 1);
|
||||||
@@ -1592,7 +1675,8 @@ export const apiV26 = {
|
|||||||
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
|
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
|
||||||
return rawWarehouse(d);
|
return rawWarehouse(d);
|
||||||
},
|
},
|
||||||
update: async (id: string, body: { name?: string; description?: string | null; project_id?: string }): Promise<Warehouse> => {
|
// project_id عمداً در تایپ نیست — انبار برای همیشه به پروژهی والدش تعلق دارد
|
||||||
|
update: async (id: string, body: { name?: string; description?: string | null }): Promise<Warehouse> => {
|
||||||
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
|
const d = await patchData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses/${id}${projectQuery()}`, body);
|
||||||
return rawWarehouse(d);
|
return rawWarehouse(d);
|
||||||
},
|
},
|
||||||
@@ -1670,7 +1754,6 @@ export const apiV26 = {
|
|||||||
}): Promise<{ id: string; name: string }> =>
|
}): Promise<{ id: string; name: string }> =>
|
||||||
postData("/materials", {
|
postData("/materials", {
|
||||||
name: opts.name,
|
name: opts.name,
|
||||||
category_id: null, // دسته از اولین تأمینکنندهی مرتبط استنتاج میشود (v2.5)
|
|
||||||
topic: null,
|
topic: null,
|
||||||
canonical_unit_id: opts.canonical_unit_id,
|
canonical_unit_id: opts.canonical_unit_id,
|
||||||
status: "active",
|
status: "active",
|
||||||
|
|||||||
@@ -99,8 +99,11 @@ export const MONTHS = [
|
|||||||
|
|
||||||
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
export const WEEKDAYS = ["ش", "ی", "د", "س", "چ", "پ", "ج"];
|
||||||
|
|
||||||
function toEnSafe(s: string): string {
|
export function toAsciiDigits(s: string | number | null | undefined): string {
|
||||||
return String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)));
|
if (s == null) return "";
|
||||||
|
return String(s)
|
||||||
|
.replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d)))
|
||||||
|
.replace(/[٠-٩]/g, (d) => String("٠١٢٣٤٥٦٧٨٩".indexOf(d)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// format an ISO (gregorian) string to "۱۴۰۳/۰۵/۱۲"
|
// format an ISO (gregorian) string to "۱۴۰۳/۰۵/۱۲"
|
||||||
@@ -121,16 +124,29 @@ export function isoToJalaliLong(iso: string | null | undefined): string {
|
|||||||
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// parse a jalali "yyyy/m/d" string to a JS Date (gregorian) at local midnight
|
// parse a jalali "yyyy/m/d" or "yyyy/m/d h:m" string to a JS Date (gregorian) ISO string
|
||||||
export function jalaliStrToIso(str: string | null | undefined): string | null {
|
export function jalaliStrToIso(str: string | null | undefined): string | null {
|
||||||
if (!str) return null;
|
if (!str) return null;
|
||||||
const nums = String(str)
|
const s = toAsciiDigits(str).trim();
|
||||||
.split(/[/\-.]/)
|
const m = s.match(/^(1[34]\d{2})[/\-.](\d{1,2})[/\-.](\d{1,2})(?:[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?/);
|
||||||
.map((x) => parseInt(toEnSafe(x), 10));
|
if (!m) {
|
||||||
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
|
const nums = s
|
||||||
const [jy, jm, jd] = nums;
|
.split(/[/\-.]/)
|
||||||
|
.map((x) => parseInt(x, 10));
|
||||||
|
if (nums.length < 3 || nums.some((n) => Number.isNaN(n))) return null;
|
||||||
|
const [jy, jm, jd] = nums;
|
||||||
|
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||||
|
const dt = new Date(gy, gm - 1, gd);
|
||||||
|
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
||||||
|
}
|
||||||
|
const jy = parseInt(m[1], 10);
|
||||||
|
const jm = parseInt(m[2], 10);
|
||||||
|
const jd = parseInt(m[3], 10);
|
||||||
|
const h = m[4] != null ? parseInt(m[4], 10) : 0;
|
||||||
|
const min = m[5] != null ? parseInt(m[5], 10) : 0;
|
||||||
|
const sec = m[6] != null ? parseInt(m[6], 10) : 0;
|
||||||
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
const [gy, gm, gd] = jalaliToGregorian(jy, jm, jd);
|
||||||
const dt = new Date(gy, gm - 1, gd);
|
const dt = new Date(gy, gm - 1, gd, h, min, sec);
|
||||||
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
return Number.isNaN(dt.getTime()) ? null : dt.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,43 +35,51 @@ export function specsKey(specs: string[]): string {
|
|||||||
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
|
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
// specها با جداکنندهی « · »
|
// specها با جداکنندهی « . »
|
||||||
export function specsLabel(specs: string[]): string {
|
export function specsLabel(specs: string[]): string {
|
||||||
return specs.join(" · ");
|
return specs.join(" . ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* عنوان تمیز و بدون تکرار کالا:
|
* عنوان تمیز و بدون تکرار کالا:
|
||||||
* ترکیب هوشمندانهی نام اصلی (materials.name)، برند، و مشخصهها بدون تکرار کلمات مشترک.
|
* ترتیب اکید با جداکنندهی نقطه:
|
||||||
|
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
|
||||||
*/
|
*/
|
||||||
export function formatMaterialTitle(
|
export function formatMaterialTitle(
|
||||||
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
|
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
|
||||||
extraSpecs?: string[],
|
extraSpecs?: string[],
|
||||||
): string {
|
): string {
|
||||||
if (!m) return "کالا";
|
if (!m) return "کالا";
|
||||||
const rawName = (m.name || "").trim();
|
let rawName = (m.name || "").trim();
|
||||||
const rawBrand = (m.brand || "").trim();
|
const rawBrand = (m.brand || "").trim();
|
||||||
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
|
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
|
||||||
.map((s) => (s || "").trim())
|
.map((s) => (s || "").trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const normName = normalize(rawName);
|
|
||||||
const normBrand = normalize(rawBrand);
|
const normBrand = normalize(rawBrand);
|
||||||
|
|
||||||
if (!rawName) {
|
// اگر نام کالا با نام برند شروع شده باشد، برند را از ابتدای نام برمیداریم تا تکرار نشود
|
||||||
if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`;
|
if (normBrand && rawName) {
|
||||||
if (rawBrand) return rawBrand;
|
const normName = normalize(rawName);
|
||||||
if (rawSpecs.length) return rawSpecs.join(" · ");
|
if (normName.startsWith(normBrand)) {
|
||||||
return m.category && m.category !== "؟" ? m.category : "کالا";
|
const rest = rawName.slice(rawBrand.length).trim();
|
||||||
|
rawName = rest.replace(/^[.\s·\-]+/, "").trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let title = rawName;
|
// قطعات نهایی با ترتیب مشخص: [Brand Name] . [Material Name] . [Specification 1] . [Specification 2]
|
||||||
if (normBrand && !normName.includes(normBrand)) {
|
const parts: string[] = [];
|
||||||
title = `${rawBrand} ${title}`;
|
if (rawBrand) {
|
||||||
|
parts.push(rawBrand);
|
||||||
|
}
|
||||||
|
if (rawName) {
|
||||||
|
parts.push(rawName);
|
||||||
|
} else if (!rawBrand) {
|
||||||
|
parts.push(m.category && m.category !== "؟" ? m.category : "کالا");
|
||||||
}
|
}
|
||||||
|
|
||||||
const normTitle = normalize(title);
|
// اضافه کردن مشخصهها بدون تکرار
|
||||||
const distinctSpecs: string[] = [];
|
const normCurrent = normalize(parts.join(" "));
|
||||||
const seenNormSpecs = new Set<string>();
|
const seenNormSpecs = new Set<string>();
|
||||||
|
|
||||||
for (const s of rawSpecs) {
|
for (const s of rawSpecs) {
|
||||||
@@ -79,15 +87,12 @@ export function formatMaterialTitle(
|
|||||||
if (!ns || seenNormSpecs.has(ns)) continue;
|
if (!ns || seenNormSpecs.has(ns)) continue;
|
||||||
seenNormSpecs.add(ns);
|
seenNormSpecs.add(ns);
|
||||||
|
|
||||||
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمیکنیم
|
// اگر مشخصه قبلاً داخل نام یا برند آمده باشد، تکرار نمیکنیم
|
||||||
if (normTitle.includes(ns)) continue;
|
if (normCurrent.includes(ns)) continue;
|
||||||
distinctSpecs.push(s);
|
parts.push(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (distinctSpecs.length > 0) {
|
return parts.join(" . ");
|
||||||
return `${title} · ${distinctSpecs.join(" · ")}`;
|
|
||||||
}
|
|
||||||
return title;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
|
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
|
||||||
@@ -147,17 +152,10 @@ export function lineItemTotal(li: LineItem): number | null {
|
|||||||
// - در وضعیت accepted یا arrived: فقط تک پروژهی انتخابشده در خرید (item_decisions.project_id)
|
// - در وضعیت accepted یا arrived: فقط تک پروژهی انتخابشده در خرید (item_decisions.project_id)
|
||||||
export function itemProjects(q: Quote, li: LineItem): string[] {
|
export function itemProjects(q: Quote, li: LineItem): string[] {
|
||||||
if (q.status === "accepted" || q.status === "arrived") {
|
if (q.status === "accepted" || q.status === "arrived") {
|
||||||
if (li.target_project?.name) {
|
return li.target_project?.name ? [li.target_project.name] : [];
|
||||||
return [li.target_project.name];
|
|
||||||
}
|
|
||||||
// اگر target_project هنوز آبجکت نام نداشت، بررسی نام در صورت تکپروژه بودن
|
|
||||||
if (li.projects && li.projects.length === 1) {
|
|
||||||
const p = li.projects[0];
|
|
||||||
return [typeof p === "string" ? p : p.name];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
|
// For 'offered', 'rejected', 'expired': return all candidate projects
|
||||||
|
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- واحد مرجع و ضریب تبدیل ----------
|
// ---------- واحد مرجع و ضریب تبدیل ----------
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ export interface Supplier {
|
|||||||
// ماده/کالا
|
// ماده/کالا
|
||||||
export interface Material {
|
export interface Material {
|
||||||
id: string; // "m1", "m2", ...
|
id: string; // "m1", "m2", ...
|
||||||
name: string; // نام کامل ذخیرهشده در بکاند (برند + دسته + مشخصه)
|
name: string; // نام کامل ذخیرهشده در بکاند (برند + مشخصه)
|
||||||
topic: string;
|
topic: string;
|
||||||
category: string; // مثلاً "میلگرد"
|
category: string; // اختیاری در بکاند؛ همیشه string در فرانت (default: "")
|
||||||
brand: string; // مثلاً "ذوب آهن"
|
brand: string; // مثلاً "ذوب آهن"
|
||||||
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
||||||
base_unit: string; // واحد پایه
|
base_unit: string; // واحد پایه
|
||||||
|
|||||||
@@ -219,20 +219,14 @@ export function AcceptModal({ quoteId, onClose }: AcceptModalProps) {
|
|||||||
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
if (t.status === "accepted" && !usedIds.has(t.id)) t.status = "offered";
|
||||||
});
|
});
|
||||||
|
|
||||||
// پروژهی هر قلم (تکی هدف خرید) + همگامسازی پروژههای سطح پیشنهاد
|
// پروژهی هر قلم (تکی هدف خرید) — فقط target_project تنظیم میشود؛
|
||||||
const projUnion = new Set<string>();
|
// li.projects و next.projects دستنخورده باقی میمانند تا پروژههای کاندیدای اولیه حفظ شوند
|
||||||
next.line_items.forEach((li) => {
|
next.line_items.forEach((li) => {
|
||||||
if (li.rejected) return;
|
if (li.rejected) return;
|
||||||
const name = itemProjectsSel[li.id];
|
const name = itemProjectsSel[li.id];
|
||||||
if (name === undefined) return;
|
if (name === undefined) return;
|
||||||
li.target_project = name ? { id: "", name } : null;
|
li.target_project = name ? { id: "", name } : null;
|
||||||
// توجه: li.projects دستنخورده باقی میماند تا پروژههای کاندیدای اولیه حفظ شوند
|
|
||||||
if (name) projUnion.add(name);
|
|
||||||
});
|
});
|
||||||
if (projUnion.size > 0) {
|
|
||||||
next.projects = [...projUnion];
|
|
||||||
next.project = next.projects[0] || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
next.accepted_date = acceptDate || now;
|
next.accepted_date = acceptDate || now;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
interface EditItemRow {
|
interface EditItemRow {
|
||||||
key: string;
|
key: string;
|
||||||
origLi?: LineItem;
|
origLi?: LineItem;
|
||||||
|
projects?: { id: string; name: string }[];
|
||||||
cat: string;
|
cat: string;
|
||||||
brand: string;
|
brand: string;
|
||||||
specs: string[];
|
specs: string[];
|
||||||
@@ -87,6 +88,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
const [invoices, setInvoices] = useState<Attachment[]>([]);
|
||||||
|
const [proformas, setProformas] = useState<Attachment[]>([]);
|
||||||
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
|
||||||
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
|
||||||
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
const [quoteDate, setQuoteDate] = useState<string | null>(null);
|
||||||
@@ -148,7 +150,17 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
if (!q) return;
|
if (!q) return;
|
||||||
const sup = supplierById(suppliers, q.supplier_id);
|
const sup = supplierById(suppliers, q.supplier_id);
|
||||||
setSupplierName(sup.name);
|
setSupplierName(sup.name);
|
||||||
setSelectedProjects(q.projects && q.projects.length ? [...q.projects] : q.project ? [q.project] : []);
|
// حفظ تمام پروژههای کاندیدا: اتحاد پروژههای سطح پیشنهاد و پروژههای کاندیدای هر قلم
|
||||||
|
const candidateSet = new Set<string>();
|
||||||
|
(q.projects || []).forEach((p) => p && candidateSet.add(p));
|
||||||
|
if (q.project) candidateSet.add(q.project);
|
||||||
|
q.line_items.forEach((li) => {
|
||||||
|
(li.projects || []).forEach((p) => {
|
||||||
|
const n = typeof p === "string" ? p : p.name;
|
||||||
|
if (n) candidateSet.add(n);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setSelectedProjects([...candidateSet]);
|
||||||
setNotes(q.notes || "");
|
setNotes(q.notes || "");
|
||||||
setItemRows(
|
setItemRows(
|
||||||
q.line_items.map((li) => {
|
q.line_items.map((li) => {
|
||||||
@@ -156,7 +168,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return {
|
return {
|
||||||
key: li.id,
|
key: li.id,
|
||||||
origLi: li,
|
origLi: li,
|
||||||
cat: m.category || "",
|
projects: li.projects && li.projects.length ? [...li.projects] : [],
|
||||||
|
cat: m.name || "", // نام خود متریال — نه دسته/برند
|
||||||
brand: m.brand || "",
|
brand: m.brand || "",
|
||||||
specs: materialSpecs(m),
|
specs: materialSpecs(m),
|
||||||
qty: li.quantity,
|
qty: li.quantity,
|
||||||
@@ -185,7 +198,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
setReady(true);
|
setReady(true);
|
||||||
}, [q, suppliers, materials]);
|
}, [q, suppliers, materials]);
|
||||||
|
|
||||||
// بارگذاری فاکتورها
|
// بارگذاری فاکتورها و پیشفاکتورها
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!quoteId) return;
|
if (!quoteId) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -193,6 +206,10 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
.list(quoteId, "invoice")
|
.list(quoteId, "invoice")
|
||||||
.then((list) => alive && setInvoices(list))
|
.then((list) => alive && setInvoices(list))
|
||||||
.catch(() => alive && setInvoices([]));
|
.catch(() => alive && setInvoices([]));
|
||||||
|
void api.files
|
||||||
|
.list(quoteId, "proforma")
|
||||||
|
.then((list) => alive && setProformas(list))
|
||||||
|
.catch(() => alive && setProformas([]));
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
@@ -302,7 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
|
|
||||||
let material = materialsCopy.find(
|
let material = materialsCopy.find(
|
||||||
(m) =>
|
(m) =>
|
||||||
normalize(m.category) === normalize(cat) &&
|
normalize(m.name) === normalize(cat) &&
|
||||||
normalize(m.brand) === normalize(brand) &&
|
normalize(m.brand) === normalize(brand) &&
|
||||||
specsKey(m.specs) === specsKey(specs),
|
specsKey(m.specs) === specsKey(specs),
|
||||||
);
|
);
|
||||||
@@ -349,8 +366,15 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
acceptedTsId = null;
|
acceptedTsId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const candProjs =
|
||||||
|
row.projects && row.projects.length
|
||||||
|
? row.projects
|
||||||
|
: row.origLi?.projects && row.origLi.projects.length
|
||||||
|
? row.origLi.projects
|
||||||
|
: selectedProjects.map((pname) => ({ id: "", name: pname }));
|
||||||
|
|
||||||
newItems.push({
|
newItems.push({
|
||||||
id: "li-" + (newItems.length + 1),
|
id: row.origLi ? row.origLi.id : row.key.startsWith("new-") ? row.key : "li-" + (newItems.length + 1),
|
||||||
material_id: materialId,
|
material_id: materialId,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit,
|
unit,
|
||||||
@@ -360,7 +384,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
accepted_ts_id: acceptedTsId,
|
accepted_ts_id: acceptedTsId,
|
||||||
rejected,
|
rejected,
|
||||||
arrived_date: row.origLi?.arrived_date ?? null,
|
arrived_date: row.origLi?.arrived_date ?? null,
|
||||||
projects: row.origLi?.projects ?? [],
|
projects: candProjs,
|
||||||
target_project: status === "offered" ? null : (row.origLi?.target_project ?? null),
|
target_project: status === "offered" ? null : (row.origLi?.target_project ?? null),
|
||||||
target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null),
|
target_project_id: status === "offered" ? null : (row.origLi?.target_project_id ?? null),
|
||||||
});
|
});
|
||||||
@@ -441,6 +465,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||||||
placeholder="نام تأمینکننده"
|
placeholder="نام تأمینکننده"
|
||||||
onInputChange={(v) => setSupplierName(v)}
|
onInputChange={(v) => setSupplierName(v)}
|
||||||
|
openOnFocus={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -705,6 +730,23 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* پیشفاکتورها — در تمامی مراحل چرخه عمر پیشنهاد قابل افزودن/ویرایش است */}
|
||||||
|
<div className="border-t border-line pt-3">
|
||||||
|
<div className="mb-1 text-[13px] font-bold">پیشفاکتورها (چند فایل):</div>
|
||||||
|
<FileList
|
||||||
|
files={proformas}
|
||||||
|
onChange={setProformas}
|
||||||
|
upload={(f) => api.files.upload(q.id, "proforma", f)}
|
||||||
|
onDelete={(a) =>
|
||||||
|
a.id &&
|
||||||
|
void api.files.remove(q.id, "proforma", a.id).catch((err) =>
|
||||||
|
toast.error(err instanceof Error ? err.message : "حذف پیشفاکتور ناموفق بود"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
urlOf={(a) => (a.id ? api.files.downloadUrl(q.id, "proforma", a.id) : null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* تاریخها و وضعیت */}
|
{/* تاریخها و وضعیت */}
|
||||||
<div className="border-t border-line pt-3">
|
<div className="border-t border-line pt-3">
|
||||||
<div className="mb-2 text-[12px] font-bold text-muted">تاریخها و وضعیت:</div>
|
<div className="mb-2 text-[12px] font-bold text-muted">تاریخها و وضعیت:</div>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, Cre
|
|||||||
import type { Quote, Store } from "../../lib/types";
|
import type { Quote, Store } from "../../lib/types";
|
||||||
import {
|
import {
|
||||||
isExpired,
|
isExpired,
|
||||||
itemsSummary,
|
|
||||||
materialById,
|
materialById,
|
||||||
materialLabel,
|
materialLabel,
|
||||||
projectCell,
|
projectCell,
|
||||||
@@ -44,7 +43,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
const pc = priceCell(total);
|
const pc = priceCell(total);
|
||||||
const ts = termsheetSummary(store, q);
|
const ts = termsheetSummary(store, q);
|
||||||
const unitWarns = unitWarning(store, q);
|
const unitWarns = unitWarning(store, q);
|
||||||
const summary = itemsSummary(store, q);
|
|
||||||
|
|
||||||
// ستون / دکمه پذیرش
|
// ستون / دکمه پذیرش
|
||||||
let acceptedCell: React.ReactNode;
|
let acceptedCell: React.ReactNode;
|
||||||
@@ -142,12 +140,26 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
{sup.name.slice(0, 1)}
|
{sup.name.slice(0, 1)}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<Link
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
to={`/suppliers/${q.supplier_id}`}
|
<Link
|
||||||
className="block truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
|
to={`/suppliers/${q.supplier_id}`}
|
||||||
>
|
className="min-w-0 flex-1 truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
|
||||||
{sup.name}
|
>
|
||||||
</Link>
|
{sup.name}
|
||||||
|
</Link>
|
||||||
|
{/* فاکتور رسمی / غیررسمی — از چکباکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded px-1.5 py-px text-[9.5px] font-bold leading-4",
|
||||||
|
q.is_official
|
||||||
|
? "bg-green-bg text-green"
|
||||||
|
: "bg-slate-bg text-muted",
|
||||||
|
)}
|
||||||
|
title={q.is_official ? "فاکتور رسمی" : "فاکتور غیررسمی"}
|
||||||
|
>
|
||||||
|
{q.is_official ? "رسمی" : "غیررسمی"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
|
{sup.location && <div className="truncate text-[10px] text-muted sm:text-[11px]">{sup.location}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
|
<div className="ms-auto flex shrink-0 items-center gap-1 sm:gap-1.5">
|
||||||
@@ -188,47 +200,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
</div>
|
</div>
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
{/* ۲. اقلام — تمامعرض زیر پروژه با شکستن متن بدون سهنقطه */}
|
{/* ۲. اقلام — نمایش عمودی چندخطی */}
|
||||||
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
||||||
{summary.count > 1 ? (
|
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||||
<div className="flex flex-col gap-1.5">
|
{activeItems.length > 0 ? (
|
||||||
<span className="font-bold text-xs text-ink sm:text-sm">
|
activeItems.map((li) => {
|
||||||
{fmtNum(summary.count)} قلم کالا
|
const m = materialById(store.materials, li.material_id);
|
||||||
</span>
|
return (
|
||||||
<div className="flex flex-col gap-1.5 rounded-lg border border-line/60 bg-bg/50 p-2.5 text-[11px] leading-5 text-muted sm:text-[11.5px]">
|
<span key={li.id} className="break-words">
|
||||||
{store.quotes
|
{materialLabel(m)}
|
||||||
.find((x) => x.id === q.id)
|
|
||||||
?.line_items?.filter((li) => !li.rejected)
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((li) => {
|
|
||||||
const m = materialById(store.materials, li.material_id);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={li.id}
|
|
||||||
className="flex items-start justify-between gap-2 border-b border-line/30 pb-1.5 last:border-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1 font-medium text-ink leading-snug break-words">
|
|
||||||
• {materialLabel(m)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 font-semibold tabular-nums text-ink text-[10.5px] pt-0.5">
|
|
||||||
{fmtNum(li.quantity)}{" "}
|
|
||||||
<span className="font-normal text-muted text-[9.5px]">{li.unit}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{(q.line_items?.length || 0) > 3 && (
|
|
||||||
<span className="pt-0.5 text-[10px] text-muted">
|
|
||||||
و {(q.line_items?.length || 0) - 3} قلم دیگر...
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
);
|
||||||
</div>
|
})
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<span>—</span>
|
||||||
<div className="flex items-start gap-1.5 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
)}
|
||||||
<span className="break-words">{summary.text}</span>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{unitWarns.map((w) => (
|
{unitWarns.map((w) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -243,7 +230,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
|||||||
))}
|
))}
|
||||||
</InfoCell>
|
</InfoCell>
|
||||||
|
|
||||||
{/* ۳. مجموع مبلغ */}
|
|
||||||
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||||
{pc ? (
|
{pc ? (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -148,16 +148,8 @@ export function OrganizationPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveWarehouse = async (id: string, projectId: string) => {
|
// انبار برای همیشه به پروژهی والدش تعلق دارد — انتقال به پروژهی دیگر وجود ندارد؛
|
||||||
try {
|
// انبار فقط با حذف و ساخت دوباره زیر پروژهی موردنظر جابهجا میشود.
|
||||||
await apiV26.warehouses.update(id, { project_id: projectId });
|
|
||||||
toast.success("انبار منتقل شد ✓");
|
|
||||||
await data.refresh();
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : "خطا در انتقال انبار");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeWarehouse = async (id: string) => {
|
const removeWarehouse = async (id: string) => {
|
||||||
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
|
||||||
try {
|
try {
|
||||||
@@ -329,16 +321,6 @@ export function OrganizationPage() {
|
|||||||
>
|
>
|
||||||
📊 داشبورد
|
📊 داشبورد
|
||||||
</button>
|
</button>
|
||||||
<select
|
|
||||||
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
|
|
||||||
value={w.project_id}
|
|
||||||
onChange={(e) => void moveWarehouse(w.id, e.target.value)}
|
|
||||||
title="انتقال به پروژه دیگر"
|
|
||||||
>
|
|
||||||
{data.lookup.projects.map((p2) => (
|
|
||||||
<option key={p2.id} value={p2.id}>{p2.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
|
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import {
|
import {
|
||||||
activeItems,
|
|
||||||
lineItemTotal,
|
lineItemTotal,
|
||||||
materialById,
|
materialById,
|
||||||
materialLabel,
|
materialLabel,
|
||||||
@@ -29,25 +28,45 @@ import { EmptyState } from "../../components/EmptyState";
|
|||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
|
|
||||||
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
function projectQuotes(quotes: Quote[], name: string): Quote[] {
|
||||||
return quotes.filter((q) => (q.projects || []).includes(name) || q.project === name);
|
return quotes.filter(
|
||||||
|
(q) =>
|
||||||
|
(q.projects || []).includes(name) ||
|
||||||
|
q.project === name ||
|
||||||
|
(q.line_items || []).some(
|
||||||
|
(li) =>
|
||||||
|
li.target_project?.name === name ||
|
||||||
|
(li.projects || []).some((p) => (typeof p === "string" ? p : p.name) === name),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// اقلام مربوط به این پروژه در یک quote
|
// آیا این پیشنهاد برای «این» پروژه پذیرفته شده است؟
|
||||||
|
// ملاک، پروژهی هدفِ تصمیم خریدِ اقلام است (item_decisions.project_id) — نه
|
||||||
|
// وضعیت سراسری پیشنهاد؛ پیشنهادی که فقط برای پروژهی دیگری پذیرفته شده،
|
||||||
|
// برای این پروژه همچنان «پیشنهاد شده» است.
|
||||||
|
function acceptedForProject(q: Quote, name: string): boolean {
|
||||||
|
return (q.line_items || []).some((li) => !li.rejected && li.target_project?.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// آیا اصلاً برای این پیشنهاد پروژهی هدفی ثبت شده؟ (پیشنهادهای قدیمیِ
|
||||||
|
// پیش از era تصمیمپروژه، طبق وضعیت سراسری دستهبندی میشوند)
|
||||||
|
function hasTargets(q: Quote): boolean {
|
||||||
|
return (q.line_items || []).some((li) => !!li.target_project);
|
||||||
|
}
|
||||||
|
|
||||||
|
// اقلام مربوط به این پروژه در یک quote:
|
||||||
|
// اولویت با اقلامِ پذیرفتهشده برای همین پروژه (تصمیم خرید)؛ سپس کاندیداهای
|
||||||
|
// همین پروژه؛ و اگر هیچ اتصال قلمبهقلمی نبود، همهی اقلام فعال.
|
||||||
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
function itemsForProject(q: Quote, name: string): Quote["line_items"] {
|
||||||
const items = (q.line_items || []).filter((li) => !li.rejected);
|
const items = (q.line_items || []).filter((li) => !li.rejected);
|
||||||
|
const targeted = items.filter((li) => li.target_project?.name === name);
|
||||||
|
if (targeted.length) return targeted;
|
||||||
if (items.some((li) => li.projects && li.projects.length)) {
|
if (items.some((li) => li.projects && li.projects.length)) {
|
||||||
return items.filter((li) => (li.projects || []).some((p) => p.name === name));
|
return items.filter((li) => (li.projects || []).some((p) => p.name === name));
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
function qTotal(q: Quote): number {
|
|
||||||
return activeItems(q).reduce((sum, li) => {
|
|
||||||
const t = lineItemTotal(li);
|
|
||||||
return sum + (t || 0);
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function daysSince(iso: string | null): number | null {
|
function daysSince(iso: string | null): number | null {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -147,10 +166,11 @@ export function ProjectsPage() {
|
|||||||
// ---------- پنل جزئیات یک پروژه ----------
|
// ---------- پنل جزئیات یک پروژه ----------
|
||||||
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
||||||
const quotes = projectQuotes(store.quotes, name);
|
const quotes = projectQuotes(store.quotes, name);
|
||||||
const sumBy = (st: Quote["status"]) =>
|
|
||||||
quotes
|
// جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
|
||||||
.filter((q) => q.status === st)
|
const itemsTotal = (q: Quote) =>
|
||||||
.reduce((s, q) => s + qTotal(q), 0);
|
itemsForProject(q, name).reduce((s, li) => s + (lineItemTotal(li) || 0), 0);
|
||||||
|
const sumBy = (list: Quote[]) => list.reduce((s, q) => s + itemsTotal(q), 0);
|
||||||
|
|
||||||
const matName = (mid: string) => {
|
const matName = (mid: string) => {
|
||||||
const m = materialById(store.materials, mid);
|
const m = materialById(store.materials, mid);
|
||||||
@@ -184,11 +204,19 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// دستهبندی ایزوله per-project:
|
||||||
|
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
|
||||||
|
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژهی دیگری پذیرفته شده) → پیشنهاد شده.
|
||||||
|
// - پیشنهادهای قدیمیِ بدون پروژهی هدف، طبق وضعیت سراسری میمانند.
|
||||||
const offered = quotes
|
const offered = quotes
|
||||||
.filter((q) => q.status === "offered")
|
.filter(
|
||||||
|
(q) =>
|
||||||
|
q.status === "offered" ||
|
||||||
|
((q.status === "accepted" || q.status === "arrived") && hasTargets(q) && !acceptedForProject(q, name)),
|
||||||
|
)
|
||||||
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
|
.sort((a, b) => new Date(a.expiry_date || 0).getTime() - new Date(b.expiry_date || 0).getTime());
|
||||||
const accepted = quotes.filter((q) => q.status === "accepted");
|
const accepted = quotes.filter((q) => q.status === "accepted" && acceptedForProject(q, name));
|
||||||
const arrived = quotes.filter((q) => q.status === "arrived");
|
const arrived = quotes.filter((q) => q.status === "arrived" && acceptedForProject(q, name));
|
||||||
|
|
||||||
const offeredRow = (q: Quote) => {
|
const offeredRow = (q: Quote) => {
|
||||||
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
|
const days = q.expiry_date ? Math.ceil((new Date(q.expiry_date).getTime() - Date.now()) / 86400000) : null;
|
||||||
@@ -199,7 +227,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||||
<div className="pj-side">
|
<div className="pj-side">
|
||||||
جمع: <b>{faNum(qTotal(q))} تومان</b>
|
جمع: <b>{faNum(itemsTotal(q))} تومان</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="pj-side">
|
<div className="pj-side">
|
||||||
اعتبار تا {fmtDate(q.expiry_date)}<br />
|
اعتبار تا {fmtDate(q.expiry_date)}<br />
|
||||||
@@ -240,7 +268,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
|
||||||
<div className="pj-side">
|
<div className="pj-side">
|
||||||
هزینه: <b>{faNum(qTotal(q))} تومان</b>
|
هزینه: <b>{faNum(itemsTotal(q))} تومان</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
|
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -255,13 +283,13 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
|
|||||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||||
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
|
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
|
||||||
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
|
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
|
||||||
ارزش باز: {faNum(sumBy("offered"))} تومان
|
ارزش باز: {faNum(sumBy(offered))} تومان
|
||||||
</span>
|
</span>
|
||||||
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
|
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
|
||||||
ارزش پذیرفته: {faNum(sumBy("accepted"))} تومان
|
ارزش پذیرفته: {faNum(sumBy(accepted))} تومان
|
||||||
</span>
|
</span>
|
||||||
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
|
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
|
||||||
ارزش رسیده: {faNum(sumBy("arrived"))} تومان
|
ارزش رسیده: {faNum(sumBy(arrived))} تومان
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ export function UnitsPage() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{g.base}</span>
|
<span className="whitespace-normal break-words text-start">{g.base}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span>
|
<span className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -519,7 +519,7 @@ export function UnitsPage() {
|
|||||||
)}
|
)}
|
||||||
onClick={() => setMaterialId(v.id)}
|
onClick={() => setMaterialId(v.id)}
|
||||||
>
|
>
|
||||||
<span className="truncate">{v.label}</span>
|
<span className="whitespace-normal break-words text-start">{v.label}</span>
|
||||||
<span className="ut-ref">{v.refUnit || "—"}</span>
|
<span className="ut-ref">{v.refUnit || "—"}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function Step1TopicSupplier({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("field max-w-md", invalid && "invalid")}>
|
<div data-field="supplier_id" className={cn("field max-w-md", invalid && "invalid")}>
|
||||||
<label>یا نام تأمینکننده را تایپ و انتخاب کنید</label>
|
<label>یا نام تأمینکننده را تایپ و انتخاب کنید</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={supplierName}
|
value={supplierName}
|
||||||
|
|||||||
@@ -67,17 +67,18 @@ function materialBaseName(name: string, brand: string, specs: string[], category
|
|||||||
return base || fallback;
|
return base || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// مقادیر مجزای category/brand/spec داخل تاپیک
|
// مقادیر مجزای category/brand/spec در کاتالوگ سراسری
|
||||||
export function materialOptions(
|
export function materialOptions(
|
||||||
materials: Material[],
|
materials: Material[],
|
||||||
topic: string,
|
_topic: string,
|
||||||
item: ItemDraft,
|
item: ItemDraft,
|
||||||
field: "cat" | "brand" | "spec",
|
field: "cat" | "brand" | "spec",
|
||||||
allItems: ItemDraft[] = [],
|
allItems: ItemDraft[] = [],
|
||||||
apiBrands: string[] = [],
|
apiBrands: string[] = [],
|
||||||
apiSpecs: string[] = [],
|
apiSpecs: string[] = [],
|
||||||
): string[] {
|
): string[] {
|
||||||
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
|
// متریالها سراسری هستند — مستقل از تاپیک
|
||||||
|
let list = materials;
|
||||||
const cat = item.cat.trim();
|
const cat = item.cat.trim();
|
||||||
|
|
||||||
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
||||||
@@ -92,7 +93,7 @@ export function materialOptions(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (field === "cat") {
|
if (field === "cat") {
|
||||||
const base = new Set(list.map((m) => m.category).filter(Boolean));
|
const base = new Set<string>(materials.map((m) => m.category).filter(Boolean));
|
||||||
fromRows((it) => it.cat).forEach((c) => base.add(c));
|
fromRows((it) => it.cat).forEach((c) => base.add(c));
|
||||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
@@ -221,7 +222,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
<span>قیمت کل</span>
|
<span>قیمت کل</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div data-field="line_items" className="flex flex-col gap-2">
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
<div key={it.tempId} className="item-row wizard-item-row items-center">
|
||||||
<Combobox
|
<Combobox
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function Step3Termsheets({ cards, onCardsChange, items, coverage, onCover
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر <b>termsheet</b> یک روش پرداخت است (نقد، چک، اقساط...). داخل هر termsheet مشخص کنید کدام قلمها را پوشش میدهد — یک termsheet میتواند چند قلم را بگیرد، و یک قلم هم میتواند زیر چند termsheet مختلف (مثلاً هم نقد هم چک) پیشنهاد شود.' }} />
|
<div className="mb-3 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: 'هر <b>termsheet</b> یک روش پرداخت است (نقد، چک، اقساط...). داخل هر termsheet مشخص کنید کدام قلمها را پوشش میدهد — یک termsheet میتواند چند قلم را بگیرد، و یک قلم هم میتواند زیر چند termsheet مختلف (مثلاً هم نقد هم چک) پیشنهاد شود.' }} />
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div data-field="termsheets" className="flex flex-col gap-3">
|
||||||
{cards.map((card, idx) => (
|
{cards.map((card, idx) => (
|
||||||
<TermsheetCard
|
<TermsheetCard
|
||||||
key={card.key}
|
key={card.key}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div data-field="project">
|
||||||
<h3 className="mb-1 text-[15px] font-bold">
|
<h3 className="mb-1 text-[15px] font-bold">
|
||||||
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span>
|
<span className="me-1 inline-flex h-6 w-6 items-center justify-center rounded-full bg-accent text-[12px] font-bold text-white">۴</span>
|
||||||
پروژهها
|
پروژهها
|
||||||
|
|||||||
@@ -57,20 +57,20 @@ export function Step5Review({
|
|||||||
|
|
||||||
<div className="grid max-w-2xl grid-cols-12 gap-3">
|
<div className="grid max-w-2xl grid-cols-12 gap-3">
|
||||||
{/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */}
|
{/* تاریخ پیشنهاد: فضای بزرگتر (۷ ستون از ۱۲) به همراه ساعت */}
|
||||||
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full">
|
<div className="field col-span-7 [&>div]:w-full [&_input]:w-full" data-field="quote_date">
|
||||||
<label>تاریخ پیشنهاد</label>
|
<label>تاریخ پیشنهاد</label>
|
||||||
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
|
<JalaliDatePicker value={quoteDate} onChange={onQuoteDate} placeholder="۱۴۰۳/۰۵/۱۲" withTime />
|
||||||
<div className="hint">پیشفرض امروز</div>
|
<div className="hint">پیشفرض امروز</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* تاریخ انقضای پیشنهاد: فضای کوچکتر (۵ ستون از ۱۲) */}
|
{/* تاریخ انقضای پیشنهاد: فضای کوچکتر (۵ ستون از ۱۲) */}
|
||||||
<div className="field col-span-5 [&>div]:w-full [&_input]:w-full">
|
<div className="field col-span-5 [&>div]:w-full [&_input]:w-full" data-field="expiry_date">
|
||||||
<label>تاریخ انقضای پیشنهاد</label>
|
<label>تاریخ انقضای پیشنهاد</label>
|
||||||
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
|
<JalaliDatePicker value={expiryDate} onChange={onExpiryDate} placeholder="۱۴۰۳/۰۶/۱۲" />
|
||||||
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
|
<div className="hint">تا چه زمانی این قیمت معتبر است؟</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field col-span-12">
|
<div className="field col-span-12" data-field="notes">
|
||||||
<label>یادداشت (اختیاری)</label>
|
<label>یادداشت (اختیاری)</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
@@ -81,7 +81,7 @@ export function Step5Review({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field col-span-12">
|
<div className="field col-span-12" data-field="proforma">
|
||||||
<label>پیشفاکتور (اختیاری)</label>
|
<label>پیشفاکتور (اختیاری)</label>
|
||||||
{proforma ? (
|
{proforma ? (
|
||||||
/* وضعیت فایل انتخابشده با دکمه حذف تمیز */
|
/* وضعیت فایل انتخابشده با دکمه حذف تمیز */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
|||||||
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
import { ChevronRight, ChevronLeft, Check } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { LineItem, Quote } from "../../lib/types";
|
import type { LineItem, Quote } from "../../lib/types";
|
||||||
import { api, nextId } from "../../lib/api";
|
import { api, ApiError, looksLikeUuid, nextId } from "../../lib/api";
|
||||||
import {
|
import {
|
||||||
useAddMaterial,
|
useAddMaterial,
|
||||||
useAddProject,
|
useAddProject,
|
||||||
@@ -125,16 +125,8 @@ export function WizardPage() {
|
|||||||
goToStep(currentStep + 1);
|
goToStep(currentStep + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rememberMaterial = (rec: MaterialRec) => {
|
const rememberMaterial = (_rec: MaterialRec) => {
|
||||||
void addMaterial.mutateAsync({
|
// رکوردهای متریال به همراه واحد و مشخصهها در زمان ذخیره نهایی ساخته میشوند
|
||||||
id: nextId("m", materials),
|
|
||||||
name: (rec.category || rec.brand || rec.specs.join(" ") || "").trim() || "کالای جدید",
|
|
||||||
topic: rec.topic,
|
|
||||||
category: rec.category,
|
|
||||||
brand: rec.brand,
|
|
||||||
specs: rec.specs,
|
|
||||||
base_unit: "",
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
// ---------- پروژهها: از مجموعه + نامهای قدیمی quoteها ----------
|
||||||
@@ -193,19 +185,72 @@ export function WizardPage() {
|
|||||||
|
|
||||||
// ---------- ذخیره ----------
|
// ---------- ذخیره ----------
|
||||||
const doSave = async () => {
|
const doSave = async () => {
|
||||||
const sectionError = (step: number, title: string, err: unknown) => {
|
const resolveErrorTarget = (err: unknown): { step: number; field?: string } => {
|
||||||
|
let field = "";
|
||||||
|
let msg = "";
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
field = err.field || "";
|
||||||
|
msg = err.message || "";
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
msg = err.message || "";
|
||||||
|
if ("field" in err && typeof (err as any).field === "string") {
|
||||||
|
field = (err as any).field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = `${field} ${msg}`.toLowerCase();
|
||||||
|
|
||||||
|
// ۱. تأمینکننده
|
||||||
|
if (/supplier|تأمین|تامین/i.test(combined)) return { step: 1, field: "supplier_id" };
|
||||||
|
|
||||||
|
// ۲. اقلام / متریال / واحد / تعداد / قیمت
|
||||||
|
if (/line_item|material|canonical_unit|unit_id|quantity|متریال|واحد|قلم|تعداد/i.test(combined)) {
|
||||||
|
return { step: 2, field: field || "line_items" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۳. شرایط پرداخت / termsheet / مراحل پرداخت / سررسید
|
||||||
|
if (/termsheet|item_termsheet|schedule_step|due_date|milestone|ترم|پرداخت|پوشش|سررسید/i.test(combined)) {
|
||||||
|
return { step: 3, field: field || "termsheets" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ۴. پروژه
|
||||||
|
if (/project|پروژه/i.test(combined)) return { step: 4, field: "project" };
|
||||||
|
|
||||||
|
// ۵. تاریخها، یادداشت، پیشفاکتور
|
||||||
|
if (/expiry_date|تاریخ انقضا/i.test(combined)) return { step: 5, field: "expiry_date" };
|
||||||
|
if (/quote_date|تاریخ پیشنهاد/i.test(combined)) return { step: 5, field: "quote_date" };
|
||||||
|
if (/proforma|پیشفاکتور/i.test(combined)) return { step: 5, field: "proforma" };
|
||||||
|
if (/notes|یادداشت/i.test(combined)) return { step: 5, field: "notes" };
|
||||||
|
|
||||||
|
return { step: 5, field };
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionError = (stepOrTarget: number | { step: number; field?: string }, title: string, err: unknown) => {
|
||||||
|
const target = typeof stepOrTarget === "number" ? { step: stepOrTarget } : stepOrTarget;
|
||||||
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
const msg = err instanceof Error ? err.message : "خطای ناشناخته";
|
||||||
toast.error(`${title}: ${msg}`, {
|
toast.error(`${title}: ${msg}`, {
|
||||||
action: { label: "رفع خطا", onClick: () => goToStep(step) },
|
duration: 8000,
|
||||||
|
action: {
|
||||||
|
label: "رفع خطا",
|
||||||
|
onClick: () => {
|
||||||
|
goToStep(target.step);
|
||||||
|
setTimeout(() => {
|
||||||
|
const targetAttr = target.field ? `[data-field="${target.field}"]` : "";
|
||||||
|
const el = (targetAttr ? document.querySelector(targetAttr) : null) ||
|
||||||
|
document.querySelector(`[data-step="${target.step}"]`);
|
||||||
|
if (el instanceof HTMLElement) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
const input = el.querySelector("input, textarea, select") as HTMLElement | null;
|
||||||
|
if (input) input.focus();
|
||||||
|
else el.focus?.();
|
||||||
|
el.classList.add("ring-2", "ring-red-500", "rounded-xl");
|
||||||
|
setTimeout(() => el.classList.remove("ring-2", "ring-red-500", "rounded-xl"), 3500);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const guessStep = (msg: string): number => {
|
|
||||||
if (/تأمین|تامین|supplier/i.test(msg)) return 1;
|
|
||||||
if (/متریال|واحد|قلم|خط آیتم|item|دسته/i.test(msg)) return 2;
|
|
||||||
if (/ترم|termsheet|پرداخت|پوشش/i.test(msg)) return 3;
|
|
||||||
if (/پروژه|project/i.test(msg)) return 4;
|
|
||||||
return 5;
|
|
||||||
};
|
|
||||||
|
|
||||||
const name = supplierName.trim();
|
const name = supplierName.trim();
|
||||||
let supplierId = "";
|
let supplierId = "";
|
||||||
@@ -244,10 +289,11 @@ export function WizardPage() {
|
|||||||
if (!cat && !brand && qty == null && price == null) continue;
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||||
let material = materials.find((m) => normalize(m.name) === normalize(wantName));
|
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||||||
if (!material) {
|
if (!material) {
|
||||||
material = materials.find(
|
material = materials.find(
|
||||||
(m) =>
|
(m) =>
|
||||||
|
looksLikeUuid(m.id) &&
|
||||||
normalize(m.category) === normalize(cat) &&
|
normalize(m.category) === normalize(cat) &&
|
||||||
normalize(m.brand) === normalize(brand) &&
|
normalize(m.brand) === normalize(brand) &&
|
||||||
specsKey(m.specs) === specsKey(specs),
|
specsKey(m.specs) === specsKey(specs),
|
||||||
@@ -257,11 +303,11 @@ export function WizardPage() {
|
|||||||
// تطبیق دوم بر اساس category/brand/specs انجام میشود تا آنها هم پیدا شوند.
|
// تطبیق دوم بر اساس category/brand/specs انجام میشود تا آنها هم پیدا شوند.
|
||||||
if (!material && cat) {
|
if (!material && cat) {
|
||||||
material = materials.find(
|
material = materials.find(
|
||||||
(m) => normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
|
(m) => looksLikeUuid(m.id) && normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let materialId: string;
|
let materialId: string;
|
||||||
if (material) {
|
if (material && looksLikeUuid(material.id)) {
|
||||||
materialId = material.id;
|
materialId = material.id;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
@@ -272,7 +318,7 @@ export function WizardPage() {
|
|||||||
category: cat,
|
category: cat,
|
||||||
brand,
|
brand,
|
||||||
specs,
|
specs,
|
||||||
base_unit: unit,
|
base_unit: unit || "عدد",
|
||||||
});
|
});
|
||||||
materialId = created.id;
|
materialId = created.id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -361,8 +407,8 @@ export function WizardPage() {
|
|||||||
toast.success("پیشنهاد ذخیره شد.");
|
toast.success("پیشنهاد ذخیره شد.");
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : "خطا در ذخیره پیشنهاد";
|
const target = resolveErrorTarget(e);
|
||||||
sectionError(guessStep(msg), "خطا در ثبت پیشنهاد", e);
|
sectionError(target, "خطا در ثبت پیشنهاد", e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -392,7 +438,7 @@ export function WizardPage() {
|
|||||||
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
<Stepper current={currentStep} highestVisited={highestVisited} completedSteps={completedSteps} onGoto={goToStep} />
|
||||||
|
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="1" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step1TopicSupplier
|
<Step1TopicSupplier
|
||||||
selectedTopic={selectedTopic}
|
selectedTopic={selectedTopic}
|
||||||
onTopicSelect={(t) => {
|
onTopicSelect={(t) => {
|
||||||
@@ -416,12 +462,12 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 2 && (
|
{currentStep === 2 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="2" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
<Step2LineItems items={items} onItemsChange={setItems} selectedTopic={selectedTopic} onRemember={rememberMaterial} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 3 && (
|
{currentStep === 3 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="3" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step3Termsheets
|
<Step3Termsheets
|
||||||
cards={cards}
|
cards={cards}
|
||||||
onCardsChange={setCards}
|
onCardsChange={setCards}
|
||||||
@@ -432,7 +478,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 4 && (
|
{currentStep === 4 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="4" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step4Projects
|
<Step4Projects
|
||||||
items={items}
|
items={items}
|
||||||
projects={projectNames}
|
projects={projectNames}
|
||||||
@@ -446,7 +492,7 @@ export function WizardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentStep === 5 && (
|
{currentStep === 5 && (
|
||||||
<div className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
<div data-step="5" className="rounded-xl border border-line bg-panel p-5 shadow-sm">
|
||||||
<Step5Review
|
<Step5Review
|
||||||
quoteDate={quoteDate}
|
quoteDate={quoteDate}
|
||||||
onQuoteDate={setQuoteDate}
|
onQuoteDate={setQuoteDate}
|
||||||
|
|||||||
+2
-22
@@ -164,14 +164,12 @@ CREATE TABLE category_attributes (
|
|||||||
CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id)
|
CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ---------- MATERIALS (GLOBAL catalog entities — v2.5) ----------
|
-- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ----------
|
||||||
-- category_id is NULLABLE and DERIVED automatically from the first linked
|
-- Materials are global, category-free entities. name is GLOBALLY
|
||||||
-- supplier (supplier_materials insert, first-link-wins). name is GLOBALLY
|
|
||||||
-- unique (uq_material_name). topic is a legacy display column no longer
|
-- unique (uq_material_name). topic is a legacy display column no longer
|
||||||
-- written by the app.
|
-- written by the app.
|
||||||
CREATE TABLE materials (
|
CREATE TABLE materials (
|
||||||
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
category_id UUID REFERENCES material_categories(id) ON DELETE RESTRICT,
|
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
topic TEXT, -- LEGACY display column
|
topic TEXT, -- LEGACY display column
|
||||||
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT,
|
||||||
@@ -585,24 +583,6 @@ CREATE TRIGGER trg_quote_attachments_updated_at
|
|||||||
-- AUTOMATION TRIGGERS (v2.5 + v2.6)
|
-- AUTOMATION TRIGGERS (v2.5 + v2.6)
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
-- Derive materials.category_id from the FIRST linked supplier (first-link-wins).
|
|
||||||
-- Idempotent: only touches rows where category_id IS NULL.
|
|
||||||
CREATE OR REPLACE FUNCTION fn_derive_material_category()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
UPDATE materials
|
|
||||||
SET category_id = (SELECT category_id FROM suppliers WHERE id = NEW.supplier_id),
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = NEW.material_id
|
|
||||||
AND category_id IS NULL;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trg_supplier_materials_derive_category
|
|
||||||
AFTER INSERT ON supplier_materials
|
|
||||||
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category();
|
|
||||||
|
|
||||||
-- Sync supplier_materials.last_quoted_price / last_quoted_at when an item
|
-- Sync supplier_materials.last_quoted_price / last_quoted_at when an item
|
||||||
-- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow
|
-- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow
|
||||||
-- inserts decisions directly as 'chosen').
|
-- inserts decisions directly as 'chosen').
|
||||||
|
|||||||
Reference in New Issue
Block a user