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
View File
+74
View File
@@ -0,0 +1,74 @@
"""Shared EAV flattening utility.
Every endpoint that returns materials (``material_attribute_values``) or
line items (``line_item_specs``) with expand uses this single function to
produce the contract shape:
[{"attribute_key", "label", "data_type", "value", "unit"}]
value extraction:
number/integer -> value_number (integer cast for `integer`)
string/enum -> value_string
boolean -> value_boolean
date -> value_date (ISO string)
unit resolution:
prefer row.unit_id_at_time when available (prefer_unit_id_at_time=True),
else fall back to attribute_definitions.unit_id.
"""
from app.models.catalog import AttributeDefinition, MaterialAttributeValue
from app.models.procurement import LineItemSpec
_EAVRow = MaterialAttributeValue | LineItemSpec
def _extract_value(ad: AttributeDefinition, row: _EAVRow):
data_type = ad.data_type
if data_type in ("number", "integer"):
if row.value_number is None:
return None
return int(row.value_number) if data_type == "integer" else float(row.value_number)
if data_type in ("string", "enum"):
return row.value_string
if data_type == "boolean":
return row.value_boolean
if data_type == "date":
return row.value_date.isoformat() if row.value_date is not None else None
return None
def _resolve_unit(row: _EAVRow, ad: AttributeDefinition, unit_map: dict, prefer_unit_id_at_time: bool):
unit_id = None
if prefer_unit_id_at_time and hasattr(row, "unit_id_at_time") and row.unit_id_at_time is not None:
unit_id = row.unit_id_at_time
elif ad.unit_id is not None:
unit_id = ad.unit_id
unit = unit_map.get(unit_id) if unit_id is not None else None
if unit is None:
return None
return {"id": unit.id, "code": unit.code}
def flatten_eav_values(
rows: list[_EAVRow],
attr_defs_map: dict,
unit_map: dict,
prefer_unit_id_at_time: bool = True,
include_definition_id: bool = False,
include_data_type: bool = True,
) -> list[dict]:
"""Flatten typed EAV rows into the contract's `value`/`unit` shape."""
result: list[dict] = []
for row in rows:
ad = attr_defs_map[row.attribute_definition_id]
item: dict = {}
if include_definition_id:
item["attribute_definition_id"] = str(row.attribute_definition_id)
item["attribute_key"] = ad.attribute_key
item["label"] = ad.label
if include_data_type:
item["data_type"] = ad.data_type
item["value"] = _extract_value(ad, row)
item["unit"] = _resolve_unit(row, ad, unit_map, prefer_unit_id_at_time)
result.append(item)
return result
+657
View File
@@ -0,0 +1,657 @@
"""Expand-aware serialization.
Given a set of expand tokens, serializers resolve FK IDs into the nested
object shapes defined by the contracts (expert-opinion-p0/p1/p2).
All list serializers batch-load their dependencies (no N+1): one round of
queries per related table, then assembly from in-memory maps.
"""
import uuid
from datetime import date
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.catalog import (
AttributeDefinition,
Brand,
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialCategory,
Unit,
)
from app.models.procurement import (
ItemDecision,
ItemReceipt,
ItemTermsheet,
LineItem,
LineItemProjects,
LineItemSpec,
Project,
Quote,
QuoteEvent,
QuoteProjects,
Supplier,
Termsheet,
TermsheetScheduleStep,
)
from app.services.eav_service import flatten_eav_values
from app.utils.serialization import to_date, to_iso
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
def parse_expand(expand: str | None) -> set[str]:
if not expand:
return set()
return {token.strip() for token in expand.split(",") if token.strip()}
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 category_brief(category: MaterialCategory | None) -> dict | None:
if category is None:
return None
return {"id": str(category.id), "slug": category.slug, "name": category.name}
def supplier_brief(supplier: Supplier | None) -> dict | None:
if supplier is None:
return None
return {"id": str(supplier.id), "name": supplier.name, "location": supplier.location}
def project_brief(project: Project | None) -> dict | None:
if project is None:
return None
return {"id": str(project.id), "name": project.name}
def decision_brief(d: ItemDecision) -> dict:
"""Shape used inside expanded line items (P0-14)."""
return {
"id": str(d.id),
"status": d.status,
"chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None,
"project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project
"adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None,
"decided_at": to_iso(d.decided_at),
}
async def _attach_quote_projects(db: AsyncSession, quotes: list[Quote], out: dict) -> None:
"""Batch-load quote_projects junction rows and attach a `projects` array."""
quote_ids = [q.id for q in quotes]
qp_rows = (
(
await db.execute(
select(QuoteProjects).where(QuoteProjects.quote_id.in_(quote_ids))
)
)
.scalars()
.all()
) if quote_ids else []
proj_ids = {r.project_id for r in qp_rows}
proj_map: dict = {}
if proj_ids:
projs = (await db.execute(select(Project).where(Project.id.in_(proj_ids)))).scalars().all()
proj_map = {p.id: p for p in projs}
projects_by_quote: dict = {}
for r in qp_rows:
projects_by_quote.setdefault(r.quote_id, []).append(proj_map[r.project_id])
for q in quotes:
out[q.id]["projects"] = [
project_brief(p) for p in projects_by_quote.get(q.id, []) if p is not None
]
def decision_full(d: ItemDecision) -> dict:
"""Shape used by the decision endpoints (P1-21)."""
return {
"id": str(d.id),
"line_item_id": str(d.line_item_id),
"chosen_item_termsheet_id": str(d.chosen_item_termsheet_id) if d.chosen_item_termsheet_id else None,
"status": d.status,
"project_id": str(d.project_id) if d.project_id else None, # v2.6: the single purchased project
"adjusted_unit_price": float(d.adjusted_unit_price) if d.adjusted_unit_price is not None else None,
"adjusted_notes": d.adjusted_notes,
"decided_at": to_iso(d.decided_at),
}
def receipt_brief(r: ItemReceipt) -> dict:
"""Shape used inside expanded line items (P0-14)."""
return {
"id": str(r.id),
"received_qty": float(r.received_qty),
"received_at": to_iso(r.received_at),
"on_time": r.on_time,
}
def receipt_full(r: ItemReceipt) -> dict:
"""Shape used by the receipt endpoints (P1-25)."""
return {
"id": str(r.id),
"item_decision_id": str(r.item_decision_id),
"received_at": to_iso(r.received_at),
"received_qty": float(r.received_qty),
"on_time": r.on_time,
"quality_note": r.quality_note,
}
def event_to_dict(e: QuoteEvent) -> dict:
return {
"id": str(e.id),
"quote_id": str(e.quote_id),
"event_type": e.event_type,
"payload": e.payload,
"occurred_at": to_iso(e.occurred_at),
"actor": e.actor,
}
def schedule_step_to_dict(s: TermsheetScheduleStep) -> dict:
return {
"id": str(s.id),
"step_no": s.step_no,
"due_date": to_date(s.due_date),
"amount": float(s.amount),
"note": s.note,
}
# ---------------------------------------------------------------------------
# Quote (flat)
# ---------------------------------------------------------------------------
def quote_flat(q: Quote) -> dict:
d = {
"id": str(q.id),
"supplier_id": str(q.supplier_id),
"project_id": str(q.project_id) if q.project_id else None,
"quote_date": to_iso(q.quote_date),
"expiry_date": to_date(q.expiry_date),
"accepted_date": to_iso(q.accepted_date),
"arrived_date": to_iso(q.arrived_date),
"status": q.status,
"is_official": q.is_official,
"notes": q.notes,
"created_at": to_iso(q.created_at),
"updated_at": to_iso(q.updated_at),
}
# Lazy auto-expiry flag (contract P0-14): never changes the stored status.
if q.status == "offered" and q.expiry_date is not None and q.expiry_date < date.today():
d["is_expired"] = True
return d
# ---------------------------------------------------------------------------
# Materials
# ---------------------------------------------------------------------------
async def _load_material_maps(db: AsyncSession, materials: list[Material], expand: set[str]) -> dict:
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}
if "brands" in expand:
bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all()
by_material: dict[uuid.UUID, list[BrandMaterial]] = {}
brand_ids: set[uuid.UUID] = set()
for bm in bms:
by_material.setdefault(bm.material_id, []).append(bm)
brand_ids.add(bm.brand_id)
maps["material_brands"] = by_material
if brand_ids:
brands = (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars().all()
maps["brands"] = {b.id: b for b in brands}
if "attributes" in expand:
mavs = (
await db.execute(
select(MaterialAttributeValue)
.where(MaterialAttributeValue.material_id.in_(material_ids))
.order_by(MaterialAttributeValue.attribute_definition_id)
)
).scalars().all()
maps["mavs"] = mavs
def_ids = {row.attribute_definition_id for row in mavs}
if def_ids:
defs = (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars().all()
maps["attr_defs"] = {d.id: d for d in defs}
unit_ids |= {d.unit_id for d in defs if d.unit_id is not None}
if unit_ids:
units = (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars().all()
maps["units"] = {u.id: u for u in units}
return maps
def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
d = {
"id": str(m.id),
"name": m.name,
"topic": m.topic,
"status": m.status,
"created_at": to_iso(m.created_at),
"updated_at": to_iso(m.updated_at),
}
if "category" in expand:
d["category"] = category_brief(maps["categories"].get(m.category_id))
else:
d["category_id"] = str(m.category_id)
if "canonical_unit" in expand:
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
else:
d["canonical_unit_id"] = m.canonical_unit_id
if "brands" in expand:
d["brands"] = [
{
"id": str(bm.brand_id),
"name": maps["brands"][bm.brand_id].name,
"catalog_code": bm.catalog_code,
"is_active": bm.is_active,
}
for bm in maps["material_brands"].get(m.id, [])
if bm.brand_id in maps["brands"]
]
if "attributes" in expand:
rows = [row for row in maps["mavs"] if row.material_id == m.id]
d["attributes"] = flatten_eav_values(rows, maps["attr_defs"], maps["units"], prefer_unit_id_at_time=False)
return d
async def serialize_materials(db: AsyncSession, materials: list[Material], expand: set[str]) -> list[dict]:
if not materials:
return []
maps = await _load_material_maps(db, materials, expand)
return [_material_to_dict(m, expand, maps) for m in materials]
async def serialize_material(db: AsyncSession, material: Material, expand: set[str]) -> dict:
return (await serialize_materials(db, [material], expand))[0]
# ---------------------------------------------------------------------------
# Line items
# ---------------------------------------------------------------------------
class _LineItemData:
"""Batch-loaded dependency maps for a set of line items."""
def __init__(self):
self.materials: dict[uuid.UUID, Material] = {}
self.categories: dict[uuid.UUID, MaterialCategory] = {}
self.units: dict[int, Unit] = {}
self.brands: dict[uuid.UUID, Brand] = {}
self.material_brands: dict[uuid.UUID, list[BrandMaterial]] = {}
self.specs_by_line: dict[uuid.UUID, list[LineItemSpec]] = {}
self.attr_defs: dict[uuid.UUID, AttributeDefinition] = {}
self.decisions: dict[uuid.UUID, ItemDecision] = {}
self.receipts_by_decision: dict[uuid.UUID, ItemReceipt] = {}
self.item_termsheets_by_line: dict[uuid.UUID, list[ItemTermsheet]] = {}
self.item_termsheets_by_id: dict[uuid.UUID, ItemTermsheet] = {}
self.projects_by_line: dict[uuid.UUID, list[Project]] = {}
async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], expand: set[str]) -> _LineItemData:
data = _LineItemData()
li_ids = [li.id for li in line_items]
unit_ids: set[int] = {li.unit_id for li in line_items}
if "material" in expand:
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:
data.material_brands.setdefault(bm.material_id, []).append(bm)
brand_ids.add(bm.brand_id)
if brand_ids:
brands = (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars().all()
data.brands = {b.id: b for b in brands}
# Pricing always needs item_termsheets in expanded mode.
its = (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
for it in its:
data.item_termsheets_by_line.setdefault(it.line_item_id, []).append(it)
data.item_termsheets_by_id[it.id] = it
# Per-material project allocation — always attached in expanded mode.
lip_rows = (
(await db.execute(select(LineItemProjects).where(LineItemProjects.line_item_id.in_(li_ids)))).scalars().all()
) if li_ids else []
proj_ids = {r.project_id for r in lip_rows}
proj_map: dict[uuid.UUID, Project] = {}
if proj_ids:
projs = (await db.execute(select(Project).where(Project.id.in_(proj_ids)))).scalars().all()
proj_map = {p.id: p for p in projs}
for r in lip_rows:
if r.project_id in proj_map:
data.projects_by_line.setdefault(r.line_item_id, []).append(proj_map[r.project_id])
if "specs" in expand:
specs = (
await db.execute(
select(LineItemSpec)
.where(LineItemSpec.line_item_id.in_(li_ids))
.order_by(LineItemSpec.attribute_definition_id)
)
).scalars().all()
for spec in specs:
data.specs_by_line.setdefault(spec.line_item_id, []).append(spec)
def_ids = {spec.attribute_definition_id for spec in specs}
if def_ids:
defs = (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars().all()
data.attr_defs = {d.id: d for d in defs}
unit_ids |= {d.unit_id for d in defs if d.unit_id is not None}
unit_ids |= {spec.unit_id_at_time for spec in specs if spec.unit_id_at_time is not None}
if "decision" in expand or "receipt" in expand:
decisions = (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
data.decisions = {d.line_item_id: d for d in decisions}
dec_ids = [d.id for d in decisions]
if dec_ids:
receipts = (await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
data.receipts_by_decision = {r.item_decision_id: r for r in receipts}
if unit_ids:
units = (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars().all()
data.units = {u.id: u for u in units}
return data
def _line_item_flat(li: LineItem) -> dict:
return {
"id": str(li.id),
"quote_id": str(li.quote_id),
"material_id": str(li.material_id),
"brand_material_id": str(li.brand_material_id) if li.brand_material_id else None,
"unit_id": li.unit_id,
"quantity": float(li.quantity),
"notes": li.notes,
"created_at": to_iso(li.created_at),
}
def _price_source(li: LineItem, data: _LineItemData) -> ItemTermsheet | None:
"""unit_price source: the item's chosen termsheet link, else first link (P0-14)."""
decision = data.decisions.get(li.id)
if decision is not None and decision.chosen_item_termsheet_id is not None:
chosen = data.item_termsheets_by_id.get(decision.chosen_item_termsheet_id)
if chosen is not None:
return chosen
links = data.item_termsheets_by_line.get(li.id, [])
return links[0] if links else None
def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, include_termsheet_ids: bool) -> dict:
d: dict = {"id": str(li.id)}
if "material" in expand:
m = data.materials.get(li.material_id)
if m is not None:
d["material"] = {
"id": str(m.id),
"name": m.name,
"category": category_brief(data.categories.get(m.category_id)),
"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, [])
if bm.brand_id in data.brands
],
}
else:
d["material_id"] = str(li.material_id)
else:
d["material_id"] = str(li.material_id)
d["brand_material_id"] = str(li.brand_material_id) if li.brand_material_id else None
if "unit" in expand:
d["unit"] = unit_to_dict(data.units.get(li.unit_id))
else:
d["unit_id"] = li.unit_id
d["quantity"] = float(li.quantity)
price_it = _price_source(li, data)
d["unit_price"] = float(price_it.unit_price) if price_it is not None else None
d["total_price"] = float(price_it.unit_price * li.quantity) if price_it is not None else None
d["projects"] = [project_brief(p) for p in data.projects_by_line.get(li.id, [])]
if "specs" in expand:
d["specs"] = flatten_eav_values(
data.specs_by_line.get(li.id, []), data.attr_defs, data.units, include_data_type=False
)
decision = data.decisions.get(li.id)
if "decision" in expand:
d["decision"] = decision_brief(decision) if decision is not None else None
# chosen_item_termsheet_id یک id از جدول item_termsheets است؛ برای مقایسه با
# termsheet_ids در جلوی برنامه، id ترم‌شیت واقعی هم ارسال می‌شود.
if decision is not None and decision.chosen_item_termsheet_id is not None:
chosen_it = data.item_termsheets_by_id.get(decision.chosen_item_termsheet_id)
if chosen_it is not None:
d["accepted_termsheet_id"] = str(chosen_it.termsheet_id)
if "receipt" in expand:
r = data.receipts_by_decision.get(decision.id) if decision is not None else None
d["receipt"] = receipt_brief(r) if r is not None else None
if include_termsheet_ids:
d["termsheet_ids"] = [str(it.termsheet_id) for it in data.item_termsheets_by_line.get(li.id, [])]
return d
async def serialize_line_items(
db: AsyncSession, line_items: list[LineItem], expand: set[str], include_termsheet_ids: bool = False
) -> list[dict]:
if not line_items:
return []
if not expand:
return [_line_item_flat(li) for li in line_items]
data = await _load_line_item_data(db, line_items, expand)
return [_line_item_expanded(li, expand, data, include_termsheet_ids) for li in line_items]
# ---------------------------------------------------------------------------
# Termsheets
# ---------------------------------------------------------------------------
def _termsheet_flat(ts: Termsheet) -> dict:
return {
"id": str(ts.id),
"quote_id": str(ts.quote_id),
"label": ts.label,
"status": ts.status,
"discount_type": ts.discount_type,
"discount_value": float(ts.discount_value),
"created_at": to_iso(ts.created_at),
}
def _termsheet_quote_shape(ts: Termsheet, steps: list[TermsheetScheduleStep]) -> dict:
"""Shape inside expanded quotes (P0-14): no quote_id/created_at, steps nested."""
return {
"id": str(ts.id),
"label": ts.label,
"status": ts.status,
"discount_type": ts.discount_type,
"discount_value": float(ts.discount_value),
"schedule_steps": [schedule_step_to_dict(s) for s in steps],
}
async def serialize_termsheets(db: AsyncSession, termsheets: list[Termsheet], expand: set[str]) -> list[dict]:
if not termsheets:
return []
result = [_termsheet_flat(ts) for ts in termsheets]
ts_ids = [ts.id for ts in termsheets]
if "schedule_steps" in expand:
steps = (
await db.execute(
select(TermsheetScheduleStep)
.where(TermsheetScheduleStep.termsheet_id.in_(ts_ids))
.order_by(TermsheetScheduleStep.step_no)
)
).scalars().all()
grouped: dict[uuid.UUID, list[TermsheetScheduleStep]] = {}
for s in steps:
grouped.setdefault(s.termsheet_id, []).append(s)
for ts in termsheets:
for item in result:
if item["id"] == str(ts.id):
item["schedule_steps"] = [schedule_step_to_dict(s) for s in grouped.get(ts.id, [])]
break
if "line_items" in expand:
its = (await db.execute(select(ItemTermsheet).where(ItemTermsheet.termsheet_id.in_(ts_ids)))).scalars().all()
li_ids = [it.line_item_id for it in its]
quantities: dict[uuid.UUID, float] = {}
if li_ids:
rows = (await db.execute(select(LineItem).where(LineItem.id.in_(li_ids)))).scalars().all()
quantities = {li.id: float(li.quantity) for li in rows}
grouped_its: dict[uuid.UUID, list[ItemTermsheet]] = {}
for it in its:
grouped_its.setdefault(it.termsheet_id, []).append(it)
for ts in termsheets:
links = grouped_its.get(ts.id, [])
items = []
for it in links:
qty = quantities.get(it.line_item_id, 0)
items.append(
{
"id": str(it.id),
"line_item_id": str(it.line_item_id),
"unit_price": float(it.unit_price),
"min_order_qty": float(it.min_order_qty) if it.min_order_qty is not None else None,
"total_price": float(it.unit_price * qty),
}
)
for item in result:
if item["id"] == str(ts.id):
item["line_items"] = items
break
return result
async def serialize_termsheet(db: AsyncSession, termsheet: Termsheet, expand: set[str]) -> dict:
return (await serialize_termsheets(db, [termsheet], expand))[0]
# ---------------------------------------------------------------------------
# Quotes
# ---------------------------------------------------------------------------
async def serialize_quotes(db: AsyncSession, quotes: list[Quote], expand: set[str]) -> list[dict]:
if not quotes:
return []
out: dict[uuid.UUID, dict] = {q.id: quote_flat(q) for q in quotes}
quote_ids = [q.id for q in quotes]
await _attach_quote_projects(db, quotes, out)
if "supplier" in expand:
sup_ids = {q.supplier_id for q in quotes}
sups = (await db.execute(select(Supplier).where(Supplier.id.in_(sup_ids)))).scalars().all()
sup_map = {s.id: s for s in sups}
for q in quotes:
out[q.id]["supplier"] = supplier_brief(sup_map.get(q.supplier_id))
if "project" in expand:
proj_ids = {q.project_id for q in quotes if q.project_id is not None}
proj_map: dict = {}
if proj_ids:
projs = (await db.execute(select(Project).where(Project.id.in_(proj_ids)))).scalars().all()
proj_map = {p.id: p for p in projs}
for q in quotes:
out[q.id]["project"] = project_brief(proj_map.get(q.project_id)) if q.project_id else None
if "events" in expand:
events = (
await db.execute(
select(QuoteEvent)
.where(QuoteEvent.quote_id.in_(quote_ids))
.order_by(QuoteEvent.occurred_at.asc(), QuoteEvent.id)
)
).scalars().all()
grouped: dict[uuid.UUID, list[QuoteEvent]] = {}
for e in events:
grouped.setdefault(e.quote_id, []).append(e)
for q in quotes:
out[q.id]["events"] = [event_to_dict(e) for e in grouped.get(q.id, [])]
if "line_items" in expand:
items = (await db.execute(select(LineItem).where(LineItem.quote_id.in_(quote_ids)))).scalars().all()
grouped_li: dict[uuid.UUID, list[LineItem]] = {}
for li in items:
grouped_li.setdefault(li.quote_id, []).append(li)
for q in quotes:
lis = grouped_li.get(q.id, [])
if lis:
# Contract P0-14: expand=line_items implies full nested resolution —
# material (category + brands), unit, specs, decision, receipt,
# termsheet_ids — regardless of other tokens.
out[q.id]["line_items"] = await serialize_line_items(
db, lis, {"material", "unit", "specs", "decision", "receipt"}, include_termsheet_ids=True
)
else:
out[q.id]["line_items"] = []
if "termsheets" in expand:
termsheets = (
(await db.execute(select(Termsheet).where(Termsheet.quote_id.in_(quote_ids)))).scalars().all()
)
grouped_ts: dict[uuid.UUID, list[Termsheet]] = {}
for ts in termsheets:
grouped_ts.setdefault(ts.quote_id, []).append(ts)
all_ts_ids = [ts.id for ts in termsheets]
steps_by_ts: dict[uuid.UUID, list[TermsheetScheduleStep]] = {}
if all_ts_ids:
steps = (
await db.execute(
select(TermsheetScheduleStep)
.where(TermsheetScheduleStep.termsheet_id.in_(all_ts_ids))
.order_by(TermsheetScheduleStep.step_no)
)
).scalars().all()
for s in steps:
steps_by_ts.setdefault(s.termsheet_id, []).append(s)
for q in quotes:
out[q.id]["termsheets"] = [
_termsheet_quote_shape(ts, steps_by_ts.get(ts.id, [])) for ts in grouped_ts.get(q.id, [])
]
return [out[q.id] for q in quotes]
async def serialize_quote(db: AsyncSession, quote: Quote, expand: set[str]) -> dict:
return (await serialize_quotes(db, [quote], expand))[0]
async def serialize_quote_with_children(db: AsyncSession, quote: Quote) -> dict:
"""POST /quotes response shape: flat quote + flat line_items + flat termsheets."""
d = quote_flat(quote)
out = {quote.id: d}
await _attach_quote_projects(db, [quote], out)
d = out[quote.id]
lis = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
d["line_items"] = [_line_item_flat(li) for li in lis]
tss = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
d["termsheets"] = [_termsheet_flat(ts) for ts in tss]
return d
+76
View File
@@ -0,0 +1,76 @@
"""Invoice / proforma attachments — metadata in `quote_attachments`, bytes in MinIO.
Multi-file contract: a quote may hold any number of attachments per kind
(invoice / proforma). Every upload creates a new DB row and a new object
(object key `{quote_id}/{kind}/{attachment_id}` inside the MinIO bucket).
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.files import QuoteAttachment
from app.services import storage
from app.utils.errors import NotFoundError
def _object_key(quote_id: uuid.UUID, kind: str, attachment_id: uuid.UUID) -> str:
return f"{quote_id}/{kind}/{attachment_id}"
async def create(
db: AsyncSession,
quote_id: uuid.UUID,
kind: str,
file_name: str,
content: bytes,
mime_type: str,
) -> QuoteAttachment:
"""Upload the bytes to MinIO, then persist the metadata row."""
attachment_id = uuid.uuid4()
object_key = _object_key(quote_id, kind, attachment_id)
await storage.put_object(object_key, content, mime_type)
attachment = QuoteAttachment(
id=attachment_id,
quote_id=quote_id,
kind=kind,
file_name=file_name,
mime_type=mime_type,
size_bytes=len(content),
object_key=object_key,
)
db.add(attachment)
try:
await db.flush()
except Exception:
# Metadata insert failed — best-effort cleanup of the orphan object.
await storage.remove_object(object_key)
raise
return attachment
async def list_for(db: AsyncSession, quote_id: uuid.UUID, kind: str) -> list[QuoteAttachment]:
result = await db.execute(
select(QuoteAttachment)
.where(QuoteAttachment.quote_id == quote_id, QuoteAttachment.kind == kind)
.order_by(QuoteAttachment.uploaded_at)
)
return list(result.scalars().all())
async def get(db: AsyncSession, attachment_id: uuid.UUID) -> QuoteAttachment:
attachment = await db.get(QuoteAttachment, attachment_id)
if attachment is None:
raise NotFoundError("فایل پیوست یافت نشد")
return attachment
async def read_bytes(attachment: QuoteAttachment) -> bytes:
"""Stream the object bytes from MinIO."""
return await storage.get_object_bytes(attachment.object_key)
async def remove(db: AsyncSession, attachment: QuoteAttachment) -> None:
"""Delete the object from MinIO (best-effort) and the metadata row."""
await storage.remove_object(attachment.object_key)
await db.delete(attachment)
+283
View File
@@ -0,0 +1,283 @@
"""Inventory business logic — applying transactions to stock quantities.
Merged from the software team's shipped inventory-dashboard fork, with the
v2.6 concurrency fix: every read-modify-write of `inventory.quantity`
locks the row with SELECT ... FOR UPDATE so concurrent PM entries cannot
lose updates.
Single source of truth for how each transaction type changes inventory:
- PURCHASE / RETURN / FOUND / TRANSFER_IN → increase (find-or-create row)
- WITHDRAWAL / DAMAGE / WASTE / LOSS / TRANSFER_OUT → decrease (row must exist,
quantity must be sufficient)
- STOCK_COUNT → records physical count; difference vs. recorded is applied
(increase / decrease / no difference) and logged as the transaction quantity.
- TRANSFER_OUT additionally creates the linked TRANSFER_IN row in the
destination warehouse (shared transfer_pair_id).
Inventory rows are uniquely identified by
(warehouse_id, material_id, brand_id, specs_hash) where specs_hash is the
SHA256 of the sorted canonical specs JSON (or 'default' when empty).
"""
import hashlib
import json
import uuid
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.catalog import Brand, Material, Unit
from app.models.inventory import (
DECREASING_TYPES,
INCREASING_TYPES,
InventoryItem,
InventoryTransaction,
Warehouse,
)
from app.schemas.inventory import InventoryTransactionCreate
from app.utils.errors import NotFoundError, ValidationError
def canonical_specs_hash(specs: dict | None) -> str:
"""SHA256 of the sorted canonical specs JSON — 'default' when empty."""
if not specs:
return "default"
canonical = json.dumps(specs, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
async def get_warehouse_or_404(db: AsyncSession, warehouse_id) -> Warehouse:
warehouse = await db.get(Warehouse, warehouse_id)
if warehouse is None:
raise NotFoundError("انبار یافت نشد", field="warehouse_id")
return warehouse
async def _find_inventory(
db: AsyncSession,
warehouse_id,
material_id,
brand_id,
specs_hash: str,
for_update: bool = False,
) -> InventoryItem | None:
"""Find the inventory row for (warehouse, material, brand, specs).
With for_update=True the row is locked with SELECT ... FOR UPDATE so
concurrent transactions cannot lose updates on quantity.
"""
stmt = select(InventoryItem).where(
InventoryItem.warehouse_id == 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,
)
if for_update:
stmt = stmt.with_for_update()
return await db.scalar(stmt)
async def _get_or_create_inventory(
db: AsyncSession,
warehouse_id,
material_id,
brand_id,
unit_id: int,
specs: dict | None,
specs_hash: str,
) -> InventoryItem:
"""Return the inventory row for (warehouse, material, brand, specs), creating it if missing."""
row = await _find_inventory(db, warehouse_id, material_id, brand_id, specs_hash, for_update=True)
if row is not None:
if row.unit_id != unit_id:
raise ValidationError(
f"واحد انتخاب‌شده با واحد موجودی فعلی ({row.unit_id}) متفاوت است — از همان واحد استفاده کنید",
field="unit_id",
)
return row
row = InventoryItem(
warehouse_id=warehouse_id,
material_id=material_id,
brand_id=brand_id,
quantity=Decimal("0"),
unit_id=unit_id,
specs=specs or {},
specs_hash=specs_hash,
)
db.add(row)
await db.flush()
return row
async def _validate_refs(db: AsyncSession, payload: InventoryTransactionCreate):
"""Validate material/brand/unit references before any write."""
material = await db.get(Material, payload.material_id)
if material is None:
raise NotFoundError("ماده اولیه یافت نشد", field="material_id")
brand = None
if payload.brand_id is not None:
brand = await db.get(Brand, payload.brand_id)
if brand is None:
raise NotFoundError("برند یافت نشد", field="brand_id")
if payload.unit_id is not None:
unit = await db.get(Unit, payload.unit_id)
if unit is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
return material, brand
async def apply_transaction(
db: AsyncSession,
payload: InventoryTransactionCreate,
performed_by: str | None = None,
) -> list[InventoryTransaction]:
"""Apply one inventory operation atomically. Returns the created transaction row(s).
Caller is responsible for the surrounding transaction (async with db.begin()).
"""
await _validate_refs(db, payload)
await get_warehouse_or_404(db, payload.warehouse_id)
t = payload.transaction_type
specs = payload.specs or {}
specs_hash = canonical_specs_hash(specs)
# STOCK_COUNT: apply the difference between counted and recorded quantity.
if t == "STOCK_COUNT":
row = await _find_inventory(
db, payload.warehouse_id, payload.material_id, payload.brand_id, specs_hash, for_update=True
)
if row is None:
raise ValidationError(
"برای این ماده هیچ موجودی ثبت نشده است — ابتدا یک خرید (PURCHASE) ثبت کنید",
field="material_id",
)
recorded = row.quantity
diff = payload.physical_qty - recorded
row.quantity = payload.physical_qty
tx = InventoryTransaction(
inventory_id=row.id,
warehouse_id=row.warehouse_id,
transaction_type="STOCK_COUNT",
quantity=diff,
recorded_qty=recorded,
physical_qty=payload.physical_qty,
performed_by=performed_by,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(tx)
await db.flush()
return [tx]
quantity = payload.quantity
if quantity is None or quantity <= 0:
raise ValidationError("تعداد باید بزرگ‌تر از صفر باشد", field="quantity")
if payload.unit_id is None:
raise ValidationError("واحد (unit_id) الزامی است", field="unit_id")
created: list[InventoryTransaction] = []
# TRANSFER_OUT: source decreases; destination (linked) increases.
if t == "TRANSFER_OUT":
source = await _find_inventory(
db, payload.warehouse_id, payload.material_id, payload.brand_id, specs_hash, for_update=True
)
if source is None:
raise ValidationError(
"برای این ماده در انبار مبدأ موجودی ثبت نشده است — ابتدا یک خرید (PURCHASE) ثبت کنید",
field="warehouse_id",
)
if source.quantity < quantity:
raise ValidationError(
f"موجودی کافی نیست — موجودی فعلی: {source.quantity}",
field="quantity",
)
await get_warehouse_or_404(db, payload.dest_warehouse_id)
source.quantity -= quantity
pair_id = uuid.uuid4()
out_tx = InventoryTransaction(
inventory_id=source.id,
warehouse_id=source.warehouse_id,
transaction_type="TRANSFER_OUT",
quantity=-quantity,
transfer_pair_id=pair_id,
performed_by=performed_by,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(out_tx)
created.append(out_tx)
dest = await _get_or_create_inventory(
db, payload.dest_warehouse_id, payload.material_id, payload.brand_id,
payload.unit_id, specs, specs_hash,
)
dest.quantity += quantity
in_tx = InventoryTransaction(
inventory_id=dest.id,
warehouse_id=dest.warehouse_id,
transaction_type="TRANSFER_IN",
quantity=quantity,
transfer_pair_id=pair_id,
performed_by=performed_by,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(in_tx)
created.append(in_tx)
await db.flush()
return created
# Increasing types: find-or-create the row, then add.
if t in INCREASING_TYPES:
row = await _get_or_create_inventory(
db, payload.warehouse_id, payload.material_id, payload.brand_id,
payload.unit_id, specs, specs_hash,
)
row.quantity += quantity
tx = InventoryTransaction(
inventory_id=row.id,
warehouse_id=row.warehouse_id,
transaction_type=t,
quantity=quantity,
performed_by=performed_by,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(tx)
await db.flush()
return [tx]
# Decreasing types: row must exist and have enough quantity.
if t in DECREASING_TYPES:
row = await _find_inventory(
db, payload.warehouse_id, payload.material_id, payload.brand_id, specs_hash, for_update=True
)
if row is None:
raise ValidationError(
"برای این ماده موجودی ثبت نشده است — ابتدا یک خرید (PURCHASE) ثبت کنید",
field="material_id",
)
if row.quantity < quantity:
raise ValidationError(
f"موجودی کافی نیست — موجودی فعلی: {row.quantity}",
field="quantity",
)
row.quantity -= quantity
tx = InventoryTransaction(
inventory_id=row.id,
warehouse_id=row.warehouse_id,
transaction_type=t,
quantity=-quantity,
performed_by=performed_by,
transaction_date=payload.transaction_date,
note=payload.note,
)
db.add(tx)
await db.flush()
return [tx]
raise ValidationError(f"نوع تراکنش نامعتبر است: {t}", field="transaction_type")
+197
View File
@@ -0,0 +1,197 @@
"""Material domain services: EAV value coercion/validation + atomic creation."""
from datetime import date
from decimal import Decimal, InvalidOperation
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.catalog import (
AttributeDefinition,
Brand,
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialCategory,
Unit,
)
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
from app.utils.errors import ConflictError, NotFoundError, ValidationError
def coerce_eav_value(data_type: str, value, field: str = "value"):
"""Coerce a client value against attribute_definitions.data_type.
Returns the Python value to store in the matching typed column.
Raises 400 VALIDATION_ERROR on mismatch (contract error shape).
"""
if data_type == "number":
try:
return Decimal(str(value))
except (ValueError, InvalidOperation, TypeError):
raise ValidationError("نوع مقدار با data_type سازگار نیست — expected number", field=field)
if data_type == "integer":
try:
if isinstance(value, str):
if "." in value or "e" in value.lower() or "E" in value:
raise ValueError
return int(value)
if isinstance(value, bool): # bool is an int subclass — reject it explicitly
raise ValueError
if isinstance(value, (int, float, Decimal)):
dec = Decimal(str(value))
if dec != dec.to_integral_value():
raise ValueError
return int(dec)
raise ValueError
except (ValueError, InvalidOperation, TypeError):
raise ValidationError("نوع مقدار با data_type سازگار نیست — expected integer", field=field)
if data_type in ("string", "enum"):
if value is None:
raise ValidationError("مقدار نمی‌تواند خالی باشد", field=field)
return str(value)
if data_type == "boolean":
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("true", "1", "yes"):
return True
if lowered in ("false", "0", "no"):
return False
raise ValidationError("نوع مقدار با data_type سازگار نیست — expected boolean", field=field)
if data_type == "date":
if isinstance(value, date):
return value
try:
return date.fromisoformat(str(value))
except ValueError:
raise ValidationError("نوع مقدار با data_type سازگار نیست — expected date (YYYY-MM-DD)", field=field)
raise ValidationError(f"data_type نامعتبر است: {data_type}", field="data_type")
def typed_column_values(data_type: str, coerced):
"""Map a coerced value to the EAV typed column dict."""
if data_type in ("number", "integer"):
return {"value_number": coerced}
if data_type in ("string", "enum"):
return {"value_string": coerced}
if data_type == "boolean":
return {"value_boolean": coerced}
if data_type == "date":
return {"value_date": coerced}
raise ValidationError(f"data_type نامعتبر است: {data_type}", field="data_type")
async def validate_attributes(
db: AsyncSession, attributes: list[MaterialAttributeIn]
) -> list[tuple[AttributeDefinition, dict]]:
"""Validate an EAV payload; returns [(definition, typed-column-dict)]."""
if not attributes:
return []
def_ids = {a.attribute_definition_id for a in attributes}
defs = {d.id: d for d in (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars()}
result: list[tuple[AttributeDefinition, dict]] = []
for index, attr in enumerate(attributes):
ad = defs.get(attr.attribute_definition_id)
if ad is None:
raise NotFoundError("ویژگی یافت نشد", field=f"attributes[{index}].attribute_definition_id")
field = f"attributes[{index}].value"
coerced = coerce_eav_value(ad.data_type, attr.value, field)
if ad.data_type == "enum":
allowed = ad.enum_values or []
if allowed and str(attr.value) not in {str(v) for v in allowed}:
raise ValidationError("مقدار در گزینه‌های مجاز این ویژگی نیست", field=field)
result.append((ad, typed_column_values(ad.data_type, coerced)))
return result
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.
"""
async with db.begin():
duplicate = await db.scalar(
select(Material.id).where(Material.name == payload.name)
)
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,
status=payload.status,
)
db.add(material)
await db.flush()
if payload.brand_ids:
brands = (await db.execute(select(Brand).where(Brand.id.in_(payload.brand_ids)))).scalars().all()
found = {b.id for b in brands}
for brand_id in payload.brand_ids:
if brand_id not in found:
raise NotFoundError("برند یافت نشد", field="brand_ids")
db.add(BrandMaterial(brand_id=brand_id, material_id=material.id))
validated = await validate_attributes(db, payload.attributes)
for ad, values in validated:
db.add(
MaterialAttributeValue(
material_id=material.id, attribute_definition_id=ad.id, **values
)
)
return material
async def update_material(db: AsyncSession, material: Material, payload: MaterialUpdate) -> Material:
"""Partial update (name, canonical_unit_id, status). EAV/brands are NOT edited here.
v2.5/2.6: category is DERIVED (from the first linked supplier) — it is
no longer part of the update payload.
"""
async with db.begin():
if payload.name is not None:
duplicate = await db.scalar(
select(Material.id).where(
Material.name == payload.name,
Material.id != material.id,
)
)
if duplicate is not None:
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
if payload.name is not None:
material.name = payload.name
if payload.topic is not None:
material.topic = payload.topic
if payload.canonical_unit_id is not None and payload.canonical_unit_id != material.canonical_unit_id:
if not await db.get(Unit, payload.canonical_unit_id):
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
material.canonical_unit_id = payload.canonical_unit_id
if payload.status is not None:
material.status = payload.status
return material
async def ensure_brand_material_belong(
db: AsyncSession, brand_material_id: UUID | None, material_id: UUID
) -> None:
"""App-layer integrity check: brand_material_id must belong to material_id (migration design note)."""
if brand_material_id is None:
return
bm = await db.get(BrandMaterial, 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")
+848
View File
@@ -0,0 +1,848 @@
"""Quote lifecycle services — nested creation, acceptance, arrival, transitions.
Every mutation here runs in ONE ``async with db.begin()`` block; any failure
rolls the whole operation back. All state changes log a quote_event in the
same transaction.
"""
import logging
from datetime import date, datetime, timezone
from uuid import UUID
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.procurement import (
ItemDecision,
ItemReceipt,
ItemTermsheet,
LineItem,
LineItemProjects,
Project,
Quote,
QuoteEvent,
QuoteProjects,
Supplier,
Termsheet,
TermsheetScheduleStep,
)
from app.schemas.procurement import (
AcceptQuoteIn,
ArriveItemsIn,
QuoteChildrenReplaceIn,
QuoteCreate,
QuoteUpdate,
RejectQuoteIn,
)
from app.services.material_service import ensure_brand_material_belong
from app.utils.errors import ConflictError, NotFoundError, ValidationError
logger = logging.getLogger(__name__)
QUOTE_STATUSES = ("offered", "accepted", "arrived", "rejected", "expired")
# Backward transitions allowed via PATCH /quotes/{id} (contract P0-17).
BACKWARD_TRANSITIONS: dict[str, set[str]] = {
"accepted": {"offered"},
"arrived": {"accepted", "offered"},
"rejected": {"offered"},
"expired": {"offered"},
"offered": set(),
}
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def _add_event(db: AsyncSession, quote_id: UUID, event_type: str, payload: dict | None = None, actor: str | None = None) -> None:
db.add(QuoteEvent(quote_id=quote_id, event_type=event_type, payload=payload or {}, actor=actor))
async def get_quote_or_404(db: AsyncSession, quote_id: UUID) -> Quote:
quote = await db.get(Quote, quote_id)
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
return quote
# ---------------------------------------------------------------------------
# POST /quotes — nested creation in ONE transaction (temp_id cross-referencing)
# ---------------------------------------------------------------------------
async def _insert_children(
db: AsyncSession,
quote_id: UUID,
quote_supplier_id: UUID,
line_items,
termsheets,
item_termsheets,
) -> None:
"""Insert line items + termsheets + item_termsheets links + pending decisions.
Shared by create_quote and replace_quote_children. temp_id values are
cross-referenced between line_items and item_termsheets inside the payload.
quote_supplier_id drives the v2.5 supplier<->material auto-linking.
"""
# --- temp_id sanity (400) ---
li_temp_ids = [li.temp_id for li in line_items]
ts_temp_ids = [ts.temp_id for ts in termsheets]
if len(li_temp_ids) != len(set(li_temp_ids)):
raise ValidationError("temp_id خطوط آیتم باید یکتا باشند", field="line_items")
if len(ts_temp_ids) != len(set(ts_temp_ids)):
raise ValidationError("temp_id ترم‌شیت‌ها باید یکتا باشند", field="termsheets")
# --- line items: material / unit / brand_material checks ---
from app.models.catalog import Material, Unit
for li in line_items:
if await db.get(Material, li.material_id) is None:
raise NotFoundError("متریال یافت نشد", field="line_items.material_id")
if await db.get(Unit, li.unit_id) is None:
raise NotFoundError("واحد یافت نشد", field="line_items.unit_id")
await ensure_brand_material_belong(db, li.brand_material_id, li.material_id)
# --- insert line items; map temp_id -> real id ---
li_id_map: dict[str, UUID] = {}
for li in line_items:
row = LineItem(
quote_id=quote_id,
material_id=li.material_id,
brand_material_id=li.brand_material_id,
unit_id=li.unit_id,
quantity=li.quantity,
notes=li.notes,
)
db.add(row)
await db.flush()
li_id_map[li.temp_id] = row.id
# پروژه‌های تخصیص‌یافته به این قلم
for pid in (li.project_ids or []):
if await db.get(Project, pid) is None:
raise NotFoundError("پروژه یافت نشد", field="line_items.project_ids")
db.add(LineItemProjects(line_item_id=row.id, project_id=pid))
# v2.5: auto-link the material to the quote's supplier (catalog edge).
from app.services.supplier_material_service import link_supplier_material
await link_supplier_material(db, quote_supplier_id, li.material_id)
# --- insert termsheets + schedule steps; map temp_id -> real id ---
ts_id_map: dict[str, UUID] = {}
for ts in termsheets:
row = Termsheet(
quote_id=quote_id,
label=ts.label,
status="offered",
discount_type=ts.discount_type,
discount_value=ts.discount_value,
)
db.add(row)
await db.flush()
ts_id_map[ts.temp_id] = row.id
await _insert_schedule_steps(db, row.id, ts.schedule_steps, existing_count=0)
# --- item_termsheets links (resolve temp_ids) ---
# اقلام بدون قیمت اصلاً لینک نمی‌شوند — ذخیره‌ی پیشنهاد ناقص مجاز است.
for link in item_termsheets:
li_id = li_id_map.get(link.line_item_temp_id)
ts_id = ts_id_map.get(link.termsheet_temp_id)
if li_id is None:
raise ValidationError("line_item_temp_id نامعتبر است", field="item_termsheets.line_item_temp_id")
if ts_id is None:
raise ValidationError("termsheet_temp_id نامعتبر است", field="item_termsheets.termsheet_temp_id")
db.add(
ItemTermsheet(
line_item_id=li_id, termsheet_id=ts_id,
unit_price=link.unit_price, min_order_qty=link.min_order_qty,
)
)
# --- one pending item_decision per line_item (1:1 UNIQUE) ---
for li_id in li_id_map.values():
db.add(ItemDecision(line_item_id=li_id, status="pending"))
async def _sync_quote_projects(db: AsyncSession, quote: Quote, project_ids: list[UUID]) -> None:
"""Full replacement of the quote's project junction rows."""
await db.execute(
delete(QuoteProjects)
.where(QuoteProjects.quote_id == quote.id)
.execution_options(synchronize_session=False)
)
for pid in project_ids:
if await db.get(Project, pid) is None:
raise NotFoundError("پروژه یافت نشد", field="project_ids")
db.add(QuoteProjects(quote_id=quote.id, project_id=pid))
# legacy single-project column = اولین پروژه (برای فیلترها و آمار قدیمی)
quote.project_id = project_ids[0] if project_ids else None
async def create_quote(db: AsyncSession, payload: QuoteCreate, actor: str | None = None) -> Quote:
async with db.begin():
# --- FK existence (404) ---
if await db.get(Supplier, payload.supplier_id) is None:
raise NotFoundError("تأمین‌کننده یافت نشد", field="supplier_id")
project_ids = list(payload.project_ids)
if payload.project_id is not None and payload.project_id not in project_ids:
project_ids.insert(0, payload.project_id)
# --- insert quote ---
quote = Quote(
supplier_id=payload.supplier_id,
project_id=project_ids[0] if project_ids else payload.project_id,
quote_date=payload.quote_date or utcnow(),
expiry_date=payload.expiry_date,
is_official=payload.is_official,
notes=payload.notes,
status="offered", # server-set; never client-settable
)
db.add(quote)
await db.flush()
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
if project_ids:
await _sync_quote_projects(db, quote, project_ids)
# --- offered event ---
_add_event(db, quote.id, "offered", {"by": "system"}, actor)
return quote
async def _insert_schedule_steps(
db: AsyncSession, termsheet_id: UUID, steps, existing_count: int = 0
) -> None:
"""Insert schedule steps; omitted step_no is auto-numbered MAX+1 onward."""
next_no = existing_count
for index, step in enumerate(steps):
step_no = step.step_no if step.step_no is not None else next_no + index + 1
db.add(
TermsheetScheduleStep(
termsheet_id=termsheet_id, step_no=step_no,
due_date=step.due_date, amount=step.amount, note=step.note,
)
)
async def next_step_no(db: AsyncSession, termsheet_id: UUID) -> int:
max_no = await db.scalar(
select(func.max(TermsheetScheduleStep.step_no)).where(
TermsheetScheduleStep.termsheet_id == termsheet_id
)
)
return (max_no or 0) + 1
# ---------------------------------------------------------------------------
# PATCH /quotes/{id} — metadata + backward status transitions
# ---------------------------------------------------------------------------
async def update_quote(db: AsyncSession, quote_id: UUID, payload: QuoteUpdate, actor: str | None = None) -> Quote:
async with db.begin():
quote = await get_quote_or_404(db, quote_id)
changes: list[str] = []
if payload.supplier_id is not None and payload.supplier_id != quote.supplier_id:
if await db.get(Supplier, payload.supplier_id) is None:
raise NotFoundError("تأمین‌کننده یافت نشد", field="supplier_id")
quote.supplier_id = payload.supplier_id
changes.append("supplier_id")
if payload.project_ids is not None:
project_ids = list(payload.project_ids)
if payload.project_id is not None and payload.project_id not in project_ids:
project_ids.insert(0, payload.project_id)
await _sync_quote_projects(db, quote, project_ids)
changes.append("project_ids")
elif payload.project_id is not None and payload.project_id != quote.project_id:
if await db.get(Project, payload.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="project_id")
quote.project_id = payload.project_id
changes.append("project_id")
if payload.quote_date is not None and payload.quote_date != quote.quote_date:
quote.quote_date = payload.quote_date
changes.append("quote_date")
if payload.expiry_date is not None and payload.expiry_date != quote.expiry_date:
quote.expiry_date = payload.expiry_date
changes.append("expiry_date")
if payload.is_official is not None and payload.is_official != quote.is_official:
quote.is_official = payload.is_official
changes.append("is_official")
if payload.notes is not None and payload.notes != quote.notes:
quote.notes = payload.notes
changes.append("notes")
if payload.status is not None and payload.status != quote.status:
await _apply_status_transition(db, quote, payload.status)
changes.append("status")
if changes:
_add_event(db, quote.id, "edited", {"changed_fields": changes}, actor)
return quote
async def replace_quote_children(
db: AsyncSession, quote_id: UUID, payload: QuoteChildrenReplaceIn, actor: str | None = None
) -> Quote:
"""Full replacement of a quote's line items + termsheets (edit modal).
Only allowed while the quote is still offered — acceptance data
(decisions/receipts) would otherwise be destroyed.
"""
async with db.begin():
quote = await get_quote_or_404(db, quote_id)
if quote.status != "offered":
raise ConflictError(
"برای تغییر اقلام یا شرایط پرداخت، ابتدا وضعیت را به «پیشنهاد شده» برگردانید",
field="status",
)
# children cascade via DDL: item_termsheets, item_decisions, item_receipts, schedule_steps
await db.execute(
delete(LineItem).where(LineItem.quote_id == quote.id).execution_options(synchronize_session=False)
)
await db.execute(
delete(Termsheet).where(Termsheet.quote_id == quote.id).execution_options(synchronize_session=False)
)
await _insert_children(db, quote.id, quote.supplier_id, payload.line_items, payload.termsheets, payload.item_termsheets)
_add_event(db, quote.id, "edited", {"changed_fields": ["line_items", "termsheets"]}, actor)
return quote
async def _apply_status_transition(db: AsyncSession, quote: Quote, new_status: str) -> None:
"""Backward transitions only; reverts bookkeeping when going back to offered."""
current = quote.status
if new_status not in BACKWARD_TRANSITIONS.get(current, set()):
raise ConflictError("انتقال وضعیت مجاز نیست — فقط انتقال‌های عقب‌گرد پذیرفته می‌شود", field="status")
if new_status == "offered":
# Reopen: clear lifecycle dates, demote accepted termsheets, reset decisions.
quote.accepted_date = None
quote.arrived_date = None
await db.execute(
update(Termsheet)
.where(Termsheet.quote_id == quote.id, Termsheet.status == "accepted")
.values(status="offered")
.execution_options(synchronize_session=False)
)
li_ids = select(LineItem.id).where(LineItem.quote_id == quote.id)
await db.execute(
update(ItemDecision)
.where(ItemDecision.line_item_id.in_(li_ids), ItemDecision.status != "pending")
.values(
status="pending",
decided_at=None,
chosen_item_termsheet_id=None,
adjusted_unit_price=None,
adjusted_notes=None,
)
.execution_options(synchronize_session=False)
)
# item_receipts are deliberately NOT deleted (contract P0-17).
elif new_status == "accepted": # arrived -> accepted
quote.arrived_date = None
quote.status = new_status
# ---------------------------------------------------------------------------
# PATCH /quotes/{id}/accept — the accept flow (ONE transaction, FOR UPDATE)
# ---------------------------------------------------------------------------
async def accept_quote(db: AsyncSession, quote_id: UUID, payload: AcceptQuoteIn, actor: str | None = None) -> Quote:
async with db.begin():
quote = await db.scalar(select(Quote).where(Quote.id == quote_id).with_for_update())
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
if quote.status != "offered":
raise ConflictError("فقط پیشنهادهای offered قابل پذیرش هستند")
# --- validate accepted_termsheets ---
existing_ids = {t.id for t in payload.accepted_termsheets if t.id is not None}
existing_ts: dict[UUID, Termsheet] = {}
if existing_ids:
rows = (
await db.execute(
select(Termsheet).where(Termsheet.id.in_(existing_ids), Termsheet.quote_id == quote.id)
)
).scalars().all()
existing_ts = {t.id: t for t in rows}
missing = existing_ids - set(existing_ts)
if missing:
raise NotFoundError("ترم‌شیت یافت نشد یا متعلق به این پیشنهاد نیست", field="accepted_termsheets")
# --- validate item_decisions belong to the quote ---
li_ids = {d.line_item_id for d in payload.item_decisions}
if li_ids:
rows = (
await db.execute(
select(LineItem.id).where(LineItem.id.in_(li_ids), LineItem.quote_id == quote.id)
)
).scalars().all()
found = set(rows)
missing = li_ids - found
if missing:
raise NotFoundError("خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_decisions")
# --- resolve/create termsheets (existing: replace steps; new: insert) ---
ts_ids_by_index: list[UUID] = []
for index, t in enumerate(payload.accepted_termsheets):
if t.id is not None:
ts = existing_ts[t.id]
# replace schedule steps (DELETE old + INSERT new)
old_steps = (
(await db.execute(select(TermsheetScheduleStep).where(TermsheetScheduleStep.termsheet_id == ts.id)))
).scalars().all()
for step in old_steps:
await db.delete(step)
await db.flush()
await _insert_schedule_steps(db, ts.id, t.schedule_steps, existing_count=0)
if t.label is not None:
ts.label = t.label
ts.discount_type = t.discount_type
ts.discount_value = t.discount_value
ts_ids_by_index.append(ts.id)
else:
if not t.label:
raise ValidationError("برای ترم‌شیت جدید label الزامی است", field="accepted_termsheets")
new_ts = Termsheet(
quote_id=quote.id,
label=t.label,
status="offered",
discount_type=t.discount_type,
discount_value=t.discount_value,
)
db.add(new_ts)
await db.flush()
await _insert_schedule_steps(db, new_ts.id, t.schedule_steps, existing_count=0)
ts_ids_by_index.append(new_ts.id)
# --- process item decisions (UPSERT) ---
chosen_ts_ids: set[UUID] = set()
for decision_in in payload.item_decisions:
decision = await db.scalar(
select(ItemDecision).where(ItemDecision.line_item_id == decision_in.line_item_id).with_for_update()
)
if decision_in.status == "chosen":
index = decision_in.accepted_termsheet_index
if index is None:
raise ValidationError(
"برای قلم انتخاب‌شده accepted_termsheet_index الزامی است", field="item_decisions"
)
if not 0 <= index < len(ts_ids_by_index):
raise ValidationError(
"accepted_termsheet_index خارج از محدوده ترم‌شیت‌ها است", field="item_decisions"
)
if decision_in.adjusted_unit_price is None:
raise ValidationError(
"برای قلم انتخاب‌شده adjusted_unit_price الزامی است", field="item_decisions"
)
termsheet_id = ts_ids_by_index[index]
it = await db.scalar(
select(ItemTermsheet).where(
ItemTermsheet.line_item_id == decision_in.line_item_id,
ItemTermsheet.termsheet_id == termsheet_id,
)
)
if it is None:
it = ItemTermsheet(
line_item_id=decision_in.line_item_id,
termsheet_id=termsheet_id,
unit_price=decision_in.adjusted_unit_price,
)
db.add(it)
else:
it.unit_price = decision_in.adjusted_unit_price
await db.flush()
if decision is None:
decision = ItemDecision(line_item_id=decision_in.line_item_id, status="pending")
db.add(decision)
decision.status = "chosen"
decision.chosen_item_termsheet_id = it.id
decision.adjusted_unit_price = decision_in.adjusted_unit_price
decision.adjusted_notes = decision_in.adjusted_notes
decision.decided_at = utcnow()
# v2.6: the SINGLE purchased project (proc manager picks it;
# auto-filled when the offer had exactly one project).
if decision_in.project_id is not None:
decision.project_id = decision_in.project_id
else:
line_projs = (
(await db.execute(
select(LineItemProjects.project_id).where(
LineItemProjects.line_item_id == decision_in.line_item_id
)
))
.scalars()
.all()
)
if len(line_projs) == 1:
decision.project_id = line_projs[0]
if decision.project_id is not None:
if await db.get(Project, decision.project_id) is None:
raise NotFoundError("پروژه یافت نشد", field="item_decisions.project_id")
chosen_ts_ids.add(termsheet_id)
else: # rejected
if decision is None:
decision = ItemDecision(line_item_id=decision_in.line_item_id, status="pending")
db.add(decision)
decision.status = "rejected"
decision.chosen_item_termsheet_id = None
decision.adjusted_unit_price = None
decision.adjusted_notes = decision_in.adjusted_notes
decision.decided_at = utcnow()
# --- per-material project allocation (تخصیص پروژه به هر قلم) ---
for entry in payload.item_projects:
if entry.line_item_id not in li_ids:
raise NotFoundError(
"خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="item_projects"
)
await db.execute(
delete(LineItemProjects)
.where(LineItemProjects.line_item_id == entry.line_item_id)
.execution_options(synchronize_session=False)
)
for pid in entry.project_ids:
if await db.get(Project, pid) is None:
raise NotFoundError("پروژه یافت نشد", field="item_projects.project_ids")
db.add(LineItemProjects(line_item_id=entry.line_item_id, project_id=pid))
# termsheets referenced by at least one chosen item -> accepted
if chosen_ts_ids:
await db.execute(
update(Termsheet)
.where(Termsheet.id.in_(chosen_ts_ids))
.values(status="accepted")
.execution_options(synchronize_session=False)
)
quote.status = "accepted"
quote.accepted_date = utcnow()
_add_event(db, quote.id, "accepted", {"accepted_by": actor}, actor)
return quote
# ---------------------------------------------------------------------------
# Arrival
# ---------------------------------------------------------------------------
async def _decision_for_line_item(db: AsyncSession, line_item_id: UUID) -> 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 arrive_quote(db: AsyncSession, quote_id: UUID, actor: str | None = None) -> Quote:
"""Full-arrival shortcut: receipt for every non-rejected line item."""
async with db.begin():
quote = await db.scalar(select(Quote).where(Quote.id == quote_id).with_for_update())
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
if quote.status != "accepted":
raise ConflictError("فقط پیشنهادهای پذیرفته‌شده قابل تحویل هستند")
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
for li in line_items:
decision = await _decision_for_line_item(db, li.id)
if decision.status == "rejected":
continue
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if existing is None:
db.add(ItemReceipt(item_decision_id=decision.id, received_qty=li.quantity))
quote.status = "arrived"
quote.arrived_date = utcnow()
_add_event(db, quote.id, "arrived", {"source": "arrive"}, actor)
return quote
async def arrive_items(db: AsyncSession, quote_id: UUID, payload: ArriveItemsIn, actor: str | None = None) -> Quote:
"""Per-item arrival with full replacement semantics for receipts."""
async with db.begin():
quote = await db.scalar(select(Quote).where(Quote.id == quote_id).with_for_update())
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
if quote.status != "accepted":
raise ConflictError("فقط پیشنهادهای پذیرفته‌شده قابل تحویل هستند")
line_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all()
li_by_id = {li.id: li for li in line_items}
items_in = {item.line_item_id for item in payload.items}
missing = items_in - set(li_by_id)
if missing:
raise NotFoundError("خط آیتم یافت نشد یا متعلق به این پیشنهاد نیست", field="items")
# upsert receipts for the provided items
for item in payload.items:
li = li_by_id[item.line_item_id]
decision = await _decision_for_line_item(db, li.id)
if decision.status == "rejected":
raise ConflictError("قلم ردشده قابل دریافت نیست", field="items")
receipt = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if receipt is None:
db.add(
ItemReceipt(
item_decision_id=decision.id,
received_at=item.received_at or utcnow(),
received_qty=item.received_qty,
on_time=item.on_time,
quality_note=item.quality_note,
)
)
else:
receipt.received_at = item.received_at or receipt.received_at
receipt.received_qty = item.received_qty
receipt.on_time = item.on_time
receipt.quality_note = item.quality_note
# full replacement: delete receipts of non-rejected items not in the list
for li in line_items:
if li.id in items_in:
continue
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
if decision is None or decision.status == "rejected":
continue
receipt = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if receipt is not None:
await db.delete(receipt)
# auto-arrive when every non-rejected line item has a receipt
all_received = True
for li in line_items:
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id))
if decision is None or decision.status == "rejected":
continue
receipt = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
if receipt is None:
all_received = False
break
if all_received:
quote.status = "arrived"
quote.arrived_date = utcnow()
_add_event(db, quote.id, "arrived", {"source": "arrive-items"}, actor)
return quote
# ---------------------------------------------------------------------------
# Reject / expire / delete
# ---------------------------------------------------------------------------
async def reject_quote(db: AsyncSession, quote_id: UUID, payload: RejectQuoteIn, actor: str | None = None) -> Quote:
async with db.begin():
quote = await db.scalar(select(Quote).where(Quote.id == quote_id).with_for_update())
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
if quote.status != "offered":
raise ConflictError("فقط پیشنهادهای offered قابل رد شدن هستند")
quote.status = "rejected"
_add_event(
db, quote.id, "rejected",
{"reason": payload.reason} if payload.reason else {},
actor,
)
return quote
async def expire_quote(db: AsyncSession, quote_id: UUID, actor: str | None = None) -> Quote:
async with db.begin():
quote = await db.scalar(select(Quote).where(Quote.id == quote_id).with_for_update())
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
if quote.status != "offered":
raise ConflictError("فقط پیشنهادهای offered قابل انقضا هستند")
quote.status = "expired"
_add_event(db, quote.id, "expired", {}, actor)
return quote
async def delete_quote(db: AsyncSession, quote_id: UUID) -> dict:
async with db.begin():
quote = await get_quote_or_404(db, quote_id)
if quote.status in ("accepted", "arrived"):
raise ConflictError("پیشنهادهای پذیرفته‌شده یا رسیده قابل حذف نیستند")
# count cascaded children for the response envelope
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 = (
(await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars().all()
if li_ids else []
)
dec_ids = [d.id for d in decisions]
receipts = (
(await db.execute(select(ItemReceipt).where(ItemReceipt.item_decision_id.in_(dec_ids)))).scalars().all()
if dec_ids else []
)
termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all()
events = (await db.execute(select(QuoteEvent).where(QuoteEvent.quote_id == quote.id))).scalars().all()
await db.delete(quote)
return {
"id": str(quote.id),
"deleted": True,
"cascaded": {
"line_items": len(line_items),
"termsheets": len(termsheets),
"events": len(events),
"decisions": len(decisions),
"receipts": len(receipts),
},
}
# ---------------------------------------------------------------------------
# Statistics
# ---------------------------------------------------------------------------
async def quote_stats(
db: AsyncSession,
supplier_id: UUID | None = None,
project_id: UUID | None = None,
quote_date_from=None,
quote_date_to=None,
category_id: UUID | None = None,
) -> dict:
conditions = []
if supplier_id is not None:
conditions.append(Quote.supplier_id == supplier_id)
if project_id is not None:
conditions.append(Quote.project_id == project_id)
if quote_date_from is not None:
conditions.append(Quote.quote_date >= quote_date_from)
if quote_date_to is not None:
conditions.append(Quote.quote_date <= quote_date_to)
if category_id is not None:
conditions.append(_quote_has_category(category_id))
rows = (
await db.execute(
select(Quote.status, func.count()).where(*conditions).group_by(Quote.status)
)
).all()
counts = {status: 0 for status in QUOTE_STATUSES}
for status, count in rows:
counts[status] = count
avg = await db.scalar(
select(
func.avg(func.extract("epoch", Quote.arrived_date - Quote.accepted_date) / 86400.0)
).where(
Quote.status == "arrived",
Quote.arrived_date.is_not(None),
Quote.accepted_date.is_not(None),
*conditions,
)
)
return {
"total": sum(counts.values()),
"offered": counts["offered"],
"accepted": counts["accepted"],
"arrived": counts["arrived"],
"rejected": counts["rejected"],
"expired": counts["expired"],
"average_lead_time_days": int(round(float(avg))) if avg is not None else None,
}
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
sub = (
select(1)
.where(
LineItem.quote_id == Quote.id,
Material.id == LineItem.material_id,
Material.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.
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).
"""
project = await db.get(Project, project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد")
quotes = (await db.execute(select(Quote).where(Quote.project_id == project_id))).scalars().all()
if not quotes:
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]
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]
decisions = {
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 {}
item_termsheets = (
(await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
) if li_ids else []
its_by_id = {it.id: it for it in item_termsheets}
its_by_line: dict[UUID, list[ItemTermsheet]] = {}
for it in item_termsheets:
its_by_line.setdefault(it.line_item_id, []).append(it)
per_status = {status: {"count": 0, "total_value": 0.0} for status in QUOTE_STATUSES}
for q in quotes:
per_status[q.status]["count"] += 1
for li in line_items:
decision = decisions.get(li.id)
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:
price_it = its_by_id.get(decision.chosen_item_termsheet_id)
else:
links = its_by_line.get(li.id, [])
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)
result = {"project_id": str(project_id), "total_quotes": len(quotes)}
for status, values in per_status.items():
result[status] = {"count": values["count"], "total_value": values["total_value"]}
return result
# ---------------------------------------------------------------------------
# Background auto-expiry
# ---------------------------------------------------------------------------
async def auto_expire_quotes(db: AsyncSession) -> int:
"""Expire quotes with expiry_date < today AND status = 'offered' (scheduler)."""
today = date.today()
rows = await db.execute(
select(Quote).where(
Quote.status == "offered",
Quote.expiry_date.is_not(None),
Quote.expiry_date < today,
)
)
quotes = rows.scalars().all()
if not quotes:
return 0
async with db.begin():
for q in quotes:
q.status = "expired"
_add_event(
db, q.id, "expired",
{"reason": "منقضی خودکار — تاریخ اعتبار گذشته است"}, "سیستم",
)
logger.info("Auto-expired %d quote(s)", len(quotes))
return len(quotes)
+68
View File
@@ -0,0 +1,68 @@
"""MinIO client — S3-compatible object storage for invoice / proforma attachments.
The `minio` SDK is synchronous; every call is wrapped with
``asyncio.to_thread`` so the event loop is never blocked.
"""
import asyncio
import io
import logging
from functools import lru_cache
from minio import Minio
from app.config import settings
logger = logging.getLogger(__name__)
@lru_cache(maxsize=1)
def get_client() -> Minio:
"""Lazily built Minio client from the PROCUREMENT_MINIO_* settings."""
return Minio(
settings.MINIO_ENDPOINT,
access_key=settings.MINIO_ACCESS_KEY,
secret_key=settings.MINIO_SECRET_KEY,
secure=settings.MINIO_SECURE,
)
async def ensure_bucket() -> None:
"""Create the attachments bucket if it does not exist (idempotent)."""
client = get_client()
try:
exists = await asyncio.to_thread(client.bucket_exists, settings.MINIO_BUCKET)
if not exists:
await asyncio.to_thread(client.make_bucket, settings.MINIO_BUCKET)
logger.info("Created MinIO bucket %r", settings.MINIO_BUCKET)
except Exception:
logger.exception("MinIO bucket check failed — is the minio service reachable?")
async def put_object(key: str, content: bytes, mime_type: str) -> None:
"""Upload a byte payload to the bucket (overwrites any existing object)."""
await asyncio.to_thread(
get_client().put_object,
settings.MINIO_BUCKET,
key,
io.BytesIO(content),
length=len(content),
content_type=mime_type,
)
async def get_object_bytes(key: str) -> bytes:
"""Download an object's bytes from the bucket."""
response = await asyncio.to_thread(get_client().get_object, settings.MINIO_BUCKET, key)
try:
return response.read()
finally:
response.close()
response.release_conn()
async def remove_object(key: str) -> None:
"""Delete an object; missing objects are ignored (best-effort)."""
try:
await asyncio.to_thread(get_client().remove_object, settings.MINIO_BUCKET, key)
except Exception:
logger.warning("Could not remove MinIO object %r — ignoring", key)
@@ -0,0 +1,63 @@
"""Supplier ↔ material link service (v2.5) — the direct edge of the new 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.
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.catalog import Material
from app.models.procurement import Supplier, SupplierMaterial
from app.utils.errors import NotFoundError, ValidationError
async def link_supplier_material(
db: AsyncSession, supplier_id, material_id, is_active: bool = True
) -> SupplierMaterial:
"""Upsert a supplier<->material link (idempotent) + derive category (first-link-wins).
Caller is responsible for the surrounding transaction.
"""
supplier = await db.get(Supplier, supplier_id)
if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد", field="supplier_id")
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد", field="material_id")
link = await db.scalar(
select(SupplierMaterial).where(
SupplierMaterial.supplier_id == supplier_id,
SupplierMaterial.material_id == material_id,
)
)
if link is not None:
if not link.is_active and is_active:
link.is_active = True
return link
link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active)
db.add(link)
await db.flush()
# 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)."""
link = await db.scalar(
select(SupplierMaterial).where(
SupplierMaterial.supplier_id == supplier_id,
SupplierMaterial.material_id == material_id,
)
)
if link is None:
raise ValidationError("این متریال به این تأمین‌کننده متصل نیست", field="material_id")
await db.delete(link)
await db.flush()