feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints

This commit is contained in:
ali_mohaghegh
2026-08-25 19:08:14 +03:30
parent c8a9b66395
commit 170aed66ea
67 changed files with 9774 additions and 0 deletions
+58
View File
@@ -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}}
+135
View File
@@ -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}}
+94
View File
@@ -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}}
+136
View File
@@ -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}}
+69
View File
@@ -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}}
+151
View File
@@ -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}}
+547
View File
@@ -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
]}
+138
View File
@@ -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)}
+83
View File
@@ -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)}
+136
View File
@@ -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}}
+79
View File
@@ -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)}
+138
View File
@@ -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}}
+104
View File
@@ -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}}
+385
View File
@@ -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)}}
+432
View File
@@ -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
]}
+124
View File
@@ -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)}
+51
View File
@@ -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)}
+270
View File
@@ -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}
+123
View File
@@ -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}}
+103
View File
@@ -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}}
+104
View File
@@ -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}
+133
View File
@@ -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}}
+160
View File
@@ -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}}
+146
View File
@@ -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}}
+120
View File
@@ -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}}
+60
View File
@@ -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)}