feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
"""All API routers, re-exported for main.py assembly."""
|
||||
from app.routers import (
|
||||
attribute_definitions,
|
||||
brand_materials,
|
||||
brands,
|
||||
category_attributes,
|
||||
category_ref_units,
|
||||
files,
|
||||
inventory,
|
||||
item_decisions,
|
||||
item_receipts,
|
||||
item_termsheets,
|
||||
line_item_specs,
|
||||
line_items,
|
||||
material_categories,
|
||||
materials,
|
||||
pm,
|
||||
projects,
|
||||
quote_events,
|
||||
quotes,
|
||||
schedule_steps,
|
||||
standard_conversions,
|
||||
supplier_materials,
|
||||
suppliers,
|
||||
termsheets,
|
||||
unit_conversions,
|
||||
units,
|
||||
users,
|
||||
)
|
||||
|
||||
ROUTERS = [
|
||||
units.router,
|
||||
brands.router,
|
||||
material_categories.router,
|
||||
attribute_definitions.router,
|
||||
category_attributes.router,
|
||||
materials.router,
|
||||
brand_materials.router,
|
||||
suppliers.router,
|
||||
supplier_materials.router,
|
||||
projects.router,
|
||||
quotes.router,
|
||||
line_items.router,
|
||||
line_item_specs.router,
|
||||
termsheets.router,
|
||||
schedule_steps.router,
|
||||
item_termsheets.router,
|
||||
item_decisions.router,
|
||||
item_receipts.router,
|
||||
quote_events.router,
|
||||
files.router,
|
||||
category_ref_units.router,
|
||||
unit_conversions.router,
|
||||
standard_conversions.router,
|
||||
inventory.router,
|
||||
pm.router,
|
||||
users.router,
|
||||
]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Attribute definitions (global attribute dictionary) CRUD — P2 set."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
|
||||
from app.models.procurement import LineItemSpec
|
||||
from app.schemas.catalog import AttributeDefinitionCreate, AttributeDefinitionUpdate
|
||||
from app.services.expand_service import unit_to_dict
|
||||
from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
|
||||
|
||||
|
||||
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
|
||||
d = {
|
||||
"id": str(ad.id),
|
||||
"attribute_key": ad.attribute_key,
|
||||
"label": ad.label,
|
||||
"data_type": ad.data_type,
|
||||
"unit_id": ad.unit_id,
|
||||
"enum_values": ad.enum_values,
|
||||
"created_at": to_iso(ad.created_at),
|
||||
}
|
||||
if unit is not None:
|
||||
d["unit"] = unit_to_dict(unit)
|
||||
return d
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_attribute_definitions(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
q: str | None = None,
|
||||
data_type: str | None = None,
|
||||
unit_id: int | None = None,
|
||||
sort: str | None = None,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(AttributeDefinition)
|
||||
if q:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
AttributeDefinition.attribute_key.ilike(f"%{q}%"),
|
||||
AttributeDefinition.label.ilike(f"%{q}%"),
|
||||
)
|
||||
)
|
||||
if data_type:
|
||||
stmt = stmt.where(AttributeDefinition.data_type == data_type)
|
||||
if unit_id is not None:
|
||||
stmt = stmt.where(AttributeDefinition.unit_id == unit_id)
|
||||
stmt = sort_query(stmt, AttributeDefinition, sort)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
|
||||
units = {}
|
||||
if expand and "unit" in {t.strip() for t in expand.split(",")}:
|
||||
unit_ids = {ad.unit_id for ad in items if ad.unit_id is not None}
|
||||
if unit_ids:
|
||||
rows = (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars().all()
|
||||
units = {u.id: u for u in rows}
|
||||
return {
|
||||
"data": [attr_def_to_dict(ad, units.get(ad.unit_id)) for ad in items],
|
||||
"meta": meta,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)):
|
||||
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
|
||||
if duplicate is not None:
|
||||
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
|
||||
if payload.data_type == "enum" and not payload.enum_values:
|
||||
raise ValidationError("برای data_type=enum باید enum_values مشخص شود", field="enum_values")
|
||||
ad = AttributeDefinition(
|
||||
attribute_key=payload.attribute_key,
|
||||
label=payload.label,
|
||||
data_type=payload.data_type,
|
||||
unit_id=payload.unit_id,
|
||||
enum_values=payload.enum_values,
|
||||
)
|
||||
db.add(ad)
|
||||
await db.flush()
|
||||
return {"data": attr_def_to_dict(ad)}
|
||||
|
||||
|
||||
@router.get("/{definition_id}")
|
||||
async def get_attribute_definition(
|
||||
definition_id, expand: str | None = None, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
ad = await db.get(AttributeDefinition, definition_id)
|
||||
if ad is None:
|
||||
raise NotFoundError("تعریف ویژگی یافت نشد")
|
||||
unit = None
|
||||
if expand and "unit" in {t.strip() for t in expand.split(",")} and ad.unit_id is not None:
|
||||
unit = await db.get(Unit, ad.unit_id)
|
||||
return {"data": attr_def_to_dict(ad, unit)}
|
||||
|
||||
|
||||
@router.patch("/{definition_id}")
|
||||
async def update_attribute_definition(
|
||||
definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
ad = await db.get(AttributeDefinition, definition_id)
|
||||
if ad is None:
|
||||
raise NotFoundError("تعریف ویژگی یافت نشد")
|
||||
|
||||
if payload.data_type is not None and payload.data_type != ad.data_type:
|
||||
# Changing data_type with existing values is a hard block (P2-9).
|
||||
values = await db.scalar(
|
||||
select(func.count()).select_from(MaterialAttributeValue).where(
|
||||
MaterialAttributeValue.attribute_definition_id == definition_id
|
||||
)
|
||||
)
|
||||
specs = await db.scalar(
|
||||
select(func.count()).select_from(LineItemSpec).where(
|
||||
LineItemSpec.attribute_definition_id == definition_id
|
||||
)
|
||||
)
|
||||
if values or specs:
|
||||
raise ConflictError("به دلیل وجود مقادیر ثبتشده، تغییر data_type امکانپذیر نیست", field="data_type")
|
||||
ad.data_type = payload.data_type
|
||||
if payload.data_type == "enum" and not payload.enum_values:
|
||||
raise ValidationError("برای data_type=enum باید enum_values مشخص شود", field="enum_values")
|
||||
if payload.label is not None:
|
||||
ad.label = payload.label
|
||||
if payload.unit_id is not None:
|
||||
ad.unit_id = payload.unit_id
|
||||
if payload.enum_values is not None:
|
||||
ad.enum_values = payload.enum_values
|
||||
await db.flush()
|
||||
return {"data": attr_def_to_dict(ad)}
|
||||
|
||||
|
||||
@router.delete("/{definition_id}")
|
||||
async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)):
|
||||
ad = await db.get(AttributeDefinition, definition_id)
|
||||
if ad is None:
|
||||
raise NotFoundError("تعریف ویژگی یافت نشد")
|
||||
# ON DELETE RESTRICT from material_attribute_values / line_item_specs — block if values exist.
|
||||
values = await db.scalar(
|
||||
select(func.count()).select_from(MaterialAttributeValue).where(
|
||||
MaterialAttributeValue.attribute_definition_id == definition_id
|
||||
)
|
||||
)
|
||||
specs = await db.scalar(
|
||||
select(func.count()).select_from(LineItemSpec).where(
|
||||
LineItemSpec.attribute_definition_id == definition_id
|
||||
)
|
||||
)
|
||||
if values or specs:
|
||||
raise ConflictError("این ویژگی دارای مقادیر ثبتشده است و قابل حذف نیست")
|
||||
await db.delete(ad) # category_attributes cascade via DDL
|
||||
await db.flush()
|
||||
return {"data": {"id": str(definition_id), "deleted": True}}
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Brand-material M:N junction CRUD. (brand_id, material_id) is UNIQUE."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Brand, BrandMaterial, Material
|
||||
from app.models.procurement import LineItem
|
||||
from app.schemas.catalog import BrandMaterialCreate, BrandMaterialUpdate
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
|
||||
|
||||
|
||||
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
|
||||
d = {
|
||||
"id": str(bm.id),
|
||||
"brand_id": str(bm.brand_id),
|
||||
"material_id": str(bm.material_id),
|
||||
"catalog_code": bm.catalog_code,
|
||||
"is_active": bm.is_active,
|
||||
"created_at": to_iso(bm.created_at),
|
||||
}
|
||||
if "brand" in expand and hasattr(bm, "_brand"):
|
||||
d["brand"] = {"id": str(bm._brand.id), "name": bm._brand.name, "country": bm._brand.country}
|
||||
if "material" in expand and hasattr(bm, "_material"):
|
||||
d["material"] = {"id": str(bm._material.id), "name": bm._material.name}
|
||||
return d
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_brand_materials(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
brand_id=None,
|
||||
material_id=None,
|
||||
is_active: bool | None = None,
|
||||
sort: str | None = None,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(BrandMaterial)
|
||||
if brand_id:
|
||||
stmt = stmt.where(BrandMaterial.brand_id == brand_id)
|
||||
if material_id:
|
||||
stmt = stmt.where(BrandMaterial.material_id == material_id)
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(BrandMaterial.is_active == is_active)
|
||||
stmt = sort_query(stmt, BrandMaterial, sort)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
|
||||
tokens = {t.strip() for t in expand.split(",")} if expand else set()
|
||||
if "brand" in tokens or "material" in tokens:
|
||||
brand_ids = {bm.brand_id for bm in items}
|
||||
material_ids = {bm.material_id for bm in items}
|
||||
brands = {}
|
||||
materials = {}
|
||||
if brand_ids:
|
||||
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
|
||||
if material_ids:
|
||||
materials = {
|
||||
m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars()
|
||||
}
|
||||
for bm in items:
|
||||
bm._brand = brands.get(bm.brand_id)
|
||||
bm._material = materials.get(bm.material_id)
|
||||
return {"data": [bm_to_dict(bm, tokens) for bm in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)):
|
||||
if await db.get(Brand, payload.brand_id) is None:
|
||||
raise NotFoundError("برند یافت نشد", field="brand_id")
|
||||
if await db.get(Material, payload.material_id) is None:
|
||||
raise NotFoundError("متریال یافت نشد", field="material_id")
|
||||
duplicate = await db.scalar(
|
||||
select(BrandMaterial.id).where(
|
||||
BrandMaterial.brand_id == payload.brand_id, BrandMaterial.material_id == payload.material_id
|
||||
)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("این ارتباط برند-متریال قبلاً ثبت شده است")
|
||||
bm = BrandMaterial(
|
||||
brand_id=payload.brand_id,
|
||||
material_id=payload.material_id,
|
||||
catalog_code=payload.catalog_code,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
db.add(bm)
|
||||
await db.flush()
|
||||
return {"data": bm_to_dict(bm)}
|
||||
|
||||
|
||||
@router.get("/{bm_id}")
|
||||
async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession = Depends(get_db)):
|
||||
bm = await db.get(BrandMaterial, bm_id)
|
||||
if bm is None:
|
||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||
tokens = {t.strip() for t in expand.split(",")} if expand else set()
|
||||
if "brand" in tokens:
|
||||
bm._brand = await db.get(Brand, bm.brand_id)
|
||||
if "material" in tokens:
|
||||
bm._material = await db.get(Material, bm.material_id)
|
||||
return {"data": bm_to_dict(bm, tokens)}
|
||||
|
||||
|
||||
@router.patch("/{bm_id}")
|
||||
async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)):
|
||||
bm = await db.get(BrandMaterial, bm_id)
|
||||
if bm is None:
|
||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||
if payload.catalog_code is not None:
|
||||
bm.catalog_code = payload.catalog_code
|
||||
if payload.is_active is not None:
|
||||
bm.is_active = payload.is_active
|
||||
await db.flush()
|
||||
return {"data": bm_to_dict(bm)}
|
||||
|
||||
|
||||
@router.delete("/{bm_id}")
|
||||
async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)):
|
||||
bm = await db.get(BrandMaterial, bm_id)
|
||||
if bm is None:
|
||||
raise NotFoundError("ارتباط برند-متریال یافت نشد")
|
||||
# Recommendation (P2-16): block if line items reference it (SET NULL would lose context).
|
||||
referenced = await db.scalar(
|
||||
select(func.count()).select_from(LineItem).where(LineItem.brand_material_id == bm_id)
|
||||
)
|
||||
if referenced:
|
||||
raise ConflictError("این ارتباط توسط خطوط آیتم استفاده شده و قابل حذف نیست")
|
||||
await db.delete(bm)
|
||||
await db.flush()
|
||||
return {"data": {"id": str(bm_id), "deleted": True}}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Brands CRUD. (name, country) is UNIQUE (uq_brands_name_country)."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Brand, BrandMaterial
|
||||
from app.schemas.catalog import BrandCreate, BrandUpdate
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/brands", tags=["brands"])
|
||||
|
||||
|
||||
def brand_to_dict(brand: Brand) -> dict:
|
||||
return {
|
||||
"id": str(brand.id),
|
||||
"name": brand.name,
|
||||
"country": brand.country,
|
||||
"created_at": to_iso(brand.created_at),
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_brands(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
q: str | None = None,
|
||||
country: str | None = None,
|
||||
sort: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Brand)
|
||||
if q:
|
||||
stmt = stmt.where(Brand.name.ilike(f"%{q}%"))
|
||||
if country:
|
||||
stmt = stmt.where(Brand.country == country)
|
||||
stmt = sort_query(stmt, Brand, sort)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
return {"data": [brand_to_dict(b) for b in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
|
||||
if any(b.country == payload.country for b in rows):
|
||||
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
|
||||
brand = Brand(name=payload.name, country=payload.country)
|
||||
db.add(brand)
|
||||
await db.flush()
|
||||
return {"data": brand_to_dict(brand)}
|
||||
|
||||
|
||||
@router.get("/{brand_id}")
|
||||
async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
||||
brand = await db.get(Brand, brand_id)
|
||||
if brand is None:
|
||||
raise NotFoundError("برند یافت نشد")
|
||||
return {"data": brand_to_dict(brand)}
|
||||
|
||||
|
||||
@router.patch("/{brand_id}")
|
||||
async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)):
|
||||
brand = await db.get(Brand, brand_id)
|
||||
if brand is None:
|
||||
raise NotFoundError("برند یافت نشد")
|
||||
new_name = payload.name if payload.name is not None else brand.name
|
||||
new_country = payload.country if payload.country is not None else brand.country
|
||||
rows = (await db.execute(select(Brand).where(Brand.name == new_name, Brand.id != brand_id))).scalars().all()
|
||||
if any(b.country == new_country for b in rows):
|
||||
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
|
||||
if payload.name is not None:
|
||||
brand.name = payload.name
|
||||
if payload.country is not None:
|
||||
brand.country = payload.country
|
||||
await db.flush()
|
||||
return {"data": brand_to_dict(brand)}
|
||||
|
||||
|
||||
@router.delete("/{brand_id}")
|
||||
async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)):
|
||||
brand = await db.get(Brand, brand_id)
|
||||
if brand is None:
|
||||
raise NotFoundError("برند یافت نشد")
|
||||
# Recommendation (b) from P2-4: block instead of silently cascading associations.
|
||||
linked = await db.scalar(
|
||||
select(func.count()).select_from(BrandMaterial).where(BrandMaterial.brand_id == brand_id)
|
||||
)
|
||||
if linked:
|
||||
raise ConflictError(f"این برند با {linked} متریال مرتبط است و قابل حذف نیست")
|
||||
await db.delete(brand)
|
||||
await db.flush()
|
||||
return {"data": {"id": str(brand_id), "deleted": True}}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Category attributes — scoped attribute definitions per category (P2 set).
|
||||
|
||||
Identified by composite key {category_id}/{attribute_definition_id}.
|
||||
The nested `attribute` object is ALWAYS included (not expand-based).
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, CategoryAttribute, MaterialCategory
|
||||
from app.schemas.catalog import CategoryAttributeCreate, CategoryAttributeUpdate
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/material-categories/{category_id}/attributes",
|
||||
tags=["category-attributes"],
|
||||
)
|
||||
|
||||
|
||||
def ca_to_dict(ca: CategoryAttribute, attribute: AttributeDefinition) -> dict:
|
||||
return {
|
||||
"id": str(ca.id),
|
||||
"category_id": str(ca.category_id),
|
||||
"attribute_definition_id": str(ca.attribute_definition_id),
|
||||
"is_required": ca.is_required,
|
||||
"is_filterable": ca.is_filterable,
|
||||
"conditional_rules": ca.conditional_rules,
|
||||
"sort_order": ca.sort_order,
|
||||
"attribute": {
|
||||
"attribute_key": attribute.attribute_key,
|
||||
"label": attribute.label,
|
||||
"data_type": attribute.data_type,
|
||||
"unit_id": attribute.unit_id,
|
||||
"enum_values": attribute.enum_values,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
return category
|
||||
|
||||
|
||||
async def _with_attribute(db: AsyncSession, ca: CategoryAttribute) -> dict:
|
||||
attribute = await db.get(AttributeDefinition, ca.attribute_definition_id)
|
||||
return ca_to_dict(ca, attribute)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_category_attributes(
|
||||
category_id,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _category_or_404(db, category_id)
|
||||
stmt = (
|
||||
select(CategoryAttribute)
|
||||
.where(CategoryAttribute.category_id == category_id)
|
||||
.order_by(CategoryAttribute.sort_order.asc())
|
||||
)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
return {"data": [await _with_attribute(db, ca) for ca in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_category_attribute(
|
||||
category_id, payload: CategoryAttributeCreate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
await _category_or_404(db, category_id)
|
||||
if await db.get(AttributeDefinition, payload.attribute_definition_id) is None:
|
||||
raise NotFoundError("تعریف ویژگی یافت نشد", field="attribute_definition_id")
|
||||
duplicate = await db.scalar(
|
||||
select(CategoryAttribute.id).where(
|
||||
CategoryAttribute.category_id == category_id,
|
||||
CategoryAttribute.attribute_definition_id == payload.attribute_definition_id,
|
||||
)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("این ویژگی قبلاً به این دستهبندی اضافه شده است", field="attribute_definition_id")
|
||||
ca = CategoryAttribute(
|
||||
category_id=category_id,
|
||||
attribute_definition_id=payload.attribute_definition_id,
|
||||
is_required=payload.is_required,
|
||||
is_filterable=payload.is_filterable,
|
||||
conditional_rules=payload.conditional_rules,
|
||||
sort_order=payload.sort_order,
|
||||
)
|
||||
db.add(ca)
|
||||
await db.flush()
|
||||
return {"data": await _with_attribute(db, ca)}
|
||||
|
||||
|
||||
@router.patch("/{attribute_definition_id}")
|
||||
async def update_category_attribute(
|
||||
category_id, attribute_definition_id, payload: CategoryAttributeUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
ca = await db.scalar(
|
||||
select(CategoryAttribute).where(
|
||||
CategoryAttribute.category_id == category_id,
|
||||
CategoryAttribute.attribute_definition_id == attribute_definition_id,
|
||||
)
|
||||
)
|
||||
if ca is None:
|
||||
raise NotFoundError("ویژگی این دستهبندی یافت نشد")
|
||||
if payload.is_required is not None:
|
||||
ca.is_required = payload.is_required
|
||||
if payload.is_filterable is not None:
|
||||
ca.is_filterable = payload.is_filterable
|
||||
if payload.conditional_rules is not None:
|
||||
ca.conditional_rules = payload.conditional_rules
|
||||
if payload.sort_order is not None:
|
||||
ca.sort_order = payload.sort_order
|
||||
await db.flush()
|
||||
return {"data": await _with_attribute(db, ca)}
|
||||
|
||||
|
||||
@router.delete("/{attribute_definition_id}")
|
||||
async def delete_category_attribute(
|
||||
category_id, attribute_definition_id, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
ca = await db.scalar(
|
||||
select(CategoryAttribute).where(
|
||||
CategoryAttribute.category_id == category_id,
|
||||
CategoryAttribute.attribute_definition_id == attribute_definition_id,
|
||||
)
|
||||
)
|
||||
if ca is None:
|
||||
raise NotFoundError("ویژگی این دستهبندی یافت نشد")
|
||||
await db.delete(ca)
|
||||
await db.flush()
|
||||
return {"data": {"deleted": True}}
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Category reference units — units.html dashboard (P2-26/27).
|
||||
|
||||
One reference unit per category (UNIQUE category_id); PUT is an upsert.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import CategoryReferenceUnit, MaterialCategory
|
||||
from app.schemas.catalog import CategoryReferenceUnitPut
|
||||
from app.services.expand_service import category_brief
|
||||
from app.utils.errors import NotFoundError
|
||||
from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/category-reference-units", tags=["category-reference-units"])
|
||||
|
||||
|
||||
def cru_to_dict(cru: CategoryReferenceUnit, expand: set[str] = frozenset()) -> dict:
|
||||
d = {
|
||||
"id": str(cru.id),
|
||||
"category_id": str(cru.category_id),
|
||||
"ref_unit": cru.ref_unit,
|
||||
"updated_at": to_iso(cru.updated_at),
|
||||
}
|
||||
if "category" in expand and hasattr(cru, "_category"):
|
||||
d["category"] = category_brief(cru._category)
|
||||
return d
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_category_reference_units(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
category_id=None,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(CategoryReferenceUnit)
|
||||
if category_id:
|
||||
stmt = stmt.where(CategoryReferenceUnit.category_id == category_id)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
tokens = {t.strip() for t in expand.split(",")} if expand else set()
|
||||
if "category" in tokens:
|
||||
cat_ids = {cru.category_id for cru in items}
|
||||
cats = {}
|
||||
if cat_ids:
|
||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
||||
for cru in items:
|
||||
cru._category = cats.get(cru.category_id)
|
||||
return {"data": [cru_to_dict(cru, tokens) for cru in items], "meta": meta}
|
||||
|
||||
|
||||
@router.put("/{category_id}")
|
||||
async def upsert_category_reference_unit(
|
||||
category_id, payload: CategoryReferenceUnitPut, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
cru = await db.scalar(select(CategoryReferenceUnit).where(CategoryReferenceUnit.category_id == category_id))
|
||||
if cru is None:
|
||||
cru = CategoryReferenceUnit(category_id=category_id, ref_unit=payload.ref_unit)
|
||||
db.add(cru)
|
||||
else:
|
||||
cru.ref_unit = payload.ref_unit
|
||||
await db.flush()
|
||||
return {"data": {"category_id": str(category_id), "ref_unit": cru.ref_unit}}
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Invoice / proforma attachments — multipart upload, list, download, delete.
|
||||
|
||||
Multi-file contract (P1-28..33 extended): a quote may hold several
|
||||
attachments per kind; every POST creates a new one.
|
||||
|
||||
- POST /quotes/{quote_id}/invoice → 201, new attachment metadata
|
||||
- GET /quotes/{quote_id}/invoice → list of attachment metadata
|
||||
- GET /quotes/{quote_id}/invoice/{file_id} → file bytes
|
||||
- DELETE /quotes/{quote_id}/invoice/{file_id} → delete one attachment
|
||||
|
||||
The same four endpoints exist under /proforma. Bytes live in MinIO
|
||||
(PROCUREMENT_MINIO_BUCKET); the `quote_attachments` table holds metadata.
|
||||
Both invoice and proforma uploads are allowed in every quote status (the
|
||||
user can attach the invoice from the edit page at any time).
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Response, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Quote
|
||||
from app.services import file_service
|
||||
from app.utils.errors import NotFoundError, ValidationError
|
||||
|
||||
router = APIRouter(prefix="/quotes", tags=["files"])
|
||||
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
|
||||
}
|
||||
ALLOWED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".pdf"}
|
||||
|
||||
# حداکثر حجم مجاز هر فایل — ۵ مگابایت
|
||||
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
|
||||
|
||||
|
||||
async def _quote_or_404(db: AsyncSession, quote_id: uuid.UUID) -> Quote:
|
||||
quote = await db.get(Quote, quote_id)
|
||||
if quote is None:
|
||||
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
||||
return quote
|
||||
|
||||
|
||||
def _validate_upload(file: UploadFile) -> str:
|
||||
mime = (file.content_type or "").lower()
|
||||
if mime not in ALLOWED_MIME_TYPES:
|
||||
raise ValidationError("فرمت فایل مجاز نیست — فقط تصویر یا PDF", field="file")
|
||||
name = (file.filename or "").lower()
|
||||
if name and not any(name.endswith(ext) for ext in ALLOWED_EXTENSIONS):
|
||||
raise ValidationError("پسوند فایل مجاز نیست", field="file")
|
||||
return mime
|
||||
|
||||
|
||||
async def _attachment_or_404(
|
||||
db: AsyncSession, quote_id: uuid.UUID, attachment_id: uuid.UUID, kind: str
|
||||
) -> file_service.QuoteAttachment:
|
||||
"""Load an attachment and ensure it belongs to the given quote/kind."""
|
||||
attachment = await file_service.get(db, attachment_id)
|
||||
if attachment.quote_id != quote_id or attachment.kind != kind:
|
||||
raise NotFoundError("فایل پیوست یافت نشد")
|
||||
return attachment
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{quote_id}/invoice", status_code=201)
|
||||
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
mime = _validate_upload(file)
|
||||
content = await file.read()
|
||||
if len(content) > MAX_FILE_SIZE_BYTES:
|
||||
raise ValidationError("حجم فایل از حد مجاز (۵ مگابایت) بیشتر است", field="file")
|
||||
stored = await file_service.create(db, quote_id, "invoice", file.filename or "invoice", content, mime)
|
||||
return {"data": stored.to_dict()}
|
||||
|
||||
|
||||
@router.get("/{quote_id}/invoice")
|
||||
async def list_invoices(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachments = await file_service.list_for(db, quote_id, "invoice")
|
||||
return {"data": [a.to_dict() for a in attachments]}
|
||||
|
||||
|
||||
@router.get("/{quote_id}/invoice/{attachment_id}")
|
||||
async def download_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
||||
content = await file_service.read_bytes(attachment)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=attachment.mime_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{attachment.file_name}"',
|
||||
"X-File-Id": str(attachment.id),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{quote_id}/invoice/{attachment_id}")
|
||||
async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "invoice")
|
||||
await file_service.remove(db, attachment)
|
||||
return {"data": {"deleted": True}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proforma — no status restriction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/{quote_id}/proforma", status_code=201)
|
||||
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
mime = _validate_upload(file)
|
||||
content = await file.read()
|
||||
if len(content) > MAX_FILE_SIZE_BYTES:
|
||||
raise ValidationError("حجم فایل از حد مجاز (۵ مگابایت) بیشتر است", field="file")
|
||||
stored = await file_service.create(db, quote_id, "proforma", file.filename or "proforma", content, mime)
|
||||
return {"data": stored.to_dict()}
|
||||
|
||||
|
||||
@router.get("/{quote_id}/proforma")
|
||||
async def list_proformas(quote_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachments = await file_service.list_for(db, quote_id, "proforma")
|
||||
return {"data": [a.to_dict() for a in attachments]}
|
||||
|
||||
|
||||
@router.get("/{quote_id}/proforma/{attachment_id}")
|
||||
async def download_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
||||
content = await file_service.read_bytes(attachment)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=attachment.mime_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{attachment.file_name}"',
|
||||
"X-File-Id": str(attachment.id),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{quote_id}/proforma/{attachment_id}")
|
||||
async def delete_proforma(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
|
||||
await _quote_or_404(db, quote_id)
|
||||
attachment = await _attachment_or_404(db, quote_id, attachment_id, "proforma")
|
||||
await file_service.remove(db, attachment)
|
||||
return {"data": {"deleted": True}}
|
||||
@@ -0,0 +1,547 @@
|
||||
"""Inventory router — PM (project-scoped, writable) / Admin (read-only, all projects).
|
||||
|
||||
Merged from the software team's shipped inventory-dashboard fork.
|
||||
|
||||
Authorization (v2.6):
|
||||
- Primary: Casdoor JWT (Authorization: Bearer) → app.auth.get_current_user →
|
||||
role from users.role, PM's project from projects.pm_user_id.
|
||||
- Dev fallback: X-User-Role / X-Project-Id / X-User-Name-B64 headers
|
||||
(the fork's placeholder mode — only when no Authorization header is sent).
|
||||
"""
|
||||
import base64
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, get_optional_user, require_role
|
||||
from app.config import settings
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Brand, BrandMaterial, Material, MaterialCategory, Unit
|
||||
from app.models.inventory import (
|
||||
InventoryItem,
|
||||
InventoryTransaction,
|
||||
Warehouse,
|
||||
)
|
||||
from app.models.procurement import Project
|
||||
from app.schemas.inventory import (
|
||||
InventoryTransactionCreate,
|
||||
WarehouseCreate,
|
||||
WarehouseUpdate,
|
||||
)
|
||||
from app.services.inventory_service import (
|
||||
apply_transaction,
|
||||
get_warehouse_or_404,
|
||||
)
|
||||
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/inventory", tags=["inventory"])
|
||||
|
||||
ROLE_PM = "pm"
|
||||
ROLE_ADMIN = "admin"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Scope:
|
||||
role: str
|
||||
project_id: uuid.UUID | None
|
||||
actor: str | None
|
||||
|
||||
|
||||
async def inventory_scope(request: Request, db: AsyncSession = Depends(get_db)) -> Scope:
|
||||
"""Resolve the caller role/project — Casdoor JWT first, dev headers as fallback."""
|
||||
# Primary path: Casdoor JWT. Role from users.role; PM project from
|
||||
# projects.pm_user_id (single PM per project).
|
||||
if request.headers.get("authorization"):
|
||||
user: UserContext = await get_optional_user(request, db)
|
||||
if user is not None and user.role is not None:
|
||||
# v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقطخواندنی به پنل انبار.
|
||||
if user.role == "procurement_manager":
|
||||
return Scope(role="admin", project_id=None, actor=user.display_name)
|
||||
if user.role not in ("pm", "admin"):
|
||||
raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
|
||||
if user.role == "pm":
|
||||
if user.project_id is None:
|
||||
raise ValidationError(
|
||||
"کاربر PM به هیچ پروژهای متصل نیست (projects.pm_user_id)", field="project_id"
|
||||
)
|
||||
return Scope(role="pm", project_id=user.project_id, actor=user.display_name)
|
||||
return Scope(role="admin", project_id=None, actor=user.display_name)
|
||||
|
||||
# Dev fallback: header-based identity (fork's placeholder mode).
|
||||
role = (request.headers.get("x-user-role") or settings.INVENTORY_DEFAULT_ROLE or ROLE_PM).strip().lower()
|
||||
if role == "procurement_manager":
|
||||
role = ROLE_ADMIN # مدیر تدارکات = ادمین (فقطخواندنی)
|
||||
if role not in (ROLE_PM, ROLE_ADMIN):
|
||||
raise ValidationError("نقش نامعتبر است — فقط pm یا admin", field="X-User-Role")
|
||||
raw_project = request.headers.get("x-project-id") or settings.INVENTORY_DEFAULT_PROJECT_ID
|
||||
project_id = None
|
||||
if raw_project:
|
||||
try:
|
||||
project_id = uuid.UUID(str(raw_project))
|
||||
except ValueError:
|
||||
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
|
||||
if role == ROLE_PM and project_id is None:
|
||||
raise ValidationError(
|
||||
"برای نقش PM هدر X-Project-Id (پروژه مسئول) الزامی است", field="X-Project-Id"
|
||||
)
|
||||
actor = request.headers.get("x-user-name")
|
||||
b64 = request.headers.get("x-user-name-b64")
|
||||
if b64:
|
||||
try:
|
||||
actor = base64.b64decode(b64.encode()).decode("utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
return Scope(role=role, project_id=project_id, actor=actor)
|
||||
|
||||
|
||||
def _require_pm(scope: Scope) -> None:
|
||||
if scope.role != ROLE_PM:
|
||||
raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization helpers (fork contract — stable shapes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def unit_to_dict(unit: Unit | None) -> dict | None:
|
||||
if unit is None:
|
||||
return None
|
||||
return {"id": unit.id, "code": unit.code, "name_fa": unit.name_fa}
|
||||
|
||||
|
||||
def brand_to_dict(brand: Brand | None) -> dict | None:
|
||||
if brand is None:
|
||||
return None
|
||||
return {"id": str(brand.id), "name": brand.name}
|
||||
|
||||
|
||||
def material_to_dict(material: Material, category: MaterialCategory | None = None) -> dict:
|
||||
return {
|
||||
"id": str(material.id),
|
||||
"name": material.name,
|
||||
"category": {"id": str(category.id), "name": category.name} if category else None,
|
||||
}
|
||||
|
||||
|
||||
def warehouse_to_dict(wh: Warehouse, project: Project | None = None, items_count: int | None = None) -> dict:
|
||||
return {
|
||||
"id": str(wh.id),
|
||||
"project_id": str(wh.project_id),
|
||||
"name": wh.name,
|
||||
"description": wh.description,
|
||||
"created_at": to_iso(wh.created_at),
|
||||
"project": {"id": str(project.id), "name": project.name} if project else None,
|
||||
"items_count": items_count,
|
||||
}
|
||||
|
||||
|
||||
def inventory_to_dict(item: InventoryItem, wh: Warehouse, material: Material,
|
||||
category: MaterialCategory | None, brand: Brand | None, unit: Unit | None) -> dict:
|
||||
return {
|
||||
"id": str(item.id),
|
||||
"warehouse": warehouse_to_dict(wh),
|
||||
"material": material_to_dict(material, category),
|
||||
"brand": brand_to_dict(brand),
|
||||
"quantity": str(item.quantity),
|
||||
"quantity_number": float(item.quantity),
|
||||
"unit": unit_to_dict(unit),
|
||||
"specs": item.specs or {},
|
||||
"updated_at": to_iso(item.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def transaction_to_dict(tx: InventoryTransaction, wh: Warehouse | None = None) -> dict:
|
||||
return {
|
||||
"id": str(tx.id),
|
||||
"inventory_id": str(tx.inventory_id),
|
||||
"warehouse_id": str(tx.warehouse_id),
|
||||
"warehouse": {"id": str(wh.id), "name": wh.name} if wh else None,
|
||||
"transaction_type": tx.transaction_type,
|
||||
"quantity": str(tx.quantity),
|
||||
"quantity_number": float(tx.quantity),
|
||||
"recorded_qty": float(tx.recorded_qty) if tx.recorded_qty is not None else None,
|
||||
"physical_qty": float(tx.physical_qty) if tx.physical_qty is not None else None,
|
||||
"transfer_pair_id": str(tx.transfer_pair_id) if tx.transfer_pair_id else None,
|
||||
"performed_by": tx.performed_by,
|
||||
"transaction_date": to_iso(tx.transaction_date),
|
||||
"note": tx.note,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Warehouses
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/warehouses")
|
||||
async def list_warehouses(
|
||||
project_id: uuid.UUID | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(100, ge=1, le=100),
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Warehouse)
|
||||
if scope.role == ROLE_PM:
|
||||
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope
|
||||
elif project_id is not None:
|
||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||
stmt = stmt.order_by(Warehouse.name)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
project_ids = {w.project_id for w in items}
|
||||
projects = {}
|
||||
if project_ids:
|
||||
projects = {
|
||||
p.id: p for p in (await db.execute(select(Project).where(Project.id.in_(project_ids)))).scalars()
|
||||
}
|
||||
counts = {}
|
||||
if items:
|
||||
rows = (await db.execute(
|
||||
select(InventoryItem.warehouse_id, InventoryItem.id).where(
|
||||
InventoryItem.warehouse_id.in_([w.id for w in items])
|
||||
)
|
||||
)).all()
|
||||
from collections import Counter
|
||||
counts = Counter(r.warehouse_id for r in rows)
|
||||
return {
|
||||
"data": [warehouse_to_dict(w, projects.get(w.project_id), counts.get(w.id, 0)) for w in items],
|
||||
"meta": meta,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/warehouses", status_code=201)
|
||||
async def create_warehouse(
|
||||
payload: WarehouseCreate,
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
# PM: فقط پروژه خودش — Admin: ساختار را مدیریت میکند (هر پروژه)
|
||||
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
|
||||
raise ValidationError("PM فقط میتواند برای پروژه خودش انبار بسازد", field="project_id")
|
||||
if await db.get(Project, payload.project_id) is None:
|
||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
|
||||
db.add(wh)
|
||||
await db.flush()
|
||||
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
|
||||
|
||||
|
||||
@router.patch("/warehouses/{warehouse_id}")
|
||||
async def update_warehouse(
|
||||
warehouse_id,
|
||||
payload: WarehouseUpdate,
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||
if scope.role == ROLE_PM:
|
||||
if scope.project_id != wh.project_id:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
if payload.name is not None:
|
||||
wh.name = payload.name
|
||||
if payload.description is not None:
|
||||
wh.description = payload.description
|
||||
if payload.project_id is not None and str(payload.project_id) != str(wh.project_id):
|
||||
if await db.get(Project, payload.project_id) is None:
|
||||
raise NotFoundError("پروژه مقصد یافت نشد", field="project_id")
|
||||
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
|
||||
raise ForbiddenError("PM فقط میتواند انبار را در پروژه خودش جابهجا کند", field="project_id")
|
||||
wh.project_id = payload.project_id
|
||||
await db.flush()
|
||||
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
|
||||
|
||||
|
||||
@router.delete("/warehouses/{warehouse_id}")
|
||||
async def delete_warehouse(
|
||||
warehouse_id,
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||
if scope.role == ROLE_PM and scope.project_id != wh.project_id:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
# inventory rows cascade via DDL; transactions are RESTRICTed through
|
||||
# inventory cascade — block deletion if any stock history exists.
|
||||
linked = await db.scalar(
|
||||
select(InventoryTransaction.id).where(InventoryTransaction.warehouse_id == warehouse_id).limit(1)
|
||||
)
|
||||
if linked is not None:
|
||||
raise ForbiddenError("این انبار دارای سابقه تراکنش است و قابل حذف نیست")
|
||||
await db.delete(wh)
|
||||
await db.flush()
|
||||
return {"data": {"id": str(warehouse_id), "deleted": True}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inventory (current stock) — filters: project (admin), category, warehouse,
|
||||
# material; quantity sort asc/desc.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@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,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(InventoryItem)
|
||||
if scope.role == ROLE_PM:
|
||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||
Warehouse.project_id == scope.project_id
|
||||
)
|
||||
elif project_id is not None:
|
||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||
Warehouse.project_id == project_id
|
||||
)
|
||||
if category_id is not None:
|
||||
stmt = stmt.join(Material, Material.id == InventoryItem.material_id).where(
|
||||
Material.category_id == category_id
|
||||
)
|
||||
if warehouse_id is not None:
|
||||
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
|
||||
if material_id is not None:
|
||||
stmt = stmt.where(InventoryItem.material_id == material_id)
|
||||
stmt = sort_query(stmt, InventoryItem, sort, default="-updated_at")
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
|
||||
data = await _serialize_inventory_rows(db, items)
|
||||
return {"data": data, "meta": meta}
|
||||
|
||||
|
||||
async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]) -> list[dict]:
|
||||
"""Join-related lookups in batch and serialize inventory rows."""
|
||||
if not items:
|
||||
return []
|
||||
wh_ids = {i.warehouse_id for i in items}
|
||||
mat_ids = {i.material_id for i in items}
|
||||
brand_ids = {i.brand_id for i in items if i.brand_id}
|
||||
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()}
|
||||
categories = {}
|
||||
if materials:
|
||||
cat_ids = {m.category_id for m in materials.values()}
|
||||
categories = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
||||
brands = {}
|
||||
if brand_ids:
|
||||
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
|
||||
units = {}
|
||||
if unit_ids:
|
||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||
return [
|
||||
inventory_to_dict(
|
||||
i, warehouses[i.warehouse_id], materials[i.material_id],
|
||||
categories.get(materials[i.material_id].category_id), brands.get(i.brand_id), units.get(i.unit_id),
|
||||
)
|
||||
for i in items
|
||||
]
|
||||
|
||||
|
||||
@router.get("/transactions")
|
||||
async def list_transactions(
|
||||
project_id: uuid.UUID | None = None,
|
||||
warehouse_id=None,
|
||||
transaction_type: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
sort: str | None = None,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Transaction history with filters (کنترل روزانه / history).
|
||||
|
||||
PM is always scoped to his own project. `date_from`/`date_to` are
|
||||
ISO date strings (YYYY-MM-DD), inclusive on the date part.
|
||||
"""
|
||||
stmt = select(InventoryTransaction).join(
|
||||
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
||||
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||
if scope.role == ROLE_PM:
|
||||
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||
elif project_id is not None:
|
||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||
if warehouse_id is not None:
|
||||
stmt = stmt.where(InventoryTransaction.warehouse_id == warehouse_id)
|
||||
if transaction_type is not None:
|
||||
stmt = stmt.where(InventoryTransaction.transaction_type == transaction_type)
|
||||
if date_from is not None:
|
||||
stmt = stmt.where(InventoryTransaction.transaction_date >= date_from)
|
||||
if date_to is not None:
|
||||
stmt = stmt.where(InventoryTransaction.transaction_date < date_to + " 24:00:00")
|
||||
stmt = sort_query(stmt, InventoryTransaction, sort, default="-transaction_date")
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
|
||||
# Enrich: warehouse name + material/brand/specs/unit for the UI rows.
|
||||
out = []
|
||||
if items:
|
||||
inv_ids = {t.inventory_id for t in items}
|
||||
invs = {i.id: i for i in (await db.execute(
|
||||
select(InventoryItem).where(InventoryItem.id.in_(inv_ids))
|
||||
)).scalars()}
|
||||
mat_ids = {i.material_id for i in invs.values()}
|
||||
mats = {m.id: m for m in (await db.execute(
|
||||
select(Material).where(Material.id.in_(mat_ids))
|
||||
)).scalars()}
|
||||
brand_ids = {i.brand_id for i in invs.values() if i.brand_id}
|
||||
brands = {}
|
||||
if brand_ids:
|
||||
brands = {b.id: b for b in (await db.execute(
|
||||
select(Brand).where(Brand.id.in_(brand_ids))
|
||||
)).scalars()}
|
||||
unit_ids = {i.unit_id for i in invs.values() if i.unit_id}
|
||||
units = {}
|
||||
if unit_ids:
|
||||
units = {u.id: u for u in (await db.execute(
|
||||
select(Unit).where(Unit.id.in_(unit_ids))
|
||||
)).scalars()}
|
||||
whs = {w.id: w for w in (await db.execute(
|
||||
select(Warehouse).where(Warehouse.id.in_([t.warehouse_id for t in items]))
|
||||
)).scalars()}
|
||||
for t in items:
|
||||
inv = invs.get(t.inventory_id)
|
||||
d = transaction_to_dict(t, whs.get(t.warehouse_id))
|
||||
d["material"] = {
|
||||
"id": str(inv.material_id) if inv else None,
|
||||
"name": mats[inv.material_id].name if inv and inv.material_id in mats else None,
|
||||
} if inv else None
|
||||
d["brand"] = {"id": str(inv.brand_id), "name": brands[inv.brand_id].name} if inv and inv.brand_id and inv.brand_id in brands else None
|
||||
d["specs"] = (inv.specs or {}) if inv else {}
|
||||
d["unit"] = unit_to_dict(units.get(inv.unit_id)) if inv and inv.unit_id in units else None
|
||||
out.append(d)
|
||||
return {"data": out, "meta": meta}
|
||||
|
||||
|
||||
@router.get("/{inventory_id}/transactions")
|
||||
async def inventory_history(
|
||||
inventory_id,
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Chronological transaction history of one inventory record (timeline)."""
|
||||
item = await db.get(InventoryItem, inventory_id)
|
||||
if item is None:
|
||||
raise NotFoundError("موجودی یافت نشد")
|
||||
wh = await get_warehouse_or_404(db, item.warehouse_id)
|
||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
||||
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
||||
txs = (await db.execute(
|
||||
select(InventoryTransaction)
|
||||
.where(InventoryTransaction.inventory_id == inventory_id)
|
||||
.order_by(InventoryTransaction.transaction_date, InventoryTransaction.created_at)
|
||||
)).scalars().all()
|
||||
return {"data": [transaction_to_dict(tx, wh) for tx in txs]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transactions — creation (PM only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/transactions", status_code=201)
|
||||
async def create_transaction(
|
||||
payload: InventoryTransactionCreate,
|
||||
scope: Scope = Depends(inventory_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
_require_pm(scope)
|
||||
async with db.begin():
|
||||
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
||||
if wh.project_id != scope.project_id:
|
||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||
if payload.dest_warehouse_id is not None:
|
||||
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
||||
if dest.project_id != scope.project_id:
|
||||
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
||||
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
||||
return {"data": [transaction_to_dict(tx) for tx in txs]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transaction-type helpers for the UI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/meta/types")
|
||||
async def transaction_types():
|
||||
"""The exact 10 supported transaction types (no generic adjustment/other)."""
|
||||
labels = {
|
||||
"PURCHASE": "خرید",
|
||||
"WITHDRAWAL": "برداشت",
|
||||
"RETURN": "برگشت",
|
||||
"TRANSFER_IN": "انتقال (ورودی)",
|
||||
"TRANSFER_OUT": "انتقال (خروجی)",
|
||||
"DAMAGE": "ضایعات",
|
||||
"WASTE": "اتلاف",
|
||||
"LOSS": "مفقودی",
|
||||
"FOUND": "پیدا شده",
|
||||
"STOCK_COUNT": "شمارش موجودی",
|
||||
}
|
||||
return {"data": [{"type": t, "label": labels[t]} for t in (
|
||||
"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")
|
||||
|
||||
stmt = select(Material).where(Material.category_id == category_id, Material.status == "active")
|
||||
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
|
||||
]}
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Item decisions — 1:1 with line items.
|
||||
|
||||
State machine (P1 cross-endpoint rules):
|
||||
pending -> chosen (requires chosen_item_termsheet_id of the SAME line item)
|
||||
pending -> rejected
|
||||
chosen/rejected -> pending/anything-else = 409
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import ItemDecision, ItemTermsheet, LineItem
|
||||
from app.schemas.procurement import DecisionPatch, DecisionPut, DecisionStatusUpdate
|
||||
from app.services.expand_service import decision_full
|
||||
from app.services.quote_service import utcnow
|
||||
from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/decision",
|
||||
tags=["item-decisions"],
|
||||
)
|
||||
|
||||
|
||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||
li = await db.scalar(
|
||||
select(LineItem).where(LineItem.id == line_item_id, LineItem.quote_id == quote_id)
|
||||
)
|
||||
if li is None:
|
||||
raise NotFoundError("خط آیتم یافت نشد")
|
||||
return li
|
||||
|
||||
|
||||
async def _get_or_create_pending(db: AsyncSession, line_item_id) -> ItemDecision:
|
||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
||||
if decision is None:
|
||||
decision = ItemDecision(line_item_id=line_item_id, status="pending")
|
||||
db.add(decision)
|
||||
await db.flush()
|
||||
return decision
|
||||
|
||||
|
||||
async def _validate_chosen_link(db: AsyncSession, line_item_id, item_termsheet_id) -> None:
|
||||
"""chosen_item_termsheet_id must reference an item_termsheets row of the SAME line item."""
|
||||
if item_termsheet_id is None:
|
||||
raise ValidationError("برای وضعیت chosen، chosen_item_termsheet_id الزامی است", field="chosen_item_termsheet_id")
|
||||
it = await db.scalar(
|
||||
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
||||
)
|
||||
if it is None:
|
||||
raise ValidationError("اتصال قیمت باید متعلق به همین خط آیتم باشد", field="chosen_item_termsheet_id")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_decision(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
|
||||
"""Always returns a record — pending is auto-created (P0 contract)."""
|
||||
await _line_item_or_404(db, quote_id, line_item_id)
|
||||
decision = await _get_or_create_pending(db, line_item_id)
|
||||
return {"data": decision_full(decision)}
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def put_decision(
|
||||
quote_id, line_item_id, payload: DecisionPut, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""UPSERT — pending -> chosen | pending -> rejected."""
|
||||
created = False
|
||||
async with db.begin():
|
||||
await _line_item_or_404(db, quote_id, line_item_id)
|
||||
decision = await db.scalar(
|
||||
select(ItemDecision).where(ItemDecision.line_item_id == line_item_id).with_for_update()
|
||||
)
|
||||
if decision is None:
|
||||
decision = ItemDecision(line_item_id=line_item_id, status="pending")
|
||||
db.add(decision)
|
||||
await db.flush()
|
||||
created = True
|
||||
if decision.status != "pending":
|
||||
raise ConflictError(
|
||||
f"بازگشت از وضعیت {decision.status} به {payload.status} مجاز نیست"
|
||||
)
|
||||
if payload.status == "chosen":
|
||||
await _validate_chosen_link(db, line_item_id, payload.chosen_item_termsheet_id)
|
||||
decision.chosen_item_termsheet_id = payload.chosen_item_termsheet_id
|
||||
decision.adjusted_unit_price = payload.adjusted_unit_price
|
||||
decision.adjusted_notes = payload.adjusted_notes
|
||||
else: # rejected
|
||||
decision.chosen_item_termsheet_id = None
|
||||
decision.adjusted_unit_price = None
|
||||
decision.adjusted_notes = payload.adjusted_notes
|
||||
decision.status = payload.status
|
||||
decision.decided_at = utcnow()
|
||||
return {"data": decision_full(decision), "status_code_hint": 201 if created else 200}
|
||||
|
||||
|
||||
@router.patch("/status")
|
||||
async def patch_decision_status(
|
||||
quote_id, line_item_id, payload: DecisionStatusUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Status-only transition; chosen requires chosen_item_termsheet_id already set."""
|
||||
async with db.begin():
|
||||
await _line_item_or_404(db, quote_id, line_item_id)
|
||||
decision = await db.scalar(
|
||||
select(ItemDecision).where(ItemDecision.line_item_id == line_item_id).with_for_update()
|
||||
)
|
||||
if decision is None:
|
||||
raise NotFoundError("تصمیمی برای این خط آیتم ثبت نشده است")
|
||||
if decision.status != "pending":
|
||||
raise ConflictError(
|
||||
f"بازگشت از وضعیت {decision.status} به {payload.status} مجاز نیست"
|
||||
)
|
||||
if payload.status == "chosen" and decision.chosen_item_termsheet_id is None:
|
||||
raise ValidationError(
|
||||
"برای انتخاب، ابتدا باید chosen_item_termsheet_id تنظیم شود", field="chosen_item_termsheet_id"
|
||||
)
|
||||
decision.status = payload.status
|
||||
decision.decided_at = utcnow()
|
||||
return {"data": decision_full(decision)}
|
||||
|
||||
|
||||
@router.patch("")
|
||||
async def patch_decision(
|
||||
quote_id, line_item_id, payload: DecisionPatch, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update chosen link / adjusted fields — status is NOT changeable here."""
|
||||
await _line_item_or_404(db, quote_id, line_item_id)
|
||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
||||
if decision is None:
|
||||
raise NotFoundError("تصمیمی برای این خط آیتم ثبت نشده است")
|
||||
if payload.chosen_item_termsheet_id is not None:
|
||||
await _validate_chosen_link(db, line_item_id, payload.chosen_item_termsheet_id)
|
||||
decision.chosen_item_termsheet_id = payload.chosen_item_termsheet_id
|
||||
if payload.adjusted_unit_price is not None:
|
||||
decision.adjusted_unit_price = payload.adjusted_unit_price
|
||||
if payload.adjusted_notes is not None:
|
||||
decision.adjusted_notes = payload.adjusted_notes
|
||||
await db.flush()
|
||||
return {"data": decision_full(decision)}
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Item receipts — 1:1 with item_decisions. Quote-level arrival uses separate endpoints."""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
|
||||
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
|
||||
from app.services.expand_service import receipt_full
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
|
||||
tags=["item-receipts"],
|
||||
)
|
||||
|
||||
|
||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||
li = await db.scalar(
|
||||
select(LineItem).where(LineItem.id == line_item_id, LineItem.quote_id == quote_id)
|
||||
)
|
||||
if li is None:
|
||||
raise NotFoundError("خط آیتم یافت نشد")
|
||||
return li
|
||||
|
||||
|
||||
async def _decision_or_404(db: AsyncSession, line_item_id) -> ItemDecision:
|
||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
||||
if decision is None:
|
||||
raise NotFoundError("تصمیمی برای این خط آیتم ثبت نشده است")
|
||||
return decision
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
|
||||
await _line_item_or_404(db, quote_id, line_item_id)
|
||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
||||
if decision is None:
|
||||
raise NotFoundError("رسیدی برای این خط آیتم ثبت نشده است")
|
||||
receipt = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
||||
if receipt is None:
|
||||
raise NotFoundError("رسیدی برای این خط آیتم ثبت نشده است")
|
||||
return {"data": receipt_full(receipt)}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)):
|
||||
await _line_item_or_404(db, quote_id, line_item_id)
|
||||
decision = await _decision_or_404(db, line_item_id)
|
||||
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
||||
if existing is not None:
|
||||
raise ConflictError("رسید این خط آیتم قبلاً ثبت شده است")
|
||||
receipt = ItemReceipt(
|
||||
item_decision_id=decision.id,
|
||||
received_at=payload.received_at,
|
||||
received_qty=payload.received_qty,
|
||||
on_time=payload.on_time,
|
||||
quality_note=payload.quality_note,
|
||||
)
|
||||
db.add(receipt)
|
||||
await db.flush()
|
||||
return {"data": receipt_full(receipt)}
|
||||
|
||||
|
||||
@router.patch("")
|
||||
async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)):
|
||||
await _line_item_or_404(db, quote_id, line_item_id)
|
||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
|
||||
if decision is None:
|
||||
raise NotFoundError("تصمیمی برای این خط آیتم ثبت نشده است")
|
||||
receipt = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
|
||||
if receipt is None:
|
||||
raise NotFoundError("رسیدی برای این خط آیتم ثبت نشده است")
|
||||
if payload.received_at is not None:
|
||||
receipt.received_at = payload.received_at
|
||||
if payload.received_qty is not None:
|
||||
receipt.received_qty = payload.received_qty
|
||||
if payload.on_time is not None:
|
||||
receipt.on_time = payload.on_time
|
||||
if payload.quality_note is not None:
|
||||
receipt.quality_note = payload.quality_note
|
||||
await db.flush()
|
||||
return {"data": receipt_full(receipt)}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Item termsheets — pricing links between line items and termsheets.
|
||||
|
||||
total_price is COMPUTED (unit_price × quantity via the VIEW
|
||||
item_termsheets_with_total) and never accepted from the client.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
ItemTermsheet,
|
||||
ItemTermsheetsWithTotal,
|
||||
LineItem,
|
||||
Quote,
|
||||
Termsheet,
|
||||
)
|
||||
from app.schemas.procurement import ItemTermsheetCreate, ItemTermsheetUpdate
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
|
||||
tags=["item-termsheets"],
|
||||
)
|
||||
|
||||
|
||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||
li = await db.scalar(
|
||||
select(LineItem).where(LineItem.id == line_item_id, LineItem.quote_id == quote_id)
|
||||
)
|
||||
if li is None:
|
||||
raise NotFoundError("خط آیتم یافت نشد")
|
||||
return li
|
||||
|
||||
|
||||
def it_to_dict(row: ItemTermsheetsWithTotal | ItemTermsheet, total_price=None) -> dict:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"line_item_id": str(row.line_item_id),
|
||||
"termsheet_id": str(row.termsheet_id),
|
||||
"unit_price": float(row.unit_price),
|
||||
"min_order_qty": float(row.min_order_qty) if row.min_order_qty is not None else None,
|
||||
"total_price": float(total_price) if total_price is not None else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
|
||||
await _line_item_or_404(db, quote_id, line_item_id)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ItemTermsheetsWithTotal).where(ItemTermsheetsWithTotal.line_item_id == line_item_id)
|
||||
)
|
||||
).scalars().all()
|
||||
return {"data": [it_to_dict(r, r.total_price) for r in rows]}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_item_termsheet(
|
||||
quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
async with db.begin():
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
quote = await db.get(Quote, quote_id)
|
||||
if quote is None or quote.status != "offered":
|
||||
raise ConflictError("اتصال قیمت فقط در پیشنهادهای offered قابل افزودن است")
|
||||
|
||||
# termsheet must belong to the SAME quote (contract P0-35)
|
||||
ts = await db.scalar(select(Termsheet).where(Termsheet.id == payload.termsheet_id, Termsheet.quote_id == quote_id))
|
||||
if ts is None:
|
||||
raise NotFoundError("ترمشیت یافت نشد یا متعلق به این پیشنهاد نیست", field="termsheet_id")
|
||||
|
||||
duplicate = await db.scalar(
|
||||
select(ItemTermsheet.id).where(
|
||||
ItemTermsheet.line_item_id == line_item_id, ItemTermsheet.termsheet_id == payload.termsheet_id
|
||||
)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("این خط آیتم قبلاً به این ترمشیت متصل شده است")
|
||||
|
||||
it = ItemTermsheet(
|
||||
line_item_id=line_item_id,
|
||||
termsheet_id=payload.termsheet_id,
|
||||
unit_price=payload.unit_price,
|
||||
min_order_qty=payload.min_order_qty,
|
||||
)
|
||||
db.add(it)
|
||||
await db.flush()
|
||||
total = it.unit_price * li.quantity
|
||||
return {"data": it_to_dict(it, total)}
|
||||
|
||||
|
||||
@router.patch("/{item_termsheet_id}")
|
||||
async def update_item_termsheet(
|
||||
quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
quote = await db.get(Quote, quote_id)
|
||||
if quote is None or quote.status != "offered":
|
||||
raise ConflictError("اتصال قیمت فقط در پیشنهادهای offered قابل ویرایش است")
|
||||
it = await db.scalar(
|
||||
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
||||
)
|
||||
if it is None:
|
||||
raise NotFoundError("اتصال قیمت یافت نشد")
|
||||
if payload.unit_price is not None:
|
||||
it.unit_price = payload.unit_price
|
||||
if payload.min_order_qty is not None:
|
||||
it.min_order_qty = payload.min_order_qty
|
||||
await db.flush()
|
||||
return {"data": it_to_dict(it, it.unit_price * li.quantity)}
|
||||
|
||||
|
||||
@router.delete("/{item_termsheet_id}")
|
||||
async def delete_item_termsheet(
|
||||
quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
it = await db.scalar(
|
||||
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
|
||||
)
|
||||
if it is None:
|
||||
raise NotFoundError("اتصال قیمت یافت نشد")
|
||||
quote = await db.get(Quote, quote_id)
|
||||
if quote is None or quote.status != "offered":
|
||||
raise ConflictError("اتصال قیمت فقط در پیشنهادهای offered قابل حذف است")
|
||||
chosen = await db.scalar(
|
||||
select(ItemDecision.id).where(
|
||||
ItemDecision.chosen_item_termsheet_id == item_termsheet_id, ItemDecision.status == "chosen"
|
||||
).limit(1)
|
||||
)
|
||||
if chosen:
|
||||
raise ConflictError("اتصال قیمت انتخابشده قابل حذف نیست")
|
||||
await db.delete(it)
|
||||
await db.flush()
|
||||
return {"data": {"deleted": True}}
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Line item specs — frozen EAV snapshot, full-replacement PUT, editable only while offered."""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, Unit
|
||||
from app.models.procurement import LineItem, LineItemSpec, Quote
|
||||
from app.schemas.procurement import SpecsPutIn
|
||||
from app.services import material_service
|
||||
from app.services.eav_service import flatten_eav_values
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
|
||||
|
||||
|
||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||
li = await db.scalar(
|
||||
select(LineItem).where(LineItem.id == line_item_id, LineItem.quote_id == quote_id)
|
||||
)
|
||||
if li is None:
|
||||
raise NotFoundError("خط آیتم یافت نشد")
|
||||
return li
|
||||
|
||||
|
||||
async def _flatten(db: AsyncSession, li: LineItem) -> list[dict]:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(LineItemSpec)
|
||||
.where(LineItemSpec.line_item_id == li.id)
|
||||
.order_by(LineItemSpec.attribute_definition_id)
|
||||
)
|
||||
).scalars().all()
|
||||
def_ids = {row.attribute_definition_id for row in rows}
|
||||
defs = {}
|
||||
if def_ids:
|
||||
defs = {
|
||||
d.id: d
|
||||
for d in (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars()
|
||||
}
|
||||
unit_ids = {d.unit_id for d in defs.values() if d.unit_id is not None}
|
||||
unit_ids |= {row.unit_id_at_time for row in rows if row.unit_id_at_time is not None}
|
||||
units = {}
|
||||
if unit_ids:
|
||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||
return flatten_eav_values(rows, defs, units, include_definition_id=True)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
return {"data": await _flatten(db, li)}
|
||||
|
||||
|
||||
@router.put("")
|
||||
async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)):
|
||||
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
|
||||
async with db.begin():
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
quote = await db.get(Quote, quote_id)
|
||||
if quote is None or quote.status != "offered":
|
||||
raise ConflictError("مشخصات فقط در پیشنهادهای offered قابل ویرایش است")
|
||||
|
||||
seen: set = set()
|
||||
for index, spec in enumerate(payload.specs):
|
||||
if spec.attribute_definition_id in seen:
|
||||
raise ConflictError("attribute_definition_id در درخواست تکراری است", field=f"specs[{index}]")
|
||||
seen.add(spec.attribute_definition_id)
|
||||
|
||||
validated = await material_service.validate_attributes(db, payload.specs)
|
||||
existing = (
|
||||
(await db.execute(select(LineItemSpec).where(LineItemSpec.line_item_id == li.id))).scalars().all()
|
||||
)
|
||||
for row in existing:
|
||||
await db.delete(row)
|
||||
await db.flush()
|
||||
for (ad, values), spec in zip(validated, payload.specs):
|
||||
db.add(LineItemSpec(line_item_id=li.id, attribute_definition_id=ad.id, **values, unit_id_at_time=spec.unit_id_at_time))
|
||||
return {"data": await _flatten(db, li)}
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Line items nested under quotes. Editable only while the quote is offered."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material, Unit
|
||||
from app.models.procurement import ItemDecision, LineItem, Quote
|
||||
from app.schemas.procurement import LineItemCreate, LineItemUpdate
|
||||
from app.services.expand_service import parse_expand, serialize_line_items
|
||||
from app.services.material_service import ensure_brand_material_belong
|
||||
from app.services.supplier_material_service import link_supplier_material
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
|
||||
|
||||
|
||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||
quote = await db.get(Quote, quote_id)
|
||||
if quote is None:
|
||||
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
||||
return quote
|
||||
|
||||
|
||||
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
|
||||
li = await db.scalar(
|
||||
select(LineItem).where(LineItem.id == line_item_id, LineItem.quote_id == quote_id)
|
||||
)
|
||||
if li is None:
|
||||
raise NotFoundError("خط آیتم یافت نشد")
|
||||
return li
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_line_items(
|
||||
quote_id,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
sort: str | None = None,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _quote_or_404(db, quote_id)
|
||||
stmt = select(LineItem).where(LineItem.quote_id == quote_id)
|
||||
stmt = sort_query(stmt, LineItem, sort, default="created_at")
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
data = await serialize_line_items(db, items, parse_expand(expand))
|
||||
return {"data": data, "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)):
|
||||
async with db.begin():
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
raise ConflictError("خط آیتم فقط در پیشنهادهای offered قابل افزودن است")
|
||||
|
||||
if await db.get(Material, payload.material_id) is None:
|
||||
raise NotFoundError("متریال یافت نشد", field="material_id")
|
||||
if await db.get(Unit, payload.unit_id) is None:
|
||||
raise NotFoundError("واحد یافت نشد", field="unit_id")
|
||||
await ensure_brand_material_belong(db, payload.brand_material_id, payload.material_id)
|
||||
|
||||
li = LineItem(
|
||||
quote_id=quote.id,
|
||||
material_id=payload.material_id,
|
||||
brand_material_id=payload.brand_material_id,
|
||||
unit_id=payload.unit_id,
|
||||
quantity=payload.quantity,
|
||||
notes=payload.notes,
|
||||
)
|
||||
db.add(li)
|
||||
await db.flush()
|
||||
# v2.5: auto-link the material to the quote's supplier (catalog edge).
|
||||
await link_supplier_material(db, quote.supplier_id, payload.material_id)
|
||||
# auto-create the 1:1 pending decision (contract P0-22)
|
||||
db.add(ItemDecision(line_item_id=li.id, status="pending"))
|
||||
return {"data": (await serialize_line_items(db, [li], set()))[0]}
|
||||
|
||||
|
||||
@router.get("/{line_item_id}")
|
||||
async def get_line_item(
|
||||
quote_id, line_item_id, expand: str | None = None, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
return {"data": (await serialize_line_items(db, [li], parse_expand(expand)))[0]}
|
||||
|
||||
|
||||
@router.patch("/{line_item_id}")
|
||||
async def update_line_item(
|
||||
quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
raise ConflictError("خط آیتم فقط در پیشنهادهای offered قابل ویرایش است")
|
||||
|
||||
new_material_id = payload.material_id if payload.material_id is not None else li.material_id
|
||||
if payload.material_id is not None:
|
||||
if await db.get(Material, payload.material_id) is None:
|
||||
raise NotFoundError("متریال یافت نشد", field="material_id")
|
||||
if payload.unit_id is not None:
|
||||
if await db.get(Unit, payload.unit_id) is None:
|
||||
raise NotFoundError("واحد یافت نشد", field="unit_id")
|
||||
if payload.brand_material_id is not None or payload.material_id is not None:
|
||||
# If only the material changed, re-validate the EXISTING brand link against it.
|
||||
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)
|
||||
|
||||
if payload.material_id is not None:
|
||||
li.material_id = payload.material_id
|
||||
# v2.5: keep the supplier<->material catalog edge in sync.
|
||||
await link_supplier_material(db, quote.supplier_id, payload.material_id)
|
||||
if payload.brand_material_id is not None:
|
||||
li.brand_material_id = payload.brand_material_id
|
||||
if payload.unit_id is not None:
|
||||
li.unit_id = payload.unit_id
|
||||
if payload.quantity is not None:
|
||||
li.quantity = payload.quantity
|
||||
if payload.notes is not None:
|
||||
li.notes = payload.notes
|
||||
await db.flush()
|
||||
return {"data": (await serialize_line_items(db, [li], set()))[0]}
|
||||
|
||||
|
||||
@router.delete("/{line_item_id}")
|
||||
async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
|
||||
li = await _line_item_or_404(db, quote_id, line_item_id)
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
raise ConflictError("خط آیتم فقط در پیشنهادهای offered قابل حذف است")
|
||||
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
|
||||
if decision is not None and decision.status == "chosen":
|
||||
raise ConflictError("خط آیتم انتخابشده قابل حذف نیست")
|
||||
await db.delete(li) # specs / links / decision / receipt cascade via DDL
|
||||
await db.flush()
|
||||
return {"data": {"id": str(line_item_id), "deleted": True}}
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Material categories CRUD. slug is UNIQUE; category_attributes cascade on delete."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material, MaterialCategory
|
||||
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
|
||||
|
||||
|
||||
def category_to_dict(category: MaterialCategory) -> dict:
|
||||
return {
|
||||
"id": str(category.id),
|
||||
"slug": category.slug,
|
||||
"name": category.name,
|
||||
"description": category.description,
|
||||
"is_active": category.is_active,
|
||||
"created_at": to_iso(category.created_at),
|
||||
"updated_at": to_iso(category.updated_at),
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_categories(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
q: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
sort: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(MaterialCategory)
|
||||
if q:
|
||||
stmt = stmt.where(or_(MaterialCategory.name.ilike(f"%{q}%"), MaterialCategory.slug.ilike(f"%{q}%")))
|
||||
if is_active is not None:
|
||||
stmt = stmt.where(MaterialCategory.is_active == is_active)
|
||||
stmt = sort_query(stmt, MaterialCategory, sort)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
return {"data": [category_to_dict(c) for c in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)):
|
||||
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
|
||||
if duplicate is not None:
|
||||
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
|
||||
category = MaterialCategory(
|
||||
slug=payload.slug,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
is_active=payload.is_active,
|
||||
)
|
||||
db.add(category)
|
||||
await db.flush()
|
||||
return {"data": category_to_dict(category)}
|
||||
|
||||
|
||||
@router.get("/{category_id}")
|
||||
async def get_category(category_id, db: AsyncSession = Depends(get_db)):
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
return {"data": category_to_dict(category)}
|
||||
|
||||
|
||||
@router.patch("/{category_id}")
|
||||
async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)):
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
if payload.slug is not None and payload.slug != category.slug:
|
||||
duplicate = await db.scalar(
|
||||
select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug, MaterialCategory.id != category_id)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
|
||||
category.slug = payload.slug
|
||||
if payload.name is not None:
|
||||
category.name = payload.name
|
||||
if payload.description is not None:
|
||||
category.description = payload.description
|
||||
if payload.is_active is not None:
|
||||
category.is_active = payload.is_active
|
||||
await db.flush()
|
||||
return {"data": category_to_dict(category)}
|
||||
|
||||
|
||||
@router.delete("/{category_id}")
|
||||
async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد")
|
||||
# materials.category_id is ON DELETE RESTRICT — block if any material exists.
|
||||
linked = await db.scalar(select(func.count()).select_from(Material).where(Material.category_id == category_id))
|
||||
if linked:
|
||||
raise ConflictError(f"این دستهبندی شامل {linked} متریال است و قابل حذف نیست")
|
||||
await db.delete(category) # category_attributes cascade via DDL
|
||||
await db.flush()
|
||||
return {"data": {"id": str(category_id), "deleted": True}}
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Materials router — CRITICAL: EAV flattening, M:N brands, dynamic attr_ filters.
|
||||
|
||||
GET /materials supports `?attr_{attribute_key}=value` dynamic EAV filters plus
|
||||
`expand=category,brands,canonical_unit,attributes`. Nested sub-resources
|
||||
(material_units, attribute-values) live here too (P1-34 / P2-17..22).
|
||||
"""
|
||||
from datetime import date
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy import exists, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import (
|
||||
AttributeDefinition,
|
||||
BrandMaterial,
|
||||
Material,
|
||||
MaterialAttributeValue,
|
||||
MaterialUnit,
|
||||
Unit,
|
||||
)
|
||||
from app.models.procurement import LineItem
|
||||
from app.schemas.catalog import (
|
||||
MaterialAttributeValuesPut,
|
||||
MaterialCreate,
|
||||
MaterialUnitCreate,
|
||||
MaterialUnitUpdate,
|
||||
MaterialUpdate,
|
||||
)
|
||||
from app.services import material_service
|
||||
from app.services.eav_service import flatten_eav_values
|
||||
from app.services.expand_service import (
|
||||
parse_expand,
|
||||
serialize_material,
|
||||
serialize_materials,
|
||||
unit_to_dict,
|
||||
)
|
||||
from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/materials", tags=["materials"])
|
||||
|
||||
|
||||
def _coerce_filter_value(data_type: str, raw: str):
|
||||
"""Typed coercion for `attr_{key}=value` query filters."""
|
||||
try:
|
||||
if data_type in ("number",):
|
||||
return float(raw)
|
||||
if data_type in ("integer",):
|
||||
return int(raw)
|
||||
if data_type == "boolean":
|
||||
return raw.lower() in ("true", "1")
|
||||
if data_type == "date":
|
||||
return date.fromisoformat(raw)
|
||||
return raw # string / enum
|
||||
except ValueError:
|
||||
raise ValidationError("مقدار فیلتر برای ویژگی نامعتبر است", field=f"attr_{raw}")
|
||||
|
||||
|
||||
async def _apply_attr_filters(db: AsyncSession, stmt, request: Request):
|
||||
"""Apply every `attr_{key}=value` query param as a typed EAV filter."""
|
||||
for key, raw_value in request.query_params.multi_items():
|
||||
if not key.startswith("attr_"):
|
||||
continue
|
||||
attr_key = key[len("attr_"):]
|
||||
definition = await db.scalar(
|
||||
select(AttributeDefinition).where(AttributeDefinition.attribute_key == attr_key)
|
||||
)
|
||||
if definition is None:
|
||||
raise ValidationError(f"ویژگی ناشناخته است: {attr_key}", field=key)
|
||||
value = _coerce_filter_value(definition.data_type, raw_value)
|
||||
column_name = {
|
||||
"number": "value_number",
|
||||
"integer": "value_number",
|
||||
"string": "value_string",
|
||||
"enum": "value_string",
|
||||
"boolean": "value_boolean",
|
||||
"date": "value_date",
|
||||
}[definition.data_type]
|
||||
column = getattr(MaterialAttributeValue, column_name)
|
||||
sub = (
|
||||
select(1)
|
||||
.where(
|
||||
MaterialAttributeValue.material_id == Material.id,
|
||||
MaterialAttributeValue.attribute_definition_id == definition.id,
|
||||
column == value,
|
||||
)
|
||||
)
|
||||
stmt = stmt.where(exists(sub))
|
||||
return stmt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Materials CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("")
|
||||
async def list_materials(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
category_id=None,
|
||||
brand_id=None,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
sort: str | None = None,
|
||||
expand: str | None = None,
|
||||
request: Request = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Material)
|
||||
if category_id:
|
||||
stmt = stmt.where(Material.category_id == category_id)
|
||||
if brand_id:
|
||||
# WHERE EXISTS brand_materials with that brand_id
|
||||
sub = select(1).where(
|
||||
BrandMaterial.material_id == Material.id, BrandMaterial.brand_id == brand_id
|
||||
)
|
||||
stmt = stmt.where(exists(sub))
|
||||
if status:
|
||||
stmt = stmt.where(Material.status == status)
|
||||
if q:
|
||||
stmt = stmt.where(Material.name.ilike(f"%{q}%"))
|
||||
stmt = await _apply_attr_filters(db, stmt, request)
|
||||
stmt = sort_query(stmt, Material, sort)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
data = await serialize_materials(db, items, parse_expand(expand))
|
||||
return {"data": data, "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_material(
|
||||
payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
material = await material_service.create_material(db, payload)
|
||||
return {"data": await serialize_material(db, material, parse_expand(expand))}
|
||||
|
||||
|
||||
@router.get("/{material_id}")
|
||||
async def get_material(material_id, expand: str | None = None, db: AsyncSession = Depends(get_db)):
|
||||
material = await db.get(Material, material_id)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
return {"data": await serialize_material(db, material, parse_expand(expand))}
|
||||
|
||||
|
||||
@router.patch("/{material_id}")
|
||||
async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)):
|
||||
material = await db.get(Material, material_id)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
material = await material_service.update_material(db, material, payload)
|
||||
return {"data": await serialize_material(db, material, parse_expand(None))}
|
||||
|
||||
|
||||
@router.delete("/{material_id}")
|
||||
async def delete_material(material_id, db: AsyncSession = Depends(get_db)):
|
||||
material = await db.get(Material, material_id)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
referenced = await db.scalar(
|
||||
select(1).where(LineItem.material_id == material_id).limit(1)
|
||||
)
|
||||
if referenced:
|
||||
raise ConflictError("این متریال در خطوط آیتم استفاده شده و قابل حذف نیست")
|
||||
await db.delete(material) # attribute_values / brand_materials / material_units cascade via DDL
|
||||
await db.flush()
|
||||
return {"data": {"id": str(material_id), "deleted": True}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Material units (packaging / conversion overrides) — P1-34, P2-17..20
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def material_unit_to_dict(mu: MaterialUnit, expand: set[str] = frozenset()) -> dict:
|
||||
d = {
|
||||
"id": str(mu.id),
|
||||
"material_id": str(mu.material_id),
|
||||
"brand_material_id": str(mu.brand_material_id) if mu.brand_material_id else None,
|
||||
"unit_id": mu.unit_id,
|
||||
"conversion_factor": float(mu.conversion_factor),
|
||||
"is_default": mu.is_default,
|
||||
}
|
||||
if "unit" in expand and hasattr(mu, "_unit"):
|
||||
d["unit"] = unit_to_dict(mu._unit)
|
||||
if "brand_material" in expand and hasattr(mu, "_brand_material"):
|
||||
bm = mu._brand_material
|
||||
d["brand_material"] = {
|
||||
"id": str(bm.id),
|
||||
"brand_id": str(bm.brand_id),
|
||||
"material_id": str(bm.material_id),
|
||||
"catalog_code": bm.catalog_code,
|
||||
}
|
||||
return d
|
||||
|
||||
|
||||
async def _material_or_404(db: AsyncSession, material_id) -> Material:
|
||||
material = await db.get(Material, material_id)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
return material
|
||||
|
||||
|
||||
def _bm_cond(brand_material_id):
|
||||
"""PG «IS» فقط با NULL معتبر است؛ برای مقداردار از «==» استفاده میشود."""
|
||||
if brand_material_id is None:
|
||||
return MaterialUnit.brand_material_id.is_(None)
|
||||
return MaterialUnit.brand_material_id == brand_material_id
|
||||
|
||||
|
||||
async def _set_default_unit(db: AsyncSession, material_id, brand_material_id, exclude_id) -> None:
|
||||
"""If is_default=True: demote all other units in the same group."""
|
||||
await db.execute(
|
||||
MaterialUnit.__table__.update()
|
||||
.where(
|
||||
MaterialUnit.material_id == material_id,
|
||||
_bm_cond(brand_material_id),
|
||||
MaterialUnit.id != exclude_id,
|
||||
)
|
||||
.values(is_default=False)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{material_id}/units")
|
||||
async def list_material_units(
|
||||
material_id,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
brand_material_id=None,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _material_or_404(db, material_id)
|
||||
stmt = select(MaterialUnit).where(MaterialUnit.material_id == material_id)
|
||||
if brand_material_id:
|
||||
stmt = stmt.where(MaterialUnit.brand_material_id == brand_material_id)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
tokens = parse_expand(expand)
|
||||
if "unit" in tokens:
|
||||
unit_ids = {mu.unit_id for mu in items}
|
||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||
for mu in items:
|
||||
mu._unit = units.get(mu.unit_id)
|
||||
if "brand_material" in tokens:
|
||||
bm_ids = {mu.brand_material_id for mu in items if mu.brand_material_id}
|
||||
bms = {}
|
||||
if bm_ids:
|
||||
bms = {b.id: b for b in (await db.execute(select(BrandMaterial).where(BrandMaterial.id.in_(bm_ids)))).scalars()}
|
||||
for mu in items:
|
||||
mu._brand_material = bms.get(mu.brand_material_id)
|
||||
return {"data": [material_unit_to_dict(mu, tokens) for mu in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("/{material_id}/units", status_code=201)
|
||||
async def create_material_unit(
|
||||
material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
async with db.begin():
|
||||
await _material_or_404(db, material_id)
|
||||
if await db.get(Unit, payload.unit_id) is None:
|
||||
raise NotFoundError("واحد یافت نشد", field="unit_id")
|
||||
if payload.brand_material_id is not None:
|
||||
bm = await db.get(BrandMaterial, payload.brand_material_id)
|
||||
if bm is None:
|
||||
raise NotFoundError("برند-متریال یافت نشد", field="brand_material_id")
|
||||
if bm.material_id != material_id:
|
||||
raise ConflictError("brand_material_id متعلق به این متریال نیست", field="brand_material_id")
|
||||
|
||||
# App-level uniqueness (PG treats NULL brand_material_id as distinct)
|
||||
duplicate = await db.scalar(
|
||||
select(MaterialUnit.id).where(
|
||||
MaterialUnit.material_id == material_id,
|
||||
MaterialUnit.unit_id == payload.unit_id,
|
||||
_bm_cond(payload.brand_material_id),
|
||||
)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("این واحد قبلاً برای این متریال ثبت شده است")
|
||||
if payload.is_default:
|
||||
await _set_default_unit(db, material_id, payload.brand_material_id, exclude_id=None)
|
||||
mu = MaterialUnit(
|
||||
material_id=material_id,
|
||||
brand_material_id=payload.brand_material_id,
|
||||
unit_id=payload.unit_id,
|
||||
conversion_factor=payload.conversion_factor,
|
||||
is_default=payload.is_default,
|
||||
)
|
||||
db.add(mu)
|
||||
await db.flush()
|
||||
return {"data": material_unit_to_dict(mu)}
|
||||
|
||||
|
||||
@router.patch("/{material_id}/units/{mu_id}")
|
||||
async def update_material_unit(
|
||||
material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
async with db.begin():
|
||||
mu = await db.scalar(
|
||||
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
|
||||
)
|
||||
if mu is None:
|
||||
raise NotFoundError("واحد متریال یافت نشد")
|
||||
if payload.unit_id is not None and payload.unit_id != mu.unit_id:
|
||||
if await db.get(Unit, payload.unit_id) is None:
|
||||
raise NotFoundError("واحد یافت نشد", field="unit_id")
|
||||
duplicate = await db.scalar(
|
||||
select(MaterialUnit.id).where(
|
||||
MaterialUnit.material_id == material_id,
|
||||
MaterialUnit.unit_id == payload.unit_id,
|
||||
_bm_cond(mu.brand_material_id),
|
||||
MaterialUnit.id != mu.id,
|
||||
)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("این واحد قبلاً برای این متریال ثبت شده است")
|
||||
mu.unit_id = payload.unit_id
|
||||
if payload.conversion_factor is not None:
|
||||
mu.conversion_factor = payload.conversion_factor
|
||||
if payload.is_default is not None:
|
||||
if payload.is_default:
|
||||
await _set_default_unit(db, material_id, mu.brand_material_id, exclude_id=mu.id)
|
||||
mu.is_default = payload.is_default
|
||||
return {"data": material_unit_to_dict(mu)}
|
||||
|
||||
|
||||
@router.delete("/{material_id}/units/{mu_id}")
|
||||
async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)):
|
||||
mu = await db.scalar(
|
||||
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
|
||||
)
|
||||
if mu is None:
|
||||
raise NotFoundError("واحد متریال یافت نشد")
|
||||
await db.delete(mu)
|
||||
await db.flush()
|
||||
return {"data": {"deleted": True}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Material attribute values (EAV) — P2-21/22
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{material_id}/attribute-values")
|
||||
async def get_material_attribute_values(
|
||||
material_id,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _material_or_404(db, material_id)
|
||||
stmt = (
|
||||
select(MaterialAttributeValue)
|
||||
.where(MaterialAttributeValue.material_id == material_id)
|
||||
.order_by(MaterialAttributeValue.attribute_definition_id)
|
||||
)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
def_ids = {row.attribute_definition_id for row in items}
|
||||
defs = {}
|
||||
if def_ids:
|
||||
defs = {
|
||||
d.id: d
|
||||
for d in (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars()
|
||||
}
|
||||
unit_ids = {d.unit_id for d in defs.values() if d.unit_id is not None}
|
||||
units = {}
|
||||
if unit_ids:
|
||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||
data = flatten_eav_values(items, defs, units, prefer_unit_id_at_time=False, include_definition_id=True)
|
||||
return {"data": data, "meta": meta}
|
||||
|
||||
|
||||
@router.put("/{material_id}/attribute-values")
|
||||
async def put_material_attribute_values(
|
||||
material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""FULL replacement of a material's EAV values (empty array deletes all)."""
|
||||
async with db.begin():
|
||||
await _material_or_404(db, material_id)
|
||||
validated = await material_service.validate_attributes(db, payload.attributes)
|
||||
existing = (
|
||||
(await db.execute(select(MaterialAttributeValue).where(MaterialAttributeValue.material_id == material_id))).scalars().all()
|
||||
)
|
||||
for row in existing:
|
||||
await db.delete(row)
|
||||
await db.flush()
|
||||
for ad, values in validated:
|
||||
db.add(MaterialAttributeValue(material_id=material_id, attribute_definition_id=ad.id, **values))
|
||||
return {"data": {"updated": len(validated)}}
|
||||
@@ -0,0 +1,432 @@
|
||||
"""PM arrival flow (v2.6) — on-the-way list + confirm arrival + warehouse split.
|
||||
|
||||
Flow: proc manager purchases (item_decisions.status='chosen' + project_id)
|
||||
→ item appears on the PM dash as on-the-way → PM confirms each delivery
|
||||
(POST /pm/receipts) and splits it across warehouses (free count, typically
|
||||
3) → inventory posts PURCHASE transactions (idempotent via
|
||||
receipt_allocation_id UNIQUE) → the procurement dashboard flips to arrived
|
||||
once every chosen item is fully received.
|
||||
|
||||
Rules enforced here:
|
||||
- Only the PM of the item's purchased project can confirm (role 'pm' +
|
||||
projects.pm_user_id).
|
||||
- Partial deliveries allowed (1 item : N receipts); three derived states:
|
||||
on-the-way (sum=0) / partially received / arrived (sum >= ordered).
|
||||
- SUM(allocations) == received_qty; every warehouse belongs to the
|
||||
decision's project; quantity converts to the inventory row's unit via the
|
||||
material's canonical unit (material_units) — inventory rows are stored in
|
||||
the canonical unit for auto-posted purchases.
|
||||
- quote.status flips to 'arrived' when ALL chosen items are fully received.
|
||||
|
||||
Auth: Casdoor JWT primary; dev fallback headers (X-User-Role: pm +
|
||||
X-Project-Id) when no Authorization header — same pattern as the inventory
|
||||
router.
|
||||
"""
|
||||
import base64
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, get_optional_user
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit
|
||||
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
ItemReceipt,
|
||||
ItemReceiptAllocation,
|
||||
LineItem,
|
||||
Project,
|
||||
Quote,
|
||||
)
|
||||
from app.schemas.procurement import PMReceiptCreate
|
||||
from app.services.inventory_service import canonical_specs_hash
|
||||
from app.utils.errors import ForbiddenError, NotFoundError, ValidationError
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/pm", tags=["pm"])
|
||||
|
||||
NULL_BRAND_SENTINEL = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PMScope:
|
||||
user_id: uuid.UUID | None
|
||||
project_id: uuid.UUID | None
|
||||
display_name: str
|
||||
|
||||
|
||||
async def pm_scope(request: Request, db: AsyncSession = Depends(get_db)) -> PMScope:
|
||||
"""Resolve the PM caller — Casdoor JWT first, dev headers as fallback."""
|
||||
if request.headers.get("authorization"):
|
||||
ctx: UserContext | None = await get_optional_user(request, db)
|
||||
if ctx is not None:
|
||||
if ctx.role != "pm":
|
||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه است")
|
||||
if ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
return PMScope(user_id=ctx.user.id, project_id=ctx.project_id, display_name=ctx.display_name)
|
||||
# Dev fallback: X-User-Role: pm + X-Project-Id
|
||||
role = (request.headers.get("x-user-role") or "").strip().lower()
|
||||
if role != "pm":
|
||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه است — هدر X-User-Role: pm را بفرستید")
|
||||
raw_project = request.headers.get("x-project-id")
|
||||
if not raw_project:
|
||||
raise ForbiddenError("هدر X-Project-Id (پروژه مسئول) الزامی است")
|
||||
try:
|
||||
project_id = uuid.UUID(str(raw_project))
|
||||
except ValueError:
|
||||
raise ValidationError("شناسه پروژه نامعتبر است", field="X-Project-Id")
|
||||
actor = request.headers.get("x-user-name")
|
||||
b64 = request.headers.get("x-user-name-b64")
|
||||
if b64:
|
||||
try:
|
||||
actor = base64.b64decode(b64.encode()).decode("utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
# Dev fallback needs a real users row (item_receipts.confirmed_by FK).
|
||||
from app.models.procurement import User
|
||||
dev = await db.scalar(select(User).where(User.casdoor_id == "dev"))
|
||||
if dev is None:
|
||||
dev = User(casdoor_id="dev", first_name="کاربر", last_name="آزمایشی", role="pm")
|
||||
db.add(dev)
|
||||
await db.flush()
|
||||
return PMScope(user_id=dev.id, project_id=project_id, display_name=actor or "کاربر آزمایشی")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derived arrival state helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _receipted_quantities(db: AsyncSession, decision_ids: list) -> dict:
|
||||
"""decision_id -> sum of confirmed received_qty (in line-item unit)."""
|
||||
if not decision_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(ItemReceipt.item_decision_id, func.sum(ItemReceipt.received_qty))
|
||||
.where(ItemReceipt.item_decision_id.in_(decision_ids))
|
||||
.group_by(ItemReceipt.item_decision_id)
|
||||
)
|
||||
).all()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
|
||||
def arrival_state(ordered: Decimal, received: Decimal | None) -> str:
|
||||
received = received or Decimal("0")
|
||||
if received <= 0:
|
||||
return "on_the_way"
|
||||
if received < ordered:
|
||||
return "partially_received"
|
||||
return "arrived"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /pm/on-the-way — chosen items not fully received (PM's project)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/on-the-way")
|
||||
async def pm_on_the_way(
|
||||
ctx: PMScope = Depends(pm_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
decisions = (
|
||||
(
|
||||
await db.execute(
|
||||
select(ItemDecision, LineItem, Quote, Material)
|
||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||
.join(Quote, Quote.id == LineItem.quote_id)
|
||||
.join(Material, Material.id == LineItem.material_id)
|
||||
.where(
|
||||
ItemDecision.status == "chosen",
|
||||
ItemDecision.project_id == ctx.project_id,
|
||||
)
|
||||
.order_by(ItemDecision.decided_at.desc().nulls_last())
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
received_map = await _receipted_quantities(db, [d.ItemDecision.id for d in decisions])
|
||||
|
||||
# unit / category lookups for display
|
||||
unit_ids = {d.LineItem.unit_id for d in decisions}
|
||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||
mat_ids = {d.Material.id for d in decisions}
|
||||
cats = {}
|
||||
if mat_ids:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Material.id, MaterialCategory)
|
||||
.join(MaterialCategory, MaterialCategory.id == Material.category_id)
|
||||
.where(Material.id.in_(mat_ids))
|
||||
)
|
||||
).all()
|
||||
cats = {m_id: c for m_id, c in rows}
|
||||
|
||||
out = []
|
||||
for d, li, q, m in decisions:
|
||||
received = received_map.get(d.id)
|
||||
out.append({
|
||||
"item_decision_id": str(d.id),
|
||||
"line_item_id": str(li.id),
|
||||
"quote_id": str(q.id),
|
||||
"quote_status": q.status,
|
||||
"material": {"id": str(m.id), "name": m.name,
|
||||
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
|
||||
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
|
||||
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
|
||||
"ordered_qty": str(li.quantity),
|
||||
"received_qty": str(received) if received is not None else "0",
|
||||
"state": arrival_state(li.quantity, received),
|
||||
})
|
||||
return {"data": out}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /pm/receipts — confirm arrival + split into warehouses + post PURCHASE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/receipts", status_code=201)
|
||||
async def pm_confirm_receipt(
|
||||
payload: PMReceiptCreate,
|
||||
ctx: PMScope = Depends(pm_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
|
||||
async with db.begin():
|
||||
# --- decision belongs to the PM's project ---
|
||||
decision = await db.get(ItemDecision, payload.item_decision_id)
|
||||
if decision is None or decision.status != "chosen":
|
||||
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
||||
if decision.project_id != ctx.project_id:
|
||||
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
|
||||
|
||||
li = await db.get(LineItem, decision.line_item_id)
|
||||
quote = await db.get(Quote, li.quote_id)
|
||||
material = await db.get(Material, li.material_id)
|
||||
|
||||
# --- allocations sum + warehouse ownership ---
|
||||
total_alloc = sum((a.quantity for a in payload.allocations), Decimal("0"))
|
||||
if total_alloc != payload.received_qty:
|
||||
raise ValidationError(
|
||||
f"مجموع تخصیص انبارها ({total_alloc}) باید با مقدار رسید ({payload.received_qty}) برابر باشد",
|
||||
field="allocations",
|
||||
)
|
||||
wh_ids = {a.warehouse_id for a in payload.allocations}
|
||||
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
|
||||
for a in payload.allocations:
|
||||
wh = warehouses.get(a.warehouse_id)
|
||||
if wh is None:
|
||||
raise NotFoundError("انبار یافت نشد", field="allocations.warehouse_id")
|
||||
if wh.project_id != ctx.project_id:
|
||||
raise ForbiddenError("انبار انتخابشده متعلق به پروژه شما نیست", field="allocations.warehouse_id")
|
||||
|
||||
# --- create the receipt (1:N partial deliveries allowed) ---
|
||||
receipt = ItemReceipt(
|
||||
item_decision_id=decision.id,
|
||||
confirmed_by=ctx.user_id,
|
||||
received_at=payload.received_at,
|
||||
received_qty=payload.received_qty,
|
||||
on_time=payload.on_time,
|
||||
quality_note=payload.quality_note,
|
||||
)
|
||||
db.add(receipt)
|
||||
await db.flush()
|
||||
|
||||
# --- unit conversion: line-item unit -> material canonical unit ---
|
||||
canonical_unit_id = material.canonical_unit_id
|
||||
factor = Decimal("1")
|
||||
if li.unit_id != canonical_unit_id:
|
||||
from app.models.catalog import MaterialUnit
|
||||
mu = await db.scalar(
|
||||
select(MaterialUnit).where(
|
||||
MaterialUnit.material_id == material.id,
|
||||
MaterialUnit.unit_id == li.unit_id,
|
||||
MaterialUnit.brand_material_id.is_(None),
|
||||
)
|
||||
)
|
||||
if mu is None:
|
||||
raise ValidationError(
|
||||
"واحد قلم به واحد مرجع متریال قابل تبدیل نیست — ابتدا material_units تعریف کنید",
|
||||
field="allocations.quantity",
|
||||
)
|
||||
factor = mu.conversion_factor
|
||||
|
||||
# --- brand + specs for the inventory row ---
|
||||
brand_id = None
|
||||
if li.brand_material_id:
|
||||
bm = await db.get(BrandMaterial, li.brand_material_id)
|
||||
brand_id = bm.brand_id if bm else None
|
||||
specs = {}
|
||||
from app.models.catalog import MaterialAttributeValue
|
||||
mavs = (
|
||||
await db.execute(
|
||||
select(MaterialAttributeValue).where(MaterialAttributeValue.material_id == material.id)
|
||||
)
|
||||
).scalars().all()
|
||||
for mav in mavs:
|
||||
from app.models.catalog import AttributeDefinition
|
||||
ad = await db.get(AttributeDefinition, mav.attribute_definition_id)
|
||||
if ad:
|
||||
key = ad.attribute_key
|
||||
if mav.value_string is not None:
|
||||
specs[key] = mav.value_string
|
||||
elif mav.value_number is not None:
|
||||
specs[key] = str(mav.value_number)
|
||||
elif mav.value_boolean is not None:
|
||||
specs[key] = mav.value_boolean
|
||||
elif mav.value_date is not None:
|
||||
specs[key] = mav.value_date.isoformat()
|
||||
specs_hash = canonical_specs_hash(specs)
|
||||
|
||||
# --- post one PURCHASE transaction per allocation (idempotent) ---
|
||||
posted = []
|
||||
for a in payload.allocations:
|
||||
qty_canonical = a.quantity * factor
|
||||
inv = await db.scalar(
|
||||
select(InventoryItem).where(
|
||||
InventoryItem.warehouse_id == a.warehouse_id,
|
||||
InventoryItem.material_id == material.id,
|
||||
InventoryItem.brand_id.is_(None) if brand_id is None else InventoryItem.brand_id == brand_id,
|
||||
InventoryItem.specs_hash == specs_hash,
|
||||
).with_for_update()
|
||||
)
|
||||
if inv is None:
|
||||
inv = InventoryItem(
|
||||
warehouse_id=a.warehouse_id,
|
||||
material_id=material.id,
|
||||
brand_id=brand_id,
|
||||
quantity=Decimal("0"),
|
||||
unit_id=canonical_unit_id,
|
||||
specs=specs,
|
||||
specs_hash=specs_hash,
|
||||
)
|
||||
db.add(inv)
|
||||
await db.flush()
|
||||
else:
|
||||
if inv.unit_id != canonical_unit_id:
|
||||
raise ValidationError(
|
||||
f"واحد موجودی این ردیف ({inv.unit_id}) با واحد مرجع متریال متفاوت است",
|
||||
field="allocations.quantity",
|
||||
)
|
||||
inv.quantity += qty_canonical
|
||||
|
||||
tx = InventoryTransaction(
|
||||
inventory_id=inv.id,
|
||||
warehouse_id=a.warehouse_id,
|
||||
receipt_allocation_id=None, # set after allocation flush below
|
||||
transaction_type="PURCHASE",
|
||||
quantity=qty_canonical,
|
||||
performed_by=ctx.display_name,
|
||||
note=f"رسید {receipt.id}",
|
||||
)
|
||||
db.add(tx)
|
||||
await db.flush()
|
||||
posted.append(tx)
|
||||
|
||||
# --- link the transactions to the allocations (idempotency key) ---
|
||||
for a, tx in zip(payload.allocations, posted):
|
||||
alloc = ItemReceiptAllocation(
|
||||
receipt_id=receipt.id,
|
||||
warehouse_id=a.warehouse_id,
|
||||
quantity=a.quantity,
|
||||
)
|
||||
db.add(alloc)
|
||||
await db.flush()
|
||||
tx.receipt_allocation_id = alloc.id
|
||||
|
||||
# --- auto-flip the quote to arrived when all chosen items are in ---
|
||||
chosen_ids = (
|
||||
(
|
||||
await db.execute(
|
||||
select(ItemDecision.id).where(
|
||||
ItemDecision.line_item_id.in_(
|
||||
select(LineItem.id).where(LineItem.quote_id == quote.id)
|
||||
),
|
||||
ItemDecision.status == "chosen",
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
received_map = await _receipted_quantities(db, chosen_ids)
|
||||
all_arrived = True
|
||||
for d_id in chosen_ids:
|
||||
d = await db.get(ItemDecision, d_id)
|
||||
li_d = await db.get(LineItem, d.line_item_id)
|
||||
if arrival_state(li_d.quantity, received_map.get(d_id)) != "arrived":
|
||||
all_arrived = False
|
||||
break
|
||||
if all_arrived and quote.status not in ("arrived", "rejected", "expired"):
|
||||
quote.status = "arrived"
|
||||
quote.arrived_date = payload.received_at
|
||||
|
||||
return {"data": {
|
||||
"receipt_id": str(receipt.id),
|
||||
"state": "arrived",
|
||||
"posted_transactions": [str(tx.id) for tx in posted],
|
||||
}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /pm/receipts — delivery history for the PM's project
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/receipts")
|
||||
async def pm_receipts(
|
||||
ctx: PMScope = Depends(pm_scope),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if ctx.project_id is None:
|
||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||
rows = (
|
||||
(
|
||||
await db.execute(
|
||||
select(ItemReceipt, ItemDecision, LineItem, Material)
|
||||
.join(ItemDecision, ItemDecision.id == ItemReceipt.item_decision_id)
|
||||
.join(LineItem, LineItem.id == ItemDecision.line_item_id)
|
||||
.join(Material, Material.id == LineItem.material_id)
|
||||
.where(ItemDecision.project_id == ctx.project_id)
|
||||
.order_by(ItemReceipt.received_at.desc())
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
receipts = [r.ItemReceipt for r in rows]
|
||||
alloc_rows = (
|
||||
(
|
||||
await db.execute(
|
||||
select(ItemReceiptAllocation, Warehouse)
|
||||
.join(Warehouse, Warehouse.id == ItemReceiptAllocation.warehouse_id)
|
||||
.where(ItemReceiptAllocation.receipt_id.in_([r.id for r in receipts]) if receipts else False)
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
allocs_by_receipt: dict = {}
|
||||
for alloc, wh in alloc_rows:
|
||||
allocs_by_receipt.setdefault(str(alloc.receipt_id), []).append(
|
||||
{"warehouse_id": str(wh.id), "warehouse_name": wh.name, "quantity": str(alloc.quantity)}
|
||||
)
|
||||
return {"data": [
|
||||
{
|
||||
"receipt_id": str(r.ItemReceipt.id),
|
||||
"line_item_id": str(r.LineItem.id),
|
||||
"material": {"id": str(r.Material.id), "name": r.Material.name},
|
||||
"received_at": to_iso(r.ItemReceipt.received_at),
|
||||
"received_qty": str(r.ItemReceipt.received_qty),
|
||||
"on_time": r.ItemReceipt.on_time,
|
||||
"quality_note": r.ItemReceipt.quality_note,
|
||||
"allocations": allocs_by_receipt.get(str(r.ItemReceipt.id), []),
|
||||
}
|
||||
for r in rows
|
||||
]}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Projects CRUD + per-project stats."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Project, Quote, User
|
||||
from app.schemas.procurement import ProjectCreate, ProjectUpdate
|
||||
from app.services.quote_service import project_stats
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_date, to_iso
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
async def _pm_map(db: AsyncSession, projects: list[Project]) -> dict:
|
||||
"""project_id -> pm brief (v2.6: the single PM of the project)."""
|
||||
pm_ids = {p.pm_user_id for p in projects if p.pm_user_id}
|
||||
if not pm_ids:
|
||||
return {}
|
||||
users = (await db.execute(select(User).where(User.id.in_(pm_ids)))).scalars().all()
|
||||
return {u.id: u for u in users}
|
||||
|
||||
|
||||
async def project_to_dict(db: AsyncSession, project: Project) -> dict:
|
||||
d = {
|
||||
"id": str(project.id),
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"start_date": to_date(project.start_date),
|
||||
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
|
||||
"created_at": to_iso(project.created_at),
|
||||
}
|
||||
if project.pm_user_id:
|
||||
pm = await db.get(User, project.pm_user_id)
|
||||
if pm:
|
||||
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
||||
return d
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_projects(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
q: str | None = None,
|
||||
sort: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Project)
|
||||
if q:
|
||||
stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%")))
|
||||
stmt = sort_query(stmt, Project, sort)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
pm_map = await _pm_map(db, items)
|
||||
data = []
|
||||
for p in items:
|
||||
d = {
|
||||
"id": str(p.id),
|
||||
"name": p.name,
|
||||
"description": p.description,
|
||||
"start_date": to_date(p.start_date),
|
||||
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
|
||||
"created_at": to_iso(p.created_at),
|
||||
}
|
||||
pm = pm_map.get(p.pm_user_id)
|
||||
if pm:
|
||||
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
|
||||
data.append(d)
|
||||
return {"data": data, "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)):
|
||||
project = Project(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
start_date=payload.start_date,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
return {"data": await project_to_dict(db, project)}
|
||||
|
||||
|
||||
@router.get("/{project_id}")
|
||||
async def get_project(project_id, db: AsyncSession = Depends(get_db)):
|
||||
project = await db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
return {"data": await project_to_dict(db, project)}
|
||||
|
||||
|
||||
@router.patch("/{project_id}")
|
||||
async def update_project(project_id, payload: ProjectUpdate, db: AsyncSession = Depends(get_db)):
|
||||
project = await db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
if payload.name is not None:
|
||||
project.name = payload.name
|
||||
if payload.description is not None:
|
||||
project.description = payload.description
|
||||
if payload.start_date is not None:
|
||||
project.start_date = payload.start_date
|
||||
await db.flush()
|
||||
return {"data": await project_to_dict(db, project)}
|
||||
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
async def delete_project(project_id, db: AsyncSession = Depends(get_db)):
|
||||
project = await db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
# Recommendation (P2-24): block instead of silently unlinking quotes (SET NULL).
|
||||
linked = await db.scalar(select(func.count()).select_from(Quote).where(Quote.project_id == project_id))
|
||||
if linked:
|
||||
raise ConflictError("این پروژه دارای پیشنهاد است و قابل حذف نیست")
|
||||
await db.delete(project)
|
||||
await db.flush()
|
||||
return {"data": {"id": str(project_id), "deleted": True}}
|
||||
|
||||
|
||||
@router.get("/{project_id}/stats")
|
||||
async def get_project_stats(project_id, db: AsyncSession = Depends(get_db)):
|
||||
return {"data": await project_stats(db, project_id)}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Quote events — audit timeline. Manual creation is restricted to event_type=note."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Quote, QuoteEvent
|
||||
from app.schemas.procurement import NoteEventIn
|
||||
from app.services.expand_service import event_to_dict
|
||||
from app.utils.errors import NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_events(
|
||||
quote_id,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
event_type: str | None = None,
|
||||
sort: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
quote = await db.get(Quote, quote_id)
|
||||
if quote is None:
|
||||
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
||||
stmt = select(QuoteEvent).where(QuoteEvent.quote_id == quote_id)
|
||||
if event_type:
|
||||
stmt = stmt.where(QuoteEvent.event_type == event_type)
|
||||
stmt = sort_query(stmt, QuoteEvent, sort, default="occurred_at") # contract default: ASC
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
return {"data": [event_to_dict(e) for e in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)):
|
||||
quote = await db.get(Quote, quote_id)
|
||||
if quote is None:
|
||||
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
||||
if payload.event_type != "note":
|
||||
raise ValidationError("فقط رویدادهای note به صورت دستی ثبت میشوند", field="event_type")
|
||||
event = QuoteEvent(
|
||||
quote_id=quote.id,
|
||||
event_type="note",
|
||||
payload=payload.payload or {},
|
||||
actor=payload.actor,
|
||||
)
|
||||
db.add(event)
|
||||
await db.flush()
|
||||
return {"data": event_to_dict(event)}
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Quotes router — the dashboard ledger + full lifecycle.
|
||||
|
||||
⚠️ Route ordering: GET /quotes/stats is registered BEFORE /quotes/{quote_id}
|
||||
(contract P0-38), otherwise FastAPI would capture "stats" as a UUID path.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import exists, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material
|
||||
from app.models.procurement import ItemDecision, LineItem, Project, Quote, Supplier, Termsheet
|
||||
from app.schemas.procurement import (
|
||||
AcceptQuoteIn,
|
||||
ArriveItemsIn,
|
||||
QuoteChildrenReplaceIn,
|
||||
QuoteCreate,
|
||||
QuoteUpdate,
|
||||
RejectQuoteIn,
|
||||
)
|
||||
from app.services import quote_service
|
||||
from app.services.expand_service import (
|
||||
decision_brief,
|
||||
parse_expand,
|
||||
serialize_quote,
|
||||
serialize_quote_with_children,
|
||||
serialize_quotes,
|
||||
)
|
||||
from app.utils.errors import NotFoundError
|
||||
from app.utils.pagination import sort_query
|
||||
from app.utils.security import get_actor
|
||||
|
||||
router = APIRouter(prefix="/quotes", tags=["quotes"])
|
||||
|
||||
|
||||
# --- list filter helpers ---------------------------------------------------
|
||||
|
||||
def _quote_category_exists(category_id) -> exists:
|
||||
return exists(
|
||||
select(1).where(
|
||||
LineItem.quote_id == Quote.id,
|
||||
Material.id == LineItem.material_id,
|
||||
Material.category_id == category_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _quote_search(q: str) -> exists:
|
||||
like = f"%{q}%"
|
||||
return or_(
|
||||
Quote.notes.ilike(like),
|
||||
exists(select(1).where(Supplier.id == Quote.supplier_id, Supplier.name.ilike(like))),
|
||||
exists(select(1).where(Project.id == Quote.project_id, Project.name.ilike(like))),
|
||||
exists(
|
||||
select(1).where(
|
||||
LineItem.quote_id == Quote.id,
|
||||
Material.id == LineItem.material_id,
|
||||
Material.name.ilike(like),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /quotes/stats — MUST be declared before GET /quotes/{quote_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_quote_stats(
|
||||
supplier_id=None,
|
||||
project_id=None,
|
||||
quote_date_from: datetime | None = None,
|
||||
quote_date_to: datetime | None = None,
|
||||
category_id=None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return {
|
||||
"data": await quote_service.quote_stats(
|
||||
db,
|
||||
supplier_id=supplier_id,
|
||||
project_id=project_id,
|
||||
quote_date_from=quote_date_from,
|
||||
quote_date_to=quote_date_to,
|
||||
category_id=category_id,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List / single
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("")
|
||||
async def list_quotes(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
status: str | None = None,
|
||||
supplier_id=None,
|
||||
project_id=None,
|
||||
category_id=None,
|
||||
quote_date_from: datetime | None = None,
|
||||
quote_date_to: datetime | None = None,
|
||||
q: str | None = None,
|
||||
sort: str | None = None,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Quote)
|
||||
if status:
|
||||
stmt = stmt.where(Quote.status == status)
|
||||
if supplier_id:
|
||||
stmt = stmt.where(Quote.supplier_id == supplier_id)
|
||||
if project_id:
|
||||
stmt = stmt.where(Quote.project_id == project_id)
|
||||
if category_id:
|
||||
stmt = stmt.where(_quote_category_exists(category_id))
|
||||
if quote_date_from is not None:
|
||||
stmt = stmt.where(Quote.quote_date >= quote_date_from)
|
||||
if quote_date_to is not None:
|
||||
stmt = stmt.where(Quote.quote_date <= quote_date_to)
|
||||
if q:
|
||||
stmt = stmt.where(_quote_search(q))
|
||||
stmt = sort_query(stmt, Quote, sort, default="-quote_date")
|
||||
items, meta = await _paginate_quotes(db, stmt, page, page_size)
|
||||
return {"data": await serialize_quotes(db, items, parse_expand(expand)), "meta": meta}
|
||||
|
||||
|
||||
async def _paginate_quotes(db: AsyncSession, stmt, page: int, page_size: int):
|
||||
total = await db.scalar(select(func.count()).select_from(stmt.subquery()))
|
||||
items = (await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))).scalars().all()
|
||||
return items, {"total": total or 0, "page": page, "page_size": page_size}
|
||||
|
||||
|
||||
@router.get("/{quote_id}")
|
||||
async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depends(get_db)):
|
||||
quote = await db.get(Quote, quote_id)
|
||||
if quote is None:
|
||||
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
||||
return {"data": await serialize_quote(db, quote, parse_expand(expand))}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
||||
quote = await quote_service.create_quote(db, payload, actor)
|
||||
return {"data": await serialize_quote_with_children(db, quote)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edit (metadata + backward transitions)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.patch("/{quote_id}")
|
||||
async def update_quote(
|
||||
quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
):
|
||||
quote = await quote_service.update_quote(db, quote_id, payload, actor)
|
||||
return {"data": await serialize_quote(db, quote, set())}
|
||||
|
||||
|
||||
@router.patch("/{quote_id}/children")
|
||||
async def replace_quote_children(
|
||||
quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
):
|
||||
"""Full replacement of a quote's line items + termsheets (edit modal)."""
|
||||
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
|
||||
return {"data": await serialize_quote_with_children(db, quote)}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.patch("/{quote_id}/accept")
|
||||
async def accept_quote(
|
||||
quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
):
|
||||
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
|
||||
return {"data": await _accept_response(db, quote)}
|
||||
|
||||
|
||||
async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
|
||||
"""Contract P0-18 response shape: id/status/accepted_date + decisions + termsheet statuses."""
|
||||
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
|
||||
li_ids = [li.id for li in line_items]
|
||||
decisions: dict = {}
|
||||
if li_ids:
|
||||
decisions = {
|
||||
d.line_item_id: d
|
||||
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars()
|
||||
}
|
||||
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
|
||||
return {
|
||||
"id": str(quote.id),
|
||||
"status": quote.status,
|
||||
"accepted_date": quote.accepted_date.isoformat() if quote.accepted_date else None,
|
||||
"line_items": [
|
||||
{
|
||||
"id": str(li.id),
|
||||
"decision": decision_brief(decisions[li.id]) if li.id in decisions else None,
|
||||
}
|
||||
for li in line_items
|
||||
],
|
||||
"termsheets": [
|
||||
{"id": str(ts.id), "status": ts.status}
|
||||
for ts in sorted(termsheets, key=lambda t: t.created_at)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{quote_id}/arrive")
|
||||
async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
||||
quote = await quote_service.arrive_quote(db, quote_id, actor)
|
||||
return {
|
||||
"data": {
|
||||
"id": str(quote.id),
|
||||
"status": quote.status,
|
||||
"arrived_date": quote.arrived_date.isoformat() if quote.arrived_date else None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{quote_id}/arrive-items")
|
||||
async def arrive_items(
|
||||
quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
):
|
||||
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
|
||||
return {
|
||||
"data": {
|
||||
"id": str(quote.id),
|
||||
"status": quote.status,
|
||||
"arrived_date": quote.arrived_date.isoformat() if quote.arrived_date else None,
|
||||
"items": [
|
||||
{
|
||||
"line_item_id": str(item.line_item_id),
|
||||
"received": True,
|
||||
"received_qty": float(item.received_qty),
|
||||
}
|
||||
for item in payload.items
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{quote_id}/reject")
|
||||
async def reject_quote(
|
||||
quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
|
||||
):
|
||||
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
|
||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||
|
||||
|
||||
@router.patch("/{quote_id}/expire")
|
||||
async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
|
||||
quote = await quote_service.expire_quote(db, quote_id, actor)
|
||||
return {"data": {"id": str(quote.id), "status": quote.status}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Delete (only offered / rejected / expired)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.delete("/{quote_id}")
|
||||
async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)):
|
||||
result = await quote_service.delete_quote(db, quote_id)
|
||||
return {"data": result}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Schedule steps nested under termsheets. step_no auto-numbers (MAX+1)."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Termsheet, TermsheetScheduleStep
|
||||
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
|
||||
from app.services.expand_service import schedule_step_to_dict
|
||||
from app.services.quote_service import next_step_no
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
|
||||
tags=["schedule-steps"],
|
||||
)
|
||||
|
||||
|
||||
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
|
||||
ts = await db.scalar(
|
||||
select(Termsheet).where(Termsheet.id == termsheet_id, Termsheet.quote_id == quote_id)
|
||||
)
|
||||
if ts is None:
|
||||
raise NotFoundError("ترمشیت یافت نشد")
|
||||
return ts
|
||||
|
||||
|
||||
async def _step_or_404(db: AsyncSession, termsheet_id, step_id) -> TermsheetScheduleStep:
|
||||
step = await db.scalar(
|
||||
select(TermsheetScheduleStep).where(
|
||||
TermsheetScheduleStep.id == step_id, TermsheetScheduleStep.termsheet_id == termsheet_id
|
||||
)
|
||||
)
|
||||
if step is None:
|
||||
raise NotFoundError("مرحله پرداخت یافت نشد")
|
||||
return step
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_schedule_steps(
|
||||
quote_id,
|
||||
termsheet_id,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
stmt = (
|
||||
select(TermsheetScheduleStep)
|
||||
.where(TermsheetScheduleStep.termsheet_id == termsheet_id)
|
||||
.order_by(TermsheetScheduleStep.step_no.asc())
|
||||
)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
return {"data": [schedule_step_to_dict(s) for s in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_schedule_step(
|
||||
quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
step_no = payload.step_no
|
||||
if step_no is None:
|
||||
step_no = await next_step_no(db, ts.id)
|
||||
duplicate = await db.scalar(
|
||||
select(TermsheetScheduleStep.id).where(
|
||||
TermsheetScheduleStep.termsheet_id == ts.id, TermsheetScheduleStep.step_no == step_no
|
||||
)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("شماره مرحله تکراری است", field="step_no")
|
||||
step = TermsheetScheduleStep(
|
||||
termsheet_id=ts.id,
|
||||
step_no=step_no,
|
||||
due_date=payload.due_date,
|
||||
amount=payload.amount,
|
||||
note=payload.note,
|
||||
)
|
||||
db.add(step)
|
||||
await db.flush()
|
||||
return {"data": schedule_step_to_dict(step)}
|
||||
|
||||
|
||||
@router.patch("/{step_id}")
|
||||
async def update_schedule_step(
|
||||
quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
step = await _step_or_404(db, termsheet_id, step_id)
|
||||
if payload.step_no is not None and payload.step_no != step.step_no:
|
||||
duplicate = await db.scalar(
|
||||
select(TermsheetScheduleStep.id).where(
|
||||
TermsheetScheduleStep.termsheet_id == termsheet_id,
|
||||
TermsheetScheduleStep.step_no == payload.step_no,
|
||||
TermsheetScheduleStep.id != step_id,
|
||||
)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("شماره مرحله تکراری است", field="step_no")
|
||||
step.step_no = payload.step_no
|
||||
if payload.due_date is not None:
|
||||
step.due_date = payload.due_date
|
||||
if payload.amount is not None:
|
||||
step.amount = payload.amount
|
||||
if payload.note is not None:
|
||||
step.note = payload.note
|
||||
await db.flush()
|
||||
return {"data": schedule_step_to_dict(step)}
|
||||
|
||||
|
||||
@router.delete("/{step_id}")
|
||||
async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)):
|
||||
step = await _step_or_404(db, termsheet_id, step_id)
|
||||
count = await db.scalar(
|
||||
select(func.count()).select_from(TermsheetScheduleStep).where(
|
||||
TermsheetScheduleStep.termsheet_id == termsheet_id
|
||||
)
|
||||
)
|
||||
if count <= 1:
|
||||
raise ConflictError("آخرین مرحله پرداخت قابل حذف نیست")
|
||||
await db.delete(step)
|
||||
await db.flush()
|
||||
return {"data": {"deleted": True}}
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Standard conversions — global, applies to all categories (P2-32..35)."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import StandardConversion
|
||||
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
|
||||
|
||||
|
||||
def sc_to_dict(sc: StandardConversion) -> dict:
|
||||
return {
|
||||
"id": str(sc.id),
|
||||
"from_unit": sc.from_unit,
|
||||
"to_unit": sc.to_unit,
|
||||
"factor": float(sc.factor),
|
||||
"updated_at": to_iso(sc.updated_at),
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_standard_conversions(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
from_unit: str | None = None,
|
||||
to_unit: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(StandardConversion)
|
||||
if from_unit:
|
||||
stmt = stmt.where(StandardConversion.from_unit == from_unit)
|
||||
if to_unit:
|
||||
stmt = stmt.where(StandardConversion.to_unit == to_unit)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
return {"data": [sc_to_dict(sc) for sc in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)):
|
||||
duplicate = await db.scalar(
|
||||
select(StandardConversion.id).where(
|
||||
StandardConversion.from_unit == payload.from_unit,
|
||||
StandardConversion.to_unit == payload.to_unit,
|
||||
)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("این تبدیل استاندارد قبلاً ثبت شده است")
|
||||
sc = StandardConversion(
|
||||
from_unit=payload.from_unit, to_unit=payload.to_unit, factor=payload.factor
|
||||
)
|
||||
db.add(sc)
|
||||
await db.flush()
|
||||
return {"data": sc_to_dict(sc)}
|
||||
|
||||
|
||||
@router.get("/{sc_id}")
|
||||
async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
||||
sc = await db.get(StandardConversion, sc_id)
|
||||
if sc is None:
|
||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||
return {"data": sc_to_dict(sc)}
|
||||
|
||||
|
||||
@router.patch("/{sc_id}")
|
||||
async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)):
|
||||
sc = await db.get(StandardConversion, sc_id)
|
||||
if sc is None:
|
||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||
new_from = payload.from_unit if payload.from_unit is not None else sc.from_unit
|
||||
new_to = payload.to_unit if payload.to_unit is not None else sc.to_unit
|
||||
if payload.from_unit is not None or payload.to_unit is not None:
|
||||
duplicate = await db.scalar(
|
||||
select(StandardConversion.id).where(
|
||||
StandardConversion.from_unit == new_from,
|
||||
StandardConversion.to_unit == new_to,
|
||||
StandardConversion.id != sc_id,
|
||||
)
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("این تبدیل استاندارد قبلاً ثبت شده است")
|
||||
if payload.from_unit is not None:
|
||||
sc.from_unit = payload.from_unit
|
||||
if payload.to_unit is not None:
|
||||
sc.to_unit = payload.to_unit
|
||||
if payload.factor is not None:
|
||||
sc.factor = payload.factor
|
||||
await db.flush()
|
||||
return {"data": sc_to_dict(sc)}
|
||||
|
||||
|
||||
@router.delete("/{sc_id}")
|
||||
async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
|
||||
sc = await db.get(StandardConversion, sc_id)
|
||||
if sc is None:
|
||||
raise NotFoundError("تبدیل استاندارد یافت نشد")
|
||||
await db.delete(sc)
|
||||
await db.flush()
|
||||
return {"data": {"deleted": True}}
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Supplier <-> Material catalog links (v2.5).
|
||||
|
||||
GET/POST /suppliers/{id}/materials — manage a supplier's catalog.
|
||||
DELETE /suppliers/{id}/materials/{material_id} — unlink.
|
||||
GET /materials/{id}/suppliers — price-comparison data source.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import Material, MaterialCategory
|
||||
from app.models.procurement import Supplier, SupplierMaterial
|
||||
from app.schemas.procurement import SupplierMaterialCreate
|
||||
from app.services.supplier_material_service import link_supplier_material, unlink_supplier_material
|
||||
from app.utils.errors import NotFoundError
|
||||
from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(tags=["supplier-materials"])
|
||||
|
||||
|
||||
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
|
||||
category: MaterialCategory | None = None,
|
||||
supplier: Supplier | None = None) -> dict:
|
||||
return {
|
||||
"id": str(link.id),
|
||||
"supplier_id": str(link.supplier_id),
|
||||
"material_id": str(link.material_id),
|
||||
"is_active": link.is_active,
|
||||
"last_quoted_price": float(link.last_quoted_price) if link.last_quoted_price is not None else None,
|
||||
"last_quoted_at": to_iso(link.last_quoted_at) if link.last_quoted_at else None,
|
||||
"created_at": to_iso(link.created_at),
|
||||
"material": {
|
||||
"id": str(material.id),
|
||||
"name": material.name,
|
||||
"category": {"id": str(category.id), "name": category.name} if category else None,
|
||||
} if material else None,
|
||||
"supplier": {"id": str(supplier.id), "name": supplier.name} if supplier else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/suppliers/{supplier_id}/materials")
|
||||
async def list_supplier_materials(
|
||||
supplier_id,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
supplier = await db.get(Supplier, supplier_id)
|
||||
if supplier is None:
|
||||
raise NotFoundError("تأمینکننده یافت نشد")
|
||||
stmt = (
|
||||
select(SupplierMaterial)
|
||||
.where(SupplierMaterial.supplier_id == supplier_id)
|
||||
.order_by(SupplierMaterial.created_at)
|
||||
)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
mat_ids = {l.material_id for l in items}
|
||||
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||
cat_ids = {m.category_id for m in materials.values() if m.category_id}
|
||||
cats = {}
|
||||
if cat_ids:
|
||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
||||
return {"data": [link_to_dict(l, materials.get(l.material_id), cats.get(materials.get(l.material_id).category_id if l.material_id in materials else None)) for l in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
||||
async def create_supplier_material(
|
||||
supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
async with db.begin():
|
||||
link = await link_supplier_material(db, supplier_id, payload.material_id)
|
||||
material = await db.get(Material, payload.material_id)
|
||||
category = await db.get(MaterialCategory, material.category_id) if material and material.category_id else None
|
||||
return {"data": link_to_dict(link, material, category)}
|
||||
|
||||
|
||||
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
||||
async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
|
||||
async with db.begin():
|
||||
await unlink_supplier_material(db, supplier_id, material_id)
|
||||
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
|
||||
|
||||
|
||||
@router.get("/materials/{material_id}/suppliers")
|
||||
async def list_material_suppliers(
|
||||
material_id,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
material = await db.get(Material, material_id)
|
||||
if material is None:
|
||||
raise NotFoundError("متریال یافت نشد")
|
||||
stmt = (
|
||||
select(SupplierMaterial)
|
||||
.where(SupplierMaterial.material_id == material_id)
|
||||
.order_by(SupplierMaterial.last_quoted_at.desc().nulls_last())
|
||||
)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
sup_ids = {l.supplier_id for l in items}
|
||||
suppliers = {s.id: s for s in (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars()}
|
||||
return {"data": [link_to_dict(l, supplier=suppliers.get(l.supplier_id)) for l in items], "meta": meta}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Suppliers CRUD — v2.6: 1 supplier → exactly 1 category (category_id FK)."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import MaterialCategory
|
||||
from app.models.procurement import Quote, Supplier
|
||||
from app.schemas.procurement import SupplierCreate, SupplierUpdate
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
|
||||
|
||||
|
||||
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
|
||||
return {
|
||||
"id": str(supplier.id),
|
||||
"name": supplier.name,
|
||||
"category_id": str(supplier.category_id),
|
||||
"category": {
|
||||
"id": str(category.id),
|
||||
"slug": category.slug,
|
||||
"name": category.name,
|
||||
} if category else None,
|
||||
"location": supplier.location,
|
||||
"phone": supplier.phone,
|
||||
"contact_person": supplier.contact_person,
|
||||
"tax_id": supplier.tax_id,
|
||||
"created_at": to_iso(supplier.created_at),
|
||||
}
|
||||
|
||||
|
||||
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
|
||||
category = await db.get(MaterialCategory, category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
||||
return category
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_suppliers(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
q: str | None = None,
|
||||
category_id=None,
|
||||
sort: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Supplier)
|
||||
if q:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Supplier.name.ilike(f"%{q}%"),
|
||||
Supplier.location.ilike(f"%{q}%"),
|
||||
Supplier.phone.ilike(f"%{q}%"),
|
||||
)
|
||||
)
|
||||
if category_id:
|
||||
stmt = stmt.where(Supplier.category_id == category_id)
|
||||
stmt = sort_query(stmt, Supplier, sort)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
cat_ids = {s.category_id for s in items}
|
||||
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": [supplier_to_dict(s, cats.get(s.category_id)) for s in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)):
|
||||
category = await _category_or_404(db, payload.category_id)
|
||||
supplier = Supplier(
|
||||
name=payload.name,
|
||||
category_id=payload.category_id,
|
||||
location=payload.location,
|
||||
phone=payload.phone,
|
||||
contact_person=payload.contact_person,
|
||||
tax_id=payload.tax_id,
|
||||
)
|
||||
db.add(supplier)
|
||||
await db.flush()
|
||||
return {"data": supplier_to_dict(supplier, category)}
|
||||
|
||||
|
||||
@router.get("/{supplier_id}")
|
||||
async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
||||
supplier = await db.get(Supplier, supplier_id)
|
||||
if supplier is None:
|
||||
raise NotFoundError("تأمینکننده یافت نشد")
|
||||
category = await db.get(MaterialCategory, supplier.category_id)
|
||||
return {"data": supplier_to_dict(supplier, category)}
|
||||
|
||||
|
||||
@router.patch("/{supplier_id}")
|
||||
async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)):
|
||||
supplier = await db.get(Supplier, supplier_id)
|
||||
if supplier is None:
|
||||
raise NotFoundError("تأمینکننده یافت نشد")
|
||||
if payload.name is not None:
|
||||
supplier.name = payload.name
|
||||
if payload.category_id is not None:
|
||||
if payload.category_id != supplier.category_id:
|
||||
await _category_or_404(db, payload.category_id)
|
||||
supplier.category_id = payload.category_id
|
||||
if payload.location is not None:
|
||||
supplier.location = payload.location
|
||||
if payload.phone is not None:
|
||||
supplier.phone = payload.phone
|
||||
if payload.contact_person is not None:
|
||||
supplier.contact_person = payload.contact_person
|
||||
if payload.tax_id is not None:
|
||||
supplier.tax_id = payload.tax_id
|
||||
await db.flush()
|
||||
category = await db.get(MaterialCategory, supplier.category_id)
|
||||
return {"data": supplier_to_dict(supplier, category)}
|
||||
|
||||
|
||||
@router.delete("/{supplier_id}")
|
||||
async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
|
||||
supplier = await db.get(Supplier, supplier_id)
|
||||
if supplier is None:
|
||||
raise NotFoundError("تأمینکننده یافت نشد")
|
||||
# quotes.supplier_id is NOT NULL RESTRICT — block if any quote exists.
|
||||
linked = await db.scalar(select(func.count()).select_from(Quote).where(Quote.supplier_id == supplier_id))
|
||||
if linked:
|
||||
raise ConflictError("این تأمینکننده دارای پیشنهاد است و قابل حذف نیست")
|
||||
await db.delete(supplier) # supplier_materials cascade via DDL
|
||||
await db.flush()
|
||||
return {"data": {"id": str(supplier_id), "deleted": True}}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Termsheets nested under quotes. Unlimited per quote (constraint is per line item)."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import (
|
||||
ItemDecision,
|
||||
ItemTermsheet,
|
||||
Quote,
|
||||
Termsheet,
|
||||
TermsheetScheduleStep,
|
||||
)
|
||||
from app.schemas.procurement import (
|
||||
TermsheetCreate,
|
||||
TermsheetStatusUpdate,
|
||||
TermsheetUpdate,
|
||||
)
|
||||
from app.services.expand_service import (
|
||||
parse_expand,
|
||||
serialize_termsheet,
|
||||
serialize_termsheets,
|
||||
)
|
||||
from app.services.quote_service import _add_event, next_step_no
|
||||
from app.utils.errors import ConflictError, NotFoundError, ValidationError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
|
||||
|
||||
|
||||
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
|
||||
quote = await db.get(Quote, quote_id)
|
||||
if quote is None:
|
||||
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
|
||||
return quote
|
||||
|
||||
|
||||
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
|
||||
ts = await db.scalar(
|
||||
select(Termsheet).where(Termsheet.id == termsheet_id, Termsheet.quote_id == quote_id)
|
||||
)
|
||||
if ts is None:
|
||||
raise NotFoundError("ترمشیت یافت نشد")
|
||||
return ts
|
||||
|
||||
|
||||
async def _insert_steps(db: AsyncSession, termsheet_id, steps) -> None:
|
||||
"""Insert steps with auto-numbered step_no (MAX+1) when omitted."""
|
||||
next_no = await next_step_no(db, termsheet_id)
|
||||
for index, step in enumerate(steps):
|
||||
step_no = step.step_no if step.step_no is not None else next_no + index
|
||||
db.add(
|
||||
TermsheetScheduleStep(
|
||||
termsheet_id=termsheet_id,
|
||||
step_no=step_no,
|
||||
due_date=step.due_date,
|
||||
amount=step.amount,
|
||||
note=step.note,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_termsheets(
|
||||
quote_id,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
status: str | None = None,
|
||||
sort: str | None = None,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
await _quote_or_404(db, quote_id)
|
||||
stmt = select(Termsheet).where(Termsheet.quote_id == quote_id)
|
||||
if status:
|
||||
stmt = stmt.where(Termsheet.status == status)
|
||||
stmt = sort_query(stmt, Termsheet, sort)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
return {"data": await serialize_termsheets(db, items, parse_expand(expand)), "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)):
|
||||
async with db.begin():
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
raise ConflictError("ترمشیت فقط در پیشنهادهای offered قابل افزودن است")
|
||||
if not payload.schedule_steps:
|
||||
raise ValidationError("ترمشیت باید حداقل یک مرحله پرداخت داشته باشد", field="schedule_steps")
|
||||
ts = Termsheet(
|
||||
quote_id=quote.id,
|
||||
label=payload.label,
|
||||
status="offered",
|
||||
discount_type=payload.discount_type,
|
||||
discount_value=payload.discount_value,
|
||||
)
|
||||
db.add(ts)
|
||||
await db.flush()
|
||||
await _insert_steps(db, ts.id, payload.schedule_steps)
|
||||
return {"data": await serialize_termsheet(db, ts, set())}
|
||||
|
||||
|
||||
@router.get("/{termsheet_id}")
|
||||
async def get_termsheet(
|
||||
quote_id, termsheet_id, expand: str | None = None, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
return {"data": await serialize_termsheet(db, ts, parse_expand(expand))}
|
||||
|
||||
|
||||
@router.patch("/{termsheet_id}")
|
||||
async def update_termsheet(
|
||||
quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
if ts.status != "offered":
|
||||
raise ConflictError("ترمشیتهای پذیرفتهشده یا ردشده قابل ویرایش نیستند")
|
||||
if payload.label is not None:
|
||||
ts.label = payload.label
|
||||
if payload.discount_type is not None:
|
||||
ts.discount_type = payload.discount_type
|
||||
if payload.discount_value is not None:
|
||||
ts.discount_value = payload.discount_value
|
||||
await db.flush()
|
||||
return {"data": await serialize_termsheet(db, ts, set())}
|
||||
|
||||
|
||||
@router.patch("/{termsheet_id}/status")
|
||||
async def update_termsheet_status(
|
||||
quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
async with db.begin():
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
if ts.status != "offered":
|
||||
raise ConflictError("فقط ترمشیتهای offered تغییر وضعیت میدهند")
|
||||
ts.status = payload.status # offered -> accepted | offered -> rejected (multiple OK)
|
||||
_add_event(db, quote_id, "edited", {"action": "termsheet_status", "termsheet_id": str(ts.id), "status": ts.status})
|
||||
return {"data": await serialize_termsheet(db, ts, set())}
|
||||
|
||||
|
||||
@router.delete("/{termsheet_id}")
|
||||
async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)):
|
||||
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
|
||||
quote = await _quote_or_404(db, quote_id)
|
||||
if quote.status != "offered":
|
||||
raise ConflictError("ترمشیت فقط در پیشنهادهای offered قابل حذف است")
|
||||
count = await db.scalar(select(func.count()).select_from(Termsheet).where(Termsheet.quote_id == quote_id))
|
||||
if count <= 1:
|
||||
raise ConflictError("آخرین ترمشیت پیشنهاد قابل حذف نیست")
|
||||
chosen = await db.scalar(
|
||||
select(ItemDecision.id)
|
||||
.join(ItemTermsheet, ItemDecision.chosen_item_termsheet_id == ItemTermsheet.id)
|
||||
.where(ItemTermsheet.termsheet_id == termsheet_id, ItemDecision.status == "chosen")
|
||||
.limit(1)
|
||||
)
|
||||
if chosen:
|
||||
raise ConflictError("ترمشیتی که قلم انتخابشده دارد قابل حذف نیست")
|
||||
await db.delete(ts) # steps + item_termsheets cascade via DDL
|
||||
await db.flush()
|
||||
return {"data": {"deleted": True}}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Unit conversions — category/brand-scoped (units.html dashboard, P2-28..31).
|
||||
|
||||
Model: {category_id, brand?, from_unit, to_unit, factor}
|
||||
1 from_unit = factor × to_unit
|
||||
brand empty = all brands of the category; brand set = that brand only.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import MaterialCategory, UnitConversion
|
||||
from app.schemas.catalog import UnitConversionCreate, UnitConversionUpdate
|
||||
from app.services.expand_service import category_brief
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/unit-conversions", tags=["unit-conversions"])
|
||||
|
||||
|
||||
def uc_to_dict(uc: UnitConversion, expand: set[str] = frozenset()) -> dict:
|
||||
d = {
|
||||
"id": str(uc.id),
|
||||
"category_id": str(uc.category_id),
|
||||
"brand": uc.brand,
|
||||
"from_unit": uc.from_unit,
|
||||
"to_unit": uc.to_unit,
|
||||
"factor": float(uc.factor),
|
||||
"updated_at": to_iso(uc.updated_at),
|
||||
}
|
||||
if "category" in expand and hasattr(uc, "_category"):
|
||||
d["category"] = category_brief(uc._category)
|
||||
return d
|
||||
|
||||
|
||||
def _duplicate_condition(uc_or_none, category_id, brand, from_unit, to_unit):
|
||||
"""Uniqueness treats NULL brand as a concrete value (matches the partial unique index)."""
|
||||
where = (
|
||||
UnitConversion.category_id == category_id,
|
||||
UnitConversion.from_unit == from_unit,
|
||||
UnitConversion.to_unit == to_unit,
|
||||
)
|
||||
# PG «IS» فقط با NULL معتبر است؛ برای برند مقداردار از «==» استفاده میشود
|
||||
where = where + (UnitConversion.brand == brand if brand is not None else UnitConversion.brand.is_(None),)
|
||||
if uc_or_none is not None:
|
||||
where = where + (UnitConversion.id != uc_or_none.id,)
|
||||
return where
|
||||
|
||||
|
||||
async def _check_duplicate(db: AsyncSession, exclude_id, category_id, brand, from_unit, to_unit) -> None:
|
||||
duplicate = await db.scalar(
|
||||
select(UnitConversion.id).where(*_duplicate_condition(exclude_id, category_id, brand, from_unit, to_unit))
|
||||
)
|
||||
if duplicate is not None:
|
||||
raise ConflictError("این تبدیل قبلاً ثبت شده است")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_unit_conversions(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
category_id=None,
|
||||
brand: str | None = None,
|
||||
from_unit: str | None = None,
|
||||
to_unit: str | None = None,
|
||||
expand: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(UnitConversion)
|
||||
if category_id:
|
||||
stmt = stmt.where(UnitConversion.category_id == category_id)
|
||||
if brand:
|
||||
stmt = stmt.where(UnitConversion.brand == brand)
|
||||
if from_unit:
|
||||
stmt = stmt.where(UnitConversion.from_unit == from_unit)
|
||||
if to_unit:
|
||||
stmt = stmt.where(UnitConversion.to_unit == to_unit)
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
tokens = {t.strip() for t in expand.split(",")} if expand else set()
|
||||
if "category" in tokens:
|
||||
cat_ids = {uc.category_id for uc in items}
|
||||
cats = {}
|
||||
if cat_ids:
|
||||
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
||||
for uc in items:
|
||||
uc._category = cats.get(uc.category_id)
|
||||
return {"data": [uc_to_dict(uc, tokens) for uc in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_unit_conversion(payload: UnitConversionCreate, db: AsyncSession = Depends(get_db)):
|
||||
category = await db.get(MaterialCategory, payload.category_id)
|
||||
if category is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
||||
await _check_duplicate(db, None, payload.category_id, payload.brand, payload.from_unit, payload.to_unit)
|
||||
uc = UnitConversion(
|
||||
category_id=payload.category_id,
|
||||
brand=payload.brand,
|
||||
from_unit=payload.from_unit,
|
||||
to_unit=payload.to_unit,
|
||||
factor=payload.factor,
|
||||
)
|
||||
db.add(uc)
|
||||
await db.flush()
|
||||
return {"data": uc_to_dict(uc)}
|
||||
|
||||
|
||||
@router.get("/{uc_id}")
|
||||
async def get_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
|
||||
uc = await db.get(UnitConversion, uc_id)
|
||||
if uc is None:
|
||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
||||
return {"data": uc_to_dict(uc)}
|
||||
|
||||
|
||||
@router.patch("/{uc_id}")
|
||||
async def update_unit_conversion(uc_id, payload: UnitConversionUpdate, db: AsyncSession = Depends(get_db)):
|
||||
uc = await db.get(UnitConversion, uc_id)
|
||||
if uc is None:
|
||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
||||
new_brand = payload.brand if payload.brand is not None else uc.brand
|
||||
new_from = payload.from_unit if payload.from_unit is not None else uc.from_unit
|
||||
new_to = payload.to_unit if payload.to_unit is not None else uc.to_unit
|
||||
if payload.brand is not None or payload.from_unit is not None or payload.to_unit is not None:
|
||||
await _check_duplicate(db, uc.id, uc.category_id, new_brand, new_from, new_to)
|
||||
if payload.brand is not None:
|
||||
uc.brand = payload.brand
|
||||
if payload.from_unit is not None:
|
||||
uc.from_unit = payload.from_unit
|
||||
if payload.to_unit is not None:
|
||||
uc.to_unit = payload.to_unit
|
||||
if payload.factor is not None:
|
||||
uc.factor = payload.factor
|
||||
await db.flush()
|
||||
return {"data": uc_to_dict(uc)}
|
||||
|
||||
|
||||
@router.delete("/{uc_id}")
|
||||
async def delete_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
|
||||
uc = await db.get(UnitConversion, uc_id)
|
||||
if uc is None:
|
||||
raise NotFoundError("تبدیل واحد یافت نشد")
|
||||
await db.delete(uc)
|
||||
await db.flush()
|
||||
return {"data": {"deleted": True}}
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Units CRUD. units.id is the ONLY integer PK in the system (IDENTITY)."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.engine import get_db
|
||||
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
|
||||
from app.models.procurement import LineItem, LineItemSpec
|
||||
from app.schemas.catalog import UnitCreate, UnitUpdate
|
||||
from app.utils.errors import ConflictError, NotFoundError
|
||||
from app.utils.pagination import paginate, sort_query
|
||||
|
||||
router = APIRouter(prefix="/units", tags=["units"])
|
||||
|
||||
|
||||
def unit_full_to_dict(unit: Unit) -> dict:
|
||||
"""Full unit shape (P0-1): includes dimension, si_factor, is_base."""
|
||||
return {
|
||||
"id": unit.id,
|
||||
"code": unit.code,
|
||||
"name_fa": unit.name_fa,
|
||||
"dimension": unit.dimension,
|
||||
"si_factor": float(unit.si_factor),
|
||||
"is_base": unit.is_base,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_units(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
q: str | None = None,
|
||||
dimension: str | None = None,
|
||||
sort: str | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(Unit)
|
||||
if q:
|
||||
stmt = stmt.where(or_(Unit.code.ilike(f"%{q}%"), Unit.name_fa.ilike(f"%{q}%")))
|
||||
if dimension:
|
||||
stmt = stmt.where(Unit.dimension == dimension)
|
||||
stmt = sort_query(stmt, Unit, sort, default="id")
|
||||
items, meta = await paginate(stmt, page, page_size, db)
|
||||
return {"data": [unit_full_to_dict(u) for u in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)):
|
||||
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
|
||||
if duplicate is not None:
|
||||
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
|
||||
unit = Unit(
|
||||
code=payload.code,
|
||||
name_fa=payload.name_fa,
|
||||
dimension=payload.dimension,
|
||||
si_factor=payload.si_factor,
|
||||
is_base=payload.is_base,
|
||||
)
|
||||
db.add(unit)
|
||||
await db.flush()
|
||||
return {"data": unit_full_to_dict(unit)}
|
||||
|
||||
|
||||
@router.get("/{unit_id}")
|
||||
async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
||||
unit = await db.get(Unit, unit_id)
|
||||
if unit is None:
|
||||
raise NotFoundError("واحد یافت نشد")
|
||||
return {"data": unit_full_to_dict(unit)}
|
||||
|
||||
|
||||
@router.patch("/{unit_id}")
|
||||
async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)):
|
||||
unit = await db.get(Unit, unit_id)
|
||||
if unit is None:
|
||||
raise NotFoundError("واحد یافت نشد")
|
||||
if payload.code is not None and payload.code != unit.code:
|
||||
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code, Unit.id != unit_id))
|
||||
if duplicate is not None:
|
||||
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
|
||||
unit.code = payload.code
|
||||
if payload.name_fa is not None:
|
||||
unit.name_fa = payload.name_fa
|
||||
if payload.dimension is not None:
|
||||
unit.dimension = payload.dimension
|
||||
if payload.si_factor is not None:
|
||||
unit.si_factor = payload.si_factor
|
||||
if payload.is_base is not None:
|
||||
unit.is_base = payload.is_base
|
||||
await db.flush()
|
||||
return {"data": unit_full_to_dict(unit)}
|
||||
|
||||
|
||||
@router.delete("/{unit_id}")
|
||||
async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
|
||||
unit = await db.get(Unit, unit_id)
|
||||
if unit is None:
|
||||
raise NotFoundError("واحد یافت نشد")
|
||||
|
||||
refs: dict[str, int] = {
|
||||
"attribute_definitions": await db.scalar(
|
||||
select(func.count()).select_from(AttributeDefinition).where(AttributeDefinition.unit_id == unit_id)
|
||||
),
|
||||
"materials": await db.scalar(
|
||||
select(func.count()).select_from(Material).where(Material.canonical_unit_id == unit_id)
|
||||
),
|
||||
"material_units": await db.scalar(
|
||||
select(func.count()).select_from(MaterialUnit).where(MaterialUnit.unit_id == unit_id)
|
||||
),
|
||||
"line_items": await db.scalar(select(func.count()).select_from(LineItem).where(LineItem.unit_id == unit_id)),
|
||||
"line_item_specs": await db.scalar(
|
||||
select(func.count()).select_from(LineItemSpec).where(LineItemSpec.unit_id_at_time == unit_id)
|
||||
),
|
||||
}
|
||||
total = sum(refs.values())
|
||||
if total:
|
||||
raise ConflictError(f"این واحد در {total} رکورد دیگر استفاده شده و قابل حذف نیست")
|
||||
await db.delete(unit)
|
||||
await db.flush()
|
||||
return {"data": {"id": unit_id, "deleted": True}}
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Users — profile + role assignment (v2.6, Casdoor-backed).
|
||||
|
||||
GET /users/me — the caller's profile: role, PM's project, display name.
|
||||
PATCH /users/{id} — assign role (admin only).
|
||||
"""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth import UserContext, get_current_user, require_role
|
||||
from app.db.engine import get_db
|
||||
from app.models.procurement import Project, User
|
||||
from app.schemas.procurement import UserRoleUpdate
|
||||
from app.utils.errors import NotFoundError
|
||||
from app.utils.serialization import to_iso
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
USER_ROLE_LABELS = {
|
||||
"pm": "مدیر پروژه",
|
||||
"procurement_manager": "مدیر تدارکات",
|
||||
"admin": "ادمین",
|
||||
}
|
||||
|
||||
|
||||
def user_to_dict(user: User, project: Project | None = None) -> dict:
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"casdoor_id": user.casdoor_id,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"phone": user.phone,
|
||||
"role": user.role,
|
||||
"role_label": USER_ROLE_LABELS.get(user.role) if user.role else None,
|
||||
"project_id": str(project.id) if project else None,
|
||||
"project_name": project.name if project else None,
|
||||
"created_at": to_iso(user.created_at),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
project = await db.scalar(select(Project).where(Project.pm_user_id == ctx.user.id)) if ctx.role == "pm" else None
|
||||
return {"data": user_to_dict(ctx.user, project)}
|
||||
|
||||
|
||||
@router.patch("/{user_id}")
|
||||
async def update_user_role(
|
||||
user_id,
|
||||
payload: UserRoleUpdate,
|
||||
_: UserContext = Depends(require_role("admin")),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
user = await db.get(User, user_id)
|
||||
if user is None:
|
||||
raise NotFoundError("کاربر یافت نشد")
|
||||
user.role = payload.role
|
||||
await db.flush()
|
||||
project = await db.scalar(select(Project).where(Project.pm_user_id == user.id)) if user.role == "pm" else None
|
||||
return {"data": user_to_dict(user, project)}
|
||||
Reference in New Issue
Block a user