6 Commits
Author SHA1 Message Date
whqreza 65758b5e78 fix(projects): isolate quote acceptance status per project (decision-target based)
Bug: a multi-project quote accepted for «تست ۱» only also appeared under «تست ۲»'s پذیرفته شده column, because both the Projects page and /projects/{id}/stats classified quotes by their GLOBAL status over any project link.

Rule now: a quote counts as accepted/arrived for a project ONLY if at least one non-rejected line item's decision targets that project (item_decisions.project_id). A quote merely linked as a candidate — including one accepted for a different project — is categorized exclusively under پیشنهاد شده for that project. Legacy quotes without any decision targets keep the old global-status classification.

Backend: project_stats membership now spans all link kinds (legacy mirror, quote_projects, line_item_projects, decision targets) with the per-project decision-based bucketing above; bucket VALUES follow the same isolation (accepted/arrived value counts only items targeting the project; candidate items of a still-proposal quote stay in the offered bucket); the raw path string is normalized to UUID for the Python-side comparisons. /quotes?project_id=… now matches any relevant link kind instead of only the legacy mirror column.

Frontend: ProjectsPage gains acceptedForProject/hasTargets helpers; the three columns (پیشنهاد شده / پذیرفته شده / رسیده) and their value chips use per-project items (target-project items first, then candidates), so totals no longer leak other projects' items.

Verified end-to-end (2 projects × 1 quote × 2 items, accept item-1 for P1 only): P1 accepted=1 then arrived=1 after the PM receipt; P2 stays offered=1 / accepted=0 / arrived=0 at every step; /quotes?project_id=P2 still lists the quote. All fixture data removed; 7/7 checks pass. python compile + api/frontend builds clean.
2026-09-19 15:11:57 +03:30
whqreza b1edf6eed3 feat(inventory)!: lock warehouses permanently to their parent project
Warehouses now belong strictly and exclusively to the project they were created under; cross-project reassignment is removed end-to-end.

Backend: PATCH /inventory/warehouses/{id} no longer applies project_id — any attempt to move a warehouse to a different project returns 400 with «پروژه‌ی انبار قابل تغییر نیست — انبار برای همیشه به پروژه‌ی والدش تعلق دارد» (sending the current project stays a harmless no-op). The WarehouseUpdate schema documents project_id as accept-only-to-reject; creation still requires project_id and delete is unchanged.

Frontend: the Organization page's per-warehouse project dropdown («انتقال به پروژه دیگر») and its moveWarehouse handler are removed — warehouses render statically inside their project card with only the dashboard link and delete; the warehouses.update API type no longer accepts project_id. WarehousesPage already had no move UI (rename/describe only).

Verified end-to-end against the running stack: rename 200; same-project project_id no-op 200; cross-project reassignment 400 with the guard message; warehouse remains in its original project; test data cleaned up. python compile + api/frontend builds clean.
2026-09-19 14:32:46 +03:30
whqreza a181a8753d feat(quotes,units): official-invoice badge, conversion text wrapping, seeded fixed conversions, universal proforma, EditModal fixes
1) Quote card header: an official/unofficial badge (رسمی / غیررسمی) renders next to the supplier name from quotes.is_official (the «فاکتور رسمی» checkbox) — green for official, muted slate for unofficial.

2) Conversion Factors sidebar: the material group and variant labels no longer truncate with ellipsis — whitespace-normal + break-words so long Persian names wrap cleanly to the next line.

3) Global fixed conversions are now seeded in the unified seeder (app/db/init_db.py — the same file that seeds categories, base units and the superuser path), NOT hardcoded in the frontend: تن→کیلوگرم (1000), کیلوگرم→گرم (1000), هزار عدد→عدد (1000). The supporting units (گرم, هزار عدد) were added to the idempotent BASE_UNITS seed; inserts use ON CONFLICT (from_unit, to_unit) DO NOTHING against the uq_standard_conversion key so every startup is safe. Verified live: 3 rows after first boot AND still 3 rows after an api restart.

4) Universal proforma attachments: the Edit modal now has a پیش‌فاکتورها section (list/upload/delete via the existing MinIO-backed files API) alongside the invoices section, active at every quote lifecycle stage — including accepted/arrived and receipts-locked quotes (the section is deliberately not gated by hasReceipts; the backend already permits uploads in any status). View/delete of existing proformas unchanged elsewhere.

5) EditModal fixes: (a) the supplier Combobox no longer opens on mount — Radix Dialog autofocus was triggering its onFocus; Combobox gains an openOnFocus prop (default true) and the modal passes false, so the dropdown now opens only on explicit click or typing; (b) the line-item material field binds to the material's own name (m.name) instead of its category relation, and the save path matches materials by name+brand+specs accordingly.

Validated: python compile, api + frontend image builds (tsc + vite) clean; seeding verified against the live local DB.
2026-09-19 13:44:09 +03:30
whqreza 8adfd2267f refactor(catalog)!: decouple materials from categories (v2.7)
Materials are now global and category-free. The category lives only on the supplier (suppliers.category_id); every category-facing query goes through the supplier edge instead of the material row.

Schema (migration.sql + init_db DDL):
- drop the materials.category_id column, its FK, and the v2.5 first-link-wins automation (fn_derive_material_category + trg_supplier_materials_derive_category) — the DDL runs idempotently at API startup

Backend:
- GET /materials?category_id=… now filters via EXISTS(supplier_materials ⋈ suppliers.category_id)
- quote category filters (quotes list) use the quote's supplier category
- /pm/on-the-way derives the display category from the supplier of the quote
- supplier_materials links no longer derive/return a material category
- expand_service: material expand=category resolves to null (kept for contract compatibility)
- material create/update no longer validates or stores category_id; seed.py aligned

Frontend:
- material creation no longer sends category_id
- formatMaterialTitle reworked to a strict order with « . » separator: [Brand] . [Material] . [Spec 1] . [Spec 2], brand deduped when the stored name already starts with it; specs separator switched from « · » to « . »

Validated: python compile, api + frontend image builds (tsc + vite) clean; branch cut from the tip of prod_v4 (= origin/production, 5a9ef40 — the team's merge made them identical).
2026-09-19 13:19:26 +03:30
whqreza 5a9ef408ff feat(catalog): auto-purge orphan materials when quotes/items release them
A material that ends up referenced by nothing (no line_items anywhere, no warehouse stock) was previously left behind forever, accumulating dead catalog rows as offers were edited or deleted.

- new quote_service.cleanup_orphan_materials(db, material_ids): for each candidate it checks usage across line_items (all quotes) and warehouse inventory; only when both are zero does it remove the auxiliary rows (supplier_materials, brand_materials, material_units, material_attribute_values) and finally the materials row; returns the purged ids
- replace_quote_children (edit modal): snapshots the quote's material ids before the children are replaced, diffs against the incoming items, and purges whatever was detached
- delete_quote: collects the quote's material ids + attachment keys first, deletes the quote, purges orphans inside the transaction, and only then (post-commit) removes the MinIO objects — now both the recorded attachment keys and a recursive remove_prefix(quote_id/) sweep, so stray objects under the quote folder cannot survive; the response's cascaded block reports purged_orphan_materials
- line_items: PATCH that switches a line item's material, and DELETE of a line item, both clean up the previously referenced material
- storage.remove_prefix(prefix): best-effort recursive object deletion under a prefix (sync MinIO client wrapped in asyncio.to_thread, same pattern as remove_object)

Safety: usage checks run against the whole system before any delete, so shared materials — including those held only by warehouse stock — are never touched. Verified: python compile + api image build clean.
2026-09-17 18:04:57 +03:30
whqreza 43ff048899 fix(wizard,quotes,dates): reliable error routing, single-project accept, Persian date/time input
Wizard:
- failed saves now route to the exact step+field: a new data-field attribute on every required section (supplier_id, line_items, termsheets, project, quote_date, expiry_date, notes, proforma) plus resolveErrorTarget() that maps ApiError.field / message keywords (supplier, line item/material/unit/quantity, termsheet/schedule/milestone, project, dates, notes, file) to the offending step — the «رفع خطا» toast action scrolls/focuses the right control instead of guessing
- material records are no longer created on the fly while typing (rememberMaterial no longer fires addMaterial per blur); materials/specs/units are written once during the final save, avoiding half-created catalog rows

Accept flow:
- quote_service no longer rewrites LineItemProjects on accept — those candidate-project rows stay 100% untouched and only item_decisions.project_id is written, matching the per-item target-project model and the UI payload (item_projects: [])

Dates/time:
- JalaliDatePicker: hour/minute are now editable text fields (Persian digits, clamping, 3-digit retype handling) with a controlled useEffect that only rewrites the inputs when the numeric value actually changes — no more wiping in-progress typing; date-only detection tightened to a full YYYY-MM-DD match so datetime strings are not misread

Also: /api validation logging (RequestValidationError) now includes the path and error list for faster diagnosis.

Verified: api + frontend image builds (python, tsc, vite) clean on top of origin/production (incl. the team's mobile layout merges).
2026-09-17 16:13:27 +03:30
38 changed files with 947 additions and 418 deletions
+26 -6
View File
@@ -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()
""") """)
+1
View File
@@ -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
+1 -5
View File
@@ -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
+6 -5
View File
@@ -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))}
+13
View File
@@ -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}}
+11 -2
View File
@@ -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(
+5 -11
View File
@@ -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),
+29 -5
View File
@@ -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:
+2 -7
View File
@@ -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}")
-1
View File
@@ -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
+7
View File
@@ -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
+1 -1
View File
@@ -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
+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] 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, [])
+1 -8
View File
@@ -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,
+212 -50
View File
@@ -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():
+19
View File
@@ -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,
+3 -3
View File
@@ -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,
+9 -1
View File
@@ -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}
/> />
+185 -37
View File
@@ -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}>
امروز امروز
+1 -1
View File
@@ -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
View File
@@ -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",
+25 -9
View File
@@ -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();
} }
+30 -32
View File
@@ -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);
} }
// ---------- واحد مرجع و ضریب تبدیل ---------- // ---------- واحد مرجع و ضریب تبدیل ----------
+2 -2
View File
@@ -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; // واحد پایه
+2 -8
View File
@@ -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;
+48 -6
View File
@@ -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>
+34 -48
View File
@@ -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>
+50 -22
View File
@@ -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">
+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>
<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}
+6 -5
View File
@@ -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}
+1 -1
View File
@@ -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>
پروژه‌ها پروژه‌ها
+4 -4
View File
@@ -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 ? (
/* وضعیت فایل انتخاب‌شده با دکمه حذف تمیز */ /* وضعیت فایل انتخاب‌شده با دکمه حذف تمیز */
+77 -31
View File
@@ -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
View File
@@ -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').