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).
56 lines
1.9 KiB
Python
56 lines
1.9 KiB
Python
"""Supplier ↔ material link service (v2.6/v2.7) — the direct edge of the catalog flow.
|
|
|
|
Suppliers can supply any global material across categories.
|
|
"""
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.catalog import Material
|
|
from app.models.procurement import Supplier, SupplierMaterial
|
|
from app.utils.errors import NotFoundError, ValidationError
|
|
|
|
|
|
async def link_supplier_material(
|
|
db: AsyncSession, supplier_id, material_id, is_active: bool = True
|
|
) -> SupplierMaterial:
|
|
"""Upsert a supplier<->material link (idempotent).
|
|
|
|
Caller is responsible for the surrounding transaction.
|
|
"""
|
|
supplier = await db.get(Supplier, supplier_id)
|
|
if supplier is None:
|
|
raise NotFoundError("تأمینکننده یافت نشد", field="supplier_id")
|
|
material = await db.get(Material, material_id)
|
|
if material is None:
|
|
raise NotFoundError("متریال یافت نشد", field="material_id")
|
|
|
|
link = await db.scalar(
|
|
select(SupplierMaterial).where(
|
|
SupplierMaterial.supplier_id == supplier_id,
|
|
SupplierMaterial.material_id == material_id,
|
|
)
|
|
)
|
|
if link is not None:
|
|
if not link.is_active and is_active:
|
|
link.is_active = True
|
|
return link
|
|
|
|
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
|
|
db.add(link)
|
|
await db.flush()
|
|
return link
|
|
|
|
|
|
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
|
|
"""Remove the link."""
|
|
link = await db.scalar(
|
|
select(SupplierMaterial).where(
|
|
SupplierMaterial.supplier_id == supplier_id,
|
|
SupplierMaterial.material_id == material_id,
|
|
)
|
|
)
|
|
if link is None:
|
|
raise ValidationError("این متریال به این تأمینکننده متصل نیست", field="material_id")
|
|
await db.delete(link)
|
|
await db.flush()
|