主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
227 lines
6.8 KiB
Python
227 lines
6.8 KiB
Python
"""
|
|
特例审批 API
|
|
创建、查询、审批特例记录
|
|
"""
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
|
from sqlalchemy import select, and_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.models.tenant import Tenant
|
|
from app.models.risk_exception import (
|
|
RiskException,
|
|
RiskTargetType as DBRiskTargetType,
|
|
RiskExceptionStatus as DBRiskExceptionStatus,
|
|
)
|
|
from app.schemas.review import (
|
|
RiskExceptionCreateRequest,
|
|
RiskExceptionRecord,
|
|
RiskExceptionStatus,
|
|
RiskExceptionDecisionRequest,
|
|
RiskTargetType,
|
|
)
|
|
|
|
router = APIRouter(prefix="/risk-exceptions", tags=["risk-exceptions"])
|
|
|
|
|
|
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
|
|
"""确保租户存在,不存在则自动创建"""
|
|
result = await db.execute(
|
|
select(Tenant).where(Tenant.id == tenant_id)
|
|
)
|
|
tenant = result.scalar_one_or_none()
|
|
|
|
if not tenant:
|
|
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
|
|
db.add(tenant)
|
|
await db.flush()
|
|
|
|
return tenant
|
|
|
|
|
|
def _exception_to_response(record: RiskException) -> RiskExceptionRecord:
|
|
"""将数据库模型转换为响应模型"""
|
|
return RiskExceptionRecord(
|
|
record_id=record.id,
|
|
applicant_id=record.applicant_id,
|
|
apply_time=record.apply_time,
|
|
target_type=RiskTargetType(record.target_type.value),
|
|
target_id=record.target_id,
|
|
risk_rule_id=record.risk_rule_id,
|
|
status=RiskExceptionStatus(record.status.value),
|
|
valid_start_time=record.valid_start_time,
|
|
valid_end_time=record.valid_end_time,
|
|
reason_category=record.reason_category,
|
|
justification=record.justification,
|
|
attachment_url=record.attachment_url,
|
|
current_approver_id=record.current_approver_id,
|
|
approval_chain_log=record.approval_chain_log or [],
|
|
auto_rejected=record.auto_rejected,
|
|
rejection_reason=record.rejection_reason,
|
|
last_status_at=record.last_status_at,
|
|
)
|
|
|
|
|
|
@router.post("", response_model=RiskExceptionRecord, status_code=status.HTTP_201_CREATED)
|
|
async def create_exception(
|
|
request: RiskExceptionCreateRequest,
|
|
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> RiskExceptionRecord:
|
|
"""创建特例申请"""
|
|
# 确保租户存在
|
|
await _ensure_tenant_exists(x_tenant_id, db)
|
|
|
|
record_id = f"exc-{uuid.uuid4().hex[:12]}"
|
|
now = datetime.now(timezone.utc)
|
|
|
|
record = RiskException(
|
|
id=record_id,
|
|
tenant_id=x_tenant_id,
|
|
applicant_id=request.applicant_id,
|
|
apply_time=now,
|
|
target_type=DBRiskTargetType(request.target_type.value),
|
|
target_id=request.target_id,
|
|
risk_rule_id=request.risk_rule_id,
|
|
status=DBRiskExceptionStatus.PENDING,
|
|
valid_start_time=request.valid_start_time,
|
|
valid_end_time=request.valid_end_time,
|
|
reason_category=request.reason_category,
|
|
justification=request.justification,
|
|
attachment_url=request.attachment_url,
|
|
current_approver_id=request.current_approver_id,
|
|
approval_chain_log=[],
|
|
auto_rejected=False,
|
|
rejection_reason=None,
|
|
last_status_at=now,
|
|
)
|
|
db.add(record)
|
|
await db.flush()
|
|
await db.refresh(record)
|
|
|
|
return _exception_to_response(record)
|
|
|
|
|
|
@router.get("/{record_id}", response_model=RiskExceptionRecord)
|
|
async def get_exception(
|
|
record_id: str,
|
|
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> RiskExceptionRecord:
|
|
"""查询特例记录"""
|
|
result = await db.execute(
|
|
select(RiskException).where(
|
|
and_(
|
|
RiskException.id == record_id,
|
|
RiskException.tenant_id == x_tenant_id,
|
|
)
|
|
)
|
|
)
|
|
record = result.scalar_one_or_none()
|
|
|
|
if not record:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"特例记录不存在: {record_id}",
|
|
)
|
|
|
|
return _exception_to_response(record)
|
|
|
|
|
|
@router.post("/{record_id}/approve", response_model=RiskExceptionRecord)
|
|
async def approve_exception(
|
|
record_id: str,
|
|
request: RiskExceptionDecisionRequest,
|
|
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> RiskExceptionRecord:
|
|
"""审批通过"""
|
|
result = await db.execute(
|
|
select(RiskException).where(
|
|
and_(
|
|
RiskException.id == record_id,
|
|
RiskException.tenant_id == x_tenant_id,
|
|
)
|
|
)
|
|
)
|
|
record = result.scalar_one_or_none()
|
|
|
|
if not record:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"特例记录不存在: {record_id}",
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
record.status = DBRiskExceptionStatus.APPROVED
|
|
record.last_status_at = now
|
|
|
|
# 更新审批日志
|
|
approval_log = record.approval_chain_log or []
|
|
approval_log.append({
|
|
"approver_id": request.approver_id,
|
|
"action": "approve",
|
|
"comment": request.comment,
|
|
"timestamp": now.isoformat(),
|
|
})
|
|
record.approval_chain_log = approval_log
|
|
|
|
await db.flush()
|
|
await db.refresh(record)
|
|
|
|
return _exception_to_response(record)
|
|
|
|
|
|
@router.post("/{record_id}/reject", response_model=RiskExceptionRecord)
|
|
async def reject_exception(
|
|
record_id: str,
|
|
request: RiskExceptionDecisionRequest,
|
|
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> RiskExceptionRecord:
|
|
"""驳回申请"""
|
|
result = await db.execute(
|
|
select(RiskException).where(
|
|
and_(
|
|
RiskException.id == record_id,
|
|
RiskException.tenant_id == x_tenant_id,
|
|
)
|
|
)
|
|
)
|
|
record = result.scalar_one_or_none()
|
|
|
|
if not record:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"特例记录不存在: {record_id}",
|
|
)
|
|
|
|
# 驳回必须填写原因
|
|
if not request.comment:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="驳回必须填写原因",
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
record.status = DBRiskExceptionStatus.REJECTED
|
|
record.rejection_reason = request.comment
|
|
record.last_status_at = now
|
|
|
|
# 更新审批日志
|
|
approval_log = record.approval_chain_log or []
|
|
approval_log.append({
|
|
"approver_id": request.approver_id,
|
|
"action": "reject",
|
|
"comment": request.comment,
|
|
"timestamp": now.isoformat(),
|
|
})
|
|
record.approval_chain_log = approval_log
|
|
|
|
await db.flush()
|
|
await db.refresh(record)
|
|
|
|
return _exception_to_response(record)
|