Merge prod_v5 (catalog v2.7, warehouse locking, per-project accept) into integration
Conflict in init_db.py resolved keeping the audit_log table ensure function (new-one) — prod_v5 predates it and its side was empty. Both sides' SQL lands: audit_log table plus the category-free materials model.
This commit is contained in:
@@ -345,6 +345,18 @@ BASE_UNITS: list[dict] = [
|
||||
{"code": "m", "name_fa": "متر", "dimension": "length", "si_factor": 1, "is_base": True},
|
||||
{"code": "bag", "name_fa": "کیسه", "dimension": "count", "si_factor": 1, "is_base": False},
|
||||
{"code": "ton", "name_fa": "تن", "dimension": "mass", "si_factor": 1000, "is_base": False},
|
||||
{"code": "g", "name_fa": "گرم", "dimension": "mass", "si_factor": 0.001, "is_base": False},
|
||||
{"code": "kpiece", "name_fa": "هزار عدد", "dimension": "count", "si_factor": 1000, "is_base": False},
|
||||
]
|
||||
|
||||
# تبدیلهای ثابت جهانی («تبدیلهای ثابت — همه متریالها») — ضرایب استاندارد و
|
||||
# فراگیر که در هر استارتآپ بهصورت idempotent ذخیره میشوند. کلید یکتا:
|
||||
# (from_unit, to_unit)؛ مقدار فیلدها، نام نمایشی فارسی واحد است — همان
|
||||
# قالبی که فرانتاند در standard_conversions مینویسد.
|
||||
STANDARD_CONVERSIONS: list[dict] = [
|
||||
{"from_unit": "تن", "to_unit": "کیلوگرم", "factor": 1000},
|
||||
{"from_unit": "کیلوگرم", "to_unit": "گرم", "factor": 1000},
|
||||
{"from_unit": "هزار عدد", "to_unit": "عدد", "factor": 1000},
|
||||
]
|
||||
|
||||
|
||||
@@ -379,13 +391,20 @@ async def ensure_v26(engine: AsyncEngine) -> None:
|
||||
"VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING",
|
||||
u,
|
||||
)
|
||||
# v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync).
|
||||
# Seed the global fixed conversions (idempotent — unique key from/to unit).
|
||||
for c in STANDARD_CONVERSIONS:
|
||||
await run_statement(
|
||||
"INSERT INTO standard_conversions (from_unit, to_unit, factor) "
|
||||
"VALUES (:from_unit, :to_unit, :factor) "
|
||||
"ON CONFLICT (from_unit, to_unit) DO NOTHING",
|
||||
c,
|
||||
)
|
||||
# v2.5/v2.6 category decoupling from materials: drop trigger, function, and column.
|
||||
await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials")
|
||||
await run_statement("""
|
||||
CREATE TRIGGER trg_supplier_materials_derive_category
|
||||
AFTER INSERT ON supplier_materials
|
||||
FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category()
|
||||
""")
|
||||
await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()")
|
||||
await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey")
|
||||
await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id")
|
||||
|
||||
await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions")
|
||||
await run_statement("""
|
||||
CREATE TRIGGER trg_item_decisions_sync_price
|
||||
|
||||
@@ -130,14 +130,11 @@ class CategoryAttribute(Base):
|
||||
|
||||
class Material(Base):
|
||||
__tablename__ = "materials"
|
||||
# v2.5/2.6: name is GLOBALLY unique; category_id is nullable + derived.
|
||||
# v2.6/v2.7: name is GLOBALLY unique; decoupled from categories.
|
||||
__table_args__ = (UniqueConstraint("name", name="uq_material_name"),)
|
||||
__mapper_args__ = {"eager_defaults": True}
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
category_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("material_categories.id", ondelete="RESTRICT")
|
||||
)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy
|
||||
canonical_unit_id: Mapped[int] = mapped_column(
|
||||
@@ -149,7 +146,6 @@ class Material(Base):
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
category: Mapped["MaterialCategory | None"] = relationship(lazy="raise")
|
||||
canonical_unit: Mapped["Unit"] = relationship(lazy="raise")
|
||||
attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship(
|
||||
back_populates="material", lazy="raise", passive_deletes=True
|
||||
|
||||
@@ -274,12 +274,13 @@ async def update_warehouse(
|
||||
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
|
||||
raise ValidationError(
|
||||
"پروژهی انبار قابل تغییر نیست — انبار برای همیشه به پروژهی والدش تعلق دارد",
|
||||
field="project_id",
|
||||
)
|
||||
await db.flush()
|
||||
return {"data": warehouse_to_dict(wh, await db.get(Project, wh.project_id))}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from app.models.catalog import (
|
||||
MaterialUnit,
|
||||
Unit,
|
||||
)
|
||||
from app.models.procurement import LineItem
|
||||
from app.models.procurement import LineItem, Supplier, SupplierMaterial
|
||||
from app.schemas.catalog import (
|
||||
MaterialAttributeValuesPut,
|
||||
MaterialCreate,
|
||||
@@ -108,7 +108,16 @@ async def list_materials(
|
||||
):
|
||||
stmt = select(Material)
|
||||
if category_id:
|
||||
stmt = stmt.where(Material.category_id == category_id)
|
||||
sub = (
|
||||
select(1)
|
||||
.select_from(SupplierMaterial)
|
||||
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
||||
.where(
|
||||
SupplierMaterial.material_id == Material.id,
|
||||
Supplier.category_id == category_id,
|
||||
)
|
||||
)
|
||||
stmt = stmt.where(exists(sub))
|
||||
if brand_id:
|
||||
# WHERE EXISTS brand_materials with that brand_id
|
||||
sub = select(1).where(
|
||||
|
||||
@@ -201,17 +201,11 @@ async def pm_on_the_way(
|
||||
# unit / category lookups for display
|
||||
unit_ids = {d.LineItem.unit_id for d in decisions}
|
||||
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
|
||||
mat_ids = {d.Material.id for d in decisions}
|
||||
sup_cat_ids = {d.Supplier.category_id for d in decisions if d.Supplier.category_id}
|
||||
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}
|
||||
if sup_cat_ids:
|
||||
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(sup_cat_ids)))).scalars().all()
|
||||
cats = {c.id: c for c in rows}
|
||||
|
||||
out = []
|
||||
for d, li, q, m, sup, proj in decisions:
|
||||
@@ -226,7 +220,7 @@ async def pm_on_the_way(
|
||||
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
|
||||
"decided_at": to_iso(d.decided_at),
|
||||
"material": {"id": str(m.id), "name": m.name,
|
||||
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None},
|
||||
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(sup.category_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),
|
||||
|
||||
@@ -10,7 +10,16 @@ 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.models.procurement import (
|
||||
ItemDecision,
|
||||
LineItem,
|
||||
LineItemProjects,
|
||||
Project,
|
||||
Quote,
|
||||
QuoteProjects,
|
||||
Supplier,
|
||||
Termsheet,
|
||||
)
|
||||
from app.schemas.procurement import (
|
||||
AcceptQuoteIn,
|
||||
ArriveItemsIn,
|
||||
@@ -39,9 +48,8 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
|
||||
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,
|
||||
Supplier.id == Quote.supplier_id,
|
||||
Supplier.category_id == category_id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -112,7 +120,23 @@ async def list_quotes(
|
||||
if supplier_id:
|
||||
stmt = stmt.where(Quote.supplier_id == supplier_id)
|
||||
if project_id:
|
||||
stmt = stmt.where(Quote.project_id == project_id)
|
||||
# any relevant link: legacy mirror, quote-level junction, line-item
|
||||
# candidates, or an accepted (decision) target — NOT just the mirror
|
||||
stmt = stmt.where(
|
||||
Quote.id.in_(
|
||||
select(Quote.id)
|
||||
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
|
||||
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
|
||||
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
|
||||
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
|
||||
.where(
|
||||
(Quote.project_id == project_id)
|
||||
| (QuoteProjects.project_id == project_id)
|
||||
| (LineItemProjects.project_id == project_id)
|
||||
| (ItemDecision.project_id == project_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
if category_id:
|
||||
stmt = stmt.where(_quote_category_exists(category_id))
|
||||
if quote_date_from is not None:
|
||||
|
||||
@@ -58,11 +58,7 @@ async def list_supplier_materials(
|
||||
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}
|
||||
return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta}
|
||||
|
||||
|
||||
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
|
||||
@@ -72,8 +68,7 @@ async def create_supplier_material(
|
||||
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)}
|
||||
return {"data": link_to_dict(link, material, None)}
|
||||
|
||||
|
||||
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
|
||||
|
||||
@@ -89,7 +89,6 @@ class MaterialAttributeIn(APIModel):
|
||||
|
||||
|
||||
class MaterialCreate(APIModel):
|
||||
category_id: UUID | None = None # v2.5: optional — derived from first linked supplier
|
||||
name: str = Field(min_length=1)
|
||||
topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy
|
||||
canonical_unit_id: int
|
||||
|
||||
@@ -30,6 +30,13 @@ class WarehouseCreate(APIModel):
|
||||
|
||||
|
||||
class WarehouseUpdate(APIModel):
|
||||
"""Rename/re-describe a warehouse.
|
||||
|
||||
`project_id` is accepted only to be rejected: a warehouse belongs
|
||||
permanently to its parent project and cannot be reassigned (the router
|
||||
answers 400 unless the value equals the current project).
|
||||
"""
|
||||
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
project_id: UUID | None = None
|
||||
|
||||
@@ -37,7 +37,7 @@ class SupplierUpdate(APIModel):
|
||||
|
||||
|
||||
class SupplierMaterialCreate(APIModel):
|
||||
"""Link a global material to a supplier (derives materials.category_id)."""
|
||||
"""Link a global material to a supplier."""
|
||||
|
||||
material_id: UUID
|
||||
|
||||
|
||||
@@ -206,11 +206,6 @@ async def _load_material_maps(db: AsyncSession, materials: list[Material], expan
|
||||
material_ids = [m.id for m in materials]
|
||||
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
|
||||
|
||||
if "category" in expand:
|
||||
cat_ids = {m.category_id for m in materials}
|
||||
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
|
||||
maps["categories"] = {c.id: c for c in rows}
|
||||
|
||||
unit_ids: set[int] = set()
|
||||
if "canonical_unit" in expand:
|
||||
unit_ids |= {m.canonical_unit_id for m in materials}
|
||||
@@ -258,9 +253,9 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
|
||||
"updated_at": to_iso(m.updated_at),
|
||||
}
|
||||
if "category" in expand:
|
||||
d["category"] = category_brief(maps["categories"].get(m.category_id))
|
||||
d["category"] = None
|
||||
else:
|
||||
d["category_id"] = str(m.category_id)
|
||||
d["category_id"] = None
|
||||
if "canonical_unit" in expand:
|
||||
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
|
||||
else:
|
||||
@@ -326,9 +321,6 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
|
||||
material_ids = {li.material_id for li in line_items}
|
||||
materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all()
|
||||
data.materials = {m.id: m for m in materials}
|
||||
cat_ids = {m.category_id for m in materials}
|
||||
cats = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all()
|
||||
data.categories = {c.id: c for c in cats}
|
||||
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
|
||||
brand_ids: set[uuid.UUID] = set()
|
||||
for bm in bms:
|
||||
@@ -450,7 +442,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
|
||||
d["material"] = {
|
||||
"id": str(m.id),
|
||||
"name": m.name,
|
||||
"category": category_brief(data.categories.get(m.category_id)),
|
||||
"category": None,
|
||||
"brands": [
|
||||
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
|
||||
for bm in data.material_brands.get(m.id, [])
|
||||
|
||||
@@ -12,7 +12,6 @@ from app.models.catalog import (
|
||||
BrandMaterial,
|
||||
Material,
|
||||
MaterialAttributeValue,
|
||||
MaterialCategory,
|
||||
Unit,
|
||||
)
|
||||
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
|
||||
@@ -110,9 +109,7 @@ async def validate_attributes(
|
||||
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
|
||||
"""Create a material + brand_materials rows + EAV values in ONE transaction.
|
||||
|
||||
v2.5/2.6: category_id is OPTIONAL — a global material may be created
|
||||
without a category (it gets derived automatically from the first linked
|
||||
supplier). name is GLOBALLY unique.
|
||||
v2.6/v2.7: materials are global and category-free. name is GLOBALLY unique.
|
||||
"""
|
||||
async with db.begin():
|
||||
duplicate = await db.scalar(
|
||||
@@ -121,14 +118,10 @@ async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material
|
||||
if duplicate is not None:
|
||||
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
|
||||
|
||||
if payload.category_id is not None:
|
||||
if await db.get(MaterialCategory, payload.category_id) is None:
|
||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
||||
if not await db.get(Unit, payload.canonical_unit_id):
|
||||
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
|
||||
|
||||
material = Material(
|
||||
category_id=payload.category_id,
|
||||
name=payload.name,
|
||||
topic=payload.topic,
|
||||
canonical_unit_id=payload.canonical_unit_id,
|
||||
|
||||
@@ -918,37 +918,57 @@ async def quote_stats(
|
||||
|
||||
|
||||
def _quote_has_category(category_id: UUID):
|
||||
"""EXISTS: quote has a line item whose material belongs to category_id."""
|
||||
from app.models.catalog import Material
|
||||
|
||||
"""EXISTS: quote's supplier belongs to category_id."""
|
||||
sub = (
|
||||
select(1)
|
||||
.where(
|
||||
LineItem.quote_id == Quote.id,
|
||||
Material.id == LineItem.material_id,
|
||||
Material.category_id == category_id,
|
||||
Supplier.id == Quote.supplier_id,
|
||||
Supplier.category_id == category_id,
|
||||
)
|
||||
)
|
||||
return func.exists(sub)
|
||||
|
||||
|
||||
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
||||
"""Counts + total value per quote status for one project.
|
||||
"""Counts + total value per quote status for ONE project, isolated per project.
|
||||
|
||||
total_value: SUM(quantity × unit_price) over line items using the chosen
|
||||
termsheet link first, else the first link. Rejected line items are
|
||||
excluded from value sums (contract P1-14).
|
||||
A quote lands in this project's «accepted»/«arrived» bucket ONLY if at
|
||||
least one non-rejected line item's decision targets this project
|
||||
(item_decisions.project_id). A quote merely linked to the project as a
|
||||
candidate (quote-level link, line-item candidates, or accepted for a
|
||||
DIFFERENT project) stays in «offered» — this project never accepted it.
|
||||
|
||||
total_value: SUM(quantity × unit_price) over the bucket's line items,
|
||||
using the chosen termsheet link first, else the first link. Rejected
|
||||
line items are excluded from value sums (contract P1-14).
|
||||
"""
|
||||
project = await db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise NotFoundError("پروژه یافت نشد")
|
||||
# the route passes the raw path string — normalize for the Python-side
|
||||
# UUID comparisons below (decision.project_id == project_id etc.)
|
||||
project_id = UUID(str(project_id))
|
||||
|
||||
quotes = (await db.execute(select(Quote).where(Quote.project_id == project_id))).scalars().all()
|
||||
if not quotes:
|
||||
# --- every quote RELEVANT to this project (any kind of link) ---
|
||||
quote_id_rows = await db.execute(
|
||||
select(Quote.id)
|
||||
.outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id)
|
||||
.outerjoin(LineItem, LineItem.quote_id == Quote.id)
|
||||
.outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id)
|
||||
.outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id)
|
||||
.where(
|
||||
(Quote.project_id == project_id)
|
||||
| (QuoteProjects.project_id == project_id)
|
||||
| (LineItemProjects.project_id == project_id)
|
||||
| (ItemDecision.project_id == project_id)
|
||||
)
|
||||
)
|
||||
quote_ids = [row[0] for row in quote_id_rows.all()]
|
||||
if not quote_ids:
|
||||
empty = {status: {"count": 0, "total_value": 0} for status in QUOTE_STATUSES}
|
||||
return {"project_id": str(project_id), "total_quotes": 0, **empty}
|
||||
|
||||
quote_ids = [q.id for q in quotes]
|
||||
quotes = (await db.execute(select(Quote).where(Quote.id.in_(quote_ids)))).scalars().all()
|
||||
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
|
||||
li_ids = [li.id for li in line_items]
|
||||
|
||||
@@ -956,6 +976,18 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
||||
d.line_item_id: d
|
||||
for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
|
||||
} if li_ids else {}
|
||||
# line items explicitly linked to this project as candidates
|
||||
candidate_li_ids = set(
|
||||
(
|
||||
await db.execute(
|
||||
select(LineItemProjects.line_item_id).where(
|
||||
LineItemProjects.line_item_id.in_(li_ids),
|
||||
LineItemProjects.project_id == project_id,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
) if li_ids else set()
|
||||
|
||||
item_termsheets = (
|
||||
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
||||
) if li_ids else []
|
||||
@@ -964,12 +996,56 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
||||
for it in item_termsheets:
|
||||
its_by_line.setdefault(it.line_item_id, []).append(it)
|
||||
|
||||
def _items_of(quote: Quote) -> list[LineItem]:
|
||||
return [li for li in line_items if li.quote_id == quote.id]
|
||||
|
||||
def _accepted_for_project(quote: Quote) -> bool:
|
||||
"""≥1 non-rejected line item whose decision targets THIS project."""
|
||||
return any(
|
||||
(decision := decisions.get(li.id)) is not None
|
||||
and decision.status == "chosen"
|
||||
and decision.project_id == project_id
|
||||
for li in _items_of(quote)
|
||||
)
|
||||
|
||||
def _has_any_target(quote: Quote) -> bool:
|
||||
"""Whether any decision of this quote carries a target project at all
|
||||
(legacy quotes accepted before the decision-project era have none —
|
||||
they keep the old global-status classification)."""
|
||||
return any(
|
||||
(decisions.get(li.id) is not None and decisions[li.id].project_id is not None)
|
||||
for li in _items_of(quote)
|
||||
)
|
||||
|
||||
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
|
||||
for q in quotes:
|
||||
per_status[q.status]["count"] += 1
|
||||
if q.status in ("accepted", "arrived") and _has_any_target(q) and not _accepted_for_project(q):
|
||||
per_status["offered"]["count"] += 1 # linked, but never accepted for THIS project
|
||||
else:
|
||||
per_status[q.status]["count"] += 1
|
||||
|
||||
for li in line_items:
|
||||
quote = next(q for q in quotes if q.id == li.quote_id)
|
||||
decision = decisions.get(li.id)
|
||||
|
||||
# --- which status bucket does this line item's value belong to? ---
|
||||
accepted_here = (
|
||||
decision is not None
|
||||
and decision.status == "chosen"
|
||||
and decision.project_id == project_id
|
||||
)
|
||||
if quote.status in ("accepted", "arrived") and _has_any_target(quote):
|
||||
if accepted_here:
|
||||
bucket = quote.status
|
||||
elif li.id in candidate_li_ids:
|
||||
# the quote is still just a proposal for this project — the
|
||||
# item's candidate value stays in the offered bucket
|
||||
bucket = "offered"
|
||||
else:
|
||||
continue # this item targets another project entirely
|
||||
else:
|
||||
bucket = quote.status
|
||||
|
||||
if decision is not None and decision.status == "rejected":
|
||||
continue # rejected items excluded from value sums
|
||||
if decision is not None and decision.chosen_item_termsheet_id is not None:
|
||||
@@ -979,8 +1055,7 @@ async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
|
||||
price_it = links[0] if links else None
|
||||
if price_it is None:
|
||||
continue
|
||||
quote = next(q for q in quotes if q.id == li.quote_id)
|
||||
per_status[quote.status]["total_value"] += float(price_it.unit_price * li.quantity)
|
||||
per_status[bucket]["total_value"] += float(price_it.unit_price * li.quantity)
|
||||
|
||||
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
|
||||
for status, values in per_status.items():
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
"""Supplier ↔ material link service (v2.5) — the direct edge of the new flow.
|
||||
"""Supplier ↔ material link service (v2.6/v2.7) — the direct edge of the catalog flow.
|
||||
|
||||
Linking a material to a supplier derives materials.category_id from the
|
||||
supplier's category when the material has none (first-link-wins). The same
|
||||
rule is enforced by the DB trigger trg_supplier_materials_derive_category;
|
||||
this service applies it in the application layer so the ORM state is
|
||||
consistent inside the same transaction.
|
||||
Suppliers can supply any global material across categories.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -17,7 +13,7 @@ from app.utils.errors import NotFoundError, ValidationError
|
||||
async def link_supplier_material(
|
||||
db: AsyncSession, supplier_id, material_id, is_active: bool = True
|
||||
) -> SupplierMaterial:
|
||||
"""Upsert a supplier<->material link (idempotent) + derive category (first-link-wins).
|
||||
"""Upsert a supplier<->material link (idempotent).
|
||||
|
||||
Caller is responsible for the surrounding transaction.
|
||||
"""
|
||||
@@ -42,15 +38,11 @@ async def link_supplier_material(
|
||||
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
|
||||
db.add(link)
|
||||
await db.flush()
|
||||
|
||||
# First-link-wins: only when the material has no category yet.
|
||||
if material.category_id is None:
|
||||
material.category_id = supplier.category_id
|
||||
return link
|
||||
|
||||
|
||||
async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None:
|
||||
"""Remove the link. The material's category is NEVER auto-cleared (documented rule)."""
|
||||
"""Remove the link."""
|
||||
link = await db.scalar(
|
||||
select(SupplierMaterial).where(
|
||||
SupplierMaterial.supplier_id == supplier_id,
|
||||
|
||||
@@ -261,9 +261,7 @@ async def run() -> None:
|
||||
material_by_name: dict[str, Material] = {}
|
||||
brand_material_by_material: dict[str, object] = {}
|
||||
for name, cat_slug, unit_code, brand_name in MATERIALS:
|
||||
cat = await get_category(db, cat_slug)
|
||||
material = Material(
|
||||
category_id=cat.id,
|
||||
name=name,
|
||||
canonical_unit_id=unit_ids[unit_code],
|
||||
status="active",
|
||||
@@ -289,8 +287,10 @@ async def run() -> None:
|
||||
await db.flush()
|
||||
suppliers.append(sup)
|
||||
# last-quoted prices: link each supplier to 2 materials of its category
|
||||
sup_cat_slug = {name: cat_slug for name, cat_slug, *_ in SUPPLIERS}
|
||||
for sup in suppliers:
|
||||
cat_materials = [m for m in material_by_name.values() if m.category_id == sup.category_id]
|
||||
target_slug = sup_cat_slug.get(sup.name)
|
||||
cat_materials = [material_by_name[mat_name] for mat_name, c_slug, *_ in MATERIALS if c_slug == target_slug]
|
||||
for m in cat_materials[:2]:
|
||||
db.add(SupplierMaterial(
|
||||
supplier_id=sup.id, material_id=m.id, is_active=True,
|
||||
|
||||
Reference in New Issue
Block a user