"""备机闭环 HTTP endpoints — `/api/v1/assets/hardware-prep*`. Asset-admin surface (route gate ``require_asset_admin``; reserve / release additionally pass ``assert_can_manage_asset`` so a region- scoped ``asset_admin`` can only earmark devices in their own offices — ``asset_global_admin`` co-derives ``asset_admin`` and bypasses the region gate via ``has_global_asset_scope``, same as every other asset write). """ from __future__ import annotations from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import ( assert_can_manage_asset, require_asset_admin, ) from app.core.response import success_response from app.domains.asset.hardware_prep import HardwarePrepService router = APIRouter( prefix="/api/v1/assets/hardware-prep", tags=["assets", "onboarding"], ) @router.get("") async def get_hardware_prep_queue( request: Request, window_days: int = Query(default=14, ge=1, le=60), _admin: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): """待备机 worklist — upcoming/overdue hires needing hardware prep, their reservations, and stale reservations to release.""" payload = await HardwarePrepService(db).build_queue( window_days=window_days, ) return success_response(data=payload, request=request) @router.get("/{process_id}/candidates") async def list_reservation_candidates( process_id: UUID, request: Request, q: str | None = Query(default=None, max_length=128), limit: int = Query(default=50, ge=1, le=200), _admin: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): items = await HardwarePrepService(db).list_candidates( process_id, q=q, limit=limit, ) return success_response(data={"items": items}, request=request) class ReserveBody(BaseModel): asset_id: UUID @router.post("/{process_id}/reserve") async def reserve_asset_for_hire( process_id: UUID, body: ReserveBody, request: Request, admin: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): svc = HardwarePrepService(db) # Region gate BEFORE mutating: load the asset via the service's own # lookup path inside reserve(); pre-check here with a light read. from app.domains.asset.service import AssetService asset = await AssetService(db).get_asset(body.asset_id) await assert_can_manage_asset(db, admin, asset) trace_id = getattr(request.state, "trace_id", str(process_id)) reserved = await svc.reserve( process_id, body.asset_id, trace_id=trace_id, actor_user_id=admin.id, ) await db.commit() from app.domains.asset.hardware_prep import _serialize_asset_brief return success_response( data=_serialize_asset_brief(reserved), request=request, ) class ReleaseBody(BaseModel): reason: str | None = Field(default=None, max_length=512) @router.post("/release/{asset_id}") async def release_asset_reservation( asset_id: UUID, request: Request, body: ReleaseBody | None = None, admin: AuthUser = Depends(require_asset_admin()), db: AsyncSession = Depends(get_db), ): from app.domains.asset.service import AssetService asset = await AssetService(db).get_asset(asset_id) await assert_can_manage_asset(db, admin, asset) trace_id = getattr(request.state, "trace_id", str(asset_id)) released = await HardwarePrepService(db).release( asset_id, trace_id=trace_id, actor_user_id=admin.id, reason=body.reason if body else None, ) await db.commit() from app.domains.asset.hardware_prep import _serialize_asset_brief return success_response( data=_serialize_asset_brief(released), request=request, )