Merge prod_v5 (catalog v2.7, warehouse locking, per-project accept) into integration

Conflict in init_db.py resolved keeping the audit_log table ensure
function (new-one) — prod_v5 predates it and its side was empty.
Both sides' SQL lands: audit_log table plus the category-free materials
model.
This commit is contained in:
MMD
2026-09-19 18:36:29 +03:30
26 changed files with 350 additions and 273 deletions
+25 -6
View File
@@ -345,6 +345,18 @@ BASE_UNITS: list[dict] = [
{"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": "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,13 +391,20 @@ async def ensure_v26(engine: AsyncEngine) -> None:
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
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("""
CREATE TRIGGER trg_supplier_materials_derive_category
AFTER INSERT ON supplier_materials
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
""")
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
await run_statement("""
CREATE TRIGGER trg_item_decisions_sync_price
+1 -5
View File
@@ -130,14 +130,11 @@ class CategoryAttribute(Base):
class Material(Base):
__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"),)
__mapper_args__ = {"eager_defaults": True}
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)
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
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()
)
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
back_populates="material", lazy="raise", passive_deletes=True
+6 -5
View File
@@ -274,12 +274,13 @@ async def update_warehouse(
wh.name = payload.name
if payload.description is not None:
wh.description = payload.description
# پروژه‌ی انبار تغییرناپذیر است — انبار برای همیشه به پروژه‌ی والدش تعلق
# دارد و فقط با حذف و ساخت دوباره می‌تواند جابه‌جا شود.
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 NotFoundError("پروژه مقصد یافت نشد", field="project_id")
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
raise ForbiddenError("PM فقط می‌تواند انبار را در پروژه خودش جابه‌جا کند", field="project_id")
wh.project_id = payload.project_id
raise ValidationError(
"پروژه‌ی انبار قابل تغییر نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد",
field="project_id",
)
await db.flush()
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
+11 -2
View File
@@ -18,7 +18,7 @@ from app.models.catalog import (
MaterialUnit,
Unit,
)
from app.models.procurement import LineItem
from app.models.procurement import LineItem, Supplier, SupplierMaterial
from app.schemas.catalog import (
MaterialAttributeValuesPut,
MaterialCreate,
@@ -108,7 +108,16 @@ async def list_materials(
):
stmt = select(Material)
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:
# WHERE EXISTS brand_materials with that brand_id
sub = select(1).where(
+5 -11
View File
@@ -201,17 +201,11 @@ async def pm_on_the_way(
# unit / category lookups for display
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()}
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 = {}
if mat_ids:
rows = (
await db.execute(
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}
if sup_cat_ids:
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(sup_cat_ids)))).scalars().all()
cats = {c.id: c for c in rows}
out = []
for d, li, q, m, sup, proj in decisions:
@@ -226,7 +220,7 @@ async def pm_on_the_way(
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
"decided_at": to_iso(d.decided_at),
"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,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity),
+29 -5
View File
@@ -10,7 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
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 (
AcceptQuoteIn,
ArriveItemsIn,
@@ -39,9 +48,8 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
def _quote_category_exists(category_id) -> exists:
return exists(
select(1).where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.category_id == category_id,
Supplier.id == Quote.supplier_id,
Supplier.category_id == category_id,
)
)
@@ -112,7 +120,23 @@ async def list_quotes(
if supplier_id:
stmt = stmt.where(Quote.supplier_id == supplier_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:
stmt = stmt.where(_quote_category_exists(category_id))
if quote_date_from is not None:
+2 -7
View File
@@ -58,11 +58,7 @@ async def list_supplier_materials(
items, meta = await paginate(stmt, page, page_size, db)
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()}
cat_ids = {m.category_id for m in materials.values() if m.category_id}
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}
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
@@ -72,8 +68,7 @@ async def create_supplier_material(
async with db.begin():
link = await link_supplier_material(db, supplier_id, 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, category)}
return {"data": link_to_dict(link, material, None)}
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
-1
View File
@@ -89,7 +89,6 @@ class MaterialAttributeIn(APIModel):
class MaterialCreate(APIModel):
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
name: str = Field(min_length=1)
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
canonical_unit_id: int
+7
View File
@@ -30,6 +30,13 @@ class WarehouseCreate(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)
description: str | None = None
project_id: UUID | None = None
+1 -1
View File
@@ -37,7 +37,7 @@ class SupplierUpdate(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
+3 -11
View File
@@ -206,11 +206,6 @@ async def _load_material_maps(db: AsyncSession, materials: list[Material], expan
material_ids = [m.id for m in materials]
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()
if "canonical_unit" in expand:
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),
}
if "category" in expand:
d["category"] = category_brief(maps["categories"].get(m.category_id))
d["category"] = None
else:
d["category_id"] = str(m.category_id)
d["category_id"] = None
if "canonical_unit" in expand:
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
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}
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
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()
brand_ids: set[uuid.UUID] = set()
for bm in bms:
@@ -450,7 +442,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
d["material"] = {
"id": str(m.id),
"name": m.name,
"category": category_brief(data.categories.get(m.category_id)),
"category": None,
"brands": [
{"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, [])
+1 -8
View File
@@ -12,7 +12,6 @@ from app.models.catalog import (
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialCategory,
Unit,
)
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:
"""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
without a category (it gets derived automatically from the first linked
supplier). name is GLOBALLY unique.
v2.6/v2.7: materials are global and category-free. name is GLOBALLY unique.
"""
async with db.begin():
duplicate = await db.scalar(
@@ -121,14 +118,10 @@ async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material
if duplicate is not None:
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):
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
material = Material(
category_id=payload.category_id,
name=payload.name,
topic=payload.topic,
canonical_unit_id=payload.canonical_unit_id,
+90 -15
View File
@@ -918,37 +918,57 @@ async def quote_stats(
def _quote_has_category(category_id: UUID):
"""EXISTS: quote has a line item whose material belongs to category_id."""
from app.models.catalog import Material
"""EXISTS: quote's supplier belongs to category_id."""
sub = (
select(1)
.where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.category_id == category_id,
Supplier.id == Quote.supplier_id,
Supplier.category_id == category_id,
)
)
return func.exists(sub)
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
termsheet link first, else the first link. Rejected line items are
excluded from value sums (contract P1-14).
A quote lands in this project's «accepted»/«arrived» bucket ONLY if at
least one non-rejected line item's decision targets this project
(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)
if project is None:
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()
if not quotes:
# --- every quote RELEVANT to this project (any kind of link) ---
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}
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()
li_ids = [li.id for li in line_items]
@@ -956,6 +976,18 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
d.line_item_id: d
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
} 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 = (
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
) if li_ids else []
@@ -964,12 +996,56 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
for it in item_termsheets:
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}
for q in quotes:
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:
quote = next(q for q in quotes if q.id == li.quote_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":
continue # rejected items excluded from value sums
if decision is not None and decision.chosen_item_termsheet_id is not None:
@@ -979,8 +1055,7 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
price_it = links[0] if links else None
if price_it is None:
continue
quote = next(q for q in quotes if q.id == li.quote_id)
per_status[quote.status]["total_value"] += float(price_it.unit_price * li.quantity)
per_status[bucket]["total_value"] += float(price_it.unit_price * li.quantity)
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
for status, values in per_status.items():
@@ -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
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.
Suppliers can supply any global material across categories.
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,7 +13,7 @@ from app.utils.errors import NotFoundError, ValidationError
async def link_supplier_material(
db: AsyncSession, supplier_id, material_id, is_active: bool = True
) -> 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.
"""
@@ -42,15 +38,11 @@ async def link_supplier_material(
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
db.add(link)
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
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(
select(SupplierMaterial).where(
SupplierMaterial.supplier_id == supplier_id,
+3 -3
View File
@@ -261,9 +261,7 @@ async def run() -> None:
material_by_name: dict[str, Material] = {}
brand_material_by_material: dict[str, object] = {}
for name, cat_slug, unit_code, brand_name in MATERIALS:
cat = await get_category(db, cat_slug)
material = Material(
category_id=cat.id,
name=name,
canonical_unit_id=unit_ids[unit_code],
status="active",
@@ -289,8 +287,10 @@ async def run() -> None:
await db.flush()
suppliers.append(sup)
# 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:
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]:
db.add(SupplierMaterial(
supplier_id=sup.id, material_id=m.id, is_active=True,
+9 -1
View File
@@ -25,6 +25,9 @@ export interface ComboboxProps {
onAdd?: (val: string) => void;
onInputChange?: (val: string) => void;
onBlur?: () => void;
// با false، فوکوسِ برنامه‌ای (مثل فوکوسِ خودکار Dialog) منو را باز نمی‌کند —
// فقط کلیک یا تایپِ صریح کاربر بازش می‌کند.
openOnFocus?: boolean;
}
function toItem(x: ComboboxSourceItem): ComboboxItem {
@@ -49,6 +52,7 @@ export function Combobox({
onAdd,
onInputChange,
onBlur,
openOnFocus = true,
}: ComboboxProps) {
const [text, setText] = useState(value ?? "");
const [open, setOpen] = useState(false);
@@ -132,9 +136,13 @@ export function Combobox({
onChange={(e) => {
setText(e.target.value);
setHighlight(-1);
if (!open) setOpen(true);
onInputChange?.(e.target.value);
}}
onFocus={() => setOpen(true)}
onFocus={() => {
if (openOnFocus) setOpen(true);
}}
onClick={() => setOpen(true)}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
+2 -9
View File
@@ -1108,12 +1108,6 @@ export const api = {
},
create: async (m: Material): Promise<Material> => {
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 را الزامی می‌داند)
const unitName = (m.base_unit || "").trim();
@@ -1138,7 +1132,6 @@ export const api = {
if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
}
const raw = await postData<RawMaterial>("/materials", {
category_id: categoryId ?? null,
name,
topic: m.topic || null,
canonical_unit_id: unitId,
@@ -1705,7 +1698,8 @@ export const apiV26 = {
const d = await postData<Parameters<typeof rawWarehouse>[0]>(`/inventory/warehouses${projectQuery()}`, body);
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);
return rawWarehouse(d);
},
@@ -1783,7 +1777,6 @@ export const apiV26 = {
}): Promise<{ id: string; name: string }> =>
postData("/materials", {
name: opts.name,
category_id: null, // دسته از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (v2.5)
topic: null,
canonical_unit_id: opts.canonical_unit_id,
status: "active",
+27 -22
View File
@@ -35,43 +35,51 @@ export function specsKey(specs: string[]): string {
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
}
// specها با جداکننده‌ی « · »
// specها با جداکننده‌ی « . »
export function specsLabel(specs: string[]): string {
return specs.join(" · ");
return specs.join(" . ");
}
/**
* عنوان تمیز و بدون تکرار کالا:
* ترکیب هوشمندانه‌ی نام اصلی (materials.name)، برند، و مشخصه‌ها بدون تکرار کلمات مشترک.
* ترتیب اکید با جداکننده‌ی نقطه:
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
*/
export function formatMaterialTitle(
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
extraSpecs?: string[],
): string {
if (!m) return "کالا";
const rawName = (m.name || "").trim();
let rawName = (m.name || "").trim();
const rawBrand = (m.brand || "").trim();
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
.map((s) => (s || "").trim())
.filter(Boolean);
const normName = normalize(rawName);
const normBrand = normalize(rawBrand);
if (!rawName) {
if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`;
if (rawBrand) return rawBrand;
if (rawSpecs.length) return rawSpecs.join(" · ");
return m.category && m.category !== "؟" ? m.category : "کالا";
// اگر نام کالا با نام برند شروع شده باشد، برند را از ابتدای نام برمی‌داریم تا تکرار نشود
if (normBrand && rawName) {
const normName = normalize(rawName);
if (normName.startsWith(normBrand)) {
const rest = rawName.slice(rawBrand.length).trim();
rawName = rest.replace(/^[.\s·\-]+/, "").trim();
}
}
let title = rawName;
if (normBrand && !normName.includes(normBrand)) {
title = `${rawBrand} ${title}`;
// قطعات نهایی با ترتیب مشخص: [Brand Name] . [Material Name] . [Specification 1] . [Specification 2]
const parts: string[] = [];
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>();
for (const s of rawSpecs) {
@@ -79,15 +87,12 @@ export function formatMaterialTitle(
if (!ns || seenNormSpecs.has(ns)) continue;
seenNormSpecs.add(ns);
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمی‌کنیم
if (normTitle.includes(ns)) continue;
distinctSpecs.push(s);
// اگر مشخصه قبلاً داخل نام یا برند آمده باشد، تکرار نمی‌کنیم
if (normCurrent.includes(ns)) continue;
parts.push(s);
}
if (distinctSpecs.length > 0) {
return `${title} · ${distinctSpecs.join(" · ")}`;
}
return title;
return parts.join(" . ");
}
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
+2 -2
View File
@@ -20,9 +20,9 @@ export interface Supplier {
// ماده/کالا
export interface Material {
id: string; // "m1", "m2", ...
name: string; // نام کامل ذخیره‌شده در بک‌اند (برند + دسته + مشخصه)
name: string; // نام کامل ذخیره‌شده در بک‌اند (برند + مشخصه)
topic: string;
category: string; // مثلاً "میلگرد"
category: string; // اختیاری در بک‌اند؛ همیشه string در فرانت (default: "")
brand: string; // مثلاً "ذوب آهن"
specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"])
base_unit: string; // واحد پایه
+26 -3
View File
@@ -88,6 +88,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
const [notes, setNotes] = useState("");
const [invoices, setInvoices] = useState<Attachment[]>([]);
const [proformas, setProformas] = useState<Attachment[]>([]);
const [itemRows, setItemRows] = useState<EditItemRow[]>([]);
const [tsCards, setTsCards] = useState<EditTsCard[]>([]);
const [quoteDate, setQuoteDate] = useState<string | null>(null);
@@ -168,7 +169,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
key: li.id,
origLi: li,
projects: li.projects && li.projects.length ? [...li.projects] : [],
cat: m.category || "",
cat: m.name || "", // نام خود متریال — نه دسته/برند
brand: m.brand || "",
specs: materialSpecs(m),
qty: li.quantity,
@@ -197,7 +198,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
setReady(true);
}, [q, suppliers, materials]);
// بارگذاری فاکتورها
// بارگذاری فاکتورها و پیش‌فاکتورها
useEffect(() => {
if (!quoteId) return;
let alive = true;
@@ -205,6 +206,10 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
.list(quoteId, "invoice")
.then((list) => alive && setInvoices(list))
.catch(() => alive && setInvoices([]));
void api.files
.list(quoteId, "proforma")
.then((list) => alive && setProformas(list))
.catch(() => alive && setProformas([]));
return () => {
alive = false;
};
@@ -314,7 +319,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
let material = materialsCopy.find(
(m) =>
normalize(m.category) === normalize(cat) &&
normalize(m.name) === normalize(cat) &&
normalize(m.brand) === normalize(brand) &&
specsKey(m.specs) === specsKey(specs),
);
@@ -460,6 +465,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
addSuffix="به عنوان تأمین‌کنندهٔ جدید"
placeholder="نام تأمین‌کننده"
onInputChange={(v) => setSupplierName(v)}
openOnFocus={false}
/>
</div>
</div>
@@ -724,6 +730,23 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
/>
</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="mb-2 text-[12px] font-bold text-muted">تاریخ‌ها و وضعیت:</div>
+24 -63
View File
@@ -3,7 +3,6 @@ import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, Cre
import type { Quote, Store } from "../../lib/types";
import {
isExpired,
itemsSummary,
materialById,
materialLabel,
projectCell,
@@ -44,7 +43,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
const pc = priceCell(total);
const ts = termsheetSummary(store, q);
const unitWarns = unitWarning(store, q);
const summary = itemsSummary(store, q);
// ستون / دکمه پذیرش
let acceptedCell: React.ReactNode;
@@ -142,12 +140,26 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
{sup.name.slice(0, 1)}
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
<Link
to={`/suppliers/${q.supplier_id}`}
className="block truncate text-xs font-bold text-ink no-underline hover:text-accent sm:text-[13.5px]"
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>
{/* فاکتور رسمی / غیررسمی — از چک‌باکس «فاکتور رسمی» هنگام ثبت پیشنهاد */}
<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>}
</div>
<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>
</InfoCell>
{/* ۲. اقلام — تمام‌عرض زیر پروژه با شکستن متن بدون سه‌نقطه */}
{/* ۲. اقلام — نمایش عمودی چندخطی */}
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
{summary.count > 1 ? (
<div className="flex flex-col gap-1.5">
<span className="font-bold text-xs text-ink sm:text-sm">
{fmtNum(summary.count)} قلم کالا
</span>
<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]">
{store.quotes
.find((x) => x.id === q.id)
?.line_items?.filter((li) => !li.rejected)
.slice(0, 3)
.map((li) => {
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
{activeItems.length > 0 ? (
activeItems.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 key={li.id} className="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>
)}
</div>
</div>
})
) : (
<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>
<span>—</span>
)}
</div>
{unitWarns.map((w) => (
<Link
@@ -243,32 +230,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
))}
</InfoCell>
<InfoCell label="پروژه">
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="truncate">
{(() => {
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
if (q.status === "accepted" || q.status === "arrived") {
// فقط پروژه‌ی هدف پذیرفته‌شده — همان منطق itemProjects در وضعیت accepted
const targetProj = activeItems.find((li) => li.target_project)?.target_project;
return targetProj?.name ?? projectCell(q);
}
// وضعیت offered: همه‌ی پروژه‌های کاندیدا از line_items (همان منطق itemProjects)
const names = new Set<string>();
activeItems.forEach((li) =>
(li.projects || []).forEach((p) => {
const name = typeof p === "string" ? p : p.name;
if (name) names.add(name);
})
);
if (names.size > 0) return [...names].join("، ");
return projectCell(q);
})()}
</span>
</span>
</InfoCell>
<InfoCell label="مجموع مبلغ" className="col-span-1">
{pc ? (
<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) => {
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
try {
@@ -329,16 +321,6 @@ export function OrganizationPage() {
>
📊 داشبورد
</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>
</div>
</div>
+50 -22
View File
@@ -16,7 +16,6 @@ import {
import { normalize } from "../../lib/normalize";
import { useAppStore } from "../../store";
import {
activeItems,
lineItemTotal,
materialById,
materialLabel,
@@ -29,25 +28,45 @@ import { EmptyState } from "../../components/EmptyState";
import { Button } from "../../components/ui/button";
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"] {
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)) {
return items.filter((li) => (li.projects || []).some((p) => p.name === name));
}
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 {
if (!iso) return null;
const d = new Date(iso);
@@ -147,10 +166,11 @@ export function ProjectsPage() {
// ---------- پنل جزئیات یک پروژه ----------
function renderProjectDetail(store: Store, name: string): React.ReactElement {
const quotes = projectQuotes(store.quotes, name);
const sumBy = (st: Quote["status"]) =>
quotes
.filter((q) => q.status === st)
.reduce((s, q) => s + qTotal(q), 0);
// جمع مبلغ هر ستون فقط روی اقلامِ همین پروژه
const itemsTotal = (q: Quote) =>
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 m = materialById(store.materials, mid);
@@ -184,11 +204,19 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
);
};
// دسته‌بندی ایزوله per-project:
// - پذیرفته/رسیده فقط وقتی حداقل یک قلمِ غیرردشده برای «این» پروژه پذیرفته شده باشد؛
// - پیشنهادی که صرفاً کاندیداست (یا فقط برای پروژه‌ی دیگری پذیرفته شده) → پیشنهاد شده.
// - پیشنهادهای قدیمیِ بدون پروژه‌ی هدف، طبق وضعیت سراسری می‌مانند.
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());
const accepted = quotes.filter((q) => q.status === "accepted");
const arrived = quotes.filter((q) => q.status === "arrived");
const accepted = quotes.filter((q) => q.status === "accepted" && acceptedForProject(q, name));
const arrived = quotes.filter((q) => q.status === "arrived" && acceptedForProject(q, name));
const offeredRow = (q: Quote) => {
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 className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
<div className="pj-side">
جمع: <b>{faNum(qTotal(q))} تومان</b>
جمع: <b>{faNum(itemsTotal(q))} تومان</b>
</div>
<div className="pj-side">
اعتبار تا {fmtDate(q.expiry_date)}<br />
@@ -240,7 +268,7 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
</div>
<div className="pj-meta">{itemsForProject(q, name).map(itemLine)}</div>
<div className="pj-side">
هزینه: <b>{faNum(qTotal(q))} تومان</b>
هزینه: <b>{faNum(itemsTotal(q))} تومان</b>
</div>
<div className="pj-side">رسید: {fmtDate(q.arrived_date)}</div>
</div>
@@ -255,13 +283,13 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement {
<div className="mb-3 flex flex-wrap gap-1.5">
<span className="pj-chip">کل پیشنهادها: {faNum(quotes.length)}</span>
<span className="pj-chip" style={{ background: "#dbeafe", color: "#1d4ed8" }}>
ارزش باز: {faNum(sumBy("offered"))} تومان
ارزش باز: {faNum(sumBy(offered))} تومان
</span>
<span className="pj-chip" style={{ background: "#fef3c7", color: "#d97706" }}>
ارزش پذیرفته: {faNum(sumBy("accepted"))} تومان
ارزش پذیرفته: {faNum(sumBy(accepted))} تومان
</span>
<span className="pj-chip" style={{ background: "#dcfce7", color: "#16a34a" }}>
ارزش رسیده: {faNum(sumBy("arrived"))} تومان
ارزش رسیده: {faNum(sumBy(arrived))} تومان
</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
+2 -2
View File
@@ -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 className="ut-ref">{hasChildren ? faNum(g.variants.length) : g.repRef || "—"}</span>
</button>
@@ -519,7 +519,7 @@ export function UnitsPage() {
)}
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>
</button>
))}
+5 -4
View File
@@ -67,17 +67,18 @@ function materialBaseName(name: string, brand: string, specs: string[], category
return base || fallback;
}
// مقادیر مجزای category/brand/spec داخل تاپیک
// مقادیر مجزای category/brand/spec در کاتالوگ سراسری
export function materialOptions(
materials: Material[],
topic: string,
_topic: string,
item: ItemDraft,
field: "cat" | "brand" | "spec",
allItems: ItemDraft[] = [],
apiBrands: string[] = [],
apiSpecs: string[] = [],
): string[] {
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
// متریال‌ها سراسری هستند — مستقل از تاپیک
let list = materials;
const cat = item.cat.trim();
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
@@ -92,7 +93,7 @@ export function materialOptions(
};
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));
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
}
+2 -22
View File
@@ -164,14 +164,12 @@ CREATE TABLE category_attributes (
CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id)
);
-- ---------- MATERIALS (GLOBAL catalog entities — v2.5) ----------
-- category_id is NULLABLE and DERIVED automatically from the first linked
-- supplier (supplier_materials insert, first-link-wins). name is GLOBALLY
-- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ----------
-- Materials are global, category-free entities. name is GLOBALLY
-- unique (uq_material_name). topic is a legacy display column no longer
-- written by the app.
CREATE TABLE materials (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
category_id UUID REFERENCES material_categories(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
topic TEXT, -- LEGACY display column
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)
-- ============================================================================
-- 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
-- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow
-- inserts decisions directly as 'chosen').