diff --git a/app/api/v1/module_energy/__init__.py b/app/api/v1/module_energy/__init__.py index f851b6d..d84fdc6 100644 --- a/app/api/v1/module_energy/__init__.py +++ b/app/api/v1/module_energy/__init__.py @@ -3,11 +3,13 @@ from fastapi import APIRouter from app.api.v1.module_energy.chapter.controller import ChapterRouter from app.api.v1.module_energy.config.controller import TemplateConfigRouter from app.api.v1.module_energy.report_template.controller import EnergyReportTemplateRouter +from app.api.v1.module_energy.report_review.controller import EnergyReportReviewRouter from app.api.v1.module_energy.variable.controller import VariableRouter energy_router = APIRouter() energy_router.include_router(EnergyReportTemplateRouter) +energy_router.include_router(EnergyReportReviewRouter) energy_router.include_router(TemplateConfigRouter) energy_router.include_router(ChapterRouter) energy_router.include_router(VariableRouter) diff --git a/app/api/v1/module_energy/report_review/__init__.py b/app/api/v1/module_energy/report_review/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/api/v1/module_energy/report_review/__init__.py @@ -0,0 +1 @@ + diff --git a/app/api/v1/module_energy/report_review/controller.py b/app/api/v1/module_energy/report_review/controller.py new file mode 100644 index 0000000..9b26531 --- /dev/null +++ b/app/api/v1/module_energy/report_review/controller.py @@ -0,0 +1,93 @@ +from typing import Annotated + +from fastapi import APIRouter, Body, Depends, Path +from fastapi.responses import JSONResponse + +from app.common.response import ResponseSchema, SuccessResponse +from app.core.base_params import PaginationQueryParam +from app.core.base_schema import AuthSchema +from app.core.dependencies import AuthPermission +from app.core.router_class import OperationLogRoute + +from .schema import ( + EnergyReportReviewCreateSchema, + EnergyReportReviewOutSchema, + EnergyReportReviewPageOutSchema, + EnergyReportReviewQueryParam, + EnergyReportReviewUpdateSchema, +) +from .service import EnergyReportReviewService + +EnergyReportReviewRouter = APIRouter(route_class=OperationLogRoute, prefix="/report-review", tags=["能源报告审核"]) + + +@EnergyReportReviewRouter.get( + "/list", + summary="查询能源报告审核任务列表", + response_model=ResponseSchema[EnergyReportReviewPageOutSchema], +) +async def get_energy_report_review_list_controller( + page: Annotated[PaginationQueryParam, Depends()], + search: Annotated[EnergyReportReviewQueryParam, Depends()], + auth: Annotated[AuthSchema, Depends(AuthPermission(["module_energy:report_review:query"]))], +) -> JSONResponse: + result = await EnergyReportReviewService(auth).page( + page_no=page.page_no, + page_size=page.page_size, + search=search, + order_by=page.order_by or [{"id": "desc"}], + ) + return SuccessResponse(data=result, msg="查询能源报告审核任务列表成功") + + +@EnergyReportReviewRouter.get( + "/detail/{id}", + summary="查询能源报告审核任务详情", + response_model=ResponseSchema[EnergyReportReviewOutSchema], +) +async def get_energy_report_review_detail_controller( + id: Annotated[int, Path(description="能源报告审核任务ID")], + auth: Annotated[AuthSchema, Depends(AuthPermission(["module_energy:report_review:detail"]))], +) -> JSONResponse: + result = await EnergyReportReviewService(auth).detail(id=id) + return SuccessResponse(data=result, msg="查询能源报告审核任务详情成功") + + +@EnergyReportReviewRouter.post( + "/create", + summary="创建能源报告审核任务", + response_model=ResponseSchema[EnergyReportReviewOutSchema], +) +async def create_energy_report_review_controller( + data: EnergyReportReviewCreateSchema, + auth: Annotated[AuthSchema, Depends(AuthPermission(["module_energy:report_review:create"]))], +) -> JSONResponse: + result = await EnergyReportReviewService(auth).create(data=data) + return SuccessResponse(data=result, msg="创建能源报告审核任务成功") + + +@EnergyReportReviewRouter.put( + "/update/{id}", + summary="修改能源报告审核任务", + response_model=ResponseSchema[EnergyReportReviewOutSchema], +) +async def update_energy_report_review_controller( + data: EnergyReportReviewUpdateSchema, + id: Annotated[int, Path(description="能源报告审核任务ID")], + auth: Annotated[AuthSchema, Depends(AuthPermission(["module_energy:report_review:update"]))], +) -> JSONResponse: + result = await EnergyReportReviewService(auth).update(id=id, data=data) + return SuccessResponse(data=result, msg="修改能源报告审核任务成功") + + +@EnergyReportReviewRouter.delete( + "/delete", + summary="删除能源报告审核任务", + response_model=ResponseSchema[None], +) +async def delete_energy_report_review_controller( + ids: Annotated[list[int], Body(description="ID列表")], + auth: Annotated[AuthSchema, Depends(AuthPermission(["module_energy:report_review:delete"]))], +) -> JSONResponse: + await EnergyReportReviewService(auth).delete(ids=ids) + return SuccessResponse(msg="删除能源报告审核任务成功") diff --git a/app/api/v1/module_energy/report_review/crud.py b/app/api/v1/module_energy/report_review/crud.py new file mode 100644 index 0000000..7ce5814 --- /dev/null +++ b/app/api/v1/module_energy/report_review/crud.py @@ -0,0 +1,12 @@ +from app.core.base_crud import CRUDBase +from app.core.base_schema import AuthSchema + +from .model import EnergyReportReviewModel +from .schema import EnergyReportReviewCreateSchema, EnergyReportReviewUpdateSchema + + +class EnergyReportReviewCRUD(CRUDBase[EnergyReportReviewModel, EnergyReportReviewCreateSchema, EnergyReportReviewUpdateSchema]): + """能源报告审核任务数据层。""" + + def __init__(self, auth: AuthSchema) -> None: + super().__init__(model=EnergyReportReviewModel, auth=auth) diff --git a/app/api/v1/module_energy/report_review/model.py b/app/api/v1/module_energy/report_review/model.py new file mode 100644 index 0000000..7291eb8 --- /dev/null +++ b/app/api/v1/module_energy/report_review/model.py @@ -0,0 +1,49 @@ +from sqlalchemy import ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.base_model import ModelMixin, TenantMixin, UserMixin + + +class EnergyReportReviewModel(ModelMixin, TenantMixin, UserMixin): + """能源报告审核任务模型。""" + + __tablename__: str = "energy_report_review" + __table_args__: dict[str, str] = {"comment": "能源报告审核任务表"} + __loader_options__: list[str] = [ + "template", + "file", + "reviewer", + "created_by", + "updated_by", + "deleted_by", + "tenant_by", + ] + + task_name: Mapped[str] = mapped_column(String(128), nullable=False, comment="任务名称", index=True) + template_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("energy_report_template.id", ondelete="RESTRICT", onupdate="CASCADE"), + nullable=False, + index=True, + comment="能源报告模板ID", + ) + file_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("common_file.id", ondelete="RESTRICT", onupdate="CASCADE"), + nullable=False, + index=True, + comment="审核文档文件ID", + ) + status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态 0:待审查 1:已审查 2:审查失败", index=True) + reviewer_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey("sys_user.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + index=True, + comment="审查人ID", + ) + description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="描述") + + template = relationship("EnergyReportTemplateModel", lazy="selectin", foreign_keys=[template_id], uselist=False) + file = relationship("FileModel", lazy="selectin", foreign_keys=[file_id], uselist=False) + reviewer = relationship("UserModel", lazy="selectin", foreign_keys=[reviewer_id], uselist=False) diff --git a/app/api/v1/module_energy/report_review/schema.py b/app/api/v1/module_energy/report_review/schema.py new file mode 100644 index 0000000..80dd99f --- /dev/null +++ b/app/api/v1/module_energy/report_review/schema.py @@ -0,0 +1,94 @@ +from dataclasses import dataclass + +from fastapi import Query +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from app.api.v1.module_common.file.schema import FileOutSchema +from app.api.v1.module_energy.report_template.schema import EnergyReportTemplateOutSchema +from app.common.enums import QueueEnum +from app.core.base_params import BaseQueryParam, TenantByQueryParam, UserByQueryParam +from app.core.base_schema import BaseSchema, CommonSchema, TenantBySchema, UserBySchema + + +class EnergyReportReviewCreateSchema(BaseModel): + """能源报告审核任务创建模型。""" + + task_name: str = Field(..., min_length=1, max_length=128, description="任务名称") + template_id: int = Field(..., ge=1, description="能源报告模板ID") + file_id: int = Field(..., ge=1, description="上传文档文件ID") + status: int = Field(default=0, ge=0, le=2, description="状态 0:待审查 1:已审查 2:审查失败") + reviewer_id: int | None = Field(default=None, ge=1, description="审查人ID") + description: str | None = Field(default=None, max_length=500, description="描述") + + @field_validator("task_name") + @classmethod + def validate_task_name(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("任务名称不能为空") + return value + + @field_validator("status") + @classmethod + def validate_status(cls, value: int) -> int: + if value not in {0, 1, 2}: + raise ValueError("状态仅支持 0(待审查)、1(已审查)、2(审查失败)") + return value + + +class EnergyReportReviewUpdateSchema(EnergyReportReviewCreateSchema): + """能源报告审核任务更新模型。""" + + +class EnergyReportReviewOutSchema(EnergyReportReviewCreateSchema, BaseSchema, UserBySchema, TenantBySchema): + """能源报告审核任务响应模型。""" + + model_config = ConfigDict(from_attributes=True) + + template: EnergyReportTemplateOutSchema | None = Field(default=None, description="能源报告模板") + file: FileOutSchema | None = Field(default=None, description="上传文档") + reviewer: CommonSchema | None = Field(default=None, description="审查人") + template_name: str | None = Field(default=None, description="模板名称") + reviewer_name: str | None = Field(default=None, description="审查人名称") + file_url: str | None = Field(default=None, description="文档访问地址") + original_name: str | None = Field(default=None, description="文档原始名称") + + +class EnergyReportReviewStatsSchema(BaseModel): + """能源报告审核任务统计模型。""" + + submitted_count: int = Field(default=0, description="提交任务数") + reviewed_count: int = Field(default=0, description="已审查任务数") + pending_count: int = Field(default=0, description="待审查任务数") + failed_count: int = Field(default=0, description="审查失败任务数") + + +class EnergyReportReviewPageOutSchema(BaseModel): + """能源报告审核任务分页和统计响应模型。""" + + page_no: int = Field(description="当前页码") + page_size: int = Field(description="每页数量") + total: int = Field(description="总数") + has_next: bool = Field(description="是否有下一页") + stats: EnergyReportReviewStatsSchema = Field(description="统计数据") + items: list[dict] = Field(default_factory=list, description="列表数据") + + +@dataclass +class EnergyReportReviewQueryParam(BaseQueryParam, UserByQueryParam, TenantByQueryParam): + """能源报告审核任务查询参数。""" + + task_name: str | None = Query(None, description="任务名称") + status: int | None = Query(None, ge=0, le=2, description="状态 0:待审查 1:已审查 2:审查失败") + template_id: int | None = Query(None, ge=1, description="模板ID") + reviewer_id: int | None = Query(None, ge=1, description="审查人ID") + + def __post_init__(self) -> None: + if self.task_name: + self.task_name = (QueueEnum.like.value, self.task_name) + if isinstance(self.status, int): + self.status = (QueueEnum.eq.value, self.status) + if isinstance(self.template_id, int): + self.template_id = (QueueEnum.eq.value, self.template_id) + if isinstance(self.reviewer_id, int): + self.reviewer_id = (QueueEnum.eq.value, self.reviewer_id) diff --git a/app/api/v1/module_energy/report_review/service.py b/app/api/v1/module_energy/report_review/service.py new file mode 100644 index 0000000..e44d7a1 --- /dev/null +++ b/app/api/v1/module_energy/report_review/service.py @@ -0,0 +1,156 @@ +from app.api.v1.module_common.file.crud import FileCRUD +from app.api.v1.module_energy.report_template.crud import EnergyReportTemplateCRUD +from app.api.v1.module_system.user.crud import UserCRUD +from app.core.base_schema import AuthSchema +from app.core.exceptions import CustomException + +from .crud import EnergyReportReviewCRUD +from .schema import ( + EnergyReportReviewCreateSchema, + EnergyReportReviewOutSchema, + EnergyReportReviewQueryParam, + EnergyReportReviewStatsSchema, + EnergyReportReviewUpdateSchema, +) + +ENERGY_REPORT_REVIEW_BUSINESS_TYPE = "energy_report_review" + + +class EnergyReportReviewService: + """能源报告审核任务服务。""" + + def __init__(self, auth: AuthSchema) -> None: + self.auth = auth + + async def detail(self, id: int) -> EnergyReportReviewOutSchema: + review = await EnergyReportReviewCRUD(self.auth).get_or_404(id=id) + return self._to_out_schema(review) + + async def page( + self, + page_no: int, + page_size: int, + search: EnergyReportReviewQueryParam | None = None, + order_by: list[dict[str, str]] | None = None, + ) -> dict: + search_dict = vars(search) if search else None + stats = await self._stats(search_dict=self._build_stats_search(search_dict)) + offset = (page_no - 1) * page_size + page_result = await EnergyReportReviewCRUD(self.auth).page( + offset=offset, + limit=page_size, + order_by=order_by or [{"id": "desc"}], + search=search_dict, + out_schema=EnergyReportReviewOutSchema, + ) + items = [self._fill_out_dict(item) for item in page_result.items] + return { + "page_no": page_result.page_no, + "page_size": page_result.page_size, + "total": page_result.total, + "has_next": page_result.has_next, + "stats": stats.model_dump(), + "items": items, + } + + async def create(self, data: EnergyReportReviewCreateSchema) -> EnergyReportReviewOutSchema: + await self._validate_template(template_id=data.template_id) + await self._validate_file(file_id=data.file_id) + await self._validate_reviewer(reviewer_id=data.reviewer_id) + review = await EnergyReportReviewCRUD(self.auth).create(data=data) + await self._bind_file(file_id=data.file_id, review_id=review.id) + return self._to_out_schema(review) + + async def update(self, id: int, data: EnergyReportReviewUpdateSchema) -> EnergyReportReviewOutSchema: + old_review = await EnergyReportReviewCRUD(self.auth).get_or_404(id=id, msg="更新失败,能源报告审核任务不存在") + await self._validate_template(template_id=data.template_id) + await self._validate_file(file_id=data.file_id, current_review_id=id) + await self._validate_reviewer(reviewer_id=data.reviewer_id) + review = await EnergyReportReviewCRUD(self.auth).update(id=id, data=data) + if old_review.file_id != data.file_id: + await self._unbind_file(file_id=old_review.file_id, review_id=id) + await self._bind_file(file_id=data.file_id, review_id=id) + return self._to_out_schema(review) + + async def delete(self, ids: list[int]) -> None: + if len(ids) < 1: + raise CustomException(msg="删除失败,删除对象不能为空") + reviews = await EnergyReportReviewCRUD(self.auth).get_list(search={"id": ("in", ids)}) + review_map = {review.id: review for review in reviews} + for review_id in ids: + if review_id not in review_map: + raise CustomException(msg="删除失败,能源报告审核任务不存在") + for review in reviews: + await self._unbind_file(file_id=review.file_id, review_id=review.id) + await EnergyReportReviewCRUD(self.auth).delete(ids=ids) + + async def _stats(self, search_dict: dict | None = None) -> EnergyReportReviewStatsSchema: + records = await EnergyReportReviewCRUD(self.auth).get_list(search=search_dict or {}) + return EnergyReportReviewStatsSchema( + submitted_count=len(records), + reviewed_count=len([record for record in records if record.status == 1]), + pending_count=len([record for record in records if record.status == 0]), + failed_count=len([record for record in records if record.status == 2]), + ) + + def _build_stats_search(self, search_dict: dict | None = None) -> dict: + if not search_dict: + return {} + stats_keys = {"created_time", "updated_time", "tenant_id", "created_id", "updated_id"} + return {key: value for key, value in search_dict.items() if key in stats_keys and value is not None} + + def _to_out_schema(self, review) -> EnergyReportReviewOutSchema: + review_out = EnergyReportReviewOutSchema.model_validate(review) + if review_out.template: + review_out.template_name = review_out.template.template_name + if review_out.file: + review_out.file_url = review_out.file.file_url + review_out.original_name = review_out.file.original_name + if hasattr(review, "reviewer") and review.reviewer: + review_out.reviewer_name = review.reviewer.name + return review_out + + def _fill_out_dict(self, item: dict) -> dict: + template_info = item.get("template") or {} + file_info = item.get("file") or {} + reviewer_info = item.get("reviewer") or {} + item["template_name"] = template_info.get("template_name") + item["file_url"] = file_info.get("file_url") + item["original_name"] = file_info.get("original_name") + item["reviewer_name"] = reviewer_info.get("name") + return item + + async def _validate_template(self, template_id: int) -> None: + template = await EnergyReportTemplateCRUD(self.auth).get(id=template_id) + if not template: + raise CustomException(msg="能源报告模板不存在") + + async def _validate_file(self, file_id: int, current_review_id: int | None = None) -> None: + file_record = await FileCRUD(self.auth).get(id=file_id) + if not file_record: + raise CustomException(msg="文件记录不存在") + if file_record.business_type and file_record.business_type != ENERGY_REPORT_REVIEW_BUSINESS_TYPE: + raise CustomException(msg="文件已绑定到其他业务类型") + if file_record.business_id and file_record.business_id != current_review_id: + raise CustomException(msg="文件已绑定到其他能源报告审核任务") + + async def _validate_reviewer(self, reviewer_id: int | None) -> None: + if reviewer_id is None: + return + reviewer = await UserCRUD(self.auth).get(id=reviewer_id) + if not reviewer: + raise CustomException(msg="审查人不存在") + + async def _bind_file(self, file_id: int, review_id: int) -> None: + await FileCRUD(self.auth).set( + ids=[file_id], + business_type=ENERGY_REPORT_REVIEW_BUSINESS_TYPE, + business_id=review_id, + ) + + async def _unbind_file(self, file_id: int, review_id: int) -> None: + file_record = await FileCRUD(self.auth).get(id=file_id) + if not file_record: + return + if file_record.business_type == ENERGY_REPORT_REVIEW_BUSINESS_TYPE and file_record.business_id == review_id: + await FileCRUD(self.auth).set(ids=[file_id], business_id=None) diff --git a/app/scripts/initialize.py b/app/scripts/initialize.py index 920d927..921fea3 100644 --- a/app/scripts/initialize.py +++ b/app/scripts/initialize.py @@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.v1.module_common.file.model import FileModel from app.api.v1.module_energy.chapter.model import EnergyReportChapterModel +from app.api.v1.module_energy.report_review.model import EnergyReportReviewModel from app.api.v1.module_energy.report_template.model import EnergyReportTemplateModel from app.api.v1.module_energy.variable.model import EnergyReportVariableModel from app.api.v1.module_platform.email.model import EmailConfigModel, EmailTemplateModel @@ -84,6 +85,7 @@ class InitializeData: EnergyReportTemplateModel, EnergyReportChapterModel, EnergyReportVariableModel, + EnergyReportReviewModel, # ── 日志表(追加写入) ── LoginLogModel, OperationLogModel,