feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user