refactor(catalog): reduce the category taxonomy to a supplier label
Categories are kept only as the label each supplier carries (suppliers.category_id + the material_categories list it points at). Everything that treated category as a shared key across materials, inventory, quotes and unit conversions is removed — including two features that were never populated (0 rows on every volume). Removed (backend): - routers category_attributes, category_ref_units, unit_conversions + registration - ?category_id= filters on /materials, /inventory and /quotes (the /suppliers one stays — it filters suppliers by their own label) - GET /inventory/materials/options: required a category and had no caller - the material-payload category derivation (expand_service.category_by_material, the inventory serializer lookup) and the material category on /pm/on-the-way; the wire keys stay, now always null, so the payload shape is unchanged - startup DDL: DROP the empty category_attributes / category_reference_units / unit_conversions tables (idempotent) and their CREATE statements; seed.py truncate list updated Removed (frontend): - the category-keyed conversion path: refUnitForCategory / findConversion / refConverted, the categoryUnits + unitConversions store slices, api clients, hooks and the "≈ converted quantity" line on quote detail and project pages (it needed rows in those tables to render, and there were none) - material.supplier_category and its read sites (the edit modal grew one yesterday to restore labels; the label lives on the supplier now) - the inventory page's category chips, the row filter and the «دسته» column, plus the lookup's categories fetch and catName helper Kept: supplier label + its list and picker, /suppliers?category_id=, the global standard_conversions feature and the units page (material_units conversions). Verified against a restore of the production dump: startup DDL drops exactly the three tables, suppliers keep their label (3/3), material_categories still serves its 8 rows, the removed endpoints 404, and materials/inventory/quotes/units/ standard-conversions all return 200 with quote line items intact.
This commit is contained in:
@@ -15,7 +15,7 @@ from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, get_optional_user, require_role
|
||||
@@ -27,13 +27,12 @@ from app.models.inventory import (
|
||||
InventoryTransaction,
|
||||
Warehouse,
|
||||
)
|
||||
from app.models.procurement import Project, Supplier, SupplierMaterial
|
||||
from app.models.procurement import Project
|
||||
from app.schemas.inventory import (
|
||||
InventoryTransactionCreate,
|
||||
WarehouseCreate,
|
||||
WarehouseUpdate,
|
||||
)
|
||||
from app.services.expand_service import category_by_material
|
||||
from app.services.inventory_service import (
|
||||
apply_transaction,
|
||||
get_warehouse_or_404,
|
||||
@@ -315,7 +314,6 @@ async def delete_warehouse(
|
||||
@router.get("")
|
||||
async def list_inventory(
|
||||
project_id: uuid.UUID | None = None,
|
||||
category_id=None,
|
||||
warehouse_id=None,
|
||||
material_id=None,
|
||||
sort: str | None = None,
|
||||
@@ -333,20 +331,6 @@ async def list_inventory(
|
||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||
Warehouse.project_id == project_id
|
||||
)
|
||||
if category_id is not None:
|
||||
# v2.7: materials carry no category — match through their suppliers
|
||||
# (any link, mirroring GET /materials?category_id=…).
|
||||
stmt = stmt.where(
|
||||
exists(
|
||||
select(1)
|
||||
.select_from(SupplierMaterial)
|
||||
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
||||
.where(
|
||||
SupplierMaterial.material_id == InventoryItem.material_id,
|
||||
Supplier.category_id == category_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
if warehouse_id is not None:
|
||||
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
|
||||
if material_id is not None:
|
||||
@@ -368,8 +352,6 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
||||
unit_ids = {i.unit_id for i in items}
|
||||
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
|
||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||
# v2.7: the category shown for a material comes from its first linked supplier.
|
||||
material_category = await category_by_material(db, mat_ids)
|
||||
brands = {}
|
||||
if brand_ids:
|
||||
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
|
||||
@@ -379,7 +361,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
||||
return [
|
||||
inventory_to_dict(
|
||||
i, warehouses[i.warehouse_id], materials[i.material_id],
|
||||
material_category.get(i.material_id), brands.get(i.brand_id), units.get(i.unit_id),
|
||||
None, brands.get(i.brand_id), units.get(i.unit_id),
|
||||
)
|
||||
for i in items
|
||||
]
|
||||
@@ -541,71 +523,3 @@ async def transaction_types():
|
||||
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
|
||||
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT",
|
||||
)]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Material selector — filtered by Category + Warehouse (transaction forms)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/materials/options")
|
||||
async def material_options(
|
||||
category_id: uuid.UUID,
|
||||
warehouse_id: uuid.UUID | None = None,
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Materials for the transaction form.
|
||||
|
||||
Always filtered by category. When `warehouse_id` is given, only materials
|
||||
that already exist in that warehouse's inventory are returned (so the PM
|
||||
works with the actual stock of that warehouse); otherwise the full
|
||||
category list is returned (e.g. first purchase of a new material).
|
||||
"""
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
||||
|
||||
# v2.7: a material has no category column — it belongs to a category when a
|
||||
# linked supplier does (same rule as GET /materials?category_id=…).
|
||||
stmt = select(Material).where(
|
||||
Material.status == "active",
|
||||
exists(
|
||||
select(1)
|
||||
.select_from(SupplierMaterial)
|
||||
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
||||
.where(
|
||||
SupplierMaterial.material_id == Material.id,
|
||||
Supplier.category_id == category_id,
|
||||
)
|
||||
),
|
||||
)
|
||||
if warehouse_id is not None:
|
||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
stmt = stmt.where(
|
||||
Material.id.in_(
|
||||
select(InventoryItem.material_id).where(InventoryItem.warehouse_id == warehouse_id)
|
||||
)
|
||||
)
|
||||
stmt = stmt.order_by(Material.name)
|
||||
materials = (await db.execute(stmt)).scalars().all()
|
||||
|
||||
# Brands per material (from brand_materials catalog + brands actually used in stock)
|
||||
brand_rows = (await db.execute(
|
||||
select(BrandMaterial.material_id, Brand.id, Brand.name)
|
||||
.join(Brand, Brand.id == BrandMaterial.brand_id)
|
||||
.where(BrandMaterial.material_id.in_([m.id for m in materials]), BrandMaterial.is_active.is_(True))
|
||||
)).all()
|
||||
brands_by_material: dict[str, list[dict]] = {}
|
||||
for mat_id, b_id, b_name in brand_rows:
|
||||
brands_by_material.setdefault(str(mat_id), []).append({"id": str(b_id), "name": b_name})
|
||||
|
||||
return {"data": [
|
||||
{
|
||||
"id": str(m.id),
|
||||
"name": m.name,
|
||||
"brands": brands_by_material.get(str(m.id), []),
|
||||
}
|
||||
for m in materials
|
||||
]}
|
||||
|
||||
Reference in New Issue
Block a user