Files
ERP/backend/app/main.py
T
whqreza 43ff048899 fix(wizard,quotes,dates): reliable error routing, single-project accept, Persian date/time input
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).
2026-09-17 16:13:27 +03:30

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"}