feat(dashboard): supporting schema, seed, catalog links, funnel/PM labels and API contract (merge resolution)
This commit is contained in:
@@ -384,11 +384,12 @@ async def ensure_v26(engine: AsyncEngine) -> None:
|
||||
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
|
||||
c,
|
||||
)
|
||||
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
|
||||
# v2.5/v2.6 category decoupling from materials: drop the old supplier-derived
|
||||
# trigger/function. The `materials.category_id` COLUMN is NOT dropped here
|
||||
# anymore — v2.8 re-introduces it (topic-backfilled) for the dashboard's
|
||||
# Category→Material filter cascade (see ensure_dashboard_schema below).
|
||||
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
|
||||
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
|
||||
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
|
||||
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
|
||||
|
||||
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
|
||||
await run_statement("""
|
||||
@@ -558,3 +559,70 @@ async def ensure_project_transfers_table(engine: AsyncEngine) -> None:
|
||||
await conn.execute(text(stmt))
|
||||
logger.info("project_transfers table ensured")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# v2.8 — MVP dashboard schema: materials.category_id + reporting indexes
|
||||
# The dashboard's «What» filter cascade (Category → Material → Brand → Specs)
|
||||
# needs a real materials.category_id link. v2.6 had deliberately dropped the
|
||||
# old supplier-derived column; this is a fresh, topic-backfilled link
|
||||
# (nullable — a material may stay uncategorized). The rest is B-tree index
|
||||
# coverage for the aggregation queries in services/dashboard_service.py.
|
||||
# Runs AFTER ensure_v26 so the 8 fixed categories always exist for the
|
||||
# backfill. Every statement is idempotent.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DASHBOARD_SCHEMA_DDL = [
|
||||
# Fresh column (inline FK names the constraint materials_category_id_fkey,
|
||||
# matching the DO-block guard below for volumes that predate v2.8).
|
||||
"""
|
||||
ALTER TABLE materials ADD COLUMN IF NOT EXISTS category_id UUID REFERENCES material_categories(id) ON DELETE SET NULL
|
||||
""",
|
||||
# Backfill from the legacy Persian topic text (NULL stays NULL — the UI
|
||||
# groups those under «بدون دسته»).
|
||||
"""
|
||||
UPDATE materials SET category_id = mc.id
|
||||
FROM material_categories mc
|
||||
WHERE materials.topic = mc.name AND materials.category_id IS NULL
|
||||
""",
|
||||
# FK for the (rare) volume that already had the column without it.
|
||||
"""
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE materials ADD CONSTRAINT materials_category_id_fkey
|
||||
FOREIGN KEY (category_id) REFERENCES material_categories(id) ON DELETE SET NULL;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_materials_category ON materials(category_id)",
|
||||
# Drill-down / PM scoping: every dashboard query filters through it.
|
||||
"CREATE INDEX IF NOT EXISTS idx_warehouses_project ON warehouses(project_id)",
|
||||
# Daily flow + daily control (date-range aggregation).
|
||||
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_date ON inventory_transactions(transaction_date)",
|
||||
# Type slices (withdrawal / return / waste) inside a date range.
|
||||
"CREATE INDEX IF NOT EXISTS idx_inventory_tx_type_date ON inventory_transactions(transaction_type, transaction_date)",
|
||||
# Funnel cumulative counts + lead-time average (partial — tiny & hot).
|
||||
"CREATE INDEX IF NOT EXISTS idx_quotes_accepted ON quotes(accepted_date) WHERE accepted_date IS NOT NULL",
|
||||
"CREATE INDEX IF NOT EXISTS idx_quotes_arrived ON quotes(arrived_date) WHERE arrived_date IS NOT NULL",
|
||||
# Funnel per-status counts (existing composite leads with supplier_id).
|
||||
"CREATE INDEX IF NOT EXISTS idx_quotes_status ON quotes(status)",
|
||||
# Project-density workload grouping.
|
||||
"CREATE INDEX IF NOT EXISTS idx_item_decisions_project ON item_decisions(project_id)",
|
||||
# Receipt-level arrival reporting.
|
||||
"CREATE INDEX IF NOT EXISTS idx_item_receipts_received ON item_receipts(received_at)",
|
||||
# «What»-filtered stock scans across warehouses (existing composites lead
|
||||
# with warehouse_id, useless for a bare material/brand filter).
|
||||
"CREATE INDEX IF NOT EXISTS idx_inventory_material_brand ON inventory(material_id, brand_id)",
|
||||
]
|
||||
|
||||
|
||||
async def ensure_dashboard_schema(engine: AsyncEngine) -> None:
|
||||
"""v2.8: materials.category_id (+ backfill) + dashboard reporting indexes.
|
||||
|
||||
Statement-per-transaction like ensure_v26: one failure can never block
|
||||
the rest of the upgrade. Safe to call on every startup.
|
||||
"""
|
||||
for ddl in _DASHBOARD_SCHEMA_DDL:
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(ddl))
|
||||
except Exception: # pragma: no cover - pre-existing data conflicts
|
||||
first_line = ddl.splitlines()[0][:100] if ddl.splitlines() else ddl[:100]
|
||||
logger.exception("dashboard schema statement skipped (continuing): %s", first_line)
|
||||
logger.info("dashboard schema ensured (materials.category_id + reporting indexes)")
|
||||
|
||||
@@ -28,6 +28,7 @@ from app.db.init_db import (
|
||||
ensure_attachment_table,
|
||||
ensure_audit_table,
|
||||
ensure_bot_drafts_table,
|
||||
ensure_dashboard_schema,
|
||||
ensure_drop_category_tables,
|
||||
ensure_dashboard_tables,
|
||||
ensure_material_topic_column,
|
||||
@@ -109,6 +110,7 @@ async def lifespan(_app: FastAPI):
|
||||
await ensure_quote_projects(engine)
|
||||
await ensure_v26(engine)
|
||||
await ensure_rbac_schema(engine)
|
||||
await ensure_dashboard_schema(engine)
|
||||
await ensure_audit_table(engine)
|
||||
await ensure_drop_category_tables(engine)
|
||||
await ensure_bot_drafts_table(engine)
|
||||
|
||||
@@ -130,13 +130,18 @@ class CategoryAttribute(Base):
|
||||
|
||||
class Material(Base):
|
||||
__tablename__ = "materials"
|
||||
# v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
|
||||
# v2.6/v2.7: name is GLOBALLY unique; v2.8 re-links materials to the
|
||||
# category taxonomy for the dashboard filter cascade (nullable — backfill
|
||||
# from the legacy `topic` text runs in init_db.ensure_dashboard_schema).
|
||||
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
|
||||
__mapper_args__ = {"eager_defaults": True}
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
|
||||
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("material_categories.id", ondelete="SET NULL")
|
||||
)
|
||||
canonical_unit_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("units.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
@@ -147,6 +152,7 @@ class Material(Base):
|
||||
)
|
||||
|
||||
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
|
||||
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
|
||||
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
||||
back_populates="material", lazy="raise", passive_deletes=True
|
||||
)
|
||||
|
||||
@@ -38,13 +38,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.auth import UserContext, get_optional_user
|
||||
from app.config import settings
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import BrandMaterial, Material, Unit
|
||||
from app.models.catalog import AttributeDefinition, Brand, BrandMaterial, Material, Unit
|
||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
ItemReceipt,
|
||||
ItemReceiptAllocation,
|
||||
LineItem,
|
||||
LineItemSpec,
|
||||
Project,
|
||||
Quote,
|
||||
Supplier,
|
||||
@@ -182,16 +183,24 @@ def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
|
||||
async def pm_on_the_way(
|
||||
ctx: ArrivalScope = Depends(arrivals_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
material_id: uuid.UUID | None = Query(default=None, description="فیلتر متریال (داشبورد بازرگانی)"),
|
||||
brand_id: uuid.UUID | None = Query(default=None, description="فیلتر برند (داشبورد بازرگانی)"),
|
||||
):
|
||||
stmt = (
|
||||
select(ItemDecision, LineItem, Quote, Material, Supplier, Project)
|
||||
select(ItemDecision, LineItem, Quote, Material, Supplier, Project, BrandMaterial, Brand)
|
||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||
.join(Quote, Quote.id == LineItem.quote_id)
|
||||
.join(Material, Material.id == LineItem.material_id)
|
||||
.join(Supplier, Supplier.id == Quote.supplier_id)
|
||||
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
||||
.outerjoin(BrandMaterial, BrandMaterial.id == LineItem.brand_material_id)
|
||||
.outerjoin(Brand, Brand.id == BrandMaterial.brand_id)
|
||||
.where(ItemDecision.status == "chosen")
|
||||
)
|
||||
if material_id is not None:
|
||||
stmt = stmt.where(Material.id == material_id)
|
||||
if brand_id is not None:
|
||||
stmt = stmt.where(BrandMaterial.brand_id == brand_id)
|
||||
if ctx.role == "pm":
|
||||
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||
if not allowed:
|
||||
@@ -211,8 +220,29 @@ async def pm_on_the_way(
|
||||
# unit / category lookups for display
|
||||
unit_ids = {d.LineItem.unit_id for d in decisions}
|
||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||
# frozen spec snapshot per line item (key = attribute label)
|
||||
li_ids = [d.LineItem.id for d in decisions]
|
||||
specs_by_line: dict = {}
|
||||
if li_ids:
|
||||
spec_rows = (
|
||||
await db.execute(
|
||||
select(LineItemSpec, AttributeDefinition)
|
||||
.join(AttributeDefinition, AttributeDefinition.id == LineItemSpec.attribute_definition_id)
|
||||
.where(LineItemSpec.line_item_id.in_(li_ids))
|
||||
)
|
||||
).all()
|
||||
for spec, ad in spec_rows:
|
||||
value = spec.value_string
|
||||
if value is None and spec.value_number is not None:
|
||||
value = str(spec.value_number)
|
||||
if value is None and spec.value_boolean is not None:
|
||||
value = str(spec.value_boolean)
|
||||
if value is None and spec.value_date is not None:
|
||||
value = spec.value_date.isoformat()
|
||||
if value is not None:
|
||||
specs_by_line.setdefault(spec.line_item_id, {})[ad.label or ad.attribute_key] = value
|
||||
out = []
|
||||
for d, li, q, m, sup, proj in decisions:
|
||||
for d, li, q, m, sup, proj, bm, br in decisions:
|
||||
received = received_map.get(d.id)
|
||||
out.append({
|
||||
"item_decision_id": str(d.id),
|
||||
@@ -224,6 +254,8 @@ async def pm_on_the_way(
|
||||
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||
"decided_at": to_iso(d.decided_at),
|
||||
"material": {"id": str(m.id), "name": m.name},
|
||||
"brand": {"id": str(br.id), "name": br.name} if br else None,
|
||||
"specs": specs_by_line.get(li.id, {}),
|
||||
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
||||
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
||||
"ordered_qty": str(li.quantity),
|
||||
|
||||
@@ -1100,6 +1100,17 @@ async def quote_stats(
|
||||
*conditions,
|
||||
)
|
||||
)
|
||||
# Procurement funnel (cumulative, not the current-state counts above):
|
||||
# a quote counts as accepted/arrived if it EVER reached that stage.
|
||||
funnel_row = (
|
||||
await db.execute(
|
||||
select(
|
||||
func.count().label("offered_total"),
|
||||
func.count().filter(Quote.accepted_date.is_not(None)).label("accepted_total"),
|
||||
func.count().filter(Quote.arrived_date.is_not(None)).label("arrived_total"),
|
||||
).where(*conditions)
|
||||
)
|
||||
).one()
|
||||
return {
|
||||
"total": sum(counts.values()),
|
||||
"offered": counts["offered"],
|
||||
@@ -1108,6 +1119,11 @@ async def quote_stats(
|
||||
"rejected": counts["rejected"],
|
||||
"expired": counts["expired"],
|
||||
"average_lead_time_days": int(round(float(avg))) if avg is not None else None,
|
||||
"funnel": {
|
||||
"offered_total": funnel_row.offered_total,
|
||||
"accepted_total": funnel_row.accepted_total,
|
||||
"arrived_total": funnel_row.arrived_total,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -262,8 +262,11 @@ async def run() -> None:
|
||||
material_by_name: dict[str, Material] = {}
|
||||
brand_material_by_material: dict[str, object] = {}
|
||||
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
||||
cat = await get_category(db, cat_slug)
|
||||
material = Material(
|
||||
name=name,
|
||||
topic=cat.name,
|
||||
category_id=cat.id, # v2.8: dashboard Category→Material cascade
|
||||
canonical_unit_id=unit_ids[unit_code],
|
||||
status="active",
|
||||
)
|
||||
@@ -441,6 +444,86 @@ async def run() -> None:
|
||||
))
|
||||
await db.flush()
|
||||
|
||||
# ---- MVP dashboard demo ledger -----------------------------------
|
||||
# Multi-day history so every widget has real data to show:
|
||||
# - two old PURCHASEs 70/85 days ago → dead stock rows (60-90d)
|
||||
# - withdrawals/returns/waste over the last ~12 days → flow series
|
||||
# - tickets dated TODAY (performed_by names) → daily control
|
||||
# Quantities are hand-checked to keep every row >= 0.
|
||||
|
||||
async def ensure_inv(mat_idx: int, project_idx: int, wh_idx: int) -> InventoryItem:
|
||||
mat = material_by_name[MATERIALS[mat_idx][0]]
|
||||
wh = warehouse_rows[project_idx][wh_idx]
|
||||
inv = await db.scalar(select(InventoryItem).where(
|
||||
InventoryItem.warehouse_id == wh.id,
|
||||
InventoryItem.material_id == mat.id,
|
||||
InventoryItem.specs_hash == "default",
|
||||
))
|
||||
if inv is None:
|
||||
inv = InventoryItem(
|
||||
warehouse_id=wh.id, material_id=mat.id, brand_id=None,
|
||||
quantity=Decimal("0"), unit_id=mat.canonical_unit_id,
|
||||
specs={}, specs_hash="default",
|
||||
)
|
||||
db.add(inv)
|
||||
await db.flush()
|
||||
return inv
|
||||
|
||||
async def add_tx(inv: InventoryItem, wh: Warehouse, tx_type: str, qty: int,
|
||||
days_ago: int, performed_by: str, note: str | None = None) -> None:
|
||||
inv.quantity += Decimal(str(qty))
|
||||
db.add(InventoryTransaction(
|
||||
inventory_id=inv.id,
|
||||
warehouse_id=wh.id,
|
||||
transaction_type=tx_type,
|
||||
quantity=Decimal(str(qty)),
|
||||
performed_by=performed_by,
|
||||
note=note or "تاریخچه دمو داشبورد",
|
||||
transaction_date=d(days_ago),
|
||||
))
|
||||
|
||||
wh_aram, wh_sepehr, wh_shargh = warehouse_rows[0], warehouse_rows[1], warehouse_rows[2]
|
||||
|
||||
# dead stock: cable + switches bought long ago, untouched since
|
||||
cable = await ensure_inv(0, 0, 0)
|
||||
await add_tx(cable, wh_aram[0], "PURCHASE", 800, 70, "حسین موسوی", "خرید اولیه — دمو")
|
||||
keys = await ensure_inv(1, 0, 0)
|
||||
await add_tx(keys, wh_aram[0], "PURCHASE", 200, 85, "حسین موسوی", "خرید اولیه — دمو")
|
||||
|
||||
# cement (انبار مرکزی آرام is project0 — cement lives in شرق اصلی)
|
||||
cement = await ensure_inv(4, 2, 0)
|
||||
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -80, 10, "رضا محمدی")
|
||||
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -60, 8, "رضا محمدی")
|
||||
await add_tx(cement, wh_shargh[0], "RETURN", 20, 7, "علی رضایی", "برگشت از سایت — مازاد")
|
||||
await add_tx(cement, wh_shargh[0], "DAMAGE", -10, 5, "کاربر تست")
|
||||
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -50, 1, "رضا محمدی")
|
||||
await add_tx(cement, wh_shargh[0], "WITHDRAWAL", -30, 0, "رضا محمدی")
|
||||
await add_tx(cement, wh_shargh[0], "RETURN", 12, 0, "علی رضایی", "برگشت از سایت — امروز")
|
||||
|
||||
blocks = await ensure_inv(5, 2, 1)
|
||||
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -400, 12, "رضا محمدی")
|
||||
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -300, 9, "کاربر تست")
|
||||
await add_tx(blocks, wh_shargh[1], "LOSS", -15, 6, "کاربر تست")
|
||||
await add_tx(blocks, wh_shargh[1], "WASTE", -25, 4, "رضا محمدی")
|
||||
await add_tx(blocks, wh_shargh[1], "WITHDRAWAL", -250, 0, "حسین موسوی")
|
||||
|
||||
pe16_a = await ensure_inv(6, 1, 0)
|
||||
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -100, 6, "محمد محقق")
|
||||
await add_tx(pe16_a, wh_sepehr[0], "RETURN", 25, 3, "محمد محقق", "برگشت — برش اضافه")
|
||||
await add_tx(pe16_a, wh_sepehr[0], "WITHDRAWAL", -80, 0, "محمد محقق")
|
||||
|
||||
pe16_b = await ensure_inv(6, 1, 1)
|
||||
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -150, 11, "محمد محقق")
|
||||
await add_tx(pe16_b, wh_sepehr[1], "WASTE", -20, 7, "محمد محقق")
|
||||
await add_tx(pe16_b, wh_sepehr[1], "WITHDRAWAL", -120, 2, "محمد محقق")
|
||||
|
||||
pe25 = await ensure_inv(7, 1, 0)
|
||||
await add_tx(pe25, wh_sepehr[0], "WITHDRAWAL", -60, 9, "محمد محقق")
|
||||
await add_tx(pe25, wh_sepehr[0], "RETURN", 10, 5, "محمد محقق")
|
||||
await add_tx(pe25, wh_sepehr[0], "DAMAGE", -8, 3, "محمد محقق")
|
||||
|
||||
await db.flush()
|
||||
|
||||
await db.commit()
|
||||
|
||||
# summary
|
||||
|
||||
Reference in New Issue
Block a user