Wizard: - failed saves now route to the exact step+field: a new data-field attribute on every required section (supplier_id, line_items, termsheets, project, quote_date, expiry_date, notes, proforma) plus resolveErrorTarget() that maps ApiError.field / message keywords (supplier, line item/material/unit/quantity, termsheet/schedule/milestone, project, dates, notes, file) to the offending step — the «رفع خطا» toast action scrolls/focuses the right control instead of guessing - material records are no longer created on the fly while typing (rememberMaterial no longer fires addMaterial per blur); materials/specs/units are written once during the final save, avoiding half-created catalog rows Accept flow: - quote_service no longer rewrites LineItemProjects on accept — those candidate-project rows stay 100% untouched and only item_decisions.project_id is written, matching the per-item target-project model and the UI payload (item_projects: []) Dates/time: - JalaliDatePicker: hour/minute are now editable text fields (Persian digits, clamping, 3-digit retype handling) with a controlled useEffect that only rewrites the inputs when the numeric value actually changes — no more wiping in-progress typing; date-only detection tightened to a full YYYY-MM-DD match so datetime strings are not misread Also: /api validation logging (RequestValidationError) now includes the path and error list for faster diagnosis. Verified: api + frontend image builds (python, tsc, vite) clean on top of origin/production (incl. the team's mobile layout merges).
169 lines
6.1 KiB
Python
169 lines
6.1 KiB
Python
"""FastAPI application assembly.
|
|
|
|
- Routes: all routers under /api/v1
|
|
- Errors: contract envelope {"error": {"code", "message", "field"}}
|
|
- Lifespan: ensure units-dashboard tables (IF NOT EXISTS) + background
|
|
auto-expiry scheduler for offered quotes past their expiry_date.
|
|
"""
|
|
import asyncio
|
|
import logging
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.exceptions import RequestValidationError
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
from app.config import settings
|
|
from app.db.engine import AsyncSessionLocal, engine
|
|
from app.db.init_db import (
|
|
ensure_attachment_table,
|
|
ensure_dashboard_tables,
|
|
ensure_material_topic_column,
|
|
ensure_quote_projects,
|
|
ensure_v26,
|
|
)
|
|
from app.routers import ROUTERS
|
|
from app.services.quote_service import auto_expire_quotes
|
|
from app.utils.errors import AppError
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s")
|
|
logger = logging.getLogger("procurement")
|
|
|
|
API_V1_PREFIX = "/api/v1"
|
|
|
|
|
|
async def _auto_expire_loop() -> None:
|
|
"""Background job: expire quotes with expiry_date < today AND status='offered'."""
|
|
while True:
|
|
await asyncio.sleep(settings.AUTO_EXPIRE_INTERVAL_SECONDS)
|
|
try:
|
|
async with AsyncSessionLocal() as session:
|
|
await auto_expire_quotes(session)
|
|
except Exception:
|
|
logger.exception("Auto-expiry pass failed")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_app: FastAPI):
|
|
await ensure_dashboard_tables(engine)
|
|
await ensure_attachment_table(engine)
|
|
await ensure_material_topic_column(engine)
|
|
await ensure_quote_projects(engine)
|
|
await ensure_v26(engine)
|
|
task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None
|
|
try:
|
|
yield
|
|
finally:
|
|
if task is not None:
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
await engine.dispose()
|
|
|
|
|
|
app = FastAPI(
|
|
title="Procurement API",
|
|
version="2.4.0",
|
|
description="Backend for the procurement system — schema v2.4 (expert-opinion contracts P0/P1/P2)",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=settings.CORS_ORIGINS,
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Exception handlers — contract error envelope
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@app.exception_handler(AppError)
|
|
async def app_error_handler(_request: Request, exc: AppError):
|
|
return JSONResponse(status_code=exc.status_code, content=exc.body)
|
|
|
|
|
|
# ترجمهی پیامهای اعتبارسنجی pydantic به فارسی — الگوهای رایج
|
|
_VALIDATION_FA: list[tuple[str, str]] = [
|
|
("field required", "فیلد الزامی خالی است"),
|
|
("input should be a valid uuid", "شناسهی ارسالشده معتبر نیست"),
|
|
("input should be a valid datetime", "تاریخ و ساعت واردشده معتبر نیست"),
|
|
("input should be a valid date", "تاریخ واردشده معتبر نیست"),
|
|
("input should be a valid integer", "عدد صحیح وارد کنید"),
|
|
("input should be a valid number", "عدد معتبر وارد کنید"),
|
|
("input should be a valid boolean", "مقدار درست/نادرست (بولی) وارد کنید"),
|
|
("input should be greater than 0", "مقدار باید بزرگتر از صفر باشد"),
|
|
("input should be greater than or equal to", "مقدار از حداقل مجاز کوچکتر است"),
|
|
("input should be less than or equal to", "مقدار از حداکثر مجاز بزرگتر است"),
|
|
("string should have at least 1 character", "متن نباید خالی باشد"),
|
|
("string too short", "مقدار خیلی کوتاه است"),
|
|
("string too long", "مقدار خیلی طولانی است"),
|
|
("value error", "مقدار واردشده نامعتبر است"),
|
|
]
|
|
|
|
|
|
def _fa_validation_msg(msg: str) -> str:
|
|
lower = msg.lower()
|
|
for en, fa in _VALIDATION_FA:
|
|
if en in lower:
|
|
return fa
|
|
return msg
|
|
|
|
|
|
@app.exception_handler(RequestValidationError)
|
|
async def validation_error_handler(_request: Request, exc: RequestValidationError):
|
|
logger.warning("RequestValidationError on %s: %s", _request.url.path, exc.errors())
|
|
first = exc.errors()[0]
|
|
loc = [str(part) for part in first.get("loc", []) if part not in ("body", "query", "path")]
|
|
field = ".".join(loc) if loc else None
|
|
return JSONResponse(
|
|
status_code=400,
|
|
content={
|
|
"error": {
|
|
"code": "VALIDATION_ERROR",
|
|
"message": _fa_validation_msg(first.get("msg", "داده نامعتبر است")),
|
|
"field": field,
|
|
}
|
|
},
|
|
)
|
|
|
|
|
|
@app.exception_handler(IntegrityError)
|
|
async def integrity_error_handler(_request: Request, exc: IntegrityError):
|
|
# Services pre-check FKs and duplicates, so an IntegrityError here almost
|
|
# always means a unique-constraint violation → 409 CONFLICT.
|
|
logger.warning("IntegrityError: %s", exc.orig)
|
|
return JSONResponse(
|
|
status_code=409,
|
|
content={"error": {"code": "CONFLICT", "message": "تعارض داده — رکورد تکراری یا ارجاع نامعتبر"}},
|
|
)
|
|
|
|
|
|
@app.exception_handler(Exception)
|
|
async def unhandled_error_handler(_request: Request, exc: Exception):
|
|
logger.exception("Unhandled error: %s", exc)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"error": {"code": "INTERNAL_ERROR", "message": "خطای داخلی سرور"}},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
for router in ROUTERS:
|
|
app.include_router(router, prefix=API_V1_PREFIX)
|
|
|
|
|
|
@app.get("/health", tags=["system"])
|
|
async def health():
|
|
return {"status": "ok", "service": "procurement-api"}
|