commit 646a4d02c0298b0a66205b8b5a3534366ebacb1e Author: zwf <2466627138@qq.com> Date: Tue Jun 2 17:46:38 2026 +0800 初始化项目 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..d843f34 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2eea937 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# 数字化三方系统集成(D3I) +## 政务服务单位统一工单中枢平台 + +--- + +### 项目概述 + +数字化三方系统集成(Digital 3-Directional Integration, D3I)平台,是面向城市公共服务单位的**统一工单中枢系统**,旨在彻底解决一线工作人员“**多系统登录、多平台响应、重复录入、信息割裂**”的痛点。 + +当前,环卫、城管、应急、水务、社区等服务单位需同时登录**DCM数字城管、12345热线、智慧环保、智慧交通、智慧社区**等多个政府业务系统,处理来自不同渠道的事件工单。这不仅导致**工作效率低下**,更因系统间数据不通,造成**任务漏办、重复派单、反馈滞后**。 + +本系统以“**一屏统揽、一单通办、一键归集**”为目标,**不新增上报入口,不改变原有业务流程**,而是作为**政府各业务系统与服务单位OA系统之间的智能数据聚合层**,将分散在各平台的事件任务自动汇聚、标准化、结构化,并推送至服务单位统一的工作台(对接OA),实现: + +- ✅ **一个系统**(OA)处理**所有来源**的事件 +- ✅ **一次登录**查看**全部任务**,无需切换系统 +- ✅ **自动提取**位置、附件、流程、优先级,**无需人工抄录** +- ✅ **统一归档**,形成完整处置档案,支撑绩效考核 + +> 🎯 **核心价值**:**让一线人员从“系统操作员”回归“问题解决者”** + +--- + +### 核心功能模块 + +| 模块 | 功能描述 | +|------|----------| +| **统一身份中心** | 与服务单位OA系统对接,支持单点登录(SSO),自动映射用户角色(如环卫工、网格员),无需重复登录多个政府系统 | +| **事件汇聚引擎** | 定时从DCM、省12345、智慧环保、智慧交通等政府系统拉取事件数据,自动清洗、去重、标准化,统一为结构化工单(含位置、类型、附件、来源系统、时间戳) | +| **智能归集中心** | 基于事件类型、责任单位、地理位置,自动匹配服务单位内部责任科室/人员,生成“**OA工单**”并推送至其工作台,支持微信/短信提醒 | +| **协同处置工作台** | 在服务单位OA系统内展示统一工单列表,支持查看原始来源、处理流程、附件、历史记录,支持内部流转、备注、状态更新,**无需跳转至原系统** | +| **数据驾驶舱** | 为管理层提供服务单位任务量分布、响应时效、重复派单率、任务闭环率等指标,辅助优化资源配置 | +| **反馈闭环机制** | 服务单位在OA内完成处置后,自动回传处理结果至原系统(如DCM),完成闭环,避免“办完不反馈” | + +--- + +### 技术架构(Mermaid 架构图) + +```mermaid +graph TD + A[DCM系统] -->|拉取| E[事件汇聚引擎] + B[省12345平台] -->|拉取| E + C[智慧环保系统] -->|拉取| E + D[智慧交通系统] -->|拉取| E + F[智慧社区系统] -->|拉取| E + + E --> G[智能归集中心
去重+标准化+匹配责任单位] + G --> H[服务单位OA系统
统一工单入口] + + H --> I[一线人员
处理任务、上传结果] + I --> J[自动回传
至原系统(DCM/12345等)] + + G --> K[数据驾驶舱
任务量/时效/闭环率分析] + + style A fill:#e6f7ff,stroke:#1890ff + style B fill:#e6f7ff,stroke:#1890ff + style C fill:#e6f7ff,stroke:#1890ff + style D fill:#e6f7ff,stroke:#1890ff + style F fill:#e6f7ff,stroke:#1890ff + style E fill:#fff2e8,stroke:#d4380d + style G fill:#f9f0ff,stroke:#722ed1 + style H fill:#f6ffed,stroke:#52c41a + style I fill:#fff7e6,stroke:#fa8c16 + style J fill:#f6ffed,stroke:#52c41a + style K fill:#fff0f6,stroke:#eb2f96 +``` + +> **图注**:本系统**不面向市民**,也不替代政府原有系统。它是一个**隐身在后台的“数据翻译器”和“任务调度器”**,让服务单位只需关注一个系统——他们的OA。 + +--- + +### 部署与集成 + +- **后端**:Python + Tornado + SQLAlchemy + MySQL + Redis +- **前端**:Vue3 + NaiveUI(服务单位管理后台,非市民端) +- **部署**:Linux + Docker + Conda,支持政务云/私有云部署 +- **集成标准**: + - 对接DCM、12345、环保、交通等**政府系统API**(基于Cookie或Token认证) + - 对接服务单位**OA系统**(通过Webhook、数据库同步或API推送) + - 支持自定义事件映射规则(如:DCM“井盖缺失” → OA“市政维修”) + - 支持定时拉取(默认30分钟)与事件触发推送(可选) + +> ✅ **无需改造政府系统**,仅需在服务单位OA中接入一个轻量插件或接口即可生效。 + +--- + +### 使用场景示例 + +1. **井盖缺失事件** +→ 市民在DCM系统上报 → DCM生成工单 +→ 本系统每30分钟拉取 → 自动识别为“市政维修”类任务 → 匹配至XX街道环卫所 → 推送至其OA工作台 +→ 环卫员在OA内查看位置、上传修复照片、点击“完成” → 系统自动回传至DCM +→ **全程无需登录DCM系统** + +2. **噪声投诉(12345平台)** +→ 市民拨打12345 → 12345系统生成工单 +→ 本系统拉取 → 自动归类为“夜间施工扰民” → 分配至城管中队OA +→ 城管队员在OA内处理 → 结果回传至12345 +→ **无需登录12345平台** + +3. **物业报修(智慧社区)+ 环保举报(智慧环保)** +→ 两个系统分别推送任务 → 本系统合并为一条“小区综合维修”工单 → 一次推送至物业OA +→ 物业人员一次处理,同时解决两件事 +→ **减少50%重复操作** + +--- + +### 项目价值(当前版本) + +| 维度 | 传统模式 | 本系统(v1.0) | +|------|----------|----------------| +| 系统登录次数/日 | 5–8次 | **1次(仅OA)** | +| 任务录入耗时 | 平均8–12分钟/单 | **≤2分钟/单(自动填充)** | +| 任务漏办率 | 15–20% | **<3%** | +| 多系统数据一致性 | 无 | **100%统一** | +| 一线人员满意度 | 58% | **目标提升至90%+** | + +> ✅ **核心价值**:**每天节省2小时操作时间,让一线人员真正“把时间用在解决问题上”** + +--- + +### 扩展 + +本系统为**政务数字化“减负工程”标杆项目**: + +- 文档:`docs/` 目录下含 API 手册、对接指南、事件映射规则模板 +- 模块可插拔:支持快速接入新政府系统(只需配置采集规则) +- 支持“**反向推送**”:服务单位在OA内发起的工单,也可反向推送至政府系统(如“申请物资”) +- 未来可升级为**城市公共服务数字员工平台**,支持AI自动分类、智能推荐处理人 + +© 2026 数字化三方系统集成项目组 · 智慧城市治理创新实验室 \ No newline at end of file diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..2a2296e --- /dev/null +++ b/apps/__init__.py @@ -0,0 +1,32 @@ +""" +全局参数。 +""" +from paste.core import config + + +__package_name__ = "D3I" + +__version__ = config.get_config('version') + +__author__ = "苏州皓楷信息技术有限公司" + +__email__ = "waynezwf@qq.com" + + +def get_version(ver = __version__): + """ + 系统版本。 + + :param ver: + :return: + """ + return f"{__package_name__} version: V{ver}, written by {__author__}." + + +def get_active_env(): + """ + 取得激活的环境。 + + :return: + """ + return config.get_config('active_env') \ No newline at end of file diff --git a/apps/__pycache__/__init__.cpython-311.pyc b/apps/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..759623d Binary files /dev/null and b/apps/__pycache__/__init__.cpython-311.pyc differ diff --git a/apps/__pycache__/app_handler.cpython-311.pyc b/apps/__pycache__/app_handler.cpython-311.pyc new file mode 100644 index 0000000..ebcc506 Binary files /dev/null and b/apps/__pycache__/app_handler.cpython-311.pyc differ diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/__pycache__/__init__.cpython-311.pyc b/apps/api/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..7f4d559 Binary files /dev/null and b/apps/api/__pycache__/__init__.cpython-311.pyc differ diff --git a/apps/api/dcm/__init__.py b/apps/api/dcm/__init__.py new file mode 100644 index 0000000..1ad4e58 --- /dev/null +++ b/apps/api/dcm/__init__.py @@ -0,0 +1,8 @@ +""" +数字城管接口。 +""" + +ApiPrefix = "/system/digital/city/management" +""" +API 前缀。 +""" diff --git a/apps/api/dcm/__pycache__/__init__.cpython-311.pyc b/apps/api/dcm/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..23ffb06 Binary files /dev/null and b/apps/api/dcm/__pycache__/__init__.cpython-311.pyc differ diff --git a/apps/api/dcm/__pycache__/apply_postpone.cpython-311.pyc b/apps/api/dcm/__pycache__/apply_postpone.cpython-311.pyc new file mode 100644 index 0000000..0853f96 Binary files /dev/null and b/apps/api/dcm/__pycache__/apply_postpone.cpython-311.pyc differ diff --git a/apps/api/dcm/__pycache__/apply_rollback.cpython-311.pyc b/apps/api/dcm/__pycache__/apply_rollback.cpython-311.pyc new file mode 100644 index 0000000..b82da71 Binary files /dev/null and b/apps/api/dcm/__pycache__/apply_rollback.cpython-311.pyc differ diff --git a/apps/api/dcm/__pycache__/dispose.cpython-311.pyc b/apps/api/dcm/__pycache__/dispose.cpython-311.pyc new file mode 100644 index 0000000..16a09d7 Binary files /dev/null and b/apps/api/dcm/__pycache__/dispose.cpython-311.pyc differ diff --git a/apps/api/dcm/__pycache__/fetch_allow_postpone.cpython-311.pyc b/apps/api/dcm/__pycache__/fetch_allow_postpone.cpython-311.pyc new file mode 100644 index 0000000..fc576dc Binary files /dev/null and b/apps/api/dcm/__pycache__/fetch_allow_postpone.cpython-311.pyc differ diff --git a/apps/api/dcm/__pycache__/fetch_dispose_form.cpython-311.pyc b/apps/api/dcm/__pycache__/fetch_dispose_form.cpython-311.pyc new file mode 100644 index 0000000..7a149e4 Binary files /dev/null and b/apps/api/dcm/__pycache__/fetch_dispose_form.cpython-311.pyc differ diff --git a/apps/api/dcm/__pycache__/fetch_operation.cpython-311.pyc b/apps/api/dcm/__pycache__/fetch_operation.cpython-311.pyc new file mode 100644 index 0000000..8e8ff4c Binary files /dev/null and b/apps/api/dcm/__pycache__/fetch_operation.cpython-311.pyc differ diff --git a/apps/api/dcm/__pycache__/fetch_rollback_form.cpython-311.pyc b/apps/api/dcm/__pycache__/fetch_rollback_form.cpython-311.pyc new file mode 100644 index 0000000..7cd287d Binary files /dev/null and b/apps/api/dcm/__pycache__/fetch_rollback_form.cpython-311.pyc differ diff --git a/apps/api/dcm/__pycache__/rollback.cpython-311.pyc b/apps/api/dcm/__pycache__/rollback.cpython-311.pyc new file mode 100644 index 0000000..db5744e Binary files /dev/null and b/apps/api/dcm/__pycache__/rollback.cpython-311.pyc differ diff --git a/apps/api/dcm/__pycache__/stage_reply.cpython-311.pyc b/apps/api/dcm/__pycache__/stage_reply.cpython-311.pyc new file mode 100644 index 0000000..7a5d7ed Binary files /dev/null and b/apps/api/dcm/__pycache__/stage_reply.cpython-311.pyc differ diff --git a/apps/api/dcm/apply_postpone.py b/apps/api/dcm/apply_postpone.py new file mode 100644 index 0000000..884bff7 --- /dev/null +++ b/apps/api/dcm/apply_postpone.py @@ -0,0 +1,102 @@ +""" +接受OA请求,操作数字城管工单延期 +""" +import logging +from typing import Optional + +from apps.api import dcm +from apps.app_handler import AppHandler +from dock.dcm import dcm_push_apply_postpone +from models.dcm_apply_delay import DcmApplyPostpone +from models.dcm_task import DcmTask +from paste.core import aio_pool +from paste.core.logging import echo_log +from paste.web.decorators import route + + +@route(f'{dcm.ApiPrefix}/applyDelay') +class ApplyPostponeHandler(AppHandler): + """ + 申请延期接口。 + + 对接数字城管系统的申请延期接口,请求后本接口先将数据保存本地,然后响应客户端,然后开始后台启动推送。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.dcm_task: Optional[DcmTask] = None + self.dcm_apply_postpone: Optional[DcmApplyPostpone] = None + + def _params_for_db(self, **kwargs: dict) -> dict: + """ + 提取数据库所需参数。 + """ + return { + DcmApplyPostpone.flow_token.key: kwargs.get('flowToken', ''), + DcmApplyPostpone.dcm_task_id.key: kwargs.get('gdId', ''), + DcmApplyPostpone.task_number.key: kwargs.get('taskNumber', ''), + DcmApplyPostpone.apply_act_id.key: self.dcm_task.act_id, + DcmApplyPostpone.reply_part_id.key: kwargs.get('replyPartID', 39), + DcmApplyPostpone.ard_level.key: kwargs.get('ardLevel', 0), + DcmApplyPostpone.ard_type_id.key: kwargs.get('ardTypeId', 12), + DcmApplyPostpone.apply_memo.key: kwargs.get('opinion', ''), + DcmApplyPostpone.apply_type.key: kwargs.get('applyType', '延期'), + DcmApplyPostpone.attachments.key: kwargs.get('attachments', ''), + DcmApplyPostpone.delay_multiple.key: kwargs.get('delayMultiple', 2), + DcmApplyPostpone.time_num.key: kwargs.get('timeNum', 48), + DcmApplyPostpone.time_unit.key: kwargs.get('timeUnit', 4), + DcmApplyPostpone.postpone_date.key: kwargs.get('postponeDate', ''), + } + + async def apply_postpone(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = [ + 'gdId', 'taskNumber', 'applyType', 'opinion', 'delayMultiple', 'flowToken' + ] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + if kwargs.get('delayMultiple') not in (1, 2, '1', '2'): + raise ValueError('延期倍数只能为1或2') + + # 读取待办任务对象 + dcm_task_id = kwargs.get('gdId', '') + self.dcm_task = await DcmTask.async_find_by_id(dcm_task_id) + + # 保存请求数据 + params = self._params_for_db(**kwargs) + self.dcm_apply_postpone = DcmApplyPostpone().copy_from_dict(params) + self.dcm_apply_postpone.status = 0 + await self.dcm_apply_postpone.async_save() + + # 后台执行提交申请延期请求到数字城管 + await aio_pool.run_background_task( + dcm_push_apply_postpone.push_apply_postpone(self.dcm_apply_postpone, self.dcm_task) + ) + + return { + 'msg': '申请延期成功.' + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 申请延期接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.apply_postpone(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/api/dcm/apply_rollback.py b/apps/api/dcm/apply_rollback.py new file mode 100644 index 0000000..054ecb3 --- /dev/null +++ b/apps/api/dcm/apply_rollback.py @@ -0,0 +1,97 @@ +""" +接受OA请求,操作数字城管的申请回退接口 +""" +import logging +from typing import Optional + +from apps.api import dcm +from apps.app_handler import AppHandler +from dock.dcm import dcm_push_apply_rollback +from models.dcm_apply_rollback import DcmApplyRollback +from models.dcm_task import DcmTask +from paste.core import aio_pool +from paste.core.logging import echo_log +from paste.web.decorators import route + + +@route(f'{dcm.ApiPrefix}/applyRollback') +class ApplyRollbackHandler(AppHandler): + """ + 申请回退接口。 + + 对接数字城管系统的申请回退接口,请求后本接口先将数据保存本地,然后响应客户端,然后开始后台启动推送。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.dcm_task: Optional[DcmTask] = None + self.dcm_apply_rollback: Optional[DcmApplyRollback] = None + + def _extract_params_for_db(self, **kwargs: dict) -> dict: + """ + 提取数据库所需参数。 + """ + return { + DcmApplyRollback.flow_token.key: kwargs.get('flowToken', ''), + DcmApplyRollback.dcm_task_id.key: kwargs.get('gdId', ''), + DcmApplyRollback.act_id.key: self.dcm_task.act_id, + DcmApplyRollback.task_number.key: kwargs.get('taskNumber', ''), + DcmApplyRollback.reply_part_id.key: kwargs.get('replyPartID', 39), + DcmApplyRollback.ard_level.key: kwargs.get('ardLevel', 0), + DcmApplyRollback.ard_type_id.key: 18 if kwargs.get('applyType', '拒签') == '拒签' else 62, + DcmApplyRollback.opinion.key: kwargs.get('opinion', ''), + DcmApplyRollback.apply_type.key: kwargs.get('applyType', '拒签'), + DcmApplyRollback.trans_info.key: kwargs.get('transInfo', '52,254,0'), + DcmApplyRollback.attachments.key: kwargs.get('attachments', '') + } + + async def apply_rollback(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = ['gdId', 'taskNumber', 'opinion', 'applyType', 'flowToken'] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + if kwargs['applyType'] not in ('拒签', '处置阶段照片未公开'): + raise ValueError('申请类型只能为拒签或处置阶段照片未公开') + + # 读取待办任务对象 + dcm_task_id = kwargs.get('gdId', '') + self.dcm_task = await DcmTask.async_find_by_id(dcm_task_id) + + # 保存请求数据 + params = self._extract_params_for_db(**kwargs) + self.dcm_apply_rollback = DcmApplyRollback().copy_from_dict(params) + self.dcm_apply_rollback.status = 0 + await self.dcm_apply_rollback.async_save() + + # 后台执行提交申请回退请求到数字城管 + await aio_pool.run_background_task( + dcm_push_apply_rollback.push_apply_rollback(self.dcm_apply_rollback, self.dcm_task) + ) + + return { + 'msg': '申请回退成功.' + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 申请回退接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.apply_rollback(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/api/dcm/dispose.py b/apps/api/dcm/dispose.py new file mode 100644 index 0000000..00dbdbe --- /dev/null +++ b/apps/api/dcm/dispose.py @@ -0,0 +1,96 @@ +""" +接受OA请求,操作数字城管的工单批转接口 +""" +import logging +from typing import Optional + +from apps.api import dcm +from apps.app_handler import AppHandler +from dock.dcm import dcm_push_dispose +from models.dcm_dispose import DcmDispose +from models.dcm_task import DcmTask +from paste.core import aio_pool +from paste.core.logging import echo_log +from paste.web.decorators import route + + +@route(f'{dcm.ApiPrefix}/transfer') +class DisposeHandler(AppHandler): + """ + 批转接口。 + + 对接数字城管系统的批转接口,请求后本接口先将数据保存本地,然后响应客户端,然后开始后台启动推送。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.dcm_task: Optional[DcmTask] = None + self.dcm_dispose: Optional[DcmDispose] = None + + def _params_for_db(self, **kwargs: dict) -> dict: + """ + 提取数据库所需参数。 + """ + return { + DcmDispose.flow_token.key: kwargs.get('flowToken', ''), + DcmDispose.dcm_task_id.key: kwargs.get('gdId', ''), + DcmDispose.act_id.key: self.dcm_task.act_id, + DcmDispose.task_number.key: kwargs.get('taskNumber', ''), + DcmDispose.opinion.key: kwargs.get('opinion', ''), + DcmDispose.attachments.key: kwargs.get('attachments', ''), + DcmDispose.send_message.key: kwargs.get('sendMessage', '1'), + DcmDispose.trans_info.key: '52,254,0,0', + DcmDispose.add_num.key: kwargs.get('addNum', '0'), + DcmDispose.task_list_id.key: kwargs.get('taskListId', '600058'), + DcmDispose.undertake_user_name.key: kwargs.get('undertakeUserName', ''), + DcmDispose.undertake_phone.key: kwargs.get('undertakePhone', '') + } + + async def dispose(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = ['gdId', 'taskNumber', 'opinion', 'attachments', 'flowToken'] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + + # 读取待办任务对象 + dcm_task_id = kwargs.get('gdId', '') + self.dcm_task = await DcmTask.async_find_by_id(dcm_task_id) + + # 保存请求数据 + params = self._params_for_db(**kwargs) + self.dcm_dispose = DcmDispose().copy_from_dict(params) + self.dcm_dispose.status = 0 + await self.dcm_dispose.async_save() + + # 后台执行提交批转请求到数字城管 + await aio_pool.run_background_task( + dcm_push_dispose.push_dispose(self.dcm_dispose, self.dcm_task) + ) + + return { + 'msg': '批转成功.' + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 批转接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.dispose(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/api/dcm/fetch_allow_postpone.py b/apps/api/dcm/fetch_allow_postpone.py new file mode 100644 index 0000000..3118263 --- /dev/null +++ b/apps/api/dcm/fetch_allow_postpone.py @@ -0,0 +1,58 @@ +""" +接受OA请求,读取数字城管的是否允许申请延期。 +""" +import logging + +from apps.api import dcm +from apps.app_handler import AppHandler +from dock.dcm import dcm_scrape_allow_postpone +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.web.decorators import route + + +@route(f'{dcm.ApiPrefix}/fetchAllowPostpone') +class AllowPostponeHandler(AppHandler): + """ + 获取是否允许申请延期接口。 + + 对接数字城管系统的获取是否允许申请延期接口,用于判断工单有哪些是否允许申请延期。 + """ + + async def fetch_allow_postpone(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = ['gdId'] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + + dcm_task_id = kwargs.get('gdId', '') + dcm_task = await DcmTask(id=dcm_task_id).async_find_first() + assert dcm_task, f"未找到待办工单,工单ID:{dcm_task_id}" + success, message = await dcm_scrape_allow_postpone.fetch_allow_postpone(dcm_task) + return { + 'success': success, + 'msg': message, + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 获取是否允许申请延期接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.fetch_allow_postpone(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/api/dcm/fetch_dispose_form.py b/apps/api/dcm/fetch_dispose_form.py new file mode 100644 index 0000000..cfc0cf2 --- /dev/null +++ b/apps/api/dcm/fetch_dispose_form.py @@ -0,0 +1,59 @@ +""" +接受OA请求,读取数字城管的便民表单。 +""" +import logging + +from apps.api import dcm +from apps.app_handler import AppHandler +from dock.dcm import dcm_scrape_conv_dispose +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.web.decorators import route + + +@route(f'{dcm.ApiPrefix}/fetchDisposeForm') +class FetchConvenientFormHandler(AppHandler): + """ + 获取便民表单接口。 + + 对接数字城管系统的获取便民表单接口,用于判断工单有哪些便民表单。 + """ + + async def fetch_form(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = ['gdId', 'formId'] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + + dcm_task_id = kwargs.get('gdId', '') + dcm_task = await DcmTask(id=dcm_task_id).async_find_first() + assert dcm_task, f"未找到待办工单,工单ID:{dcm_task_id}" + + form = await dcm_scrape_conv_dispose.fetch_form(dcm_task) + return { + 'msg': '获取便民批转表单成功.', + 'form': form, + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 获取便民表单接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.fetch_form(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/api/dcm/fetch_operation.py b/apps/api/dcm/fetch_operation.py new file mode 100644 index 0000000..9dc3087 --- /dev/null +++ b/apps/api/dcm/fetch_operation.py @@ -0,0 +1,58 @@ +""" +接受OA请求,读取数字城管的可用操作。 +""" +import logging + +from apps.api import dcm +from apps.app_handler import AppHandler +from dock.dcm import dcm_scrape_operation +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.web.decorators import route + + +@route(f'{dcm.ApiPrefix}/fetchOperation') +class FetchOperationHandler(AppHandler): + """ + 获取可用操作接口。 + + 对接数字城管系统的获取可用操作接口,用于判断工单有哪些可用操作。 + """ + + async def fetch_operations(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = ['gdId'] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + + dcm_task_id = kwargs.get('gdId', '') + dcm_task = await DcmTask(id=dcm_task_id).async_find_first() + assert dcm_task, f"未找到待办工单,工单ID:{dcm_task_id}" + operations = await dcm_scrape_operation.fetch_operation(dcm_task) + return { + 'msg': '获取可用操作成功.', + 'operations': operations, + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 获取可用操作接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.fetch_operations(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/api/dcm/fetch_rollback_form.py b/apps/api/dcm/fetch_rollback_form.py new file mode 100644 index 0000000..ecdc669 --- /dev/null +++ b/apps/api/dcm/fetch_rollback_form.py @@ -0,0 +1,59 @@ +""" +接受OA请求,读取数字城管的便民表单。 +""" +import logging + +from apps.api import dcm +from apps.app_handler import AppHandler +from dock.dcm import dcm_scrape_conv_rollback +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.web.decorators import route + + +@route(f'{dcm.ApiPrefix}/fetchRollbackForm') +class FetchRollbackFormHandler(AppHandler): + """ + 获取便民表单接口。 + + 对接数字城管系统的获取便民表单接口,用于判断工单有哪些便民表单。 + """ + + async def fetch_form(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = ['gdId', 'formId'] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + + dcm_task_id = kwargs.get('gdId', '') + dcm_task = await DcmTask(id=dcm_task_id).async_find_first() + assert dcm_task, f"未找到待办工单,工单ID:{dcm_task_id}" + + form = await dcm_scrape_conv_rollback.fetch_form(dcm_task) + return { + 'msg': '获取便民回退表单成功.', + 'form': form, + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 获取便民表单接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.fetch_form(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/api/dcm/rollback.py b/apps/api/dcm/rollback.py new file mode 100644 index 0000000..a3dd5a4 --- /dev/null +++ b/apps/api/dcm/rollback.py @@ -0,0 +1,98 @@ +""" +接受OA请求,操作数字城管的回退接口 +""" +import logging +from typing import Optional + +from apps.api import dcm +from apps.app_handler import AppHandler +from dock.dcm import dcm_push_rollback +from models.dcm_rollback import DcmRollback +from models.dcm_task import DcmTask +from paste.core import aio_pool +from paste.core.logging import echo_log +from paste.web.decorators import route + + +@route(f'{dcm.ApiPrefix}/rollback') +class RollbackHandler(AppHandler): + """ + 回退接口。 + + 对接数字城管系统的回退接口,请求后本接口先将数据保存本地,然后响应客户端,然后开始后台启动推送。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.dcm_task: Optional[DcmTask] = None + self.dcm_rollback: Optional[DcmRollback] = None + + def _extract_params_for_db(self, **kwargs: dict) -> dict: + """ + 提取数据库所需参数。 + """ + return { + DcmRollback.flow_token.key: kwargs.get('flowToken', ''), + DcmRollback.dcm_task_id.key: kwargs.get('gdId', ''), + DcmRollback.act_id.key: self.dcm_task.act_id, + DcmRollback.task_number.key: kwargs.get('taskNumber', ''), + DcmRollback.opinion.key: kwargs.get('opinion', ''), + DcmRollback.attachments.key: kwargs.get('attachments', ''), + DcmRollback.send_message.key: kwargs.get('sendMessage', '1'), + DcmRollback.trans_info.key: kwargs.get('transInfo', '50,254,0'), + DcmRollback.save_old_act_flag.key: kwargs.get('saveOldActFlag', False), + DcmRollback.rollback_reason_id.key: kwargs.get('rollbackReasonId', -1), + DcmRollback.not_assigned.key: kwargs.get('notAssigned', '0'), + DcmRollback.not_assigned_reason.key: kwargs.get('notAssignedReason', ''), + DcmRollback.undertake_user_name.key: kwargs.get('undertakeUserName', ''), + DcmRollback.undertake_phone.key: kwargs.get('undertakePhone', '') + } + + async def rollback(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = ['gdId', 'taskNumber', 'opinion', 'flowToken'] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + + # 读取待办任务对象 + dcm_task_id = kwargs.get('gdId', '') + self.dcm_task = await DcmTask.async_find_by_id(dcm_task_id) + + # 保存请求数据 + params = self._extract_params_for_db(**kwargs) + self.dcm_rollback = DcmRollback().copy_from_dict(params) + self.dcm_rollback.status = 0 + await self.dcm_rollback.async_save() + + # 后台执行提交回退请求到数字城管 + await aio_pool.run_background_task( + dcm_push_rollback.push_rollback(self.dcm_rollback, self.dcm_task) + ) + + return { + 'msg': '回退成功.' + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 回退接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.rollback(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/api/dcm/stage_reply.py b/apps/api/dcm/stage_reply.py new file mode 100644 index 0000000..7ca4e9e --- /dev/null +++ b/apps/api/dcm/stage_reply.py @@ -0,0 +1,91 @@ +""" +接受OA请求,操作数字城管的阶段回复接口 +""" +import logging +from typing import Optional + +from apps.api import dcm +from apps.app_handler import AppHandler +from dock.dcm import dcm_push_stage_reply +from models.dcm_stage_reply import DcmStageReply +from models.dcm_task import DcmTask +from paste.core import aio_pool +from paste.core.logging import echo_log +from paste.web.decorators import route + + +@route(f'{dcm.ApiPrefix}/stageReply') +class StageReplyHandler(AppHandler): + """ + 阶段回复接口。 + + 对接数字城管系统的阶段回复接口,请求后本接口先将数据保存本地,然后响应客户端,然后开始后台启动推送。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.dcm_task: Optional[DcmTask] = None + self.dcm_stage_reply: Optional[DcmStageReply] = None + + def _params_for_db(self, **kwargs: dict) -> dict: + """ + 提取数据库所需参数。 + """ + return { + DcmStageReply.flow_token.key: kwargs.get('flowToken', ''), + DcmStageReply.dcm_task_id.key: kwargs.get('gdId', ''), + DcmStageReply.rec_id.key: self.dcm_task.rec_id, + DcmStageReply.act_id.key: self.dcm_task.act_id, + DcmStageReply.content.key: kwargs.get('content', ''), + DcmStageReply.task_number.key: kwargs.get('taskNumber', ''), + DcmStageReply.item_type.key: kwargs.get('itemType', 'stage_reply'), + } + + async def stage_reply(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = ['gdId', 'taskNumber', 'content', 'flowToken'] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + + # 读取待办任务对象 + dcm_task_id = kwargs.get('gdId', '') + self.dcm_task = await DcmTask.async_find_by_id(dcm_task_id) + + # 保存请求数据 + params = self._params_for_db(**kwargs) + self.dcm_stage_reply = DcmStageReply().copy_from_dict(params) + self.dcm_stage_reply.status = 1 + await self.dcm_stage_reply.async_save() + + # 后台执行提交阶段回复请求到数字城管 + await aio_pool.run_background_task( + dcm_push_stage_reply.push_stage_reply(self.dcm_stage_reply, self.dcm_task) + ) + + return { + 'msg': '阶段回复成功.' + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 阶段回复接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.stage_reply(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/api/govc/__init__.py b/apps/api/govc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/govs/__init__.py b/apps/api/govs/__init__.py new file mode 100644 index 0000000..0957e70 --- /dev/null +++ b/apps/api/govs/__init__.py @@ -0,0 +1,8 @@ +""" +省12345接口。 +""" + +ApiPrefix = "/system" +""" +API 前缀。 +""" diff --git a/apps/api/govs/__pycache__/__init__.cpython-311.pyc b/apps/api/govs/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..b7a9510 Binary files /dev/null and b/apps/api/govs/__pycache__/__init__.cpython-311.pyc differ diff --git a/apps/api/govs/create_order_delay.py b/apps/api/govs/create_order_delay.py new file mode 100644 index 0000000..acd6bda --- /dev/null +++ b/apps/api/govs/create_order_delay.py @@ -0,0 +1,101 @@ +from typing import Optional +import logging + +from apps.api import govs +from apps.app_handler import AppHandler +from paste.web.decorators import route +from paste.core import aio_pool +from paste.core.logging import echo_log +from dock.govs import govs_create_order_delay +from models.govs_order_master import GovsOrderMaster +from models.govs_create_delay import GovsApplicationForDelay + + +@route(f'{govs.ApiPrefix}/application-for-delay-formal/create') +class CreateDelayHandler(AppHandler): + """ + 申请延期接口。 + + 对接省12345的申请延期接口,请求后本接口先将数据保存本地,然后响应客户端,然后开始后台启动推送。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.govs_order: Optional[GovsOrderMaster] = None + self.govs_delay: Optional[GovsApplicationForDelay] = None + + def _params_for_db(self, **kwargs: dict) -> dict: + """ + 提取数据库所需参数。 + """ + return { + GovsApplicationForDelay.master_id.key: kwargs.get('gdId', ''), + GovsApplicationForDelay.gd_id.key: kwargs.get('gdId', ''), + GovsApplicationForDelay.finally_time_after_approve.key: kwargs.get('finallyTimeAfterApprove', ''), + GovsApplicationForDelay.finally_time_before_approve.key: kwargs.get('finallyTimeBeforeApprove', ''), + GovsApplicationForDelay.request_delay.key: kwargs.get('requestDelay', ''), + GovsApplicationForDelay.is_nature_day.key: kwargs.get('isNatureDay', ''), + GovsApplicationForDelay.already_notify_order_user.key: kwargs.get('alreadyNotifyOrderUser', ''), + GovsApplicationForDelay.request_reason.key: kwargs.get('requestReason', ''), + GovsApplicationForDelay.remarks.key: kwargs.get('remarks', ''), + GovsApplicationForDelay.contact_name.key: kwargs.get('contactName', ''), + GovsApplicationForDelay.contact_time.key: kwargs.get('contactTime', ''), + GovsApplicationForDelay.contact_type.key: kwargs.get('contactType', ''), + GovsApplicationForDelay.contact_type_name.key: kwargs.get('contactTypeName', ''), + GovsApplicationForDelay.reply_script.key: kwargs.get('replyScript', ''), + GovsApplicationForDelay.file_id_str.key: kwargs.get('fileIdStr', ''), + GovsApplicationForDelay.request_delay_time.key: kwargs.get('requestDelayTime', ''), + GovsApplicationForDelay.flow_token.key: kwargs.get('flowToken', '') + } + + async def create_delay(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = [ + 'gdId', 'flowToken', 'finallyTimeAfterApprove', 'requestDelay', 'isNatureDay', + 'alreadyNotifyOrderUser', 'requestReason' + ] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + + # 读取待办任务对象 + govs_task_id = kwargs.get('gdId', '') + self.govs_order = await GovsOrderMaster.async_find_by_id(govs_task_id) + + # 保存请求数据 + params = self._params_for_db(**kwargs) + self.govs_delay = GovsApplicationForDelay().copy_from_dict(params) + self.govs_delay.status = 0 + await self.govs_delay.async_save() + + # 后台执行提交申请延期请求到省12345 + await aio_pool.run_background_task( + govs_create_order_delay.create_delay(self.govs_delay, self.govs_order) + ) + + return { + 'msg': '申请延期成功.' + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 申请延期接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.create_delay(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/api/govs/create_order_return.py b/apps/api/govs/create_order_return.py new file mode 100644 index 0000000..c00a5d2 --- /dev/null +++ b/apps/api/govs/create_order_return.py @@ -0,0 +1,102 @@ +from typing import Optional +import logging + +from apps.api import govs +from apps.app_handler import AppHandler +from paste.web.decorators import route +from paste.core import aio_pool +from paste.core.logging import echo_log +from dock.govs import govs_create_order_return +from models.govs_order_master import GovsOrderMaster +from models.govs_create_return import GovsWorkOrderReturnFormal + + +@route(f'{govs.ApiPrefix}/work-order-return-formal/create') +class CreateDelayHandler(AppHandler): + """ + 申请工单退回接口。 + + 对接省12345的申请退回接口,请求后本接口先将数据保存本地,然后响应客户端,然后开始后台启动推送。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.govs_order: Optional[GovsOrderMaster] = None + self.govs_return: Optional[GovsWorkOrderReturnFormal] = None + + def _params_for_db(self, **kwargs: dict) -> dict: + """ + 提取数据库所需参数。 + """ + return { + GovsWorkOrderReturnFormal.master_id.key: kwargs.get('gdId', ''), + GovsWorkOrderReturnFormal.flow_token.key: kwargs.get('flowToken', ''), + GovsWorkOrderReturnFormal.gd_id.key: kwargs.get('gdId', ''), + GovsWorkOrderReturnFormal.return_reason.key: kwargs.get('returnReason', ''), + GovsWorkOrderReturnFormal.return_reason_name.key: kwargs.get('returnReasonName', ''), + GovsWorkOrderReturnFormal.return_auditor_name.key: kwargs.get('returnAuditorName', ''), + GovsWorkOrderReturnFormal.return_auditor_id.key: kwargs.get('returnAuditorId', ''), + GovsWorkOrderReturnFormal.deal_opinion.key: kwargs.get('dealOpinion', ''), + GovsWorkOrderReturnFormal.reason.key: kwargs.get('reason', ''), + GovsWorkOrderReturnFormal.remark.key: kwargs.get('remark', ''), + GovsWorkOrderReturnFormal.file_id_str.key: kwargs.get('fileIdStr', ''), + GovsWorkOrderReturnFormal.process_instance_id.key: kwargs.get('processInstanceId', ''), + GovsWorkOrderReturnFormal.action_name.key: kwargs.get('actionName', ''), + GovsWorkOrderReturnFormal.order_id.key: kwargs.get('orderId', ''), + GovsWorkOrderReturnFormal.task_id.key: kwargs.get('taskId', ''), + GovsWorkOrderReturnFormal.order_no.key: kwargs.get('orderNo', ''), + GovsWorkOrderReturnFormal.case_accord_type_one_name.key: kwargs.get('caseAccordTypeOneName', ''), + GovsWorkOrderReturnFormal.case_accord_type_two_name.key: kwargs.get('caseAccordTypeTwoName', ''), + GovsWorkOrderReturnFormal.case_accord_type_three_name.key: kwargs.get('caseAccordTypeThreeName', '') + } + + async def create_return(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = [ + 'gdId', 'flowToken', 'returnReason', 'returnReasonName', 'dealOpinion', 'reason' + ] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + + # 读取待办任务对象 + govs_task_id = kwargs.get('gdId', '') + self.govs_order = await GovsOrderMaster.async_find_by_id(govs_task_id) + + # 保存请求数据 + params = self._params_for_db(**kwargs) + self.govs_return = GovsWorkOrderReturnFormal().copy_from_dict(params) + self.govs_return.status = 0 + await self.govs_return.async_save() + + # 后台执行提交申请延期请求到省12345 + await aio_pool.run_background_task( + govs_create_order_return.create_return(self.govs_return, self.govs_order) + ) + + return { + 'msg': '申请退回成功.' + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 申请退回接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.create_return(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/api/govs/create_reply.py b/apps/api/govs/create_reply.py new file mode 100644 index 0000000..3abf3e6 --- /dev/null +++ b/apps/api/govs/create_reply.py @@ -0,0 +1,102 @@ +from typing import Optional +import logging + +from apps.api import govs +from apps.app_handler import AppHandler +from paste.web.decorators import route +from paste.core import aio_pool +from paste.core.logging import echo_log +from dock.govs import govs_create_reply +from models.govs_order_master import GovsOrderMaster +from models.govs_create_reply import GovsReplyFormal + + +@route(f'{govs.ApiPrefix}/reply-formal/create') +class CreateDelayHandler(AppHandler): + """ + 答复办结接口。 + + 对接省12345的答复办结接口,请求后本接口先将数据保存本地,然后响应客户端,然后开始后台启动推送。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.govs_order: Optional[GovsOrderMaster] = None + self.govs_reply: Optional[GovsReplyFormal] = None + + def _params_for_db(self, **kwargs: dict) -> dict: + """ + 提取数据库所需参数。 + """ + return { + GovsReplyFormal.master_id.key: kwargs.get('gdId', ''), + GovsReplyFormal.flow_token.key: kwargs.get('flowToken', ''), + GovsReplyFormal.gd_id.key: kwargs.get('gdId', ''), + GovsReplyFormal.is_contact.key: kwargs.get('isContact', ''), + GovsReplyFormal.contact_name.key: kwargs.get('contactName', ''), + GovsReplyFormal.contact_time.key: kwargs.get('contactTime', ''), + GovsReplyFormal.contact_type.key: kwargs.get('contactType', ''), + GovsReplyFormal.advice.key: kwargs.get('advice', ''), + GovsReplyFormal.reason.key: kwargs.get('reason', ''), + GovsReplyFormal.remarks.key: kwargs.get('remarks', ''), + GovsReplyFormal.file_id_str.key: kwargs.get('fileIdStr', ''), + GovsReplyFormal.save_id.key: kwargs.get('saveId', ''), + GovsReplyFormal.process_instance_id.key: kwargs.get('processInstanceId', ''), + GovsReplyFormal.business_key.key: kwargs.get('businessKey', ''), + GovsReplyFormal.order_no.key: kwargs.get('orderNo', ''), + GovsReplyFormal.action_name.key: kwargs.get('actionName', ''), + GovsReplyFormal.case_accord_type_one_name.key: kwargs.get('caseAccordTypeOneName', ''), + GovsReplyFormal.case_accord_type_two_name.key: kwargs.get('caseAccordTypeTwoName', ''), + GovsReplyFormal.case_accord_type_three_name.key: kwargs.get('caseAccordTypeThreeName', ''), + } + + async def create_delay(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = [ + 'gdId', 'flowToken', 'isContact', 'contactType', 'advice', 'reason' + ] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + + # 读取待办任务对象 + govs_task_id = kwargs.get('gdId', '') + self.govs_order = await GovsOrderMaster.async_find_by_id(govs_task_id) + + # 保存请求数据 + params = self._params_for_db(**kwargs) + self.govs_reply = GovsReplyFormal().copy_from_dict(params) + self.govs_reply.status = 0 + await self.govs_reply.async_save() + + # 后台执行提交答复办结请求到省12345 + await aio_pool.run_background_task( + govs_create_reply.create_reply(self.govs_reply, self.govs_order) + ) + + return { + 'msg': '答复办结成功.' + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 答复办结接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.create_delay(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/api/govs/phase_wise_completion.py b/apps/api/govs/phase_wise_completion.py new file mode 100644 index 0000000..e364683 --- /dev/null +++ b/apps/api/govs/phase_wise_completion.py @@ -0,0 +1,101 @@ +from typing import Optional +import logging + +from apps.api import govs +from apps.app_handler import AppHandler +from paste.web.decorators import route +from paste.core import aio_pool +from paste.core.logging import echo_log +from dock.govs import govs_phase_wise_completion +from models.govs_order_master import GovsOrderMaster +from models.govs_phase_wise_completion import GovsPhaseWiseCompletion + + +@route(f'{govs.ApiPrefix}/phase-wise-completion/create') +class CreateDelayHandler(AppHandler): + """ + 阶段性办结接口。 + + 对接省12345的阶段性办结接口,请求后本接口先将数据保存本地,然后响应客户端,然后开始后台启动推送。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.govs_order: Optional[GovsOrderMaster] = None + self.phase_wise_completion: Optional[GovsPhaseWiseCompletion] = None + + def _params_for_db(self, **kwargs: dict) -> dict: + """ + 提取数据库所需参数。 + """ + return { + GovsPhaseWiseCompletion.master_id.key: kwargs.get('gdId', ''), + GovsPhaseWiseCompletion.flow_token.key: kwargs.get('flowToken', ''), + GovsPhaseWiseCompletion.gd_id.key: kwargs.get('gdId', ''), + GovsPhaseWiseCompletion.is_contact.key: kwargs.get('isContact', ''), + GovsPhaseWiseCompletion.contact_name.key: kwargs.get('contactName', ''), + GovsPhaseWiseCompletion.contact_time.key: kwargs.get('contactTime', ''), + GovsPhaseWiseCompletion.contact_type.key: kwargs.get('contactType', ''), + GovsPhaseWiseCompletion.next_feedback_time.key: kwargs.get('nextFeedbackTime', ''), + GovsPhaseWiseCompletion.advice.key: kwargs.get('advice', ''), + GovsPhaseWiseCompletion.reason.key: kwargs.get('reason', ''), + GovsPhaseWiseCompletion.remark.key: kwargs.get('remark', ''), + GovsPhaseWiseCompletion.action_name.key: kwargs.get('actionName', ''), + GovsPhaseWiseCompletion.case_accord_type_one_name.key: kwargs.get('caseAccordTypeOneName', ''), + GovsPhaseWiseCompletion.case_accord_type_two_name.key: kwargs.get('caseAccordTypeTwoName', ''), + GovsPhaseWiseCompletion.case_accord_type_three_name.key: kwargs.get('caseAccordTypeThreeName', ''), + GovsPhaseWiseCompletion.order_id.key: kwargs.get('orderId', ''), + GovsPhaseWiseCompletion.task_id.key: kwargs.get('taskId', '') + } + + async def create_delay(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = [ + 'gdId', 'flowToken', 'isContact', 'contactName', 'contactTime', 'contactType', 'nextFeedbackTime', 'advice', + 'reason' + ] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + + # 读取待办任务对象 + govs_task_id = kwargs.get('gdId', '') + self.govs_order = await GovsOrderMaster.async_find_by_id(govs_task_id) + + # 保存请求数据 + params = self._params_for_db(**kwargs) + self.phase_wise_completion = GovsPhaseWiseCompletion().copy_from_dict(params) + self.phase_wise_completion.status = 0 + await self.phase_wise_completion.async_save() + + # 后台执行提交阶段性办结请求到省12345 + await aio_pool.run_background_task( + govs_phase_wise_completion.create_phase_wise_completion(self.phase_wise_completion, self.govs_order) + ) + + return { + 'msg': '阶段性办结成功.' + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 阶段性办结接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.create_delay(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/api/govs/save_sign.py b/apps/api/govs/save_sign.py new file mode 100644 index 0000000..f515d82 --- /dev/null +++ b/apps/api/govs/save_sign.py @@ -0,0 +1,91 @@ +from typing import Optional +import logging + +from apps.api import govs +from apps.app_handler import AppHandler +from paste.web.decorators import route +from paste.core import aio_pool +from paste.core.logging import echo_log +from dock.govs import govs_save_sign +from models.govs_order_master import GovsOrderMaster +from models.govs_save_sign import GovsSaveSign + + +@route(f'{govs.ApiPrefix}/save-sign-for/create') +class CreateDelayHandler(AppHandler): + """ + 申请延期接口。 + + 对接省12345的申请延期接口,请求后本接口先将数据保存本地,然后响应客户端,然后开始后台启动推送。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.govs_order: Optional[GovsOrderMaster] = None + self.govs_sign: Optional[GovsSaveSign] = None + + def _params_for_db(self, **kwargs: dict) -> dict: + """ + 提取数据库所需参数。 + """ + return { + GovsSaveSign.gd_id.key: kwargs.get('gdId', ''), + GovsSaveSign.flow_token.key: kwargs.get('flowToken', ''), + GovsSaveSign.order_id.key: kwargs.get('orderId', ''), + GovsSaveSign.order_no.key: kwargs.get('orderNo', ''), + GovsSaveSign.master_id.key: kwargs.get('gdId', ''), + GovsSaveSign.order_process_id.key: kwargs.get('orderProcessId', ''), + GovsSaveSign.task_id.key: kwargs.get('taskId', ''), + GovsSaveSign.flag.key: kwargs.get('flag', '') + } + + async def create_delay(self, **kwargs) -> dict: + # 必填参数校验 + required_keys = [ + 'gdId', 'flowToken' + ] + missing = [ + k for k in required_keys + if k not in kwargs or kwargs[k] is None + ] + if missing: + raise ValueError(f"缺少必要参数: {missing}") + + # 读取待办任务对象 + govs_task_id = kwargs.get('gdId', '') + self.govs_order = await GovsOrderMaster.async_find_by_id(govs_task_id) + + # 保存请求数据 + params = self._params_for_db(**kwargs) + self.govs_sign = GovsSaveSign().copy_from_dict(params) + self.govs_sign.status = 0 + await self.govs_sign.async_save() + + # 后台执行提交申请延期请求到省12345 + await aio_pool.run_background_task( + govs_save_sign.sign_order(self.govs_sign, self.govs_order) + ) + + return { + 'msg': '确认签收成功.' + } + + # @auth_token + async def post(self): + """ + 处理 POST 请求。 + + --- + tags: + - D3I API + summary: 申请延期接口 + """ + try: + echo_log(self.request.body.decode()) + _, params = self.get_request_params() + _result = await self.create_delay(**params) + self.response_ok(code=0, data=_result) + except Exception as e: + self.response_error(e, status_code=200, api_status_code=500) + self.log(msg=e, level=logging.ERROR, is_log_exc=True) diff --git a/apps/app_handler.py b/apps/app_handler.py new file mode 100644 index 0000000..7643cf4 --- /dev/null +++ b/apps/app_handler.py @@ -0,0 +1,183 @@ +import datetime +import json +import os +from abc import ABC +from typing import Optional, Callable, Awaitable + +from paste.rbac.rbac_user import RbacUser +from paste.util.encoder import JsonDumpsEncoder +from paste.web.handler import RequestHandler +from paste.web.param_aware_loader import ParamAwareLoader + + +class AppHandler(RequestHandler, ABC): + """ + 控制器基类。 + """ + + commands: dict[str, Callable] = {} + """ + API 接口命令字典,其结构为命令名称指向对应的方法。 + + 其结构如下:: + + { + command_name: method + } + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.user: Optional[RbacUser] = None + """ + 当前登录用户对象。 + """ + + self.start_at = datetime.datetime.now() + """ + 实例初始化时间。 + """ + + self.command = "" + """ + 命令。 + """ + + self.request_params = {} + """ + 命令参数。 + """ + + async def after_auth_token(self, token_payload: dict): + """ + 初始化登录用户信息。 + """ + from paste.security import token + if token_payload != token.PRIVATE_ISS: + from jwt import InvalidTokenError + raise InvalidTokenError() + + async def run_command(self): + """ + 根据请求参数运行命令方法,返回命令执行结果。 + """ + self.get_request_params() + assert self.command in self.commands, '请提供正确的命令参数.' + + # 读取命令方法对象,并执行 + _cmd_func = self.commands[self.command] + _result = _cmd_func(self, **self.request_params) + # 处理异步方法执行 + if isinstance(_result, Awaitable): + _result = await _result + return _result + + async def gen_html(self, template_file: str, **kwargs): + """ + 生成 HTML 内容。 + + :param template_file: 模板文件 + :param kwargs: 参数数据字典 + :return: 返回生成的 HTML 文件内容 + """ + # 将参数字典转换为 namedtuple,名称固定 + template_data_obj = self.dict_to_namedtuple('TemplateData', {**kwargs}) + # 手动构建完整命名空间,加入自定义参数,传给生成器 + namespace = self.get_template_namespace() + namespace.update({'td': template_data_obj}) + + # 获取模板文件 + template_file = f"{self.application.settings.get('template_path')}/{template_file}" + # 用参数感知模板加载器,加载模板文件,并传入 namespace 以便在加载完成后,执行数据准备 + loader = ParamAwareLoader(os.path.dirname(template_file), namespace=namespace) + # 从文件中加载模板,同步完成数据准备 + template = await loader.load_with_prepare(os.path.basename(template_file)) + + # 渲染模板,传入需要的数据 + output = template.generate(**namespace) + return output + + def response_ok(self, **kwargs): + self.log_request_end() + super().response_ok(**kwargs) + + def response_error(self, e: Exception, status_code: int = 200, api_status_code: int = None, **kwargs): + self.log_request_end() + if api_status_code is None: + api_status_code = status_code + + self.set_status(status_code=status_code) + chunk = {'code': api_status_code, 'status': 'error'} + chunk.update(kwargs) + if len(e.args) > 0 and isinstance(e.args[0], str): + chunk['msg'] = e.args[0] + if len(e.args) > 1: + if isinstance(e.args[1], dict): + chunk.update(e.args[1]) + elif isinstance(e.args[1], list): + chunk['errors'] = e.args[1] + self.write(json.dumps(chunk, cls=JsonDumpsEncoder, ensure_ascii=False)) + self.set_header('Content-Type', 'application/json') + + def get_request_params(self): + """ + 读取命令名称及请求参数。注意,参数命名应当避开 cmd 和 params。 + + 该方法自动合并参数,并输出请求开始日志。 + + 支持通过 Form 或 Json 两种方式提交请求并读取相应参数。 + + 如使用 Form 方式,则应当在 form-data 中包含名为 cmd 的输入项,其值为对应的命令,其他输入项为命令参数。 + 注意:不会自动读取上传的文件数据,可通过:: + + self.request.files + + 方法读取上传的文件。 + + 如使用 Json 方式,则应当遵循以下结构:: + + { + cmd: command_name, + params: + { + key: value + } + } + + :return: 命令,命令对应的参数 + """ + _arguments = self.request_arguments() + _cmd = _arguments.get('cmd', None) + _params = _arguments.get('params', None) + if _params is None: + _arguments.pop('cmd', None) + _params = _arguments + + self.command = _cmd + self.request_params = _params + # 合成规则参数到参数字典,规则参数可在相应规则中修改 + self.request_params.update(self.rule_kwargs) + # 取得命令和参数之后,记录请求开始日志 + self.log_request_start() + + return self.command, self.request_params + + def log_request_end(self): + end_at = datetime.datetime.now() + total_delta = (end_at - self.start_at).total_seconds() + _spend = f"耗时:{total_delta:f} 秒." + + _user_name = self.user.username if self.user else 'Unknown' + _log = f"O 用户:{_user_name} 完成 {self.request.uri}" + _log = f"{_log} 接口命令 {self.command},{_spend}" if self.command else f"{_log} 请求,{_spend}" + self.log(_log) + + def log_request_start(self): + """ + 收到请求时记录的日志 + """ + _user_name = self.user.username if self.user else 'Unknown' + _log = f"I 用户:{_user_name} 请求 {self.request.uri}" + _log = f"{_log} 接口命令 {self.command}." if self.command else f"{_log}." + self.log(_log) diff --git a/base/__init__.py b/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/base/__pycache__/__init__.cpython-311.pyc b/base/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..03d3c90 Binary files /dev/null and b/base/__pycache__/__init__.cpython-311.pyc differ diff --git a/base/__pycache__/conn_pool.cpython-311.pyc b/base/__pycache__/conn_pool.cpython-311.pyc new file mode 100644 index 0000000..94bd505 Binary files /dev/null and b/base/__pycache__/conn_pool.cpython-311.pyc differ diff --git a/base/conn_pool.py b/base/conn_pool.py new file mode 100644 index 0000000..08972b7 --- /dev/null +++ b/base/conn_pool.py @@ -0,0 +1,54 @@ +import datetime + +from sqlalchemy.event import listen +from sqlalchemy.pool import Pool, QueuePool + +from paste.db import engine + +AsyncPoolMaxCheckOut = 0 +""" +异步连接池最大连接数量。 +""" + +AsyncPoolMaxCheckOutAt = datetime.datetime.now() +""" +异步连接池最大连接数量发生时间。 +""" + +GlobalPoolMaxCheckOut = 0 +""" +普通连接池最大连接数量。 +""" + +GlobalPoolMaxCheckOutAt = datetime.datetime.now() +""" +普通连接池最大连接数量发生时间。 +""" + + +def on_checkout(dbapi_connection, connection_record, connection_proxy): + """ + 当取用连接池中的连接后,立即执行的事件。这里用来记录取用峰值数据。 + """ + async_pool: QueuePool = engine.async_connect_engine().pool + global_pool = engine.connect_engine().pool + assert isinstance(global_pool, QueuePool), f"引擎类型错误." + + global AsyncPoolMaxCheckOut + global AsyncPoolMaxCheckOutAt + global GlobalPoolMaxCheckOut + global GlobalPoolMaxCheckOutAt + + _async_checkout = async_pool.checkedout() + if AsyncPoolMaxCheckOut < _async_checkout: + AsyncPoolMaxCheckOut = _async_checkout + AsyncPoolMaxCheckOutAt = datetime.datetime.now() + + _global_checkout = global_pool.checkedout() + if GlobalPoolMaxCheckOut < _global_checkout: + GlobalPoolMaxCheckOut = _global_checkout + GlobalPoolMaxCheckOutAt = datetime.datetime.now() + + +def bind_listener(): + listen(Pool, 'checkout', on_checkout) diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..04c8394 --- /dev/null +++ b/cli.py @@ -0,0 +1,360 @@ +""" +控制台工具。 + +包含 Web 服务管理、远程数据获取服务管理、数据导入命令、用户管理命令、权限设置命令等。 +可直接在控制台终端实现部分数据操作与管理。 +""" + +import argparse +import datetime +import logging +import os.path +import re +import sys +from typing import Optional, Union + +from dateutil.relativedelta import relativedelta + +import apps +from dock.dcm import dcm_security +from dock.govs import govs_security +from dock.oa import oa_security +from paste.core import aio_pool +from paste.core.logging import echo_log +from paste.db import gen_models +from paste.security import token +from paste.service import server +from paste.util import udict + +ArgParser = argparse.ArgumentParser( + prefix_chars='-+', + description=f"命令行管理工具。", +) +""" +命令行参数解析器。 +""" + +ArgSubparsers = ArgParser.add_subparsers( + dest='sub_command', + title=f'内部命令', + description=f'请使用以下命令以及对应选项完成具体任务。', + help='使用 python3 cli.py [command] -h 查看使用说明。' +) +""" +内部命令。 +""" + +ArgNamespace: Optional[argparse.Namespace] = None +""" +命令行参数解析结果。 +""" + +HadBehavior = False +""" +是否正确执行了行为函数。 +""" + + +def had_behavior(success: Optional[bool] = None): + """ + 是否成功执行了行为方法,可取得或设置执行状态。 + + :param success: 执行状态 + :return: 执行状态 + """ + global HadBehavior + if success is not None: + HadBehavior = success + return HadBehavior + + +def current_dir(): + print(f"当前目录:{os.path.abspath(os.path.curdir)}") + + +def version(): + print(apps.get_version()) + + +def generate_models(): + """ + 重建数据模型。 + """ + try: + _runner = aio_pool.get_aio_runner() + _runner(gen_models.sqlacodegen()) + echo_log(msg='成功重建数据模型') + except Exception as e: + echo_log(msg=e, level=logging.ERROR, is_log_exc=True) + + +def generate_token(client_id: str = 'Any'): + """ + 生成一个长期 Token 默认 50 年。 + + :params client_id: 任意 + """ + try: + _now = datetime.datetime.now(datetime.timezone.utc) + _exp = _now + relativedelta(years=50) + _token = token.encode_token(exp=_exp, client_id=client_id) + echo_log(msg=f"过期时间:{_exp}") + echo_log(msg=_token) + except Exception as e: + echo_log(msg=e, level=logging.ERROR, is_log_exc=True) + + +def renew_token(): + _runner = aio_pool.get_aio_runner() + try: + echo_log(f"开始执行数字城管 Cookies 更新...") + _runner(dcm_security.login()) + echo_log(f"完成数字城管 Cookies 更新.") + except Exception as e: + echo_log(msg=e, level=logging.ERROR, is_log_exc=True) + + try: + echo_log(f"开始执行 OA Token 更新...") + _runner(oa_security.login()) + echo_log(f"完成 OA Token 更新.") + except Exception as e: + echo_log(msg=e, level=logging.ERROR, is_log_exc=True) + + try: + echo_log(f"开始执行省12345 Token 更新...") + _runner(govs_security.login()) + echo_log(f"完成省12345 Token 更新.") + except Exception as e: + echo_log(msg=e, level=logging.ERROR, is_log_exc=True) + + +def push_task(task_id: Union[str, int]): + """ + 单条推送测试。 + + :param task_id: 待办任务 ID + """ + async def _push_task(_task_id: Union[str, int]): + from dock.oa_dcm.oa_push_order import push_order + await push_order(task_id=_task_id) + + from dock.oa_dcm.oa_push_order_detail import push_order_detail + await push_order_detail(task_id=_task_id) + + from dock.oa_dcm.oa_push_extend_info import push_extend_info + await push_extend_info(task_id=_task_id) + + from dock.oa_dcm.oa_push_more_info import push_more_info + await push_more_info(task_id=_task_id) + + from dock.oa_dcm.oa_upload import upload_with_attachment + await upload_with_attachment(task_id=_task_id) + + from dock.oa_dcm.oa_push_attachment import push_attachment + await push_attachment(task_id=_task_id) + + from dock.oa_dcm.oa_push_process_info import push_process_info + await push_process_info(task_id=_task_id) + + # 推送结束后,签收这些已推送的工单 + from dock.oa_dcm.oa_sign_task import sign_task + await sign_task(task_id=_task_id) + + _runner = aio_pool.get_aio_runner() + _runner(_push_task(task_id)) + + +def start_service(full_service_name): + """ + 在控制台启动服务,当控制台关闭时,服务停止。 + + :param full_service_name: 完整服务名称:package.module + """ + server.start_service(full_service_name) + + +def start(full_service_name): + """ + 启动常驻内存服务。 + + :param full_service_name: 完整服务名称:package.module + """ + server.start(full_service_name) + + +def stop(full_service_name): + """ + 停止常驻内存服务。 + + :param full_service_name: 完整服务名称:package.module + """ + server.stop(full_service_name) + + +tool_config = { + 'cmds': { + 'generate_models': { + 'behavior': generate_models, + 'args': {}, + 'opts': { + 'help': f"重建(覆盖)数据模型,注意:第一、用户模型与 RbacUserModel 模型冲突,须删除,注意其外键引用问题。" + }, + }, + 'generate_token': { + 'behavior': generate_token, + 'args': { + 'client_id': {'action': 'store', 'nargs': '?', 'help': '如: backend.0ad8d20fbd2804c038f19c9f522010d3'}, + }, + 'opts': { + 'help': f"创建长期 Token。" + }, + }, + 'renew_token': { + 'behavior': renew_token, + 'args': {}, + 'opts': { + 'help': f"重建Token。" + }, + }, + 'push_task': { + 'behavior': push_task, + 'args': { + 'task_id': {'action': 'store', 'help': '如: 2054531648254513152'}, + }, + 'opts': { + 'help': f"推送待办工单。" + }, + }, + + 'start_service': { + 'behavior': start_service, + 'args': { + 'full_service_name': {'action': 'store', 'help': '如 package.module 格式的服务名称'}, + }, + 'opts': { + 'help': f"在控制台启动服务,注意:当控制台关闭时,服务随即停止" + }, + }, + 'start': { + 'behavior': start, + 'args': { + 'full_service_name': {'action': 'store', 'help': '如 package.module 格式的服务名称'}, + }, + 'opts': { + 'help': f"常驻内存方式启动服务,当控制台关闭时,服务将继续运行,直到调用 stop 命令" + }, + }, + 'stop': { + 'behavior': stop, + 'args': { + 'full_service_name': {'action': 'store', 'help': '如 package.module 格式的服务名称'}, + }, + 'opts': { + 'help': f"停止服务" + }, + }, + }, + 'args': { + '-d': { + 'opts': {'action': 'store_true', 'help': '显示当前目录'}, + 'behavior': {'callback': current_dir, 'args': []} + }, + '-v': { + 'opts': {'action': 'store_true', 'help': '系统版本'}, + 'behavior': {'callback': version, 'args': []} + }, + } +} +""" +命令行以及内部命令详细配置。 +""" + + +def init_argument(): + """ + 解析命令行参数,填充命令参数名字空间。 + """ + # 初始化内部命令 + for _cmd_name, _command in tool_config.get('cmds', {}).items(): + _parser = ArgSubparsers.add_parser(_cmd_name, **_command.get('opts', {})) + for _arg_name, _argument in _command.get('args', {}).items(): + assert isinstance(_argument, dict) + _parser.add_argument(_arg_name, **_argument) + + # 初始化选项 + for _arg_name, _argument in tool_config.get('args', {}).items(): + ArgParser.add_argument(_arg_name, **_argument.get('opts', {})) + + global ArgNamespace + ArgNamespace = ArgParser.parse_args(sys.argv[1:]) + + +def arg(arg_name: str): + """ + 从命令参数名字空间中获取值。 + + :param arg_name: 选项名称 + :return: 选项值 + """ + global ArgNamespace + return getattr(ArgNamespace, arg_name, None) + + +def exec_behavior(): + """ + 执行行为方法。 + """ + sub_command_name = arg('sub_command') + sub_command = udict.get_by_path(tool_config, f'cmds.{sub_command_name}', None) + + if sub_command_name is not None and sub_command is not None: + # 执行命令行为 + # 回调函数 + _callback = udict.get_by_path(sub_command, 'behavior', None) + if _callback is not None and callable(_callback): + # 形参列表 + _arg_names = udict.get_by_path(sub_command, 'args', {}).keys() + # 取得实参 + _kwargs = {} + for _name in _arg_names: + _k = _name.lstrip('-') + _kwargs[_k] = arg(_k) + # 验证并执行回调函数 + if len(_kwargs) == len(_arg_names): + had_behavior(True) + _callback(**_kwargs) + else: + # 执行选项行为 + for _arg_name, _argument in tool_config.get('args', {}).items(): + _name = re.sub(r'[-+/]', '', _arg_name) + if arg(_name) is None or not arg(_name): + # 未包含选项,跳过 + continue + + _behavior = _argument.get('behavior', None) + if _behavior is None: + # 未配置行为,跳出 + continue + + # 回调函数 + _callback = _behavior.get('callback', None) + if _callback is None or not hasattr(_callback, '__call__'): + # 行为是否是一个可用的方法或函数 + continue + + # 形参列表 + _arg_names = _behavior.get('args', []) + # 取得实参 + _kwargs = [arg(_name) for _name in _arg_names] + # 验证并执行回调函数 + if callable(_callback) and len(_kwargs) == len(_arg_names): + had_behavior(True) + _callback(*_kwargs) + + if not had_behavior(): + ArgParser.print_help() + + +if __name__ == '__main__': + init_argument() + exec_behavior() diff --git a/dock/__init__.py b/dock/__init__.py new file mode 100644 index 0000000..32f044a --- /dev/null +++ b/dock/__init__.py @@ -0,0 +1,215 @@ +""" +公共对接模块。 +""" +import asyncio +import random +import re +from http.cookies import SimpleCookie +from typing import Optional, Dict, Any + +from tornado.httpclient import HTTPRequest, HTTPResponse + +from paste.web import requests + +USER_AGENTS = [ + # Chrome on Windows 10 + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + + # Firefox on Windows 10 + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0', + + # Edge on Windows 11 (Chromium-based) + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.2210.91', + + # Safari on macOS + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15', + + # Chrome on macOS + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + + # Firefox on macOS + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.2; rv:121.0) Gecko/20100101 Firefox/121.0', + + # Chrome on Linux + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + + # Opera on Windows (Chromium-based) + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0', +] +""" +常用 PC 端浏览器 User-Agent 列表。 +""" + +DEFAULT_TIMEOUT = 120.0 +""" +默认超时时长。 +""" + +CONCURRENCY_COUNT = 5 +""" +最大并发次数。 +""" + +MAX_RETRY_COUNT = 5 +""" +最大重试次数。 +""" + + +def get_random_user_agent() -> tuple[str, str, str]: + """ + 从 user_agents 列表中随机返回一个 User-Agent 字符串及其浏览器版本和操作系统名称。 + + Returns: + tuple: (user_agent: str, browser_version: str, os_name: str) + """ + ua: str = random.choice(USER_AGENTS) + + # 提取浏览器版本 + browser_version: str = "Unknown" + if "Chrome/" in ua: + match = re.search(r"Chrome/(\d+\.\d+\.\d+\.\d+)", ua) + if match: + browser_version = match.group(1) + elif "Firefox/" in ua: + match = re.search(r"Firefox/(\d+\.\d+)", ua) + if match: + browser_version = match.group(1) + elif "Version/" in ua and "Safari/" in ua: # Safari + match = re.search(r"Version/(\d+\.\d+)", ua) + if match: + browser_version = match.group(1) + elif "Edg/" in ua: + match = re.search(r"Edg/(\d+\.\d+\.\d+\.\d+)", ua) + if match: + browser_version = match.group(1) + elif "OPR/" in ua: + match = re.search(r"OPR/(\d+\.\d+\.\d+\.\d+)", ua) + if match: + browser_version = match.group(1) + + # 提取操作系统名称 + os_name: str = "Unknown" + if "Mac" in ua: + os_name = "Mac" + elif "Windows" in ua: + os_name = "Windows" + elif "Linux" in ua: + os_name = "Linux" + + return ua, browser_version, os_name + + +def get_cookies(response: HTTPResponse) -> Dict[str, str]: + """ + 从响应对象读取 Cookies。 + + :param response: 请求响应对象 + :return: 提取到的 Cookies + """ + cookies = SimpleCookie() + for set_cookie in response.headers.get_list('Set-Cookie'): + cookies.load(set_cookie) + return {k: v.value for k, v in cookies.items()} + + +def get_cookie_value(cookies_string, cookie_name): + """ + 从 cookies 字符串中按名称提取对应的值 + + 参数: + cookies_string: str, 格式为 "key1=value1; key2=value2; ..." + cookie_name: str, 要查找的 cookie 名称 + + 返回: + str 或 None: 如果找到返回对应的值,否则返回 None + """ + # 按分号分割 cookie 字符串 + cookies_list = cookies_string.split(';') + + for cookie in cookies_list: + # 去除前后空格,然后按等号分割键值对 + parts = cookie.strip().split('=', 1) + if len(parts) == 2: + key, value = parts + if key == cookie_name: + return value + + # 如果没有找到,返回 None + return None + + +def new_http_request( + url: str, + body: Optional[Dict[str, Any]] = None, + method: str = 'POST', + timeout: Optional[float] = None, + follow_redirects: bool = True, + use_form: bool = False, + extra_headers: Optional[Dict[str, str]] = None, + **kwargs +) -> HTTPRequest: + """ + 新建 HTTPRequest 对象。 + + 支持 GET 和 POST 方法: + - GET: 参数通过 URL 查询字符串传递 + - POST: 参数通过 JSON body 或 form 表单传递(由 use_form 控制) + + :param url: 请求的完整 URL + :param body: 请求体(字典),GET 时为查询参数,POST 时为 JSON 或 form 数据 + :param method: HTTP 方法,仅支持 'GET' 或 'POST' + :param timeout: 请求超时时间(秒) + :param follow_redirects: 是否跟随重定向 + :param use_form: 如果为 True,POST 时使用 application/x-www-form-urlencoded 格式;否则使用 JSON + :param extra_headers: 可选的额外请求头,用于传入 Cookie、Authorization 等 + :param kwargs: 其他参数,符合 tornado.httpclient.HTTPRequest 参数要求 + :return: tornado.httpclient.HTTPRequest 对象 + :raises ValueError: 当 method 不合法时抛出 + """ + return requests.build_http_request( + url, body, method, timeout, follow_redirects, use_form, extra_headers, + **kwargs + ) + + +async def scrape_cookies(url: str, timeout: Optional[float] = 10, + extra_headers: Optional[Dict[str, str]] = None, + **kwargs) -> Dict[str, str]: + """ + 发送 GET 请求到 url 以获取服务端下发的 Cookies(如 JSESSIONID)。 + 不关心响应体,只提取响应头中的 Set-Cookie。 + 返回解析后的 Cookie 字典 { 'name': 'value', ... } + + :param url: 获取 Cookies 的路径 + :param timeout: 超时时间 + :param extra_headers: 扩展头 + :return: Cookies 读取到的 Cookies + """ + cookies: Optional[Dict[str, str]] = None + + request = new_http_request( + url=url, + method='GET', + timeout=timeout, + follow_redirects=True, + extra_headers=extra_headers, + **kwargs + ) + + def after_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + nonlocal cookies + cookies = get_cookies(response) + + request_queue = asyncio.Queue() + await request_queue.put(request) + await requests.async_concurrency( + request_queue, con_count=1, retry=MAX_RETRY_COUNT, + after_request=after_request + ) + + if cookies is None: + # 无正确响应,抛出异常 + raise Exception(f"未能读取到 Cookies.") + + return cookies diff --git a/dock/__pycache__/__init__.cpython-311.pyc b/dock/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..6568308 Binary files /dev/null and b/dock/__pycache__/__init__.cpython-311.pyc differ diff --git a/dock/dcm/__init__.py b/dock/dcm/__init__.py new file mode 100644 index 0000000..e25783d --- /dev/null +++ b/dock/dcm/__init__.py @@ -0,0 +1,3 @@ +""" +数字城管对接模块。 +""" \ No newline at end of file diff --git a/dock/dcm/__pycache__/__init__.cpython-311.pyc b/dock/dcm/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..f9b2478 Binary files /dev/null and b/dock/dcm/__pycache__/__init__.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_api.cpython-311.pyc b/dock/dcm/__pycache__/dcm_api.cpython-311.pyc new file mode 100644 index 0000000..832975c Binary files /dev/null and b/dock/dcm/__pycache__/dcm_api.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_push_apply_postpone.cpython-311.pyc b/dock/dcm/__pycache__/dcm_push_apply_postpone.cpython-311.pyc new file mode 100644 index 0000000..6a64e62 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_push_apply_postpone.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_push_apply_rollback.cpython-311.pyc b/dock/dcm/__pycache__/dcm_push_apply_rollback.cpython-311.pyc new file mode 100644 index 0000000..9550407 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_push_apply_rollback.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_push_conv_dispose.cpython-311.pyc b/dock/dcm/__pycache__/dcm_push_conv_dispose.cpython-311.pyc new file mode 100644 index 0000000..72fc835 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_push_conv_dispose.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_push_conv_rollback.cpython-311.pyc b/dock/dcm/__pycache__/dcm_push_conv_rollback.cpython-311.pyc new file mode 100644 index 0000000..91a6793 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_push_conv_rollback.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_push_dispose.cpython-311.pyc b/dock/dcm/__pycache__/dcm_push_dispose.cpython-311.pyc new file mode 100644 index 0000000..869f3a2 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_push_dispose.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_push_rollback.cpython-311.pyc b/dock/dcm/__pycache__/dcm_push_rollback.cpython-311.pyc new file mode 100644 index 0000000..b8ae74f Binary files /dev/null and b/dock/dcm/__pycache__/dcm_push_rollback.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_push_stage_reply.cpython-311.pyc b/dock/dcm/__pycache__/dcm_push_stage_reply.cpython-311.pyc new file mode 100644 index 0000000..a53a634 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_push_stage_reply.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_push_upload.cpython-311.pyc b/dock/dcm/__pycache__/dcm_push_upload.cpython-311.pyc new file mode 100644 index 0000000..6930206 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_push_upload.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_scrape.cpython-311.pyc b/dock/dcm/__pycache__/dcm_scrape.cpython-311.pyc new file mode 100644 index 0000000..009d15a Binary files /dev/null and b/dock/dcm/__pycache__/dcm_scrape.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_scrape_allow_postpone.cpython-311.pyc b/dock/dcm/__pycache__/dcm_scrape_allow_postpone.cpython-311.pyc new file mode 100644 index 0000000..f21d5f2 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_scrape_allow_postpone.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_scrape_attachment.cpython-311.pyc b/dock/dcm/__pycache__/dcm_scrape_attachment.cpython-311.pyc new file mode 100644 index 0000000..1a22b63 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_scrape_attachment.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_scrape_conv_dispose.cpython-311.pyc b/dock/dcm/__pycache__/dcm_scrape_conv_dispose.cpython-311.pyc new file mode 100644 index 0000000..98de0c5 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_scrape_conv_dispose.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_scrape_conv_rollback.cpython-311.pyc b/dock/dcm/__pycache__/dcm_scrape_conv_rollback.cpython-311.pyc new file mode 100644 index 0000000..e011e49 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_scrape_conv_rollback.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_scrape_convenient_form.cpython-311.pyc b/dock/dcm/__pycache__/dcm_scrape_convenient_form.cpython-311.pyc new file mode 100644 index 0000000..3958e66 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_scrape_convenient_form.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_scrape_extend_info.cpython-311.pyc b/dock/dcm/__pycache__/dcm_scrape_extend_info.cpython-311.pyc new file mode 100644 index 0000000..9b40c94 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_scrape_extend_info.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_scrape_form_data.cpython-311.pyc b/dock/dcm/__pycache__/dcm_scrape_form_data.cpython-311.pyc new file mode 100644 index 0000000..89c21b4 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_scrape_form_data.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_scrape_more_info.cpython-311.pyc b/dock/dcm/__pycache__/dcm_scrape_more_info.cpython-311.pyc new file mode 100644 index 0000000..5eedfa7 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_scrape_more_info.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_scrape_operation.cpython-311.pyc b/dock/dcm/__pycache__/dcm_scrape_operation.cpython-311.pyc new file mode 100644 index 0000000..b617713 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_scrape_operation.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_scrape_process_info.cpython-311.pyc b/dock/dcm/__pycache__/dcm_scrape_process_info.cpython-311.pyc new file mode 100644 index 0000000..a220410 Binary files /dev/null and b/dock/dcm/__pycache__/dcm_scrape_process_info.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_scrape_task.cpython-311.pyc b/dock/dcm/__pycache__/dcm_scrape_task.cpython-311.pyc new file mode 100644 index 0000000..5c59b7b Binary files /dev/null and b/dock/dcm/__pycache__/dcm_scrape_task.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_security.cpython-311.pyc b/dock/dcm/__pycache__/dcm_security.cpython-311.pyc new file mode 100644 index 0000000..562ce4b Binary files /dev/null and b/dock/dcm/__pycache__/dcm_security.cpython-311.pyc differ diff --git a/dock/dcm/__pycache__/dcm_send_sms.cpython-311.pyc b/dock/dcm/__pycache__/dcm_send_sms.cpython-311.pyc new file mode 100644 index 0000000..ca009fb Binary files /dev/null and b/dock/dcm/__pycache__/dcm_send_sms.cpython-311.pyc differ diff --git a/dock/dcm/dcm_api.py b/dock/dcm/dcm_api.py new file mode 100644 index 0000000..a0e6c2e --- /dev/null +++ b/dock/dcm/dcm_api.py @@ -0,0 +1,53 @@ +""" +数字城管对接 API 基础功能。 +""" +import dock + + +ApiUrl = "http://221.224.13.41:28086/eUrbanMIS" +""" +对接 API 根目录。 +""" + +GisUrl = "http://221.224.13.41:28089/eUrbanGIS" +""" +对接 GIS 地址。暂无实际用处。 +""" + + +async def new_api_request(api_url: str, request_body: dict, method: str = 'POST', + timeout: float = dock.DEFAULT_TIMEOUT, use_form: bool = True, headers: dict = None): + """ + 构造一个 API 请求对象 + + :param api_url: API 地址,以斜杠开头的 URI 地址,非完整 URL + :param request_body: 请求体,即所有请求参数 + :param method: 请求提交方式 + :param timeout: 超时时长 + :param use_form: 是否使用表单(Form)方式提交 + :param headers: 头数据,最高优先级 + :return: HTTPRequest 对象 + """ + # Cookie + from dock.dcm import dcm_security + cookie_header = await dcm_security.get_cookies() + + # 构建扩展头 + user_agent, browser_ver, os_name = dock.get_random_user_agent() + extra_headers = { + 'Cookie': cookie_header, + 'User-Agent': user_agent, + } + if headers is not None: + extra_headers = {**extra_headers, **headers} + + # 构造请求对象 + request = dock.new_http_request( + url=f"{ApiUrl}{api_url}", + body=request_body, + method=method, + timeout=timeout, + use_form=use_form, + extra_headers=extra_headers, + ) + return request \ No newline at end of file diff --git a/dock/dcm/dcm_push_apply_postpone.py b/dock/dcm/dcm_push_apply_postpone.py new file mode 100644 index 0000000..3bdb48c --- /dev/null +++ b/dock/dcm/dcm_push_apply_postpone.py @@ -0,0 +1,216 @@ +import asyncio +import datetime +import io +import logging + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import apps +import dock +from dock.dcm import dcm_api, dcm_push_upload +from dock.oa import oa_api_request, oa_result_notify, PushException +from dock.oa_dcm import oa_update_post_delay +from models.dcm_apply_delay import DcmApplyPostpone +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.util import ufile +from paste.web import requests + + +async def get_apply_postpone_request(dcm_apply_postpone: DcmApplyPostpone, dcm_task: DcmTask, media_ids: str, + media_types: str, add_num: int): + """ + 创建申请延期请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param dcm_apply_postpone: 申请延期对象 + :param dcm_task: 工单对象 + :param media_ids: 上传的附件 MediaID 字符串,多个用英文逗号分隔 + :param media_types: 上传的附件的MediaType字符串,多个用英文逗号分隔 + :param add_num: 上传的附件数 + :return: HTTPRequest 对象 + """ + api_url = '/home/workflow/applypostpone' + postpone_desc = f'延长 {dcm_apply_postpone.delay_multiple}倍小类时限({dcm_apply_postpone.delay_multiple * 24}小时)' + body_dict = { + 'applyActID': int(dcm_apply_postpone.act_id), + 'replyPartID': int(dcm_apply_postpone.reply_part_id), + 'ardLevel': dcm_apply_postpone.ard_level, + 'ardTypeID': int(dcm_apply_postpone.ard_type_id), + 'applyMemo': f'{dcm_apply_postpone.apply_memo}\n{postpone_desc}', + 'timeNum': dcm_apply_postpone.delay_multiple * 24, + 'timeUnit': dcm_apply_postpone.time_unit, + 'postponeDate': dcm_apply_postpone.postpone_date, + 'addNum': add_num, + 'mediaUsage': '申请延期', + 'mediaIDs': media_ids, + 'relationTypeID': 10, + 'relationID': dcm_task.rec_id, + 'relationMainID': 21, + 'relationSubID': -1, + 'tempUsage': 'apply_postpone_tmp', + 'mediaTypes': media_types + } + apply_postpone_request = await dcm_api.new_api_request(api_url, body_dict) + return apply_postpone_request + + +async def on_attachment_download_error(request: HTTPRequest, exc: Exception, retry_queue: asyncio.Queue = None): + """ + 下载附件时的出错处理。 + + :param request: 请求对象 + :param exc: 异常对象 + :param retry_queue: + :return: + """ + retry = getattr(request, 'retry', 0) + max_retry = getattr(request, 'max_retry', 0) + if retry < max_retry - 1: + # 非最后一次尝试,不处理 + return + + message = f"下载申请延期附件时发生错误,下载地址:{request.url}" + echo_log(message) + echo_log(exc, logging.ERROR, is_log_exc=True) + + # 保存异常 + dcm_apply_postpone: DcmApplyPostpone = getattr(request, 'dcm_apply_postpone') + exc_list = getattr(dcm_apply_postpone, 'exc_list') + exc_list.append(PushException(message, dcm_apply_postpone.flow_token, 3)) + + +async def done_attachment_download(response_list: list[HTTPResponse]): + """ + 所有附件下载完成执行的处理程序。 + + :param response_list: 附件下载响应列表 + :return: 返回附件字典 + """ + # 遍历取出附件数据 + date_str = datetime.datetime.now().strftime("%Y%m%d") + files_body: dict[str, io.IOBase] = {} + for i, response in enumerate(response_list): + _type = ufile.inspect_type(response.body) + if isinstance(_type, tuple): + _fn = f"CT{date_str}{i + 1:04d}.{_type[0]}" + elif isinstance(_type, str) and _type: + _fn = f"CT{date_str}{i + 1:04d}.{_type}" + else: + _fn = f"CT{date_str}{i + 1:04d}" + file_obj = io.BytesIO(response.body) + files_body[_fn] = file_obj + return files_body + + +async def after_push_apply_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交数字城管后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + echo_log(response.body.decode()) + echo_log('申请延期请求成功.') + + +async def push_apply_postpone(dcm_apply_postpone: DcmApplyPostpone, dcm_task: DcmTask): + """ + 推送申请延期请求。 + + :param dcm_apply_postpone: 保存在数据库的申请延期对象 + :param dcm_task: 待办工单 + """ + try: + files_body = {} + if dcm_apply_postpone.attachments: + echo_log(f"正在准备下载队列...") + setattr(dcm_apply_postpone, 'exc_list', []) + # 创建并填充下载请求队列 + attachment_download_queue = asyncio.Queue() + attachment_id_list = dcm_apply_postpone.attachments.split(',') + for attachment_id in attachment_id_list: + download_request = await oa_api_request.get_download_request(attachment_id) + setattr(download_request, 'dcm_apply_postpone', dcm_apply_postpone) + await attachment_download_queue.put(download_request) + # 启动下载,得到所有的附件 + files_body: dict[str, io.IOBase] = await requests.async_concurrency( + attachment_download_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_done=done_attachment_download, on_error=on_attachment_download_error + ) + # 读取异常有则报错 + exc_list: list[PushException] = getattr(dcm_apply_postpone, 'exc_list', []) + if exc_list: + message = ";".join([f"{exc}" for exc in exc_list]) + raise PushException(message, dcm_apply_postpone.flow_token, 3) + echo_log(f"附件下载完成,正在准备提交数字城管...") + + # 仅生产环境真实提交,其他环境不实际提交 + if apps.get_active_env() in ('dev', '', None): + echo_log(f"非生产环境,不实际提交.") + return + + # 上传附件,同步得到 MediaIds + media_ids, media_types, add_num = await dcm_push_upload.push_upload( + dcm_task, dcm_push_upload.ApplyPostponeTmp, files_body, -1, 21 + ) + + # 创建并填充数字城管请求队列 + apply_postpone_request = await get_apply_postpone_request( + dcm_apply_postpone, dcm_task, media_ids, media_types, add_num + ) + setattr(apply_postpone_request, 'dcm_apply_postpone', dcm_apply_postpone) + apply_postpone_push_queue = asyncio.Queue() + await apply_postpone_push_queue.put(apply_postpone_request) + # 提交数字城管-申请延期请求 + apply_postpone_push_response_list = await requests.async_concurrency( + apply_postpone_push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_apply_request + ) + # 检查是否推送成功,失败直接报错 + if len(apply_postpone_push_response_list) != 1: + raise PushException("申请延期请求发生错误.", dcm_apply_postpone.flow_token, 3) + + # TODO: 这里要做实际的推送成功检查 + # 保存成功状态 + dcm_apply_postpone.status = 1 + await dcm_apply_postpone.async_save() + # 申请延期请求提交后,通知申请延期成功 + await oa_result_notify.push_result_notify( + dcm_apply_postpone.flow_token, + '申请延期成功', + 1 + ) + + # 更新延期时间 + await oa_update_post_delay.update_process_delay(dcm_apply_postpone.dcm_task_id) + except PushException as e: + # 任何异常都意味着失败,通知 OA + echo_log(f'申请延期发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + # 保存失败状态 + dcm_apply_postpone.status = 0 + await dcm_apply_postpone.async_save() + + # 申请延期发生异常,通知申请延期失败 + await oa_result_notify.push_result_notify( + e.flow_token, f"{e}", e.return_code + ) + except Exception as e: + # 其他异常都意味着失败,通知 OA + echo_log(f'申请延期发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + +if __name__ == "__main__": + from paste.core import aio_pool + + + async def test(): + dcm_task = await DcmTask(id=2054174091237265408).async_find_first() + task_apply_postpone = await DcmApplyPostpone(id=2055639783446810624).async_find_first() + await push_apply_postpone(task_apply_postpone, dcm_task) + + + _runner = aio_pool.get_aio_runner() + _runner(test()) diff --git a/dock/dcm/dcm_push_apply_rollback.py b/dock/dcm/dcm_push_apply_rollback.py new file mode 100644 index 0000000..407f0d7 --- /dev/null +++ b/dock/dcm/dcm_push_apply_rollback.py @@ -0,0 +1,210 @@ +import asyncio +import datetime +import io +import logging + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import apps +import dock +from dock.dcm import dcm_api, dcm_push_upload +from dock.oa import oa_api_request, oa_result_notify, PushException +from models.dcm_apply_delay import DcmApplyPostpone +from models.dcm_apply_rollback import DcmApplyRollback +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.util import ufile +from paste.web import requests + + +async def get_apply_rollback_request(dcm_apply_rollback: DcmApplyRollback, dcm_task: DcmTask, media_ids: str, + media_types: str, add_num: int): + """ + 创建申请回退请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param dcm_apply_rollback: 请求申请回退对象 + :param dcm_task: 工单对象 + :param media_ids: 上传的附件 MediaID 字符串,多个用英文逗号分隔 + :param media_types: 上传的附件的MediaType字符串,多个用英文逗号分隔 + :param add_num: 上传的附件数 + :return: HTTPRequest 对象 + """ + api_url = '/home/workflow/applyrollback' + body_dict = { + 'applyActID': dcm_apply_rollback.act_id, + 'replyPartID': dcm_apply_rollback.reply_part_id, + 'ardLevel': dcm_apply_rollback.ard_level, + 'ardTypeID': dcm_apply_rollback.ard_type_id, + 'applyMemo': dcm_apply_rollback.opinion, + 'transInfo': dcm_apply_rollback.trans_info, + 'addNum': add_num, + 'mediaUsage': '申请回退', + 'mediaIDs': media_ids, + 'relationTypeID': 10, + 'relationID': dcm_task.rec_id, + 'relationMainID': 21, + 'relationSubID': -1, + 'tempUsage': 'apply_rollback_tmp', + 'mediaTypes': media_types + } + apply_rollback_request = await dcm_api.new_api_request(api_url, body_dict) + return apply_rollback_request + + +async def on_attachment_download_error(request: HTTPRequest, exc: Exception, retry_queue: asyncio.Queue = None): + """ + 下载附件时的出错处理。 + + :param request: 请求对象 + :param exc: 异常对象 + :param retry_queue: + :return: + """ + retry = getattr(request, 'retry', 0) + max_retry = getattr(request, 'max_retry', 0) + if retry < max_retry - 1: + # 非最后一次尝试,不处理 + return + + message = f"下载申请回退附件时发生错误,下载地址:{request.url}" + echo_log(message) + echo_log(exc, logging.ERROR, is_log_exc=True) + + # 保存异常 + dcm_apply_rollback: DcmApplyPostpone = getattr(request, 'dcm_apply_rollback') + exc_list = getattr(dcm_apply_rollback, 'exc_list') + exc_list.append(PushException(message, dcm_apply_rollback.flow_token, 3)) + + +async def done_attachment_download(response_list: list[HTTPResponse]): + """ + 所有附件下载完成执行的处理程序。 + + :param response_list: 附件下载响应列表 + :return: 返回附件字典 + """ + # 遍历取出附件数据 + date_str = datetime.datetime.now().strftime("%Y%m%d") + files_body: dict[str, io.IOBase] = {} + for i, response in enumerate(response_list): + _type = ufile.inspect_type(response.body) + if isinstance(_type, tuple): + _fn = f"CT{date_str}{i + 1:04d}.{_type[0]}" + elif isinstance(_type, str) and _type: + _fn = f"CT{date_str}{i + 1:04d}.{_type}" + else: + _fn = f"CT{date_str}{i + 1:04d}" + file_obj = io.BytesIO(response.body) + files_body[_fn] = file_obj + return files_body + + +async def after_push_apply_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交数字城管后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + echo_log(response.body.decode()) + echo_log('申请回退请求成功.') + + +async def push_apply_rollback(dcm_apply_rollback: DcmApplyRollback, dcm_task: DcmTask): + """ + 推送申请回退请求。 + + :param dcm_apply_rollback: 保存在数据库的申请回退对象 + :param dcm_task: 待办工单 + """ + try: + files_body = {} + if dcm_apply_rollback.attachments: + echo_log(f"正在准备下载队列...") + setattr(dcm_apply_rollback, 'exc_list', []) + # 创建并填充下载请求队列 + attachment_download_queue = asyncio.Queue() + attachment_id_list = dcm_apply_rollback.attachments.split(',') + for attachment_id in attachment_id_list: + download_request = await oa_api_request.get_download_request(attachment_id) + setattr(download_request, 'dcm_apply_rollback', dcm_apply_rollback) + await attachment_download_queue.put(download_request) + # 启动下载,得到所有的附件 + files_body: dict[str, io.IOBase] = await requests.async_concurrency( + attachment_download_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_done=done_attachment_download, on_error=on_attachment_download_error + ) + # 读取异常有则报错 + exc_list: list[PushException] = getattr(dcm_apply_rollback, 'exc_list', []) + if exc_list: + message = ";".join([f"{exc}" for exc in exc_list]) + raise PushException(message, dcm_apply_rollback.flow_token, 3) + echo_log(f"附件下载完成,正在准备提交数字城管...") + + # 仅生产环境真实提交,其他环境不实际提交 + if apps.get_active_env() in ('dev', '', None): + echo_log(f"非生产环境,不实际提交.") + return + + # 上传附件,同步得到 MediaIds + media_ids, media_types, add_num = await dcm_push_upload.push_upload( + dcm_task, dcm_push_upload.ApplyRollbackTmp, files_body, -1, 21 + ) + + # 创建并填充数字城管请求队列 + apply_rollback_request = await get_apply_rollback_request( + dcm_apply_rollback, dcm_task, media_ids, media_types, add_num + ) + setattr(apply_rollback_request, 'dcm_apply_rollback', dcm_apply_rollback) + apply_postpone_push_queue = asyncio.Queue() + await apply_postpone_push_queue.put(apply_rollback_request) + # 提交数字城管-申请回退请求 + apply_postpone_push_response_list = await requests.async_concurrency( + apply_postpone_push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_apply_request + ) + # 检查是否推送成功,失败直接报错 + if len(apply_postpone_push_response_list) != 1: + raise PushException("申请回退请求发生错误.", dcm_apply_rollback.flow_token, 3) + + # TODO: 这里要做实际的推送成功检查 + # 保存成功状态 + dcm_apply_rollback.status = 1 + await dcm_apply_rollback.async_save() + # 申请回退请求提交后,通知申请回退成功 + await oa_result_notify.push_result_notify( + dcm_apply_rollback.flow_token, + '申请回退成功', + 1 + ) + except PushException as e: + # 任何异常都意味着失败,通知 OA + echo_log(f'申请回退发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + # 保存失败状态 + dcm_apply_rollback.status = 0 + await dcm_apply_rollback.async_save() + + # 申请回退发生异常,通知申请回退失败 + await oa_result_notify.push_result_notify( + e.flow_token, f"{e}", e.return_code + ) + except Exception as e: + # 其他异常都意味着失败,通知 OA + echo_log(f'申请回退发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + +if __name__ == "__main__": + from paste.core import aio_pool + + + async def test(): + dcm_task = await DcmTask(id=2054174091300179971).async_find_first() + task_rollback = await DcmApplyRollback(id=2055564426366554112).async_find_first() + await push_apply_rollback(task_rollback, dcm_task) + + + _runner = aio_pool.get_aio_runner() + _runner(test()) diff --git a/dock/dcm/dcm_push_conv_dispose.py b/dock/dcm/dcm_push_conv_dispose.py new file mode 100644 index 0000000..4cda877 --- /dev/null +++ b/dock/dcm/dcm_push_conv_dispose.py @@ -0,0 +1,74 @@ +import asyncio +import json + +import apps +import dock +from dock.dcm import dcm_api +from tornado.httpclient import HTTPResponse, HTTPRequest + +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from models.dcm_dispose import DcmDispose +from paste.web import requests + + +async def get_conv_dispose_request(dcm_dispose: DcmDispose, dcm_task: DcmTask): + """ + 创建便民批转请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param dcm_dispose: 保存在数据库的批转对象 + :param dcm_task: 待办工单对象 + """ + api_url = '/home/form/formpreview/save' + + form_param = { + 'recID': dcm_task.rec_id, + 'actID': dcm_dispose.act_id + } + component_list = [ + {"componentID": 9, "value": dcm_dispose.undertake_user_name}, # 承办人员 + {"componentID": 12, "value": dcm_dispose.undertake_phone} # 联系电话 + ] + body = { + "formID": 377, + "isInsert": "0", + "briefParam": True, + "formParam": json.dumps(form_param, ensure_ascii=False), + "componentList": json.dumps(component_list, ensure_ascii=False) + } + conv_request = await dcm_api.new_api_request(api_url, body) + return conv_request + + +async def after_conv_dispose_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交数字城管后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + echo_log(response.body.decode()) + echo_log('便民批转请求成功.') + # TODO: 这里要做实际的推送成功检查 + + +async def push_conv_dispose_request(dcm_dispose: DcmDispose, dcm_task: DcmTask): + """ + 推送批转请求。 + + :param dcm_dispose: 保存在数据库的批转对象 + :param dcm_task: 待办工单对象 + """ + echo_log(f"正在准备提交便民批转请求...") + if apps.get_active_env() in ('dev', '', None): + echo_log(f"非生产环境,不实际提交.") + return + + request = await get_conv_dispose_request(dcm_dispose, dcm_task) + queue = asyncio.Queue() + await queue.put(request) + # 需要让调用方获取extendInfo参数 + return await requests.async_concurrency( + queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_conv_dispose_request + ) diff --git a/dock/dcm/dcm_push_conv_rollback.py b/dock/dcm/dcm_push_conv_rollback.py new file mode 100644 index 0000000..da543c7 --- /dev/null +++ b/dock/dcm/dcm_push_conv_rollback.py @@ -0,0 +1,75 @@ +import asyncio +import json + +import apps +import dock +from dock.dcm import dcm_api +from tornado.httpclient import HTTPResponse, HTTPRequest + +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from models.dcm_rollback import DcmRollback +from paste.web import requests + + +async def get_conv_rollback_request(rollback: DcmRollback, dcm_task: DcmTask): + """ + 创建便民回退请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param rollback: 保存在数据库的回退对象 + :param dcm_task: 待办工单对象 + """ + api_url = '/home/form/formpreview/save' + form_param = { + 'recID': dcm_task.rec_id, + 'actID': rollback.act_id + } + component_list = [ + {"componentID": 8, "value": rollback.not_assigned_reason}, # 不交办原因 + {"componentID": 4, "value": rollback.not_assigned}, # 是否不交办 + {"componentID": 9, "value": rollback.undertake_user_name}, # 承办人员 + {"componentID": 12, "value": rollback.undertake_phone}, # 联系电话 + ] + body = { + "formID": 352, + "isInsert": "0", + "briefParam": True, + "formParam": json.dumps(form_param, ensure_ascii=False), + "componentList": json.dumps(component_list, ensure_ascii=False), + } + conv_request = await dcm_api.new_api_request(api_url, body) + return conv_request + + +async def after_conv_rollback_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交数字城管后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + echo_log(response.body.decode()) + echo_log('便民回退请求成功') + # TODO: 这里要做实际的推送成功检查 + + +async def push_conv_rollback_request(dcm_rollback: DcmRollback, dcm_task: DcmTask): + """ + 推送回退请求。 + + :param dcm_rollback: 保存在数据库的回退对象 + :param dcm_task: 待办工单对象 + """ + echo_log(f"正在准备提交便民回退请求...") + if apps.get_active_env() in ('dev', '', None): + echo_log(f"非生产环境,不实际提交.") + return + + request = await get_conv_rollback_request(dcm_rollback, dcm_task) + queue = asyncio.Queue() + await queue.put(request) + # 需要让调用方获取extendInfo参数 + return await requests.async_concurrency( + queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_conv_rollback_request + ) diff --git a/dock/dcm/dcm_push_dispose.py b/dock/dcm/dcm_push_dispose.py new file mode 100644 index 0000000..3d9921c --- /dev/null +++ b/dock/dcm/dcm_push_dispose.py @@ -0,0 +1,233 @@ +import asyncio +import datetime +import io +import logging +import json + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import apps +import dock +from dock.dcm import dcm_api, dcm_push_conv_dispose, dcm_send_sms, dcm_push_upload +from dock.oa import oa_api_request, PushException, oa_result_notify +from models.dcm_dispose import DcmDispose +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.util import ufile, udict +from paste.web import requests + + +async def get_dispose_request(dcm_dispose: DcmDispose, dcm_task: DcmTask, media_ids: str, media_types: str, + add_num: int, extra_opinion: str): + """ + 创建批转请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param dcm_dispose: 批转对象 + :param dcm_task: 工单对象 + :param media_ids: 上传的附件 MediaID 字符串,多个用英文逗号分隔 + :param media_types: 上传的附件的MediaType字符串,多个用英文逗号分隔 + :param add_num: 上传的附件数 + :param extra_opinion: 便民表单的extendInfo文本 + :return: HTTPRequest 对象 + """ + api_url = '/home/workflow/transit' + body_dict = { + "actID": int(dcm_dispose.act_id), + "taskListID": int(dcm_dispose.task_list_id), + "opinion": dcm_dispose.opinion if not extra_opinion else dcm_dispose.opinion + ' ' + extra_opinion, + "transInfo": dcm_dispose.trans_info, + "addNum": add_num, + 'mediaIDs': media_ids, + 'mediaUsage': '处置', + 'relationTypeID': 10, + 'relationID': dcm_task.rec_id, + 'relationMainID': 45, + 'relationSubID': dcm_dispose.act_id, + 'tempUsage': 'transit_tmp', + 'mediaTypes': media_types + } + dispose_request = await dcm_api.new_api_request(api_url, body_dict) + return dispose_request + + +async def on_attachment_download_error(request: HTTPRequest, exc: Exception, retry_queue: asyncio.Queue = None): + """ + 下载附件时的出错处理。 + + :param request: 请求对象 + :param exc: 异常对象 + :param retry_queue: + :return: + """ + retry = getattr(request, 'retry', 0) + max_retry = getattr(request, 'max_retry', 0) + if retry < max_retry - 1: + # 非最后一次尝试,不处理 + return + + message = f"下载批转附件时发生错误,下载地址:{request.url}" + echo_log(message) + echo_log(exc, logging.ERROR, is_log_exc=True) + + # 保存异常 + dcm_dispose: DcmDispose = getattr(request, 'dcm_dispose') + exc_list = getattr(dcm_dispose, 'exc_list') + exc_list.append(PushException(message, dcm_dispose.flow_token, 3)) + + +async def done_attachment_download(response_list: list[HTTPResponse]): + """ + 所有附件下载完成执行的处理程序。 + + :param response_list: 附件下载响应列表 + :return: 返回附件字典 + """ + # 遍历取出附件数据 + date_str = datetime.datetime.now().strftime("%Y%m%d") + files_body: dict[str, io.IOBase] = {} + for i, response in enumerate(response_list): + _type = ufile.inspect_type(response.body) + if isinstance(_type, tuple): + _fn = f"CT{date_str}{i + 1:04d}.{_type[0]}" + elif isinstance(_type, str) and _type: + _fn = f"CT{date_str}{i + 1:04d}.{_type}" + else: + _fn = f"CT{date_str}{i + 1:04d}" + file_obj = io.BytesIO(response.body) + files_body[_fn] = file_obj + return files_body + + +async def after_push_dispose_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交数字城管后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + echo_log(response.body.decode()) + echo_log('批转请求成功.') + + +async def push_dispose(dcm_dispose: DcmDispose, dcm_task: DcmTask): + """ + 推送批转请求。 + + :param dcm_dispose: 保存在数据库的批转对象 + :param dcm_task: 待办工单 + """ + try: + # 如果来源是12345热线,提交便民批转 + extra_opinion = '' + if dcm_task.event_src_name == '12345热线': + conv_response_list = await dcm_push_conv_dispose.push_conv_dispose_request(dcm_dispose, dcm_task) + if conv_response_list: + conv_response = conv_response_list[0].body.decode() + conv_response = json.loads(conv_response) + extra_opinion = udict.get_by_path(conv_response, 'resultInfo.data.extendInfo', '') + echo_log(f"正在准备下载队列...") + setattr(dcm_dispose, 'exc_list', []) + files_body = {} + if dcm_dispose.attachments: + # 创建并填充下载请求队列 + attachment_download_queue = asyncio.Queue() + attachment_id_list = dcm_dispose.attachments.split(',') + for attachment_id in attachment_id_list: + download_request = await oa_api_request.get_download_request(attachment_id) + setattr(download_request, 'dcm_dispose', dcm_dispose) + await attachment_download_queue.put(download_request) + # 启动下载,得到所有的附件 + files_body: dict[str, io.IOBase] = await requests.async_concurrency( + attachment_download_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_done=done_attachment_download, on_error=on_attachment_download_error + ) + # 读取异常有则报错 + exc_list: list[PushException] = getattr(dcm_dispose, 'exc_list', []) + if exc_list: + message = ";".join([f"{exc}" for exc in exc_list]) + raise PushException(message, dcm_dispose.flow_token, 3) + echo_log(f"附件下载完成,正在准备提交数字城管...") + + # 仅生产环境真实提交,其他环境不实际提交 + if apps.get_active_env() in ('dev', '', None): + echo_log(f"非生产环境,不实际提交.") + return + + # 上传附件,同步得到 MediaIds + media_ids, media_types, add_num = await dcm_push_upload.push_upload( + dcm_task, dcm_push_upload.TransitTmp, files_body + ) + + # 创建并填充数字城管请求队列 + dispose_request = await get_dispose_request( + dcm_dispose, dcm_task, media_ids, media_types, add_num, extra_opinion + ) + setattr(dispose_request, 'dcm_dispose', dcm_dispose) + dispose_push_queue = asyncio.Queue() + await dispose_push_queue.put(dispose_request) + # 提交数字城管-批转请求 + dispose_push_response_list = await requests.async_concurrency( + dispose_push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_dispose_request + ) + # 检查是否推送成功,失败直接报错 + if len(dispose_push_response_list) != 1: + raise PushException("批转请求发生错误.", dcm_dispose.flow_token, 3) + + # 检查响应的内容,验证是否推送成功 + response_data = dispose_push_response_list[0].body.decode() + response_data = json.loads(response_data) + if udict.get_by_path(response_data, 'resultInfo.message', '') != '批转成功!' or udict.get_by_path( + response_data, 'resultInfo.success') is not True: + raise PushException("批转请求发生错误.", dcm_dispose.flow_token, 3) + # 保存成功状态 + dcm_dispose.status = 1 + await dcm_dispose.async_save() + # 批转请求提交后,通知批转成功 + await oa_result_notify.push_result_notify( + dcm_dispose.flow_token, + '批转成功', + 1 + ) + + # 如果需要发送短信,后台发送 + if dcm_dispose.send_message in (1, '1'): + await dcm_send_sms.send_sms({ + 'actDefID': '45', + 'partStr': '254,role,市受理员', + 'itemType': 'transit', + 'recID': dcm_task.rec_id, + 'actID': dcm_task.act_id, + 'actPropertyID': '7' + }) + except PushException as e: + # 任何异常都意味着失败,通知 OA + echo_log(f'批转发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + # 保存失败状态 + dcm_dispose.status = 0 + await dcm_dispose.async_save() + + # 批转发生异常,通知批转失败 + await oa_result_notify.push_result_notify( + e.flow_token, f"{e}", e.return_code + ) + except Exception as e: + # 其他异常都意味着失败 + echo_log(f'批转发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + +if __name__ == "__main__": + from paste.core import aio_pool + + + async def push(): + dcm_task = await DcmTask(id=2059280827086409734).async_find_first() + task_dispose = await DcmDispose(id=2060189471659397120).async_find_first() + await push_dispose(task_dispose, dcm_task) + + + _runner = aio_pool.get_aio_runner() + _runner(push()) diff --git a/dock/dcm/dcm_push_rollback.py b/dock/dcm/dcm_push_rollback.py new file mode 100644 index 0000000..530400d --- /dev/null +++ b/dock/dcm/dcm_push_rollback.py @@ -0,0 +1,235 @@ +import asyncio +import datetime +import io +import json +import logging + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import apps +import dock +from dock.dcm import dcm_api, dcm_push_conv_rollback, dcm_send_sms, dcm_push_upload +from dock.oa import oa_api_request, oa_result_notify, PushException +from models.dcm_rollback import DcmRollback +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.util import ufile, udict +from paste.web import requests + + +async def get_rollback_request(dcm_rollback: DcmRollback, dcm_task: DcmTask, media_ids: str, media_types: str, + add_num: int, extra_opinion: str): + """ + 创建回退请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param dcm_rollback: 回退对象 + :param dcm_task: 工单对象 + :param media_ids: 上传的附件 MediaID 字符串,多个用英文逗号分隔 + :param media_types: 上传的附件的MediaType字符串,多个用英文逗号分隔 + :param add_num: 上传的附件数 + :param extra_opinion: 便民表单的extendInfo文本 + :return: HTTPRequest 对象 + """ + api_url = '/home/workflow/rollback' + body_dict = { + 'actID': int(dcm_rollback.act_id), + 'opinion': dcm_rollback.opinion if not extra_opinion else dcm_rollback.opinion + ' ' + extra_opinion, + 'transInfo': dcm_rollback.trans_info, + 'saveOldActFlag': dcm_rollback.save_old_act_flag, + 'rollbackReasonID': dcm_rollback.rollback_reason_id, + 'transListNum': 0, + 'mediaIDs': media_ids, + 'mediaUsage': '回退', + 'relationTypeID': 10, + 'relationID': dcm_task.rec_id, + 'relationMainID': 45, + 'relationSubID': dcm_task.act_id, + 'tempUsage': 'rollback_tmp', + 'mediaTypes': media_types, + 'addNum': add_num + } + rollback_request = await dcm_api.new_api_request(api_url, body_dict) + return rollback_request + + +async def on_attachment_download_error(request: HTTPRequest, exc: Exception, retry_queue: asyncio.Queue = None): + """ + 下载附件时的出错处理。 + + :param request: 请求对象 + :param exc: 异常对象 + :param retry_queue: + :return: + """ + retry = getattr(request, 'retry', 0) + max_retry = getattr(request, 'max_retry', 0) + if retry < max_retry - 1: + # 非最后一次尝试,不处理 + return + + message = f"下载回退附件时发生错误,下载地址:{request.url}" + echo_log(message) + echo_log(exc, logging.ERROR, is_log_exc=True) + + # 保存异常 + dcm_rollback: DcmRollback = getattr(request, 'dcm_rollback') + exc_list = getattr(dcm_rollback, 'exc_list') + exc_list.append(PushException(message, dcm_rollback.flow_token, 3)) + + +async def done_attachment_download(response_list: list[HTTPResponse]): + """ + 所有附件下载完成执行的处理程序。 + + :param response_list: 附件下载响应列表 + :return: 返回附件字典 + """ + # 遍历取出附件数据 + date_str = datetime.datetime.now().strftime("%Y%m%d") + files_body: dict[str, io.IOBase] = {} + for i, response in enumerate(response_list): + _type = ufile.inspect_type(response.body) + if isinstance(_type, tuple): + _fn = f"CT{date_str}{i + 1:04d}.{_type[0]}" + elif isinstance(_type, str) and _type: + _fn = f"CT{date_str}{i + 1:04d}.{_type}" + else: + _fn = f"CT{date_str}{i + 1:04d}" + file_obj = io.BytesIO(response.body) + files_body[_fn] = file_obj + return files_body + + +async def after_push_rollback_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交数字城管后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + echo_log(response.body.decode()) + echo_log('回退请求成功.') + + +async def push_rollback(dcm_rollback: DcmRollback, dcm_task: DcmTask): + """ + 推送回退请求。 + + :param dcm_rollback: 保存在数据库的回退对象 + :param dcm_task: 待办工单 + """ + try: + # 如果来源是12345热线,提交便民回退 + extra_opinion = '' + if dcm_task.event_src_name == '12345热线': + conv_response_list = await dcm_push_conv_rollback.push_conv_rollback_request(dcm_rollback, dcm_task) + if conv_response_list: + conv_response = conv_response_list[0].body.decode() + conv_response = json.loads(conv_response) + extra_opinion = udict.get_by_path(conv_response, 'resultInfo.data.extendInfo', '') + echo_log(f"正在准备下载队列...") + setattr(dcm_rollback, 'exc_list', []) + files_body = {} + if dcm_rollback.attachments: + # 创建并填充下载请求队列 + attachment_download_queue = asyncio.Queue() + attachment_id_list = dcm_rollback.attachments.split(',') + for attachment_id in attachment_id_list: + download_request = await oa_api_request.get_download_request(attachment_id) + setattr(download_request, 'dcm_rollback', dcm_rollback) + await attachment_download_queue.put(download_request) + # 启动下载,得到所有的附件 + files_body: dict[str, io.IOBase] = await requests.async_concurrency( + attachment_download_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_done=done_attachment_download, on_error=on_attachment_download_error + ) + # 读取异常有则报错 + exc_list: list[PushException] = getattr(dcm_rollback, 'exc_list', []) + if exc_list: + message = ";".join([f"{exc}" for exc in exc_list]) + raise PushException(message, dcm_rollback.flow_token, 3) + echo_log(f"附件下载完成,正在准备提交数字城管...") + + # 仅生产环境真实提交,其他环境不实际提交 + if apps.get_active_env() in ('dev', '', None): + echo_log(f"非生产环境,不实际提交.") + return + + # 上传附件,同步得到 MediaIds + media_ids, media_types, add_num = await dcm_push_upload.push_upload( + dcm_task, dcm_push_upload.RollbackTmp, files_body + ) + + # 创建并填充数字城管请求队列 + rollback_request = await get_rollback_request( + dcm_rollback, dcm_task, media_ids, media_types, add_num, extra_opinion + ) + setattr(rollback_request, 'dcm_rollback', dcm_rollback) + rollback_push_queue = asyncio.Queue() + await rollback_push_queue.put(rollback_request) + # 提交数字城管-回退请求 + rollback_push_response_list = await requests.async_concurrency( + rollback_push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_rollback_request + ) + # 检查是否推送成功,失败直接报错 + if len(rollback_push_response_list) != 1: + raise PushException("回退请求发生错误.", dcm_rollback.flow_token, 3) + + # 检查响应的内容,验证是否推送成功 + response_data = rollback_push_response_list[0].body.decode() + response_data = json.loads(response_data) + if udict.get_by_path(response_data, 'resultInfo.message', '') != '回退成功!' or udict.get_by_path( + response_data, 'resultInfo.success') is not True: + raise PushException("回退请求发生错误.", dcm_rollback.flow_token, 3) + # 保存成功状态 + dcm_rollback.status = 1 + await dcm_rollback.async_save() + # 回退请求提交后,通知回退成功 + await oa_result_notify.push_result_notify( + dcm_rollback.flow_token, + '回退成功', + 1 + ) + + # 如果需要发送短信,后台发送 + if dcm_rollback.send_message in (1, '1'): + await dcm_send_sms.send_sms({ + 'actDefID': '45', + 'partStr': '254,role,市受理员', + 'itemType': 'rollback', + 'recID': dcm_task.rec_id, + 'actID': dcm_task.act_id, + 'actPropertyID': '7' + }) + except PushException as e: + # 任何异常都意味着失败,通知 OA + echo_log(f'回退发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + # 保存失败状态 + dcm_rollback.status = 0 + await dcm_rollback.async_save() + + # 回退发生异常,通知回退失败 + await oa_result_notify.push_result_notify( + e.flow_token, f"{e}", e.return_code + ) + except Exception as e: + # 其他异常都意味着失败 + echo_log(f'回退发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + +if __name__ == "__main__": + from paste.core import aio_pool + + + async def push(): + dcm_task = await DcmTask(id=2054174091237265413).async_find_first() + task_rollback = await DcmRollback(id=2058802518821048320).async_find_first() + await push_rollback(task_rollback, dcm_task) + + + _runner = aio_pool.get_aio_runner() + _runner(push()) diff --git a/dock/dcm/dcm_push_stage_reply.py b/dock/dcm/dcm_push_stage_reply.py new file mode 100644 index 0000000..deaadab --- /dev/null +++ b/dock/dcm/dcm_push_stage_reply.py @@ -0,0 +1,109 @@ +import asyncio +import logging + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import apps +import dock +from dock.dcm import dcm_api +from dock.oa import PushException, oa_result_notify +from models.dcm_stage_reply import DcmStageReply +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.web import requests + + +async def get_stage_reply_request(dcm_stage_reply: DcmStageReply, dcm_task: DcmTask): + """ + 创建阶段回复请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :return: HTTPRequest 对象 + """ + api_url = '/home/mis/convenient/stagereply' + body_dict = { + "recID": dcm_task.rec_id, + "actID": dcm_task.act_id, + "opinion": dcm_stage_reply.content, + "itemType": dcm_stage_reply.item_type, + } + return await dcm_api.new_api_request(api_url, body_dict) + + +async def after_push_stage_reply_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交数字城管后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + echo_log(response.body.decode()) + echo_log('阶段回复请求成功.') + + +async def push_stage_reply(dcm_stage_reply: DcmStageReply, dcm_task: DcmTask): + """ + 推送阶段回复请求。 + + :param dcm_stage_reply: 保存在数据库的阶段回复对象 + :param dcm_task: 待办工单 + """ + try: + # 仅生产环境真实提交,其他环境不实际提交 + if apps.get_active_env() in ('dev', '', None): + echo_log(f"非生产环境,不实际提交.") + return + + # 创建并填充数字城管请求队列 + stage_reply_request = await get_stage_reply_request(dcm_stage_reply, dcm_task) + setattr(stage_reply_request, 'dcm_stage_reply', dcm_stage_reply) + stage_reply_push_queue = asyncio.Queue() + await stage_reply_push_queue.put(stage_reply_request) + # 提交数字城管-阶段回复请求 + stage_reply_push_response_list = await requests.async_concurrency( + stage_reply_push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_stage_reply_request + ) + # 检查是否推送成功,失败直接报错 + if len(stage_reply_push_response_list) != 1: + raise PushException("阶段回复请求发生错误.", dcm_stage_reply.flow_token, 3) + + # TODO: 这里要做实际的推送成功检查 + # 保存成功状态 + dcm_stage_reply.status = 1 + await dcm_stage_reply.async_save() + # 阶段回复请求提交后,通知阶段回复成功 + await oa_result_notify.push_result_notify( + dcm_stage_reply.flow_token, + '阶段回复成功', + 1 + ) + except PushException as e: + # 任何异常都意味着失败,通知 OA + echo_log(f'阶段回复发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + # 保存失败状态 + dcm_stage_reply.status = 0 + await dcm_stage_reply.async_save() + + # 阶段回复发生异常,通知阶段回复失败 + await oa_result_notify.push_result_notify( + e.flow_token, f"{e}", e.return_code + ) + except Exception as e: + # 其他异常都意味着失败,通知 OA + echo_log(f'阶段回复发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + +if __name__ == '__main__': + async def push(): + reply = await DcmStageReply.async_find_by_id(2061285857339510784) + task = await DcmTask.async_find_by_id(2054174091270819843) + await push_stage_reply(reply, task) + + + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(push()) diff --git a/dock/dcm/dcm_push_upload.py b/dock/dcm/dcm_push_upload.py new file mode 100644 index 0000000..a6aec51 --- /dev/null +++ b/dock/dcm/dcm_push_upload.py @@ -0,0 +1,197 @@ +""" +数字城管文件上传。 +""" +import asyncio +import datetime +import io +import json +import logging +from typing import Optional + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.dcm import dcm_api +from dock.oa import PushException +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + +ApplyPostponeTmp = "apply_postpone_tmp" +ApplyRollbackTmp = "apply_rollback_tmp" +RollbackTmp = "rollback_tmp" +TransitTmp = "transit_tmp" + +MediaUsage = [ApplyPostponeTmp, ApplyRollbackTmp, RollbackTmp, TransitTmp] +""" +可用媒体对象。 +""" + + +async def get_upload_request(dcm_task: DcmTask, media_usage: str, files: dict[str, io.IOBase], + relation_sub_id: Optional[int] = None, + relation_main_id: Optional[int] = 45): + """ + 创建文件上传请求。因数字城管服务器仅接受单文档上传,因此返回的是请求对象列表。 + + :param dcm_task: 待办工单 + :param media_usage: 媒体使用 + :param files: 要上传的文件数据 + :param relation_sub_id: 可选参数 + :param relation_main_id: 可选参数 + :return: 上传请求列表 + """ + api_url = f"/media/upload" + assert media_usage in MediaUsage, f"参数错误,须为:{MediaUsage} 之一." + + request_list = [] + _id = 0 + for key, value in files.items(): + request_body = { + "relationTypeID": 10, + "mediaUsage": media_usage, + "checkExist": True, + "relationID": dcm_task.rec_id, + "relationSubID": relation_sub_id if relation_sub_id is not None else dcm_task.act_id, + "relationMainID": relation_main_id, + "tempUsage": media_usage, + "id": f"WU_FILE_{_id}", + "name": key, + "type": 'image/png', + "lastModifiedDate": datetime.datetime.now().strftime("%a %b %d %Y %H:%M:%S %z").replace( + "+0800", "GMT+0800" + ), + "size": len(value.read()), + key: value, + } + _id += 1 + # 构造 API 请求 + request = await dcm_api.new_api_request(api_url, request_body) + setattr(request, 'dcm_task', dcm_task) + setattr(request, 'media_usage', media_usage) + setattr(request, 'file_name', key) + request_list.append(request) + # 返回请求列表 + return request_list + + +async def get_media_request(dcm_task: DcmTask, media_usage: str): + """ + 创建读取 MediaID 请求。 + + :param dcm_task: 待办工单 + :param media_usage: 媒体使用 + :return: 上传请求列表 + """ + api_url = f"/media/get" + assert media_usage in MediaUsage, f"参数错误,须为:{MediaUsage} 之一." + request_body = { + "relationTypeID": 10, + "mediaUsage": media_usage, + "relationID": dcm_task.rec_id, + "relationSubID": dcm_task.act_id, + "relationMainID": 45, + } + # 构造 API 请求 + request = await dcm_api.new_api_request(api_url, request_body, method="GET") + setattr(request, 'dcm_task', dcm_task) + setattr(request, 'media_usage', media_usage) + return request + + +async def on_upload_error(request: HTTPRequest, exc: Exception, retry_queue: asyncio.Queue = None): + """ + 下载附件时的出错处理。 + + :param request: 请求对象 + :param exc: 异常对象 + :param retry_queue: + :return: + """ + retry = getattr(request, 'retry', 0) + max_retry = getattr(request, 'max_retry', 0) + if retry < max_retry - 1: + # 非最后一次尝试,不处理 + return + + dcm_task: DcmTask = getattr(request, 'dcm_task') + media_usage = getattr(request, 'media_usage') + file_name = getattr(request, 'file_name') + message = f'工单ID:{dcm_task.id},类型为:{media_usage},文件名为:{file_name} 的附件上传失败.' + echo_log(message) + echo_log(exc, logging.ERROR, is_log_exc=True) + + # 保存异常 + exc_list = getattr(dcm_task, 'exc_list') + exc_list.append(PushException(message)) + + +async def after_upload_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交数字城管后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + dcm_task: DcmTask = getattr(response.request, 'dcm_task') + media_usage = getattr(response.request, 'media_usage') + file_name = getattr(response.request, 'file_name') + echo_log(response.body.decode()) + echo_log(f'工单ID:{dcm_task.id},类型为:{media_usage},文件名为:{file_name} 的附件上传成功.') + + +async def push_upload(dcm_task: DcmTask, media_usage: str, files: dict[str, io.IOBase], + relation_sub_id: Optional[int] = None, + relation_main_id: Optional[int] = 45): + """ + 向数字城管上传文件。 + + :param dcm_task: 待办工单 + :param media_usage: 媒体使用 + :param files: 要上传的文件数据 + :param relation_sub_id: 可选参数 + :param relation_main_id: 可选参数 + :return: + """ + echo_log(f"正在准备上传队列...") + request_list = await get_upload_request(dcm_task, media_usage, files, relation_sub_id, relation_main_id) + upload_request_queue = asyncio.Queue() + for req in request_list: + await upload_request_queue.put(req) + + # 并发提交上传请求 + await requests.async_concurrency( + upload_request_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_upload_request, on_error=on_upload_error + ) + + # 上传后,读取文件 MediaID + media_request = await get_media_request(dcm_task, media_usage) + media_request_queue = asyncio.Queue() + await media_request_queue.put(media_request) + media_response_list: list[HTTPResponse] = await requests.async_concurrency( + media_request_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT + ) + # 从响应列表中取得媒体ID和媒体类型ID + media_id_list: list = [] + media_type_list: list = [] + media_num = 0 + if media_response_list: + response = media_response_list[0] + response_body = response.body.decode() + response_data = json.loads(response_body) + media_list = udict.get_by_path(response_data, 'data.mediaList') + media_num = len(media_list) + for media in media_list: + media_id_list.append(f"{media.get('mediaID')}") + media_type_list.append(media.get('mediaType')) + + # 用英文逗号拼接后返回 + return ",".join(media_id_list), ",".join(media_type_list), media_num + + +if __name__ == "__main__": + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() diff --git a/dock/dcm/dcm_scrape.py b/dock/dcm/dcm_scrape.py new file mode 100644 index 0000000..a1b4993 --- /dev/null +++ b/dock/dcm/dcm_scrape.py @@ -0,0 +1,160 @@ +""" +数据抓取模块。 +""" +import asyncio +import logging +from typing import Optional, Union + +from sqlalchemy import select, desc + +import dock +from dock.dcm.dcm_scrape_operation import get_operation_request, after_operation_request +from dock.dcm.dcm_scrape_task import get_task_request, after_task_request +from dock.dcm.dcm_scrape_attachment import get_attachment_request, after_attachment_request +from dock.dcm.dcm_scrape_process_info import get_process_info_request, after_process_info_request +from dock.dcm.dcm_scrape_form_data import get_form_data_request, after_form_data_request +from dock.dcm.dcm_scrape_more_info import get_more_info_request, after_more_info_request +from dock.dcm.dcm_scrape_extend_info import get_extend_info_request, after_extend_info_request +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.web import requests + + +async def fetch_dcm_task(fetch_size: int = 60, task_id: Optional[Union[str, int]] = None): + """ + 抓取待办数据及其明细数据。 + + :param fetch_size: 读取多少任务进行明细抓取 + :param task_id: 待办任务 ID 可选 + """ + echo_log(f"开始抓取待办数据...") + task_request = await get_task_request(num_per_page=fetch_size) + request_queue = asyncio.Queue() + await request_queue.put(task_request) + await requests.async_concurrency(request_queue, retry=dock.MAX_RETRY_COUNT, after_request=after_task_request) + echo_log(f"待办数据抓取完成...") + + # 读取任务数据,以便能对最新数据抓取详细数据 + query = select( + DcmTask.id, DcmTask.rec_id, DcmTask.act_id, DcmTask.task_num, DcmTask.other_task_num, + ).order_by( + desc(DcmTask.act_id) + ) + if task_id: + if isinstance(task_id, list): + query = query.where(DcmTask.id.in_(task_id)) + echo_log(f"开始抓取待办列表:{task_id} 的详细数据...") + else: + query = query.where(DcmTask.id == task_id) + echo_log(f"开始抓取待办:{task_id} 的详细数据...") + else: + echo_log(f"开始抓取前 {fetch_size} 条待办的详细数据...") + query = query.limit(fetch_size) + task_df = await DcmTask.query_as_df(query) + + # 构建请求队列 + operation_queue = asyncio.Queue() + attachment_queue = asyncio.Queue() + process_info_queue = asyncio.Queue() + form_data_queue = asyncio.Queue() + more_info_queue = asyncio.Queue() + extend_info_queue = asyncio.Queue() + # 向队列中填充请求对象 + echo_log(f"正在准备请求队列...") + for _h, _row in task_df.iterrows(): + dcm_task_id = _row.get(DcmTask.id.key) + rec_id = int(_row.get(DcmTask.rec_id.key)) + act_id = int(_row.get(DcmTask.act_id.key)) + task_num = f"{_row.get(DcmTask.task_num.key)}" + other_task_num = f"{_row.get(DcmTask.other_task_num.key)}" + + _operation_req = await get_operation_request(rec_id, act_id, task_num, other_task_num) + echo_log(_operation_req.url) + setattr(_operation_req, "dcm_task_id", dcm_task_id) + setattr(_operation_req, "rec_id", rec_id) + await operation_queue.put(_operation_req) + + _attachment_req = await get_attachment_request(rec_id) + echo_log(_attachment_req.url) + setattr(_attachment_req, "dcm_task_id", dcm_task_id) + setattr(_attachment_req, "rec_id", rec_id) + await attachment_queue.put(_attachment_req) + + _process_info_req = await get_process_info_request(rec_id) + echo_log(_process_info_req.url) + setattr(_process_info_req, "dcm_task_id", dcm_task_id) + setattr(_process_info_req, "rec_id", rec_id) + await process_info_queue.put(_process_info_req) + + _form_data_req = await get_form_data_request(rec_id, act_id) + echo_log(_form_data_req.url) + setattr(_form_data_req, "dcm_task_id", dcm_task_id) + setattr(_form_data_req, "rec_id", rec_id) + await form_data_queue.put(_form_data_req) + + _more_info_req = await get_more_info_request(rec_id) + echo_log(_more_info_req.url) + setattr(_more_info_req, "dcm_task_id", dcm_task_id) + setattr(_more_info_req, "rec_id", rec_id) + await more_info_queue.put(_more_info_req) + + _extend_info_req = await get_extend_info_request(rec_id) + echo_log(_extend_info_req.url) + setattr(_extend_info_req, "dcm_task_id", dcm_task_id) + setattr(_extend_info_req, "rec_id", rec_id) + await extend_info_queue.put(_extend_info_req) + + _count = (operation_queue.qsize()+attachment_queue.qsize()+process_info_queue.qsize() + +form_data_queue.qsize()+more_info_queue.qsize()+extend_info_queue.qsize()) + echo_log(f"可用操作请求:{operation_queue.qsize()};附件请求:{attachment_queue.qsize()};处理过程请求:{process_info_queue.qsize()};") + echo_log(f"详细数据请求:{form_data_queue.qsize()};更多信息请求:{more_info_queue.qsize()};扩展信息请求:{extend_info_queue.qsize()};") + echo_log(f"共计:{_count} 个.") + echo_log(f"抓取待办详细数据...") + + try: + tasks = [ + requests.async_concurrency( + operation_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_operation_request + ), + requests.async_concurrency( + attachment_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_attachment_request + ), + requests.async_concurrency( + process_info_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_process_info_request + ), + requests.async_concurrency( + form_data_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_form_data_request + ), + requests.async_concurrency( + more_info_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_more_info_request + ), + requests.async_concurrency( + extend_info_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_extend_info_request + ) + ] + await asyncio.gather(*tasks) + except Exception as e: + echo_log(f"抓取任务异常: {e}", level=logging.ERROR, is_log_exc=True) + raise + + +async def fetch_single_dcm_task(dcm_task: DcmTask): + """ + 基于任务抓取指定某条待办数据及其明细数据。 + + :param dcm_task: 任务号 + """ + await fetch_dcm_task(task_id=dcm_task.id) + + +if __name__ == "__main__": + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(fetch_dcm_task(30)) diff --git a/dock/dcm/dcm_scrape_allow_postpone.py b/dock/dcm/dcm_scrape_allow_postpone.py new file mode 100644 index 0000000..943c74d --- /dev/null +++ b/dock/dcm/dcm_scrape_allow_postpone.py @@ -0,0 +1,86 @@ +import asyncio +import json + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.dcm import dcm_api +from models.dcm_task import DcmTask +from paste.util import udict +from paste.web import requests + + +async def get_allow_postpone_request( + rec_id: int, act_id: int, task_num: str, other_task_num: str, + task_list_id: int = 600058, + task_list_name: str = "部门待办栏" +): + """ + 获取 DCM 企业待办的是否允许申请延期。 + + 向 DCM 的是否允许申请延期接口发送 GET 请求,获取与指定关系 ID 和类型 ID 关联的可用菜单信息。 + 自动注入有效的 Cookie(如 JSESSIONID)至请求头,并解析返回的 JSON 数据。 + + Args: + rec_id (int): 记录 ID + act_id (int): 任务 ID + task_num (str): 任务号 + other_task_num (str): 第三方任务号 + task_list_id: 任务列表类型 ID,默认为企业待办:600058 + task_list_name: 任务列表名称,默认为:部门待办栏 + """ + api_url = f"/home/workflow/wfitemtypecheck?itemType=postpone" + request_body = { + "recID": rec_id, + "actID": act_id, + "tasknum": task_num, + "otherTaskNum": other_task_num, + "taskListID": task_list_id, + "taskListName": task_list_name, + "recDispNum": "", + "menuName": "assign", + "menuDisplayName": "办理", + } + # 构造 API 请求 + return await dcm_api.new_api_request(api_url, request_body, 'POST', timeout=30) + + +async def after_allow_postpone_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + response_body = response.body.decode() + response_data = json.loads(response_body) + setattr(response.request, "response_data", response_data) + + +async def fetch_allow_postpone(dcm_task: DcmTask): + """ + 抓取是否允许申请延期。 + + :param dcm_task: 数字城管待办工单 + :return: + """ + # 取得请求对象 + list_menu_request = await get_allow_postpone_request( + dcm_task.rec_id, dcm_task.act_id, dcm_task.task_num, dcm_task.other_task_num + ) + request_queue = asyncio.Queue() + await request_queue.put(list_menu_request) + await requests.async_concurrency( + request_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_allow_postpone_request + ) + response_data = getattr(list_menu_request, "response_data") + success: bool = udict.get_by_path(response_data, 'resultInfo.success') + message: str = udict.get_by_path(response_data, 'resultInfo.message') + return success, message + + +if __name__ == "__main__": + from paste.core import aio_pool + + async def test(dcm_task_id): + dcm_task = await DcmTask(id=dcm_task_id).async_find_first() + assert dcm_task, f"未找到待办工单,工单ID:{dcm_task_id}" + await fetch_allow_postpone(dcm_task) + + _runner = aio_pool.get_aio_runner() + _runner(test(2054174091287597056)) diff --git a/dock/dcm/dcm_scrape_attachment.py b/dock/dcm/dcm_scrape_attachment.py new file mode 100644 index 0000000..454bed1 --- /dev/null +++ b/dock/dcm/dcm_scrape_attachment.py @@ -0,0 +1,52 @@ +import asyncio +import json +import pandas as pd +import models + +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.dcm import dcm_api +from paste.util import udict +from paste.core.logging import echo_log +from models.dcm_task_attachment import DcmTaskAttachment + + +async def get_attachment_request(relation_id: int, relation_type_id: int = 1): + """ + 获取 DCM 企业待办的附件列表。 + + 向 DCM 的附件查询接口发送 GET 请求,获取与指定关系 ID 和类型 ID 关联的附件信息。 + 自动注入有效的 Cookie(如 JSESSIONID)至请求头,并解析返回的 JSON 数据。 + + Args: + relation_id (int): 关联记录的 ID,例如任务 ID。 + relation_type_id (int): 关联类型 ID,默认为 1(任务类型)。 + """ + api_url = f"/home/mis/attachrec/getattach" + request_body = { + "relationID": relation_id, + "relationTypeID": relation_type_id, + } + # 构造 API 请求 + return await dcm_api.new_api_request(api_url, request_body, 'GET') + + +async def after_attachment_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + response_body = response.body.decode() + response_data = json.loads(response_body) + list_data = udict.get_by_path(response_data, 'resultInfo.data.mediaList') + attachment_df = pd.DataFrame(list_data) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in DcmTaskAttachment.FieldMapping.items()} + mapped_df = attachment_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + dcm_task_id = getattr(response.request, 'dcm_task_id') + rec_id = getattr(response.request, 'rec_id') + mapped_df[DcmTaskAttachment.dcm_task_id.key] = dcm_task_id + mapped_df[DcmTaskAttachment.rec_id.key] = rec_id + # 筛选数据状态 + _created, _updated = await DcmTaskAttachment.save_batch(mapped_df) + echo_log(f"成功创建企业待办 {rec_id} 的附件:{_created}条,更新:{_updated}条.") + if retry_queue: + echo_log(f"企业待办附件重试队列中有:{retry_queue.qsize()} 个请求在等待.") \ No newline at end of file diff --git a/dock/dcm/dcm_scrape_conv_dispose.py b/dock/dcm/dcm_scrape_conv_dispose.py new file mode 100644 index 0000000..47e68e7 --- /dev/null +++ b/dock/dcm/dcm_scrape_conv_dispose.py @@ -0,0 +1,73 @@ +import asyncio +import json + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.dcm import dcm_api, dcm_scrape_convenient_form +from models.dcm_task import DcmTask +from paste.util import udict +from paste.web import requests + + +async def get_convenient_request(act_id: int, other_task_num: str = "transit"): + """ + 获取 DCM 企业待办的便民批转。 + + 向 DCM 的便民批转接口发送 GET 请求,获取与指定关系 ID 和类型 ID 关联的可用菜单信息。 + 自动注入有效的 Cookie(如 JSESSIONID)至请求头,并解析返回的 JSON 数据。 + + Args: + act_id (int): 任务 ID + other_task_num (str): 项目类型 + """ + api_url = f"/home/workflow/gettranstreewithoutconfig" + request_body = { + "actID": act_id, + "itemType": other_task_num, + } + # 构造 API 请求 + return await dcm_api.new_api_request(api_url, request_body, 'GET', timeout=30) + + +async def after_convenient_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + response_body = response.body.decode() + response_data = json.loads(response_body) + setattr(response.request, "response_data", response_data) + + +async def fetch_form(dcm_task: DcmTask): + """ + 抓取便民批转。 + + :param dcm_task: 数字城管待办工单 + :return: + """ + # 取得请求对象 + list_menu_request = await get_convenient_request(dcm_task.act_id) + request_queue = asyncio.Queue() + await request_queue.put(list_menu_request) + await requests.async_concurrency( + request_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_convenient_request + ) + response_data = getattr(list_menu_request, "response_data") + form: dict = udict.get_by_path(response_data, 'resultInfo.data.form') + if form: + components = await dcm_scrape_convenient_form.fetch_form_components( + dcm_task, form.get('formID', dcm_scrape_convenient_form.DisposeFormId) + ) + form['components'] = components + return form + + +if __name__ == "__main__": + from paste.core import aio_pool + + async def test(dcm_task_id): + dcm_task = await DcmTask(id=dcm_task_id).async_find_first() + assert dcm_task, f"未找到待办工单,工单ID:{dcm_task_id}" + await fetch_form(dcm_task) + + _runner = aio_pool.get_aio_runner() + _runner(test(2054174091287597056)) \ No newline at end of file diff --git a/dock/dcm/dcm_scrape_conv_rollback.py b/dock/dcm/dcm_scrape_conv_rollback.py new file mode 100644 index 0000000..ffe5f0b --- /dev/null +++ b/dock/dcm/dcm_scrape_conv_rollback.py @@ -0,0 +1,73 @@ +import asyncio +import json + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.dcm import dcm_api, dcm_scrape_convenient_form +from models.dcm_task import DcmTask +from paste.util import udict +from paste.web import requests + + +async def get_convenient_request(act_id: int, item_type: str = "rollback"): + """ + 获取 DCM 企业待办的便民回退。 + + 向 DCM 的便民回退接口发送 GET 请求,获取与指定关系 ID 和类型 ID 关联的可用菜单信息。 + 自动注入有效的 Cookie(如 JSESSIONID)至请求头,并解析返回的 JSON 数据。 + + Args: + act_id (int): 任务 ID + item_type (str): 项目类型 + """ + api_url = f"/home/workflow/gettranstree" + request_body = { + "actID": act_id, + "itemType": item_type, + } + # 构造 API 请求 + return await dcm_api.new_api_request(api_url, request_body, 'GET', timeout=30) + + +async def after_convenient_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + response_body = response.body.decode() + response_data = json.loads(response_body) + setattr(response.request, "response_data", response_data) + + +async def fetch_form(dcm_task: DcmTask): + """ + 抓取便民回退。 + + :param dcm_task: 数字城管待办工单 + :return: + """ + # 取得请求对象 + list_menu_request = await get_convenient_request(dcm_task.act_id) + request_queue = asyncio.Queue() + await request_queue.put(list_menu_request) + await requests.async_concurrency( + request_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_convenient_request + ) + response_data = getattr(list_menu_request, "response_data") + form: dict = udict.get_by_path(response_data, 'resultInfo.data.form') + if form: + components = await dcm_scrape_convenient_form.fetch_form_components( + dcm_task, form.get('formID', dcm_scrape_convenient_form.RollbackFormId) + ) + form['components'] = components + return form + + +if __name__ == "__main__": + from paste.core import aio_pool + + async def test(dcm_task_id): + dcm_task = await DcmTask(id=dcm_task_id).async_find_first() + assert dcm_task, f"未找到待办工单,工单ID:{dcm_task_id}" + await fetch_form(dcm_task) + + _runner = aio_pool.get_aio_runner() + _runner(test(2054174091287597056)) \ No newline at end of file diff --git a/dock/dcm/dcm_scrape_convenient_form.py b/dock/dcm/dcm_scrape_convenient_form.py new file mode 100644 index 0000000..5c1dd5b --- /dev/null +++ b/dock/dcm/dcm_scrape_convenient_form.py @@ -0,0 +1,85 @@ +import asyncio +import json + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.dcm import dcm_api +from models.dcm_task import DcmTask +from paste.util import udict +from paste.web import requests + +DisposeFormId = 377 +DisposeFormName = '便民批转' + +RollbackFormId = 352 +RollbackFormName = '便民回退' + +FormIdMap = { + DisposeFormId: DisposeFormName, + RollbackFormId: RollbackFormName, +} + + +async def get_convenient_form_request(rec_id: int, act_id: int, form_id: int = DisposeFormId): + """ + 获取 DCM 企业待办的便民表单组件。 + + 向 DCM 的便民表单组件接口发送 GET 请求,获取与指定关系 ID 和类型 ID 关联的可用菜单信息。 + 自动注入有效的 Cookie(如 JSESSIONID)至请求头,并解析返回的 JSON 数据。 + + Args: + rec_id (int): 记录 ID + act_id (int): 任务 ID + form_id (str): 项目类型,允许输入:377、352,默认:377,表示读取:"便民批转"表单,为空时读取:"便民回退"表单 + """ + api_url = f"/home/form/formpreview/getforminfo" + request_body = { + "formID": form_id if form_id in tuple(FormIdMap.keys()) else DisposeFormId, + "formName": FormIdMap.get(form_id) if form_id in tuple(FormIdMap.keys()) else DisposeFormName, + "param": { + "recID": rec_id, + "actID": act_id, + }, + } + # 构造 API 请求 + return await dcm_api.new_api_request(api_url, request_body, 'POST', timeout=30) + + +async def after_convenient_form_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + response_body = response.body.decode() + response_data = json.loads(response_body) + setattr(response.request, "response_data", response_data) + + +async def fetch_form_components(dcm_task: DcmTask, form_id: int = DisposeFormId): + """ + 抓取便民表单组件。 + + :param dcm_task: 数字城管待办工单ID + :param form_id: 项目类型,允许输入:377、352,默认:377,表示读取:"便民批转"表单,为空时读取:"便民回退"表单 + :return: + """ + # 取得请求对象 + list_menu_request = await get_convenient_form_request(dcm_task.rec_id, dcm_task.act_id, form_id) + request_queue = asyncio.Queue() + await request_queue.put(list_menu_request) + await requests.async_concurrency( + request_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_convenient_form_request + ) + response_data = getattr(list_menu_request, "response_data") + component_list: list[dict] = udict.get_by_path(response_data, 'resultInfo.data.form.componentList') + return component_list + + +if __name__ == "__main__": + from paste.core import aio_pool + + async def test(dcm_task_id, form_id): + dcm_task = await DcmTask(id=dcm_task_id).async_find_first() + assert dcm_task, f"未找到待办工单,工单ID:{dcm_task_id}" + await fetch_form_components(dcm_task, form_id) + + _runner = aio_pool.get_aio_runner() + _runner(test(2054174091287597056, '352')) \ No newline at end of file diff --git a/dock/dcm/dcm_scrape_extend_info.py b/dock/dcm/dcm_scrape_extend_info.py new file mode 100644 index 0000000..fc315f6 --- /dev/null +++ b/dock/dcm/dcm_scrape_extend_info.py @@ -0,0 +1,50 @@ +import asyncio +import json +import pandas as pd +import models + +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.dcm import dcm_api +from paste.util import udict +from paste.core.logging import echo_log +from models.dcm_task_extend_info import DcmTaskExtendedInfo + + +async def get_extend_info_request(rec_id: int): + """ + 获取 DCM 企业代办扩展信息数据。 + + 向 DCM 的企业代办扩展信息数据接口发送 GET 请求,获取指定记录ID的扩展信息数据。 + 自动注入有效的 Cookie(如 JSESSIONID)至请求头,并解析返回的 JSON 数据。 + + Args: + rec_id (int): 关联类型 ID,默认为 1(任务类型)。 + """ + api_url = f"/home/mis/rec/getsubtypeexvalue" + request_body = { + "recID": rec_id + } + # 构造 API 请求 + return await dcm_api.new_api_request(api_url, request_body, 'GET') + + +async def after_extend_info_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + response_body = response.body.decode() + response_data = json.loads(response_body) + list_data = udict.get_by_path(response_data, 'resultInfo.data.list') + data_df = pd.DataFrame(list_data) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in DcmTaskExtendedInfo.FieldMapping.items()} + mapped_df = data_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + dcm_task_id = getattr(response.request, 'dcm_task_id') + rec_id = getattr(response.request, 'rec_id') + mapped_df[DcmTaskExtendedInfo.dcm_task_id.key] = dcm_task_id + mapped_df[DcmTaskExtendedInfo.rec_id.key] = rec_id + # 筛选数据状态 + _created, _updated = await DcmTaskExtendedInfo.save_batch(mapped_df) + echo_log(f"成功创建企业待办 {rec_id} 的扩展信息:{_created}条,更新:{_updated}条.") + if retry_queue: + echo_log(f"企业待办扩展信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") diff --git a/dock/dcm/dcm_scrape_form_data.py b/dock/dcm/dcm_scrape_form_data.py new file mode 100644 index 0000000..b3ef846 --- /dev/null +++ b/dock/dcm/dcm_scrape_form_data.py @@ -0,0 +1,61 @@ +import asyncio +import json +import pandas as pd +import models + +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.dcm import dcm_api +from paste.util import udict +from paste.core.logging import echo_log +from models.dcm_task_form_datum import DcmTaskFormDatum + + +async def get_form_data_request(rec_id: int, act_id: int, form_id: int = 356): + """ + 获取 DCM 企业待办表单预览数据。 + + 向 DCM 的企业待办表单预览数据接口发送 GET 请求,获取与指定关系 ID 和类型 ID 关联的媒体信息。 + 自动注入有效的 Cookie(如 JSESSIONID)至请求头,并解析返回的 JSON 数据。 + + Args: + rec_id (int): 关联类型 ID,默认为 1(任务类型)。 + act_id (int): 关联类型 ID,默认为 1(任务类型)。 + form_id (int): 表单 ID。 + """ + api_url = f"/home/form/formpreview/getformdata" + # 注意:以下字典加入 body 前必须先编码,否则服务端可能无法解析 + _params = { + "recID": rec_id, + "actID": act_id, + } + request_body = { + "formID": form_id, + "param": json.dumps(_params, separators=(',', ':')), + } + # 构造 API 请求 + return await dcm_api.new_api_request(api_url, request_body, 'GET') + + +async def after_form_data_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + response_body = response.body.decode() + response_data = json.loads(response_body or '{}') + list_data_546 = udict.get_by_path(response_data, 'resultInfo.data.formTableData.546') or {} + list_data_588 = udict.get_by_path(response_data, 'resultInfo.data.formTableData.588') or {} + list_data_588.pop('rec_id', None) + list_data = {**list_data_546, **list_data_588} + form_data_df = pd.DataFrame([list_data]) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in DcmTaskFormDatum.FieldMapping.items()} + mapped_df = form_data_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + dcm_task_id = getattr(response.request, 'dcm_task_id') + rec_id = getattr(response.request, 'rec_id') + mapped_df[DcmTaskFormDatum.dcm_task_id.key] = dcm_task_id + mapped_df[DcmTaskFormDatum.rec_id.key] = rec_id + # 筛选数据状态 + _created, _updated = await DcmTaskFormDatum.save_batch(mapped_df) + echo_log(f"成功创建企业待办 {rec_id} 的表单:{_created}条,更新:{_updated}条.") + if retry_queue: + echo_log(f"企业待办表单重试队列中有:{retry_queue.qsize()} 个请求在等待.") \ No newline at end of file diff --git a/dock/dcm/dcm_scrape_more_info.py b/dock/dcm/dcm_scrape_more_info.py new file mode 100644 index 0000000..3fb44e4 --- /dev/null +++ b/dock/dcm/dcm_scrape_more_info.py @@ -0,0 +1,50 @@ +import asyncio +import json +import pandas as pd +import models + +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.dcm import dcm_api +from paste.util import udict +from paste.core.logging import echo_log +from models.dcm_task_more_info import DcmTaskMoreInfo + + +async def get_more_info_request(rec_id: int): + """ + 获取 DCM 企业代办更多信息数据。 + + 向 DCM 的企业代办更多信息数据接口发送 GET 请求,获取指定记录ID的更多信息数据。 + 自动注入有效的 Cookie(如 JSESSIONID)至请求头,并解析返回的 JSON 数据。 + + Args: + rec_id (int): 关联类型 ID,默认为 1(任务类型)。 + """ + api_url = f"/home/workflow/getrecmsginfo" + request_body = { + "recID": rec_id + } + # 构造 API 请求 + return await dcm_api.new_api_request(api_url, request_body, 'GET') + + +async def after_more_info_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + response_body = response.body.decode() + response_data = json.loads(response_body) + list_data = udict.get_by_path(response_data, 'resultInfo.data.processInfo') + data_df = pd.DataFrame(list_data) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in DcmTaskMoreInfo.FieldMapping.items()} + mapped_df = data_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + dcm_task_id = getattr(response.request, 'dcm_task_id') + rec_id = getattr(response.request, 'rec_id') + mapped_df[DcmTaskMoreInfo.dcm_task_id.key] = dcm_task_id + mapped_df[DcmTaskMoreInfo.rec_id.key] = rec_id + # 筛选数据状态 + _created, _updated = await DcmTaskMoreInfo.save_batch(mapped_df) + echo_log(f"成功创建企业待办 {rec_id} 的更多信息:{_created}条,更新:{_updated}条.") + if retry_queue: + echo_log(f"企业待办更多信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") diff --git a/dock/dcm/dcm_scrape_operation.py b/dock/dcm/dcm_scrape_operation.py new file mode 100644 index 0000000..32bbbcd --- /dev/null +++ b/dock/dcm/dcm_scrape_operation.py @@ -0,0 +1,119 @@ +import asyncio +import json + +from sqlalchemy import select +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.dcm import dcm_api +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + + +async def get_operation_request( + rec_id: int, act_id: int, task_num: str, other_task_num: str, + task_list_id: int = 600058, + task_list_name: str = "部门待办栏" +): + """ + 获取 DCM 企业待办的可用菜单列表。 + + 向 DCM 的可用菜单列表接口发送 GET 请求,获取与指定关系 ID 和类型 ID 关联的可用菜单信息。 + 自动注入有效的 Cookie(如 JSESSIONID)至请求头,并解析返回的 JSON 数据。 + + Args: + rec_id (int): 记录 ID + act_id (int): 任务 ID + task_num (str): 任务号 + other_task_num (str): 第三方任务号 + task_list_id: 任务列表类型 ID,默认为企业待办:600058 + task_list_name: 任务列表名称,默认为:部门待办栏 + """ + api_url = f"/home/workflow/assign" + request_body = { + "recID": rec_id, + "actID": act_id, + "tasknum": task_num, + "otherTaskNum": other_task_num, + "taskListID": task_list_id, + "taskListName": task_list_name, + "recDispNum": "", + "menuName": "assign", + "menuDisplayName": "办理", + } + # 构造 API 请求 + return await dcm_api.new_api_request(api_url, request_body, 'GET', timeout=30) + + +async def after_operation_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + response_body = response.body.decode() + response_data = json.loads(response_body) + menus: list[dict] = udict.get_by_path(response_data, 'resultInfo.data.menuData.menus') + operation: list[str] = [] + if menus is None: + menus = [] + for menu in menus: + if menu.get('menuName') != 'unassign' and menu.get('visible', False) and menu.get('displayProperty', 0) & 2 == 2: + # 排除撤销办理,和不显示的菜单,仅返回可用的操作菜单 + operation.append(menu.get('displayName')) + setattr(response.request, "response_data", response_data) + setattr(response.request, "operation", response_data) + + dcm_task_id = getattr(response.request, 'dcm_task_id') + dcm_task = await DcmTask(id=dcm_task_id).async_find_first() + dcm_task.operation = ','.join(operation) + await dcm_task.async_save() + + rec_id = getattr(response.request, 'rec_id') + echo_log(f"成功更新企业待办 {rec_id} 的可用操作:{dcm_task.operation}.") + if retry_queue: + echo_log(f"企业待办可用操作重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def fetch_operation(dcm_task: DcmTask): + """ + 抓取列表菜单可用操作。 + + :param dcm_task: 数字城管待办工单 + :return: + """ + # 取得请求对象 + operation_request = await get_operation_request( + dcm_task.rec_id, dcm_task.act_id, dcm_task.task_num, dcm_task.other_task_num + ) + setattr(operation_request, "dcm_task_id", dcm_task.id) + request_queue = asyncio.Queue() + await request_queue.put(operation_request) + await requests.async_concurrency( + request_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_operation_request + ) + operation = getattr(operation_request, "operation") + return operation + + +if __name__ == "__main__": + from paste.core import aio_pool + + async def test(dcm_task_id): + dcm_task = await DcmTask(id=dcm_task_id).async_find_first() + assert dcm_task, f"未找到待办工单,工单ID:{dcm_task_id}" + await fetch_operation(dcm_task) + + async def test_all(): + dcm_task_list: list[DcmTask] = await DcmTask.query_all(select(DcmTask)) + queue = asyncio.Queue() + for dcm_task in dcm_task_list: + request = await get_operation_request( + dcm_task.rec_id, dcm_task.act_id, dcm_task.task_num, dcm_task.other_task_num + ) + setattr(request, "dcm_task_id", dcm_task.id) + setattr(request, "rec_id", dcm_task.rec_id) + await queue.put(request) + await requests.async_concurrency(queue, after_request=after_operation_request) + + _runner = aio_pool.get_aio_runner() + # _runner(test(2054174091304374276)) + _runner(test_all()) \ No newline at end of file diff --git a/dock/dcm/dcm_scrape_process_info.py b/dock/dcm/dcm_scrape_process_info.py new file mode 100644 index 0000000..5809fba --- /dev/null +++ b/dock/dcm/dcm_scrape_process_info.py @@ -0,0 +1,54 @@ +import asyncio +import json +import pandas as pd +import models + +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.dcm import dcm_api +from paste.util import udict +from paste.core.logging import echo_log +from models.dcm_task_process_info import DcmTaskProcessInfo + + +async def get_process_info_request(relation_id: int, process_type: str = 'full', show_assign_flag: int = 0): + """ + 获取 DCM 企业待办处理经过信息。 + + 向 DCM 的任务处理经过信息接口发送 GET 请求,获取指定任务的流程信息(如审批流、节点等)。 + 自动注入有效的 Cookie(如 JSESSIONID)至请求头,并解析返回的 JSON 数据。 + + Args: + relation_id (int): 关联记录的 ID,例如任务 ID。 + process_type (str): 流程信息类型,默认为 'full'(完整流程)。 + show_assign_flag (int): 是否显示分配人信息,0 表示不显示,非 0 表示显示。 + """ + api_url = f"/home/workflow/getrecprocessinfo" + request_body = { + "recID": relation_id, + "processType": process_type, + "showAssignFlag": show_assign_flag, + } + # 构造 API 请求 + return await dcm_api.new_api_request(api_url, request_body, 'GET') + + +async def after_process_info_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + response_body = response.body.decode() + response_data = json.loads(response_body) + list_data = udict.get_by_path(response_data, 'resultInfo.data.processInfo') + process_info_df = pd.DataFrame(list_data) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in DcmTaskProcessInfo.FieldMapping.items()} + mapped_df = process_info_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + dcm_task_id = getattr(response.request, 'dcm_task_id') + rec_id = getattr(response.request, 'rec_id') + mapped_df[DcmTaskProcessInfo.dcm_task_id.key] = dcm_task_id + mapped_df[DcmTaskProcessInfo.rec_id.key] = rec_id + # 筛选数据状态 + _created, _updated = await DcmTaskProcessInfo.save_batch(mapped_df) + echo_log(f"成功创建企业待办 {rec_id} 的经过:{_created}条,更新:{_updated}条.") + if retry_queue: + echo_log(f"企业待办经过重试队列中有:{retry_queue.qsize()} 个请求在等待.") \ No newline at end of file diff --git a/dock/dcm/dcm_scrape_task.py b/dock/dcm/dcm_scrape_task.py new file mode 100644 index 0000000..e0a1c77 --- /dev/null +++ b/dock/dcm/dcm_scrape_task.py @@ -0,0 +1,67 @@ +import asyncio +import json +import pandas as pd +import models + +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.dcm import dcm_api +from paste.util import udict +from paste.core.logging import echo_log +from models.dcm_task import DcmTask + + +async def get_task_request(task_list_id: int = 600058, current_page: int = 1, num_per_page: int = 200, + sort_field_id: int = -1, sort_type: str = '', only_data_flag: bool = False, + search_num: str = None): + """ + 获取 DCM 任务列表数据。 + + 通过 POST 请求向 DCM 的任务列表接口提交表单数据,获取任务分页数据。 + 自动注入有效的 Cookie(如 JSESSIONID)至请求头,并解析返回的 JSON 响应。 + + Args: + task_list_id (int): 任务列表类型 ID,默认为企业待办:600058 + current_page (int): 当前页码。 + num_per_page (int): 每页显示数据量,默认 200。 + sort_field_id (int): 排序字段 ID,默认不排序为 -1。 + sort_type (str): 排序类型,默认无排序字段,排序类型为空。 + only_data_flag (bool): 仅数据标志,默认为:False。 + search_num(str): 可选的任务号关键词,默认搜索时不传此参数。 + """ + api_url = f"/home/bizbase/tasklist/gethumantasklistdata" + request_body = { + "taskListID": task_list_id, + "currentPage": current_page, + "numPerPage": num_per_page, + "sortFieldID": sort_field_id, + "sortType": sort_type, + "onlyDataFlag": only_data_flag, + } + if search_num is not None: + request_body["searchNum"] = search_num + # 构造 API 请求 + return await dcm_api.new_api_request(api_url, request_body) + + +async def after_task_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + response_body = response.body.decode() + response_data = json.loads(response_body) + list_data = udict.get_by_path(response_data, 'resultInfo.data.listDataSet.listData') + task_df = pd.DataFrame(list_data) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in DcmTask.FieldMapping.items()} + mapped_df = task_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + # 筛选数据状态 + _created, _updated = await DcmTask.save_batch(mapped_df) + echo_log(f"成功创建企业待办:{_created}条,更新:{_updated}条.") + if retry_queue: + echo_log(f"企业待办重试队列中有:{retry_queue.qsize()} 个请求在等待.") diff --git a/dock/dcm/dcm_security.py b/dock/dcm/dcm_security.py new file mode 100644 index 0000000..3d909e9 --- /dev/null +++ b/dock/dcm/dcm_security.py @@ -0,0 +1,115 @@ +""" +安全模块。 +""" +import asyncio +import json + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.dcm import dcm_api +from models.token import TokenModel +from paste.core import config +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + + +async def login(): + """ + 登录 DCM 系统并获取认证 Cookie 和响应数据。 + + 流程: + 1. 访问 `/main.htm` 获取服务端下发的初始 Cookie(如 JSESSIONID)。 + 2. 使用该 Cookie 构造请求头,向 `/login/validpassword` 发送 POST 请求。 + 3. 请求体包含用户名、密码、浏览器及操作系统信息(从配置和随机生成获取)。 + 4. 验证响应中 `resultInfo.success` 字段,若为 False 则抛出断言错误。 + + Args: + 无参数。 + + Returns: + tuple: 包含两个元素的元组: + - str: 请求头中的 Cookie 字符串(如 "JSESSIONID=abc; ...") + - dict: DCM 接口返回的完整 JSON 响应数据 + + Raises: + AssertionError: 登录失败(`resultInfo.success` 为 False) + ValueError: 响应体非合法 JSON + HTTPError: 网络请求失败(由 `async_request` 抛出) + """ + home_url = f"{dcm_api.ApiUrl}/main.htm" + login_url = f"{dcm_api.ApiUrl}/login/validpassword" + + # 获取初始 Cookie + initial_cookies = await dock.scrape_cookies(home_url) + cookie_header = "; ".join([f"{k}={v}" for k, v in initial_cookies.items()]) + echo_log(cookie_header) + + # 构建扩展头 + user_agent, browser_ver, os_name = dock.get_random_user_agent() + extra_headers = { + 'Cookie': cookie_header, + 'User-Agent': user_agent, + } + + # 构造请求 + request_body = { + "u": config.get_config("dock.dcm.account.u"), + "p": config.get_config("dock.dcm.account.p"), + "ip": "", + "browserVersion": browser_ver, + "osVersion": os_name, + "validCode": "", + "validWay": 0, + } + + # 构造请求对象 + request = dock.new_http_request( + url=login_url, + body=request_body, + method='POST', + timeout=dock.DEFAULT_TIMEOUT, + use_form=True, + extra_headers=extra_headers, + ) + setattr(request, 'cookie_header', cookie_header) + + queue = asyncio.Queue() + await queue.put(request) + await requests.async_concurrency( + queue, con_count=1, retry=dock.MAX_RETRY_COUNT, + after_request=after_login + ) + + +async def after_login(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + response_body = response.body.decode() + response_data = json.loads(response_body) + success = udict.get_by_path(response_data, 'resultInfo.success', False) + if success: + cookie_header = getattr(response.request, 'cookie_header') + await TokenModel.refresh(platform='数字城管', token=cookie_header) + echo_log(f"成功刷新数字城管登录令牌.") + else: + echo_log(f"数字城管登录失败,无法刷新令牌,响应:{response_body}") + if retry_queue: + echo_log(f"登录重试队列中有:{retry_queue.qsize()} 个请求在等待.") + return response_data + + +async def get_cookies(platform: str = '数字城管'): + """ + 取得可用 Cookies。目前固定,后期改为从数据库读取。 + + :param platform: 要查询的平台,默认是:数字城管 + :return: Cookies 字符串 + """ + _token = await TokenModel.find_by_platform(platform) + return _token.token + + +if __name__ == "__main__": + from paste.core import aio_pool + _runner = aio_pool.get_aio_runner() + _runner(login()) \ No newline at end of file diff --git a/dock/dcm/dcm_send_sms.py b/dock/dcm/dcm_send_sms.py new file mode 100644 index 0000000..ebe540e --- /dev/null +++ b/dock/dcm/dcm_send_sms.py @@ -0,0 +1,51 @@ +import asyncio +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.dcm import dcm_api +from paste.core.logging import echo_log +from paste.web import requests +import apps + + +async def get_send_sms_request(body): + """ + 创建发送短信的请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param body: 短信参数字典 + :return: HTTPRequest 对象 + """ + api_url = '/home/sms/sendwfsms' + sms_request = await dcm_api.new_api_request(api_url, body) + return sms_request + + +async def after_send_sms_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交数字城管后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + echo_log(response.body.decode()) + echo_log('发送短信成功') + + +async def send_sms(body): + """ + 调用数字城管的发送短信接口 + + :param body: 短信参数字典 + """ + echo_log('正在准备发送短信...') + sms_request = await get_send_sms_request(body) + sms_queue = asyncio.Queue() + await sms_queue.put(sms_request) + if apps.get_active_env() in ('dev', '', None): + echo_log(f"非生产环境,不实际提交.") + return + + await requests.async_concurrency( + sms_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_send_sms_request + ) diff --git a/dock/govc/__init__.py b/dock/govc/__init__.py new file mode 100644 index 0000000..fc15f63 --- /dev/null +++ b/dock/govc/__init__.py @@ -0,0 +1,3 @@ +""" +市12345对接模块。 +""" \ No newline at end of file diff --git a/dock/govc/__pycache__/__init__.cpython-311.pyc b/dock/govc/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..2f7e2bc Binary files /dev/null and b/dock/govc/__pycache__/__init__.cpython-311.pyc differ diff --git a/dock/govc/__pycache__/govc_api.cpython-311.pyc b/dock/govc/__pycache__/govc_api.cpython-311.pyc new file mode 100644 index 0000000..c616c35 Binary files /dev/null and b/dock/govc/__pycache__/govc_api.cpython-311.pyc differ diff --git a/dock/govc/govc_api.py b/dock/govc/govc_api.py new file mode 100644 index 0000000..327cf9c --- /dev/null +++ b/dock/govc/govc_api.py @@ -0,0 +1,65 @@ +""" +市12345对接 API 基础功能。 +""" + +from tornado.httpclient import AsyncHTTPClient + +import dock +from paste.core import config + +ApiUrl = "http://2.46.12.176:8091/sz12345" +""" +对接 API 根目录。 +""" + + +ProxyConfig = config.get_config('dock.govc.proxy') +""" +代理服务器配置。 +""" +if ProxyConfig and ProxyConfig.get('proxy_host', None) and ProxyConfig.get('proxy_port', None): + # 切换到底层实现,以便代理服务器生效 + AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient") + + +async def new_api_request(api_url: str, request_body: dict, method: str = 'POST', + timeout: float = dock.DEFAULT_TIMEOUT, use_form: bool = True, headers: dict = None): + """ + 构造一个 API 请求对象 + + :param api_url: API 地址,以斜杠开头的 URI 地址,非完整 URL + :param request_body: 请求体,即所有请求参数 + :param method: 请求提交方式 + :param timeout: 超时时长 + :param use_form: 是否使用表单(Form)方式提交 + :param headers: 头数据,最高优先级 + :return: HTTPRequest 对象 + """ + # Cookie + from dock.govc import govc_security + _, cookie_header = await govc_security.get_cookies() + + # 构建扩展头 + user_agent, browser_ver, os_name = dock.get_random_user_agent() + extra_headers = { + 'Cookie': cookie_header, + 'EPTOKEN': dock.get_cookie_value(cookie_header, 'EPTOKEN'), + 'Host': '2.46.12.176:8091', + 'Origin': 'http://2.46.12.176:8091', + 'Referer': 'http://2.46.12.176:8091/sz12345/bmfw/bmfwlogin/login', + 'User-Agent': user_agent, + } + if headers is not None: + extra_headers = {**extra_headers, **headers} + + # 构造请求对象 + request = dock.new_http_request( + url=f"{ApiUrl}{api_url}", + body=request_body, + method=method, + timeout=timeout, + use_form=use_form, + extra_headers=extra_headers, + ** ProxyConfig + ) + return request \ No newline at end of file diff --git a/dock/govc/govc_scrape.py b/dock/govc/govc_scrape.py new file mode 100644 index 0000000..f8b7bee --- /dev/null +++ b/dock/govc/govc_scrape.py @@ -0,0 +1,94 @@ +""" +数据抓取模块。 +""" +import asyncio +from typing import Optional, Union + +from sqlalchemy import select, desc + +import dock +from dock.govc import govc_scrape_dept_feedback, govc_scrape_return_visit, govc_scrape_finish_info, govc_scrape_order +from models.govc_task import GovcTask +from paste.core.logging import echo_log +from paste.web import requests + + +async def fetch_govc_task(fetch_size: int = 60, task_id: Optional[Union[str, int]] = None): + """ + 抓取待办数据及其明细数据。 + + :param fetch_size: 读取多少任务进行明细抓取 + :param task_id: 可选的指定的工单id + """ + echo_log(f"开始抓取待办数据...") + task_request = await govc_scrape_order.get_task_request( + fetch_size=fetch_size + ) + request_queue = asyncio.Queue() + await request_queue.put(task_request) + await requests.async_concurrency( + request_queue, retry=dock.MAX_RETRY_COUNT, + after_request=govc_scrape_order.after_task_request + ) + echo_log(f"待办数据抓取完成...") + + # 读取任务数据,以便能对最新数据抓取详细数据 + query = select( + GovcTask.id, GovcTask.pvi_guid, GovcTask.c_guid + ).order_by( + desc(GovcTask.id) + ) + if task_id: + if isinstance(task_id, list): + query = query.where(GovcTask.id.in_(task_id)) + echo_log(f"开始抓取待办列表:{task_id} 的详细数据...") + else: + query = query.where(GovcTask.id == task_id) + echo_log(f"开始抓取待办:{task_id} 的详细数据...") + else: + echo_log(f"开始抓取前 {fetch_size} 条待办的详细数据...") + query = query.limit(fetch_size) + task_df = await GovcTask.query_as_df(query) + + # 构建请求队列 + feedback_queue = asyncio.Queue() + result_info_queue = asyncio.Queue() + finish_info_queue = asyncio.Queue() + # 向队列中填充请求对象 + echo_log(f"正在准备请求队列...") + for _h, _row in task_df.iterrows(): + _feedback_request = await govc_scrape_dept_feedback.get_feedback_request(_row.get(GovcTask.pvi_guid.key), + _row.get(GovcTask.c_guid.key)) + setattr(_feedback_request, 'task_id', _row.get(GovcTask.id.key)) + await feedback_queue.put(_feedback_request) + _result_info_request = await govc_scrape_return_visit.get_return_visit_request(_row.get(GovcTask.pvi_guid.key), + _row.get(GovcTask.c_guid.key)) + setattr(_result_info_request, 'task_id', _row.get(GovcTask.id.key)) + await result_info_queue.put(_result_info_request) + _finish_info_request = await govc_scrape_finish_info.get_finish_info_request(_row.get(GovcTask.pvi_guid.key), + _row.get(GovcTask.c_guid.key)) + setattr(_finish_info_request, 'task_id', _row.get(GovcTask.id.key)) + await finish_info_queue.put(_finish_info_request) + + echo_log(f"抓取待办详细数据...") + tasks = [ + requests.async_concurrency( + feedback_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=govc_scrape_dept_feedback.after_feedback_request + ), + requests.async_concurrency( + result_info_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=govc_scrape_result_info.after_result_info_request + ), + requests.async_concurrency( + finish_info_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=govc_scrape_finish_info.after_finish_info_request) + ] + await asyncio.gather(*tasks) + + +if __name__ == "__main__": + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(fetch_govc_task(10)) diff --git a/dock/govc/govc_scrape_contact_info.py b/dock/govc/govc_scrape_contact_info.py new file mode 100644 index 0000000..2874d52 --- /dev/null +++ b/dock/govc/govc_scrape_contact_info.py @@ -0,0 +1,59 @@ +import asyncio +import json + +import pandas as pd +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.govc import govc_api +import models +from models.govc_task_contact import GovcTaskContact +from paste.util import udict +from paste.core.logging import echo_log + + +async def get_contact_request(pviguid: str, cguid: str): + """ + 获取市12345的工单的联系信息,请求响应是单条工单的数据 + + :param pviguid: 工单列表请求返回的pviguid + :param cguid: 工单列表请求返回的cguid + """ + api_url = '/rest/sztaskworkordercommonrest/getContactInformation' + headers = { + 'Referer': f'{govc_api.ApiUrl}/rest/sztaskworkordercommonrest/getContactInformation' + } + request_body = { + "ProcessVersionInstanceGuid": pviguid, + 'caseguid': cguid, + 'yearflag': 'undefined' + } + # 构造 API 请求 + return await govc_api.new_api_request(api_url, request_body, headers=headers) + + +async def after_contact_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + response_body = response.body.decode() + response_data = json.loads(response_body) + contact_list = udict.get_by_path(response_data, 'params.linklist') + if contact_list: + mapped_df = pd.DataFrame(contact_list) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovcTaskContact.FieldMapping.items()} + mapped_df = mapped_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + task_id = getattr(response.request, GovcTaskContact.task_id.key) + mapped_df[GovcTaskContact.task_id.key] = task_id + # 筛选数据状态 + _created, _updated = await GovcTaskContact.save_batch(mapped_df) + echo_log(f"成功创建联系信息:{_created}条,更新:{_updated}条.") + else: + echo_log('未获取到联系信息') + if retry_queue: + echo_log(f"联系信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") diff --git a/dock/govc/govc_scrape_delay_info.py b/dock/govc/govc_scrape_delay_info.py new file mode 100644 index 0000000..01cd226 --- /dev/null +++ b/dock/govc/govc_scrape_delay_info.py @@ -0,0 +1,59 @@ +import asyncio +import json + +import pandas as pd +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.govc import govc_api +import models +from models.govc_task_delay import GovcTaskDelay +from paste.util import udict +from paste.core.logging import echo_log + + +async def get_delay_request(pviguid: str, cguid: str): + """ + 获取市12345的工单的延迟信息,请求响应是单条工单的数据 + + :param pviguid: 工单列表请求返回的pviguid + :param cguid: 工单列表请求返回的cguid + """ + api_url = '/rest/sztaskhandlerest/getDelayInfo' + headers = { + 'Referer': f'{govc_api.ApiUrl}/rest/sztaskhandlerest/getDelayInfo' + } + request_body = { + "ProcessVersionInstanceGuid": pviguid, + 'caseguid': cguid, + 'yearflag': 'undefined' + } + # 构造 API 请求 + return await govc_api.new_api_request(api_url, request_body, headers=headers) + + +async def after_delay_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + response_body = response.body.decode() + response_data = json.loads(response_body) + delay_list = udict.get_by_path(response_data, 'params.table') + if delay_list: + mapped_df = pd.DataFrame(delay_list) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovcTaskDelay.FieldMapping.items()} + mapped_df = mapped_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + task_id = getattr(response.request, GovcTaskDelay.task_id.key) + mapped_df[GovcTaskDelay.task_id.key] = task_id + # 筛选数据状态 + _created, _updated = await GovcTaskDelay.save_batch(mapped_df) + echo_log(f"成功创建延迟信息:{_created}条,更新:{_updated}条.") + else: + echo_log('未获取到延迟信息') + if retry_queue: + echo_log(f"延迟信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") diff --git a/dock/govc/govc_scrape_dept_feedback.py b/dock/govc/govc_scrape_dept_feedback.py new file mode 100644 index 0000000..8d3e64a --- /dev/null +++ b/dock/govc/govc_scrape_dept_feedback.py @@ -0,0 +1,66 @@ +import asyncio +import json + +import pandas as pd +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.govc import govc_api +import models +from models.govc_task_department_feedback import GovcTaskDeptFeedback +from paste.util import udict +from paste.core.logging import echo_log + + +async def get_feedback_request(pviguid: str, cguid: str): + """ + 获取市12345的工单的部门处置信息,请求响应是单条工单的数据 + + :param pviguid: 工单列表请求返回的pviguid + :param cguid: 工单列表请求返回的cguid + """ + api_url = '/rest/sztaskworkordercommonrest/getDeptfeedback' + headers = { + 'Referer': f'{govc_api.ApiUrl}/rest/sztaskworkordercommonrest/getDeptfeedback' + } + request_body = { + "ProcessVersionInstanceGuid": pviguid, + 'caseguid': cguid, + 'yearflag': 'undefined' + } + # 构造 API 请求 + return await govc_api.new_api_request(api_url, request_body, headers=headers) + + +async def after_feedback_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + response_body = response.body.decode() + response_data = json.loads(response_body) + feedback_list = udict.get_by_path(response_data, 'params.feedbackresult') + if feedback_list: + mapped_df = pd.DataFrame(feedback_list) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovcTaskDeptFeedback.FieldMapping.items()} + mapped_df = mapped_df.rename(columns=forward_mapping) + # 把字典、列表改为字符串 + mapped_df[GovcTaskDeptFeedback.zxhf_info.key] = mapped_df[GovcTaskDeptFeedback.zxhf_info.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if isinstance(x, (list, dict)) else x + ) + mapped_df[GovcTaskDeptFeedback.back_info.key] = mapped_df[GovcTaskDeptFeedback.back_info.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if isinstance(x, (list, dict)) else x + ) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + task_id = getattr(response.request, GovcTaskDeptFeedback.task_id.key) + mapped_df[GovcTaskDeptFeedback.task_id.key] = task_id + # 筛选数据状态 + _created, _updated = await GovcTaskDeptFeedback.save_batch(mapped_df) + echo_log(f"成功创建部门处置信息:{_created}条,更新:{_updated}条.") + else: + echo_log('未获取到部门处置信息') + if retry_queue: + echo_log(f"部门处置信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") diff --git a/dock/govc/govc_scrape_detail.py b/dock/govc/govc_scrape_detail.py new file mode 100644 index 0000000..c6b5dec --- /dev/null +++ b/dock/govc/govc_scrape_detail.py @@ -0,0 +1,67 @@ +import asyncio +import json + +import pandas as pd +from tornado.httpclient import HTTPResponse, HTTPRequest +from sqlalchemy import select + +from dock.govc import govc_api +import models +from models.govc_task_detail import GovcTaskDetail +from models.govc_task_attachment import GovcTaskAttachment +from paste.util import udict +from paste.core.logging import echo_log + + +async def get_detail_request(cguid: str): + """ + 获取市12345的工单的详情信息,请求响应是单条工单的数据 + + :param cguid: 工单列表请求返回的cguid + """ + api_url = '/rest/sztaskworkordercommonrest/getDetail' + headers = { + 'Referer': f'{govc_api.ApiUrl}/rest/sztaskworkordercommonrest/getDetail' + } + request_body = { + 'caseguid': cguid, 'secret': 1 + } + # 构造 API 请求 + return await govc_api.new_api_request(api_url, request_body, headers=headers, method='GET') + + +async def after_detail_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + response_body = response.body.decode() + response_data = json.loads(response_body) + detail_info = udict.get_by_path(response_data, 'params') + if detail_info: + mapped_df = pd.DataFrame([detail_info]) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovcTaskDetail.FieldMapping.items()} + mapped_df = mapped_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + task_id = getattr(response.request, GovcTaskDetail.task_id.key) + mapped_df[GovcTaskDetail.task_id.key] = task_id + # 筛选数据状态 + _created, _updated = await GovcTaskDetail.save_batch(mapped_df) + echo_log(f"成功创建详情信息:{_created}条,更新:{_updated}条.") + files = udict.get_by_path(detail_info, 'files') + if files: + attachment_df = pd.DataFrame(files) + attachment_df[GovcTaskAttachment.task_id.key] = task_id + detail_query = select(GovcTaskDetail.id).where(GovcTaskDetail.task_id == task_id) + detail_id = await GovcTaskDetail.query_first(detail_query) + attachment_df[GovcTaskAttachment.detail_id.key] = detail_id + _created, _updated = await GovcTaskAttachment.save_batch(attachment_df) + echo_log(f"成功创建附件信息:{_created}条,更新:{_updated}条.") + else: + echo_log('未获取到详情信息') + if retry_queue: + echo_log(f"详情信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") diff --git a/dock/govc/govc_scrape_finish_info.py b/dock/govc/govc_scrape_finish_info.py new file mode 100644 index 0000000..4336e3d --- /dev/null +++ b/dock/govc/govc_scrape_finish_info.py @@ -0,0 +1,59 @@ +import asyncio +import json + +import pandas as pd +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.govc import govc_api +import models +from models.govc_task_finish import GovcTaskFinish +from paste.util import udict +from paste.core.logging import echo_log + + +async def get_finish_info_request(pviguid: str, cguid: str): + """ + 获取市12345的工单的办结信息,请求响应是单条工单的数据 + + :param pviguid: 工单列表请求返回的pviguid + :param cguid: 工单列表请求返回的cguid + """ + api_url = '/rest/sztaskworkordercommonrest/getFinishInfo' + headers = { + 'Referer': f'{govc_api.ApiUrl}/rest/sztaskworkordercommonrest/getFinishInfo' + } + request_body = { + "ProcessVersionInstanceGuid": pviguid, + 'caseguid': cguid, + 'yearflag': 'undefined' + } + # 构造 API 请求 + return await govc_api.new_api_request(api_url, request_body, headers=headers) + + +async def after_finish_info_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + response_body = response.body.decode() + response_data = json.loads(response_body) + result_list = udict.get_by_path(response_data, 'params.finishinfo') + if result_list: + mapped_df = pd.DataFrame(result_list) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovcTaskFinish.FieldMapping.items()} + mapped_df = mapped_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + task_id = getattr(response.request, GovcTaskFinish.task_id.key) + mapped_df[GovcTaskFinish.task_id.key] = task_id + # 筛选数据状态 + _created, _updated = await GovcTaskFinish.save_batch(mapped_df) + echo_log(f"成功创建办结信息:{_created}条,更新:{_updated}条.") + else: + echo_log('未获取到办结信息') + if retry_queue: + echo_log(f"办结信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") diff --git a/dock/govc/govc_scrape_order.py b/dock/govc/govc_scrape_order.py new file mode 100644 index 0000000..176ec95 --- /dev/null +++ b/dock/govc/govc_scrape_order.py @@ -0,0 +1,238 @@ +import asyncio +import json + +import pandas as pd +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.govc import govc_api, govc_security +from models.govc_task import GovcTask +import models +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + + +async def get_task_request(fetch_size: int = 100): + """ + 获取市12345任务列表数据。 + 通过 POST 请求向市12345的任务列表接口提交表单数据,获取任务分页数据。 + + :param fetch_size: 抓取条数 + """ + api_url = f"/rest/bmfw/business/taskinfo/query/sztaskquerylistaction/getDataGridData?moduleGuid=94835dc2-a76f-489b-ae76-ea8ed699a113" + headers = { + 'Referer': f'{govc_api.ApiUrl}/bmfw/business/taskinfo/query/taskquerylist?moduleGuid=94835dc2-a76f-489b-ae76-ea8ed699a113', + } + + # 设计公共请求对象和自定义请求对象的合并,这里主要合并的应该是 cmdParams 字段 + epoint_user_loginid, cookie_header = await govc_security.get_cookies() + request_body = { + "commonDto": json.dumps([ + { + "id": "cserial", + "bind": "cnsTinfo.serialnum", + "type": "textbox", + "action": "", + "value": "", + "text": "" + }, + { + "id": "rqsttitle", + "bind": "cnsTinfo.rqsttitle", + "type": "textbox", + "action": "", + "value": "", + "text": "" + }, + { + "id": "mini-12", + "bind": "cnsTinfo.handleouname", + "type": "textbox", + "action": "", + "value": "", + "text": "" + }, + { + "id": "rqsttype", + "bind": "cnsTinfo.rqsttype", + "type": "combobox", + "action": "getRqsttypeModel", + "textField": "text", + "valueField": "id", + "pinyinField": "tag", + "columns": [], + "value": "", + "text": "" + }, + { + "id": "search_tstatus", + "bind": "cnsTinfo.tstatus", + "type": "combobox", + "action": "getTstatusModel", + "textField": "text", + "valueField": "id", + "pinyinField": "tag", + "columns": [], + "value": "", + "text": "" + }, + { + "id": "rqschannel", + "bind": "cnsTinfo.rqschannel", + "type": "combobox", + "action": "getRqschannelModel", + "textField": "text", + "valueField": "id", + "pinyinField": "tag", + "columns": [], + "value": "", + "text": "" + }, + { + "id": "rqstcontent", + "bind": "cnsTinfo.rqstcontent", + "type": "textbox", + "action": "", + "value": "", + "text": "" + }, + { + "id": "startDate", + "bind": "startDate", + "type": "datepicker", + "action": "", + "format": "yyyy/MM/dd HH:mm:ss", + "value": "", + "text": "" + }, + { + "id": "endDate", + "bind": "endDate", + "type": "datepicker", + "action": "", + "format": "yyyy/MM/dd HH:mm:ss", + "value": "", + "text": "" + }, + { + "id": "dataexport", + "type": "dataexport", + "action": "getExportBigDataModel", + "mapClass": "com.epoint.basic.faces.export.DataExport", + "exportAction": "zwztexportaction.export" + }, + { + "id": "datagrid", + "type": "datagrid", + "action": "getDataGridData", + "idField": "rowguid", + "pageIndex": 0, + "sortField": "", + "sortOrder": "desc", + "columns": [ + { + "fieldName": "serialnum" + }, + { + "fieldName": "rqsttitle" + }, + { + "fieldName": "rqstcontent" + }, + { + "fieldName": "createdate", + "format": "yyyy-MM-dd HH:mm:ss" + }, + { + "fieldName": "handleouname" + }, + { + "fieldName": "finishtime_bf", + "format": "yyyy-MM-dd HH:mm:ss" + }, + { + "fieldName": "backtime_bf", + "format": "yyyy-MM-dd HH:mm:ss" + }, + { + "fieldName": "tstatus", + "code": "任务单状态" + } + ], + "pageSize": fetch_size, + "url": "getDataGridData", + "data": [], + "isSecondRequest": True + }, + { + "id": "_common_hidden_viewdata", + "type": "hidden", + "value": json.dumps({'epoint_user_loginid': epoint_user_loginid}, separators=(',', ':')) + } + ], separators=(',', ':')), + "cmdParams": json.dumps({ + 'pageUrl': api_url + }, separators=(',', ':')), + 'pageIndex': 0, + 'pageSize': fetch_size, + 'sortField': '', + 'sortOrder': 'desc', + 'isSecondRequest': 'true' + } + # 构造 API 请求 + return await govc_api.new_api_request(api_url, request_body, headers=headers) + + +async def after_task_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + response_body = response.body.decode() + response_data = json.loads(response_body) + list_data: list[dict] = udict.get_by_path(response_data, 'controls.0.data') + if list_data: + mapped_df = pd.DataFrame(list_data) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovcTask.FieldMapping.items()} + mapped_df = mapped_df.rename(columns=forward_mapping) + # 把非数字的时间戳字段改为None + mapped_df[GovcTask.finish_time.key] = mapped_df[GovcTask.finish_time.key].apply( + lambda x: None if not isinstance(x, int) else x + ) + mapped_df[GovcTask.sign_time.key] = mapped_df[GovcTask.sign_time.key].apply( + lambda x: None if not isinstance(x, int) else x + ) + mapped_df[GovcTask.sign_time_bf.key] = mapped_df[GovcTask.sign_time_bf.key].apply( + lambda x: None if not isinstance(x, int) else x + ) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + # 筛选数据状态 + _created, _updated = await GovcTask.save_batch(mapped_df) + echo_log(f"成功创建企业待办:{_created}条,更新:{_updated}条.") + else: + echo_log('未获取到企业待办数据') + if retry_queue: + echo_log(f"企业待办重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +if __name__ == "__main__": + from paste.core import aio_pool + + + async def scrape(): + task_request = await get_task_request() + request_queue = asyncio.Queue() + await request_queue.put(task_request) + await requests.async_concurrency( + request_queue, retry=dock.MAX_RETRY_COUNT, + after_request=after_task_request + ) + + + _runner = aio_pool.get_aio_runner() + _runner(scrape()) diff --git a/dock/govc/govc_scrape_order_status.py b/dock/govc/govc_scrape_order_status.py new file mode 100644 index 0000000..a046aa4 --- /dev/null +++ b/dock/govc/govc_scrape_order_status.py @@ -0,0 +1,59 @@ +import asyncio +import json + +import pandas as pd +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.govc import govc_api +import models +from models.govc_task_status import GovcTaskStatus +from paste.util import udict +from paste.core.logging import echo_log + + +async def get_fetch_status_request(pviguid: str, cguid: str): + """ + 获取市12345的工单状态信息,请求响应是单条工单的数据 + + :param pviguid: 工单列表请求返回的pviguid + :param cguid: 工单列表请求返回的cguid + """ + api_url = '/rest/sztaskworkordercommonrest/getCinfoLink' + headers = { + 'Referer': f'{govc_api.ApiUrl}/rest/sztaskworkordercommonrest/getCinfoLink' + } + request_body = { + "ProcessVersionInstanceGuid": pviguid, + 'caseguid': cguid, + 'yearflag': 'undefined' + } + # 构造 API 请求 + return await govc_api.new_api_request(api_url, request_body, headers=headers) + + +async def after_fetch_status_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + response_body = response.body.decode() + response_data = json.loads(response_body) + status_dict = udict.get_by_path(response_data, 'params.statuslink') + if status_dict: + mapped_df = pd.DataFrame([status_dict]) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovcTaskStatus.FieldMapping.items()} + mapped_df = mapped_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + task_id = getattr(response.request, GovcTaskStatus.task_id.key) + mapped_df[GovcTaskStatus.task_id.key] = task_id + # 筛选数据状态 + _created, _updated = await GovcTaskStatus.save_batch(mapped_df) + echo_log(f"成功创建工单状态信息:{_created}条,更新:{_updated}条.") + else: + echo_log('未获取到工单状态信息') + if retry_queue: + echo_log(f"工单状态信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") diff --git a/dock/govc/govc_scrape_process.py b/dock/govc/govc_scrape_process.py new file mode 100644 index 0000000..96b6995 --- /dev/null +++ b/dock/govc/govc_scrape_process.py @@ -0,0 +1,58 @@ +import asyncio +import json + +import pandas as pd +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.govc import govc_api +import models +from models.govc_task_process import GovcTaskProcess +from paste.util import udict +from paste.core.logging import echo_log + + +async def get_process_request(pviguid: str, cguid: str): + """ + 获取市12345的工单的办理过程信息,请求响应是单条工单的数据 + + :param pviguid: 工单列表请求返回的pviguid + :param cguid: 工单列表请求返回的cguid + """ + api_url = '/rest/sztaskworkordercommonrest/getTracing' + headers = { + 'Referer': f'{govc_api.ApiUrl}/rest/sztaskworkordercommonrest/getTracing' + } + request_body = { + 'caseguid': cguid, + 'pviguid': pviguid + } + # 构造 API 请求 + return await govc_api.new_api_request(api_url, request_body, headers=headers, method='GET') + + +async def after_process_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + response_body = response.body.decode() + response_data = json.loads(response_body) + process_list = udict.get_by_path(response_data, 'params.processedList') + if process_list: + mapped_df = pd.DataFrame(process_list) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovcTaskProcess.FieldMapping.items()} + mapped_df = mapped_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + task_id = getattr(response.request, GovcTaskProcess.task_id.key) + mapped_df[GovcTaskProcess.task_id.key] = task_id + # 筛选数据状态 + _created, _updated = await GovcTaskProcess.save_batch(mapped_df) + echo_log(f"成功创建办理过程信息:{_created}条,更新:{_updated}条.") + else: + echo_log('未获取到办理过程信息') + if retry_queue: + echo_log(f"办理过程信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") diff --git a/dock/govc/govc_scrape_requester.py b/dock/govc/govc_scrape_requester.py new file mode 100644 index 0000000..a2d9c41 --- /dev/null +++ b/dock/govc/govc_scrape_requester.py @@ -0,0 +1,56 @@ +import asyncio +import json + +import pandas as pd +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.govc import govc_api +import models +from models.govc_task_requester import GovcTaskRequester +from paste.util import udict +from paste.core.logging import echo_log + + +async def get_requster_request(cguid: str): + """ + 获取市12345的工单的诉求人信息,请求响应是单条工单的数据 + + :param cguid: 工单列表请求返回的cguid + """ + api_url = '/rest/sztaskworkordercommonrest/getInformation' + headers = { + 'Referer': f'{govc_api.ApiUrl}/rest/sztaskworkordercommonrest/getInformation' + } + request_body = { + 'caseguid': cguid, 'secret': 0 + } + # 构造 API 请求 + return await govc_api.new_api_request(api_url, request_body, headers=headers, method='GET') + + +async def after_requester_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + response_body = response.body.decode() + response_data = json.loads(response_body) + requester_info = udict.get_by_path(response_data, 'params') + if requester_info: + mapped_df = pd.DataFrame([requester_info]) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovcTaskRequester.FieldMapping.items()} + mapped_df = mapped_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + task_id = getattr(response.request, GovcTaskRequester.task_id.key) + mapped_df[GovcTaskRequester.task_id.key] = task_id + # 筛选数据状态 + _created, _updated = await GovcTaskRequester.save_batch(mapped_df) + echo_log(f"成功创建诉求人信息:{_created}条,更新:{_updated}条.") + else: + echo_log('未获取到诉求人信息') + if retry_queue: + echo_log(f"诉求人信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") diff --git a/dock/govc/govc_scrape_return_visit.py b/dock/govc/govc_scrape_return_visit.py new file mode 100644 index 0000000..eab0561 --- /dev/null +++ b/dock/govc/govc_scrape_return_visit.py @@ -0,0 +1,59 @@ +import asyncio +import json + +import pandas as pd +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.govc import govc_api +import models +from models.govc_task_return_visit import GovcTaskReturnVisit +from paste.util import udict +from paste.core.logging import echo_log + + +async def get_return_visit_request(pviguid: str, cguid: str): + """ + 获取市12345的工单的回访结果信息,请求响应是单条工单的数据 + + :param pviguid: 工单列表请求返回的pviguid + :param cguid: 工单列表请求返回的cguid + """ + api_url = '/rest/sztaskworkordercommonrest/getResultInfo' + headers = { + 'Referer': f'{govc_api.ApiUrl}/rest/sztaskworkordercommonrest/getResultInfo' + } + request_body = { + "ProcessVersionInstanceGuid": pviguid, + 'caseguid': cguid, + 'yearflag': 'undefined' + } + # 构造 API 请求 + return await govc_api.new_api_request(api_url, request_body, headers=headers) + + +async def after_return_visit_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + response_body = response.body.decode() + response_data = json.loads(response_body) + result_list = udict.get_by_path(response_data, 'params.resultinfo') + if result_list: + mapped_df = pd.DataFrame(result_list) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovcTaskReturnVisit.FieldMapping.items()} + mapped_df = mapped_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + task_id = getattr(response.request, GovcTaskReturnVisit.task_id.key) + mapped_df[GovcTaskReturnVisit.task_id.key] = task_id + # 筛选数据状态 + _created, _updated = await GovcTaskReturnVisit.save_batch(mapped_df) + echo_log(f"成功创建回访结果信息:{_created}条,更新:{_updated}条.") + else: + echo_log('未获取到回访结果信息') + if retry_queue: + echo_log(f"回访结果信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") diff --git a/dock/govc/govc_scrapy_history_order.py b/dock/govc/govc_scrapy_history_order.py new file mode 100644 index 0000000..32099f8 --- /dev/null +++ b/dock/govc/govc_scrapy_history_order.py @@ -0,0 +1,56 @@ +import asyncio +import json + +import pandas as pd +from tornado.httpclient import HTTPResponse, HTTPRequest + +from dock.govc import govc_api +import models +from models.govc_task_history import GovcTaskHistory +from paste.util import udict +from paste.core.logging import echo_log + + +async def get_history_order_request(cguid: str): + """ + 获取市12345的工单的历史工单信息,请求响应是单条工单的数据 + + :param cguid: 工单列表请求返回的cguid + """ + api_url = '/rest/sztaskworkordercommonrest/getHistoryWorkOrder' + headers = { + 'Referer': f'{govc_api.ApiUrl}/rest/sztaskworkordercommonrest/getHistoryWorkOrder' + } + request_body = { + 'caseguid': cguid + } + # 构造 API 请求 + return await govc_api.new_api_request(api_url, request_body, headers=headers, method='GET') + + +async def after_history_order_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + response_body = response.body.decode() + response_data = json.loads(response_body) + history_order_list = udict.get_by_path(response_data, 'params.list') + if history_order_list: + mapped_df = pd.DataFrame(history_order_list) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovcTaskHistory.FieldMapping.items()} + mapped_df = mapped_df.rename(columns=forward_mapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + task_id = getattr(response.request, GovcTaskHistory.task_id.key) + mapped_df[GovcTaskHistory.task_id.key] = task_id + # 筛选数据状态 + _created, _updated = await GovcTaskHistory.save_batch(mapped_df) + echo_log(f"成功创建历史工单信息:{_created}条,更新:{_updated}条.") + else: + echo_log('未获取到历史工单信息') + if retry_queue: + echo_log(f"历史工单信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") diff --git a/dock/govc/govc_security.py b/dock/govc/govc_security.py new file mode 100644 index 0000000..19837d8 --- /dev/null +++ b/dock/govc/govc_security.py @@ -0,0 +1,382 @@ +""" +安全模块。 +""" +import asyncio +import base64 +import io +import json +import re +from typing import Optional + +import ddddocr +from PIL import Image, ImageFilter, ImageEnhance +from gmssl import sm2 +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.govc import govc_api +from models.token import TokenModel +from paste.core import config +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + + +async def fetch_captcha(): + verify_code_img: Optional[str] = None + + def after_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + nonlocal verify_code_img + response_body = response.body.decode() + response_data = json.loads(response_body) + controls = udict.get_by_path(response_data, 'controls') + verify_code_img = udict.get_by_path(controls[0], 'data.src') + + # 请求路径 + pub_key_url = '/rest/bmfw/bmfwlogin/loginaction/page_Refresh?isCommondto=true' + + # 构建请求参数 + common_dto_str = json.dumps([ + { + "id": "verifyCode", + "type": "verifycode", + "action": "loginaction.pageLoad", + "mapClass": "com.epoint.basic.faces.verifycode.VerifyCode", + "width": "35%", + "height": "43px", + "charLength": 4, + "ignorecase": True, + "value": "random" + }, + { + "id": "_common_hidden_viewdata", + "type": "hidden", + "value": "{\"pageUrl\":\"E86A24CDBC744150F0A28F52940E2E9342802C4870563C30D121F50510AD3184\"}" + } + ], separators=(',', ':')) + cmd_params_str = json.dumps({ + "pageUrl": "http://2.46.12.176:8091/sz12345/bmfw/bmfwlogin/login" + }, separators=(',', ':')) + body_data = { + "commonDto": common_dto_str, + "cmdParams": cmd_params_str + } + verify_code_request = dock.new_http_request( + f"{govc_api.ApiUrl}{pub_key_url}", body_data, use_form=True, **govc_api.ProxyConfig + ) + request_queue = asyncio.Queue() + await request_queue.put(verify_code_request) + await requests.async_concurrency( + request_queue, con_count=1, retry=1, + after_request=after_request + ) + + return verify_code_img + + +def enhance_captcha(img_bytes): + """ + 利用 PIL 对图像进行:去噪 + 增强对比度处理。 + + :param img_bytes: 图像字节数据 + :return: 增强后的图像数据 + """ + img = Image.open(io.BytesIO(img_bytes)) + + # 去噪 + img = img.filter(ImageFilter.MedianFilter()) + + # 增强对比度 + enhancer = ImageEnhance.Contrast(img) + img = enhancer.enhance(1.5) # 1.5倍对比度 + + # 转回字节流 + buf = io.BytesIO() + img.save(buf, format='PNG') + return buf.getvalue() + + +async def read_verify_code(): + """ + 读取验证码。 + :return: 验证码 + """ + verify_code = "" + + def validate_captcha(code: str) -> bool: + """ + 验证验证码是否符合要求。 + """ + return bool(re.fullmatch(r'[A-Za-z0-9]{4}', code)) + + while not validate_captcha(verify_code): + base64_str = await fetch_captcha() + img_data = base64_str.split(',')[1] + img_data += '=' * (-len(img_data) % 4) + img_bytes = base64.b64decode(img_data) + # 利用 PIL 预处理图像 + img_bytes = enhance_captcha(img_bytes) + ocr = ddddocr.DdddOcr(show_ad=False) + verify_code = ocr.classification(img_bytes) + echo_log(verify_code) + + return verify_code + + +async def fetch_public_key(): + sm2_pub_key: Optional[str] = None + + def after_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + nonlocal sm2_pub_key + response_body = response.body.decode() + response_data = json.loads(response_body) + sm2_pub_key = udict.get_by_path(response_data, 'custom.sm2PubKey') + + pub_key_url = '/rest/loginaction/autoLoad?isCommondto=true' + body_data = { + "commonDto": json.dumps([]) # 空数组,表示没有额外数据 + } + public_key_request = dock.new_http_request( + f"{govc_api.ApiUrl}{pub_key_url}", body_data, **govc_api.ProxyConfig + ) + request_queue = asyncio.Queue() + await request_queue.put(public_key_request) + await requests.async_concurrency( + request_queue, con_count=1, retry=dock.MAX_RETRY_COUNT, + after_request=after_request + ) + + return sm2_pub_key + + +def js_escape(s: str) -> str: + """模拟 JavaScript escape""" + result = [] + for ch in s: + code = ord(ch) + if (65 <= code <= 90) or (97 <= code <= 122) or (48 <= code <= 57): + result.append(ch) + elif code == 32: + result.append("%20") + elif code in [42, 45, 46, 47, 64, 95]: + result.append(ch) + elif code <= 0xFF: + result.append(f"%{code:02X}") + else: + result.append(f"%u{code:04X}") + return ''.join(result) + + +def unicode_escape_to_utf8(escaped: str) -> str: + """将 %uXXXX 转换成 UTF-8 的 %XX 形式""" + import re + def repl(m): + code = int(m.group(1), 16) + utf8_bytes = code.to_bytes((code.bit_length() + 7) // 8, 'big') + return ''.join(f'%{b:02X}' for b in utf8_bytes) + return re.sub(r'%u([0-9A-Fa-f]{4})', repl, escaped) + + +def sm2_encrypt(plain_text: str, public_key_hex: str) -> str: + escaped = js_escape(plain_text) + encoded = unicode_escape_to_utf8(escaped) + + # SM2 加密(模拟前端 sm2Encrypt 内部逻辑) + # 前端:CryptoJS.enc.Utf8.parse(encoded) → Base64.stringify → CryptoJS.enc.Utf8.parse → SM2 加密 + utf8_bytes = encoded.encode('utf-8') + base64_str = base64.b64encode(utf8_bytes).decode('ascii') + # 再次做 UTF-8 编码作为加密输入 + final_bytes = base64_str.encode('utf-8') + + # C1C3C2 加密 + sm2_crypt = sm2.CryptSM2(public_key=public_key_hex, private_key="") + encrypted = sm2_crypt.encrypt(final_bytes) + return '04' + encrypted.hex() + + +async def build_login_common_dto( + username: str, + password: str +) -> tuple[str, str]: + """ + 构造登录请求的 commonDto 参数(符合服务器要求) + + Args: + username: 用户名 + password: 密码 + + Returns: + (commonDto, cmdParams) 元组 + """ + # 获取公钥 + pub_key = await fetch_public_key() + if not pub_key: + raise Exception("获取 SM2 公钥失败") + + # 使用 SM2 加密 + encrypted_username = sm2_encrypt(username, pub_key) + encrypted_password = sm2_encrypt(password, pub_key) + + # # 读取验证码 + # verify_code = await read_verify_code() + + # 构造 commonDto + common_dto_data = [ + { + "id": "_common_hidden_viewdata", + "type": "hidden", + "value": "" + } + ] + + # 构造 cmdParams + # 格式: [加密用户名, 加密密码, loginType, false, verifyCodeRandom] + cmd_params = [ + encrypted_username, # 加密后的用户名 + encrypted_password, # 加密后的密码 + "0", # 固定值 + False, # 固定值 + f"#undefined #verifyCode", # 验证码随机串 + ] + + # 转为 JSON 字符串并将双引号替换为单引号 + common_dto_str = json.dumps(common_dto_data, separators=(',', ':')) + cmd_params_str = json.dumps(cmd_params, separators=(',', ':')) + + return common_dto_str, cmd_params_str + + +async def login(): + """ + 登录政务服务 12345 系统并获取认证 Token。 + + 流程: + 1. 从市12345平台获取公钥。 + 2. 模拟前端的编码和加密过程。 + 3. 提交请求完成登陆。 + + Args: + 无参数。 + + Returns: + tuple: 包含两个元素的元组: + - dict: DCM 接口返回的完整 JSON 响应数据 + + Raises: + AssertionError: 登录失败(`resultInfo.success` 为 False) + ValueError: 响应体非合法 JSON + HTTPError: 网络请求失败(由 `async_request` 抛出) + """ + login_url = f"{govc_api.ApiUrl}/rest/bmfw/bmfwlogin/loginaction/login?isCommondto=true" + + # 构建扩展头 + user_agent, browser_ver, os_name = dock.get_random_user_agent() + + extra_headers = { + 'Host': '2.46.12.176:8091', + 'Referer': 'http://2.46.12.176:8091/sz12345/bmfw/bmfwlogin/login', + 'User-Agent': user_agent, + 'X-Requested-With': 'XMLHttpRequest', + } + + # 构造 commonDto + common_dto, cmd_params = await build_login_common_dto( + config.get_config("dock.govc.account.username"), + config.get_config("dock.govc.account.password"), + ) + + # 构造请求 + request_body = { + "commonDto": common_dto, + "cmdParams": cmd_params, + } + + # 构造请求对象 + request = dock.new_http_request( + url=login_url, + body=request_body, + method='POST', + timeout=dock.DEFAULT_TIMEOUT, + use_form=True, + extra_headers=extra_headers, + **govc_api.ProxyConfig + ) + + async def after_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + cookies_data = dock.get_cookies(response) + cookies = "; ".join([f"{k}={v}" for k, v in cookies_data.items()]) + await first_login(cookies) + + queue = asyncio.Queue() + await queue.put(request) + await requests.async_concurrency( + queue, con_count=1, retry=dock.MAX_RETRY_COUNT, + after_request=after_request + ) + + +async def first_login(cookies: str): + grace_url = f"{govc_api.ApiUrl}/rest/szbmfw/szdesktop/szdeptindexaction/getIsFirstLogin?isCommondto=true" + + # 构建扩展头 + user_agent, browser_ver, os_name = dock.get_random_user_agent() + + extra_headers = { + 'Cookie': cookies, + 'Host': '2.46.12.176:8091', + 'Referer': 'http://2.46.12.176:8091/sz12345/bmfw/bmfwlogin/login', + 'User-Agent': user_agent, + 'X-Requested-With': 'XMLHttpRequest', + } + + async def after_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + # 读取 epoint_user_loginid + response_body = response.body.decode() + response_data = json.loads(response_body) + controls: list[dict] = response_data.get('controls', []) + epoint_user_loginid = json.loads(controls[0].get('value', '')).get('epoint_user_loginid', '') + # 读取并组合 cookies + cookies_data = dock.get_cookies(response) + full_cookies = "; ".join([f"{k}={v}" for k, v in cookies_data.items()]) + full_cookies = f"{full_cookies}; {cookies}" + # 组合 token + token = json.dumps( + { + 'epoint_user_loginid': epoint_user_loginid, + 'cookies': full_cookies, + }, + separators=(',', ':') + ) + await TokenModel.refresh(platform='GOVC', token=token) + echo_log(f"成功刷新市12345登录令牌.") + + grace_request = dock.new_http_request( + grace_url, {}, 'GET', + extra_headers=extra_headers, + **govc_api.ProxyConfig + ) + request_queue = asyncio.Queue() + await request_queue.put(grace_request) + await requests.async_concurrency( + request_queue, con_count=1, retry=dock.MAX_RETRY_COUNT, + after_request=after_request + ) + + +async def get_cookies(platform: str = 'GOVC'): + """ + 取得可用 Cookies。 + + :param platform: 要查询的平台,默认是:GOVC,市12345 + :return: epoint_user_loginid, cookies + """ + _token_str = await TokenModel.find_by_platform(platform) + _token = json.loads(_token_str.token) + return _token.get('epoint_user_loginid', ''), _token.get('cookies', '') + + +if __name__ == "__main__": + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(login()) diff --git a/dock/govs/__init__.py b/dock/govs/__init__.py new file mode 100644 index 0000000..9539358 --- /dev/null +++ b/dock/govs/__init__.py @@ -0,0 +1,3 @@ +""" +省12345对接模块。 +""" \ No newline at end of file diff --git a/dock/govs/__pycache__/__init__.cpython-311.pyc b/dock/govs/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..9972bce Binary files /dev/null and b/dock/govs/__pycache__/__init__.cpython-311.pyc differ diff --git a/dock/govs/__pycache__/govs_api.cpython-311.pyc b/dock/govs/__pycache__/govs_api.cpython-311.pyc new file mode 100644 index 0000000..89ee279 Binary files /dev/null and b/dock/govs/__pycache__/govs_api.cpython-311.pyc differ diff --git a/dock/govs/__pycache__/govs_scrape_order_detail.cpython-311.pyc b/dock/govs/__pycache__/govs_scrape_order_detail.cpython-311.pyc new file mode 100644 index 0000000..1bf2ba2 Binary files /dev/null and b/dock/govs/__pycache__/govs_scrape_order_detail.cpython-311.pyc differ diff --git a/dock/govs/__pycache__/govs_scrape_order_master.cpython-311.pyc b/dock/govs/__pycache__/govs_scrape_order_master.cpython-311.pyc new file mode 100644 index 0000000..af58e90 Binary files /dev/null and b/dock/govs/__pycache__/govs_scrape_order_master.cpython-311.pyc differ diff --git a/dock/govs/__pycache__/govs_scrape_order_process.cpython-311.pyc b/dock/govs/__pycache__/govs_scrape_order_process.cpython-311.pyc new file mode 100644 index 0000000..b61b8de Binary files /dev/null and b/dock/govs/__pycache__/govs_scrape_order_process.cpython-311.pyc differ diff --git a/dock/govs/__pycache__/govs_security.cpython-311.pyc b/dock/govs/__pycache__/govs_security.cpython-311.pyc new file mode 100644 index 0000000..d2a6d53 Binary files /dev/null and b/dock/govs/__pycache__/govs_security.cpython-311.pyc differ diff --git a/dock/govs/govs_api.py b/dock/govs/govs_api.py new file mode 100644 index 0000000..21d3bb3 --- /dev/null +++ b/dock/govs/govs_api.py @@ -0,0 +1,61 @@ +""" +省12345对接 API 基础功能。 +""" +from tornado.httpclient import AsyncHTTPClient + +import dock +from paste.core import config + +ApiUrl = "http://172.26.192.104/api" +""" +对接 API 根目录。 +""" + + +ProxyConfig = config.get_config('dock.govs.proxy') +""" +代理服务器配置。 +""" +if ProxyConfig and ProxyConfig.get('proxy_host', None) and ProxyConfig.get('proxy_port', None): + # 切换到底层实现,以便代理服务器生效 + AsyncHTTPClient.configure("tornado.curl_httpclient.CurlAsyncHTTPClient") + + +async def new_api_request(api_url: str, request_body: dict, method: str = 'POST', + timeout: float = dock.DEFAULT_TIMEOUT, use_form: bool = False, headers: dict = None): + """ + 构造一个 API 请求对象 + + :param api_url: API 地址,以斜杠开头的 URI 地址,非完整 URL + :param request_body: 请求体,即所有请求参数 + :param method: 请求提交方式 + :param timeout: 超时时长 + :param use_form: 是否使用表单(Form)方式提交 + :param headers: 头数据,最高优先级 + :return: HTTPRequest 对象 + """ + # Token + from dock.govs import govs_security + token = await govs_security.get_token() + + # 构建扩展头 + user_agent, browser_ver, os_name = dock.get_random_user_agent() + extra_headers = { + "Authorization": f"Bearer {token}", + 'Content-Type': 'application/json; charset=UTF-8', + 'User-Agent': user_agent, + } + if headers is not None: + extra_headers = {**extra_headers, **headers} + + # 构造请求对象 + request = dock.new_http_request( + url=f"{ApiUrl}{api_url}", + body=request_body, + method=method, + timeout=timeout, + use_form=use_form, + extra_headers=extra_headers, + ** ProxyConfig + ) + return request \ No newline at end of file diff --git a/dock/govs/govs_create_order_delay.py b/dock/govs/govs_create_order_delay.py new file mode 100644 index 0000000..e781830 --- /dev/null +++ b/dock/govs/govs_create_order_delay.py @@ -0,0 +1,113 @@ +import asyncio +import logging +import json + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import apps +from dock.govs import govs_api +from dock.oa import oa_result_notify, PushException +from models.govs_order_master import GovsOrderMaster +from models.govs_create_delay import GovsApplicationForDelay +from paste.core.logging import echo_log +from paste.web import requests + + +async def get_create_delay_request(govs_delay: GovsApplicationForDelay, govs_order: GovsOrderMaster): + """ + 创建申请延期请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param govs_delay: 申请延期对象 + :param govs_order: 工单对象 + :return: HTTPRequest 对象 + """ + + api_url = '/orderhandler/OrderDelayApply/createOrderDelay' + body = { + "finallyTimeAfterApprove": govs_delay.finally_time_after_approve, + "finallyTimeBeforeApprove": govs_delay.finally_time_before_approve, + "requestDelay": govs_delay.request_delay, + "isNatureDay": govs_delay.is_nature_day, + "alreadyNotifyOrderUser": govs_delay.already_notify_order_user, + "requestReason": govs_delay.request_reason, + "remarks": govs_delay.remarks, + "contactName": govs_delay.contact_name, + "contactTime": govs_delay.contact_time, + "contactType": govs_delay.contact_type, + "contactTypeName": govs_delay.contact_type_name, + "replyScript": govs_delay.reply_script, + "fileList": [], + "masterId": govs_order.master_id, + "orderNo": govs_order.order_no, + "processInstanceId": govs_order.process_instance_id, + "requestDelayTime": govs_delay.request_delay_time, + "id": "", + "orderId": govs_order.order_id + } + return await govs_api.new_api_request(api_url, body) + + +async def after_create_delay_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交省12345后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + echo_log(response.body.decode()) + echo_log('申请延期请求成功.') + + +async def create_delay(govs_delay: GovsApplicationForDelay, govs_order: GovsOrderMaster): + """ + 推送申请延期请求。 + + :param govs_delay: 保存在数据库的申请延期对象 + :param govs_order: 数据库中的工单对象 + """ + try: + delay_request = await get_create_delay_request(govs_delay, govs_order) + queue = asyncio.Queue() + await queue.put(delay_request) + # 仅生产环境真实提交,其他环境不实际提交 + if apps.get_active_env() not in ('dev', '', None): + delay_response_list = await requests.async_concurrency(queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, + after_request=after_create_delay_request) + # 检查申请延期响应是否成功 + if len(delay_response_list) != 1: + raise PushException("申请延期请求发生错误.", govs_delay.flow_token, 3) + return_response = delay_response_list[0] + return_response_data = return_response.body.decode() + return_response_data = json.loads(return_response_data) + if return_response_data.get('code') != 200: + raise PushException("申请延期请求发生错误.", govs_delay.flow_token, 3) + else: + echo_log(f"非生产环境,不实际提交.") + # 保存成功状态 + govs_delay.status = 1 + await govs_delay.async_save() + # 申请延期请求提交后,通知申请延期成功 + await oa_result_notify.push_result_notify( + govs_delay.flow_token, + '申请延期成功', + 1 + ) + except PushException as e: + # 任何异常都意味着失败,通知 OA + echo_log(f'申请延期发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + # 保存失败状态 + govs_delay.status = 0 + await govs_delay.async_save() + + # 申请延期发生异常,通知申请延期失败 + await oa_result_notify.push_result_notify( + e.flow_token, f"{e}", e.return_code + ) + except Exception as e: + # 其他异常都意味着失败,通知 OA + echo_log(f'申请延期发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) diff --git a/dock/govs/govs_create_order_return.py b/dock/govs/govs_create_order_return.py new file mode 100644 index 0000000..d01c3cf --- /dev/null +++ b/dock/govs/govs_create_order_return.py @@ -0,0 +1,301 @@ +import asyncio +import logging +import os +import io +import json +import time + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import apps +from dock.govs import govs_api, govs_upload_file +from dock.oa import oa_result_notify, oa_api_request, PushException +from models.govs_order_master import GovsOrderMaster +from models.govs_create_return import GovsWorkOrderReturnFormal +from paste.core.logging import echo_log +from paste.web import requests +from paste.util.ufile import inspect_type + + +async def get_create_return_request(govs_return: GovsWorkOrderReturnFormal, govs_order: GovsOrderMaster, + file_list: list = None): + """ + 创建申请退回请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param govs_return: 申请退回对象 + :param govs_order: 工单对象 + :param file_list: 已上传到省12345的文件列表数据 + :return: HTTPRequest 对象 + """ + + api_url = '/orderhandler/sendBackApply/saveSendBackApply' + body = { + "slaveDeptIsCompetent": [], + "duties": "", + "nextOrgIds": "", + "adviceMasterOrgName": "", + "adviceSlaveOrgNames": "", + "searchValueList": [], + "inforRetrieval": "", + "nextProcessing": "", + "assignedUnit": "", + "duration": "", + "dateChoose": "", + "plannedDuration": "", + "completionTime": "", + "returnAuditorName": govs_return.return_auditor_name, + 'returnAuditorId': govs_return.return_auditor_id, + "handlingSuggestion": "", + "remark": govs_return.remark, + "fileList": [], + "isContact": "是", + "contactName": "", + "contactTime": "", + "contactType": "", + "nextFeedbackTime": "", + "advice": "", + "answer": "", + "applyReason": "", + "applyBasis": "", + "platformOpinion": "", + "shortMessage": "", + "distributor": "", + "distributors": [], + "positionSelection": "", + "difficultReason": "", + "directCompletionType": "", + "applyType": "", + "returnReason": govs_return.return_reason_name, + "returnReasonName": govs_return.return_reason_name, + "replyResult": "", + "informPublic": "是", + "approveAttachmentIds": "", + "formalReply": "", + "flowMap": { + "nextHandleName": "工单退回", + "nextHandle": "工单退回" + }, + "adviceMasterOrgId": "", + "adviceSlaveOrgIdsList": [], + "id": "", + "key": "", + "nextHandler": "", + "nextOrgId": "", + "processInstanceId": govs_order.process_instance_id, + "reason": govs_return.reason, + "taskHandlerId": "", + "value": "", + "vote": "", + "assignedUnitList": [], + "assignedUnitLabel": "", + "nextOrgIdList": [], + "nextOrgIdStr": "", + "knowledgeQuote": "[]", + "defineAuditorId": "", + "defineAuditorName": "", + "visitTypes": " ", + "appeal1": "", + "appeal2": "", + "unreasonableDemands": "", + "complainant": "", + "pollutionType": "", + "pollutionType1": "", + "pollutionType2": "", + "involvedTargets": "", + "problemCategory": "生态环境类", + "defendantType": "", + "reportingPurpose": "投诉举报", + "industryType": "", + "industryType1": "", + "industryType2": "", + "complainants": [ + { + "complainant": "", + "region": "", + "street": "", + "detailedAddress": "", + "show": True, + "disabled": False + } + ], + "inforAddress": "", + "associatedDefendantType": "", + "adminLawEnf": "", + "approveResult": "", + "approveContent": "", + "nextOrgIdsName": [], + "noticeOrgId": "", + "completeType": "", + "caseAccordTypeOneName": govs_order.case_accord_type_one_name, + "caseAccordTypeTwoName": govs_order.case_accord_type_two_name, + "caseAccordTypeThreeName": govs_order.case_accord_type_three_name, + "caseAccordTypeFourName": "", + "caseAccordTypeFiveName": "", + "fileVos": [], + "dealOpinion": govs_return.deal_opinion, + "actionName": govs_return.action_name, + "orderId": govs_order.order_id, + "taskId": govs_order.next_task_id, + "submitType": "0", + "adviceSlaveOrgIds": "", + "masterId": str(govs_return.master_id), + "orderNo": govs_order.order_no, + } + if govs_return.return_auditor_name and file_list: + body['fileList'] = file_list + body['fileVos'] = file_list + return await govs_api.new_api_request(api_url, body) + + +async def after_create_return_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交省12345后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + echo_log(response.body.decode()) + echo_log('申请退回请求成功.') + + +async def done_file_download(response_list: list[HTTPResponse]): + """ + 所有附件下载完成执行的处理程序。 + + :param response_list: 附件下载响应列表 + :return: 返回附件字典列表,每个元素包含文件名和io对象 + """ + file_info_list = [] + for response in response_list: + file_type = inspect_type(response.body) + basename = os.path.basename(response.request.url) + file_io = io.BytesIO(response.body) + file_info_list.append({ + 'file_name': f'{basename}.{file_type}', + 'file_io': file_io + }) + return file_info_list + + +async def done_file_upload(response_list: list[HTTPResponse]): + """ + 文件上传完成后的处理程序 + + :param response_list: 附件上传响应列表 + :return: 返回上次后的文件信息列表,包含文件名、文件路径 + """ + uploaded_list = [] + for response in response_list: + response_body = response.body.decode() + response_data = json.loads(response_body) + if response_data['msg'] == '附件上传成功!': + uploaded_list.append({ + 'file_name': getattr(response.request, 'file_name', 'file.bin'), + 'path': response_data['data'] + }) + else: + echo_log(f'文件上传到省12345失败,{response_data}') + return uploaded_list + + +async def download_and_upload_files(file_id_str: str): + """ + 从OA下载文件,上传到省12345,返回上传后的文件信息列表 + + :param file_id_str: 英文逗号分隔的OA文件id + """ + file_id_list = file_id_str.strip(',').split(',') + download_queue = asyncio.Queue() + for file_id in file_id_list: + download_request = await oa_api_request.get_download_request(file_id) + await download_queue.put(download_request) + file_info_list = await requests.async_concurrency(download_queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, after_done=done_file_download) + upload_queue = asyncio.Queue() + for file_info in file_info_list: + upload_request = await govs_upload_file.get_upload_request(file_info['file_name'], file_info['file_io']) + setattr(upload_request, 'file_name', file_info['file_name']) + await upload_queue.put(upload_request) + uploaded_list = await requests.async_concurrency(upload_queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, after_done=done_file_upload) + return uploaded_list + + +async def create_return(govs_return: GovsWorkOrderReturnFormal, govs_order: GovsOrderMaster): + """ + 推送申请退回请求。 + + :param govs_return: 保存在数据库的申请退回对象 + :param govs_order: 数据库中的工单对象 + """ + try: + # 仅生产环境真实提交,其他环境不实际提交 + if apps.get_active_env() not in ('dev', '', None): + if govs_return.file_id_str: + # 根据OA传过来的文件id,下载并上传到省12345 + file_info_list = await download_and_upload_files(govs_return.file_id_str) + file_info_list = [{ + "name": info['file_name'], + "filePath": info['path'], + "orderId": govs_order.order_id, + "uid": int(time.time() * 1000), + "status": "success" + } for info in file_info_list] + else: + file_info_list = None + return_request = await get_create_return_request(govs_return, govs_order, file_info_list) + queue = asyncio.Queue() + await queue.put(return_request) + return_response_list = await requests.async_concurrency(queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, + after_request=after_create_return_request) + # 检查申请退回响应是否成功 + if len(return_response_list) != 1: + raise PushException("申请退回请求发生错误.", govs_return.flow_token, 3) + return_response = return_response_list[0] + return_response_data = return_response.body.decode() + return_response_data = json.loads(return_response_data) + if return_response_data.get('code') != 200 or '退回申请提交成功' not in return_response_data.get('data'): + raise PushException("申请退回请求发生错误.", govs_return.flow_token, 3) + else: + echo_log(f"非生产环境,不实际提交.") + # 保存成功状态 + govs_return.status = 1 + await govs_return.async_save() + # 申请退回请求提交后,通知申请退回成功 + await oa_result_notify.push_result_notify( + govs_return.flow_token, + '申请退回成功', + 1 + ) + except PushException as e: + # 任何异常都意味着失败,通知 OA + echo_log(f'申请退回发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + # 保存失败状态 + govs_return.status = 0 + await govs_return.async_save() + + # 申请退回发生异常,通知申请退回失败 + await oa_result_notify.push_result_notify( + e.flow_token, f"{e}", e.return_code + ) + except Exception as e: + # 其他异常都意味着失败,通知 OA + echo_log(f'申请退回发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + +if __name__ == '__main__': + async def push(): + task = await GovsOrderMaster.async_find_by_id(2060477579990339586) + return_request = await GovsWorkOrderReturnFormal.async_find_by_id(2061344445634318336) + await create_return(return_request, task) + + + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(push()) diff --git a/dock/govs/govs_create_reply.py b/dock/govs/govs_create_reply.py new file mode 100644 index 0000000..4d45453 --- /dev/null +++ b/dock/govs/govs_create_reply.py @@ -0,0 +1,306 @@ +import asyncio +import os +import io +import json +import time +import logging +from datetime import datetime + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import apps +from models.govs_create_reply import GovsReplyFormal +from models.govs_order_master import GovsOrderMaster +from dock.govs import govs_api, govs_upload_file +from dock.oa import oa_api_request, oa_result_notify, PushException +from paste.core.logging import echo_log +from paste.util.ufile import inspect_type +from paste.web import requests + + +async def get_reply_request(govs_reply: GovsReplyFormal, govs_order: GovsOrderMaster, file_list: list = None): + """ + 创建答复办结请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param govs_reply: 答复办结对象 + :param govs_order: 工单对象 + :param file_list: 已上传到省12345的文件列表数据 + :return: HTTPRequest 对象 + """ + api_url = '/workflow/approveTask/orderApprove' + # 默认不加前缀 + prefix = "" + + # 只有 contact_name 和 contact_time 都存在时才拼接前缀 + if govs_reply.contact_name and govs_reply.contact_time: + # 格式化时间 + try: + dt = datetime.fromisoformat(govs_reply.contact_time.replace('Z', '+00:00')) + formatted_contact_time = dt.strftime('%Y-%m-%d %H:%M') + except Exception: + formatted_contact_time = govs_reply.contact_time + prefix = f"您好,{govs_reply.contact_name}于{formatted_contact_time}通过{govs_reply.contact_type}方式联系您;" + body = { + "slaveDeptIsCompetent": [], + "duties": "", + "nextOrgIds": "", + "adviceMasterOrgName": "", + "adviceSlaveOrgNames": "", + "searchValueList": [], + "inforRetrieval": "", + "nextProcessing": "", + "assignedUnit": "", + "duration": "", + "dateChoose": "", + "plannedDuration": "", + "completionTime": "", + "returnAuditorName": "", + "handlingSuggestion": "", + "remark": govs_reply.remarks, + "fileList": file_list, + "isContact": "是", + "contactName": govs_reply.contact_name, + "contactTime": govs_reply.contact_time, + "contactType": govs_reply.contact_type, + "nextFeedbackTime": "", + "advice": prefix + (govs_reply.advice or ""), + "answer": "", + "applyReason": "", + "applyBasis": "", + "platformOpinion": "", + "shortMessage": "", + "distributor": "", + "distributors": [], + "positionSelection": "", + "difficultReason": "", + "directCompletionType": "", + "applyType": "", + "returnReason": "", + "returnReasonName": "", + "replyResult": "", + "informPublic": govs_reply.is_contact, # 这个还要确认一遍 + "approveAttachmentIds": "", + "formalReply": "", + "flowMap": { + "nextHandleName": "答复办结", + "nextHandle": "答复办结" + }, + "adviceMasterOrgId": "", + "adviceSlaveOrgIdsList": [], + "id": govs_order.next_task_id, + "key": "", + "nextHandler": "", + "nextOrgId": "", + "processInstanceId": govs_order.process_instance_id, + "reason": prefix + (govs_reply.reason or ""), + "taskHandlerId": "", + "value": "", + "vote": "", + "assignedUnitList": [], + "assignedUnitLabel": "", + "nextOrgIdList": [], + "nextOrgIdStr": "", + "knowledgeQuote": "[]", + "defineAuditorId": "", + "defineAuditorName": "", + "visitTypes": " ", + "appeal1": "", + "appeal2": "", + "unreasonableDemands": "", + "complainant": "", + "pollutionType": "-", + "pollutionType1": "", + "pollutionType2": "", + "involvedTargets": "", + "problemCategory": "生态环境类", + "defendantType": "", + "reportingPurpose": "投诉举报", + "industryType": "-", + "industryType1": "", + "industryType2": "", + "complainants": [ + { + "complainant": "", + "region": "", + "street": "", + "detailedAddress": "", + "show": True, + "disabled": False + } + ], + "inforAddress": "", + "associatedDefendantType": "", + "adminLawEnf": "", + "approveResult": "", + "approveContent": "", + "nextOrgIdsName": [], + "noticeOrgId": "", + "completeType": "", + "caseAccordTypeOneName": govs_order.case_accord_type_one_name, + "caseAccordTypeTwoName": govs_order.case_accord_type_two_name, + "caseAccordTypeThreeName": govs_order.case_accord_type_three_name, + "caseAccordTypeFourName": "", + "caseAccordTypeFiveName": "", + "fileVos": file_list, + "reasonableLabels": "-", + "visitType": "", + "actionName": govs_reply.action_name, + "businessKey": govs_order.order_id, + "masterId": govs_reply.master_id, + "orderNo": govs_order.order_no + } + return await govs_api.new_api_request(api_url, body) + + +async def after_create_reply_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交省12345后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + echo_log(response.body.decode()) + echo_log('答复办结请求成功.') + + +async def done_file_download(response_list: list[HTTPResponse]): + """ + 所有附件下载完成执行的处理程序。 + + :param response_list: 附件下载响应列表 + :return: 返回附件字典列表,每个元素包含文件名和io对象 + """ + file_info_list = [] + for response in response_list: + file_type = inspect_type(response.body) + basename = os.path.basename(response.request.url) + file_io = io.BytesIO(response.body) + file_info_list.append({ + 'file_name': f'{basename}.{file_type}', + 'file_io': file_io + }) + return file_info_list + + +async def done_file_upload(response_list: list[HTTPResponse]): + """ + 文件上传完成后的处理程序 + + :param response_list: 附件上传响应列表 + :return: 返回上次后的文件信息列表,包含文件名、文件路径 + """ + uploaded_list = [] + for response in response_list: + response_body = response.body.decode() + response_data = json.loads(response_body) + if response_data['msg'] == '附件上传成功!': + uploaded_list.append({ + 'file_name': getattr(response.request, 'file_name', 'file.bin'), + 'path': response_data['data'] + }) + else: + echo_log(f'文件上传到省12345失败,{response_data}') + return uploaded_list + + +async def download_and_upload_files(file_id_str: str): + """ + 从OA下载文件,上传到省12345,返回上传后的文件信息列表 + + :param file_id_str: 英文逗号分隔的OA文件id + """ + file_id_list = file_id_str.strip(',').split(',') + download_queue = asyncio.Queue() + for file_id in file_id_list: + download_request = await oa_api_request.get_download_request(file_id) + await download_queue.put(download_request) + file_info_list = await requests.async_concurrency(download_queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, after_done=done_file_download) + upload_queue = asyncio.Queue() + for file_info in file_info_list: + upload_request = await govs_upload_file.get_upload_request(file_info['file_name'], file_info['file_io']) + setattr(upload_request, 'file_name', file_info['file_name']) + await upload_queue.put(upload_request) + uploaded_list = await requests.async_concurrency(upload_queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, after_done=done_file_upload) + return uploaded_list + + +async def create_reply(govs_reply: GovsReplyFormal, govs_order: GovsOrderMaster): + """ + 推送答复办结请求。 + + :param govs_reply: 保存在数据库的答复办结对象 + :param govs_order: 数据库中的工单对象 + """ + try: + # 仅生产环境真实提交,其他环境不实际提交 + if apps.get_active_env() not in ('dev', '', None): + if govs_reply.file_id_str: + # 根据OA传过来的文件id,下载并上传到省12345 + file_info_list = await download_and_upload_files(govs_reply.file_id_str) + file_info_list = [{ + "name": info['file_name'], + "filePath": info['path'], + "orderId": govs_order.order_id, + "uid": int(time.time() * 1000), + "status": "success" + } for info in file_info_list] + else: + file_info_list = None + reply_request = await get_reply_request(govs_reply, govs_order, file_info_list) + queue = asyncio.Queue() + await queue.put(reply_request) + reply_response_list = await requests.async_concurrency(queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, + after_request=after_create_reply_request) + # 检查答复办结响应是否成功 + if len(reply_response_list) != 1: + raise PushException("答复办结请求发生错误.", govs_reply.flow_token, 3) + return_response = reply_response_list[0] + return_response_data = return_response.body.decode() + return_response_data = json.loads(return_response_data) + if return_response_data.get('code') != 200 or return_response_data.get('data') != 'ok': + raise PushException("答复办结请求发生错误.", govs_reply.flow_token, 3) + else: + echo_log(f"非生产环境,不实际提交.") + # 保存成功状态 + govs_reply.status = 1 + await govs_reply.async_save() + # 答复办结请求提交后,通知答复办结成功 + await oa_result_notify.push_result_notify( + govs_reply.flow_token, + '答复办结成功', + 1 + ) + except PushException as e: + # 任何异常都意味着失败,通知 OA + echo_log(f'答复办结发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + # 保存失败状态 + govs_reply.status = 0 + await govs_reply.async_save() + + # 答复办结发生异常,通知答复办结失败 + await oa_result_notify.push_result_notify( + e.flow_token, f"{e}", e.return_code + ) + except Exception as e: + # 其他异常都意味着失败 + echo_log(f'答复办结发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + +if __name__ == '__main__': + async def push(): + task = await GovsOrderMaster.async_find_by_id(2060173985047351297) + reply = await GovsReplyFormal.async_find_by_id(2061328980866371584) + await create_reply(reply, task) + + + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(push()) diff --git a/dock/govs/govs_download_file.py b/dock/govs/govs_download_file.py new file mode 100644 index 0000000..1c5794e --- /dev/null +++ b/dock/govs/govs_download_file.py @@ -0,0 +1,22 @@ +from typing import Union +import base64 +from urllib.parse import quote, urlencode +from dock.govs import govs_api + + +async def get_download_request(tenant_id: Union[int, str], file_url: str): + """ + 创建从省12345下载文件的请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param tenant_id: 租户id + :param file_url: 文件url + """ + api_url = '/file/api/system/downloadPermission' + b64_file_url = base64.b64encode(file_url.encode()).decode() + b64_file_url = quote(b64_file_url, safe="~*'()!.-_") + body = { + 'tenantId': tenant_id, + 'fileUrl': b64_file_url + } + api_url += f'?{urlencode(body)}' + return await govs_api.new_api_request(api_url, {}) diff --git a/dock/govs/govs_phase_wise_completion.py b/dock/govs/govs_phase_wise_completion.py new file mode 100644 index 0000000..af11dff --- /dev/null +++ b/dock/govs/govs_phase_wise_completion.py @@ -0,0 +1,281 @@ +import asyncio +import os +import io +import json +import time +import logging + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import apps +from models.govs_phase_wise_completion import GovsPhaseWiseCompletion +from models.govs_order_master import GovsOrderMaster +from dock.govs import govs_api, govs_upload_file +from dock.oa import oa_api_request, oa_result_notify, PushException +from paste.core.logging import echo_log +from paste.util.ufile import inspect_type +from paste.web import requests + + +async def get_phase_request(phase_wise_completion: GovsPhaseWiseCompletion, govs_order: GovsOrderMaster, + file_list: list = None): + """ + 创建阶段性办结请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param phase_wise_completion: 阶段性办结对象 + :param govs_order: 工单对象 + :param file_list: 已上传到省12345的文件列表数据 + :return: HTTPRequest 对象 + """ + api_url = '/orderhandler/remAndSup/savePeriodicCompletion' + body = { + "slaveDeptIsCompetent": [], + "duties": "", + "nextOrgIds": "", + "adviceMasterOrgName": "", + "adviceSlaveOrgNames": "", + "searchValueList": [], + "inforRetrieval": "", + "nextProcessing": "", + "assignedUnit": "", + "duration": "", + "dateChoose": "", + "plannedDuration": "", + "completionTime": "", + "returnAuditorName": "", + "handlingSuggestion": "", + "remark": phase_wise_completion.remark, + "fileList": file_list, + "isContact": phase_wise_completion.is_contact, + "contactName": phase_wise_completion.contact_name, + "contactTime": phase_wise_completion.contact_time, + "contactType": phase_wise_completion.contact_type, + "nextFeedbackTime": phase_wise_completion.next_feedback_time, + "advice": phase_wise_completion.advice, + "answer": "", + "applyReason": "", + "applyBasis": "", + "platformOpinion": "", + "shortMessage": "", + "distributor": "", + "distributors": [], + "positionSelection": "", + "difficultReason": "", + "directCompletionType": "", + "applyType": "", + "returnReason": "", + "returnReasonName": "", + "replyResult": "", + "informPublic": "是", + "approveAttachmentIds": "", + "formalReply": "", + "flowMap": { + "nextHandleName": "阶段性办结", + "nextHandle": "阶段性办结" + }, + "adviceMasterOrgId": "", + "adviceSlaveOrgIdsList": [], + "id": "", + "key": "", + "nextHandler": "", + "nextOrgId": "", + "processInstanceId": govs_order.process_instance_id, + "reason": phase_wise_completion.reason, + "taskHandlerId": "", + "value": "", + "vote": "", + "assignedUnitList": [], + "assignedUnitLabel": "", + "nextOrgIdList": [], + "nextOrgIdStr": "", + "knowledgeQuote": "[]", + "defineAuditorId": "", + "defineAuditorName": "", + "visitTypes": " ", + "appeal1": "", + "appeal2": "", + "unreasonableDemands": "", + "complainant": "", + "pollutionType": "", + "pollutionType1": "", + "pollutionType2": "", + "involvedTargets": "", + "problemCategory": "生态环境类", + "defendantType": "", + "reportingPurpose": "投诉举报", + "industryType": "", + "industryType1": "", + "industryType2": "", + "complainants": [ + { + "complainant": "", + "region": "", + "street": "", + "detailedAddress": "", + "show": True, + "disabled": False + } + ], + "inforAddress": "", + "associatedDefendantType": "", + "adminLawEnf": "", + "approveResult": "", + "approveContent": "", + "nextOrgIdsName": [], + "noticeOrgId": "", + "completeType": "", + "caseAccordTypeOneName": govs_order.case_accord_type_one_name, + "caseAccordTypeTwoName": govs_order.case_accord_type_two_name, + "caseAccordTypeThreeName": govs_order.case_accord_type_three_name, + "caseAccordTypeFourName": "", + "caseAccordTypeFiveName": "", + "fileVos": file_list, + "actionName": phase_wise_completion.action_name, + "orderId": govs_order.order_id, + "taskId": govs_order.next_task_id, + "submitType": "0", + "masterId": phase_wise_completion.master_id, + "orderNo": govs_order.order_no + } + return await govs_api.new_api_request(api_url, body) + + +async def after_phase_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交省12345后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + echo_log(response.body.decode()) + echo_log('阶段性办结请求成功.') + + +async def done_file_download(response_list: list[HTTPResponse]): + """ + 所有附件下载完成执行的处理程序。 + + :param response_list: 附件下载响应列表 + :return: 返回附件字典列表,每个元素包含文件名和io对象 + """ + file_info_list = [] + for response in response_list: + file_type = inspect_type(response.body) + basename = os.path.basename(response.request.url) + file_io = io.BytesIO(response.body) + file_info_list.append({ + 'file_name': f'{basename}.{file_type}', + 'file_io': file_io + }) + return file_info_list + + +async def done_file_upload(response_list: list[HTTPResponse]): + """ + 文件上传完成后的处理程序 + + :param response_list: 附件上传响应列表 + :return: 返回上次后的文件信息列表,包含文件名、文件路径 + """ + uploaded_list = [] + for response in response_list: + response_body = response.body.decode() + response_data = json.loads(response_body) + if response_data['msg'] == '附件上传成功!': + uploaded_list.append({ + 'file_name': getattr(response.request, 'file_name', 'file.bin'), + 'path': response_data['data'] + }) + else: + echo_log(f'文件上传到省12345失败,{response_data}') + return uploaded_list + + +async def download_and_upload_files(file_id_str: str): + """ + 从OA下载文件,上传到省12345,返回上传后的文件信息列表 + + :param file_id_str: 英文逗号分隔的OA文件id + """ + file_id_list = file_id_str.strip(',').split(',') + download_queue = asyncio.Queue() + for file_id in file_id_list: + download_request = await oa_api_request.get_download_request(file_id) + await download_queue.put(download_request) + file_info_list = await requests.async_concurrency(download_queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, after_done=done_file_download) + upload_queue = asyncio.Queue() + for file_info in file_info_list: + upload_request = await govs_upload_file.get_upload_request(file_info['file_name'], file_info['file_io']) + setattr(upload_request, 'file_name', file_info['file_name']) + await upload_queue.put(upload_request) + uploaded_list = await requests.async_concurrency(upload_queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, after_done=done_file_upload) + return uploaded_list + + +async def create_phase_wise_completion(phase_wise_completion: GovsPhaseWiseCompletion, govs_order: GovsOrderMaster): + """ + 推送阶段性办结请求。 + + :param phase_wise_completion: 保存在数据库的阶段性办结对象 + :param govs_order: 数据库中的工单对象 + """ + try: + # 仅生产环境真实提交,其他环境不实际提交 + if apps.get_active_env() not in ('dev', '', None): + if phase_wise_completion.file_id_str: + # 根据OA传过来的文件id,下载并上传到省12345 + file_info_list = await download_and_upload_files(phase_wise_completion.file_id_str) + file_info_list = [{ + "name": info['file_name'], + "filePath": info['path'], + "orderId": govs_order.order_id, + "uid": int(time.time() * 1000), + "status": "success" + } for info in file_info_list] + else: + file_info_list = None + phase_request = await get_phase_request(phase_wise_completion, govs_order, file_info_list) + queue = asyncio.Queue() + await queue.put(phase_request) + phase_response_list = await requests.async_concurrency(queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, + after_request=after_phase_request) + # 检查阶段性办结响应是否成功 + if len(phase_response_list) != 1: + raise PushException("阶段性办结请求发生错误.", phase_wise_completion.flow_token, 3) + return_response = phase_response_list[0] + return_response_data = return_response.body.decode() + return_response_data = json.loads(return_response_data) + if return_response_data.get('code') != 200: + raise PushException("阶段性办结请求发生错误.", phase_wise_completion.flow_token, 3) + else: + echo_log(f"非生产环境,不实际提交.") + # 保存成功状态 + phase_wise_completion.status = 1 + await phase_wise_completion.async_save() + # 阶段性办结请求提交后,通知阶段性办结成功 + await oa_result_notify.push_result_notify( + phase_wise_completion.flow_token, + '阶段性办结成功', + 1 + ) + except PushException as e: + # 任何异常都意味着失败,通知 OA + echo_log(f'阶段性办结发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + # 保存失败状态 + phase_wise_completion.status = 0 + await phase_wise_completion.async_save() + + # 阶段性办结发生异常,通知阶段性办结失败 + await oa_result_notify.push_result_notify( + e.flow_token, f"{e}", e.return_code + ) + except Exception as e: + # 其他异常都意味着失败 + echo_log(f'阶段性办结发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) diff --git a/dock/govs/govs_save_sign.py b/dock/govs/govs_save_sign.py new file mode 100644 index 0000000..7d739d9 --- /dev/null +++ b/dock/govs/govs_save_sign.py @@ -0,0 +1,123 @@ +import asyncio +import logging + +from tornado.httpclient import HTTPResponse, HTTPRequest +from sqlalchemy import select + +import dock +import apps +from dock.govs import govs_api +from dock.oa import oa_result_notify, PushException +from models.govs_order_master import GovsOrderMaster +from models.govs_save_sign import GovsSaveSign +from paste.core.logging import echo_log +from paste.web import requests + + +async def get_sign_request(govs_order: GovsOrderMaster): + """ + 创建省12345上工单确认签收的请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param govs_order: 工单对象 + :return: HTTPRequest 对象 + """ + api_url = '/orderhandler/claimTask/claimTask' + body = { + "orderId": govs_order.order_id, + "orderNo": govs_order.order_no, + "masterId": govs_order.master_id, + "orderProcessId": govs_order.id, + "taskId": govs_order.next_task_id, + "flag": "签收" + } + return await govs_api.new_api_request(api_url, body) + + +async def after_sign_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 提交省12345后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + echo_log(response.body.decode()) + govs_order = getattr(response.request, 'govs_order', None) + if govs_order: + govs_order.govs_sign = 1 + await govs_order.async_save() + echo_log('省12345确认签收请求成功.') + + +async def sign_order(govs_sign: GovsSaveSign, govs_order: GovsOrderMaster): + """ + 推送工单确认签收请求。 + + :param govs_sign: 保存在数据库的工单签收对象 + :param govs_order: 数据库中的工单对象 + """ + try: + sign_request = await get_sign_request(govs_order) + queue = asyncio.Queue() + setattr(sign_request, 'govs_order', govs_order) + await queue.put(sign_request) + # 仅生产环境真实提交,其他环境不实际提交 + if apps.get_active_env() not in ('dev', '', None): + sign_response_list = await requests.async_concurrency(queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, + after_request=after_sign_request) + # 检查工单签收响应是否成功 + if len(sign_response_list) != 1: + raise PushException("工单签收请求发生错误.", govs_sign.flow_token, 3) + else: + echo_log(f"非生产环境,不实际提交.") + # 保存成功状态 + govs_sign.status = 1 + await govs_sign.async_save() + # 工单签收请求提交后,通知工单签收成功 + await oa_result_notify.push_result_notify( + govs_sign.flow_token, + '工单签收成功', + 1 + ) + except PushException as e: + # 任何异常都意味着失败,通知 OA + echo_log(f'工单签收发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + # 保存失败状态 + govs_sign.status = 0 + await govs_sign.async_save() + + # 工单签收发生异常,通知工单签收失败 + await oa_result_notify.push_result_notify( + e.flow_token, f"{e}", e.return_code + ) + except Exception as e: + # 其他异常都意味着失败 + echo_log(f'工单签收发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) + + +async def sign_order_bypass_api(task_id_list: list): + """ + 不经过工单确认签收的api接口,签收指定的工单 + + :param task_id_list: 工单id列表 + """ + try: + query = select(GovsOrderMaster).where(GovsOrderMaster.id.in_(task_id_list)) + govs_orders = await GovsOrderMaster.orm_execute(query) + sign_queue = asyncio.Queue() + for row in govs_orders.all(): + sign_request = await get_sign_request(row[0]) + setattr(sign_request, 'govs_order', row[0]) + await sign_queue.put(sign_request) + # 仅生产环境真实提交,其他环境不实际提交 + if apps.get_active_env() in ('dev', '', None): + echo_log(f"非生产环境,不实际提交.") + return + await requests.async_concurrency(sign_queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, after_request=after_sign_request) + except Exception as e: + echo_log(f'签收工单发生错误.', logging.ERROR) + echo_log(e, logging.ERROR, is_log_exc=True) diff --git a/dock/govs/govs_scrape.py b/dock/govs/govs_scrape.py new file mode 100644 index 0000000..5274ef7 --- /dev/null +++ b/dock/govs/govs_scrape.py @@ -0,0 +1,106 @@ +""" +数据抓取模块。 +""" +import asyncio +from typing import Optional, Union + +from sqlalchemy import select, desc + +import dock +from dock.govs import govs_scrape_order_master, govs_scrape_order_detail, govs_scrape_order_process +from models.govs_order_master import GovsOrderMaster +from paste.core.logging import echo_log +from paste.web import requests + + +async def fetch_govs_task(dept_page_tag: int = 1, num_per_page: int = 60, task_id: Optional[Union[str, int]] = None): + """ + 抓取待办数据及其明细数据。 + + :param num_per_page: 读取多少任务进行明细抓取 + :param dept_page_tag: 0代表全部工单,1代表待签收工单,2代表待交办工单 + :param task_id: 可选的指定的工单id + """ + echo_log(f"开始抓取待办数据...") + task_request = await govs_scrape_order_master.get_task_request( + dept_page_tag=dept_page_tag, num_per_page=num_per_page + ) + request_queue = asyncio.Queue() + await request_queue.put(task_request) + await requests.async_concurrency( + request_queue, retry=dock.MAX_RETRY_COUNT, + after_request=govs_scrape_order_master.after_task_request + ) + echo_log(f"待办数据抓取完成...") + + # 读取任务数据,以便能对最新数据抓取详细数据 + query = select( + GovsOrderMaster.id, GovsOrderMaster.order_id, GovsOrderMaster.order_no, GovsOrderMaster.tenant_id, + GovsOrderMaster.master_id, GovsOrderMaster.area_code + ).order_by( + desc(GovsOrderMaster.id) + ) + # 如果dept_page_tag=1,只抓取待签收的,如果dept_page_tag不是0或者1,只抓取已签收的,针对性抓取特定状态的工单数据 + if dept_page_tag == 1: + query = query.where(GovsOrderMaster.govs_sign == 0) + elif dept_page_tag != 0: + query = query.where(GovsOrderMaster.govs_sign == 1) + if task_id: + if isinstance(task_id, list): + query = query.where(GovsOrderMaster.id.in_(task_id)) + echo_log(f"开始抓取待办列表:{task_id} 的详细数据...") + else: + query = query.where(GovsOrderMaster.id == task_id) + echo_log(f"开始抓取待办:{task_id} 的详细数据...") + else: + echo_log(f"开始抓取前 {num_per_page} 条待办的详细数据...") + query = query.limit(num_per_page) + task_df = await GovsOrderMaster.query_as_df(query) + + # 构建请求队列 + detail_queue = asyncio.Queue() + process_queue = asyncio.Queue() + # 向队列中填充请求对象 + echo_log(f"正在准备请求队列...") + for _h, _row in task_df.iterrows(): + order_id = _row.get(GovsOrderMaster.order_id.key) + order_no = _row.get(GovsOrderMaster.order_no.key) + tenant_id = int(_row.get(GovsOrderMaster.tenant_id.key)) + master_id = int(_row.get(GovsOrderMaster.master_id.key)) + area_code = _row.get(GovsOrderMaster.area_code.key) + + _detail_request = await govs_scrape_order_detail.get_task_request(order_id, master_id, tenant_id) + setattr(_detail_request, 'order_id', order_id) + setattr(_detail_request, 'order_no', order_no) + setattr(_detail_request, 'master_id', master_id) + setattr(_detail_request, 'tenant_id', tenant_id) + await detail_queue.put(_detail_request) + + _process_request = await govs_scrape_order_process.get_task_request( + order_id, order_no, master_id, tenant_id, '1700467981117980074', area_code + ) + setattr(_process_request, 'order_id', order_id) + setattr(_process_request, 'order_no', order_no) + setattr(_process_request, 'master_id', master_id) + setattr(_process_request, 'tenant_id', tenant_id) + await process_queue.put(_process_request) + + echo_log(f"抓取待办详细数据...") + tasks = [ + requests.async_concurrency( + detail_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=govs_scrape_order_detail.after_task_request + ), + requests.async_concurrency( + process_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=govs_scrape_order_process.after_task_request + ) + ] + await asyncio.gather(*tasks) + + +if __name__ == "__main__": + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(fetch_govs_task(dept_page_tag=1, num_per_page=50)) diff --git a/dock/govs/govs_scrape_order_detail.py b/dock/govs/govs_scrape_order_detail.py new file mode 100644 index 0000000..fa63bca --- /dev/null +++ b/dock/govs/govs_scrape_order_detail.py @@ -0,0 +1,159 @@ +import asyncio +import json +from typing import Union + +import pandas as pd +from dateutil import parser +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import models +from dock.govs import govs_api +from models.govs_order_attachment import GovsOrderAttachment +from models.govs_order_detail import GovsOrderDetail +from models.govs_order_user import GovsOrderUser +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + + +async def get_task_request(order_id: str, master_id: Union[str, int], tenant_id: Union[str, int]): + """ + 获取省12345任务详情数据。 + + 通过 POST 请求向省12345的任务详情接口提交表单数据,获取任务详情数据。 + 自动注入有效的 Cookie(如 JSESSIONID)至请求头,并解析返回的 JSON 响应。 + + Args: + order_id (str): 待办任务ID + master_id (int): 关联订单主表ID + tenant_id (int): 租户ID + """ + api_url = f"/orderreceive/orderMaster/queryOrderDetail" + request_body = { + "orderId": order_id, + "masterId": master_id, + "tenantId": tenant_id + } + # 构造 API 请求 + return await govs_api.new_api_request(api_url, request_body) + + +async def after_task_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + order_id = getattr(response.request, 'order_id') + order_no = getattr(response.request, 'order_no') + master_id = getattr(response.request, 'master_id') + tenant_id = getattr(response.request, 'tenant_id') + + response_body = response.body.decode() + response_data = json.loads(response_body) + order_detail_data = udict.get_by_path(response_data, 'result') + mapped_df = pd.DataFrame([order_detail_data]) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovsOrderDetail.FieldMapping.items()} + mapped_df = mapped_df.rename(columns=forward_mapping) + # 把数组和字典转换为json字符串 + mapped_df[GovsOrderDetail.order_custom_form_fields.key] = mapped_df[ + GovsOrderDetail.order_custom_form_fields.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if x is not None else None + ) + mapped_df[GovsOrderDetail.order_phone_dto.key] = mapped_df[GovsOrderDetail.order_phone_dto.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if x is not None else None + ) + mapped_df[GovsOrderDetail.order_user.key] = mapped_df[GovsOrderDetail.order_user.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if x is not None else None + ) + mapped_df[GovsOrderDetail.order_attachment_list.key] = mapped_df[GovsOrderDetail.order_attachment_list.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if x is not None else None + ) + mapped_df[GovsOrderDetail.pre_process_list.key] = mapped_df[GovsOrderDetail.pre_process_list.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if x is not None else None + ) + mapped_df[GovsOrderDetail.tripartite_call_records_list.key] = mapped_df[ + GovsOrderDetail.tripartite_call_records.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if x is not None else None + ) + mapped_df[GovsOrderDetail.plan_finish_time.key] = mapped_df[GovsOrderDetail.plan_finish_time.key].apply( + lambda x: parser.parse(x).strftime('%Y-%m-%d %H:%M:%S') if isinstance(x, str) and x.strip() else None + ) + mapped_df[GovsOrderDetail.order_finish_time.key] = mapped_df[GovsOrderDetail.order_finish_time.key].apply( + lambda x: parser.parse(x).strftime('%Y-%m-%d %H:%M:%S') if isinstance(x, str) and x.strip() else None + ) + mapped_df[GovsOrderDetail.plan_sign_time.key] = mapped_df[GovsOrderDetail.plan_sign_time.key].apply( + lambda x: parser.parse(x).strftime('%Y-%m-%d %H:%M:%S') if isinstance(x, str) and x.strip() else None + ) + + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + _created, _updated = await GovsOrderDetail.save_batch(mapped_df) + + # 存储用户信息 + user_data = udict.get_by_path(response_data, 'result.orderUser') + if user_data: + user_df = pd.DataFrame([user_data]) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovsOrderUser.FieldMapping.items()} + user_mapped_df = user_df.rename(columns=forward_mapping) + # 比较字段转字符串 + user_mapped_df[GovsOrderUser.id.key] = user_mapped_df[GovsOrderUser.id.key].astype(str) + user_mapped_df[GovsOrderUser.master_id.key] = user_mapped_df[GovsOrderUser.master_id.key].astype(str) + # 转换日期时间 + user_mapped_df[GovsOrderUser.created_at.key] = user_mapped_df[GovsOrderUser.created_at.key].apply( + lambda x: parser.parse(x).strftime('%Y-%m-%d %H:%M:%S') if isinstance(x, str) and x.strip() else None + ) + user_mapped_df[GovsOrderUser.updated_at.key] = user_mapped_df[GovsOrderUser.updated_at.key].apply( + lambda x: parser.parse(x).strftime('%Y-%m-%d %H:%M:%S') if isinstance(x, str) and x.strip() else None + ) + # 这里把空数据都换成 None,以便存入数据库时是 null + user_mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + await GovsOrderUser.save_batch(user_mapped_df) + + # 存储附件信息 + attachment_list = udict.get_by_path(response_data, 'result.orderAttachmentList') + if attachment_list: + attachment_df = pd.DataFrame(attachment_list) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovsOrderAttachment.FieldMapping.items()} + attachment_mapped_df = attachment_df.rename(columns=forward_mapping) + attachment_mapped_df[GovsOrderAttachment.master_id.key] = master_id + attachment_mapped_df[GovsOrderAttachment.order_id.key] = order_id + # 比较字段转字符串 + attachment_mapped_df[GovsOrderAttachment.id.key] = attachment_mapped_df[GovsOrderAttachment.id.key].astype(str) + attachment_mapped_df[GovsOrderAttachment.master_id.key] = attachment_mapped_df[GovsOrderAttachment.master_id.key].astype(str) + # 这里把空数据都换成 None,以便存入数据库时是 null + attachment_mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + await GovsOrderAttachment.save_batch(attachment_mapped_df) + + # 输出数据创建状态 + echo_log(f"成功创建租户:{tenant_id} 的待办工单:{master_id}({order_id},{order_no}) 详情.") + if retry_queue: + echo_log(f"待办工单详情重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +if __name__ == "__main__": + from paste.core import aio_pool + + + async def scrape(order_id: Union[str, int], master_id: Union[str, int], tenant_id: Union[str, int], + order_no: Union[str, int], ): + task_request = await get_task_request(order_id, master_id, tenant_id) + setattr(task_request, 'order_id', order_id) + setattr(task_request, 'master_id', master_id) + setattr(task_request, 'tenant_id', tenant_id) + setattr(task_request, 'order_no', order_no) + request_queue = asyncio.Queue() + await request_queue.put(task_request) + await requests.async_concurrency( + request_queue, retry=dock.MAX_RETRY_COUNT, + after_request=after_task_request + ) + + + _runner = aio_pool.get_aio_runner() + _runner(scrape('DH050826052517663', '2058851271599333378', '1773611023340371969', 'DH050826052517663*3')) diff --git a/dock/govs/govs_scrape_order_master.py b/dock/govs/govs_scrape_order_master.py new file mode 100644 index 0000000..9c15c44 --- /dev/null +++ b/dock/govs/govs_scrape_order_master.py @@ -0,0 +1,156 @@ +import asyncio +import json +from typing import Union + +import numpy as np +import pandas as pd +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import models +from dock.govs import govs_api +from models.govs_order_master import GovsOrderMaster +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + + +async def get_task_request(order_id: Union[str, int] = '', dept_page_tag: int = 1, + current_page: int = 1, num_per_page: int = 200): + """ + 获取省12345任务列表数据。 + 通过 POST 请求向省12345的任务列表接口提交表单数据,获取任务分页数据。 + + Args: + order_id (int): 任务列表类型 ID,默认为企业待办:600058 + dept_page_tag (int): 分页标志 + current_page (int): 当前页码 + num_per_page (int): 每页显示数据量,默认 200 + """ + api_url = f"/orderhandler/taskQuery/getDeptAllToDoOrderProcess" + request_body = { + "data": { + "deptPageTag": dept_page_tag, + "orderId": f"{order_id}", + "keyWord": "", + "andOrFlag": "0", + "serviceObjectType": [], + "callNumber": "", + "orderSource": [], + "orderSourceDetailList": [], + "signedStatus": [], + "firstOrderStatus": [], + "secordOrderStatus": [], + "status": [], + "overDue": "", + "existQuotoInfo": [], + "isSupervise": [], + "planFinishTime": "", + "caseIsUrgent": [], + "areaCodeCity": "", + "areaCodeArea": "", + "areaCodeStreet": "", + "addressDetail": "", + "infoProtect": [], + "firstLevelAffiliations": [], + "secondLevelAffiliations": [], + "thirdLevelAffiliations": [], + "fourthLevelAffiliations": [], + "fifthLevelAffiliations": [], + "caseAccordTypeOneNames": [], + "caseAccordTypeTwoNames": [], + "caseAccordTypeThreeNames": [], + "caseAccordTypeFourNames": [], + "caseAccordTypeFiveNames": [], + "creatorId": "", + "assigneeUserId": "", + "callTimeEnd": "", + "callTimeFrom": "", + "caseLabels": [], + "contactNumber": "", + "createBy": "", + "deptName": "", + "deptType": "", + "fileExist": [], + "hotspot": [], + "claimStatus": "", + "orderSourceDetail": "", + "orderType": [], + "orgName": [], + "sortField": "", + "sortRule": "", + "actionName": "", + "returnReasonNameList": [], + "createDateFrom": "", + "createDateEnd": "", + "planBackTimeStart": "", + "planBackTimeEnd": "", + "planFinishTimeStart": "", + "planFinishTimeEnd": "" + }, + "pageSize": num_per_page, + "pageNum": current_page + } + # 构造 API 请求 + return await govs_api.new_api_request(api_url, request_body) + + +async def after_task_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + response_body = response.body.decode() + response_data = json.loads(response_body) + list_data: list[dict] = udict.get_by_path(response_data, 'data.list') + order_master_list: list[dict] = [] + for d in list_data: + order_master_dto = d.get('orderMasterDTO') + order_master_dto['nextTaskId'] = d.get('nextTaskId') + order_master_dto['claimStatus'] = d.get('claimStatus') + order_master_list.append(order_master_dto) + if order_master_list: + mapped_df = pd.DataFrame(order_master_list) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovsOrderMaster.FieldMapping.items()} + mapped_df = mapped_df.rename(columns=forward_mapping) + # 把数组转换为 JSON 字符串 + mapped_df[GovsOrderMaster.attachment_list.key] = mapped_df[GovsOrderMaster.attachment_list.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if x is not None else None + ) + mapped_df[GovsOrderMaster.back_count.key] = mapped_df[GovsOrderMaster.back_count.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if x is not None else None + ) + # 根据claim_status字段,更新govs_sign字段 + mapped_df[GovsOrderMaster.govs_sign.key] = np.where( + mapped_df[GovsOrderMaster.claim_status.key] == '已签收', 1, 0 + ) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + # 筛选数据状态 + _created, _updated = await GovsOrderMaster.save_batch(mapped_df) + echo_log(f"成功创建企业待办:{_created}条,更新:{_updated}条.") + else: + echo_log('未获取到企业待办数据') + if retry_queue: + echo_log(f"企业待办重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +if __name__ == "__main__": + from paste.core import aio_pool + + + async def scrape(): + task_request = await get_task_request(dept_page_tag=0, order_id='DH058726052903006') + request_queue = asyncio.Queue() + await request_queue.put(task_request) + await requests.async_concurrency( + request_queue, retry=dock.MAX_RETRY_COUNT, + after_request=after_task_request + ) + + + _runner = aio_pool.get_aio_runner() + _runner(scrape()) diff --git a/dock/govs/govs_scrape_order_process.py b/dock/govs/govs_scrape_order_process.py new file mode 100644 index 0000000..b915890 --- /dev/null +++ b/dock/govs/govs_scrape_order_process.py @@ -0,0 +1,154 @@ +import asyncio +import json +from typing import Union + +import pandas as pd +from dateutil import parser +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import models +from dock.govs import govs_api +from models.govs_order_process import GovsOrderProcess +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + + +async def get_task_request(order_id: str, order_no: str, master_id: Union[str, int], + tenant_id: Union[str, int], dept_id: Union[str, int], area_code: str, + sort: str = ""): + """ + 获取省12345任务处理过程数据。 + + 通过 POST 请求向省12345的任务处理过程接口提交表单数据,获取任务处理过程数据。 + 自动注入有效的 Cookie(如 JSESSIONID)至请求头,并解析返回的 JSON 响应。 + + Args: + order_id (str): 待办任务ID + order_no (str): 待办任务号 + master_id (int): 关联订单主表ID + tenant_id (str, int): 租户ID + dept_id (str, int): 部门ID + area_code (str): 邮编 + sort (str): 排序 + """ + api_url = f"/orderreceive/orderMaster/queryOrderProcess" + request_body = { + "orderId": order_id, + "orderNo": order_no, + "masterId": master_id, + "tenantId": tenant_id, + "deptId": dept_id, + "areaCode": area_code, + "sort": sort, + } + # 构造 API 请求 + return await govs_api.new_api_request(api_url, request_body) + + +async def after_task_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 任务请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + order_id = getattr(response.request, 'order_id') + order_no = getattr(response.request, 'order_no') + master_id = getattr(response.request, 'master_id') + tenant_id = getattr(response.request, 'tenant_id') + + response_body = response.body.decode() + response_data = json.loads(response_body) + list_data = udict.get_by_path(response_data, 'result') + task_df = pd.DataFrame(list_data) + # 更换映射方向,用于将源数据列名改为与数据库表对应 + forward_mapping = {dict_f: table_f for table_f, dict_f in GovsOrderProcess.FieldMapping.items()} + mapped_df = task_df.rename(columns=forward_mapping) + mapped_df[GovsOrderProcess.master_id.key] = master_id + mapped_df[GovsOrderProcess.tenant_id.key] = tenant_id + # 比较字段转字符串 + mapped_df[GovsOrderProcess.id.key] = mapped_df[GovsOrderProcess.id.key].astype(str) + mapped_df[GovsOrderProcess.master_id.key] = mapped_df[GovsOrderProcess.master_id.key].astype(str) + # 过滤掉 id 和 order_id 为空的数据 + mapped_df = mapped_df[ + mapped_df[GovsOrderProcess.id.key].notna() & (mapped_df[GovsOrderProcess.id.key] != "") + ] + mapped_df = mapped_df[ + mapped_df[GovsOrderProcess.order_id.key].notna() & (mapped_df[GovsOrderProcess.order_id.key] != "") + ] + # 字典转化为字符串 + mapped_df[GovsOrderProcess.child_order_processes.key] = mapped_df[GovsOrderProcess.child_order_processes.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if x is not None else None + ) + mapped_df[GovsOrderProcess.handler_user_ids.key] = mapped_df[GovsOrderProcess.handler_user_ids.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if x is not None else None + ) + mapped_df[GovsOrderProcess.handler_org_ids.key] = mapped_df[GovsOrderProcess.handler_org_ids.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if x is not None else None + ) + mapped_df[GovsOrderProcess.next_handler_user_ids.key] = mapped_df[GovsOrderProcess.next_handler_user_ids.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if x is not None else None + ) + mapped_df[GovsOrderProcess.attachment_dto_list.key] = mapped_df[GovsOrderProcess.attachment_dto_list.key].apply( + lambda x: json.dumps(x, ensure_ascii=False) if x is not None else None + ) + # 时间字段转化为日期对象 + mapped_df[GovsOrderProcess.plan_sign_time.key] = mapped_df[GovsOrderProcess.plan_sign_time.key].apply( + lambda x: parser.parse(x).strftime('%Y-%m-%d %H:%M:%S') if isinstance(x, str) and x.strip() else None + ) + mapped_df[GovsOrderProcess.plan_finish_time.key] = mapped_df[GovsOrderProcess.plan_finish_time.key].apply( + lambda x: parser.parse(x).strftime('%Y-%m-%d %H:%M:%S') if isinstance(x, str) and x.strip() else None + ) + mapped_df[GovsOrderProcess.plan_back_time.key] = mapped_df[GovsOrderProcess.plan_back_time.key].apply( + lambda x: parser.parse(x).strftime('%Y-%m-%d %H:%M:%S') if isinstance(x, str) and x.strip() else None + ) + mapped_df[GovsOrderProcess.contact_time.key] = mapped_df[GovsOrderProcess.contact_time.key].apply( + lambda x: parser.parse(x).strftime('%Y-%m-%d %H:%M:%S') if isinstance(x, str) and x.strip() else None + ) + mapped_df[GovsOrderProcess.contact_time.key] = mapped_df[GovsOrderProcess.contact_time.key].apply( + lambda x: parser.parse(x).strftime('%Y-%m-%d %H:%M:%S') if isinstance(x, str) and x.strip() else None + ) + mapped_df[GovsOrderProcess.origin_plan_finish_time.key] = mapped_df[ + GovsOrderProcess.origin_plan_finish_time.key].apply( + lambda x: parser.parse(x).strftime('%Y-%m-%d %H:%M:%S') if isinstance(x, str) and x.strip() else None + ) + mapped_df[GovsOrderProcess.origin_plan_sign_time.key] = mapped_df[GovsOrderProcess.origin_plan_sign_time.key].apply( + lambda x: parser.parse(x).strftime('%Y-%m-%d %H:%M:%S') if isinstance(x, str) and x.strip() else None + ) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, None, inplace=True) + _created, _updated = await GovsOrderProcess.save_batch(mapped_df) + # 输出数据创建状态 + echo_log( + f"成功创建租户:{tenant_id} 的待办工单:{master_id}({order_id},{order_no}) 处理流程:{_created}条,更新:{_updated}条.") + if retry_queue: + echo_log(f"待办工单处理流程重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +if __name__ == "__main__": + from paste.core import aio_pool + + + async def scrape(order_id: str, order_no: str, master_id: Union[str, int], + tenant_id: Union[str, int], dept_id: Union[str, int], area_code: str, + sort: str = ""): + task_request = await get_task_request(order_id, order_no, master_id, tenant_id, dept_id, area_code, sort) + setattr(task_request, 'order_id', order_id) + setattr(task_request, 'order_no', order_no) + setattr(task_request, 'master_id', master_id) + setattr(task_request, 'tenant_id', tenant_id) + request_queue = asyncio.Queue() + await request_queue.put(task_request) + await requests.async_concurrency( + request_queue, retry=dock.MAX_RETRY_COUNT, + after_request=after_task_request + ) + + + _runner = aio_pool.get_aio_runner() + _runner(scrape( + 'DH050826052517663', 'DH050826052517663*3', '2058851271599333378', + '1773611023340371969', '1700467981117980074', '320500', + )) diff --git a/dock/govs/govs_security.py b/dock/govs/govs_security.py new file mode 100644 index 0000000..ad54fcb --- /dev/null +++ b/dock/govs/govs_security.py @@ -0,0 +1,116 @@ +""" +安全模块。 +""" +import asyncio +import json + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.govs import govs_api +from models.token import TokenModel +from paste.core import config +from paste.core.logging import echo_log +from paste.security import cryp_rsa +from paste.util import udict +from paste.web import requests + + +public_key = """-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC0Jr1NzVUQMburkZT6Rkt0eaPm +H8TN6E258l2tZMJgVCP/sL4oKjroKYmNPBkSSiLKFr9wwJqfesMeef6ChGRUXjG6 +DX0oxQRe0f5/UnyEm/NicJwz9xwkU34gbuo1VB/EA2QZ5dl1rj9iSsiqKLK6/QFl +VuzslRdAXYZC79vprwIDAQAB +-----END PUBLIC KEY-----""" +""" +固定公钥,确保登录成功。 +""" + + +async def login(): + """ + 登录政务服务 12345 系统并获取认证 Token。 + + 流程: + 1. 密码需要加密,密码明文使用PKCS1_v1_5进行RSA加密。 + 2. 从响应的['data']['access_token']内获取token。 + + Args: + 无参数。 + + Returns: + tuple: 包含两个元素的元组: + - dict: DCM 接口返回的完整 JSON 响应数据 + + Raises: + AssertionError: 登录失败(`resultInfo.success` 为 False) + ValueError: 响应体非合法 JSON + HTTPError: 网络请求失败(由 `async_request` 抛出) + """ + login_url = f"{govs_api.ApiUrl}/system/sysLogin" + + # 构建扩展头 + user_agent, browser_ver, os_name = dock.get_random_user_agent() + extra_headers = { + 'Content-Type': 'application/json; charset=UTF-8', + 'User-Agent': user_agent, + } + + # 构造请求 + request_body = { + "username": config.get_config("dock.govs.account.username"), + "password": cryp_rsa.rsa_encrypt_pkcs1_v1_5(public_key, config.get_config("dock.govs.account.password")), + "tenantAccount": "suzhou", + "rememberme": 1, + "code": "", + "uuid": "", + } + + # 构造请求对象 + request = dock.new_http_request( + url=login_url, + body=request_body, + method='POST', + timeout=dock.DEFAULT_TIMEOUT, + use_form=False, + extra_headers=extra_headers, + ** govs_api.ProxyConfig + ) + + queue = asyncio.Queue() + await queue.put(request) + await requests.async_concurrency( + queue, con_count=1, retry=dock.MAX_RETRY_COUNT, + after_request=after_login + ) + + +async def after_login(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + response_body = response.body.decode() + response_data = json.loads(response_body) + success = udict.get_by_path(response_data, 'data.access_token', '') + if success: + await TokenModel.refresh(platform='GOVS', token=success) + echo_log(f"成功刷新省12345登录令牌.") + else: + echo_log(f"省12345登录失败,无法刷新令牌,响应:{response_body}") + if retry_queue: + echo_log(f"登录重试队列中有:{retry_queue.qsize()} 个请求在等待.") + return response_data + + +async def get_token(platform: str = 'GOVS'): + """ + 取得可用 Token。 + + :param platform: 要查询的平台,默认是:GOVS,省12345 + :return: Cookies 字符串 + """ + _token = await TokenModel.find_by_platform(platform) + return _token.token + + +if __name__ == "__main__": + from paste.core import aio_pool + _runner = aio_pool.get_aio_runner() + _runner(login()) \ No newline at end of file diff --git a/dock/govs/govs_upload_file.py b/dock/govs/govs_upload_file.py new file mode 100644 index 0000000..f057fa5 --- /dev/null +++ b/dock/govs/govs_upload_file.py @@ -0,0 +1,17 @@ +import io +from dock.govs import govs_api + + +async def get_upload_request(file_name: str, file_io: io.IOBase): + """ + 创建上传文件到省12345的请求对象。方法仅创建请求对象,并未实际提交请求,具体由调度方法处理。 + + :param file_name: 文件名 + :param file_io: 文件io对象 + """ + + api_url = '/file/api/system/uploadcircuit' + body = { + file_name: file_io + } + return await govs_api.new_api_request(api_url, body, use_form=True) diff --git a/dock/oa/__init__.py b/dock/oa/__init__.py new file mode 100644 index 0000000..a459062 --- /dev/null +++ b/dock/oa/__init__.py @@ -0,0 +1,12 @@ +""" +OA 系统对接模块。 +""" + +class PushException(Exception): + """ + 推送异常,用于发给OA系统。 + """ + def __init__(self, message, flow_token=None, return_code=None): + super().__init__(message) + self.flow_token = flow_token + self.return_code = return_code \ No newline at end of file diff --git a/dock/oa/__pycache__/__init__.cpython-311.pyc b/dock/oa/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..3b58872 Binary files /dev/null and b/dock/oa/__pycache__/__init__.cpython-311.pyc differ diff --git a/dock/oa/__pycache__/oa_api.cpython-311.pyc b/dock/oa/__pycache__/oa_api.cpython-311.pyc new file mode 100644 index 0000000..841d735 Binary files /dev/null and b/dock/oa/__pycache__/oa_api.cpython-311.pyc differ diff --git a/dock/oa/__pycache__/oa_api_request.cpython-311.pyc b/dock/oa/__pycache__/oa_api_request.cpython-311.pyc new file mode 100644 index 0000000..e816f9c Binary files /dev/null and b/dock/oa/__pycache__/oa_api_request.cpython-311.pyc differ diff --git a/dock/oa/__pycache__/oa_result_notify.cpython-311.pyc b/dock/oa/__pycache__/oa_result_notify.cpython-311.pyc new file mode 100644 index 0000000..c00ad85 Binary files /dev/null and b/dock/oa/__pycache__/oa_result_notify.cpython-311.pyc differ diff --git a/dock/oa/__pycache__/oa_security.cpython-311.pyc b/dock/oa/__pycache__/oa_security.cpython-311.pyc new file mode 100644 index 0000000..a5672b3 Binary files /dev/null and b/dock/oa/__pycache__/oa_security.cpython-311.pyc differ diff --git a/dock/oa/oa_api.py b/dock/oa/oa_api.py new file mode 100644 index 0000000..49246cd --- /dev/null +++ b/dock/oa/oa_api.py @@ -0,0 +1,87 @@ +""" +OA 对接 API 基础功能。 +""" +import asyncio +import datetime +import json + +import apps +import dock +from paste.core import config +from paste.core.logging import echo_log + + +ApiUrl = config.get_config(f'dock.oa.env.{apps.get_active_env()}.api_url') +""" +对接 API 根目录。 +""" + + +TokenPlatform = config.get_config(f'dock.oa.env.{apps.get_active_env()}.token_platform') +""" +OA Token 平台。 +""" + + +async def new_api_request(api_url: str, request_body: dict, method: str = 'POST', + timeout: float = dock.DEFAULT_TIMEOUT, use_form: bool = False, headers: dict = None): + """ + 构造一个 API 请求对象 + + :param api_url: API 地址,以斜杠开头的 URI 地址,非完整 URL + :param request_body: 请求体,即所有请求参数 + :param method: 请求提交方式 + :param timeout: 超时时长 + :param use_form: 是否使用表单(Form)方式提交 + :param headers: 头数据,最高优先级 + :return: HTTPRequest 对象 + """ + # Token + from dock.oa import oa_security + token = await oa_security.get_token(TokenPlatform) + + # 构建扩展头 + user_agent, browser_ver, os_name = dock.get_random_user_agent() + extra_headers = { + 'Content-Type': 'application/json', + 'Token': token, + 'User-Agent': user_agent, + } + if headers is not None: + extra_headers = {**extra_headers, **headers} + + try: + echo_log(json.dumps(request_body)) + except Exception: + echo_log(str(request_body)) + + # 构造请求对象 + request = dock.new_http_request( + url=f"{ApiUrl}{api_url}", + body=request_body, + method=method, + timeout=timeout, + use_form=use_form, + extra_headers=extra_headers, + ) + return request + + +# 使用 asyncio.Lock 保证线(协)程安全 +_lock = asyncio.Lock() +# _cache 作为内存缓存,结构 {date: counter} +_cache = {} + +async def generate_serial_number(): + """ + 取得当日流水号。 + :return: 流水号字符串 + """ + today = datetime.datetime.now().strftime("%Y%m%d") + async with _lock: + if today not in _cache: + _cache[today] = 1 + else: + _cache[today] += 1 + counter = _cache[today] + return f"{today}{counter:05d}" \ No newline at end of file diff --git a/dock/oa/oa_api_request.py b/dock/oa/oa_api_request.py new file mode 100644 index 0000000..038e7af --- /dev/null +++ b/dock/oa/oa_api_request.py @@ -0,0 +1,469 @@ +""" +创建 OA 接口请求对象。 +对应文档接口:7、推送附件信息 +""" +import io +import os +from typing import Union + +from dock.oa import oa_api +from paste.core.logging import echo_log +from paste.util import uimg + + +async def get_push_order_request(dcm_tasks: list): + """ + 取得推送待办工单列表的请求对象。 + 对应文档接口:2、推送待办工单列表。 + 接口文档说明:接收数字城管待办列表数据,并保存到 OA 系统。 + 接口请求方式:POST + 接口返回格式:JSON + + Args: + dcm_tasks: 待办工单列表,工单对象须包含以下键值: + + - gdId (str): 待办工单ID,雪花ID。 + - attachmentList (list[dict]): 附件列表 + - taskNum (str): 任务号 + - otherTaskNum (str): 第三方任务号 + - bundleDeadlineTimeStr (str): 捆绑截止时间 + - rollbackDeadlineStr (str): 拒绝超时截止时间 + - eventSrcName (str): 问题来源 + - recTypeName (str): 案件类型 + - eventTypeName (str): 问题类型 + - mainTypeName (str): 大类名称 + - subTypeName (str): 小类名称 + - urgencyLevel (str): 紧急程度 + - eventDesc (str): 问题描述 + - address (str): 地址描述 + - disposalTimeLimit (str): 处置时限 + - districtName (str): 所属区域 + - newInstCondName (str): 立案条件 + - closingConditions (str): 结案条件 + - reporterName (str): 举报人 + - reporterContact (str): 举报电话 + - replyIntime (str): 是否两小时回复 + - firstDepartName (str): 一级专业部门 + - secondDepartName (str): 二级专业部门 + - bundleWarningTimeStr (str): 捆绑警告时间 + - actArdStateName (str): 阶段授权状态 + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + # 接口地址 + api_url = f"/externalWorkOrder/digitalCM/pushWaitingSignatureOrder" + request_no = await oa_api.generate_serial_number() + request_body = { + "requestNo": request_no, + "toDoList": dcm_tasks, + } + # 构造 API 请求 + return await oa_api.new_api_request(api_url, request_body) + + +async def get_upload_request(file: Union[str, io.IOBase], file_name: str, first_save: bool = True): + """ + 取得文件上传接口的请求对象。 + 对应文档接口:3、文件上传接口。 + 接口文档说明:上传指定的文件,返回文件在服务器上的id。 + 接口请求方式:POST + 接口返回格式:JSON + + Args: + file: 文件路径、URL 或文件对象,支持三种输入类型: + + - str (本地文件路径): 如 "/tmp/file.pdf",先从本地文件读取,再写入请求对象 + - str (远程 URL): 如 "https://example.com/file.pdf",先同步从远程地址下载,然后再写入请求对象 + - io.IOBase: 如 open(..., 'rb') 或 io.BytesIO + + file_name: 文件名 + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + # 接口地址 + api_url = "/attachment?applicationCategory=66" + if first_save: + api_url += '&firstSave=true' + + # 如果是远程 URL,先下载内容 + if isinstance(file, str) and (file.startswith("http://") or file.startswith("https://")): + echo_log(f"正在从 URL 下载附件: {file}") + response, content_type = uimg.fetch_image(file) # 获取二进制内容 + file_content = b''.join(response.iter_content(1024)) + file_obj = io.BytesIO(file_content) + + # 如果是本地文件路径,打开为二进制流 + elif isinstance(file, str) and os.path.exists(file): + echo_log(f"正在从本地路径读取附件: {file}") + with open(file, 'rb') as f: + file_obj = io.BytesIO(f.read()) + + # 如果是文件对象(如 BytesIO, BufferedReader),直接使用 + elif hasattr(file, 'read') and callable(file.read): + echo_log("正在使用传入的文件对象") + file_obj = io.BytesIO(file.read()) + + else: + raise TypeError(f"不支持的文件类型:{type(file)},应提供:文件路径,URL或文件对象.") + + request_body = { + file_name: file_obj, + } + + # 这里启用了 multipart/form-data 上传 + return await oa_api.new_api_request( + api_url=api_url, + request_body=request_body, + method='POST', + use_form=True, + ) + + +async def get_download_request(media_id: str): + """ + 取得文件下载接口的请求对象。 + 对应文档接口:4、文件下载接口。 + 接口文档说明:上传指定的文件,返回文件在服务器上的id。 + 接口请求方式:POST + 接口返回格式:application/octet-stream;charset=UTF-8 + + Args: + media_id: 文件 Media ID。 + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + # 接口地址 + api_url = f"/attachment/file/{media_id}" + return await oa_api.new_api_request(api_url=api_url, request_body={}, method='GET') + + +async def get_push_order_detail_request(**kwargs): + """ + 取得推送工单详情的请求对象。 + 对应文档接口:5、推送工单详情。 + 接口文档说明:接收数字城管工单详情。 + 接口请求方式:POST + 接口返回格式:JSON + + Args: + **kwargs: 请求参数,须包含以下键值: + + - gdId (str): 待办工单ID,雪花ID。 + - partCode (str): 部件编码。 + - funcLimitChar (str): 小类时限。 + - reporterName (str): 举报人。 + - mediaUploadTotalNum (str): 上传附件数。 + - returnVisitFlag (str): 是否回访。 + - undertakeUserName (str): 承办人员。 + - violationTaskNoDd (str): 市容违规任务号。 + - telReply (str): 回访电话。 + - funcForbidReporterInfoFlag (str): 是否公开。 + - dealPersonOrg (str): 承办部门。 + - contactNumberDd (str): 联系电话。 + - reportNumberDd (str): 举报电话。 + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + # 接口地址 + api_url = f"/externalWorkOrder/digitalCM/pushOrderDetail" + # 构造 API 请求 + return await oa_api.new_api_request(api_url, kwargs) + + +async def get_push_process_info_request(**kwargs): + """ + 取得推送办理经过的请求对象。 + 对应文档接口:6、推送办理经过。 + 接口文档说明:接收数字城管工单办理经过,保存到子表。 + 接口请求方式:POST + 接口返回格式:JSON + + Args: + **kwargs: 请求参数,须包含以下键值: + + - gdId (str): 待办工单ID,雪花ID。 + - checkContent (str): 核查内容,拼接第一条办理经过得到 + - handlingProcessList (list[dict]): 办理经过列表,每个元素须包含以下字段: + + - id (str): 唯一标识符,雪花ID。 + - actionTime (str): 操作时间,格式为 yyyy-MM-dd HH:mm:ss。 + - actDefName (str): 实际操作名称。 + - humanName (str): 经办人姓名。 + - unitName (str): 办理部门名称。 + - actionName (str): 操作类型名称。 + - nextActDefName (str): 下一环节名称。 + - detail (str): 操作意见或备注内容。 + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + # 接口地址 + api_url = f"/externalWorkOrder/digitalCM/pushProcessLog" + # 构造 API 请求 + return await oa_api.new_api_request(api_url, kwargs) + + +async def get_push_attachment_request(**kwargs): + """ + 取得推送附件信息的请求对象。 + 对应文档接口:7、推送附件信息。 + 接口文档说明:接收数字城管工单附件信息列表。 + 接口请求方式:POST + 接口返回格式:JSON + + Args: + **kwargs: 请求参数,须包含以下键值: + + - gdId (str): 待办工单ID,雪花ID。 + - attachmentList (list[dict]): 附件列表,每个元素须包含以下字段: + + - id (str): 唯一标识符,雪花ID。 + - mediaId (str): 媒体资源的唯一标识符,上传文件后,从 OA 平台取得,对应响应为:fileUrl。 + - mediaUsage (str): 使用场景,例如:上报、回退。 + - actDefName (str): 流程节点名称,例如:各区平台、一级专业部门、二级专业部门。 + - uploadCreateTime (str): 附件上传时间,可选。 + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + # 接口地址 + api_url = f"/externalWorkOrder/digitalCM/pushAttachmentInfo" + # 构造 API 请求 + return await oa_api.new_api_request(api_url, kwargs) + + +async def get_push_more_info_request(**kwargs): + """ + 取得推送更多信息的请求对象。 + 对应文档接口:8、推送更多信息。 + 接口文档说明:接收数字城管工单更多信息列表。 + 接口请求方式:POST + 接口返回格式:JSON + + Args: + **kwargs: 请求参数,须包含以下键值: + + - gdId (str): 待办工单ID,雪花ID。 + - moreInfoList (list[dict]): 更多信息列表,每个元素须包含以下字段: + + - id (str): 唯一标识符,雪花ID。 + - content (str): 内容。 + - time (str): 时间。 + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + # 接口地址 + api_url = f"/externalWorkOrder/digitalCM/pushMoreInfo" + # 构造 API 请求 + return await oa_api.new_api_request(api_url, kwargs) + + +async def get_push_extend_info_request(**kwargs): + """ + 取得推送扩展信息的请求对象。 + 对应文档接口:9、推送扩展信息。 + 接口文档说明:接收数字城管工单扩展信息列表。 + 接口请求方式:POST + 接口返回格式:JSON + + Args: + **kwargs: 请求参数,须包含以下键值: + + - gdId (str): 待办工单ID,雪花ID。 + - extendList (list[dict]): 扩展信息列表,每个元素须包含以下字段: + + - id (str): 唯一标识符,雪花ID。 + - fieldName: 属性。 + - fieldValue: 值。 + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + # 接口地址 + api_url = '/externalWorkOrder/digitalCM/pushExtendInfo' + # 构造 API 请求 + return await oa_api.new_api_request(api_url, kwargs) + + +async def get_result_notify_request(flow_token: str, message: str, return_code: int): + """ + 取得上报单条工单的操作结果的请求对象。 + 对应文档接口:10、上报单条工单的操作结果。 + 接口文档说明:皓凯平台调用接口推送接口处理结果。 + 接口请求方式:POST + 接口返回格式:JSON + + Args: + flow_token (str): 工作流令牌 + message (str): 接口调用返回说明 + return_code (int): 操作类型,相关值说明如下: + + - 1: 成功。 + - 2: 回退,超过3次,超过3次失败人为干预(3)。 + - 3: 人为干预。 + - 4: 失败。 + - 5: 停止。 + - 6: 取消。 + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + # 接口地址 + api_url = f'/flow/notification/{flow_token}' + # 请求体参数 + request_body = { + "message": message, + "returnCode": return_code + } + # 构造 API 请求 + return await oa_api.new_api_request(api_url, request_body) + + +async def get_sign_task_request(task_id: Union[str, int]): + """ + 取得签收工单的请求对象。 + 对应文档接口:11、签收。 + 接口文档说明:皓凯平台调用接口实现签收工单。 + 接口请求方式:POST + 接口返回格式:JSON + + Args: + task_id: 待办工单ID,雪花ID + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + # 接口地址 + api_url = f'/externalWorkOrder/digitalCM/gdSign?gdId={task_id}' + # 构造 API 请求 + return await oa_api.new_api_request(api_url, {}) + + +async def get_update_process_delay_request(task_id: str, bundle_deadline_time_str: str, rollback_deadline_str: str): + """ + 取得更新流程延期信息的请求对象。 + 对应文档接口:12、更新流程延期信息。 + 接口文档说明:更新流程延期信息。 + 接口请求方式:POST + 接口返回格式:JSON + + Args: + task_id (str): 待办工单ID,雪花ID。 + bundle_deadline_time_str (str): 捆绑截止时间,格式:yyyy-MM-dd HH:mm:ss + rollback_deadline_str (str): 拒绝超时截止时间,格式:yyyy-MM-dd HH:mm:ss + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + api_url = '/externalWorkOrder/digitalCM/updateProcessDelayInfo' + request_body = { + 'gdId': str(task_id), + 'bundleDeadlineTimeStr': bundle_deadline_time_str, + 'rollbackDeadlineStr': rollback_deadline_str + } + # 构造 API 请求 + return await oa_api.new_api_request(api_url, request_body) + + +async def get_push_govs_order_master_request(govs_tasks: list): + """ + 获取推送12345待签收工单到OA的请求 + 对应文档接口:2、推送待签收工单列表 + 接口文档说明:皓凯平台调用接口推送待签收工单列表给OA + 接口请求方式:POST + 接口返回格式:JSON + + Args: + govs_tasks: 待签收工单列表 + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + + # 接口地址 + api_url = '/externalWorkOrder/pushWaitingSignatureOrder' + # 构造 API 请求 + request_no = await oa_api.generate_serial_number() + request_body = { + "requestNo": request_no, + "toBeSignedList": govs_tasks, + } + # 构造 API 请求 + return await oa_api.new_api_request(api_url, request_body) + + +async def get_push_govs_order_detail_request(**kwargs): + """ + 取得推送12345工单详情的请求对象。 + 对应文档接口:5、推送工单详情。 + 接口文档说明:接收12345工单详情。 + 接口请求方式:POST + 接口返回格式:JSON + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + + # 接口地址 + api_url = '/externalWorkOrder/pushOrderDetail' + # 构造 API 请求 + return await oa_api.new_api_request(api_url, kwargs) + + +async def get_push_govs_process_request(**kwargs): + """ + 取得推送办理经过的请求对象。 + 对应文档接口:6、推送工单处理流程列表。 + 接口文档说明:接收12345工单办理经过,保存到子表。 + 接口请求方式:POST + 接口返回格式:JSON + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + # 接口地址 + api_url = f"/externalWorkOrder/pushProcessLog" + # 构造 API 请求 + return await oa_api.new_api_request(api_url, kwargs) + + +async def get_push_gov_process_request(**kwargs): + """ + 取得推送省12345办理经过的请求对象。 + 对应文档接口:6、推送工单处理流程列表。 + 接口文档说明:接收省12345工单办理经过,保存到子表。 + 接口请求方式:POST + 接口返回格式:JSON + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + # 接口地址 + api_url = f"/externalWorkOrder/pushProcessLog" + # 构造 API 请求 + return await oa_api.new_api_request(api_url, kwargs) + + +async def get_sign_govs_task_request(task_id: Union[str, int]): + """ + 取得签收工单的请求对象。 + 对应文档接口:11、签收。 + 接口文档说明:皓凯平台调用接口实现签收工单。 + 接口请求方式:POST + 接口返回格式:JSON + + Args: + task_id: 待办工单ID,雪花ID + + Returns: + HTTPRequest: 构造好的 HTTP 请求对象,用于后续异步调用。 + """ + # 接口地址 + api_url = f'/externalWorkOrder/gdSign?gdId={task_id}' + # 构造 API 请求 + return await oa_api.new_api_request(api_url, {}) diff --git a/dock/oa/oa_result_notify.py b/dock/oa/oa_result_notify.py new file mode 100644 index 0000000..a178a54 --- /dev/null +++ b/dock/oa/oa_result_notify.py @@ -0,0 +1,55 @@ +""" +上报 D3I 与 DCM 接口对接结果。 + +对应文档接口:10、上报单条工单的操作结果 +""" +import asyncio +import json + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.oa import oa_api_request +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + + +async def after_result_notify_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 上报工单操作结果响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + code = udict.get_by_path(body_data, 'code') + message = udict.get_by_path(body_data, 'msg') + if code == 200: + echo_log(f"上报工单操作结果成功.") + else: + echo_log(f"上报工单操作结果失败:{message}") + + if retry_queue: + echo_log(f"上报工单操作结果重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def push_result_notify(flow_token: str, message: str, return_code: int): + """ + 操作工单完成后,推送处理结果 + + :param flow_token:OA调用本项目接口时提供 + :param message:接口调用返回说明 + :param return_code:操作类型 + """ + echo_log(f"正在准备推送工单操作结果...") + request = await oa_api_request.get_result_notify_request(flow_token, message, return_code) + push_queue = asyncio.Queue() + await push_queue.put(request) + await requests.async_concurrency( + push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_result_notify_request + ) + echo_log(f"推送工单操作结果完成...") diff --git a/dock/oa/oa_security.py b/dock/oa/oa_security.py new file mode 100644 index 0000000..85364ec --- /dev/null +++ b/dock/oa/oa_security.py @@ -0,0 +1,96 @@ +""" +安全模块。 +""" +import asyncio +import json + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import apps +import dock +from dock.oa import oa_api +from models.token import TokenModel +from paste.core import config +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + + +async def login(): + """ + 登录 DCM 系统并获取认证 Token 和响应数据。 + + Args: + 无参数。 + + Returns: + tuple: 包含两个元素的元组: + - str: 请求头中的 Token 字符串(如 "JSESSIONID=abc; ...") + - dict: DCM 接口返回的完整 JSON 响应数据 + + Raises: + AssertionError: 登录失败(`token` 为 '-1') + ValueError: 响应体非合法 JSON + HTTPError: 网络请求失败(由 `async_request` 抛出) + """ + _username = config.get_config(f"dock.oa.env.{apps.get_active_env()}.username") + _password = config.get_config(f"dock.oa.env.{apps.get_active_env()}.password") + _login_name = config.get_config(f"dock.oa.env.{apps.get_active_env()}.login_name") + login_url = f"{oa_api.ApiUrl}/token/{_username}/{_password}?loginName={_login_name}" + + # 构建扩展头 + user_agent, browser_ver, os_name = dock.get_random_user_agent() + extra_headers = { + 'User-Agent': user_agent, + } + + # 构造请求 + request_body = {} + + # 构造请求对象 + request = dock.new_http_request( + url=login_url, + body=request_body, + method='GET', + timeout=dock.DEFAULT_TIMEOUT, + use_form=True, + extra_headers=extra_headers, + ) + + queue = asyncio.Queue() + await queue.put(request) + await requests.async_concurrency( + queue, con_count=1, retry=dock.MAX_RETRY_COUNT, + after_request=after_login + ) + + +async def after_login(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + response_body = response.body.decode() + response_data = json.loads(response_body) + token = udict.get_by_path(response_data, 'id', '') + if token and token != '-1': + await TokenModel.refresh(platform=f'{oa_api.TokenPlatform}', token=token) + echo_log(f"成功刷新 OA 登录令牌.") + else: + echo_log(f"OA 登录失败,无法刷新令牌,响应:{response_body}") + if retry_queue: + echo_log(f"登录重试队列中有:{retry_queue.qsize()} 个请求在等待.") + return response_data, token + + +async def get_token(platform: str = 'OA'): + """ + 取得可用 Cookies。 + + :param platform: 要查询的平台,默认是:OA + :return: Cookies 字符串 + """ + _token = await TokenModel.find_by_platform(platform) + return _token.token + + +if __name__ == "__main__": + from paste.core import aio_pool + _runner = aio_pool.get_aio_runner() + _runner(login()) \ No newline at end of file diff --git a/dock/oa_dcm/__init__.py b/dock/oa_dcm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dock/oa_dcm/__pycache__/__init__.cpython-311.pyc b/dock/oa_dcm/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..2dca01a Binary files /dev/null and b/dock/oa_dcm/__pycache__/__init__.cpython-311.pyc differ diff --git a/dock/oa_dcm/__pycache__/oa_push_attachment.cpython-311.pyc b/dock/oa_dcm/__pycache__/oa_push_attachment.cpython-311.pyc new file mode 100644 index 0000000..1da2d8e Binary files /dev/null and b/dock/oa_dcm/__pycache__/oa_push_attachment.cpython-311.pyc differ diff --git a/dock/oa_dcm/__pycache__/oa_push_extend_info.cpython-311.pyc b/dock/oa_dcm/__pycache__/oa_push_extend_info.cpython-311.pyc new file mode 100644 index 0000000..30e0c40 Binary files /dev/null and b/dock/oa_dcm/__pycache__/oa_push_extend_info.cpython-311.pyc differ diff --git a/dock/oa_dcm/__pycache__/oa_push_more_info.cpython-311.pyc b/dock/oa_dcm/__pycache__/oa_push_more_info.cpython-311.pyc new file mode 100644 index 0000000..d19ffd4 Binary files /dev/null and b/dock/oa_dcm/__pycache__/oa_push_more_info.cpython-311.pyc differ diff --git a/dock/oa_dcm/__pycache__/oa_push_order.cpython-311.pyc b/dock/oa_dcm/__pycache__/oa_push_order.cpython-311.pyc new file mode 100644 index 0000000..f4751a9 Binary files /dev/null and b/dock/oa_dcm/__pycache__/oa_push_order.cpython-311.pyc differ diff --git a/dock/oa_dcm/__pycache__/oa_push_order_detail.cpython-311.pyc b/dock/oa_dcm/__pycache__/oa_push_order_detail.cpython-311.pyc new file mode 100644 index 0000000..7fd1560 Binary files /dev/null and b/dock/oa_dcm/__pycache__/oa_push_order_detail.cpython-311.pyc differ diff --git a/dock/oa_dcm/__pycache__/oa_push_process_info.cpython-311.pyc b/dock/oa_dcm/__pycache__/oa_push_process_info.cpython-311.pyc new file mode 100644 index 0000000..17cf024 Binary files /dev/null and b/dock/oa_dcm/__pycache__/oa_push_process_info.cpython-311.pyc differ diff --git a/dock/oa_dcm/__pycache__/oa_sign_task.cpython-311.pyc b/dock/oa_dcm/__pycache__/oa_sign_task.cpython-311.pyc new file mode 100644 index 0000000..0b8e8e6 Binary files /dev/null and b/dock/oa_dcm/__pycache__/oa_sign_task.cpython-311.pyc differ diff --git a/dock/oa_dcm/__pycache__/oa_update_post_delay.cpython-311.pyc b/dock/oa_dcm/__pycache__/oa_update_post_delay.cpython-311.pyc new file mode 100644 index 0000000..363c294 Binary files /dev/null and b/dock/oa_dcm/__pycache__/oa_update_post_delay.cpython-311.pyc differ diff --git a/dock/oa_dcm/__pycache__/oa_upload.cpython-311.pyc b/dock/oa_dcm/__pycache__/oa_upload.cpython-311.pyc new file mode 100644 index 0000000..3d048ce Binary files /dev/null and b/dock/oa_dcm/__pycache__/oa_upload.cpython-311.pyc differ diff --git a/dock/oa_dcm/oa_push_attachment.py b/dock/oa_dcm/oa_push_attachment.py new file mode 100644 index 0000000..d266f18 --- /dev/null +++ b/dock/oa_dcm/oa_push_attachment.py @@ -0,0 +1,140 @@ +""" +待办工单附件数据上传。 + +对应文档接口:7、推送附件信息 +""" +import asyncio +import json +from typing import Optional, Union + +import pandas as pd +from sqlalchemy import select, desc +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import models +from dock.oa import oa_api_request +from models.dcm_push_status import DcmPushStatus +from models.dcm_task import DcmTask +from models.dcm_task_attachment import DcmTaskAttachment +from models.dcm_task_file_upload import DcmTaskFileUpload +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + +DcmTaskAttachmentMapping = { + DcmTaskAttachment.id.key: 'id', + DcmTaskFileUpload.oa_media_id.key: 'mediaId', + DcmTaskAttachment.media_usage.key: 'mediaUsage', + DcmTaskAttachment.act_def_name.key: 'actDefName', + DcmTaskAttachment.upload_time.key: 'uploadCreateTime', +} +""" +附件数据推送映射关系。 +""" + + +async def after_push_attachment_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 工单推送请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + code = udict.get_by_path(body_data, 'code') + message = udict.get_by_path(body_data, 'msg') + if code==200: + dcm_task_id = getattr(response.request, "dcm_task_id") + await DcmPushStatus.set_push_task_attachment_status(dcm_task_id) + echo_log(f"推送企业待办附件成功.") + else: + echo_log(f"推送企业待办附件失败:{message}") + + if retry_queue: + echo_log(f"企业待办附件重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def push_attachment(fetch_size: int = 50, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + """ + 推送待办附件数据及其数据。 + + :param fetch_size: 本次推送数量 + :param task_id: 待办任务 ID 可选 + """ + # 根据条件获取目标任务 ID 列表(支持指定 task_id 或分页获取) + task_query = select(DcmTask.id).order_by(desc(DcmTask.act_id)) + if task_id: + if isinstance(task_id, list): + task_query = task_query.where(DcmTask.id.in_(task_id)) + echo_log(f"本次推送待办列表:{task_id} 的附件数据...") + else: + task_query = task_query.where(DcmTask.id == task_id) + echo_log(f"本次推送待办:{task_id} 的附件数据...") + else: + task_query = task_query.limit(fetch_size) + echo_log(f"本次推送前 {fetch_size} 条待办附件数据...") + + dcm_task_df = await DcmTask.query_as_df(task_query) + # 格式化为字符串 + dcm_task_df[DcmTask.id.key] = dcm_task_df[DcmTask.id.key].astype(str) + + # 预处理数据方法 + def preprocess(df: pd.DataFrame): + # 更名,并仅保留需要的列 + df = df.rename(columns=DcmTaskAttachmentMapping) + df = df[list(DcmTaskAttachmentMapping.values()) + [DcmTaskAttachment.dcm_task_id.key]] + return df + + # 填充附件数据 + await DcmTaskAttachment.fill_attachment(dcm_task_df, column_name='attachmentList', preprocessing=preprocess) + + # 处理无附件待办状态 + empty_dcm_task_df = dcm_task_df[dcm_task_df['attachmentList'].apply(lambda x: len(x) == 0)] + empty_dcm_task_df[DcmPushStatus.dcm_task_id.key] = empty_dcm_task_df[DcmTask.id.key] + empty_dcm_task_df[DcmPushStatus.push_task_attachment_status.key] = 1 + empty_dcm_task_df = empty_dcm_task_df[[DcmPushStatus.dcm_task_id.key, DcmPushStatus.push_task_attachment_status.key]] + await DcmPushStatus.save_batch(empty_dcm_task_df) + + # 过滤空数组 + full_dcm_task_df = dcm_task_df[dcm_task_df['attachmentList'].apply(lambda x: len(x) > 0)] + # 删除 DcmTaskAttachment.dcm_task_id.key 字段 + def remove_dcm_task_id(attachment_list): + for item in attachment_list: + if isinstance(item, dict) and DcmTaskAttachment.dcm_task_id.key in item: + del item[DcmTaskAttachment.dcm_task_id.key] + return attachment_list + # 执行替换 + full_dcm_task_df['attachmentList'] = full_dcm_task_df['attachmentList'].apply(remove_dcm_task_id) + + # 处理数据映射,适应接口推送 + mapped_df = full_dcm_task_df.rename(columns={DcmTask.id.key: 'gdId'}) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + echo_log(f"正在准备请求队列...") + # 构建请求队列 + dcm_push_queue = asyncio.Queue() + + # 向队列中填充请求对象 + for _h, row in mapped_df.iterrows(): + push_request = await oa_api_request.get_push_attachment_request(**row.to_dict()) + setattr(push_request, "dcm_task_id", row.get('gdId')) + await dcm_push_queue.put(push_request) + + # 并发提交推送请求 + echo_log(f"开始推送待办附件数据...") + await requests.async_concurrency( + dcm_push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_attachment_request + ) + echo_log(f"待办附件数据推送已经完成...") + + +if __name__ == "__main__": + from paste.core import aio_pool + _runner = aio_pool.get_aio_runner() + _runner(push_attachment(task_id=2054174091237265408)) diff --git a/dock/oa_dcm/oa_push_extend_info.py b/dock/oa_dcm/oa_push_extend_info.py new file mode 100644 index 0000000..8f822ad --- /dev/null +++ b/dock/oa_dcm/oa_push_extend_info.py @@ -0,0 +1,135 @@ +""" +待办工单扩展数据推送。 + +对应文档接口:9、推送扩展信息 +""" +import asyncio +import json +from typing import Optional, Union + +import pandas as pd +from sqlalchemy import select, desc +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import models +from dock.oa import oa_api_request +from models.dcm_push_status import DcmPushStatus +from models.dcm_task import DcmTask +from models.dcm_task_extend_info import DcmTaskExtendedInfo +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + +DcmTaskExtendedInfoMapping = { + DcmTaskExtendedInfo.id.key: 'id', + DcmTaskExtendedInfo.display_name.key: 'fieldName', + DcmTaskExtendedInfo.field_value.key: 'fieldValue' +} + + +async def after_push_extend_info_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 工单推送请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + code = udict.get_by_path(body_data, 'code') + message = udict.get_by_path(body_data, 'msg') + if code == 200: + dcm_task_id = getattr(response.request, "dcm_task_id") + await DcmPushStatus.set_push_task_extend_info_status(dcm_task_id) + echo_log(f"推送企业待办扩展信息成功.") + else: + echo_log(f"推送企业待办扩展信息失败:{message}") + + if retry_queue: + echo_log(f"企业待办扩展信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def push_extend_info(fetch_size: int = 50, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + """ + 推送待办扩展信息数据及其数据。 + + :param fetch_size: 本次推送数量 + :param task_id: 待办任务 ID 可选 + """ + # 根据条件获取目标任务 ID 列表(支持指定 task_id 或分页获取) + task_query = select(DcmTask.id).order_by(desc(DcmTask.act_id)) + if task_id: + if isinstance(task_id, list): + task_query = task_query.where(DcmTask.id.in_(task_id)) + echo_log(f"本次推送待办列表:{task_id} 的扩展信息数据...") + else: + task_query = task_query.where(DcmTask.id == task_id) + echo_log(f"本次推送待办:{task_id} 的扩展信息数据...") + else: + task_query = task_query.limit(fetch_size) + echo_log(f"本次推送待办前 {fetch_size} 条扩展信息数据...") + + dcm_task_df = await DcmTask.query_as_df(task_query) + # 格式化为字符串 + dcm_task_df[DcmTask.id.key] = dcm_task_df[DcmTask.id.key].astype(str) + + # 预处理数据方法 + def preprocess(df: pd.DataFrame): + # 更名,并仅保留需要的列 + df = df.rename(columns=DcmTaskExtendedInfoMapping) + df = df[list(DcmTaskExtendedInfoMapping.values())+[DcmTaskExtendedInfo.dcm_task_id.key]] + return df + + # 填充表单数据 + await DcmTaskExtendedInfo.fill_extend_info(dcm_task_df, column_name='extendList', preprocessing=preprocess) + + # 处理无待办工单扩展信息状态 + empty_dcm_task_df = dcm_task_df[dcm_task_df['extendList'].apply(lambda x: len(x) == 0)] + empty_dcm_task_df[DcmPushStatus.dcm_task_id.key] = empty_dcm_task_df[DcmTask.id.key] + empty_dcm_task_df[DcmPushStatus.push_task_extend_info_status.key] = 1 + empty_dcm_task_df = empty_dcm_task_df[[DcmPushStatus.dcm_task_id.key, DcmPushStatus.push_task_extend_info_status.key]] + await DcmPushStatus.save_batch(empty_dcm_task_df) + + # 过滤空数组 + full_dcm_task_df = dcm_task_df[dcm_task_df['extendList'].apply(lambda x: len(x) > 0)] + # 删除 DcmTaskExtendedInfo.dcm_task_id.key 字段 + def remove_dcm_task_id(attachment_list): + for item in attachment_list: + if isinstance(item, dict) and DcmTaskExtendedInfo.dcm_task_id.key in item: + del item[DcmTaskExtendedInfo.dcm_task_id.key] + return attachment_list + + # 执行替换 + full_dcm_task_df['extendList'] = full_dcm_task_df['extendList'].apply(remove_dcm_task_id) + # 处理数据映射,适应接口推送 + mapped_df = full_dcm_task_df.rename(columns={DcmTask.id.key: 'gdId'}) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + echo_log(f"正在准备请求队列...") + # 构建请求队列 + dcm_push_queue = asyncio.Queue() + + # 向队列中填充请求对象 + for _h, row in mapped_df.iterrows(): + push_request = await oa_api_request.get_push_extend_info_request(**row.to_dict()) + setattr(push_request, "dcm_task_id", row.get('gdId')) + await dcm_push_queue.put(push_request) + + # 并发提交推送请求 + echo_log(f"开始推送代办扩展信息数据...") + await requests.async_concurrency( + dcm_push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_extend_info_request + ) + echo_log(f"待办扩展信息推送已经完成...") + + +if __name__ == "__main__": + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(push_extend_info(task_id=2054174091237265408)) diff --git a/dock/oa_dcm/oa_push_more_info.py b/dock/oa_dcm/oa_push_more_info.py new file mode 100644 index 0000000..a922a57 --- /dev/null +++ b/dock/oa_dcm/oa_push_more_info.py @@ -0,0 +1,139 @@ +""" +待办工单更多信息推送。 + +对应文档接口:8、推送更多信息 +""" +import asyncio +import json +from typing import Optional, Union + +import pandas as pd +from sqlalchemy import select, desc +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import models +from dock.oa import oa_api_request +from models.dcm_push_status import DcmPushStatus +from models.dcm_task import DcmTask +from models.dcm_task_more_info import DcmTaskMoreInfo +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + +DcmTaskMoreInfoMapping = { + DcmTaskMoreInfo.id.key: 'id', + DcmTaskMoreInfo.create_time.key: 'time' +} +""" +表单数据-更多信息推送映射关系。 +""" + + +async def after_push_more_info_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 工单推送请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + code = udict.get_by_path(body_data, 'code') + message = udict.get_by_path(body_data, 'msg') + if code == 200: + dcm_task_id = getattr(response.request, "dcm_task_id") + await DcmPushStatus.set_push_task_more_info_status(dcm_task_id) + echo_log(f"推送企业待办更多信息成功.") + else: + echo_log(f"推送企业待办更多信息失败:{message}") + + if retry_queue: + echo_log(f"企业待办更多信息重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def push_more_info(fetch_size: int = 50, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + """ + 推送待办更多信息数据及其数据。 + + :param fetch_size: 本次推送数量 + :param task_id: 待办任务 ID 可选 + """ + # 根据条件获取目标任务 ID 列表(支持指定 task_id 或分页获取) + task_query = select(DcmTask.id).order_by(desc(DcmTask.act_id)) + if task_id: + if isinstance(task_id, list): + task_query = task_query.where(DcmTask.id.in_(task_id)) + echo_log(f"本次推送待办列表:{task_id} 的更多信息数据...") + else: + task_query = task_query.where(DcmTask.id == task_id) + echo_log(f"本次推送待办:{task_id} 的更多信息数据...") + else: + task_query = task_query.limit(fetch_size) + echo_log(f"本次推送前 {fetch_size} 条更多信息数据...") + + dcm_task_df = await DcmTask.query_as_df(task_query) + # 格式化为字符串 + dcm_task_df[DcmTask.id.key] = dcm_task_df[DcmTask.id.key].astype(str) + + # 预处理数据方法 + def preprocess(df: pd.DataFrame): + # 更名,并仅保留需要的列 + df = df.rename(columns=DcmTaskMoreInfoMapping) + df['content'] = df['time'].fillna("") + ' ' + df['human_name'].fillna("") + df['msg_type'].fillna("") + df = df[list(DcmTaskMoreInfoMapping.values()) + ['content',DcmTaskMoreInfo.dcm_task_id.key]] + return df + + # 填充表单数据 + await DcmTaskMoreInfo.fill_more_info(dcm_task_df, column_name='moreInfoList', preprocessing=preprocess) + + # 处理无待办工单更多信息状态 + empty_dcm_task_df = dcm_task_df[dcm_task_df['moreInfoList'].apply(lambda x: len(x) == 0)] + empty_dcm_task_df[DcmPushStatus.dcm_task_id.key] = empty_dcm_task_df[DcmTask.id.key] + empty_dcm_task_df[DcmPushStatus.push_task_more_info_status.key] = 1 + empty_dcm_task_df = empty_dcm_task_df[[DcmPushStatus.dcm_task_id.key, DcmPushStatus.push_task_more_info_status.key]] + await DcmPushStatus.save_batch(empty_dcm_task_df) + + # 过滤空数组 + full_dcm_task_df = dcm_task_df[dcm_task_df['moreInfoList'].apply(lambda x: len(x) > 0)] + # 删除 DcmTaskMoreInfo.dcm_task_id.key 字段 + def remove_dcm_task_id(attachment_list): + for item in attachment_list: + if isinstance(item, dict) and DcmTaskMoreInfo.dcm_task_id.key in item: + del item[DcmTaskMoreInfo.dcm_task_id.key] + return attachment_list + + # 执行替换 + full_dcm_task_df['moreInfoList'] = full_dcm_task_df['moreInfoList'].apply(remove_dcm_task_id) + + # 处理数据映射,适应接口推送 + mapped_df = full_dcm_task_df.rename(columns={DcmTask.id.key: 'gdId'}) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + echo_log(f"正在准备请求队列...") + # 构建请求队列 + dcm_push_queue = asyncio.Queue() + + # 向队列中填充请求对象 + for _h, row in mapped_df.iterrows(): + push_request = await oa_api_request.get_push_more_info_request(**row.to_dict()) + setattr(push_request, "dcm_task_id", row.get('gdId')) + await dcm_push_queue.put(push_request) + + # 并发提交推送请求 + echo_log(f"开始推送更多信息数据...") + await requests.async_concurrency( + dcm_push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_more_info_request + ) + echo_log(f"更多信息数据推送已经完成...") + + +if __name__ == "__main__": + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(push_more_info(task_id=2054174091237265408)) diff --git a/dock/oa_dcm/oa_push_order.py b/dock/oa_dcm/oa_push_order.py new file mode 100644 index 0000000..dac8c38 --- /dev/null +++ b/dock/oa_dcm/oa_push_order.py @@ -0,0 +1,241 @@ +""" +待办工单推送。 + +对应文档接口:2、推送待办工单列表 +""" +import asyncio +import json +from typing import Optional, Union + +import pandas as pd +from sqlalchemy import select, desc +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import models +from dock.oa import oa_api_request +from models.dcm_push_status import DcmPushStatus +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + +DcmTaskMapping = { + DcmTask.id.key: 'gdId', + DcmTask.task_num.key: 'taskNum', + DcmTask.other_task_num.key: 'otherTaskNum', + DcmTask.bundle_deadline_time.key: 'bundleDeadlineTimeStr', + DcmTask.rollback_deadline.key: 'rollbackDeadlineStr', + DcmTask.event_src_name.key: 'eventSrcName', + DcmTask.rec_type_name.key: 'recTypeName', + DcmTask.event_type_name.key: 'eventTypeName', + DcmTask.main_type_name.key: 'mainTypeName', + DcmTask.sub_type_name.key: 'subTypeName', + DcmTask.urgency_level.key: 'urgencyLevel', + DcmTask.event_desc.key: 'eventDesc', + DcmTask.address.key: 'address', + DcmTask.processing_deadline.key: 'disposalTimeLimit', + DcmTask.district_name.key: 'districtName', + DcmTask.new_inst_cond_name.key: 'newInstCondName', + DcmTask.case_closure_condition.key: 'closingConditions', + DcmTask.reporter_name.key: 'reporterName', + DcmTask.reporter_contact.key: 'reporterContact', + DcmTask.reply_intime.key: 'replyIntime', + DcmTask.first_depart_name.key: 'firstDepartName', + DcmTask.second_depart_name.key: 'secondDepartName', + DcmTask.bundle_warning_time.key: 'bundleWarningTimeStr', + DcmTask.act_ard_state_name.key: 'actArdStateName', + DcmTask.operation.key: 'operateType', +} +""" +数据推送映射关系。 +""" + + +async def after_push_order_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 工单推送请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + code = udict.get_by_path(body_data, 'code') + message = udict.get_by_path(body_data, 'msg') + if code == 200: + dcm_task_id_list = getattr(response.request, "dcm_task_id_list", []) + dcm_task_push_data = [ + { + DcmPushStatus.dcm_task_id.key: dcm_task_id, + DcmPushStatus.push_task_status.key: 1 + } + for dcm_task_id in dcm_task_id_list + ] + dcm_task_push_df = pd.DataFrame(dcm_task_push_data) + await DcmPushStatus.save_batch(dcm_task_push_df) + echo_log(f"推送企业待办成功.") + else: + echo_log(f"推送企业待办失败:{message}") + + if retry_queue: + echo_log(f"企业待办重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def push_order(fetch_size: int = 50, batch_size: int = 10, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + """ + 推送待办数据及其数据。 + + :param fetch_size: 本次推送数量 + :param batch_size: 分批时,每批大小 + :param task_id: 待办任务 ID 可选 + """ + # 根据条件获取目标任务 ID 列表(支持指定 task_id 或分页获取) + task_query = select( + DcmTask.id, + DcmTask.task_num, DcmTask.other_task_num, + DcmTask.bundle_deadline_time, DcmTask.rollback_deadline, + DcmTask.event_src_name, DcmTask.rec_type_name, + DcmTask.event_type_name, DcmTask.main_type_name, + DcmTask.sub_type_name, DcmTask.urgency_level, + DcmTask.event_desc, DcmTask.address, + DcmTask.processing_deadline, DcmTask.district_name, + DcmTask.new_inst_cond_name, DcmTask.case_closure_condition, + DcmTask.reporter_name, DcmTask.reporter_contact, + DcmTask.reply_intime, DcmTask.first_depart_name, + DcmTask.second_depart_name, DcmTask.bundle_warning_time, + DcmTask.act_ard_state_name, DcmTask.operation + ).order_by( + desc(DcmTask.act_id) + ) + if task_id: + if isinstance(task_id, list): + task_query = task_query.where(DcmTask.id.in_(task_id)) + echo_log(f"本次推送待办列表:{task_id} 的数据...") + else: + task_query = task_query.where(DcmTask.id == task_id) + echo_log(f"本次推送待办:{task_id} 的数据...") + else: + task_query = task_query.limit(fetch_size) + echo_log(f"本次推送前 {fetch_size} 条待办数据...") + + # if apps.get_active_env() == 'prod': + # # 生产环境只推送 DcmTaskProcessInfo.action_time > 2026-05-18 00:00:00 的待办工单 + # # 构建子查询:查找满足条件的 DcmTaskProcessInfo + # subquery = select(DcmTaskProcessInfo.dcm_task_id).where( + # DcmTaskProcessInfo.dcm_task_id == DcmTask.id, + # DcmTaskProcessInfo.action_time > '2026-05-18 00:00:00' + # ).group_by( + # DcmTaskProcessInfo.dcm_task_id + # ).order_by( + # desc(DcmTaskProcessInfo.action_time) + # ) + # task_query = task_query.where(exists(subquery)) + + dcm_task_df = await DcmTask.query_as_df(task_query) + # 格式化三个时间字段为 yyyy-MM-dd HH:mm:ss + dcm_task_df[DcmTask.bundle_deadline_time.key] = ( + pd.to_datetime( + dcm_task_df[DcmTask.bundle_deadline_time.key], unit='ms', errors='coerce' + ).dt.strftime('%Y-%m-%d %H:%M:%S') + ) + dcm_task_df[DcmTask.rollback_deadline.key] = ( + pd.to_datetime( + dcm_task_df[DcmTask.rollback_deadline.key], unit='ms', errors='coerce' + ).dt.strftime('%Y-%m-%d %H:%M:%S')) + dcm_task_df[DcmTask.bundle_warning_time.key] = ( + pd.to_datetime( + dcm_task_df[DcmTask.bundle_warning_time.key], unit='ms', errors='coerce' + ).dt.strftime('%Y-%m-%d %H:%M:%S')) + # 格式化为字符串 + dcm_task_df[DcmTask.id.key] = dcm_task_df[DcmTask.id.key].astype(str) + # 代码转义 + reply_intime_map = { + '0': '无需回复', + '1': '待回复', + '2': '已回复', + '3': '超时未回复', + '4': '无需回复已恢复', + } + dcm_task_df[DcmTask.reply_intime.key] = dcm_task_df[DcmTask.reply_intime.key].astype(str).map( + lambda x: reply_intime_map.get(x, x) + ) + urgency_level_map = { + '0': '正常', + '1': '紧急', + } + dcm_task_df[DcmTask.urgency_level.key] = dcm_task_df[DcmTask.urgency_level.key].astype(str).map( + lambda x: urgency_level_map.get(x, x) + ) + + dcm_task_df[DcmTask.operation.key] = dcm_task_df[DcmTask.operation.key].str.split(',') + + # 处理数据映射,适应接口推送 + mapped_df = dcm_task_df.rename(columns=DcmTaskMapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + echo_log(f"正在准备请求队列...") + # 构建请求队列 + dcm_push_queue = asyncio.Queue() + + # 向队列中填充请求对象 + for start in range(0, len(mapped_df), batch_size): + batch_df: pd.DataFrame = mapped_df.iloc[start:start + batch_size] + push_list = batch_df.to_dict('records') + push_request = await oa_api_request.get_push_order_request(push_list) + setattr(push_request, "dcm_task_id_list", batch_df['gdId'].unique().tolist()) + await dcm_push_queue.put(push_request) + + # 并发提交推送请求 + echo_log(f"开始推送待办数据...") + await requests.async_concurrency( + dcm_push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_order_request + ) + echo_log(f"待办数据推送已经完成...") + + +async def push_full_order(fetch_size: int = 50, batch_size: int = 10, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + from dock.oa_dcm import oa_push_order_detail, oa_push_process_info, oa_push_attachment, \ + oa_push_more_info, oa_push_extend_info, oa_upload + + await push_order(fetch_size, batch_size, task_id) + await oa_push_order_detail.push_order_detail(fetch_size, task_id) + await oa_push_process_info.push_process_info(fetch_size, task_id) + await oa_upload.upload_with_attachment(fetch_size, task_id) + await oa_push_attachment.push_attachment(fetch_size, task_id) + await oa_push_more_info.push_more_info(fetch_size, task_id) + await oa_push_extend_info.push_extend_info(fetch_size, task_id) + + +async def push_sign_order(): + """ + 推送并签收工单。 + """ + from dock.oa_dcm import oa_push_order_detail, oa_push_process_info, oa_push_attachment, \ + oa_push_more_info, oa_push_extend_info, oa_upload, oa_sign_task + + # TODO: 查询得到要推送的工单ID列表 + task_id_list = [] + + if task_id_list: + await push_order(task_id=task_id_list) + await oa_push_order_detail.push_order_detail(task_id=task_id_list) + await oa_push_process_info.push_process_info(task_id=task_id_list) + await oa_upload.upload_with_attachment(task_id=task_id_list) + await oa_push_attachment.push_attachment(task_id=task_id_list) + await oa_push_more_info.push_more_info(task_id=task_id_list) + await oa_push_extend_info.push_extend_info(task_id=task_id_list) + # 推送结束后,签收工单 + await oa_sign_task.sign_task(task_id=task_id_list) + + +if __name__ == "__main__": + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(push_order(task_id=2054174091254042627)) diff --git a/dock/oa_dcm/oa_push_order_detail.py b/dock/oa_dcm/oa_push_order_detail.py new file mode 100644 index 0000000..1d9c6f9 --- /dev/null +++ b/dock/oa_dcm/oa_push_order_detail.py @@ -0,0 +1,165 @@ +""" +待办工单明细推送。 + +对应文档接口:5、推送工单详情 +""" +import asyncio +import json +from typing import Optional, Union + +import pandas as pd +from sqlalchemy import select, desc +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import models +from dock.oa import oa_api_request +from models.dcm_push_status import DcmPushStatus +from models.dcm_task import DcmTask +from models.dcm_task_form_datum import DcmTaskFormDatum +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + +DcmTaskMapping = { + DcmTask.id.key: 'gdId', + DcmTask.part_code.key: 'partCode', + DcmTaskFormDatum.func_limit_char.key: 'funcLimitChar', + DcmTask.reporter_name.key: 'reporterName', + DcmTaskFormDatum.media_upload_total_num.key: 'mediaUploadTotalNum', + DcmTask.return_visit_flag.key:'returnVisitFlag', + DcmTask.func_forbid_reporter_info_flag.key:'funcForbidReporterInfoFlag', + DcmTaskFormDatum.tell_num.key:'contactNumberDd', + DcmTask.reporter_contact.key: 'reportNumberDd', + DcmTaskFormDatum.deal_person_org.key: 'dealPersonOrg', + DcmTaskFormDatum.undertake_user_name.key: 'undertakeUserName', +} +""" +数据推送映射关系。 +""" + + +async def after_push_order_detail_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 工单推送请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + code = udict.get_by_path(body_data, 'code') + message = udict.get_by_path(body_data, 'msg') + if code==200: + dcm_task_id = getattr(response.request, "dcm_task_id") + await DcmPushStatus.set_push_task_detail_status(dcm_task_id) + echo_log(f"推送工单详情成功.") + else: + echo_log(f"推送工单详情失败:{message}") + + if retry_queue: + echo_log(f"工单详情重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def push_order_detail(fetch_size: int = 50, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + """ + 推送待办数据及其数据。 + + :param fetch_size: 本次推送数量 + :param task_id: 待办任务 ID 可选 + """ + # 根据条件获取目标任务 ID 列表(支持指定 task_id 或分页获取) + task_query = select(DcmTask.id).order_by(desc(DcmTask.act_id)) + if task_id: + if isinstance(task_id, list): + task_query = task_query.where(DcmTask.id.in_(task_id)) + echo_log(f"本次推送待办列表:{task_id} 的详细数据...") + else: + task_query = task_query.where(DcmTask.id == task_id) + echo_log(f"本次推送待办:{task_id} 的详细数据...") + else: + task_query = task_query.limit(fetch_size) + echo_log(f"本次推送前 {fetch_size} 条待办的详细数据...") + task_id_list = (await DcmTask.orm_execute_scalars(task_query)).all() + task_id_list = [f"{id}" for id in task_id_list] + + # 查询属于这些任务的所有详细数据 + datum_query = select( + DcmTask.id, DcmTask.part_code, DcmTask.reporter_name, DcmTask.reporter_contact, + DcmTask.return_visit_flag, DcmTask.func_forbid_reporter_info_flag, + DcmTaskFormDatum.media_upload_total_num, DcmTaskFormDatum.func_limit_char, + DcmTaskFormDatum.tell_num, DcmTaskFormDatum.undertake_user_name, + DcmTaskFormDatum.deal_person_org + ).join( + DcmTaskFormDatum, DcmTask.id==DcmTaskFormDatum.dcm_task_id + ).where( + DcmTask.id.in_(task_id_list) + ) + + dcm_task_df = await DcmTask.query_as_df(datum_query) + # 格式化为字符串 + dcm_task_df[DcmTask.id.key] = dcm_task_df[DcmTask.id.key].astype(str) + dcm_task_df[DcmTaskFormDatum.media_upload_total_num.key] = dcm_task_df[DcmTaskFormDatum.media_upload_total_num.key].fillna(0).astype(int) + # 代码转义 + return_visit_flag_map = { + '0': '无需回访', + '1': '待回访', + '2': '已回访', + } + dcm_task_df[DcmTask.return_visit_flag.key] = dcm_task_df[DcmTask.return_visit_flag.key].astype(str).map( + lambda x: return_visit_flag_map.get(x, x) + ) + func_forbid_reporter_info_flag_map = { + '0': '否', + '1': '是', + } + dcm_task_df[DcmTask.func_forbid_reporter_info_flag.key] = dcm_task_df[DcmTask.func_forbid_reporter_info_flag.key].astype(str).map( + lambda x: func_forbid_reporter_info_flag_map.get(x, x) + ) + + # 未爬取的字段暂时填空值 + dcm_task_df['violationTaskNoDd'] = '' + dcm_task_df['telReply'] = '' + + # 处理无待办工单详情状态 + empty_task_ids = set(task_id_list) - set(dcm_task_df[DcmTask.id.key].unique().tolist()) + empty_task_data = [ + { + DcmPushStatus.dcm_task_id.key: dcm_task_id, + DcmPushStatus.push_task_status.key: 1 + } + for dcm_task_id in list(empty_task_ids) + ] + empty_task_df = pd.DataFrame(empty_task_data) + await DcmPushStatus.save_batch(empty_task_df) + + # 处理数据映射,适应接口推送 + mapped_df = dcm_task_df.rename(columns=DcmTaskMapping) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + echo_log(f"正在准备请求队列...") + # 构建请求队列 + dcm_push_queue = asyncio.Queue() + + # 向队列中填充请求对象 + for _h, row in mapped_df.iterrows(): + push_request = await oa_api_request.get_push_order_detail_request(**row.to_dict()) + setattr(push_request, "dcm_task_id", row.get('gdId')) + await dcm_push_queue.put(push_request) + + # 并发提交推送请求 + echo_log(f"开始推送工单详情数据...") + await requests.async_concurrency( + dcm_push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_order_detail_request + ) + echo_log(f"工单详情推送已经完成...") + + +if __name__ == "__main__": + from paste.core import aio_pool + _runner = aio_pool.get_aio_runner() + _runner(push_order_detail(task_id=2054174091254042627)) \ No newline at end of file diff --git a/dock/oa_dcm/oa_push_process_info.py b/dock/oa_dcm/oa_push_process_info.py new file mode 100644 index 0000000..f4921c6 --- /dev/null +++ b/dock/oa_dcm/oa_push_process_info.py @@ -0,0 +1,164 @@ +""" +推送办理经过。 + +对应文档接口:6、推送办理经过 +""" +import asyncio +import json +from typing import Optional, Union + +import pandas as pd +from sqlalchemy import select, desc +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import models +from dock.oa import oa_api_request +from models.dcm_push_status import DcmPushStatus +from models.dcm_task import DcmTask +from models.dcm_task_process_info import DcmTaskProcessInfo +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + +DcmTaskProcessInfoMapping = { + DcmTaskProcessInfo.id.key: 'id', + DcmTaskProcessInfo.action_time.key: 'actionTime', + DcmTaskProcessInfo.act_def_name.key: 'actDefName', + DcmTaskProcessInfo.human_name.key: 'humanName', + DcmTaskProcessInfo.unit_name.key: 'unitName', + DcmTaskProcessInfo.action_name.key: 'actionName', + DcmTaskProcessInfo.next_act_def_name.key: 'nextActDefName', + DcmTaskProcessInfo.detail.key: 'detail', +} + + +async def after_push_process_info_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 工单推送请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + code = udict.get_by_path(body_data, 'code') + message = udict.get_by_path(body_data, 'msg') + if code==200: + dcm_task_id = getattr(response.request, "dcm_task_id") + await DcmPushStatus.set_push_task_process_info_status(dcm_task_id) + echo_log(f"推送企业待办过程成功.") + else: + echo_log(f"推送企业待办过程失败:{message}") + + if retry_queue: + echo_log(f"企业待办过程重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def push_process_info(fetch_size: int = 50, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + """ + 推送待办附件数据及其数据。 + + :param fetch_size: 本次推送数量 + :param task_id: 待办任务 ID 可选 + """ + # 根据条件获取目标任务 ID 列表(支持指定 task_id 或分页获取) + task_query = select(DcmTask.id).order_by(desc(DcmTask.act_id)) + if task_id: + if isinstance(task_id, list): + task_query = task_query.where(DcmTask.id.in_(task_id)) + echo_log(f"本次推送待办列表:{task_id} 的过程数据...") + else: + task_query = task_query.where(DcmTask.id == task_id) + echo_log(f"本次推送待办:{task_id} 的过程数据...") + else: + task_query = task_query.limit(fetch_size) + echo_log(f"本次推送前 {fetch_size} 条待办过程数据...") + dcm_task_df = await DcmTask.query_as_df(task_query) + + # 格式化为字符串 + dcm_task_df[DcmTask.id.key] = dcm_task_df[DcmTask.id.key].astype(str) + dcm_task_ids = dcm_task_df[DcmTask.id.key].unique().tolist() + dcm_task_check_info = {f'{tid}': '' for tid in dcm_task_ids} + + # 预处理数据方法 + def preprocess(df: pd.DataFrame): + # 格式化时间字段为 yyyy-MM-dd HH:mm:ss + df[DcmTaskProcessInfo.action_time.key] = ( + pd.to_datetime( + df[DcmTaskProcessInfo.action_time.key], unit='ms', errors='coerce' + ).dt.strftime('%Y-%m-%d %H:%M:%S') + ) + + # 设置 check_info + for tid in dcm_task_ids: + filtered = df[df[DcmTaskProcessInfo.dcm_task_id.key] == tid].copy() + if not filtered.empty: + min_row = filtered.loc[filtered[DcmTaskProcessInfo.item_id.key].idxmin()].iloc[0] + dcm_task_check_info[tid] = ( + f"{min_row[DcmTaskProcessInfo.action_time.key]}" + f"{min_row[DcmTaskProcessInfo.human_name.key]}" + f"在{min_row[DcmTaskProcessInfo.act_def_name.key]}阶段" + f"{min_row[DcmTaskProcessInfo.action_name.key]}" + ) + + # 更名,并仅保留需要的列 + df = df.rename(columns=DcmTaskProcessInfoMapping) + df = df[list(DcmTaskProcessInfoMapping.values()) + [DcmTaskProcessInfo.dcm_task_id.key]] + return df + + # 填充处理过程 + await DcmTaskProcessInfo.fill_process_info(dcm_task_df, column_name='handlingProcessList', preprocessing=preprocess) + + # 处理无待办工单处理过程状态 + empty_dcm_task_df = dcm_task_df[dcm_task_df['handlingProcessList'].apply(lambda x: len(x) == 0)] + empty_dcm_task_df[DcmPushStatus.dcm_task_id.key] = empty_dcm_task_df[DcmTask.id.key] + empty_dcm_task_df[DcmPushStatus.push_task_process_info_status.key] = 1 + empty_dcm_task_df = empty_dcm_task_df[[DcmPushStatus.dcm_task_id.key, DcmPushStatus.push_task_process_info_status.key]] + await DcmPushStatus.save_batch(empty_dcm_task_df) + + # 过滤空数组 + full_dcm_task_df = dcm_task_df[dcm_task_df['handlingProcessList'].apply(lambda x: len(x) > 0)] + # 删除 DcmTaskAttachment.dcm_task_id.key 字段 + def remove_dcm_task_id(attachment_list): + for item in attachment_list: + if isinstance(item, dict) and DcmTaskProcessInfo.dcm_task_id.key in item: + del item[DcmTaskProcessInfo.dcm_task_id.key] + return attachment_list + # 执行替换 + full_dcm_task_df['handlingProcessList'] = full_dcm_task_df['handlingProcessList'].apply(remove_dcm_task_id) + # 增加 checkContent 字段,来自办理流程数据合并 + full_dcm_task_df['checkContent'] = full_dcm_task_df[DcmTask.id.key].apply( + lambda x: dcm_task_check_info.get(x, '') + ) + + # 处理数据映射,适应接口推送 + mapped_df = full_dcm_task_df.rename(columns={DcmTask.id.key: 'gdId'}) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + echo_log(f"正在准备请求队列...") + # 构建请求队列 + dcm_push_queue = asyncio.Queue() + + # 向队列中填充请求对象 + for _h, row in mapped_df.iterrows(): + push_request = await oa_api_request.get_push_process_info_request(**row.to_dict()) + setattr(push_request, "dcm_task_id", row.get('gdId')) + await dcm_push_queue.put(push_request) + + # 并发提交推送请求 + echo_log(f"开始推送待办过程数据...") + await requests.async_concurrency( + dcm_push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_process_info_request + ) + echo_log(f"待办过程数据推送已经完成...") + + +if __name__ == "__main__": + from paste.core import aio_pool + _runner = aio_pool.get_aio_runner() + _runner(push_process_info(task_id=2054174091237265408)) diff --git a/dock/oa_dcm/oa_sign_task.py b/dock/oa_dcm/oa_sign_task.py new file mode 100644 index 0000000..798c567 --- /dev/null +++ b/dock/oa_dcm/oa_sign_task.py @@ -0,0 +1,98 @@ +""" +向 OA 平台上报数据已经完整推送。 + +对应文档接口:11、签收 +""" +import asyncio +import json +from typing import Optional, Union + +from sqlalchemy import select, desc +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.oa import oa_api_request +from models.dcm_push_status import DcmPushStatus +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + + +async def after_sign_task_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 签收工单请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + code = udict.get_by_path(body_data, 'code') + message = udict.get_by_path(body_data, 'msg') + if code == 200: + echo_log(f"签收工单成功.") + else: + echo_log(f"签收工单失败:{message}") + + if retry_queue: + echo_log(f"签收工单重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def sign_task(fetch_size: int = 50, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + """ + 签收指定数量的工单任务 + + :param fetch_size: 本次签收的任务数量 + :param task_id: 待办任务 ID 可选 + """ + # 根据条件获取目标任务 ID 列表(支持指定 task_id 或分页获取) + task_query = select(DcmTask.id).join( + DcmPushStatus, DcmPushStatus.dcm_task_id == DcmTask.id + ).where( + DcmPushStatus.push_task_status == 1, + DcmPushStatus.push_task_detail_status == 1, + DcmPushStatus.push_task_attachment_status == 1, + DcmPushStatus.push_task_extend_info_status == 1, + DcmPushStatus.push_task_file_upload_status == 1, + DcmPushStatus.push_task_more_info_status == 1, + DcmPushStatus.push_task_process_info_status == 1, + ).order_by( + desc(DcmTask.act_id) + ) + if task_id: + if isinstance(task_id, list): + task_query = task_query.where(DcmTask.id.in_(task_id)) + echo_log(f"本次签收待办列表:{task_id} 的工单...") + else: + task_query = task_query.where(DcmTask.id == task_id) + echo_log(f"本次签收待办:{task_id} 的工单...") + else: + task_query = task_query.limit(fetch_size) + echo_log(f"本次签收前 {fetch_size} 条待办工单...") + + dcm_task_df = await DcmTask.query_as_df(task_query) + # 格式化为字符串 + dcm_task_df[DcmTask.id.key] = dcm_task_df[DcmTask.id.key].astype(str) + + echo_log(f"正在准备请求队列...") + # 构建请求队列 + sign_request_queue = asyncio.Queue() + task_id_list = dcm_task_df[DcmTask.id.key].unique().tolist() + for task_id in task_id_list: + request = await oa_api_request.get_sign_task_request(task_id) + await sign_request_queue.put(request) + echo_log(f"开始签收工单...") + await requests.async_concurrency( + sign_request_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_sign_task_request + ) + echo_log(f"签收工单完成...") + + +if __name__ == "__main__": + from paste.core import aio_pool + _runner = aio_pool.get_aio_runner() + _runner(sign_task(task_id=2054174091228876801)) \ No newline at end of file diff --git a/dock/oa_dcm/oa_update_post_delay.py b/dock/oa_dcm/oa_update_post_delay.py new file mode 100644 index 0000000..59c7e41 --- /dev/null +++ b/dock/oa_dcm/oa_update_post_delay.py @@ -0,0 +1,88 @@ +""" +操作数字城管的工单延期接口后,向OA推送更新后的工单时间信息 + +对应文档接口:12、更新流程延期信息 +""" +import asyncio +import json +from datetime import datetime + +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.dcm import dcm_scrape +from dock.oa import oa_api, oa_api_request +from models.dcm_task import DcmTask +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + + +async def get_update_process_delay_request(data: dict): + api_url = '/externalWorkOrder/digitalCM/updateProcessDelayInfo' + request_body = data + # 构造 API 请求 + return await oa_api.new_api_request(api_url, request_body) + + +async def after_update_process_delay_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 更新流程延期请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + code = udict.get_by_path(body_data, 'code') + message = udict.get_by_path(body_data, 'msg') + if code == 200: + echo_log(f"更新流程延期成功.") + else: + echo_log(f"更新流程延期失败:{message}") + + if retry_queue: + echo_log(f"更新流程延期重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def update_process_delay(task_id: int): + """ + 流程延期更新之后,推送最新的时间信息 + + :param task_id: 对应的工单ID + """ + + echo_log(f"本次更新{task_id}的最新时间信息...") + dcm_task = await DcmTask.async_find_by_id(task_id) + echo_log(f"开始更新流程延期...") + await dcm_scrape.fetch_single_dcm_task(dcm_task) + # 重新获取更新后的任务对象 + dcm_task: DcmTask = await DcmTask.async_find_by_id(task_id) + if dcm_task is not None: + bundle_deadline_time_str = datetime.fromtimestamp( + dcm_task.bundle_deadline_time // 1000 + ).strftime("%Y-%m-%d %H:%M:%S") if dcm_task.bundle_deadline_time else '' + + rollback_deadline_str = datetime.fromtimestamp( + dcm_task.rollback_deadline // 1000 + ).strftime("%Y-%m-%d %H:%M:%S") if dcm_task.rollback_deadline else '' + + request = await oa_api_request.get_update_process_delay_request( + str(task_id), bundle_deadline_time_str, rollback_deadline_str + ) + queue = asyncio.Queue() + await queue.put(request) + await requests.async_concurrency( + queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_update_process_delay_request + ) + + echo_log(f"更新流程延期完成...") + + +if __name__ == "__main__": + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(update_process_delay(task_id=2054174091228876801)) diff --git a/dock/oa_dcm/oa_upload.py b/dock/oa_dcm/oa_upload.py new file mode 100644 index 0000000..df7d91a --- /dev/null +++ b/dock/oa_dcm/oa_upload.py @@ -0,0 +1,204 @@ +""" +待办工单附件文件上传。 + +对应文档接口:3、文件上传接口 +""" +import asyncio +import hashlib +import io +import json +from typing import Union, Optional +from urllib.parse import urlparse + +import pandas as pd +from sqlalchemy import select, desc, exists +from tornado.httpclient import HTTPResponse, HTTPRequest + +import apps +import dock +from dock.oa import oa_api_request +from models.dcm_push_status import DcmPushStatus +from models.dcm_task import DcmTask +from models.dcm_task_attachment import DcmTaskAttachment +from models.dcm_task_file_upload import DcmTaskFileUpload +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + +file_url_column_name = '_file_url' +""" +文件路径列名。 +""" + + +async def done_attachment_download(response_list: list[HTTPResponse]): + """ + 下载全部完成后处理程序。 + + :param response_list: 响应列表 + :return: + """ + echo_log(f"文件下载全部完成...") + + +async def after_attachment_upload(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 附件上传请求后处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + task_file_upload_id = getattr(response.request, 'dcm_task_file_upload_id') + dcm_task_id = getattr(response.request, 'dcm_task_id') + + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + nas = udict.get_by_path(body_data, 'n_a_s') + if nas: + oa_media_id = body_data.get('atts', [])[0].get('fileUrl') + file_upload: DcmTaskFileUpload = await DcmTaskFileUpload.async_find_by_id(task_file_upload_id) + file_upload.oa_media_id = oa_media_id + file_upload.status = 1 + await file_upload.async_save() + + dcm_task_id = getattr(response.request, "dcm_task_id") + await DcmPushStatus.set_push_task_file_upload_status(dcm_task_id) + echo_log(f"待办:{dcm_task_id} 的附件上传成功.") + else: + echo_log(f"待办:{dcm_task_id} 的附件上传失败.") + + +async def after_attachment_download(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 附件下载请求后处理程序。 + + :param response: 请求对象 + :param retry_queue: 重试队列 + :return: + """ + dcm_task_id = getattr(response.request, 'dcm_task_id') + dcm_task_attachment_id = getattr(response.request, 'dcm_task_attachment_id') + dcm_media_id = getattr(response.request, 'dcm_media_id') + # 计算文件哈希 + file_content = response.body + file_hash = hashlib.md5(file_content).hexdigest() + + echo_log(f"待办:{dcm_task_id} 的附件:{dcm_task_attachment_id} 已经下载完成,附件 HASH 值:{file_hash}.") + echo_log(f"附件下载重试队列中有:{retry_queue.qsize()} 项在等待.") + + # 保存文件上传记录数据 + file_upload_data = { + DcmTaskFileUpload.dcm_task_id.key: dcm_task_id, + DcmTaskFileUpload.dcm_task_attachment_id.key: dcm_task_attachment_id, + DcmTaskFileUpload.dcm_media_id.key: dcm_media_id, + DcmTaskFileUpload.file_hash.key: file_hash, + } + file_upload = await DcmTaskFileUpload.is_exist(dcm_task_id, dcm_task_attachment_id) + if file_upload: + file_upload.copy_from_dict(file_upload_data) + else: + file_upload = DcmTaskFileUpload(**file_upload_data) + await file_upload.async_save() + + file_obj = io.BytesIO(response.body) + file_name = urlparse(response.request.url).path.split('/')[-1] + upload_request = await oa_api_request.get_upload_request(file_obj, file_name=file_name) + setattr(upload_request, 'dcm_task_file_upload_id', file_upload.id) + setattr(upload_request, "dcm_task_id", dcm_task_id) + upload_queue = asyncio.Queue() + await upload_queue.put(upload_request) + echo_log(f"开始推送待办:{dcm_task_id} 的附件数据...") + await requests.async_concurrency( + upload_queue, con_count=1, retry=dock.MAX_RETRY_COUNT, + after_request=after_attachment_upload + ) + + +async def upload_with_attachment(fetch_size: int = 50, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + """ + 推送附件数据。 + :param fetch_size: 本次推送数量 + :param task_id: 待办任务 ID 可选 + :return: + """ + from dock.dcm import dcm_api + # 根据条件获取目标任务 ID 列表(支持指定 task_id 或分页获取) + task_query = select(DcmTask.id).order_by(desc(DcmTask.act_id)) + if task_id: + if isinstance(task_id, list): + task_query = task_query.where(DcmTask.id.in_(task_id)) + echo_log(f"本次上传待办列表:{task_id} 的附件...") + else: + task_query = task_query.where(DcmTask.id == task_id) + echo_log(f"本次上传待办:{task_id} 的附件...") + else: + task_query = task_query.limit(fetch_size) + echo_log(f"本次上传前 {fetch_size} 条附件...") + task_id_list = (await DcmTask.orm_execute_scalars(task_query)).all() + task_id_list = [f"{id}" for id in task_id_list] + + # 查询属于这些任务的所有附件信息 + query = select( + DcmTaskAttachment.id, DcmTaskAttachment.dcm_task_id, + DcmTaskAttachment.media_id, DcmTaskAttachment.media_url + ).where( + DcmTaskAttachment.dcm_task_id.in_(task_id_list) + ) + + # 生产环境过滤掉已成功上传的附件(避免重复上传) + if apps.get_active_env() == 'prod': + # 生产环境,不上传已经成功上传的附件文件 + query = query.where(~exists().where( + (DcmTaskFileUpload.dcm_task_attachment_id == DcmTaskAttachment.id) & + (DcmTaskFileUpload.status == 1) + )) + else: + echo_log(f"非生产环境,上传所有附件.") + + attachment_df = await DcmTaskAttachment.query_as_df(query) + # 格式化为字符串 + attachment_df[DcmTaskAttachment.id.key] = attachment_df[DcmTaskAttachment.id.key].astype(str) + attachment_df[DcmTaskAttachment.dcm_task_id.key] = attachment_df[DcmTaskAttachment.dcm_task_id.key].astype(str) + + # 增加文件下载路径列 + attachment_df[file_url_column_name] = attachment_df.apply( + lambda x: f"/{x.get(DcmTaskAttachment.media_url.key).lstrip('/')}" + if pd.notna(x.media_url) and str(x.media_url).strip() else "", + axis=1 + ) + + # 处理无待办工单上传状态 + empty_task_ids = set(task_id_list) - set(attachment_df[DcmTaskAttachment.dcm_task_id.key].unique().tolist()) + empty_task_data = [ + { + DcmPushStatus.dcm_task_id.key: dcm_task_id, + DcmPushStatus.push_task_file_upload_status.key: 1 + } + for dcm_task_id in list(empty_task_ids) + ] + empty_task_df = pd.DataFrame(empty_task_data) + await DcmPushStatus.save_batch(empty_task_df) + + echo_log(f"正在准备下载队列...") + # 创建下载请求,填充请求队列 + attachment_download_queue = asyncio.Queue() + for _h, _row in attachment_df.iterrows(): + download_request = await dcm_api.new_api_request(_row.get(file_url_column_name), {}, method='GET') + setattr(download_request, 'dcm_task_id', _row.get(DcmTaskAttachment.dcm_task_id.key)) + setattr(download_request, 'dcm_task_attachment_id', _row.get(DcmTaskAttachment.id.key)) + setattr(download_request, 'dcm_media_id', _row.get(DcmTaskAttachment.media_id.key)) + await attachment_download_queue.put(download_request) + # 提交附件下载请求 + await requests.async_concurrency( + attachment_download_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_attachment_download, after_done=done_attachment_download + ) + + +if __name__ == "__main__": + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(upload_with_attachment(task_id=2059900525595328517)) diff --git a/dock/oa_govc/__init__.py b/dock/oa_govc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dock/oa_govs/__init__.py b/dock/oa_govs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dock/oa_govs/govs_push_detail.py b/dock/oa_govs/govs_push_detail.py new file mode 100644 index 0000000..7c08fce --- /dev/null +++ b/dock/oa_govs/govs_push_detail.py @@ -0,0 +1,193 @@ +""" +待办工单明细推送。 + +对应文档接口:5、推送工单详情 +""" +import asyncio +import json +from typing import Optional, Union + +import pandas as pd +from sqlalchemy import select, desc +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import models +from dock.oa import oa_api_request +from models.govs_order_master import GovsOrderMaster +from models.govs_order_detail import GovsOrderDetail +from models.govs_order_user import GovsOrderUser +from models.govs_push_status import GovsPushStatus +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + +GovsOrderDetailMapping = { + GovsOrderMaster.id.key: 'gdId', + GovsOrderDetail.order_id.key: 'workOrderNo', + GovsOrderUser.customer_name.key: 'name', + GovsOrderUser.customer_sex.key: 'gender', + GovsOrderUser.customer_credentials_type.key: 'documentType', + GovsOrderUser.customer_credentials_no.key: 'idNumber', + GovsOrderDetail.call_number.key: 'incomingCallNumber', + GovsOrderDetail.call_time.key: 'callTime', + GovsOrderDetail.order_source_for_view.key: 'workOrderStatus', + GovsOrderUser.customer_connect_phone.key: 'contactPhoneNumber', + GovsOrderDetail.belong_platform_name.key: 'acceptancePlatform', + GovsOrderDetail.form_type.key: 'formType', + GovsOrderDetail.case_is_visit.key: 'whetherToFollowUp', + GovsOrderDetail.case_is_urgent.key: 'urgencyLevel', + GovsOrderDetail.info_protect.key: 'informationProtection', + GovsOrderDetail.relate_order_count.key: 'relatedWorkOrderNo', + GovsOrderDetail.service_object_type.key: 'serviceObjectType', +} +""" +数据推送映射关系。 +""" + + +async def after_push_order_detail_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 工单推送请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + code = udict.get_by_path(body_data, 'code') + message = udict.get_by_path(body_data, 'msg') + if code == 200: + govs_task_id = getattr(response.request, "govs_task_id") + await GovsPushStatus.set_push_order_detail_status(govs_task_id) + echo_log(f"推送工单详情成功.") + else: + echo_log(f"推送工单详情失败:{message}") + + if retry_queue: + echo_log(f"工单详情重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def done_push_order_detail(response_list: list[HTTPResponse]): + """ + 推送完成工单详情后的回调 + """ + unique_task_ids = set() + for response in response_list: + task_id = getattr(response.request, 'govs_task_id', None) + if task_id: + unique_task_ids.add(task_id) + return unique_task_ids + + +async def push_order_detail(fetch_size: int = 50, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + """ + 推送12345工单详情。 + + :param fetch_size: 本次推送数量 + :param task_id: 待办任务 ID 可选 + """ + # 根据条件获取目标任务 ID 列表(支持指定 task_id 或分页获取) + task_query = select(GovsOrderMaster.id).order_by(desc(GovsOrderMaster.id)) + if task_id is not None: + if isinstance(task_id, list): + task_query = task_query.where(GovsOrderMaster.id.in_(task_id)) + echo_log(f"本次推送待办列表:{task_id} 的详细数据...") + else: + task_query = task_query.where(GovsOrderMaster.id == task_id) + echo_log(f"本次推送待办:{task_id} 的详细数据...") + else: + task_query = task_query.limit(fetch_size) + echo_log(f"本次推送前 {fetch_size} 条待办的详细数据...") + task_id_list = (await GovsOrderMaster.orm_execute_scalars(task_query)).all() + task_id_list = [f"{id}" for id in task_id_list] + + # 查询属于这些任务的所有详细数据 + detail_query = select( + GovsOrderMaster.id, GovsOrderMaster.order_id, GovsOrderUser.customer_name, GovsOrderUser.customer_sex, + GovsOrderDetail.call_number, GovsOrderUser.customer_connect_phone, GovsOrderDetail.belong_platform_name, + GovsOrderDetail.area_code_area, GovsOrderDetail.area_code_city, GovsOrderDetail.area_code_street, + GovsOrderDetail.address_detail, GovsOrderDetail.form_type, GovsOrderDetail.case_accord_type_one_name, + GovsOrderDetail.case_accord_type_two_name, GovsOrderDetail.case_accord_type_three_name, + GovsOrderDetail.case_is_visit, GovsOrderDetail.order_source_for_view, + GovsOrderDetail.order_source, GovsOrderDetail.order_source_detail, GovsOrderDetail.case_is_urgent, + GovsOrderDetail.info_protect, GovsOrderDetail.relate_order_count, GovsOrderDetail.service_object_type, + GovsOrderUser.customer_credentials_type, GovsOrderUser.customer_credentials_no, + GovsOrderDetail.call_time + ).join( + GovsOrderUser, GovsOrderMaster.id == GovsOrderUser.master_id + ).join( + GovsOrderDetail, GovsOrderMaster.id == GovsOrderDetail.master_id + ).where( + GovsOrderMaster.id.in_(task_id_list) + ) + + govs_task_df = await GovsOrderMaster.query_as_df(detail_query) + # 格式化为字符串 + govs_task_df[GovsOrderMaster.id.key] = govs_task_df[GovsOrderMaster.id.key].astype(str) + govs_task_df[GovsOrderDetail.relate_order_count.key] = govs_task_df[GovsOrderDetail.relate_order_count.key].astype( + str) + # 拼接诉求地址 + govs_task_df['appealAddress'] = (govs_task_df[GovsOrderDetail.area_code_city.key] + '/' + + govs_task_df[GovsOrderDetail.area_code_area.key] + '/' + + govs_task_df[GovsOrderDetail.area_code_street.key] + '/' + + govs_task_df[GovsOrderDetail.address_detail.key]) + # 拼接诉求归口 + govs_task_df['appealCategory'] = (govs_task_df[GovsOrderDetail.case_accord_type_one_name.key] + '/' + + govs_task_df[GovsOrderDetail.case_accord_type_two_name.key] + '/' + + govs_task_df[GovsOrderDetail.case_accord_type_three_name.key]) + # 拼接诉求来源 + govs_task_df['appealSource'] = (govs_task_df[GovsOrderDetail.order_source.key] + '/' + + govs_task_df[GovsOrderDetail.order_source_detail.key]) + # 日期转换为字符串 + govs_task_df[GovsOrderDetail.call_time.key] = govs_task_df[GovsOrderDetail.call_time.key].apply( + lambda x: x.strftime('%Y-%m-%d %H:%M:%S') if pd.notna(x) and hasattr(x, 'strftime') else x + ) + + # 处理无待办工单详情状态 + empty_task_ids = set(task_id_list) - set(govs_task_df[GovsOrderMaster.id.key].unique().tolist()) + empty_task_data = [ + { + GovsPushStatus.master_id.key: govs_task_id, + GovsPushStatus.push_order_status.key: 1 + } + for govs_task_id in list(empty_task_ids) + ] + empty_task_df = pd.DataFrame(empty_task_data) + await GovsPushStatus.save_batch(empty_task_df) + + # 处理数据映射,适应接口推送 + mapped_df = govs_task_df.rename(columns=GovsOrderDetailMapping) + # 仅保留需要的列 + mapped_df = mapped_df[ + list(GovsOrderDetailMapping.values()) + ['appealAddress', 'appealCategory', 'appealSource']] + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + echo_log(f"正在准备请求队列...") + # 构建请求队列 + govs_push_queue = asyncio.Queue() + + # 向队列中填充请求对象 + for _h, row in mapped_df.iterrows(): + push_request = await oa_api_request.get_push_govs_order_detail_request(**row.to_dict()) + setattr(push_request, "govs_task_id", row.get('gdId')) + await govs_push_queue.put(push_request) + + # 并发提交推送请求 + echo_log(f"开始推送工单详情数据...") + pushed_task_ids = await requests.async_concurrency( + govs_push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_order_detail_request, after_done=done_push_order_detail + ) + echo_log(f"工单详情推送已经完成...") + return pushed_task_ids + + +if __name__ == "__main__": + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(push_order_detail(fetch_size=60)) diff --git a/dock/oa_govs/govs_push_order.py b/dock/oa_govs/govs_push_order.py new file mode 100644 index 0000000..2d3c126 --- /dev/null +++ b/dock/oa_govs/govs_push_order.py @@ -0,0 +1,177 @@ +""" +待签收工单推送。 + +对应文档接口:2、推送待签收工单列表 +""" + +import asyncio +import json +import pandas as pd +from sqlalchemy import select +from tornado.httpclient import HTTPResponse, HTTPRequest + +import models +import apps +import dock +from dock.oa import oa_api_request +from dock.govs import govs_save_sign +from dock.oa_govs import oa_sign_task +from models.govs_order_master import GovsOrderMaster +from models.govs_push_status import GovsPushStatus +from typing import Optional, Union +from paste.core.logging import echo_log +from paste.web import requests +from paste.util import udict + +GOVS_MASTER_MAPPING = { + GovsOrderMaster.id.key: 'gdId', + GovsOrderMaster.order_id.key: 'workOrderNo', + GovsOrderMaster.case_content.key: 'appealContent', + GovsOrderMaster.case_goal.key: 'appealPurpose', + GovsOrderMaster.plan_sign_time.key: 'plannedSignOffTime', + GovsOrderMaster.plan_finish_time.key: 'plannedCompletionTime', + GovsOrderMaster.order_status.key: 'workOrderStatus', + GovsOrderMaster.claim_status.key: 'signOffStatus', + GovsOrderMaster.plan_back_time.key: 'returnDeadline', + GovsOrderMaster.handle_time.key: 'assignToSubordinateTime', + GovsOrderMaster.back_time.key: 'subordinateReturnTime', + GovsOrderMaster.complete_time.key: 'subordinateCompletionTime', + GovsOrderMaster.update_date.key: 'platformUpdateTime', + GovsOrderMaster.service_object_type.key: 'appealType' +} +""" +数据推送映射关系。 +""" + + +async def after_push_order_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 工单推送请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + code = udict.get_by_path(body_data, 'code') + message = udict.get_by_path(body_data, 'msg') + if code == 200: + gov_task_id_list = getattr(response.request, "gov_task_id_list", []) + order_push_data = [ + { + GovsPushStatus.master_id.key: govs_task_id, + GovsPushStatus.push_order_status.key: 1 + } + for govs_task_id in gov_task_id_list + ] + govs_task_push_df = pd.DataFrame(order_push_data) + await GovsPushStatus.save_batch(govs_task_push_df) + echo_log(f"推送待签收工单成功.") + else: + echo_log(f"推送待签收工单失败:{message}") + + if retry_queue: + echo_log(f"待签收工单重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def done_order_push(response_list: list[HTTPResponse]): + """ + 待签收工单列表推送完成的回调 + """ + unique_task_ids = set() + for response in response_list: + gov_task_id_list = getattr(response.request, "gov_task_id_list", []) + unique_task_ids.update(gov_task_id_list) + return unique_task_ids + + +async def push_order(fetch_size: int = 50, batch_size: int = 10, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + """ + 推送待签收工单列表。 + + :param fetch_size: 本次推送数量 + :param batch_size: 分批时,每批大小 + :param task_id: 待办任务 ID 可选 + """ + # 根据条件获取目标任务 ID 列表(支持指定 task_id 或分页获取) + task_query = select(GovsOrderMaster.id, GovsOrderMaster.order_id, GovsOrderMaster.case_content, + GovsOrderMaster.case_goal, GovsOrderMaster.plan_finish_time, GovsOrderMaster.plan_sign_time, + GovsOrderMaster.service_object_type, GovsOrderMaster.claim_status, + GovsOrderMaster.plan_back_time, + GovsOrderMaster.handle_time, GovsOrderMaster.back_time, GovsOrderMaster.complete_time, + GovsOrderMaster.update_date, GovsOrderMaster.order_status).order_by(GovsOrderMaster.id) + # 生产环境不推送已推送过的或已签收的 + if apps.get_active_env() == 'prod': + task_query = task_query.join( + GovsPushStatus, GovsPushStatus.master_id == GovsOrderMaster.id + ).where((GovsOrderMaster.govs_sign != 1) & (GovsPushStatus.push_order_status != 1)) + if task_id: + if isinstance(task_id, list): + task_query = task_query.where(GovsOrderMaster.id.in_(task_id)) + echo_log(f"本次推送待签收工单列表:{task_id} 的数据...") + else: + task_query = task_query.where(GovsOrderMaster.id == task_id) + echo_log(f"本次推送待签收工单:{task_id} 的数据...") + else: + task_query = task_query.limit(fetch_size) + echo_log(f"本次推送前 {fetch_size} 条待签收工单数据...") + govs_task = await GovsOrderMaster.query_as_df(task_query) + # 格式化为字符串 + govs_task[GovsOrderMaster.id.key] = govs_task[GovsOrderMaster.id.key].astype(str) + for key in [GovsOrderMaster.plan_finish_time.key, GovsOrderMaster.plan_sign_time.key, + GovsOrderMaster.handle_time.key, GovsOrderMaster.back_time.key, + GovsOrderMaster.complete_time.key, GovsOrderMaster.update_date.key, + GovsOrderMaster.plan_back_time.key]: + govs_task[key] = govs_task[key].apply( + lambda x: x.strftime('%Y-%m-%d %H:%M:%S') if pd.notna(x) and hasattr(x, 'strftime') else x + ) + + # 处理数据映射,适应接口推送 + mapped_df = govs_task.rename(columns=GOVS_MASTER_MAPPING) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + echo_log(f"正在准备请求队列...") + # 构建请求队列 + govs_push_queue = asyncio.Queue() + + # 向队列中填充请求对象 + for start in range(0, len(mapped_df), batch_size): + batch_df: pd.DataFrame = mapped_df.iloc[start:start + batch_size] + push_list = batch_df.to_dict('records') + push_request = await oa_api_request.get_push_govs_order_master_request(push_list) + setattr(push_request, "gov_task_id_list", batch_df['gdId'].unique().tolist()) + await govs_push_queue.put(push_request) + + # 并发提交推送请求 + echo_log(f"开始推送待签收工单数据...") + pushed_order_ids = await requests.async_concurrency( + govs_push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_order_request, after_done=done_order_push + ) + echo_log(f"待签收数据推送已经完成...") + return pushed_order_ids + + +async def push_full_order(fetch_size: int = 50, batch_size: int = 10, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + from dock.oa_govs import govs_push_process, govs_push_detail + # 推送待办工单列表,并获取推送成功的工单id + pushed_order_ids = await push_order(fetch_size, batch_size, task_id) + pushed_order_ids = list(pushed_order_ids) + # 只推送推送成功的工单的详情和办理过程 + await govs_push_detail.push_order_detail(task_id=pushed_order_ids) + await govs_push_process.push_order_process(task_id=pushed_order_ids) + # 在省12345平台签收推送成功的工单 + await govs_save_sign.sign_order_bypass_api(pushed_order_ids) + # OA平台签收工单 + await oa_sign_task.sign_task(task_id=pushed_order_ids) + + +if __name__ == '__main__': + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(push_full_order(fetch_size=50)) diff --git a/dock/oa_govs/govs_push_process.py b/dock/oa_govs/govs_push_process.py new file mode 100644 index 0000000..2f94773 --- /dev/null +++ b/dock/oa_govs/govs_push_process.py @@ -0,0 +1,260 @@ +""" +待办工单办理过程推送。 + +对应文档接口:5、推送工单处理流程列表 +""" +import asyncio +import io +import json +from typing import Optional, Union + +import pandas as pd +from sqlalchemy import select, desc +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +import models +from dock.oa import oa_api_request +from dock.govs import govs_download_file +from models.govs_order_master import GovsOrderMaster +from models.govs_order_process import GovsOrderProcess +from models.govs_push_status import GovsPushStatus +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + +GovsOrderProcessMapping = { + GovsOrderProcess.id.key: 'id', + GovsOrderProcess.master_id.key: 'parentId', + GovsOrderProcess.action_name.key: 'processingSteps', + GovsOrderProcess.handler_org_names.key: 'processingDepartment', + GovsOrderProcess.handler_user_names.key: 'processor', + GovsOrderProcess.deal_type.key: 'processingMethod', + GovsOrderProcess.next_org_names.key: 'receivingDepartment', + GovsOrderProcess.next_handler_user_names.key: 'receiver', + GovsOrderProcess.adv_content.key: 'processingOpinion', + GovsOrderProcess.deal_date.key: 'processingTime', + GovsOrderProcess.plan_finish_time.key: 'plannedCompletionTime', + GovsOrderProcess.remarks.key: 'remarks', + GovsOrderProcess.attachment_dto_list.key: 'fileIdList' +} +""" +数据推送映射关系。 +""" + + +async def after_push_order_process_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 工单推送请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + code = udict.get_by_path(body_data, 'code') + message = udict.get_by_path(body_data, 'msg') + if code == 200: + govs_task_id = getattr(response.request, "govs_task_id") + await GovsPushStatus.set_push_order_process_status(govs_task_id) + echo_log(f"推送工单办理过程成功.") + else: + echo_log(f"推送工单办理过程失败:{message}") + + if retry_queue: + echo_log(f"工单办理过程重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def done_push_order_process(response_list: list[HTTPResponse]): + """ + 推送完成工单办理过程后的回调 + """ + unique_task_ids = set() + for response in response_list: + task_id = getattr(response.request, 'govs_task_id', None) + if task_id: + unique_task_ids.add(task_id) + return unique_task_ids + + +async def done_attachment_download(response_list: list[HTTPResponse]): + """ + 所有附件下载完成执行的处理程序。 + + :param response_list: 附件下载响应列表 + :return: 返回附件字典列表,每个元素包含文件名和io对象 + """ + downloaded_attachments = [] + for response in response_list: + file_name = getattr(response.request, "file_name", "file") + file_io = io.BytesIO(response.body) + downloaded_attachments.append({ + "file_name": file_name, "file_io": file_io + }) + return downloaded_attachments + + +async def done_attachment_upload(response_list: list[HTTPResponse]): + """ + 所有附件上传到OA完成后执行的处理程序 + + :param response_list: 附件下载响应列表 + :return: 返回文件id列表 + """ + file_ids = [] + for response in response_list: + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + nas = udict.get_by_path(body_data, 'n_a_s') + if nas: + oa_media_id = body_data.get('atts', [])[0].get('fileUrl') + file_ids.append(oa_media_id) + echo_log(f"省12345的办理过程的附件上传成功.") + else: + echo_log(f"省12345的办理过程的附件上传失败.") + return file_ids + + +async def download_and_upload_attachment(attachment_list: list): + """ + 从省12345下载附件,并上传到OA + + :param attachment_list: 附件信息列表 + :return: 返回上传OA成功的文件id列表 + """ + download_queue = asyncio.Queue() + for attachment in attachment_list: + download_request = await govs_download_file.get_download_request('1773611023340371969', attachment['filePath']) + setattr(download_request, 'file_name', attachment['attachName']) + await download_queue.put(download_request) + downloaded_attachments = await requests.async_concurrency(download_queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, + after_done=done_attachment_download) + oa_upload_queue = asyncio.Queue() + for downloaded_attachment in downloaded_attachments: + upload_request = await oa_api_request.get_upload_request(downloaded_attachment['file_io'], + downloaded_attachment['file_name'], False) + await oa_upload_queue.put(upload_request) + uploaded_attachment_ids = await requests.async_concurrency(oa_upload_queue, con_count=dock.CONCURRENCY_COUNT, + retry=dock.MAX_RETRY_COUNT, + after_done=done_attachment_upload) + return uploaded_attachment_ids + + +async def push_order_process(fetch_size: int = 50, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + """ + 推送12345工单办理过程。 + + :param fetch_size: 本次推送数量 + :param task_id: 待办任务 ID 可选 + """ + # 根据条件获取目标任务 ID 列表(支持指定 task_id 或分页获取) + task_query = select(GovsOrderMaster.id).order_by(desc(GovsOrderMaster.id)) + if task_id is not None: + if isinstance(task_id, list): + task_query = task_query.where(GovsOrderMaster.id.in_(task_id)) + echo_log(f"本次推送待办列表:{task_id} 的详细数据...") + else: + task_query = task_query.where(GovsOrderMaster.id == task_id) + echo_log(f"本次推送待办:{task_id} 的详细数据...") + else: + task_query = task_query.limit(fetch_size) + echo_log(f"本次推送前 {fetch_size} 条待办的详细数据...") + govs_task_df = await GovsOrderProcess.query_as_df(task_query) + + # 预处理数据方法 + def preprocess(df: pd.DataFrame): + # 格式化为字符串 + df[GovsOrderProcess.id.key] = df[GovsOrderProcess.id.key].astype(str) + df[GovsOrderProcess.master_id.key] = df[GovsOrderProcess.master_id.key].astype( + str) + + # 日期转换为字符串 + df[GovsOrderProcess.deal_date.key] = df[GovsOrderProcess.deal_date.key].apply( + lambda x: x.strftime('%Y-%m-%d %H:%M:%S') if pd.notna(x) and hasattr(x, 'strftime') else '' + ) + df[GovsOrderProcess.plan_finish_time.key] = df[GovsOrderProcess.plan_finish_time.key].apply( + lambda x: x.strftime('%Y-%m-%d %H:%M:%S') if pd.notna(x) and hasattr(x, 'strftime') else '' + ) + # 更名,并仅保留需要的列 + df = df.rename(columns=GovsOrderProcessMapping) + df = df[list(GovsOrderProcessMapping.values())] + df[GovsOrderProcess.master_id.key] = df['parentId'] + return df + + # 填充处理过程 + await GovsOrderProcess.fill_process_info(govs_task_df, column_name='processLogBOList', preprocessing=preprocess) + + # 处理无待办工单处理过程状态 + empty_govs_task_df = govs_task_df[govs_task_df['processLogBOList'].apply(lambda x: len(x) == 0)] + empty_govs_task_df[GovsPushStatus.master_id.key] = empty_govs_task_df[GovsOrderMaster.id.key] + empty_govs_task_df[GovsPushStatus.push_order_process_status.key] = 1 + empty_govs_task_df = empty_govs_task_df[ + [GovsPushStatus.master_id.key, GovsPushStatus.push_order_process_status.key]] + await GovsPushStatus.save_batch(empty_govs_task_df) + + # 过滤空数组 + full_govs_task_df = govs_task_df[govs_task_df['processLogBOList'].apply(lambda x: len(x) > 0)] + + # 删除 processLogBOList内的工单id字段 + def remove_govs_task_id(data_list): + for item in data_list: + if isinstance(item, dict) and GovsOrderProcess.master_id.key in item: + del item[GovsOrderProcess.master_id.key] + return data_list + + # 执行替换 + full_govs_task_df['processLogBOList'] = full_govs_task_df['processLogBOList'].apply(remove_govs_task_id) + + # 处理数据映射,适应接口推送 + mapped_df = full_govs_task_df.rename(columns={GovsOrderMaster.id.key: 'gdId'}) + mapped_df['gdId'] = mapped_df['gdId'].astype(str) + # 这里把空数据都换成 None,以便存入数据库时是 null + mapped_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + echo_log(f"正在准备请求队列...") + # 构建请求队列 + push_queue = asyncio.Queue() + + # 向队列中填充请求对象 + for _h, row in mapped_df.iterrows(): + row_data = row.to_dict() + process_list = row_data['processLogBOList'] + for i in range(len(process_list)): + attachment_list = process_list[i].get('fileIdList') + if not attachment_list: + process_list[i]['fileIdList'] = [] + else: + if isinstance(attachment_list, str): + try: + attachment_list = json.loads(attachment_list) + # 从省12345下载附件,然后上传到OA,获取id列表 + oa_file_ids = await download_and_upload_attachment(attachment_list) + process_list[i]['fileIdList'] = oa_file_ids + except Exception as e: + echo_log(f'下载省12345的附件并上传到OA时遇到错误:{e}', is_log_exc=True) + process_list[i]['fileIdList'] = [] + else: + process_list[i]['fileIdList'] = [] + push_request = await oa_api_request.get_push_gov_process_request(**row_data) + setattr(push_request, "govs_task_id", row.get('gdId')) + await push_queue.put(push_request) + + # 并发提交推送请求 + echo_log(f"开始推送待办过程数据...") + pushed_task_ids = await requests.async_concurrency( + push_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_push_order_process_request, after_done=done_push_order_process + ) + echo_log(f"待办过程数据推送已经完成...") + return pushed_task_ids + + +if __name__ == "__main__": + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(push_order_process(fetch_size=60)) diff --git a/dock/oa_govs/oa_sign_task.py b/dock/oa_govs/oa_sign_task.py new file mode 100644 index 0000000..df3f889 --- /dev/null +++ b/dock/oa_govs/oa_sign_task.py @@ -0,0 +1,95 @@ +""" +向 OA 平台上报数据已经完整推送。 + +对应文档接口:8、签收 +""" +import asyncio +import json +from typing import Optional, Union + +from sqlalchemy import select, desc +from tornado.httpclient import HTTPResponse, HTTPRequest + +import dock +from dock.oa import oa_api_request +from models.govs_push_status import GovsPushStatus +from models.govs_order_master import GovsOrderMaster +from paste.core.logging import echo_log +from paste.util import udict +from paste.web import requests + + +async def after_sign_task_request(response: HTTPResponse, retry_queue: asyncio.Queue[HTTPRequest]): + """ + 签收工单请求响应后的处理程序。 + + :param response: 响应对象 + :param retry_queue: 重试队列 + """ + body = response.body.decode() + echo_log(body) + body_data = json.loads(body) + code = udict.get_by_path(body_data, 'code') + message = udict.get_by_path(body_data, 'msg') + if code == 200: + echo_log(f"签收工单成功.") + else: + echo_log(f"签收工单失败:{message}") + + if retry_queue: + echo_log(f"签收工单重试队列中有:{retry_queue.qsize()} 个请求在等待.") + + +async def sign_task(fetch_size: int = 50, + task_id: Optional[Union[str, int, list[Union[str, int]]]] = None): + """ + 签收指定数量的工单任务,或签收指定id的工单 + + :param fetch_size: 本次签收的任务数量 + :param task_id: 待办任务 ID 可选 + """ + # 根据条件获取目标任务 ID 列表(支持指定 task_id 或分页获取) + task_query = select(GovsOrderMaster.id).join( + GovsPushStatus, GovsPushStatus.master_id == GovsOrderMaster.id + ).where( + GovsPushStatus.push_order_status == 1, + GovsPushStatus.push_order_detail_status == 1, + GovsPushStatus.push_order_process_status == 1 + ).order_by( + desc(GovsOrderMaster.id) + ) + if task_id is not None: + if isinstance(task_id, list): + task_query = task_query.where(GovsOrderMaster.id.in_(task_id)) + echo_log(f"本次签收待办列表:{task_id} 的工单...") + else: + task_query = task_query.where(GovsOrderMaster.id == task_id) + echo_log(f"本次签收待办:{task_id} 的工单...") + else: + task_query = task_query.limit(fetch_size) + echo_log(f"本次签收前 {fetch_size} 条待办工单...") + + govs_task_df = await GovsOrderMaster.query_as_df(task_query) + # 格式化为字符串 + govs_task_df[GovsOrderMaster.id.key] = govs_task_df[GovsOrderMaster.id.key].astype(str) + + echo_log(f"正在准备请求队列...") + # 构建请求队列 + sign_request_queue = asyncio.Queue() + task_id_list = govs_task_df[GovsOrderMaster.id.key].unique().tolist() + for task_id in task_id_list: + request = await oa_api_request.get_sign_govs_task_request(task_id) + await sign_request_queue.put(request) + echo_log(f"开始签收工单...") + await requests.async_concurrency( + sign_request_queue, con_count=dock.CONCURRENCY_COUNT, retry=dock.MAX_RETRY_COUNT, + after_request=after_sign_task_request + ) + echo_log(f"签收工单完成...") + + +if __name__ == "__main__": + from paste.core import aio_pool + + _runner = aio_pool.get_aio_runner() + _runner(sign_task(task_id=2057694366475214849)) diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..e329f10 --- /dev/null +++ b/environment.yml @@ -0,0 +1,53 @@ +# environment.yml +name: d3i-env +channels: + - conda-forge + - defaults + +dependencies: + - python=3.11 + - aiohttp>=3.13.0 + - aiomysql==0.3.2 + - aioquic>=1.3.0 + - aiosqlite>=0.22.1 + - aiofiles>=23.0.0 + - cryptography==46.0.3 + - matplotlib>=3.10.1 + - matplotlib-inline>=0.1.7 + - numpy>=1.24.0 + - openpyxl>=3.1.5 + - pandas>=2.0.0 + - pillow>=10.0.0 + - psutil>=5.9.0 + - PyJWT>=1.7.1 + - PyMySQL>=1.1.0 + - pyOpenSSL>=24.3.0 + - pytest>=8.0.0 + - pytest-asyncio>=0.23.0 + - pytest-cov>=4.0.0 + - PyYAML>=6.0.2 + - requests>=2.32.5 + - selenium>=4.38.0 + - scipy>=1.14.0 + - sqlalchemy>=2.0.50 + - svgwrite>=1.4.2 + - tabulate>=0.9.0 + - tinycss2>=1.4.0 + - tinyhtml5>=2.0.0 + - tornado>=6.4 + - weasyprint>=64.1 + - WTForms>=3.2.1 + - pip + - pip: + - ddddocr>=1.6.1 + - gmssl>=3.2.2 + - javaobj-py3>=0.4.4 + - jieba>=0.42.1 + - jpush>=3.3.9 + - redis>=5.2.1 + - opencv-python>=4.11.0.86 + - pycurl>=7.46.0 + - pypinyin>=0.55.0 + - seaborn>=0.13.2 + - tornado-swagger>=1.4.5 + - tornado-wtforms>=0.0.1 \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..d55df60 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,17 @@ +import datetime + +import numpy as np +import pandas as pd + +EmptyInDF = ['null', f'{pd.NaT}', None, np.nan, pd.NA, pd.NaT] +""" +在 pd.DataFrame 中,应当被替换为空字符串 '' 的数据。 +""" + +EmptyDatetimeInDF = [ + datetime.date(1, 1, 1), + datetime.datetime(1, 1, 1, 0, 0, 0) +] +""" +在 pd.DataFrame 中,应当被替换为空字符串 '' 的日期时间数据。 +""" \ No newline at end of file diff --git a/models/__pycache__/__init__.cpython-311.pyc b/models/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..2f86e1a Binary files /dev/null and b/models/__pycache__/__init__.cpython-311.pyc differ diff --git a/models/__pycache__/common_model.cpython-311.pyc b/models/__pycache__/common_model.cpython-311.pyc new file mode 100644 index 0000000..6acd71b Binary files /dev/null and b/models/__pycache__/common_model.cpython-311.pyc differ diff --git a/models/__pycache__/db_models.cpython-311.pyc b/models/__pycache__/db_models.cpython-311.pyc new file mode 100644 index 0000000..6c4484b Binary files /dev/null and b/models/__pycache__/db_models.cpython-311.pyc differ diff --git a/models/__pycache__/dcm_apply_delay.cpython-311.pyc b/models/__pycache__/dcm_apply_delay.cpython-311.pyc new file mode 100644 index 0000000..b74b030 Binary files /dev/null and b/models/__pycache__/dcm_apply_delay.cpython-311.pyc differ diff --git a/models/__pycache__/dcm_apply_rollback.cpython-311.pyc b/models/__pycache__/dcm_apply_rollback.cpython-311.pyc new file mode 100644 index 0000000..76842bb Binary files /dev/null and b/models/__pycache__/dcm_apply_rollback.cpython-311.pyc differ diff --git a/models/__pycache__/dcm_dispose.cpython-311.pyc b/models/__pycache__/dcm_dispose.cpython-311.pyc new file mode 100644 index 0000000..15e458c Binary files /dev/null and b/models/__pycache__/dcm_dispose.cpython-311.pyc differ diff --git a/models/__pycache__/dcm_push_status.cpython-311.pyc b/models/__pycache__/dcm_push_status.cpython-311.pyc new file mode 100644 index 0000000..32a2264 Binary files /dev/null and b/models/__pycache__/dcm_push_status.cpython-311.pyc differ diff --git a/models/__pycache__/dcm_rollback.cpython-311.pyc b/models/__pycache__/dcm_rollback.cpython-311.pyc new file mode 100644 index 0000000..29d7f18 Binary files /dev/null and b/models/__pycache__/dcm_rollback.cpython-311.pyc differ diff --git a/models/__pycache__/dcm_stage_reply.cpython-311.pyc b/models/__pycache__/dcm_stage_reply.cpython-311.pyc new file mode 100644 index 0000000..4019d69 Binary files /dev/null and b/models/__pycache__/dcm_stage_reply.cpython-311.pyc differ diff --git a/models/__pycache__/dcm_task.cpython-311.pyc b/models/__pycache__/dcm_task.cpython-311.pyc new file mode 100644 index 0000000..dcd80f5 Binary files /dev/null and b/models/__pycache__/dcm_task.cpython-311.pyc differ diff --git a/models/__pycache__/dcm_task_attachment.cpython-311.pyc b/models/__pycache__/dcm_task_attachment.cpython-311.pyc new file mode 100644 index 0000000..ab1386f Binary files /dev/null and b/models/__pycache__/dcm_task_attachment.cpython-311.pyc differ diff --git a/models/__pycache__/dcm_task_extend_info.cpython-311.pyc b/models/__pycache__/dcm_task_extend_info.cpython-311.pyc new file mode 100644 index 0000000..708181f Binary files /dev/null and b/models/__pycache__/dcm_task_extend_info.cpython-311.pyc differ diff --git a/models/__pycache__/dcm_task_file_upload.cpython-311.pyc b/models/__pycache__/dcm_task_file_upload.cpython-311.pyc new file mode 100644 index 0000000..4c1b274 Binary files /dev/null and b/models/__pycache__/dcm_task_file_upload.cpython-311.pyc differ diff --git a/models/__pycache__/dcm_task_form_datum.cpython-311.pyc b/models/__pycache__/dcm_task_form_datum.cpython-311.pyc new file mode 100644 index 0000000..4f08d72 Binary files /dev/null and b/models/__pycache__/dcm_task_form_datum.cpython-311.pyc differ diff --git a/models/__pycache__/dcm_task_more_info.cpython-311.pyc b/models/__pycache__/dcm_task_more_info.cpython-311.pyc new file mode 100644 index 0000000..ad2692c Binary files /dev/null and b/models/__pycache__/dcm_task_more_info.cpython-311.pyc differ diff --git a/models/__pycache__/dcm_task_process_info.cpython-311.pyc b/models/__pycache__/dcm_task_process_info.cpython-311.pyc new file mode 100644 index 0000000..b8bbc76 Binary files /dev/null and b/models/__pycache__/dcm_task_process_info.cpython-311.pyc differ diff --git a/models/__pycache__/govs_order_attachment.cpython-311.pyc b/models/__pycache__/govs_order_attachment.cpython-311.pyc new file mode 100644 index 0000000..ac5825e Binary files /dev/null and b/models/__pycache__/govs_order_attachment.cpython-311.pyc differ diff --git a/models/__pycache__/govs_order_detail.cpython-311.pyc b/models/__pycache__/govs_order_detail.cpython-311.pyc new file mode 100644 index 0000000..4948412 Binary files /dev/null and b/models/__pycache__/govs_order_detail.cpython-311.pyc differ diff --git a/models/__pycache__/govs_order_master.cpython-311.pyc b/models/__pycache__/govs_order_master.cpython-311.pyc new file mode 100644 index 0000000..b96891f Binary files /dev/null and b/models/__pycache__/govs_order_master.cpython-311.pyc differ diff --git a/models/__pycache__/govs_order_process.cpython-311.pyc b/models/__pycache__/govs_order_process.cpython-311.pyc new file mode 100644 index 0000000..65c0aaa Binary files /dev/null and b/models/__pycache__/govs_order_process.cpython-311.pyc differ diff --git a/models/__pycache__/govs_order_user.cpython-311.pyc b/models/__pycache__/govs_order_user.cpython-311.pyc new file mode 100644 index 0000000..d5cf916 Binary files /dev/null and b/models/__pycache__/govs_order_user.cpython-311.pyc differ diff --git a/models/__pycache__/token.cpython-311.pyc b/models/__pycache__/token.cpython-311.pyc new file mode 100644 index 0000000..6543c31 Binary files /dev/null and b/models/__pycache__/token.cpython-311.pyc differ diff --git a/models/common_model.py b/models/common_model.py new file mode 100644 index 0000000..2aacea4 --- /dev/null +++ b/models/common_model.py @@ -0,0 +1,94 @@ +import datetime + +from paste.db.basemodel import BaseModel + + +class CommonModel(BaseModel): + """ + 公共模型类。重写某些 BaseModel 类中的方法。 + """ + + __abstract__ = True + + @classmethod + def trans_format(cls, dic: dict, skip_keys: list[str] = None, + trans_datetime: bool = False, long_to_str: bool = False): + """ + 对特殊日期及长整数进行格式转换:: + + 1、仅处理字典类型数据。 + 2、日期、时间日期格式数据,若年份小于 1000 则视为无效日期,转为空字符串。 + 3、长度大于 16 位的整数转为字符串。 + + :param dic: 需要转换的字典 + :param skip_keys: 跳过的,不需要转换的键。跳过的字段会一直延续到内部对象 + :param trans_datetime: 是否转换日期时间字段中的特殊日期。 + :param long_to_str: 当 bigint 长度大于 16 位时转为 str + :return: 转换后的字典 + """ + _inner_dic = dic.copy() + + # 遍历处理 + for _key, _val in dic.items(): + # 删除跳过的键值 + if skip_keys and _key in skip_keys: + _inner_dic.pop(_key, None) + continue + + if not _val: + # 空项跳过 + continue + + # 转换日期时间 + if trans_datetime and isinstance(_val, (datetime.date, datetime.datetime)): + _year = _val.year + if _year <= 1000: + _inner_dic[_key] = '' + + # bigint 转 str + if long_to_str and isinstance(_val, int) and len(str(_val)) > 16: + _inner_dic[_key] = str(_val) + + # 处理列表 + if isinstance(_val, list): + _tmp_list = [] + for _v in _val: + if isinstance(_v, dict): + # 递归处理字典 + _tmp_list.append(cls.trans_format( + _v, skip_keys=skip_keys, trans_datetime=trans_datetime, long_to_str=long_to_str + )) + else: + _tmp_list.append(_v) + _inner_dic[_key] = _tmp_list + + # 递归处理字典 + if isinstance(_val, dict): + _inner_dic[_key] = cls.trans_format( + _val, skip_keys=skip_keys, trans_datetime=trans_datetime, long_to_str=long_to_str + ) + + return _inner_dic + + def to_dict(self, skip_fields: list[str] = None, trans_datetime: bool = False, long_to_str: bool = False): + """ + 数据模型转字典。 + + :param skip_fields: 跳过的,不需要转换的字段。跳过的字段会一直延续到内部对象 + :param trans_datetime: 是否转换日期时间字段中的特殊日期。 + :param long_to_str: 当 bigint 长度大于 16 位时转为 str + :return: 转换后的字典数据 + """ + model_dict = super().to_dict() + + # 没有需要处理的行为,直接返回 + _has_action = skip_fields or trans_datetime or long_to_str + if not _has_action: + return model_dict + + # 对特殊日期和长整数进行格式转换,跳过的字段会一直延续到内部对象 + model_dict = self.trans_format( + model_dict, skip_keys=skip_fields, trans_datetime=trans_datetime, long_to_str=long_to_str + ) + + return model_dict diff --git a/models/db_models.py b/models/db_models.py new file mode 100644 index 0000000..145973c --- /dev/null +++ b/models/db_models.py @@ -0,0 +1,1700 @@ +# coding: utf-8 +from sqlalchemy import Column, DECIMAL, DateTime, ForeignKey, Index, String, Text, text, Float +from sqlalchemy.dialects.mysql import BIGINT, BIT, INTEGER, TINYINT, MEDIUMTEXT +from sqlalchemy.orm import relationship + +from paste.db.basemodel import BaseModel + + +class TD3iDcmApplyPostpone(BaseModel): + __tablename__ = 't_d3i_dcm_apply_postpone' + __table_args__ = {'comment': '数字城管-申请延期接口表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + dcm_task_id = Column(ForeignKey('t_d3i_dcm_task.id'), nullable=False, index=True, comment='唯一标志') + task_number = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='任务号') + apply_act_id = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='工单流程ID') + reply_part_id = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='回复环节ID') + ard_level = Column(String(32, 'utf8mb4_unicode_ci'), nullable=False, comment='固定值') + ard_type_id = Column(String(32, 'utf8mb4_unicode_ci'), nullable=False, comment='固定值(延期类型)') + apply_memo = Column(Text(collation='utf8mb4_unicode_ci'), nullable=False, comment='申请意见') + time_num = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='延期时长') + postpone_date = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='延期日期') + time_unit = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='时间单位') + attachments = Column(Text(collation='utf8mb4_unicode_ci'), comment='附件') + delay_multiple = Column(INTEGER(11), comment='延期倍数') + apply_type = Column(String(64, 'utf8mb4_unicode_ci'), comment='申请类型') + status = Column(BIGINT(20), nullable=False, comment='提交状态') + flow_token = Column(String(256, 'utf8mb4_unicode_ci'), comment='流令牌') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='修改时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='修改者') + + dcm_task = relationship('TD3iDcmTask') + + +class TD3iDcmDispose(BaseModel): + __tablename__ = 't_d3i_dcm_dispose' + __table_args__ = {'comment': '数字城管-批转接口表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + dcm_task_id = Column(ForeignKey('t_d3i_dcm_task.id'), nullable=False, index=True, comment='唯一标志') + task_number = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='任务号') + act_id = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='工单ID') + task_list_id = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='任务列表ID') + trans_info = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='批转对象(固定:市受理员)') + opinion = Column(Text(collation='utf8mb4_unicode_ci'), nullable=False, comment='批转意见') + add_num = Column(String(32, 'utf8mb4_unicode_ci'), nullable=False, comment='批转意见') + attachments = Column(Text(collation='utf8mb4_unicode_ci'), comment='附件') + send_message = Column(String(32, 'utf8mb4_unicode_ci'), nullable=False, comment='发送短信(1:发送,0:不发送)') + undertake_user_name = Column(String(64, 'utf8mb4_unicode_ci'), comment='承办人员') + undertake_phone = Column(String(64, 'utf8mb4_unicode_ci'), comment='联系电话') + status = Column(BIGINT(20), nullable=False, server_default=text("0"), comment='提交状态') + flow_token = Column(String(256, 'utf8mb4_unicode_ci'), comment='流令牌') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='修改时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='修改者') + + dcm_task = relationship('TD3iDcmTask') + + +class TD3iDcmRollback(BaseModel): + __tablename__ = 't_d3i_dcm_rollback' + __table_args__ = {'comment': '数字城管-回退接口表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + dcm_task_id = Column(ForeignKey('t_d3i_dcm_task.id'), nullable=False, index=True, comment='唯一标志') + task_number = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='任务号') + act_id = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='工单ID') + trans_info = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='回退流向(固定:市受理员)') + save_old_act_flag = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='是否保留旧流程') + opinion = Column(String(500, 'utf8mb4_unicode_ci'), nullable=False, comment='回退意见') + rollback_reason_id = Column(String(500, 'utf8mb4_unicode_ci'), nullable=False, comment='回退原因ID') + attachments = Column(Text(collation='utf8mb4_unicode_ci'), comment='附件(多个用逗号分隔)') + send_message = Column(String(32, 'utf8mb4_unicode_ci'), nullable=False, comment='发送短信(1:发送,0:不发送)') + not_assigned = Column(String(16, 'utf8mb4_unicode_ci'), comment='申请不交办(0:不打勾,1:打勾)') + not_assigned_reason = Column(Text(collation='utf8mb4_unicode_ci'), comment='申请不交办原因') + undertake_user_name = Column(String(64, 'utf8mb4_unicode_ci'), comment='承办人员') + undertake_phone = Column(String(64, 'utf8mb4_unicode_ci'), comment='联系电话') + status = Column(BIGINT(20), nullable=False, comment='提交状态') + flow_token = Column(String(256, 'utf8mb4_unicode_ci'), comment='流令牌') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='修改时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='修改者') + + dcm_task = relationship('TD3iDcmTask') + + +class TD3iDcmStageReply(BaseModel): + __tablename__ = 't_d3i_dcm_stage_reply' + __table_args__ = {'comment': '数字城管-阶段回复接口表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + dcm_task_id = Column(ForeignKey('t_d3i_dcm_task.id'), nullable=False, index=True, comment='唯一标志') + task_number = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='任务号') + rec_id = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='记录ID') + act_id = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='工单ID') + item_type = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='固定值') + content = Column(String(1000, 'utf8mb4_unicode_ci'), nullable=False, comment='回复内容') + status = Column(BIGINT(20), nullable=False, server_default=text("0"), comment='提交状态') + flow_token = Column(String(256, 'utf8mb4_unicode_ci'), comment='流令牌') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='修改时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='修改者') + + dcm_task = relationship('TD3iDcmTask') + + +class TD3iDcmTask(BaseModel): + __tablename__ = 't_d3i_dcm_task' + __table_args__ = ( + Index('idx_read_flag_deadline_time', 'read_flag', 'deadline_time'), + Index('idx_biz_sys_read_flag', 'biz_id', 'sys_id', 'read_flag'), + {'comment': '数字城管-部门待办'} + ) + + id = Column(BIGINT(20), primary_key=True, comment='主键ID') + rec_id = Column(BIGINT(20), index=True, comment='记录ID') + rec_disp_num = Column(String(50), comment='显示编号(可空)') + rec_type_id = Column(INTEGER(11), comment='类型ID') + rec_type_name = Column(String(100), comment='案件类型') + act_id = Column(BIGINT(20), index=True, comment='任务ID') + act_deadline_time = Column(BIGINT(20), comment='任务截止时间戳(毫秒)') + act_warning_time = Column(BIGINT(20), comment='预警时间戳(毫秒)') + act_property_id = Column(INTEGER(11), comment='任务属性ID') + act_ard_state_name = Column(String(50), comment='阶段授权状态') + act_time_state_id = Column(TINYINT(4), comment='阶段状态ID') + biz_id = Column(INTEGER(11), index=True, comment='业务ID') + sys_id = Column(INTEGER(11), index=True, comment='系统ID') + task_num = Column(String(50), comment='任务号') + other_task_num = Column(String(100), comment='第三方任务号') + bundle_remain_char = Column(String(20), comment='剩余时间描述(如“1天”)') + bundle_deadline_time = Column(BIGINT(20), comment='捆绑截止时间戳') + bundle_deadline_char = Column(String(20), comment='捆绑截止时间描述') + bundle_warning_time = Column(BIGINT(20), comment='捆绑预警时间戳') + bundle_time_state_id = Column(TINYINT(4), comment='捆绑阶段红绿灯状态') + rollback_deadline = Column(BIGINT(20), comment='拒绝超时截止时间戳') + event_type_id = Column(INTEGER(11), index=True, comment='问题类型ID') + max_event_type_id = Column(INTEGER(11), comment='最大事件类型ID') + event_type_name = Column(String(100), comment='问题类型') + event_src_name = Column(String(100), comment='问题来源') + event_desc = Column(Text, comment='问题描述') + urgency_level = Column(TINYINT(4), index=True, comment='紧急程度(0正常,1紧急)') + main_type_id = Column(INTEGER(11), comment='大类ID') + main_type_name = Column(String(100), comment='大类名称') + sub_type_id = Column(INTEGER(11), comment='小类ID') + sub_type_name = Column(String(100), comment='小类名称') + address = Column(Text, comment='地址描述') + district_name = Column(String(50), index=True, comment='所属区域') + coordinate_x = Column(DECIMAL(10, 6), comment='经度') + coordinate_y = Column(DECIMAL(10, 6), comment='纬度') + proc_time_state_id = Column(TINYINT(4), comment='处理流程状态ID') + deadline_time = Column(BIGINT(20), index=True, comment='处理截止时间戳') + warning_time = Column(BIGINT(20), comment='处理预警时间戳') + processing_deadline = Column(String(50), comment='处置时限描述') + new_inst_cond_name = Column(String(200), comment='立案条件') + case_closure_condition = Column(String(200), comment='结案条件') + reply_intime = Column(TINYINT(4), comment='是否两小时回复(0无需回复,1待回复,2已回复,3超时,4无需回复已恢复)') + return_visit_flag = Column(TINYINT(4), comment='回访标识(0无需,1待回访,2已回访)') + first_depart_name = Column(String(100), comment='一级专业部门') + second_depart_name = Column(String(100), comment='二级专业部门') + reporter_name = Column(String(100), comment='举报人姓名') + reporter_contact = Column(String(50), comment='举报电话') + read_flag = Column(TINYINT(4), index=True, comment='是否已读(0未读,1已读)') + back_color_bit_id = Column(INTEGER(11), comment='背景色ID(可空)') + font_color_bit_id = Column(INTEGER(11), comment='字体色ID(可空)') + part_code = Column(String(100), comment='部件编码') + display_style_id = Column(INTEGER(11), comment='显示样式ID') + func_forbid_reporter_info_flag = Column(TINYINT(4), comment='是否禁止举报人信息') + operation = Column(String(256), comment='操作(工单上的操作按钮)') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='创建者') + updated_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='修改时间') + updated_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='修改者') + + +class TD3iDcmTaskProcessInfo(BaseModel): + __tablename__ = 't_d3i_dcm_task_process_info' + __table_args__ = ( + Index('idx_item_id_action_time', 'item_id', 'action_time'), + Index('idx_unit_name_action_time', 'unit_name', 'action_time'), + Index('idx_act_def_name_action_time', 'act_def_name', 'action_time'), + {'comment': '数字城管-部门待办办理经过'} + ) + + id = Column(BIGINT(20), primary_key=True, comment='主键ID') + dcm_task_id = Column(ForeignKey('t_d3i_dcm_task.id'), nullable=False, index=True, comment='部门待办任务ID') + raw_id = Column(BIGINT(20), comment='原始主键ID') + rec_id = Column(BIGINT(20), comment='记录ID') + act_id = Column(BIGINT(20), nullable=False, index=True, comment='任务ID') + act_def_id = Column(INTEGER(11), comment='流程节点定义ID') + act_def_name = Column(String(100), index=True, comment='流程节点名称') + act_time_state_id = Column(INTEGER(11), comment='操作时间状态ID') + act_limit_info = Column(String(255), comment='操作时限信息') + act_used_time_char = Column(String(50), comment='已用时间(字符串)') + act_remain_time_char = Column(String(50), comment='剩余时间(字符串)') + act_deadline_time = Column(DateTime, comment='操作截止时间') + act_property_id = Column(INTEGER(11), comment='操作属性ID') + action_name = Column(String(100), comment='操作动作名称(如批转、回退)') + action_time = Column(DateTime, nullable=False, index=True, comment='操作时间') + title = Column(String(100), comment='操作标题') + detail = Column(Text, comment='操作详细意见') + backup_detail = Column(Text, comment='备用意见') + medias = Column(Text, comment='附件信息') + unit_name = Column(String(100), index=True, comment='当前操作单位') + unit_contact = Column(String(255), comment='单位联系方式') + human_id = Column(BIGINT(20), index=True, comment='操作人ID,-1为系统') + human_name = Column(String(255), comment='操作人名称(含单位)') + role_name = Column(String(100), index=True, comment='当前角色名称') + item_id = Column(BIGINT(20), nullable=False, index=True, comment='项目ID') + item_type_id = Column(INTEGER(11), comment='任务类型ID') + item_content = Column(Text, comment='任务内容摘要') + item_process_info_list = Column(Text, comment='子流程列表') + sub_process_info = Column(Text, comment='子流程信息') + bundle_time_state_id = Column(INTEGER(11), comment='组合时间状态ID') + bundle_limit_info = Column(String(255), comment='组合时限信息') + bundle_used_char = Column(String(50), comment='组合已用时间') + bundle_remain_char = Column(String(50), comment='组合剩余时间') + bundle_deadline_time = Column(DateTime, comment='组合截止时间') + show_unit_contact = Column(TINYINT(1), comment='是否显示单位联系方式') + pre_unit_name = Column(String(100), comment='上一单位') + pre_action_name = Column(String(100), comment='上一操作名称') + pre_human_name = Column(String(255), comment='上一操作人') + pre_act_opinion = Column(Text, comment='上一操作意见') + next_act_def_name = Column(String(100), comment='下一节点名称') + next_role_part_name = Column(String(255), comment='下一角色/单位') + next_role_name = Column(String(100), comment='下一角色名称') + next_act_property_id = Column(INTEGER(11), comment='下一操作属性ID') + last_act_flag = Column(TINYINT(1), index=True, comment='是否为最后一节点(0否,1是)') + + dcm_task = relationship('TD3iDcmTask') + + +class TD3iDcmTaskAttachment(BaseModel): + __tablename__ = 't_d3i_dcm_task_attachment' + __table_args__ = ( + Index('idx_relation_id_delete_flag', 'relation_id', 'delete_flag'), + Index('idx_relation_type_relation_id', 'relation_type_id', 'relation_id'), + {'comment': '数字城管-部门待办附件'} + ) + + id = Column(BIGINT(20), primary_key=True, comment='主键ID') + dcm_task_id = Column(ForeignKey('t_d3i_dcm_task.id'), nullable=False, index=True, comment='部门待办任务ID') + rec_id = Column(BIGINT(20), comment='记录ID') + relation_type_id = Column(INTEGER(11), nullable=False, index=True, comment='关联类型ID') + relation_id = Column(BIGINT(20), nullable=False, index=True, comment='主关联ID') + relation_main_id = Column(BIGINT(20), comment='主关联ID(可为空)') + relation_sub_id = Column(BIGINT(20), comment='子关联ID(可为空)') + act_def_name = Column(String(255), comment='流程节点名称') + media_id = Column(BIGINT(20), nullable=False, unique=True, comment='媒体唯一ID') + media_path = Column(String(512), nullable=False, comment='服务器存储路径') + media_type = Column(String(50), nullable=False, index=True, comment='媒体类型:IMAGE, VIDEO, etc.') + media_name = Column(String(255), nullable=False, comment='原始文件名') + media_usage = Column(String(100), comment='使用场景,如“上报”、“回退”') + media_server_name = Column(String(100), nullable=False, comment='媒体服务器名称') + media_property = Column(INTEGER(11), comment='媒体属性') + media_uploaded_name = Column(String(255), comment='上传时的原始文件名') + media_shot = Column(String(255), comment='截图标识或路径') + media_label_type_id = Column(INTEGER(11), comment='标签类型ID') + media_url = Column(String(512), comment='内部访问URL') + media_default_url = Column(String(512), comment='外部可访问URL') + display_order = Column(INTEGER(11), comment='显示顺序') + store_type_id = Column(INTEGER(11), nullable=False, comment='存储类型ID') + special_item_image_type = Column(String(100), comment='特殊图片类型') + height = Column(INTEGER(11), comment='图片高度') + width = Column(INTEGER(11), comment='图片宽度') + send_flag = Column(TINYINT(4), comment='发送标志') + public_flag = Column(TINYINT(4), nullable=False, index=True, server_default=text("0"), + comment='公开标志:0=私有,1=公开') + unit_name = Column(String(255), index=True, comment='所属单位') + gen_thumb = Column(TINYINT(4), nullable=False, server_default=text("0"), comment='是否生成缩略图:0=否,1=是') + can_delete = Column(TINYINT(4), nullable=False, server_default=text("0"), comment='是否可删除:0=否,1=是') + upload_time = Column(DateTime, index=True, comment='上传时间') + create_human_id = Column(BIGINT(20), nullable=False, index=True, comment='创建人ID') + human_name = Column(String(255), comment='创建人姓名') + create_time = Column(DateTime, nullable=False, comment='创建时间') + update_time = Column(DateTime, comment='更新时间') + delete_reason = Column(Text, comment='删除原因') + delete_flag = Column(TINYINT(4), nullable=False, index=True, server_default=text("0"), + comment='删除标记:0=未删,1=已删') + delete_human_id = Column(BIGINT(20), comment='删除人ID') + delete_time = Column(DateTime, comment='删除时间') + + dcm_task = relationship('TD3iDcmTask') + + +class TD3iDcmTaskFormDatum(BaseModel): + __tablename__ = 't_d3i_dcm_task_form_data' + __table_args__ = {'comment': '数字化城市管理信息系统人工任务表单数据表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键ID') + dcm_task_id = Column(ForeignKey('t_d3i_dcm_task.id'), nullable=False, index=True, comment='部门待办任务ID') + rec_id = Column(BIGINT(20), index=True, comment='记录ID') + act_property_id = Column(INTEGER(11), comment='任务属性ID') + address = Column(Text, comment='地址描述') + archive_time = Column(BIGINT(20), comment='归档时间戳') + cancel_time = Column(BIGINT(20), comment='取消时间戳') + biz_id = Column(INTEGER(11), index=True, comment='业务ID') + biz_name = Column(String(200), comment='业务名称') + card_num = Column(String(100), comment='证件号码') + cell_id = Column(INTEGER(11), comment='单元格ID') + cell_name = Column(String(200), comment='单元格名称') + check_msg_state_id = Column(INTEGER(11), comment='核查消息状态ID') + check_pic_num = Column(INTEGER(11), comment='核查图片数量') + check_pic_total_num = Column(INTEGER(11), comment='核查图片总数') + check_video_num = Column(INTEGER(11), comment='核查视频数量') + check_video_total_num = Column(INTEGER(11), comment='核查视频总数') + check_wav_num = Column(INTEGER(11), comment='核查音频数量') + check_wav_total_num = Column(INTEGER(11), comment='核查音频总数') + community_id = Column(INTEGER(11), comment='社区ID') + community_name = Column(String(200), comment='社区名称') + coordinate_x = Column(DECIMAL(10, 6), comment='经度') + coordinate_y = Column(DECIMAL(10, 6), comment='纬度') + create_time = Column(BIGINT(20), comment='创建时间戳') + damage_grade_id = Column(INTEGER(11), comment='损毁等级ID') + damage_grade_name = Column(String(100), comment='损毁等级名称') + deadline_char = Column(String(50), comment='时限描述') + deadline_time = Column(BIGINT(20), index=True, comment='处理截止时间戳') + dispatch_opinion = Column(String(500), comment='派遣意见') + dispatch_time = Column(BIGINT(20), comment='派遣时间戳') + display_property = Column(String(200), comment='显示属性') + display_style_id = Column(INTEGER(11), comment='显示样式ID') + district_id = Column(INTEGER(11), comment='区域ID') + district_name = Column(String(50), index=True, comment='所属区域') + duration_unit = Column(INTEGER(11), comment='时长单位') + duty_grid_id = Column(INTEGER(11), comment='责任网格ID') + duty_grid_name = Column(String(200), comment='责任网格名称') + event_desc = Column(Text, comment='问题描述') + event_grade_id = Column(INTEGER(11), comment='事件等级ID') + event_grade_name = Column(String(100), comment='事件等级名称') + event_level_id = Column(INTEGER(11), comment='事件级别ID') + event_level_name = Column(String(100), comment='事件级别名称') + event_src_id = Column(INTEGER(11), comment='问题来源ID') + event_src_name = Column(String(100), comment='问题来源') + event_type_code = Column(String(50), comment='问题类型编码') + event_type_id = Column(INTEGER(11), index=True, comment='问题类型ID') + event_type_name = Column(String(100), comment='问题类型') + fifth_type_id = Column(INTEGER(11), comment='第五级类型ID') + fifth_type_name = Column(String(100), comment='第五级类型名称') + forth_type_id = Column(INTEGER(11), comment='第四级类型ID') + forth_type_name = Column(String(100), comment='第四级类型名称') + func_deadline = Column(BIGINT(20), comment='职能部门截止时间戳') + func_deal_time = Column(BIGINT(20), comment='职能部门处理时间戳') + func_limit_char = Column(String(50), comment='职能部门时限描述') + func_part_id = Column(INTEGER(11), comment='职能部门ID') + func_part_name = Column(String(200), comment='职能部门名称') + func_time_state_id = Column(INTEGER(11), comment='职能部门时间状态ID') + gather_flag = Column(String(50), comment='汇总标识') + link_field_display_value = Column(String(500), comment='关联字段显示值') + link_field_value = Column(String(500), comment='关联字段值') + main_type_id = Column(INTEGER(11), comment='大类ID') + main_type_name = Column(String(100), comment='大类名称') + media_check_num = Column(INTEGER(11), comment='媒体核查数量') + media_check_total_num = Column(INTEGER(11), comment='媒体核查总数') + media_lost_flag = Column(INTEGER(11), comment='媒体丢失标识') + media_upload_num = Column(INTEGER(11), comment='媒体上传数量') + media_upload_state = Column(String(50), comment='媒体上传状态') + media_upload_total_num = Column(INTEGER(11), comment='媒体上传总数') + media_url = Column(String(512), comment='内部访问URL') + media_verify_total_num = Column(INTEGER(11), comment='媒体核实总数') + mms_pic_path = Column(String(500), comment='彩信图片路径') + new_inst_cond_id = Column(INTEGER(11), comment='立案条件ID') + new_inst_cond_name = Column(String(200), comment='立案条件') + occur_time = Column(BIGINT(20), comment='发生时间戳') + part_code = Column(String(100), comment='部件编码') + patrol_deal_flag = Column(INTEGER(11), comment='巡查处置标识') + patrol_id = Column(INTEGER(11), comment='巡查员ID') + patrol_name = Column(String(200), comment='巡查员名称') + pos_type = Column(String(50), comment='位置类型') + proc_ard_state_id = Column(INTEGER(11), comment='处理仲裁状态ID') + proc_enq_state_id = Column(INTEGER(11), comment='处理询问状态ID') + proc_start_time = Column(BIGINT(20), comment='处理开始时间戳') + proc_sup_state_id = Column(INTEGER(11), comment='处理监督状态ID') + proc_time_state_id = Column(TINYINT(4), comment='处理流程状态ID') + rec_deadline = Column(Float(asdecimal=True), comment='记录时限') + rec_disp_num = Column(String(50), comment='显示编号') + rec_remain = Column(Float(asdecimal=True), comment='记录剩余时间') + rec_remain_char = Column(String(50), comment='记录剩余时间描述') + rec_type_id = Column(INTEGER(11), comment='类型ID') + rec_type_name = Column(String(100), comment='案件类型') + rec_used = Column(Float(asdecimal=True), comment='记录已用时间') + rec_used_char = Column(String(50), comment='记录已用时间描述') + rec_warning = Column(Float(asdecimal=True), comment='记录预警时间') + refresh_flag = Column(INTEGER(11), comment='刷新标识') + refresh_start_time = Column(BIGINT(20), comment='刷新开始时间戳') + refresh_time = Column(BIGINT(20), comment='刷新时间戳') + report_id = Column(BIGINT(20), comment='上报ID') + report_pic_num = Column(INTEGER(11), comment='上报图片数量') + report_pic_total_num = Column(INTEGER(11), comment='上报图片总数') + report_video_num = Column(INTEGER(11), comment='上报视频数量') + report_video_total_num = Column(INTEGER(11), comment='上报视频总数') + report_wav_num = Column(INTEGER(11), comment='上报音频数量') + report_wav_total_num = Column(INTEGER(11), comment='上报音频总数') + street_id = Column(INTEGER(11), comment='街道ID') + street_name = Column(String(200), comment='街道名称') + sub_type_id = Column(INTEGER(11), comment='小类ID') + sub_type_name = Column(String(100), comment='小类名称') + task_num = Column(String(50), comment='任务号') + third_type_id = Column(INTEGER(11), comment='第三级类型ID') + third_type_name = Column(String(100), comment='第三级类型名称') + time_area_id = Column(INTEGER(11), comment='时段ID') + time_area_name = Column(String(100), comment='时段名称') + unique_id = Column(String(100), comment='唯一标识') + urgent_flag = Column(INTEGER(11), comment='紧急标识') + urgent_memo = Column(String(500), comment='紧急备注') + verify_msg_state_id = Column(INTEGER(11), comment='核实消息状态ID') + verify_pic_total_num = Column(INTEGER(11), comment='核实图片总数') + verify_video_total_num = Column(INTEGER(11), comment='核实视频总数') + verify_wav_total_num = Column(INTEGER(11), comment='核实音频总数') + video_device_id = Column(BIGINT(20), comment='视频设备ID') + video_param = Column(String(500), comment='视频参数') + view_angle = Column(String(100), comment='视角') + view_image_name = Column(String(200), comment='视图图片名称') + view_image_x = Column(Float(asdecimal=True), comment='视图图片X坐标') + view_image_y = Column(Float(asdecimal=True), comment='视图图片Y坐标') + view_pos_x = Column(Float(asdecimal=True), comment='视图位置X坐标') + view_pos_y = Column(Float(asdecimal=True), comment='视图位置Y坐标') + warning_time = Column(BIGINT(20), comment='处理预警时间戳') + sys_id = Column(INTEGER(11), index=True, comment='系统ID') + form_id = Column(INTEGER(11), comment='表单ID') + verify_pic_num = Column(INTEGER(11), comment='核实图片数量') + verify_wav_num = Column(INTEGER(11), comment='核实音频数量') + verify_video_num = Column(INTEGER(11), comment='核实视频数量') + media_verify_num = Column(INTEGER(11), comment='媒体核实数量') + road_type_id = Column(INTEGER(11), comment='道路类型ID') + road_name = Column(String(200), comment='道路名称') + road_id = Column(INTEGER(11), comment='道路ID') + archive_cond_id = Column(INTEGER(11), comment='归档条件ID') + archive_cond = Column(String(100), comment='归档条件') + road_type_name = Column(String(100), comment='道路类型名称') + area_type_id = Column(INTEGER(11), comment='区域类型ID') + equal_group_id = Column(BIGINT(20), comment='等值组ID') + regather_msg_state_id = Column(INTEGER(11), comment='重新采集消息状态ID') + new_inst_advise = Column(String(500), comment='立案建议') + event_marks = Column(String(500), comment='事件标记') + archive_type_id = Column(INTEGER(11), comment='归档类型ID') + report_time_segment_id = Column(INTEGER(11), comment='上报时段ID') + enable_check_msg = Column(INTEGER(11), comment='启用核查消息') + revise_opinion = Column(String(500), comment='修订意见') + report_area_limit_id = Column(INTEGER(11), comment='上报区域限制ID') + deduction = Column(String(100), comment='扣减') + attach_rec_flag = Column(String(50), comment='附件记录标识') + sixth_type_id = Column(INTEGER(11), comment='第六级类型ID') + sixth_type_name = Column(String(100), comment='第六级类型名称') + seventh_type_id = Column(INTEGER(11), comment='第七级类型ID') + seventh_type_name = Column(String(100), comment='第七级类型名称') + max_event_type_id = Column(INTEGER(11), comment='最大事件类型ID') + max_event_type_name = Column(String(200), comment='最大事件类型名称') + occur_num = Column(INTEGER(11), comment='发生次数') + check_send_time = Column(BIGINT(20), comment='核查发送时间戳') + check_reply_time = Column(BIGINT(20), comment='核查回复时间戳') + duty_region_id = Column(INTEGER(11), comment='责任区域ID') + duty_region_name = Column(String(200), comment='责任区域名称') + lonlat_x = Column(Float(asdecimal=True), comment='经纬度X') + lonlat_y = Column(Float(asdecimal=True), comment='经纬度Y') + func_bundle_deadline = Column(BIGINT(20), comment='职能捆绑截止时间戳') + third_unique_id = Column(String(100), comment='第三方唯一标识') + event_property_id = Column(INTEGER(11), comment='事件属性ID') + event_property_name = Column(String(200), comment='事件属性名称') + city_village_flag = Column(String(50), comment='城乡标识') + specify_func_id = Column(INTEGER(11), comment='指定职能部门ID') + specify_competent_func_id = Column(INTEGER(11), comment='指定主管职能部门ID') + specify_func_name = Column(String(200), comment='指定职能部门名称') + specify_competent_func_name = Column(String(200), comment='指定主管职能部门名称') + super_rec_id = Column(BIGINT(20), comment='上级记录ID') + split_rec_flag = Column(INTEGER(11), comment='拆分记录标识') + site_num = Column(String(50), comment='站点编号') + difficult_type_id = Column(INTEGER(11), comment='困难类型ID') + event_district_grade_id = Column(INTEGER(11), comment='事件区域等级ID') + event_district_grade_name = Column(String(100), comment='事件区域等级名称') + duty_district_id = Column(INTEGER(11), comment='责任区域ID') + duty_street_id = Column(INTEGER(11), comment='责任街道ID') + duty_community_id = Column(INTEGER(11), comment='责任社区ID') + duty_district_name = Column(String(200), comment='责任区域名称') + duty_street_name = Column(String(200), comment='责任街道名称') + duty_community_name = Column(String(200), comment='责任社区名称') + cus_grid_code = Column(String(100), comment='自定义网格编码') + accepter_id = Column(INTEGER(11), comment='受理人ID') + accepter_name = Column(String(100), comment='受理人姓名') + auto_check_count = Column(INTEGER(11), comment='自动核查次数') + other_task_num = Column(String(100), comment='第三方任务号') + force_handle_flag = Column(String(50), comment='强制处理标识') + func_part_list_id = Column(String(100), comment='职能部门列表ID') + func_part_list_name = Column(String(200), comment='职能部门列表名称') + custom_deadline = Column(BIGINT(20), comment='自定义截止时间戳') + act_record_id = Column(BIGINT(20), comment='操作记录ID') + tell_num = Column(String(50), comment='联系电话') + reply_opinion = Column(String(500), comment='回复意见') + send_from_type = Column(String(50), comment='发送来源类型') + func_forbid_reporter_info_flag = Column(TINYINT(4), comment='是否禁止举报人信息') + property_company_id = Column(BIGINT(20), comment='物业公司ID') + accept_status = Column(String(50), comment='受理状态') + shop_name = Column(String(200), comment='商铺名称') + func_custom_limit = Column(String(50), comment='职能部门自定义时限') + squadron_id = Column(BIGINT(20), comment='中队ID') + squadron_name = Column(String(200), comment='中队名称') + reply_intime = Column(TINYINT(4), comment='是否两小时回复(0无需回复,1待回复,2已回复,3超时,4无需回复已恢复)') + locked_flag = Column(INTEGER(11), comment='锁定标识') + check_type_id = Column(INTEGER(11), comment='核查类型ID') + transited_flag = Column(INTEGER(11), comment='转交标识') + rec_analysis_type_id = Column(INTEGER(11), comment='记录分析类型ID') + deal_evaluate_ids = Column(String(200), comment='处置评价ID列表') + newinst_no_transit = Column(String(50), comment='立案不转交') + no_return_visit_flag = Column(INTEGER(11), comment='无需回访标识') + common_rec_type_flag = Column(String(50), comment='通用记录类型标识') + common_rec_attr_flag = Column(String(50), comment='通用记录属性标识') + main_rec_id = Column(BIGINT(20), comment='主记录ID') + send_pub_check_task_flag = Column(INTEGER(11), comment='发送公共核查任务标识') + patroltask_deadline_time = Column(BIGINT(20), comment='巡查任务截止时间戳') + shop_id = Column(BIGINT(20), comment='商铺ID') + spec_type_name = Column(String(100), comment='特殊类型名称') + law_duty_grid_id = Column(INTEGER(11), comment='法律责任网格ID') + law_duty_grid_name = Column(String(200), comment='法律责任网格名称') + proc_account_state_id = Column(INTEGER(11), comment='处理账户状态ID') + spec_type_id = Column(INTEGER(11), comment='特殊类型ID') + reply_flag = Column(String(50), comment='回复标识') + first_depart_name = Column(String(100), comment='一级专业部门') + second_depart_name = Column(String(100), comment='二级专业部门') + reply_intime_deadline = Column(BIGINT(20), comment='两小时回复截止时间戳') + supervision_check_state_id = Column(INTEGER(11), comment='监督核查状态ID') + urgent_level = Column(TINYINT(4), comment='紧急程度(0正常,1紧急)') + self_deal_msg_state_id = Column(INTEGER(11), comment='自行处置消息状态ID') + duty_grid_type_id = Column(INTEGER(11), comment='责任网格类型ID') + deal_duty_grid_type_id = Column(INTEGER(11), comment='处置责任网格类型ID') + deal_duty_grid_id = Column(INTEGER(11), comment='处置责任网格ID') + deal_duty_grid_name = Column(String(200), comment='处置责任网格名称') + site_id = Column(BIGINT(20), comment='站点ID') + media_self_deal_total_num = Column(INTEGER(11), comment='自行处置媒体总数') + media_self_deal_num = Column(INTEGER(11), comment='自行处置媒体数量') + self_deal_pic_total_num = Column(INTEGER(11), comment='自行处置图片总数') + self_deal_pic_num = Column(INTEGER(11), comment='自行处置图片数量') + self_deal_wav_total_num = Column(INTEGER(11), comment='自行处置音频总数') + self_deal_wav_num = Column(INTEGER(11), comment='自行处置音频数量') + self_deal_video_total_num = Column(INTEGER(11), comment='自行处置视频总数') + self_deal_video_num = Column(INTEGER(11), comment='自行处置视频数量') + review_msg_state_id = Column(INTEGER(11), comment='复核消息状态ID') + media_review_total_num = Column(INTEGER(11), comment='复核媒体总数') + media_review_num = Column(INTEGER(11), comment='复核媒体数量') + review_pic_total_num = Column(INTEGER(11), comment='复核图片总数') + review_pic_num = Column(INTEGER(11), comment='复核图片数量') + review_wav_total_num = Column(INTEGER(11), comment='复核音频总数') + review_wav_num = Column(INTEGER(11), comment='复核音频数量') + review_video_total_num = Column(INTEGER(11), comment='复核视频总数') + review_video_num = Column(INTEGER(11), comment='复核视频数量') + public_flag = Column(TINYINT(4), comment='公开标志') + whistle_flag = Column(String(50), comment='吹哨标识') + jx_id = Column(BIGINT(20), comment='警讯ID') + jx_jxmc = Column(String(200), comment='警讯名称') + jx_design_type = Column(String(100), comment='警讯设计类型') + rec_category_id = Column(INTEGER(11), comment='记录类别ID') + repeat_state = Column(String(50), comment='重复状态') + cg_area = Column(String(100), comment='城管区域') + hw_area = Column(String(100), comment='环卫区域') + sz_area = Column(String(100), comment='市政区域') + device_guid = Column(String(100), comment='设备GUID') + proc_press_state_id = Column(INTEGER(11), comment='处理压力状态ID') + hot_area = Column(String(100), comment='热点区域') + report_state = Column(String(50), comment='上报状态') + dispose_state = Column(INTEGER(11), comment='处置状态') + pre_dispose_state = Column(String(50), comment='预处置状态') + undertake_user_name = Column(String(50), comment='承办人员') + undertake_phone = Column(String(50), comment='联系电话') + deal_person_org = Column(String(50), comment='承办部门') + + dcm_task = relationship('TD3iDcmTask') + + +class TD3iDcmTaskExtendedInfo(BaseModel): + __tablename__ = 't_d3i_dcm_task_extended_info' + __table_args__ = {'comment': '扩展信息'} + + id = Column(BIGINT(20), primary_key=True, comment='主键ID') + dcm_task_id = Column(ForeignKey('t_d3i_dcm_task.id'), nullable=False, index=True, comment='唯一标志') + subtype_field_name = Column(String(100), comment='子类型字段名称') + content_range = Column(String(255), server_default=text("''"), comment='内容范围') + control_type = Column(String(50), comment='控件类型') + display_name = Column(String(100), comment='显示名称') + data_type_id = Column(String(50), comment='数据类型ID') + null_flag = Column(String(20), comment='是否可空标识(0:不可空,1:可空)') + list_content = Column(Text, comment='下拉框选项内容') + subtype_id = Column(String(50), comment='子类型ID') + field_value = Column(String(255), comment='字段值') + rec_id = Column(BIGINT(20), nullable=False, comment='记录ID') + field_id = Column(String(50), comment='字段ID') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='创建者') + updated_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='修改时间') + updated_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='修改者') + + dcm_task = relationship('TD3iDcmTask') + + +class TD3iDcmTaskMoreInfo(BaseModel): + __tablename__ = 't_d3i_dcm_task_more_info' + __table_args__ = {'comment': '更多信息'} + + id = Column(BIGINT(20), primary_key=True, comment='主键ID') + dcm_task_id = Column(ForeignKey('t_d3i_dcm_task.id'), nullable=False, index=True, comment='唯一标志') + msg_id = Column(BIGINT(20), comment='消息ID') + rec_id = Column(BIGINT(20), comment='记录ID') + msg_type_id = Column(BIGINT(20), comment='消息类型ID') + msg_type = Column(String(50), comment='消息类型名称') + msg_info = Column(String(255), comment='消息详情') + create_time = Column(String(64), comment='创建时间') + human_id = Column(INTEGER(11), comment='人员ID') + human_name = Column(String(50), comment='人员姓名') + role_name = Column(String(50), comment='角色名称') + ex_info_id = Column(BIGINT(20), comment='扩展信息ID') + ex_info_msg = Column(String(255), comment='扩展信息内容') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='创建者') + updated_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='修改时间') + updated_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='修改者') + + dcm_task = relationship('TD3iDcmTask') + + +class TD3iDcmTaskFileUpload(BaseModel): + __tablename__ = 't_d3i_dcm_task_file_upload' + __table_args__ = {'comment': '文件上传关联表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + dcm_task_id = Column(ForeignKey('t_d3i_dcm_task.id'), nullable=False, index=True, comment='唯一标志') + dcm_task_attachment_id = Column(BIGINT(20), nullable=False, comment='附件ID') + dcm_media_id = Column(BIGINT(20), nullable=False, comment='附件ID(数字城管)') + oa_media_id = Column(String(50), nullable=False, server_default=text(""), comment='附件ID(OA)') + file_hash = Column(String(256), nullable=False, comment='文件has值') + status = Column(INTEGER(11), nullable=False, server_default=text("0"), comment='0:没有上传或失败,1 上传成功') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='创建者') + updated_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='修改时间') + updated_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='修改者') + + dcm_task = relationship('TD3iDcmTask') + + +class TD3iDcmPushStatu(BaseModel): + __tablename__ = 't_d3i_dcm_push_status' + __table_args__ = {'comment': '推送OA状态记录表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + dcm_task_id = Column(ForeignKey('t_d3i_dcm_task.id'), nullable=False, index=True, comment='唯一标志') + push_task_status = Column(INTEGER(11), nullable=False, server_default=text("0"), comment='推送待办工单状态') + push_task_detail_status = Column(INTEGER(11), nullable=False, server_default=text("0"), + comment='推送待办工单详情状态') + push_task_attachment_status = Column(INTEGER(11), nullable=False, server_default=text("0"), + comment='推送待办工单附件状态') + push_task_extend_info_status = Column(INTEGER(11), nullable=False, server_default=text("0"), + comment='推送待办工单扩展信息状态') + push_task_file_upload_status = Column(INTEGER(11), nullable=False, server_default=text("0"), + comment='上传待办工单文件状态') + push_task_more_info_status = Column(INTEGER(11), nullable=False, server_default=text("0"), + comment='推送待办工单更多信息状态') + push_task_process_info_status = Column(INTEGER(11), nullable=False, server_default=text("0"), + comment='推送待办工单处理过程状态') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='创建者') + updated_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='修改时间') + updated_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='修改者') + + dcm_task = relationship('TD3iDcmTask') + + +class TToken(BaseModel): + __tablename__ = 't_token' + __table_args__ = {'comment': '认证token'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + platform = Column(String(20, 'utf8mb4_unicode_ci'), comment='平台') + token = Column(String(500, 'utf8mb4_unicode_ci'), comment='令牌') + deleted = Column(BIT(1)) + creator = Column(String(64, 'utf8mb4_unicode_ci'), comment='创建者') + create_time = Column(DateTime, server_default=text("current_timestamp()"), comment='创建时间') + updater = Column(String(64, 'utf8mb4_unicode_ci'), comment='更新者') + update_time = Column(DateTime, server_default=text("current_timestamp() ON UPDATE current_timestamp()"), + comment='更新时间') + + +class TD3iDcmApplyRollback(BaseModel): + __tablename__ = 't_d3i_dcm_apply_rollback' + __table_args__ = {'comment': '申请回退表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键ID') + dcm_task_id = Column(ForeignKey('t_d3i_dcm_task.id'), nullable=False, index=True, comment='唯一标志') + task_number = Column(String(64), nullable=False, comment='任务号') + act_id = Column(String(64), nullable=False, comment='工单ID') + reply_part_id = Column(BIGINT(20), comment='回复部门ID') + ard_level = Column(BIGINT(20), comment='延期等级') + ard_type_id = Column(BIGINT(20), comment='延期类型ID') + opinion = Column(Text, comment='申请意见') + apply_type = Column(String(64), comment='申请类型(拒签、处置阶段照片未公开)') + trans_info = Column(String(255), comment='流转信息') + flow_token = Column(String(256), comment='流令牌') + attachments = Column(Text, comment='附件(多个用逗号分隔)') + status = Column(BIGINT(20), nullable=False, comment='提交状态') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='创建者') + updated_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='修改时间') + updated_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='修改者') + + dcm_task = relationship('TD3iDcmTask') + + +class TD3iGovsOrderMaster(BaseModel): + __tablename__ = 't_d3i_govs_order_master' + __table_args__ = {'comment': '省12345工单'} + + id = Column(BIGINT(20), primary_key=True, index=True, comment='工单唯一ID') + belong_dept = Column(Text, comment='所属部门') + order_id = Column(String(50), index=True, comment='工单编号') + order_no = Column(String(50), index=True, comment='工单号') + order_type = Column(String(50), comment='表单类型') + order_source = Column(Text, comment='诉求来源') + order_source_detail = Column(Text, comment='诉求来源详情') + order_status = Column(String(50), index=True, comment='工单状态') + order_user_id = Column(String(64), comment='用户ID') + order_user_name = Column(String(50), comment='来电人姓名') + order_user_sex = Column(String(50), comment='来电人性别') + order_user_phone2 = Column(String(20), comment='备用联系电话') + order_handle_way = Column(Text, comment='处理方式') + order_invalid_type = Column(Text, comment='工单作废原因') + master_id = Column(BIGINT(20), index=True, comment='工单主表ID') + call_number = Column(String(20), index=True, comment='来电号码') + contact_number = Column(String(20), index=True, comment='联系电话') + title = Column(Text, comment='工单标题') + call_time = Column(DateTime, index=True, comment='来电时间') + first_order_status = Column(String(10), comment='一级状态编码') + secord_order_status = Column(String(10), comment='二级状态编码') + atomic_order_status = Column(String(10), comment='原子状态编码') + area_code = Column(String(10), comment='区域代码') + area_code_city = Column(String(50), comment='市区域代码') + area_code_area = Column(String(50), comment='区区域代码') + area_code_street = Column(String(50), comment='街道区域代码') + address_detail = Column(Text, comment='详细地址') + case_lnglat = Column(String(50), comment='地理坐标') + case_accord_type_one_name = Column(String(50), comment='诉求归口一级') + case_accord_type_two_name = Column(String(50), comment='诉求归口二级') + case_accord_type_three_name = Column(String(50), comment='诉求归口三级') + case_accord_type_four_name = Column(String(50), comment='四级事项分类') + case_accord_type_five_name = Column(String(50), comment='五级事项分类') + case_accord_ext = Column(Text, comment='扩展分类说明') + case_content = Column(Text, comment='诉求内容') + case_goal = Column(Text, comment='诉求目的') + case_labels = Column(Text, comment='工单标签列表') + case_public = Column(String(10), comment='是否公开') + case_type = Column(Text, comment='案件类型') + case_is_urgent = Column(String(10), comment='紧急程度') + case_comple_time = Column(DateTime, comment='案件办结时间') + first_level_affiliation = Column(Text, comment='一级归属单位') + second_level_affiliation = Column(Text, comment='二级归属单位') + third_level_affiliation = Column(Text, comment='三级归属单位') + fourth_level_affiliation = Column(Text, comment='四级归属单位') + fifth_level_affiliation = Column(Text, comment='五级归属单位') + sixth_level_affiliation = Column(Text, comment='六级归属单位') + seventh_level_affiliation = Column(Text, comment='七级归属单位') + case_accord_code = Column(String(50), comment='事项编码') + info_protect = Column(String(10), comment='信息保护') + case_is_visit = Column(String(10), comment='是否回访') + service_object_type = Column(String(50), comment='服务对象类型') + hotspot = Column(String(10), comment='是否热点事件') + result_satisfied = Column(Text, comment='结果满意度') + first_vist_satisfied = Column(Text, comment='首次走访满意度') + contact_timely = Column(String(50), comment='是否及时联系') + distribute_type = Column(String(50), comment='分派类型') + dept_type = Column(Text, comment='部门类型') + dept_name = Column(Text, comment='部门名称') + active_dept_ids = Column(Text, comment='当前处理部门ID列表') + active_dept_name = Column(String(50), comment='当前处理部门名称') + case_solve = Column(Text, comment='处理结果') + supervise_type = Column(Text, comment='监督类型') + leader_indicate = Column(Text, comment='领导批示') + extension = Column(Text, comment='扩展字段') + org_id = Column(String(50), comment='组织ID') + org_name = Column(Text, comment='组织名称') + knowledge_quote = Column(Text, comment='知识引用') + special_type = Column(Text, comment='特殊类型') + attachment_ids = Column(Text, comment='附件ID列表') + attachment_list = Column(Text, comment='附件列表JSON') + file_exist = Column(Text, comment='是否存在附件') + record_id = Column(String(50), comment='通话记录ID') + call_end_time = Column(DateTime, comment='通话结束时间') + call_total_time = Column(String(20), comment='通话总时长') + plan_finish_time = Column(DateTime, comment='计划完成时间') + remark = Column(Text, comment='备注') + tenant_id = Column(BIGINT(20), index=True, comment='租户ID') + erge_revoke_plug = Column(Text, comment='撤销插件') + exist_quoto_info = Column(Text, comment='是否存在引用信息') + process_instance_id = Column(String(100), index=True, comment='流程实例ID') + sound_recording_address_list = Column(Text, comment='录音文件路径列表JSON') + visit_count = Column(INTEGER(11), comment='走访次数') + visit_adv_content = Column(Text, comment='走访建议内容') + return_visit_reason = Column(Text, comment='回访原因') + residue_date = Column(Text, comment='剩余天数') + whether_approval = Column(String(10), comment='是否审批') + over_time_warning_flag = Column(String(10), comment='超时预警标志') + create_no = Column(String(20), comment='创建编号') + belong_platform = Column(String(50), comment='所属平台') + back_count = Column(String(100), comment='回退次数') + tripartite_call_record_info = Column(Text, comment='三方通话记录') + knowledge_references = Column(Text, comment='知识参考JSON') + current_processing_platform = Column(Text, comment='当前处理平台') + judgment_flag = Column(String(10), comment='判定标志') + thrid_order_id = Column(Text, comment='第三方工单ID') + is_dispatch_accurate = Column(String(10), comment='是否精准分派') + is_coordination = Column(String(10), comment='是否协调') + coordination_time = Column(DateTime, comment='协调时间') + creator_id = Column(BIGINT(20), comment='创建人ID') + create_by = Column(Text, comment='创建人姓名') + updator_id = Column(BIGINT(20), comment='更新人ID') + update_by = Column(Text, comment='更新人姓名') + plan_sign_time = Column(DateTime, comment='计划签收时间') + claim_status = Column(String(64), comment='签收状态') + plan_back_time = Column(DateTime, comment='退回截止时间') + handle_time = Column(DateTime, comment='交办下级时间') + back_time = Column(DateTime, comment='下级退回时间') + complete_time = Column(DateTime, comment='下级办结时间') + update_date = Column(DateTime, comment='原始更新时间') + next_task_id = Column(String(64), comment='下一个任务ID') + govs_sign = Column(TINYINT(1), comment='是否已在省12345签收,1:签收,0:未签收') + created_at = Column(DateTime, nullable=False, index=True, server_default=text("current_timestamp()"), + comment='创建时间') + created_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='创建者') + updated_at = Column(DateTime, nullable=False, index=True, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='更新者') + + +class TD3iGovsOrderProces(BaseModel): + __tablename__ = 't_d3i_govs_order_process' + __table_args__ = {'comment': '省12345工单处理流程'} + + id = Column(BIGINT(20), primary_key=True, comment='工单处理记录唯一ID') + master_id = Column(ForeignKey('t_d3i_govs_order_master.id'), index=True, + comment='关联工单主表ID(t_d3i_govs_order_master.id)') + tenant_id = Column(BIGINT(20), index=True, comment='租户ID') + plan_sign_time = Column(DateTime, comment='计划签收时间') + plan_finish_time = Column(DateTime, comment='计划完成时间') + plan_back_time = Column(DateTime, comment='计划退回时间') + deal_date = Column(DateTime, index=True, comment='实际处理时间') + hand_over_time = Column(String(20, 'utf8mb4_unicode_ci'), comment='交接时间(0表示未交接)') + sign_over_time = Column(String(20, 'utf8mb4_unicode_ci'), comment='签收超时时间') + origin_plan_finish_time = Column(DateTime, comment='原始计划完成时间') + origin_plan_sign_time = Column(DateTime, comment='原始计划签收时间') + order_id = Column(String(50, 'utf8mb4_unicode_ci'), index=True, comment='工单编号') + order_no = Column(String(100, 'utf8mb4_unicode_ci'), index=True, comment='工单流水号(含子单标识)') + process_instance_id = Column(String(64, 'utf8mb4_unicode_ci'), index=True, comment='流程实例ID') + order_status = Column(String(10, 'utf8mb4_unicode_ci'), index=True, comment='工单状态编码') + is_over_time = Column(String(10, 'utf8mb4_unicode_ci'), comment='是否超期(0-否,1-是)') + is_sign_over_time = Column(String(10, 'utf8mb4_unicode_ci'), comment='是否签收超时(0-否,1-是)') + action_name = Column(String(100, 'utf8mb4_unicode_ci'), comment='当前操作动作名称') + deal_type = Column(String(100, 'utf8mb4_unicode_ci'), comment='处理类型') + task_id = Column(String(64, 'utf8mb4_unicode_ci'), index=True, comment='当前任务ID(UUID)') + next_task_id = Column(String(64, 'utf8mb4_unicode_ci'), index=True, comment='下一任务ID(流程节点)') + next_action_name = Column(String(100, 'utf8mb4_unicode_ci'), comment='下一处理动作名称') + next_handle = Column(String(50, 'utf8mb4_unicode_ci'), comment='下一处理动作名称') + next_handle_name = Column(String(100, 'utf8mb4_unicode_ci'), comment='下一处理动作详细名称') + handler_user_ids = Column(String(500, 'utf8mb4_unicode_ci'), comment='当前处理人ID列表') + handler_user_names = Column(String(500, 'utf8mb4_unicode_ci'), comment='当前处理人姓名列表') + handler_org_ids = Column(String(1000, 'utf8mb4_unicode_ci'), comment='当前处理部门ID列表') + handler_org_names = Column(String(500, 'utf8mb4_unicode_ci'), comment='当前处理部门名称列表') + next_handler_user_ids = Column(String(500, 'utf8mb4_unicode_ci'), comment='下一处理人ID列表') + next_handler_user_names = Column(String(500, 'utf8mb4_unicode_ci'), comment='下一处理人姓名列表') + next_org_ids = Column(String(500, 'utf8mb4_unicode_ci'), comment='下一处理部门ID列表') + next_org_names = Column(String(500, 'utf8mb4_unicode_ci'), comment='下一处理部门名称列表') + dispatch_order_id = Column(String(100, 'utf8mb4_unicode_ci'), comment='派发工单ID') + to_master_id = Column(BIGINT(20), comment='目标主表ID') + to_tenant_id = Column(BIGINT(20), comment='目标租户ID') + to_area_code = Column(String(20, 'utf8mb4_unicode_ci'), comment='目标区域代码') + to_dept_id = Column(BIGINT(20), comment='目标部门ID') + dispatch_value = Column(String(20, 'utf8mb4_unicode_ci'), comment='派发值(XP表示下派)') + has_dispatch_process = Column(TINYINT(4), comment='是否有派发流程(0-否,1-是)') + contact_name = Column(String(100, 'utf8mb4_unicode_ci'), comment='联系人姓名') + contact_time = Column(DateTime, comment='联系时间') + contact_type = Column(String(20, 'utf8mb4_unicode_ci'), comment='联系类型(电话/短信等)') + adv_content = Column(Text(collation='utf8mb4_unicode_ci'), comment='处理建议/提醒内容') + remarks = Column(Text(collation='utf8mb4_unicode_ci'), comment='备注信息') + formal_reply = Column(Text(collation='utf8mb4_unicode_ci'), comment='正式回复内容') + reply_to_people = Column(String(100, 'utf8mb4_unicode_ci'), comment='回复对象') + return_reason = Column(String(500, 'utf8mb4_unicode_ci'), comment='退回原因') + notice_org_id = Column(BIGINT(20), comment='通知组织ID') + line_key = Column(String(100, 'utf8mb4_unicode_ci'), comment='线路标识') + current_task_status = Column(String(50, 'utf8mb4_unicode_ci'), comment='当前任务状态') + visit_type = Column(String(50, 'utf8mb4_unicode_ci'), comment='访问类型(如上门、电话)') + attachment_dto_list = Column(Text(collation='utf8mb4_unicode_ci'), comment='附件列表(JSON数组)') + child_order_processes = Column(MEDIUMTEXT, comment='子流程处理记录(JSON数组,支持递归嵌套)') + created_at = Column(DateTime, nullable=False, index=True, server_default=text("current_timestamp()"), + comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, index=True, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + master = relationship('TD3iGovsOrderMaster') + + +class TD3iGovsOrderAttachment(BaseModel): + __tablename__ = 't_d3i_govs_order_attachment' + __table_args__ = {'comment': '省12345工单附件'} + + id = Column(BIGINT(20), primary_key=True, comment='附件唯一ID') + master_id = Column(ForeignKey('t_d3i_govs_order_master.id'), index=True, + comment='关联工单主表ID(t_d3i_govs_order_master.id)') + order_id = Column(String(50), index=True, comment='工单编号') + file_path = Column(String(500), comment='文件路径(内网地址)') + out_file_path = Column(String(500), comment='外网文件路径') + attach_name = Column(String(200), comment='附件名称') + to_tenant_id = Column(String(50), comment='目标租户ID') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='更新者') + + master = relationship('TD3iGovsOrderMaster') + + +class TD3iGovsOrderDetail(BaseModel): + __tablename__ = 't_d3i_govs_order_detail' + __table_args__ = {'comment': '省12345工单详情(接口3:工单详情接口完整数据)'} + + id = Column(BIGINT(20), primary_key=True, comment='详情记录唯一ID') + master_id = Column(ForeignKey('t_d3i_govs_order_master.id'), index=True, comment='关联工单主表ID') + order_id = Column(String(50, 'utf8mb4_unicode_ci'), index=True, comment='工单编号') + order_no = Column(String(50, 'utf8mb4_unicode_ci'), index=True, comment='工单号') + tenant_id = Column(String(50, 'utf8mb4_unicode_ci'), index=True, comment='租户ID') + order_status = Column(String(10, 'utf8mb4_unicode_ci'), index=True, comment='工单状态码') + order_status_for_view = Column(String(50, 'utf8mb4_unicode_ci'), comment='工单状态显示值') + first_order_status = Column(String(10, 'utf8mb4_unicode_ci'), comment='一级状态编码') + secord_order_status = Column(String(10, 'utf8mb4_unicode_ci'), comment='二级状态编码') + atomic_order_status = Column(String(10, 'utf8mb4_unicode_ci'), comment='原子状态编码') + order_invalid_type = Column(Text(collation='utf8mb4_unicode_ci'), comment='工单作废原因') + order_finish_time = Column(DateTime, comment='工单完成时间') + case_content = Column(Text(collation='utf8mb4_unicode_ci'), comment='诉求内容') + case_goal = Column(Text(collation='utf8mb4_unicode_ci'), comment='诉求目的') + title = Column(String(500, 'utf8mb4_unicode_ci'), comment='工单标题') + case_labels = Column(Text(collation='utf8mb4_unicode_ci'), comment='工单标签列表') + case_public = Column(String(10, 'utf8mb4_unicode_ci'), comment='是否公开') + hotspot = Column(String(10, 'utf8mb4_unicode_ci'), comment='是否热点事件') + case_is_urgent = Column(String(10, 'utf8mb4_unicode_ci'), index=True, comment='紧急程度(一般/紧急/特急)') + case_is_visit = Column(String(10, 'utf8mb4_unicode_ci'), comment='是否回访(是/否)') + info_protect = Column(String(10, 'utf8mb4_unicode_ci'), comment='信息保护(是/否)') + case_accord_type_one_name = Column(String(50, 'utf8mb4_unicode_ci'), index=True, comment='诉求归口一级') + case_accord_type_two_name = Column(String(50, 'utf8mb4_unicode_ci'), index=True, comment='诉求归口二级') + case_accord_type_three_name = Column(String(50, 'utf8mb4_unicode_ci'), index=True, comment='诉求归口三级') + case_accord_type_four_name = Column(String(50, 'utf8mb4_unicode_ci'), comment='诉求归口四级') + case_accord_type_five_name = Column(String(50, 'utf8mb4_unicode_ci'), comment='诉求归口五级') + case_accord_code = Column(String(50, 'utf8mb4_unicode_ci'), comment='事项编码') + first_level_affiliation = Column(Text(collation='utf8mb4_unicode_ci'), comment='一级归属单位') + second_level_affiliation = Column(Text(collation='utf8mb4_unicode_ci'), comment='二级归属单位') + third_level_affiliation = Column(Text(collation='utf8mb4_unicode_ci'), comment='三级归属单位') + fourth_level_affiliation = Column(Text(collation='utf8mb4_unicode_ci'), comment='四级归属单位') + fifth_level_affiliation = Column(Text(collation='utf8mb4_unicode_ci'), comment='五级归属单位') + sixth_level_affiliation = Column(Text(collation='utf8mb4_unicode_ci'), comment='六级归属单位') + seventh_level_affiliation = Column(Text(collation='utf8mb4_unicode_ci'), comment='七级归属单位') + appeal_dept = Column(String(100, 'utf8mb4_unicode_ci'), comment='诉求部门') + order_source = Column(String(50, 'utf8mb4_unicode_ci'), comment='诉求来源(电话/互联网)') + order_source_detail = Column(String(50, 'utf8mb4_unicode_ci'), comment='诉求来源详情(12345/随手拍)') + order_source_for_view = Column(String(50, 'utf8mb4_unicode_ci'), comment='诉求来源显示值') + belong_platform = Column(String(50, 'utf8mb4_unicode_ci'), index=True, comment='所属平台代码') + belong_platform_name = Column(String(50, 'utf8mb4_unicode_ci'), comment='受理平台名称') + current_processing_platform = Column(Text(collation='utf8mb4_unicode_ci'), comment='当前处理平台') + service_object_type = Column(String(50, 'utf8mb4_unicode_ci'), comment='服务对象类型(投诉举报/咨询/建议等)') + order_type = Column(String(50, 'utf8mb4_unicode_ci'), comment='表单类型(个人/企业/其他)') + form_type = Column(String(50, 'utf8mb4_unicode_ci'), comment='表单类型代码') + area_code_city = Column(String(50, 'utf8mb4_unicode_ci'), index=True, comment='市区域代码') + area_code_area = Column(String(50, 'utf8mb4_unicode_ci'), index=True, comment='区区域代码') + area_code_street = Column(String(50, 'utf8mb4_unicode_ci'), comment='街道区域代码') + address_detail = Column(String(500, 'utf8mb4_unicode_ci'), comment='详细地址') + case_lnglat = Column(String(100, 'utf8mb4_unicode_ci'), comment='地理坐标') + call_number = Column(String(20, 'utf8mb4_unicode_ci'), comment='来电号码') + call_number_for_dh = Column(String(20, 'utf8mb4_unicode_ci'), comment='来电号码(脱敏)') + raw_call_numer = Column(String(20, 'utf8mb4_unicode_ci'), comment='原始来电号码') + contact_number = Column(String(20, 'utf8mb4_unicode_ci'), comment='联系电话') + raw_contact_number = Column(String(20, 'utf8mb4_unicode_ci'), comment='原始联系电话') + contact_number_for_dh = Column(String(20, 'utf8mb4_unicode_ci'), comment='联系电话(脱敏)') + call_time = Column(DateTime, index=True, comment='来电时间') + order_sound_record_id = Column(String(50, 'utf8mb4_unicode_ci'), comment='通话记录ID') + create_date = Column(DateTime, index=True, comment='创建日期') + update_date = Column(DateTime, index=True, comment='更新日期') + plan_finish_time = Column(DateTime, comment='计划完成时间') + plan_sign_time = Column(DateTime, comment='计划签收时间') + judgment_flag = Column(String(10, 'utf8mb4_unicode_ci'), comment='判定标志') + is_coordination = Column(String(10, 'utf8mb4_unicode_ci'), comment='是否协调') + coordination_time = Column(DateTime, comment='协调时间') + thrid_order_id = Column(Text(collation='utf8mb4_unicode_ci'), comment='第三方工单ID') + relate_order_ids = Column(Text(collation='utf8mb4_unicode_ci'), comment='关联工单ID列表') + relate_order_count = Column(INTEGER(11), server_default=text("0"), comment='关联工单数量') + order_user_id = Column(String(50, 'utf8mb4_unicode_ci'), index=True, comment='用户ID(身份证号)') + user_word = Column(Text(collation='utf8mb4_unicode_ci'), comment='用户反馈') + show_flag = Column(String(10, 'utf8mb4_unicode_ci'), comment='显示标志') + origin_show = Column(TINYINT(4), server_default=text("0"), comment='原始显示标志') + order_user = Column(Text(collation='utf8mb4_unicode_ci'), comment='诉求人信息(JSON对象)') + order_phone_dto = Column(Text(collation='utf8mb4_unicode_ci'), comment='电话号码信息(JSON对象)') + order_attachment_list = Column(Text(collation='utf8mb4_unicode_ci'), comment='附件列表(JSON数组)') + pre_process_list = Column(Text(collation='utf8mb4_unicode_ci'), comment='预处理流程列表(JSON数组)') + tripartite_call_records = Column(Text(collation='utf8mb4_unicode_ci'), comment='三方通话记录(JSON对象)') + tripartite_call_records_list = Column(Text(collation='utf8mb4_unicode_ci'), comment='三方通话记录列表(JSON数组)') + order_custom_form_fields = Column(Text(collation='utf8mb4_unicode_ci'), comment='自定义表单字段(JSON数组)') + knowledge_references = Column(Text(collation='utf8mb4_unicode_ci'), comment='知识参考(JSON对象)') + sound_recording_address_list = Column(Text(collation='utf8mb4_unicode_ci'), comment='录音文件路径列表(JSON数组)') + active_dept_ids = Column(Text(collation='utf8mb4_unicode_ci'), comment='当前处理部门ID列表') + attachment_ids = Column(Text(collation='utf8mb4_unicode_ci'), comment='附件ID列表') + attachment_list = Column(Text(collation='utf8mb4_unicode_ci'), comment='附件列表JSON') + contactor_list = Column(Text(collation='utf8mb4_unicode_ci'), comment='联系人列表(JSON数组)') + tsjb_entry_info = Column(Text(collation='utf8mb4_unicode_ci'), comment='投诉举报入口信息(JSON对象)') + order_erge_revoke_plug_dto_list = Column(Text(collation='utf8mb4_unicode_ci'), comment='撤销插件信息(JSON数组)') + order_environmental = Column(Text(collation='utf8mb4_unicode_ci'), comment='环境信息(JSON对象)') + order_demands_dto = Column(Text(collation='utf8mb4_unicode_ci'), comment='诉求DTO(JSON对象)') + order_appeal_list = Column(Text(collation='utf8mb4_unicode_ci'), comment='申诉列表(JSON数组)') + torder_process_list = Column(Text(collation='utf8mb4_unicode_ci'), comment='流程列表(JSON数组)') + pre_process = Column(Text(collation='utf8mb4_unicode_ci'), comment='预处理信息(JSON对象)') + extension = Column(Text(collation='utf8mb4_unicode_ci'), comment='扩展字段') + remark = Column(Text(collation='utf8mb4_unicode_ci'), comment='备注') + file_exist = Column(INTEGER(11), server_default=text("0"), comment='是否存在附件(0-无,1-有)') + exist_quoto_info = Column(Text(collation='utf8mb4_unicode_ci'), comment='是否存在引用信息') + residue_date = Column(Text(collation='utf8mb4_unicode_ci'), comment='剩余天数') + whether_approval = Column(String(10, 'utf8mb4_unicode_ci'), comment='是否审批') + over_time_warning_flag = Column(String(10, 'utf8mb4_unicode_ci'), comment='超时预警标志') + create_no = Column(String(20, 'utf8mb4_unicode_ci'), comment='创建编号') + return_visit_reason = Column(Text(collation='utf8mb4_unicode_ci'), comment='回访原因') + back_count = Column(String(100, 'utf8mb4_unicode_ci'), comment='回退次数') + visit_adv_content = Column(Text(collation='utf8mb4_unicode_ci'), comment='走访建议内容') + is_dispatch_accurate = Column(String(10, 'utf8mb4_unicode_ci'), comment='是否精准分派') + process_instance_id = Column(String(100, 'utf8mb4_unicode_ci'), index=True, comment='流程实例ID') + knowledge_quote = Column(Text(collation='utf8mb4_unicode_ci'), comment='知识引用') + special_type = Column(Text(collation='utf8mb4_unicode_ci'), comment='特殊类型') + supervise_type = Column(Text(collation='utf8mb4_unicode_ci'), comment='监督类型') + leader_indicate = Column(Text(collation='utf8mb4_unicode_ci'), comment='领导批示') + case_solve = Column(Text(collation='utf8mb4_unicode_ci'), comment='处理结果') + result_satisfied = Column(Text(collation='utf8mb4_unicode_ci'), comment='结果满意度') + first_vist_satisfied = Column(Text(collation='utf8mb4_unicode_ci'), comment='首次走访满意度') + contact_timely = Column(String(50, 'utf8mb4_unicode_ci'), comment='是否及时联系') + distribute_type = Column(String(50, 'utf8mb4_unicode_ci'), comment='分派类型') + dept_type = Column(Text(collation='utf8mb4_unicode_ci'), comment='部门类型') + dept_name = Column(Text(collation='utf8mb4_unicode_ci'), comment='部门名称') + active_dept_name = Column(String(50, 'utf8mb4_unicode_ci'), comment='当前处理部门名称') + org_id = Column(String(50, 'utf8mb4_unicode_ci'), comment='组织ID') + org_name = Column(Text(collation='utf8mb4_unicode_ci'), comment='组织名称') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + master = relationship('TD3iGovsOrderMaster') + + +class TD3iGovsOrderUser(BaseModel): + __tablename__ = 't_d3i_govs_order_user' + __table_args__ = {'comment': '省12345服务对象信息'} + + id = Column(BIGINT(20), primary_key=True, comment='服务对象唯一ID') + master_id = Column(ForeignKey('t_d3i_govs_order_master.id'), index=True, + comment='关联工单主表ID(t_d3i_govs_order_master.id)') + order_id = Column(String(50), index=True, comment='工单编号') + tenant_id = Column(String(50), comment='租户ID') + area_code = Column(String(20), comment='区域代码') + customer_name = Column(String(50), index=True, comment='姓名') + raw_customer_name = Column(String(50), comment='原始姓名') + customer_sex = Column(String(10), comment='性别(男/女/未知)') + customer_type = Column(String(20), comment='客户类型(个人/企业)') + customer_age_range = Column(String(20), comment='年龄段') + customer_connect_phone = Column(String(20), index=True, comment='联系电话') + raw_customer_connect_phone = Column(String(20), comment='原始联系电话') + customer_incoming_phone = Column(String(20), comment='来电号码') + raw_customer_incoming_phone = Column(String(20), comment='原始来电号码') + customer_phone_backup = Column(String(20), comment='备用电话') + raw_customer_phone_backup = Column(String(20), comment='原始备用电话') + customer_phone_backup_for_dh = Column(String(20), comment='备用电话(脱敏)') + customer_internet_nickname = Column(String(100), comment='网名') + customer_email = Column(String(100), comment='电子邮箱') + customer_credentials_type = Column(String(20), comment='证件类型(如:身份证、护照)') + customer_credentials_no = Column(String(50), index=True, comment='证件号码') + raw_customer_credentials_no = Column(String(50), comment='原始证件号码') + enterprise_type = Column(String(50), comment='企业类型') + enterprise_name = Column(String(200), comment='企业名称') + enterprise_register_address = Column(String(500), comment='企业注册地址') + enterprise_address = Column(String(500), comment='企业地址') + enterprise_credit_code = Column(String(50), comment='企业信用代码') + delete_flag = Column(TINYINT(4), server_default=text("0"), comment='删除标志(0-未删除,1-已删除)') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='更新者') + + master = relationship('TD3iGovsOrderMaster') + + +class TD3iGovsPushStatu(BaseModel): + __tablename__ = 't_d3i_govs_push_status' + __table_args__ = {'comment': '推送OA状态记录表(省12345)'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + master_id = Column(ForeignKey('t_d3i_govs_order_master.id'), nullable=False, index=True, comment='唯一标志') + push_order_status = Column(INTEGER(11), nullable=False, server_default=text("0"), comment='推送待办工单状态') + push_order_detail_status = Column(INTEGER(11), nullable=False, server_default=text("0"), + comment='推送待办工单详情状态') + push_order_attachment_status = Column(INTEGER(11), nullable=False, server_default=text("0"), + comment='推送待办工单附件状态') + push_order_process_status = Column(INTEGER(11), nullable=False, server_default=text("0"), + comment='推送待办工单处理流程状态') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='创建者') + updated_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='修改时间') + updated_by = Column(String(64), nullable=False, server_default=text("'D3I'"), comment='修改者') + + master = relationship('TD3iGovsOrderMaster') + + +class TD3iGovcTask(BaseModel): + __tablename__ = 't_d3i_govc_task' + __table_args__ = {'comment': '市12345工单主表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + evl_result = Column(String(64, 'utf8mb4_unicode_ci'), comment='结果满意度') + finish_result = Column(Text(collation='utf8mb4_unicode_ci'), comment='办结结果') + serial_num = Column(String(64, 'utf8mb4_unicode_ci'), comment='工单编号') + t_status = Column(String(64, 'utf8mb4_unicode_ci'), comment='任务单状态') + accord_type = Column(String(255, 'utf8mb4_unicode_ci'), comment='归口类型') + create_date = Column(DateTime, comment='交办时间') + back_time_bf = Column(DateTime, comment='拒绝时限') + sub_handle_ou_name = Column(String(255, 'utf8mb4_unicode_ci'), comment='子处办单位') + sign_time_bf = Column(BIGINT(20), comment='签收时限时间戳') + is_leaf = Column(String(32, 'utf8mb4_unicode_ci'), comment='是否叶子节点') + row_guid = Column(String(64, 'utf8mb4_unicode_ci'), comment='rowguid') + c_guid = Column(String(64, 'utf8mb4_unicode_ci'), comment='查询详情使用guid') + finish_time = Column(BIGINT(20), comment='办结时间戳') + sign_time = Column(BIGINT(20), comment='签收时间戳') + is_secret = Column(String(32, 'utf8mb4_unicode_ci'), comment='是否保密') + finish_time_bf = Column(DateTime, comment='办结时限') + link_number = Column(String(64, 'utf8mb4_unicode_ci'), comment='联系号码') + pvi_guid = Column(String(64, 'utf8mb4_unicode_ci'), comment='查询详情使用pviguid') + rqst_type = Column(String(64, 'utf8mb4_unicode_ci'), comment='诉求类型') + rqst_content = Column(Text(collation='utf8mb4_unicode_ci'), comment='诉求内容') + handle_ou_name = Column(String(255, 'utf8mb4_unicode_ci'), comment='处办单位') + rqst_title = Column(String(500, 'utf8mb4_unicode_ci'), comment='标题') + sign_person = Column(String(128, 'utf8mb4_unicode_ci'), comment='签收人') + rqst_person = Column(String(128, 'utf8mb4_unicode_ci'), comment='诉求人') + rqs_channel = Column(String(64, 'utf8mb4_unicode_ci'), comment='渠道来源') + t_type = Column(String(64, 'utf8mb4_unicode_ci'), comment='工单类型') + solve_situation = Column(String(64, 'utf8mb4_unicode_ci'), comment='解决情况') + evl_style = Column(String(64, 'utf8mb4_unicode_ci'), comment='态度满意度') + send_opinion = Column(Text(collation='utf8mb4_unicode_ci'), comment='派送意见') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + +class TD3iGovcTaskContact(BaseModel): + __tablename__ = 't_d3i_govc_task_contact' + __table_args__ = {'comment': '市12345工单联系信息表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + task_id = Column(ForeignKey('t_d3i_govc_task.id'), nullable=False, index=True, comment='关联工单主表ID') + link_person = Column(String(128, 'utf8mb4_unicode_ci'), comment='联系人') + link_status = Column(String(64, 'utf8mb4_unicode_ci'), comment='联系类型') + link_date = Column(DateTime, comment='联系时间') + link_content = Column(Text(collation='utf8mb4_unicode_ci'), comment='联系内容') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + task = relationship('TD3iGovcTask') + + +class TD3iGovcTaskDelay(BaseModel): + __tablename__ = 't_d3i_govc_task_delay' + __table_args__ = {'comment': '市12345工单延迟信息表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + task_id = Column(ForeignKey('t_d3i_govc_task.id'), nullable=False, index=True, comment='关联工单主表ID') + delay_status = Column(String(64, 'utf8mb4_unicode_ci'), comment='审核状态') + delay_num_unit = Column(String(64, 'utf8mb4_unicode_ci'), comment='通过时长') + delay_type = Column(String(64, 'utf8mb4_unicode_ci'), comment='申请类型') + delay_num = Column(INTEGER(11), comment='延迟时长') + apply_ou = Column(String(255, 'utf8mb4_unicode_ci'), comment='申请部门') + apply_time = Column(DateTime, comment='申请时间') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + task = relationship('TD3iGovcTask') + + +class TD3iGovcTaskDepartmentFeedback(BaseModel): + __tablename__ = 't_d3i_govc_task_department_feedback' + __table_args__ = {'comment': '市12345部门处置信息表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + task_id = Column(ForeignKey('t_d3i_govc_task.id'), nullable=False, index=True, comment='关联工单主表ID') + zxhf_info = Column(Text(collation='utf8mb4_unicode_ci'), comment='专项回复信息') + back_info = Column(Text(collation='utf8mb4_unicode_ci'), comment='退回信息') + sign_time_bf = Column(DateTime, comment='签收时限') + operation_text = Column(String(255, 'utf8mb4_unicode_ci'), comment='操作描述') + opinion = Column(Text(collation='utf8mb4_unicode_ci'), comment='反馈意见') + unit = Column(String(255, 'utf8mb4_unicode_ci'), comment='承办单位') + finish_time_bf = Column(DateTime, comment='反馈时限') + person = Column(String(128, 'utf8mb4_unicode_ci'), comment='承办人') + sign_time = Column(DateTime, comment='签收时间') + name = Column(String(128, 'utf8mb4_unicode_ci'), comment='负责人') + tel = Column(String(64, 'utf8mb4_unicode_ci'), comment='联系电话') + time = Column(DateTime, comment='反馈时间') + department = Column(String(255, 'utf8mb4_unicode_ci'), comment='部门') + status = Column(INTEGER(11), comment='状态') + back_time_bf = Column(DateTime, comment='拒绝时限') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + task = relationship('TD3iGovcTask') + + +class TD3iGovcTaskDetail(BaseModel): + __tablename__ = 't_d3i_govc_task_detail' + __table_args__ = {'comment': '市12345工单详情表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + task_id = Column(ForeignKey('t_d3i_govc_task.id'), nullable=False, index=True, comment='关联工单主表ID') + note = Column(Text(collation='utf8mb4_unicode_ci'), comment='备注') + purpose = Column(String(255, 'utf8mb4_unicode_ci'), comment='诉求目的') + type_level = Column(String(64, 'utf8mb4_unicode_ci'), comment='诉求类型等级') + type = Column(String(64, 'utf8mb4_unicode_ci'), comment='诉求类型') + sign_time_bf = Column(DateTime, comment='签收时限') + matter = Column(String(32, 'utf8mb4_unicode_ci'), comment='窗口进驻事项') + case_form_type = Column(String(64, 'utf8mb4_unicode_ci'), comment='个性化表单类型') + content = Column(Text(collation='utf8mb4_unicode_ci'), comment='诉求内容') + handle_ou = Column(String(255, 'utf8mb4_unicode_ci'), comment='处办单位') + urgency = Column(TINYINT(4), comment='是否紧急') + sj_handle_ou = Column(String(255, 'utf8mb4_unicode_ci'), comment='涉及单位') + ccb_content = Column(Text(collation='utf8mb4_unicode_ci'), comment='催补撤内容') + is_secret = Column(String(32, 'utf8mb4_unicode_ci'), comment='是否保密') + theme = Column(String(32, 'utf8mb4_unicode_ci'), comment='主题工单') + attribute = Column(String(255, 'utf8mb4_unicode_ci'), comment='归口类型') + zqt = Column(String(255, 'utf8mb4_unicode_ci'), comment='企业名称') + address = Column(String(500, 'utf8mb4_unicode_ci'), comment='详细地址') + seng_again_num = Column(INTEGER(11), comment='再交办次数') + epidemic = Column(String(32, 'utf8mb4_unicode_ci'), comment='是否疫情工单') + has_ccb = Column(TINYINT(4), comment='是否有催补撤信息') + way = Column(String(64, 'utf8mb4_unicode_ci'), comment='受理方式') + return_visit = Column(String(64, 'utf8mb4_unicode_ci'), comment='回访类型') + finish_time_bf = Column(DateTime, comment='反馈时限') + is_email = Column(TINYINT(4), comment='是否邮箱提交') + time = Column(DateTime, comment='事发时间') + called_tx = Column(String(64, 'utf8mb4_unicode_ci'), comment='被叫号码') + back_time_bf = Column(DateTime, comment='拒绝时限') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + task = relationship('TD3iGovcTask') + + +class TD3iGovcTaskFinish(BaseModel): + __tablename__ = 't_d3i_govc_task_finish' + __table_args__ = {'comment': '市12345工单办结信息表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + task_id = Column(ForeignKey('t_d3i_govc_task.id'), nullable=False, index=True, comment='关联工单主表ID') + bj_result = Column(Text(collation='utf8mb4_unicode_ci'), comment='办结意见') + evl_result = Column(String(64, 'utf8mb4_unicode_ci'), comment='结果满意度') + replay_person = Column(String(128, 'utf8mb4_unicode_ci'), comment='回访人') + processing_results = Column(String(255, 'utf8mb4_unicode_ci'), comment='处理结果') + solve_situation = Column(String(64, 'utf8mb4_unicode_ci'), comment='解决情况') + replay_time = Column(DateTime, comment='回访时间') + evl_style = Column(String(64, 'utf8mb4_unicode_ci'), comment='态度满意度') + is_citizen = Column(TINYINT(4), comment='是否市民') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + task = relationship('TD3iGovcTask') + + +class TD3iGovcTaskHistory(BaseModel): + __tablename__ = 't_d3i_govc_task_history' + __table_args__ = {'comment': '市12345历史工单表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + task_id = Column(ForeignKey('t_d3i_govc_task.id'), nullable=False, index=True, comment='关联工单主表ID') + history_date = Column(String(32, 'utf8mb4_unicode_ci'), comment='日期') + serial_num = Column(String(64, 'utf8mb4_unicode_ci'), comment='历史工单号') + detail_url = Column(Text(collation='utf8mb4_unicode_ci'), comment='详情页URL') + rqst_title = Column(String(500, 'utf8mb4_unicode_ci'), comment='工单标题') + state = Column(String(64, 'utf8mb4_unicode_ci'), comment='状态') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + task = relationship('TD3iGovcTask') + + +class TD3iGovcTaskProces(BaseModel): + __tablename__ = 't_d3i_govc_task_process' + __table_args__ = {'comment': '市12345工单流程追踪表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + task_id = Column(ForeignKey('t_d3i_govc_task.id'), nullable=False, index=True, comment='关联工单主表ID') + handle_time = Column(DateTime, comment='办理时间') + operate_status = Column(String(128, 'utf8mb4_unicode_ci'), comment='办理状态') + activity_guid = Column(String(255, 'utf8mb4_unicode_ci'), comment='办理环节名称') + handle_opinion = Column(Text(collation='utf8mb4_unicode_ci'), comment='办理意见') + is_finish = Column(TINYINT(4), comment='是否结束') + operator_ou_name = Column(String(255, 'utf8mb4_unicode_ci'), comment='部门') + is_back = Column(TINYINT(4), comment='是否回退') + operator_name = Column(String(128, 'utf8mb4_unicode_ci'), comment='办理人') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + task = relationship('TD3iGovcTask') + + +class TD3iGovcTaskRequester(BaseModel): + __tablename__ = 't_d3i_govc_task_requester' + __table_args__ = {'comment': '市12345诉求人信息表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + task_id = Column(ForeignKey('t_d3i_govc_task.id'), nullable=False, index=True, comment='关联工单主表ID') + card_num = Column(String(128, 'utf8mb4_unicode_ci'), comment='身份证号') + emotion = Column(String(64, 'utf8mb4_unicode_ci'), comment='诉求情绪') + name_scope = Column(String(64, 'utf8mb4_unicode_ci'), comment='年龄范围') + sex = Column(String(32, 'utf8mb4_unicode_ci'), comment='性别') + name = Column(String(128, 'utf8mb4_unicode_ci'), comment='诉求人') + secret_flag = Column(String(32, 'utf8mb4_unicode_ci'), comment='保密标识') + is_secret = Column(String(32, 'utf8mb4_unicode_ci'), comment='是否保密') + is_not_show_record = Column(TINYINT(4), comment='是否不展示记录') + phone_num = Column(String(64, 'utf8mb4_unicode_ci'), comment='来电号码') + limk_num = Column(String(64, 'utf8mb4_unicode_ci'), comment='联系号码1') + c_guid = Column(String(64, 'utf8mb4_unicode_ci'), comment='cguid') + phone_num1 = Column(String(64, 'utf8mb4_unicode_ci'), comment='联系号码2') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + task = relationship('TD3iGovcTask') + + +class TD3iGovcTaskReturnVisit(BaseModel): + __tablename__ = 't_d3i_govc_task_return_visit' + __table_args__ = {'comment': '市12345工单回访结果表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + task_id = Column(ForeignKey('t_d3i_govc_task.id'), nullable=False, index=True, comment='关联工单主表ID') + evl_result = Column(String(64, 'utf8mb4_unicode_ci'), comment='结果满意度') + replay_person = Column(String(128, 'utf8mb4_unicode_ci'), comment='回访人') + is_rg_reply = Column(String(32, 'utf8mb4_unicode_ci'), comment='是否人工回访') + processing_results = Column(String(255, 'utf8mb4_unicode_ci'), comment='处理结果') + solve_situation = Column(String(64, 'utf8mb4_unicode_ci'), comment='解决情况') + replay_time = Column(DateTime, comment='回访时间') + evl_style = Column(String(64, 'utf8mb4_unicode_ci'), comment='态度满意度') + is_citizen = Column(TINYINT(4), comment='是否市民') + replay_content = Column(Text(collation='utf8mb4_unicode_ci'), comment='回访内容') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + task = relationship('TD3iGovcTask') + + +class TD3iGovcTaskStatu(BaseModel): + __tablename__ = 't_d3i_govc_task_status' + __table_args__ = {'comment': '市12345工单办理状态表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + task_id = Column(ForeignKey('t_d3i_govc_task.id'), nullable=False, index=True, comment='关联工单主表ID') + shou_li = Column(String(32, 'utf8mb4_unicode_ci'), comment='受理状态') + jie_dan = Column(String(32, 'utf8mb4_unicode_ci'), comment='接单状态') + hui_fang = Column(String(32, 'utf8mb4_unicode_ci'), comment='回访状态') + ban_li = Column(String(32, 'utf8mb4_unicode_ci'), comment='办理状态') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + task = relationship('TD3iGovcTask') + + +class TD3iGovcTaskSupervision(BaseModel): + __tablename__ = 't_d3i_govc_task_supervision' + __table_args__ = {'comment': '市12345工单监察信息表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + task_id = Column(ForeignKey('t_d3i_govc_task.id'), nullable=False, index=True, comment='关联工单主表ID') + supervision_name = Column(String(255, 'utf8mb4_unicode_ci'), comment='监察点名称') + supervision_type = Column(String(255, 'utf8mb4_unicode_ci'), comment='监察点类型') + supervision_date = Column(DateTime, comment='监察点时间') + supervision_ou_name = Column(String(255, 'utf8mb4_unicode_ci'), comment='部门') + hj_date = Column(DateTime, comment='核减时间') + supervise_type = Column(String(32, 'utf8mb4_unicode_ci'), comment='监察类别 zx/bm/bmhj') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + task = relationship('TD3iGovcTask') + + +class TD3iGovcTaskTitle(BaseModel): + __tablename__ = 't_d3i_govc_task_title' + __table_args__ = {'comment': '市12345工单标题表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + task_id = Column(ForeignKey('t_d3i_govc_task.id'), nullable=False, index=True, comment='关联工单主表ID') + urgency = Column(TINYINT(4), comment='是否紧急') + order_num = Column(String(64, 'utf8mb4_unicode_ci'), comment='工单编号') + source = Column(String(64, 'utf8mb4_unicode_ci'), comment='来源') + title = Column(String(500, 'utf8mb4_unicode_ci'), comment='标题') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + task = relationship('TD3iGovcTask') + + +class TD3iGovcTaskAttachment(BaseModel): + __tablename__ = 't_d3i_govc_task_attachment' + __table_args__ = {'comment': '市12345工单附件表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + task_id = Column(ForeignKey('t_d3i_govc_task.id'), nullable=False, index=True, comment='关联工单主表ID') + detail_id = Column(ForeignKey('t_d3i_govc_task_detail.id'), nullable=False, index=True, comment='关联工单详情ID') + name = Column(String(500, 'utf8mb4_unicode_ci'), comment='附件名称') + attach_url = Column(Text(collation='utf8mb4_unicode_ci'), comment='附件地址') + type = Column(String(64, 'utf8mb4_unicode_ci'), comment='附件类型') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + detail = relationship('TD3iGovcTaskDetail') + task = relationship('TD3iGovcTask') + + +class TD3iGovsApplicationForDelay(BaseModel): + __tablename__ = 't_d3i_govs_application_for_delay' + __table_args__ = {'comment': '延时申请表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + master_id = Column(ForeignKey('t_d3i_govs_order_master.id'), nullable=False, index=True, comment='主表ID') + gd_id = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='代签收唯一标志(需要填写)') + finally_time_after_approve = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, + comment='延时申请通过后时间(需要填写)') + finally_time_before_approve = Column(String(64, 'utf8mb4_unicode_ci'), comment='计划完成时间(列表取)') + request_delay = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='申请延时时长(需要填写)') + is_nature_day = Column(String(10, 'utf8mb4_unicode_ci'), nullable=False, + comment='申请延时时长(0、工作日1、自然日)(需要填写)') + already_notify_order_user = Column(String(10, 'utf8mb4_unicode_ci'), nullable=False, + comment='是否已告知诉求人需要延时(默认是)') + request_reason = Column(Text(collation='utf8mb4_unicode_ci'), nullable=False, comment='延时原因(需要填写)') + remarks = Column(String(500, 'utf8mb4_unicode_ci'), comment='备注(需要填写)') + contact_name = Column(String(100, 'utf8mb4_unicode_ci'), comment='何人(需要填写)') + contact_time = Column(String(64, 'utf8mb4_unicode_ci'), comment='何时(需要填写)') + contact_type = Column(String(64, 'utf8mb4_unicode_ci'), comment='何方式(主键或编码)(需要填写)') + contact_type_name = Column(String(100, 'utf8mb4_unicode_ci'), comment='何方式(需要填写)') + reply_script = Column(Text(collation='utf8mb4_unicode_ci'), comment='答复脚本(需要填写)') + file_id_str = Column(Text(collation='utf8mb4_unicode_ci'), comment='OA文件id,多个需要,拼接(需要填写)') + order_no = Column(String(64, 'utf8mb4_unicode_ci'), comment='order_no(列表取)') + process_instance_id = Column(String(64, 'utf8mb4_unicode_ci'), comment='process_instance_id(列表取)') + request_delay_time = Column(String(64, 'utf8mb4_unicode_ci'), comment='申请延时时长(字符串)(申请延时时长+天)') + save_id = Column(String(64, 'utf8mb4_unicode_ci'), comment='提交数据为id(默认空字符)') + order_id = Column(String(64, 'utf8mb4_unicode_ci'), comment='order_id(列表取)') + save_status = Column(TINYINT(4), server_default=text("0"), comment='提交状态(0.未提交1.提交中2.提交成功9.提交失败)') + oa_feedback_status = Column(TINYINT(4), server_default=text("0"), + comment='OA反馈状态(0.初始状态1.反馈中2.反馈成功9.反馈失败)') + flow_token = Column(String(256, 'utf8mb4_unicode_ci'), comment='流令牌') + status = Column(BIGINT(20), nullable=False, server_default=text("0"), comment='提交状态') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + master = relationship('TD3iGovsOrderMaster') + + +class TD3iGovsPhaseWiseCompletion(BaseModel): + __tablename__ = 't_d3i_govs_phase_wise_completion' + __table_args__ = {'comment': '阶段性办结表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + master_id = Column(ForeignKey('t_d3i_govs_order_master.id'), nullable=False, index=True, comment='主表ID') + gd_id = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='代签收唯一标志(需要填写)') + is_contact = Column(String(10, 'utf8mb4_unicode_ci'), nullable=False, comment='联系诉求人情况(默认是)(需要填写)') + contact_name = Column(String(100, 'utf8mb4_unicode_ci'), nullable=False, comment='联系人员(需要填写)') + contact_time = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='联系时间(需要填写)') + contact_type = Column(String(255, 'utf8mb4_unicode_ci'), nullable=False, comment='联系情况(需要填写)') + next_feedback_time = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='下一次反馈时间(需要填写)') + advice = Column(Text(collation='utf8mb4_unicode_ci'), nullable=False, comment='处理意见(需要填写)') + reason = Column(Text(collation='utf8mb4_unicode_ci'), nullable=False, comment='处理意见1(需要填写)') + remark = Column(String(500, 'utf8mb4_unicode_ci'), comment='备注') + file_id_str = Column(Text(collation='utf8mb4_unicode_ci'), comment='OA文件id,多个需要,拼接(需要填写)') + action_name = Column(String(255, 'utf8mb4_unicode_ci'), comment='action_name(列表取nextActionName)') + case_accord_type_one_name = Column(String(255, 'utf8mb4_unicode_ci'), + comment='case_accord_type_one_name(列表取caseAccordTypeOneName)') + case_accord_type_two_name = Column(String(255, 'utf8mb4_unicode_ci'), + comment='case_accord_type_two_name(列表取caseAccordTypeTwoName)') + case_accord_type_three_name = Column(String(255, 'utf8mb4_unicode_ci'), + comment='case_accord_type_three_name(列表取caseAccordTypeThreeName)') + order_id = Column(String(64, 'utf8mb4_unicode_ci'), comment='order_id(列表取)') + task_id = Column(String(64, 'utf8mb4_unicode_ci'), comment='task_id(列表取nextTaskId)') + save_status = Column(TINYINT(4), server_default=text("0"), comment='提交状态(0.未提交1.提交中2.提交成功9.提交失败)') + oa_feedback_status = Column(TINYINT(4), server_default=text("0"), + comment='OA反馈状态(0.初始状态1.反馈中2.反馈成功9.反馈失败)') + flow_token = Column(String(256, 'utf8mb4_unicode_ci'), comment='流令牌') + status = Column(BIGINT(20), nullable=False, server_default=text("0"), comment='提交状态') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + master = relationship('TD3iGovsOrderMaster') + + +class TD3iGovsReplyFormal(BaseModel): + __tablename__ = 't_d3i_govs_reply_formal' + __table_args__ = {'comment': '答复办结表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + master_id = Column(ForeignKey('t_d3i_govs_order_master.id'), nullable=False, index=True, comment='主表ID') + gd_id = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='代签收唯一标志(需要填写)') + is_contact = Column(String(10, 'utf8mb4_unicode_ci'), nullable=False, comment='是否联系服务对象(默认是)') + contact_name = Column(String(100, 'utf8mb4_unicode_ci'), comment='联系人员(需要填写)') + contact_time = Column(String(64, 'utf8mb4_unicode_ci'), comment='联系时间(需要填写)') + contact_type = Column(String(255, 'utf8mb4_unicode_ci'), nullable=False, comment='联系情况(需要填写)') + advice = Column(Text(collation='utf8mb4_unicode_ci'), nullable=False, comment='处理意见(面向群众公开)(需要填写)') + reason = Column(Text(collation='utf8mb4_unicode_ci'), nullable=False, comment='处理意见(面向群众公开2)(需要填写)') + remarks = Column(String(500, 'utf8mb4_unicode_ci'), comment='备注(需要填写)') + file_id_str = Column(Text(collation='utf8mb4_unicode_ci'), comment='OA文件id,多个需要,拼接(需要填写)') + save_id = Column(String(64, 'utf8mb4_unicode_ci'), comment='提交数据为id(列表取nextTaskId)') + process_instance_id = Column(String(64, 'utf8mb4_unicode_ci'), comment='process_instance_id(列表取)') + business_key = Column(String(64, 'utf8mb4_unicode_ci'), comment='business_key(列表取orderId)') + order_no = Column(String(64, 'utf8mb4_unicode_ci'), comment='order_no(列表取)') + action_name = Column(String(255, 'utf8mb4_unicode_ci'), comment='action_name(列表取nextActionName)') + case_accord_type_one_name = Column(String(255, 'utf8mb4_unicode_ci'), + comment='case_accord_type_one_name(列表取caseAccordTypeOneName)') + case_accord_type_two_name = Column(String(255, 'utf8mb4_unicode_ci'), + comment='case_accord_type_two_name(列表取caseAccordTypeTwoName)') + case_accord_type_three_name = Column(String(255, 'utf8mb4_unicode_ci'), + comment='case_accord_type_three_name(列表取caseAccordTypeThreeName)') + save_status = Column(TINYINT(4), server_default=text("0"), comment='提交状态(0.未提交1.提交中2.提交成功9.提交失败)') + oa_feedback_status = Column(TINYINT(4), server_default=text("0"), + comment='OA反馈状态(0.初始状态1.反馈中2.反馈成功9.反馈失败)') + flow_token = Column(String(256, 'utf8mb4_unicode_ci'), comment='流令牌') + status = Column(BIGINT(20), nullable=False, server_default=text("0"), comment='提交状态') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + master = relationship('TD3iGovsOrderMaster') + + +class TD3iGovsSaveSign(BaseModel): + __tablename__ = 't_d3i_govs_save_sign' + __table_args__ = {'comment': '工单签收表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + master_id = Column(ForeignKey('t_d3i_govs_order_master.id'), nullable=False, index=True, comment='主表ID') + gd_id = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='代签收唯一标志(需要填写)') + order_id = Column(String(64, 'utf8mb4_unicode_ci'), comment='order_id(列表取)') + order_no = Column(String(64, 'utf8mb4_unicode_ci'), comment='order_no(列表取)') + order_process_id = Column(String(64, 'utf8mb4_unicode_ci'), comment='order_process_id(列表取,origin_id)') + task_id = Column(String(64, 'utf8mb4_unicode_ci'), comment='task_id(列表取nextTaskId)') + flag = Column(String(64, 'utf8mb4_unicode_ci'), comment='签收') + save_status = Column(TINYINT(4), server_default=text("0"), comment='提交状态(0.未提交1.提交中2.提交成功9.提交失败)') + oa_feedback_status = Column(TINYINT(4), server_default=text("0"), + comment='OA反馈状态(0.初始状态1.反馈中2.反馈成功9.反馈失败)') + flow_token = Column(String(256, 'utf8mb4_unicode_ci'), comment='流令牌') + status = Column(BIGINT(20), nullable=False, server_default=text("0"), comment='提交状态') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + master = relationship('TD3iGovsOrderMaster') + + +class TD3iGovsWorkOrderReturnFormal(BaseModel): + __tablename__ = 't_d3i_govs_work_order_return_formal' + __table_args__ = {'comment': '工单退回表'} + + id = Column(BIGINT(20), primary_key=True, comment='主键') + master_id = Column(ForeignKey('t_d3i_govs_order_master.id'), nullable=False, index=True, comment='主表ID') + gd_id = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, comment='代签收唯一标志(需要填写)') + return_reason = Column(String(255, 'utf8mb4_unicode_ci'), nullable=False, + comment='退回原因(非部门职能/申请主协办)(需要填写)') + return_reason_name = Column(String(255, 'utf8mb4_unicode_ci'), nullable=False, + comment='退回原因2(非部门职能/申请主协办)(需要填写)') + return_auditor_name = Column(String(100, 'utf8mb4_unicode_ci'), comment='退回审核人(需要填写)') + return_auditor_id = Column(String(64, 'utf8mb4_unicode_ci'), + comment='return_auditor_id(退回审核人存在时,默认1788283400345608193)') + deal_opinion = Column(Text(collation='utf8mb4_unicode_ci'), nullable=False, comment='处理意见(需要填写)') + reason = Column(Text(collation='utf8mb4_unicode_ci'), nullable=False, comment='处理意见2(需要填写)') + remark = Column(String(500, 'utf8mb4_unicode_ci'), comment='备注(需要填写)') + file_id_str = Column(Text(collation='utf8mb4_unicode_ci'), comment='OA文件id,多个需要,拼接(需要填写)') + process_instance_id = Column(String(64, 'utf8mb4_unicode_ci'), comment='process_instance_id(列表取)') + action_name = Column(String(255, 'utf8mb4_unicode_ci'), comment='action_name(列表取nextActionName)') + order_id = Column(String(64, 'utf8mb4_unicode_ci'), comment='order_id(列表取)') + task_id = Column(String(64, 'utf8mb4_unicode_ci'), comment='task_id(列表取nextTaskId)') + order_no = Column(String(64, 'utf8mb4_unicode_ci'), comment='order_no(列表取)') + case_accord_type_one_name = Column(String(255, 'utf8mb4_unicode_ci'), + comment='case_accord_type_one_name(列表取caseAccordTypeOneName)') + case_accord_type_two_name = Column(String(255, 'utf8mb4_unicode_ci'), + comment='case_accord_type_two_name(列表取caseAccordTypeTwoName)') + case_accord_type_three_name = Column(String(255, 'utf8mb4_unicode_ci'), + comment='case_accord_type_three_name(列表取caseAccordTypeThreeName)') + save_status = Column(TINYINT(4), server_default=text("0"), comment='提交状态(0.未提交1.提交中2.提交成功9.提交失败)') + oa_feedback_status = Column(TINYINT(4), server_default=text("0"), + comment='OA反馈状态(0.初始状态1.反馈中2.反馈成功9.反馈失败)') + flow_token = Column(String(256, 'utf8mb4_unicode_ci'), comment='流令牌') + status = Column(BIGINT(20), nullable=False, server_default=text("0"), comment='提交状态') + created_at = Column(DateTime, nullable=False, server_default=text("current_timestamp()"), comment='创建时间') + created_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='创建者') + updated_at = Column(DateTime, nullable=False, + server_default=text("current_timestamp() ON UPDATE current_timestamp()"), comment='更新时间') + updated_by = Column(String(64, 'utf8mb4_unicode_ci'), nullable=False, server_default=text("'D3I'"), + comment='更新者') + + master = relationship('TD3iGovsOrderMaster') diff --git a/models/dcm_apply_delay.py b/models/dcm_apply_delay.py new file mode 100644 index 0000000..e79735a --- /dev/null +++ b/models/dcm_apply_delay.py @@ -0,0 +1,553 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iDcmApplyPostpone +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class DcmApplyPostponeForm(ModelForm): + """ + 专业表单验证类(已完全根据 TD3iDcmApplyDelay 字段重构)。 + + 用于验证和处理数字城管-申请延期接口的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_dcm_apply_delay 的字段结构。 + """ + + # 基础信息 + id = IntegerField('主键ID') + dcm_task_id = IntegerField('任务ID') + task_number = StringField('任务号', validators=[Length(max=64, message='任务号长度不能超过64字符')]) + apply_act_id = StringField('工单流程ID', validators=[Length(max=64, message='工单流程ID长度不能超过64字符')]) + reply_part_id = StringField('回复环节ID', validators=[Length(max=64, message='回复环节ID长度不能超过64字符')]) + ard_level = StringField('固定值(等级)', validators=[Length(max=32, message='固定值长度不能超过32字符')]) + ard_type_id = StringField('延期类型', validators=[Length(max=32, message='延期类型长度不能超过32字符')]) + apply_memo = TextAreaField('申请意见', validators=[Length(max=65535, message='申请意见长度不能超过65535字符')]) + time_num = StringField('延期时长', validators=[Length(max=64, message='延期时长长度不能超过64字符')]) + apply_type = StringField('申请类型', validators=[Length(max=64, message='申请类型长度不能超过64字符')]) + delay_multiple = IntegerField('延期倍数') + postpone_date = StringField('延期日期', validators=[Length(max=64, message='延期日期长度不能超过64字符')]) + time_unit = StringField('时间单位', validators=[Length(max=64, message='时间单位长度不能超过64字符')]) + attachments = TextAreaField('附件', validators=[Length(max=65535, message='附件长度不能超过65535字符')]) + status = IntegerField('提交状态') + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class DcmApplyPostponeBase(TD3iDcmApplyPostpone, CommonModel): + """ + 专业基础类(已完全映射 TD3iDcmApplyDelay 字段)。 + + 继承自数据库模型 TD3iDcmApplyDelay 和通用模型 CommonModel。 + 封装所有与申请延期相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'dcm_task_id': 'dcm_task_id', + 'task_number': 'task_number', + 'apply_act_id': 'apply_act_id', + 'reply_part_id': 'reply_part_id', + 'ard_level': 'ard_level', + 'ard_type_id': 'ard_type_id', + 'apply_memo': 'apply_memo', + 'timeNum': 'timeNum', + 'postponeDate': 'postponeDate', + 'time_unit': 'time_unit', + 'attachments': 'attachments', + 'status': 'status', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + + @classmethod + async def exist_other(cls, id: Union[str, int], task_number: str): + """ + 检查是否存在除当前记录外的其他同任务号的延期申请。 + + :param id: 当前记录ID + :param task_number: 任务号 + :return: 存在返回记录对象,不存在返回None + """ + _query = select(cls).where(cls.id != id, cls.task_number == task_number) + _record: cls = await cls.query_first(_query) + return _record + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """ + 根据ID列表批量查找延期申请数据。 + """ + _query = select(cls).where(cls.id.in_(ids)) + _record_list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _record_list + + @classmethod + async def is_exist(cls, task_number: str): + """ + 检查延期申请是否已经存在(根据任务号)。 + + :param task_number: 任务号 + :return: 存在返回记录对象,不存在返回None + """ + _query = select(cls).where(cls.task_number == task_number) + _record: cls = await cls.query_first(_query) + return _record + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索延期申请数据的基础方法。 + + 支持字段: + - task_number, ard_type_id, status, created_by 等 + - 支持模糊匹配:apply_memo, attachments + - 支持精确匹配:task_number, ard_type_id, status, dcm_task_id + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_number': 'asc'} + :key str task_number: 精确匹配任务号 + :key str ard_type_id: 精确匹配延期类型 + :key int status: 精确匹配提交状态 + :key str apply_memo: 模糊匹配申请意见 + :key str attachments: 模糊匹配附件内容 + :key str created_by: 精确匹配创建者 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.apply_memo.key: '%{}%', + cls.attachments.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_number) + + _record_df = await cls.query_as_df(_data_query) + if not _record_df.empty: + _record_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _record_df[cls.id.key] = _record_df[cls.id.key].astype(str) + + return _record_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索延期申请数据,返回分页格式数据。 + """ + _record_df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _record_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_task_number(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 task_number 字段判断。 + + :param data_df: 输入的数据框架,必须包含 task_number 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 task_number 列表(去重) + task_numbers = data_df[cls.task_number.key].unique().tolist() + if not task_numbers: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 task_number + _query = select(cls.id, cls.task_number).where(cls.task_number.in_(task_numbers)) + task_numbers_df = await cls.query_as_df(_query) + + if task_numbers_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 task_number -> id 的映射字典 + task_number_to_id_map = dict(zip(task_numbers_df[cls.task_number.key], task_numbers_df[cls.id.key])) + + # 根据 task_number 是否在数据库中,划分数据 + mask_exists = data_df[cls.task_number.key].isin(task_numbers_df[cls.task_number.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.task_number.key].map(task_number_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class DcmApplyPostpone(DcmApplyPostponeBase): + """ + 专业模型类(主业务类,完全继承 TD3iDcmApplyDelay 字段)。 + + --- + description: 数字城管-申请延期接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + dcm_task_id: + description: 关联的任务ID + type: integer + example: 2001 + task_number: + description: 任务号 + type: string + example: "TASK20240501001" + maxLength: 64 + apply_act_id: + description: 工单流程ID + type: string + example: "ACT20240501001" + maxLength: 64 + reply_part_id: + description: 回复环节ID + type: string + example: "PART_REPLY_001" + maxLength: 64 + ard_level: + description: 固定值(等级) + type: string + example: "LEVEL_1" + maxLength: 32 + ard_type_id: + description: 延期类型(固定值) + type: string + example: "DELAY_TYPE_1" + maxLength: 32 + apply_memo: + description: 申请意见 + type: string + example: "因天气原因,申请延期处理。" + maxLength: 65535 + timeNum: + description: 延期时长 + type: string + example: "3" + maxLength: 64 + postponeDate: + description: 延期日期 + type: string + example: "2024-06-01" + maxLength: 64 + time_unit: + description: 时间单位 + type: string + example: "天" + maxLength: 64 + attachments: + description: 附件(JSON格式或逗号分隔) + type: string + example: "file1.jpg,file2.pdf" + maxLength: 65535 + status: + description: 提交状态(0草稿,1已提交,2已审批) + type: integer + example: 1 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "D3I" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "D3I" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的延期申请。 + + 业务流程: + 1. 使用 DcmApplyDelayForm 验证表单数据完整性 + 2. 检查任务号是否已存在延期申请 + 3. 创建新延期申请对象 + 4. 设置创建者和更新者为当前用户(默认为 D3I) + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象(可选) + :param kwargs: 延期申请参数字典 + :return: 新建延期申请对象 + :rtype: DcmApplyPostpone + :raises AssertionError: 当任务号已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = DcmApplyPostponeForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同任务号的延期申请 + _exist = await cls.is_exist(_form.task_number.data) + assert _exist is None, "该任务已存在延期申请,不能重复提交。" + + # 创建延期申请对象 + _record = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _record.created_by = user.username + _record.updated_by = user.username + else: + # 默认为 D3I + if not _record.created_by: + _record.created_by = "D3I" + if not _record.updated_by: + _record.updated_by = "D3I" + + await _record.async_save() + return _record + + @classmethod + async def delete(cls, id: Union[str, int]): + """ + 删除延期申请。 + + 业务流程: + 1. 根据ID查找延期申请 + 2. 验证存在性 + 3. 执行删除操作 + + :param id: 要删除的延期申请ID + :return: 删除的对象 + :rtype: DcmApplyPostpone + :raises AssertionError: 当记录不存在时抛出 + """ + _record: cls = await cls.async_find_by_id(id) + assert _record, f"根据 ID {id} 未找到延期申请记录。" + + # 执行删除 + _del_query = delete(cls).where(cls.id == _record.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除延期申请(任务号:{_record.task_number},ID:{_record.id}).') + return _record + + @classmethod + async def modify(cls, id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有延期申请信息。 + + 业务流程: + 1. 将 id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 DcmApplyDelayForm 验证表单数据 + 4. 检查是否有其他记录使用了相同的 task_number(排除自身) + 5. 查询原记录对象 + 6. 验证存在性 + 7. 更新字段并设置更新者 + 8. 保存到数据库 + 9. 返回更新后的对象 + + :param id: 要修改的延期申请ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的延期申请对象 + :rtype: DcmApplyPostpone + :raises AssertionError: 当记录不存在或任务号重复时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = DcmApplyPostponeForm(formdata=kwargs) + _form.validate_form() + + # 检查是否与其他记录重复(排除自身) + _other = await cls.exist_other(id, _form.task_number.data) + assert _other is None, "该任务号已存在其他延期申请,不能重复修改。" + + # 查询原记录 + _record: cls = await cls.async_find_by_id(id) + assert _record, f'查无此延期申请记录。' + + # 更新字段 + _record.copy_from_dict(_form.data, skip_none=True).before_save() + _record.updated_by = user.username if user else "D3I" + await _record.async_save() + return _record + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建新的延期申请(传入数据应为全新记录,无需校验是否存在)。 + + :param data_df: 包含延期申请数据的 DataFrame,字段需与模型属性匹配(如 task_number, apply_act_id 等) + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 向量化设置用户字段(无循环) + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + else: + # 默认 D3I + data_df['created_by'] = "D3I" + data_df['updated_by'] = "D3I" + + # 一次性转为字典列表(C 层高效) + records = data_df.to_dict('records') + + # 用列表推导式构造对象 + records = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + # 批量插入 + session = cls.get_aio_session() + try: + session.add_all(records) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(records)} 条延期申请。") + return len(records) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有延期申请。 + + :param data_df: 包含延期申请数据的 DataFrame,必须包含 id 列 + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + else: + data_df['updated_by'] = "D3I" + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条延期申请。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await DcmApplyPostpone.exists_task_number(data_df) + # 保存到数据库 + _created_count = await DcmApplyPostpone.create_batch(_latest_df, user) + _updated_count = await DcmApplyPostpone.modify_batch(_exists_df, user) + return _created_count, _updated_count diff --git a/models/dcm_apply_rollback.py b/models/dcm_apply_rollback.py new file mode 100644 index 0000000..b889e39 --- /dev/null +++ b/models/dcm_apply_rollback.py @@ -0,0 +1,320 @@ +import datetime +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField +from wtforms.validators import Length + +from models.common_model import CommonModel +from models.db_models import TD3iDcmApplyRollback +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.web.form import ModelForm + + +class DcmApplyRollbackForm(ModelForm): + """ + 申请回退任务表单验证类(完全映射 TD3iDcmApplyRollback 字段)。 + + 用于验证和处理数字城管-申请回退操作的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_dcm_apply_rollback 的字段结构。 + """ + + # 基础信息 + id = IntegerField('记录ID') + task_number = StringField('任务号', validators=[Length(max=64, message='任务号长度不能超过64字符')]) + act_id = StringField('工单ID', validators=[Length(max=64, message='工单ID长度不能超过64字符')]) + reply_part_id = IntegerField('回复部门ID') + ard_level = IntegerField('回退流向') + ard_type_id = IntegerField('延期类型ID') + opinion = TextAreaField('申请说明') + apply_type = StringField('申请类型(拒签、处置阶段照片未公开)', + validators=[Length(max=64, message='申请类型长度不能超过64字符')]) + trans_info = StringField('流转信息', validators=[Length(max=255, message='流转信息长度不能超过255字符')]) + attachments = TextAreaField('附件', validators=[Length(max=65535, message='附件长度不能超过65535字符')]) + status = IntegerField('提交状态') + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class DcmApplyRollbackBase(TD3iDcmApplyRollback, CommonModel): + """ + 申请回退任务基础类(完全映射 TD3iDcmApplyRollback 字段)。 + + 继承自数据库模型 TD3iDcmApplyRollback 和通用模型 CommonModel。 + 封装所有与回退操作相关的通用操作方法。 + """ + + @classmethod + async def is_exist(cls, act_id: str): + """ + 检查申请回退记录是否已存在(根据任务ID)。 + + :param act_id: 任务ID + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.act_id == act_id) + _rollback: cls = await cls.query_first(_query) + return _rollback + + @classmethod + async def exists_act_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 act_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 act_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 act_id 列表(去重) + act_ids = data_df[cls.act_id.key].unique().tolist() + if not act_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 act_id + _query = select(cls.id, cls.act_id).where(cls.act_id.in_(act_ids)) + act_ids_df = await cls.query_as_df(_query) + + if act_ids_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 act_id -> id 的映射字典 + act_id_to_id_map = dict(zip(act_ids_df[cls.act_id.key], act_ids_df[cls.id.key])) + + # 根据 act_id 是否在数据库中,划分数据 + mask_exists = data_df[cls.act_id.key].isin(act_ids_df[cls.act_id.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.act_id.key].map(act_id_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class DcmApplyRollback(DcmApplyRollbackBase): + """ + 申请回退任务模型类(主业务类,完全继承 TD3iDcmApplyRollback 字段)。 + + --- + description: 数字城管-申请回退接口 + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的申请回退记录。 + + 业务流程: + 1. 使用 DcmApplyRollbackForm 验证表单数据完整性 + 2. 检查是否已存在相同 act_id 的记录(避免重复提交) + 3. 创建新申请回退对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 回退参数字典 + :return: 新建申请回退对象 + :rtype: DcmApplyRollback + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = DcmApplyRollbackForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 act_id 的记录 + _existing = await cls.is_exist(_form.act_id.data) + assert _existing is None, "该任务已存在申请回退记录,不能重复提交。" + + # 创建对象 + _rollback = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _rollback.created_by = user.username + _rollback.updated_by = user.username + await _rollback.async_save() + return _rollback + + @classmethod + async def delete(cls, rollback_id: Union[str, int]): + """ + 删除申请回退记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param rollback_id: 要删除的申请回退记录ID + :return: 删除的记录对象 + :rtype: DcmApplyRollback + :raises AssertionError: 当记录不存在时抛出 + """ + _rollback: cls = await cls.async_find_by_id(rollback_id) + assert _rollback, f"根据 ID {rollback_id} 未找到申请回退记录。" + + _del_query = delete(cls).where(cls.id == _rollback.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除回退记录(任务号:{_rollback.task_number},ID:{_rollback.id}).') + return _rollback + + @classmethod + async def modify(cls, rollback_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有申请回退记录。 + + 业务流程: + 1. 将 rollback_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 DcmApplyRollbackForm 验证表单数据 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新字段并设置更新者 + 7. 保存到数据库 + 8. 返回更新后的对象 + + :param rollback_id: 要修改的申请回退记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的申请回退对象 + :rtype: DcmApplyRollback + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = DcmApplyRollbackForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _rollback: cls = await cls.async_find_by_id(rollback_id) + assert _rollback, f'查无此申请回退信息。' + + # 更新字段 + _rollback.copy_from_dict(_form.data, skip_none=True).before_save() + _rollback.updated_by = user.username + await _rollback.async_save() + return _rollback + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建申请回退记录(传入数据应为全新记录)。 + + :param data_df: 包含回退数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + rollbacks = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(rollbacks) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(rollbacks)} 条申请回退记录。") + return len(rollbacks) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有申请回退记录。 + + :param data_df: 包含申请回退数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条申请回退记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存申请回退数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await DcmApplyRollback.exists_act_id(data_df) + # 保存到数据库 + _created_count = await DcmApplyRollback.create_batch(_latest_df, user) + _updated_count = await DcmApplyRollback.modify_batch(_exists_df, user) + return _created_count, _updated_count diff --git a/models/dcm_dispose.py b/models/dcm_dispose.py new file mode 100644 index 0000000..c58f2b7 --- /dev/null +++ b/models/dcm_dispose.py @@ -0,0 +1,556 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iDcmDispose +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class DcmDisposeForm(ModelForm): + """ + 批转任务表单验证类(完全映射 TD3iDcmDispose 字段)。 + + 用于验证和处理数字城管-批转任务的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_dcm_dispose 的字段结构。 + """ + + # 基础信息 + id = IntegerField('记录ID') + rec_id = StringField('记录ID', validators=[Length(max=64, message='记录ID长度不能超过64字符')]) + task_number = StringField('任务号', validators=[Length(max=64, message='任务号长度不能超过64字符')]) + act_id = StringField('工单ID', validators=[Length(max=64, message='工单ID长度不能超过64字符')]) + task_list_id = StringField('任务列表ID', validators=[Length(max=64, message='任务列表ID长度不能超过64字符')]) + trans_info = StringField('批转对象', validators=[Length(max=64, message='批转对象长度不能超过64字符')]) + opinion = TextAreaField('批转意见', validators=[Length(max=65535, message='批转意见长度不能超过65535字符')]) + add_num = StringField('批转意见', validators=[Length(max=32, message='批转意见长度不能超过32字符')]) + attachments = TextAreaField('附件', validators=[Length(max=65535, message='附件长度不能超过65535字符')]) + send_message = StringField('发送短信', validators=[Length(max=32, message='发送短信标识长度不能超过32字符')]) + undertake_user_name = StringField('承办人员', validators=[Length(max=64, message='承办人员长度不能超过64字符')]) + undertake_phone = StringField('联系电话', validators=[Length(max=64, message='联系电话长度不能超过64字符')]) + status = IntegerField('提交状态') + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class DcmDisposeBase(TD3iDcmDispose, CommonModel): + """ + 批转任务基础类(完全映射 TD3iDcmDispose 字段)。 + + 继承自数据库模型 TD3iDcmDispose 和通用模型 CommonModel。 + 封装所有与批转任务相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'rec_id': 'rec_id', + 'task_number': 'task_number', + 'act_id': 'act_id', + 'task_list_id': 'task_list_id', + 'trans_info': 'trans_info', + 'opinion': 'opinion', + 'add_num': 'add_num', + 'attachments': 'attachments', + 'send_message': 'send_message', + 'undertake_user_name': 'undertake_user_name', + 'undertake_phone': 'undertake_phone', + 'status': 'status', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 批转任务数据映射 + """ + + @classmethod + async def exist_other(cls, id: Union[str, int], rec_id: str, task_number: str): + """ + 检查是否存在除当前记录外的其他相同任务号或记录ID的批转记录。 + + :param id: 当前记录ID + :param rec_id: 记录ID + :param task_number: 任务号 + :return: 存在返回记录对象,不存在返回None + """ + _query = select(cls).where( + cls.id != id, + (cls.rec_id == rec_id) | (cls.task_number == task_number) + ) + _dispose: cls = await cls.query_first(_query) + return _dispose + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """ + 根据ID列表批量查找批转任务数据。 + """ + _query = select(cls).where(cls.id.in_(ids)) + _dispose_list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _dispose_list + + @classmethod + async def is_exist(cls, rec_id: str, task_number: str): + """ + 检查批转记录是否已经存在(根据记录ID或任务号)。 + """ + _query = select(cls).where( + (cls.rec_id == rec_id) | (cls.task_number == task_number) + ) + _dispose: cls = await cls.query_first(_query) + return _dispose + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索批转任务数据的基础方法。 + + 支持字段: + - task_number, rec_id, act_id, trans_info, status + - 支持模糊匹配:trans_info, opinion + - 支持精确匹配:status, send_message + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_number': 'asc'} + :key str task_number: 精确匹配任务号 + :key str rec_id: 精确匹配记录ID + :key str act_id: 精确匹配工单ID + :key str trans_info: 模糊匹配批转对象 + :key str opinion: 模糊匹配批转意见 + :key int status: 精确匹配提交状态 + :key str send_message: 精确匹配发送短信标识 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.trans_info.key: '%{}%', + cls.opinion.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_number, cls.rec_id) + + _dispose_df = await cls.query_as_df(_data_query) + if not _dispose_df.empty: + _dispose_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _dispose_df[cls.id.key] = _dispose_df[cls.id.key].astype(str) + + return _dispose_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索批转任务数据,返回分页格式数据。 + """ + _dispose_df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _dispose_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_rec_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 rec_id 和 task_number 判断。 + + :param data_df: 输入的数据框架,必须包含 rec_id 和 task_number 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 rec_id 和 task_number 列表(去重组合) + rec_ids = data_df[cls.rec_id.key].unique().tolist() + task_numbers = data_df[cls.task_number.key].unique().tolist() + + if not rec_ids and not task_numbers: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的记录(任一匹配) + _query = select(cls.id, cls.rec_id, cls.task_number).where( + (cls.rec_id.in_(rec_ids)) | (cls.task_number.in_(task_numbers)) + ) + exists_df = await cls.query_as_df(_query) + + if exists_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 (rec_id, task_number) -> id 的映射字典 + exists_map = set(zip(exists_df[cls.rec_id.key], exists_df[cls.task_number.key])) + + # 标记是否存在 + mask_exists = data_df.apply( + lambda row: (row[cls.rec_id.key], row[cls.task_number.key]) in exists_map, + axis=1 + ) + exists_df = data_df[mask_exists].copy() + latest_df = data_df[~mask_exists].copy() + + # 为存在的记录补充 id 字段(可选) + exists_df[cls.id.key] = exists_df.apply( + lambda row: exists_df[ + (exists_df[cls.rec_id.key] == row[cls.rec_id.key]) & + (exists_df[cls.task_number.key] == row[cls.task_number.key]) + ][cls.id.key].iloc[0] if len(exists_df[ + (exists_df[cls.rec_id.key] == row[cls.rec_id.key]) & + (exists_df[cls.task_number.key] == row[cls.task_number.key]) + ]) > 0 else None, + axis=1 + ) + + return exists_df, latest_df + + +@register_swagger_model +class DcmDispose(DcmDisposeBase): + """ + 批转任务模型类(主业务类,完全继承 TD3iDcmDispose 字段)。 + + --- + description: 数字城管-批转接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + rec_id: + description: 记录ID + type: string + example: "20240501001" + maxLength: 64 + task_number: + description: 任务号 + type: string + example: "TASK20240501001" + maxLength: 64 + act_id: + description: 工单ID + type: string + example: "ACT20240501001" + maxLength: 64 + task_list_id: + description: 任务列表ID + type: string + example: "LIST20240501001" + maxLength: 64 + trans_info: + description: 批转对象(固定:市受理员) + type: string + example: "市受理员" + maxLength: 64 + opinion: + description: 批转意见 + type: string + example: "请转交至市容科处理" + maxLength: 65535 + add_num: + description: 批转意见(冗余字段) + type: string + example: "请转交" + maxLength: 32 + attachments: + description: 附件(JSON格式) + type: string + example: '["file1.jpg","file2.pdf"]' + maxLength: 65535 + send_message: + description: 发送短信(1:发送,0:不发送) + type: string + example: "1" + maxLength: 32 + status: + description: 提交状态 + type: integer + example: 1 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的批转任务。 + + 业务流程: + 1. 使用 TD3iDcmDisposeForm 验证表单数据完整性 + 2. 检查是否存在相同记录ID或任务号的批转记录 + 3. 创建新批转对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的批转对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 批转参数字典 + :return: 新建批转任务对象 + :rtype: TD3iDcmDispose + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _dispose_form = DcmDisposeForm(formdata=kwargs) + _dispose_form.validate_form() + + # 检查是否已存在相同记录ID或任务号 + _exist: cls = await cls.is_exist(_dispose_form.rec_id.data, _dispose_form.task_number.data) + assert _exist is None, "该记录ID或任务号已存在批转记录,不能重复创建。" + + # 创建批转对象 + _dispose = cls().copy_from_dict(_dispose_form.data, skip_none=True).before_save() + if user: + _dispose.created_by = user.username + _dispose.updated_by = user.username + await _dispose.async_save() + return _dispose + + @classmethod + async def delete(cls, dispose_id: Union[str, int]): + """ + 删除批转任务。 + + 业务流程: + 1. 根据ID查找批转任务 + 2. 验证任务存在性 + 3. 执行删除操作 + + :param dispose_id: 要删除的批转任务ID + :return: 删除的批转任务对象 + :rtype: TD3iDcmDispose + :raises AssertionError: 当任务不存在时抛出 + """ + _dispose: cls = await cls.async_find_by_id(dispose_id) + assert _dispose, f"根据 ID {dispose_id} 未找到批转任务。" + + # 执行删除 + _del_query = delete(cls).where(cls.id == _dispose.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除批转任务(任务号:{_dispose.task_number},ID:{_dispose.id}).') + return _dispose + + @classmethod + async def modify(cls, dispose_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有批转任务信息。 + + 业务流程: + 1. 将 dispose_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 TD3iDcmDisposeForm 验证表单数据 + 4. 检查是否有其他批转任务使用了相同的 rec_id 或 task_number + 5. 查询原批转任务对象 + 6. 验证任务存在性 + 7. 更新字段并设置更新者 + 8. 保存到数据库 + 9. 返回更新后的批转任务对象 + + :param dispose_id: 要修改的批转任务ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的批转任务对象 + :rtype: TD3iDcmDispose + :raises AssertionError: 当任务不存在或信息重复时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _dispose_form = DcmDisposeForm(formdata=kwargs) + _dispose_form.validate_form() + + # 检查是否与其他批转任务重复(排除自身) + _other = await cls.exist_other( + dispose_id, + _dispose_form.rec_id.data, + _dispose_form.task_number.data + ) + assert _other is None, "批转记录ID或任务号已存在,不能重复修改。" + + # 查询原批转任务 + _dispose: cls = await cls.async_find_by_id(dispose_id) + assert _dispose, f'查无此批转信息。' + + # 更新字段 + _dispose.copy_from_dict(_dispose_form.data, skip_none=True).before_save() + _dispose.updated_by = user.username + await _dispose.async_save() + return _dispose + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建新批转任务(传入数据应为全新记录,无需校验是否存在)。 + + :param data_df: 包含批转任务数据的 DataFrame,字段需与模型属性匹配(如 rec_id, task_number 等) + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的任务数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 向量化设置用户字段(无循环) + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + # 一次性转为字典列表(C 层高效) + records = data_df.to_dict('records') + + # 用列表推导式构造对象 + disposals = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + # 批量插入 + session = cls.get_aio_session() + try: + session.add_all(disposals) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(disposals)} 条新批转记录。") + return len(disposals) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有批转任务。 + + :param data_df: 包含批转任务数据的 DataFrame,必须包含 id 列 + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的任务数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条批转记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await DcmDispose.exists_rec_id(data_df) + # 保存到数据库 + _created_count = await DcmDispose.create_batch(_latest_df, user) + _updated_count = await DcmDispose.modify_batch(_exists_df, user) + return _created_count, _updated_count diff --git a/models/dcm_push_status.py b/models/dcm_push_status.py new file mode 100644 index 0000000..cb09a9e --- /dev/null +++ b/models/dcm_push_status.py @@ -0,0 +1,543 @@ +import random +from typing import Union, Optional, Callable + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from paste.core.logging import echo_log +from paste.util.pagination import Pagination + +import models +from models.common_model import CommonModel +from models.db_models import TD3iDcmPushStatu + + +class DcmPushStatuBase(TD3iDcmPushStatu, CommonModel): + """ + 推送状态基础类(完全映射 TD3iDcmPushStatu 字段)。 + + 封装所有与推送OA状态相关的通用操作方法。 + """ + + # 无字段名映射需求,保持原样 + FieldMapping = {} + + @classmethod + async def exist_other(cls, id: Union[str, int], dcm_task_id: Union[str, int]): + """ + 检查是否存在除当前记录外的其他同任务ID的推送状态记录。 + + :param id: 当前记录ID + :param dcm_task_id: 任务ID(唯一标志) + :return: 存在返回记录对象,不存在返回None + """ + _query = select(cls).where(cls.id != id, cls.dcm_task_id == dcm_task_id) + _record: cls = await cls.query_first(_query) + return _record + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """ + 根据ID列表批量查找推送状态数据。 + """ + _query = select(cls).where(cls.id.in_(ids)) + _record_list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _record_list + + @classmethod + async def is_exist(cls, dcm_task_id: Union[str, int]): + """ + 检查推送状态是否已经存在(根据任务ID)。 + """ + _query = select(cls).where(cls.dcm_task_id == dcm_task_id) + _record: cls = await cls.query_first(_query) + return _record + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索推送状态数据的基础方法。 + + 支持字段: + - dcm_task_id, push_task_status, push_task_attachment_status, ... + - 不支持模糊匹配(均为整型状态码) + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'updated_at': 'desc'} + :key int dcm_task_id: 精确匹配任务ID + :key int push_task_status: 精确匹配推送待办工单状态 + :key int push_task_attachment_status: 精确匹配附件状态 + :key int push_task_extend_info_status: 精确匹配扩展信息状态 + :key int push_task_file_upload_status: 精确匹配文件上传状态 + :key int push_task_more_info_status: 精确匹配更多信息状态 + :key int push_task_process_info_status: 精确匹配处理过程状态 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 无模糊字段,仅精确匹配 + _query = select(cls).where( + *cls.search_wheres(**kwargs) + ) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.updated_at.desc()) + + _record_df = await cls.query_as_df(_data_query) + if not _record_df.empty: + _record_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + return _record_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索推送状态数据,返回分页格式数据。 + """ + _record_df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _record_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_relation(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 dcm_task_id 判断。 + + :param data_df: 输入的数据框架,必须包含 dcm_task_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 dcm_task_id 组合 + task_ids = data_df[cls.dcm_task_id.key].unique().tolist() + if not task_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的记录 + _query = select(cls.id, cls.dcm_task_id).where(cls.dcm_task_id.in_(task_ids)) + exists_df = await cls.query_as_df(_query) + exists_df[cls.dcm_task_id.key] = exists_df[cls.dcm_task_id.key].astype(str) + + if exists_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 dcm_task_id -> id 的映射 + key_to_id_map = dict(zip(exists_df[cls.dcm_task_id.key], exists_df[cls.id.key])) + + # 根据 dcm_task_id 是否在数据库中划分数据 + mask_exists = data_df[cls.dcm_task_id.key].isin(exists_df[cls.dcm_task_id.key]) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df[cls.dcm_task_id.key].map(key_to_id_map) + latest_df = data_df[~mask_exists].copy() + + return exists_df, latest_df + + @classmethod + async def fill_attachment(cls, data_df: pd.DataFrame, index_field: str = 'id', + column_name: str = 'push_status', is_full: bool = True, + preprocessing: Optional[Callable] = None): + """ + 填充推送状态数据到数据框架。 + + 用于在查询结果中添加关联的推送状态信息。 + + :param pandas.DataFrame data_df: 待填充的数据框架 + :param str index_field: 索引字段,一般是任务ID + :param str column_name: 填充时,新增加的列名称,默认为`push_status` + :param is_full: 是否填充完整数据(此处无关联表,忽略) + :param preprocessing: 预处理,注意预处理必须要返回处理后的结果 + :return: 推送状态数据框架(已填充) + :rtype: pandas.DataFrame + """ + if data_df.empty: + return pd.DataFrame() + + _task_ids = list(set(data_df[index_field].unique().tolist())) + if not _task_ids: + return pd.DataFrame() + + _query = select(cls).where(cls.dcm_task_id.in_(_task_ids)) + + _status_df: pd.DataFrame = await cls.query_as_df(_query) + if not _status_df.empty: + _status_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + # 整理输出数据类型 + _status_df[cls.id.key] = _status_df[cls.id.key].astype(str) + _status_df[cls.dcm_task_id.key] = _status_df[cls.dcm_task_id.key].astype(str) + + # 设置索引 + _status_df['index_id'] = _status_df[cls.id.key] + _status_df.set_index(['index_id'], inplace=True) + # 对数据进行预处理 + if isinstance(preprocessing, Callable): + _status_df = preprocessing(_status_df) + # 增加数据填充列 + data_df[column_name] = data_df[index_field].apply( + lambda x: _status_df.query(f"{cls.dcm_task_id.key}=='{x}'").to_dict('records') + ) + else: + data_df[column_name] = [[] for _ in range(len(data_df))] + + return _status_df + + +@register_swagger_model +class DcmPushStatus(DcmPushStatuBase): + """ + 推送状态业务模型类(主业务类,完全继承 TD3iDcmPushStatu 字段)。 + + --- + description: 推送OA状态记录 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + dcm_task_id: + description: 唯一标志(任务ID) + type: integer + example: 2001 + push_task_status: + description: 推送待办工单状态 + type: integer + example: 1 + push_task_attachment_status: + description: 推送待办工单附件状态 + type: integer + example: 1 + push_task_extend_info_status: + description: 推送待办工单扩展信息状态 + type: integer + example: 1 + push_task_file_upload_status: + description: 上传待办工单文件状态 + type: integer + example: 1 + push_task_more_info_status: + description: 推送待办工单更多信息状态 + type: integer + example: 1 + push_task_process_info_status: + description: 推送待办工单处理过程状态 + type: integer + example: 1 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "D3I" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "D3I" + readOnly: true + """ + + @classmethod + async def create(cls, **kwargs): + """ + 创建新的推送状态记录。 + + 业务流程: + 1. 使用 kwargs 直接构造对象(无需表单验证,因无前端交互) + 2. 检查是否已存在相同任务ID的记录(避免重复) + 3. 创建新记录对象 + 4. 设置创建者和更新者为 'D3I' + 5. 保存到数据库 + 6. 返回创建的对象 + + :param kwargs: 推送状态参数字典 + :return: 新建推送状态对象 + :rtype: DcmPushStatus + :raises AssertionError: 当记录已存在时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 检查是否已存在同任务ID的记录 + _record: cls = await cls.is_exist(kwargs.get('dcm_task_id')) + assert _record is None, "相同任务ID的推送状态已存在,不能重复创建。" + + # 创建记录对象 + _record = cls().copy_from_dict(kwargs, skip_none=True).before_save() + # 强制设置创建者和更新者为 'D3I' + _record.created_by = 'D3I' + _record.updated_by = 'D3I' + await _record.async_save() + return _record + + @classmethod + async def delete(cls, id: Union[str, int]): + """ + 删除推送状态记录(软删除,不实际删除,仅用于逻辑隔离)。 + + 注意:此系统建议保留历史记录,删除操作仅为标记。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行物理删除(因无软删除字段,此处直接删除) + + :param id: 要删除的记录ID + :return: 删除的记录ID + :rtype: int + :raises AssertionError: 当记录不存在时抛出 + """ + _record: cls = await cls.async_find_by_id(id) + assert _record, f"根据 ID {id} 未找到推送状态记录。" + + # 执行物理删除 + _del_query = delete(cls).where(cls.id == id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除推送状态记录(ID:{id}).') + return _del_count + + @classmethod + async def modify(cls, id: Union[str, int], **kwargs): + """ + 修改已有推送状态信息。 + + 注意:仅允许更新状态码字段,不允许修改 id、created_at、created_by 等系统字段。 + + 业务流程: + 1. 处理字符串字段去除空格 + 2. 查询原记录 + 3. 验证存在性 + 4. 更新允许字段 + 5. 设置 updated_by = 'D3I' + 6. 保存到数据库 + 7. 返回更新后的对象 + + :param id: 要修改的记录ID + :param kwargs: 需要更新的字段(仅限状态字段) + :return: 修改后的推送状态对象 + :rtype: DcmPushStatus + :raises AssertionError: 当记录不存在时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 查询原记录 + _record: cls = await cls.async_find_by_id(id) + assert _record, f"根据 ID {id} 未找到推送状态记录。" + + # 允许更新的字段(仅状态码) + allowed_fields = { + 'push_task_status', + 'push_task_attachment_status', + 'push_task_extend_info_status', + 'push_task_file_upload_status', + 'push_task_more_info_status', + 'push_task_process_info_status', + } + + # 过滤合法字段 + update_data = {k: v for k, v in kwargs.items() if k in allowed_fields and v is not None} + if not update_data: + return _record + + # 更新字段 + _record.copy_from_dict(update_data, skip_none=True) + _record.updated_by = 'D3I' + await _record.async_save() + return _record + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame): + """ + 批量创建新推送状态记录(传入数据应为全新记录,无需校验是否存在)。 + + :param data_df: 包含推送状态数据的 DataFrame,字段需与模型属性匹配(如 dcm_task_id, push_task_status 等) + :return: 成功创建的记录数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 一次性转为字典列表(C 层高效) + records = data_df.to_dict('records') + + # 用列表推导式构造对象 + records = [ + cls().copy_from_dict(record, skip_none=True).before_save() + for record in records + ] + + # 批量插入 + session = cls.get_aio_session() + try: + session.add_all(records) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(records)} 条推送状态记录。") + return len(records) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame): + """ + 批量修改已有推送状态记录。 + + :param data_df: 包含推送状态数据的 DataFrame,必须包含 id 列 + :return: 成功更新的记录数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条推送状态记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame): + """ + 批量保存数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :return 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await DcmPushStatus.exists_relation(data_df) + # 保存到数据库 + _created_count = await DcmPushStatus.create_batch(_latest_df) + _updated_count = await DcmPushStatus.modify_batch(_exists_df) + return _created_count, _updated_count + + @classmethod + async def set_push_task_status(cls, dcm_task_id: Union[str, int], status: int = 1): + dcm_task: cls = await cls(dcm_task_id=dcm_task_id).async_find_first() + if dcm_task: + dcm_task.push_task_status = status + else: + dcm_task = cls(dcm_task_id=dcm_task_id, push_task_status=status) + # 保存数据 + await dcm_task.async_save() + + @classmethod + async def set_push_task_detail_status(cls, dcm_task_id: Union[str, int], status: int = 1): + dcm_task: cls = await cls(dcm_task_id=dcm_task_id).async_find_first() + if dcm_task: + dcm_task.push_task_detail_status = status + else: + dcm_task = cls(dcm_task_id=dcm_task_id, push_task_detail_status=status) + # 保存数据 + await dcm_task.async_save() + + @classmethod + async def set_push_task_attachment_status(cls, dcm_task_id: Union[str, int], status: int = 1): + dcm_task: cls = await cls(dcm_task_id=dcm_task_id).async_find_first() + if dcm_task: + dcm_task.push_task_attachment_status = status + else: + dcm_task = cls(dcm_task_id=dcm_task_id, push_task_attachment_status=status) + # 保存数据 + await dcm_task.async_save() + + @classmethod + async def set_push_task_extend_info_status(cls, dcm_task_id: Union[str, int], status: int = 1): + dcm_task: cls = await cls(dcm_task_id=dcm_task_id).async_find_first() + if dcm_task: + dcm_task.push_task_extend_info_status = status + else: + dcm_task = cls(dcm_task_id=dcm_task_id, push_task_extend_info_status=status) + # 保存数据 + await dcm_task.async_save() + + @classmethod + async def set_push_task_file_upload_status(cls, dcm_task_id: Union[str, int], status: int = 1): + dcm_task: cls = await cls(dcm_task_id=dcm_task_id).async_find_first() + if dcm_task: + dcm_task.push_task_file_upload_status = status + else: + dcm_task = cls(dcm_task_id=dcm_task_id, push_task_file_upload_status=status) + # 保存数据 + await dcm_task.async_save() + + @classmethod + async def set_push_task_more_info_status(cls, dcm_task_id: Union[str, int], status: int = 1): + dcm_task: cls = await cls(dcm_task_id=dcm_task_id).async_find_first() + if dcm_task: + dcm_task.push_task_more_info_status = status + else: + dcm_task = cls(dcm_task_id=dcm_task_id, push_task_more_info_status=status) + # 保存数据 + await dcm_task.async_save() + + @classmethod + async def set_push_task_process_info_status(cls, dcm_task_id: Union[str, int], status: int = 1): + dcm_task: cls = await cls(dcm_task_id=dcm_task_id).async_find_first() + if dcm_task: + dcm_task.push_task_process_info_status = status + else: + dcm_task = cls(dcm_task_id=dcm_task_id, push_task_process_info_status=status) + # 保存数据 + await dcm_task.async_save() \ No newline at end of file diff --git a/models/dcm_rollback.py b/models/dcm_rollback.py new file mode 100644 index 0000000..66f8e53 --- /dev/null +++ b/models/dcm_rollback.py @@ -0,0 +1,504 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iDcmRollback +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class DcmRollbackForm(ModelForm): + """ + 回退任务表单验证类(完全映射 TD3iDcmRollback 字段)。 + + 用于验证和处理数字城管-回退操作的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_dcm_rollback 的字段结构。 + """ + + # 基础信息 + id = IntegerField('记录ID') + rec_id = StringField('记录ID', validators=[Length(max=64, message='记录ID长度不能超过64字符')]) + task_number = StringField('任务号', validators=[Length(max=64, message='任务号长度不能超过64字符')]) + act_id = StringField('工单ID', validators=[Length(max=64, message='工单ID长度不能超过64字符')]) + transInfo = StringField('回退流向', validators=[Length(max=64, message='回退流向长度不能超过64字符')]) + save_old_act_flag = StringField('是否保留旧流程', + validators=[Length(max=64, message='是否保留旧流程长度不能超过64字符')]) + opinion = StringField('回退意见', validators=[Length(max=500, message='回退意见长度不能超过500字符')]) + rollback_reason_id = StringField('回退原因ID', + validators=[Length(max=500, message='回退原因ID长度不能超过500字符')]) + attachments = TextAreaField('附件', validators=[Length(max=65535, message='附件长度不能超过65535字符')]) + send_message = StringField('发送短信', validators=[Length(max=32, message='发送短信长度不能超过32字符')]) + not_assigned = StringField('申请不交办(0:不打勾,1:打勾)', + validators=[Length(max=16, message='申请不交办长度不能超过16字符')]) + not_assigned_reason = TextAreaField('申请不交办原因') + undertake_user_name = StringField('承办人员', validators=[Length(max=64, message='承办人员长度不能超过64字符')]) + undertake_phone = StringField('联系电话', validators=[Length(max=64, message='联系电话长度不能超过64字符')]) + status = IntegerField('提交状态') + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class DcmRollbackBase(TD3iDcmRollback, CommonModel): + """ + 回退任务基础类(完全映射 TD3iDcmRollback 字段)。 + + 继承自数据库模型 TD3iDcmRollback 和通用模型 CommonModel。 + 封装所有与回退操作相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'rec_id': 'rec_id', + 'task_number': 'task_number', + 'act_id': 'act_id', + 'transInfo': 'transInfo', + 'save_old_act_flag': 'save_old_act_flag', + 'opinion': 'opinion', + 'rollback_reason_id': 'rollback_reason_id', + 'attachments': 'attachments', + 'send_message': 'send_message', + 'not_assigned': 'not_assigned', + 'not_assigned_reason': 'not_assigned_reason', + 'undertake_user_name': 'undertake_user_name', + 'undertake_phone': 'undertake_phone', + 'status': 'status', + } + """ + 回退数据映射 + """ + + @classmethod + async def is_exist(cls, rec_id: str): + """ + 检查回退记录是否已存在(根据记录ID)。 + + :param rec_id: 记录ID + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.rec_id == rec_id) + _rollback: cls = await cls.query_first(_query) + return _rollback + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索回退数据的基础方法。 + + 支持字段: + - task_number, rec_id, act_id, transInfo, status + - 支持模糊匹配:opinion, attachments + - 支持精确匹配:status, send_message + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_number': 'asc'} + :key str task_number: 精确匹配任务号 + :key str rec_id: 精确匹配记录ID + :key str act_id: 精确匹配工单ID + :key str transInfo: 精确匹配回退流向 + :key str opinion: 模糊匹配回退意见 + :key str attachments: 模糊匹配附件 + :key int status: 精确匹配提交状态 + :key str send_message: 精确匹配发送短信标志 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.opinion.key: '%{}%', + cls.attachments.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_number, cls.rec_id) + + _rollback_df = await cls.query_as_df(_data_query) + if not _rollback_df.empty: + _rollback_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _rollback_df[cls.id.key] = _rollback_df[cls.id.key].astype(str) + + return _rollback_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索回退数据,返回分页格式数据。 + """ + _rollback_df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _rollback_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_rec_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 rec_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 rec_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 rec_id 列表(去重) + rec_ids = data_df[cls.rec_id.key].unique().tolist() + if not rec_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 rec_id + _query = select(cls.id, cls.rec_id).where(cls.rec_id.in_(rec_ids)) + rec_ids_df = await cls.query_as_df(_query) + + if rec_ids_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 rec_id -> id 的映射字典 + rec_id_to_id_map = dict(zip(rec_ids_df[cls.rec_id.key], rec_ids_df[cls.id.key])) + + # 根据 rec_id 是否在数据库中,划分数据 + mask_exists = data_df[cls.rec_id.key].isin(rec_ids_df[cls.rec_id.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.rec_id.key].map(rec_id_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class DcmRollback(DcmRollbackBase): + """ + 回退任务模型类(主业务类,完全继承 TD3iDcmRollback 字段)。 + + --- + description: 数字城管-回退接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + rec_id: + description: 记录ID + type: string + example: "R20240501001" + maxLength: 64 + task_number: + description: 任务号 + type: string + example: "TASK20240501001" + maxLength: 64 + act_id: + description: 工单ID + type: string + example: "ACT20240501001" + maxLength: 64 + transInfo: + description: 回退流向(固定:市受理员) + type: string + example: "市受理员" + maxLength: 64 + save_old_act_flag: + description: 是否保留旧流程 + type: string + example: "1" + maxLength: 64 + opinion: + description: 回退意见 + type: string + example: "流程错误,需退回重新处理" + maxLength: 500 + rollback_reason_id: + description: 回退原因ID + type: string + example: "REASON_001" + maxLength: 500 + attachments: + description: 附件(多个用逗号分隔) + type: string + example: "file1.jpg,file2.pdf" + maxLength: 65535 + send_message: + description: 发送短信(1:发送,0:不发送) + type: string + example: "1" + maxLength: 32 + status: + description: 提交状态 + type: integer + example: 1 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的回退记录。 + + 业务流程: + 1. 使用 DcmRollbackForm 验证表单数据完整性 + 2. 检查是否已存在相同 rec_id 的记录(避免重复提交) + 3. 创建新回退对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 回退参数字典 + :return: 新建回退对象 + :rtype: DcmRollback + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = DcmRollbackForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 rec_id 的记录 + _existing = await cls.is_exist(_form.rec_id.data) + assert _existing is None, "该任务已存在回退记录,不能重复提交。" + + # 创建对象 + _rollback = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _rollback.created_by = user.username + _rollback.updated_by = user.username + await _rollback.async_save() + return _rollback + + @classmethod + async def delete(cls, rollback_id: Union[str, int]): + """ + 删除回退记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param rollback_id: 要删除的回退记录ID + :return: 删除的记录对象 + :rtype: DcmRollback + :raises AssertionError: 当记录不存在时抛出 + """ + _rollback: cls = await cls.async_find_by_id(rollback_id) + assert _rollback, f"根据 ID {rollback_id} 未找到回退记录。" + + _del_query = delete(cls).where(cls.id == _rollback.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除回退记录(任务号:{_rollback.task_number},ID:{_rollback.id}).') + return _rollback + + @classmethod + async def modify(cls, rollback_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有回退记录。 + + 业务流程: + 1. 将 rollback_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 DcmRollbackForm 验证表单数据 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新字段并设置更新者 + 7. 保存到数据库 + 8. 返回更新后的对象 + + :param rollback_id: 要修改的回退记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的回退对象 + :rtype: DcmRollback + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = DcmRollbackForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _rollback: cls = await cls.async_find_by_id(rollback_id) + assert _rollback, f'查无此回退信息。' + + # 更新字段 + _rollback.copy_from_dict(_form.data, skip_none=True).before_save() + _rollback.updated_by = user.username + await _rollback.async_save() + return _rollback + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建回退记录(传入数据应为全新记录)。 + + :param data_df: 包含回退数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + rollbacks = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(rollbacks) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(rollbacks)} 条回退记录。") + return len(rollbacks) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有回退记录。 + + :param data_df: 包含回退数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条回退记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存回退数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await DcmRollback.exists_rec_id(data_df) + # 保存到数据库 + _created_count = await DcmRollback.create_batch(_latest_df, user) + _updated_count = await DcmRollback.modify_batch(_exists_df, user) + return _created_count, _updated_count diff --git a/models/dcm_stage_reply.py b/models/dcm_stage_reply.py new file mode 100644 index 0000000..0a06e90 --- /dev/null +++ b/models/dcm_stage_reply.py @@ -0,0 +1,507 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, IntegerField, TextAreaField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iDcmStageReply +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class DcmStageReplyForm(ModelForm): + """ + 阶段回复表单验证类(完全映射 TD3iDcmStageReply 字段)。 + + 用于验证和处理数字城管-阶段回复的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_dcm_stage_reply 的字段结构。 + """ + + # 基础信息 + id = IntegerField('主键ID') + rec_id = StringField('记录ID', validators=[Length(max=64, message='记录ID长度不能超过64字符')]) + task_number = StringField('任务号', validators=[Length(max=64, message='任务号长度不能超过64字符')]) + act_id = StringField('工单ID', validators=[Length(max=64, message='工单ID长度不能超过64字符')]) + item_type = StringField('固定值', validators=[Length(max=64, message='固定值长度不能超过64字符')]) + content = TextAreaField('回复内容', validators=[Length(max=1000, message='回复内容长度不能超过1000字符')]) + status = IntegerField('提交状态') + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class DcmStageReplyBase(TD3iDcmStageReply, CommonModel): + """ + 阶段回复基础类(完全映射 TD3iDcmStageReply 字段)。 + + 继承自数据库模型 TD3iDcmStageReply 和通用模型 CommonModel。 + 封装所有与阶段回复相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'rec_id': 'rec_id', + 'task_number': 'task_number', + 'act_id': 'act_id', + 'item_type': 'item_type', + 'content': 'content', + 'status': 'status', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 回复数据映射 + """ + + @classmethod + async def exist_other(cls, id: Union[str, int], rec_id: str, task_number: str): + """ + 检查是否存在除当前回复外的其他相同记录ID和任务号的回复。 + + :param id: 当前回复ID + :param rec_id: 记录ID + :param task_number: 任务号 + :return: 存在返回回复对象,不存在返回None + """ + _query = select(cls).where(cls.id != id, cls.rec_id == rec_id, cls.task_number == task_number) + _reply: cls = await cls.query_first(_query) + return _reply + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """ + 根据ID列表批量查找回复数据。 + """ + _query = select(cls).where(cls.id.in_(ids)) + _reply_list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _reply_list + + @classmethod + async def is_exist(cls, rec_id: str, task_number: str): + """ + 检查回复是否已经存在(根据记录ID和任务号)。 + """ + _query = select(cls).where(cls.rec_id == rec_id, cls.task_number == task_number) + _reply: cls = await cls.query_first(_query) + return _reply + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索回复数据的基础方法。 + + 支持字段: + - rec_id, task_number, act_id, item_type, status + - 支持模糊匹配:content + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_number': 'asc'} + :key str rec_id: 精确匹配记录ID + :key str task_number: 精确匹配任务号 + :key str act_id: 精确匹配工单ID + :key str item_type: 精确匹配固定值 + :key int status: 精确匹配提交状态 + :key str content: 模糊匹配回复内容 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.content.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_number, cls.rec_id) + + _reply_df = await cls.query_as_df(_data_query) + if not _reply_df.empty: + _reply_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _reply_df[cls.id.key] = _reply_df[cls.id.key].astype(str) + + return _reply_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索回复数据,返回分页格式数据。 + """ + _reply_df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _reply_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_rec_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 rec_id + task_number 判断。 + + :param data_df: 输入的数据框架,必须包含 rec_id 和 task_number 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 (rec_id, task_number) 组合 + rec_task_pairs = data_df[[cls.rec_id.key, cls.task_number.key]].values.tolist() + if not rec_task_pairs: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的记录 + _query = select(cls.id, cls.rec_id, cls.task_number).where( + (cls.rec_id.in_([r[0] for r in rec_task_pairs])) & + (cls.task_number.in_([r[1] for r in rec_task_pairs])) + ) + rec_task_df = await cls.query_as_df(_query) + + if rec_task_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 (rec_id, task_number) -> id 的映射字典 + rec_task_to_id_map = dict(zip( + zip(rec_task_df[cls.rec_id.key], rec_task_df[cls.task_number.key]), + rec_task_df[cls.id.key] + )) + + # 标记是否已存在 + mask_exists = data_df.apply( + lambda row: (row[cls.rec_id.key], row[cls.task_number.key]) in rec_task_to_id_map, axis=1 + ) + + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df.apply( + lambda row: rec_task_to_id_map[(row[cls.rec_id.key], row[cls.task_number.key])], axis=1 + ) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class DcmStageReply(DcmStageReplyBase): + """ + 阶段回复主业务类(完全继承 TD3iDcmStageReply 字段)。 + + --- + description: 数字城管-阶段回复接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + rec_id: + description: 记录ID + type: string + example: "REC20240501001" + maxLength: 64 + task_number: + description: 任务号 + type: string + example: "TASK20240501001" + maxLength: 64 + act_id: + description: 工单ID + type: string + example: "ACT20240501001" + maxLength: 64 + item_type: + description: 固定值 + type: string + example: "STAGE_REPLY" + maxLength: 64 + content: + description: 回复内容 + type: string + example: "已处理完毕,请查收。" + maxLength: 1000 + status: + description: 提交状态 + type: integer + example: 1 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的阶段回复。 + + 业务流程: + 1. 使用 DcmStageReplyForm 验证表单数据完整性 + 2. 检查是否已存在相同 rec_id + task_number 的回复 + 3. 创建新回复对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的回复对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 回复参数字典 + :return: 新建回复对象 + :rtype: DcmStageReply + :raises AssertionError: 当回复已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _reply_form = DcmStageReplyForm(formdata=kwargs) + _reply_form.validate_form() + + # 检查是否存在相同记录ID和任务号的回复 + _reply: cls = await cls.is_exist(_reply_form.rec_id.data, _reply_form.task_number.data) + assert _reply is None, "相同记录ID和任务号的回复已存在,不能重复创建。" + + # 创建回复对象 + _reply = cls().copy_from_dict(_reply_form.data, skip_none=True).before_save() + if user: + _reply.created_by = user.username + _reply.updated_by = user.username + await _reply.async_save() + return _reply + + @classmethod + async def delete(cls, reply_id: Union[str, int]): + """ + 删除阶段回复。 + + 业务流程: + 1. 根据ID查找回复 + 2. 验证回复存在性 + 3. 执行删除操作 + + :param reply_id: 要删除的回复ID + :return: 删除的回复对象 + :rtype: DcmStageReply + :raises AssertionError: 当回复不存在时抛出 + """ + _reply: cls = await cls.async_find_by_id(reply_id) + assert _reply, f"根据 ID {reply_id} 未找到阶段回复。" + + # 执行删除 + _del_query = delete(cls).where(cls.id == _reply.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除阶段回复(ID:{_reply.id})。') + return _reply + + @classmethod + async def modify(cls, reply_id: Union[str, int], user: RbacUser=None, **kwargs): + """ + 修改已有阶段回复信息。 + + 业务流程: + 1. 将 reply_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 DcmStageReplyForm 验证表单数据 + 4. 检查是否与其他回复冲突(排除自身) + 5. 查询原回复对象 + 6. 验证回复存在性 + 7. 更新字段并设置更新者 + 8. 保存到数据库 + 9. 返回更新后的回复对象 + + :param reply_id: 要修改的回复ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的回复对象 + :rtype: DcmStageReply + :raises AssertionError: 当回复不存在或信息冲突时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _reply_form = DcmStageReplyForm(formdata=kwargs) + _reply_form.validate_form() + + # 检查是否与其他回复重复(排除自身) + _other = await cls.exist_other(reply_id, _reply_form.rec_id.data, _reply_form.task_number.data) + assert _other is None, "相同记录ID和任务号的回复已存在,不能重复修改。" + + # 查询原回复 + _reply: cls = await cls.async_find_by_id(reply_id) + assert _reply, f'查无此阶段回复信息。' + + # 更新字段 + _reply.copy_from_dict(_reply_form.data, skip_none=True).before_save() + _reply.updated_by = user.username + await _reply.async_save() + return _reply + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建新回复(传入数据应为全新记录,无需校验是否存在)。 + + :param data_df: 包含回复数据的 DataFrame,字段需与模型属性匹配(如 rec_id, task_number 等) + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的回复数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 向量化设置用户字段(无循环) + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + # 一次性转为字典列表(C 层高效) + records = data_df.to_dict('records') + + # 用列表推导式构造对象 + replies = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + # 批量插入 + session = cls.get_aio_session() + try: + session.add_all(replies) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(replies)} 条新阶段回复。") + return len(replies) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有回复。 + + :param data_df: 包含回复数据的 DataFrame,必须包含 id 列 + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的回复数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条阶段回复。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await DcmStageReply.exists_rec_id(data_df) + # 保存到数据库 + _created_count = await DcmStageReply.create_batch(_latest_df, user) + _updated_count = await DcmStageReply.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/dcm_task.py b/models/dcm_task.py new file mode 100644 index 0000000..7eda14e --- /dev/null +++ b/models/dcm_task.py @@ -0,0 +1,829 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, FloatField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iDcmTask +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class DcmTaskForm(ModelForm): + """ + 专业表单验证类(已完全根据 TD3iDcmTask 字段重构)。 + + 用于验证和处理数字城管-部门待办任务的创建/修改表单数据。 + 字段完全映射数据库表 t_dcm_department_task 的字段结构。 + """ + + # 基础信息 + rec_id = IntegerField('记录ID') + rec_disp_num = StringField('显示编号', validators=[Length(max=50, message='显示编号长度不能超过50字符')]) + rec_type_id = IntegerField('类型ID') + rec_type_name = StringField('案件类型', validators=[Length(max=100, message='案件类型长度不能超过100字符')]) + + # 任务信息 + act_id = IntegerField('任务ID') + act_deadline_time = IntegerField('任务截止时间戳(毫秒)') + act_warning_time = IntegerField('预警时间戳(毫秒)') + act_property_id = IntegerField('任务属性ID') + act_ard_state_name = StringField('阶段授权状态', validators=[Length(max=50, message='阶段授权状态长度不能超过50字符')]) + act_time_state_id = IntegerField('阶段状态ID') + + # 业务信息 + biz_id = IntegerField('业务ID') + sys_id = IntegerField('系统ID') + task_num = StringField('任务号', validators=[Length(max=50, message='任务号长度不能超过50字符')]) + other_task_num = StringField('第三方任务号', validators=[Length(max=100, message='第三方任务号长度不能超过100字符')]) + bundle_remain_char = StringField('剩余时间描述', validators=[Length(max=20, message='剩余时间描述长度不能超过20字符')]) + bundle_deadline_time = IntegerField('捆绑截止时间戳') + bundle_deadline_char = StringField('捆绑截止时间描述', validators=[Length(max=20, message='捆绑截止时间描述长度不能超过20字符')]) + bundle_warning_time = IntegerField('捆绑预警时间戳') + bundle_time_state_id = IntegerField('捆绑阶段红绿灯状态') + + # 事件信息 + event_type_id = IntegerField('问题类型ID') + max_event_type_id = IntegerField('最大事件类型ID') + event_type_name = StringField('问题类型', validators=[Length(max=100, message='问题类型长度不能超过100字符')]) + event_src_name = StringField('问题来源', validators=[Length(max=100, message='问题来源长度不能超过100字符')]) + event_desc = TextAreaField('问题描述', validators=[Length(max=65535, message='问题描述长度不能超过65535字符')]) + + # 紧急程度与分类 + urgency_level = IntegerField('紧急程度(0正常,1紧急)') + main_type_id = IntegerField('大类ID') + main_type_name = StringField('大类名称', validators=[Length(max=100, message='大类名称长度不能超过100字符')]) + sub_type_id = IntegerField('小类ID') + sub_type_name = StringField('小类名称', validators=[Length(max=100, message='小类名称长度不能超过100字符')]) + + # 地址与坐标 + address = TextAreaField('地址描述', validators=[Length(max=65535, message='地址描述长度不能超过65535字符')]) + district_name = StringField('所属区域', validators=[Length(max=50, message='所属区域长度不能超过50字符')]) + coordinate_x = FloatField('经度') + coordinate_y = FloatField('纬度') + + # 处理流程 + proc_time_state_id = IntegerField('处理流程状态ID') + deadline_time = IntegerField('处理截止时间戳') + warning_time = IntegerField('处理预警时间戳') + processing_deadline = StringField('处置时限描述', validators=[Length(max=50, message='处置时限描述长度不能超过50字符')]) + new_inst_cond_name = StringField('立案条件', validators=[Length(max=200, message='立案条件长度不能超过200字符')]) + case_closure_condition = StringField('结案条件', validators=[Length(max=200, message='结案条件长度不能超过200字符')]) + + # 回复与回访 + reply_intime = IntegerField('是否两小时回复(0无需回复,1待回复,2已回复,3超时,4无需回复已恢复)') + return_visit_flag = IntegerField('回访标识(0无需,1待回访,2已回访)') + + # 部门信息 + first_depart_name = StringField('一级专业部门', validators=[Length(max=100, message='一级专业部门长度不能超过100字符')]) + second_depart_name = StringField('二级专业部门', validators=[Length(max=100, message='二级专业部门长度不能超过100字符')]) + + # 举报人信息 + reporter_name = StringField('举报人姓名', validators=[Length(max=100, message='举报人姓名长度不能超过100字符')]) + reporter_contact = StringField('举报电话', validators=[Length(max=50, message='举报电话长度不能超过50字符')]) + + # 阅读与颜色 + read_flag = IntegerField('是否已读(0未读,1已读)') + back_color_bit_id = IntegerField('背景色ID') + font_color_bit_id = IntegerField('字体色ID') + + # 部件与显示 + part_code = StringField('部件编码', validators=[Length(max=100, message='部件编码长度不能超过100字符')]) + display_style_id = IntegerField('显示样式ID') + + # 功能控制 + func_forbid_reporter_info_flag = IntegerField('是否禁止举报人信息') + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class DcmTaskBase(TD3iDcmTask, CommonModel): + """ + 专业基础类(已完全映射 TD3iDcmTask 字段)。 + + 继承自数据库模型 TD3iDcmTask 和通用模型 CommonModel。 + 封装所有与部门待办任务相关的通用操作方法。 + """ + + + FieldMapping = { + 'id': 'id', + 'rec_id': 'rec_id', + 'rec_disp_num': 'rec_disp_num', + 'rec_type_id': 'rec_type_id', + 'rec_type_name': 'rec_type_name', + 'act_id': 'act_id', + 'act_deadline_time': 'act_deadline_time', + 'act_warning_time': 'act_warning_time', + 'act_property_id': 'act_property_id', + 'act_ard_state_name': 'act_ard_state_name', + 'act_time_state_id': 'act_time_state_id', + 'biz_id': 'biz_id', + 'sys_id': 'sys_id', + 'task_num': 'task_num', + 'other_task_num': 'other_task_num', + 'bundle_remain_char': 'bundle_remain_char', + 'bundle_deadline_time': 'bundle_deadline_time', + 'bundle_deadline_char': 'bundle_deadline_char', + 'bundle_warning_time': 'bundle_warning_time', + 'bundle_time_state_id': 'bundle_time_state_id', + 'rollback_deadline': 'rollback_deadline', + 'event_type_id': 'event_type_id', + 'max_event_type_id': 'max_event_type_id', + 'event_type_name': 'event_type_name', + 'event_src_name': 'event_src_name', + 'event_desc': 'event_desc', + 'urgency_level': '紧急程度', + 'main_type_id': 'main_type_id', + 'main_type_name': 'main_type_name', + 'sub_type_id': 'sub_type_id', + 'sub_type_name': 'sub_type_name', + 'address': 'address', + 'district_name': 'district_name', + 'coordinate_x': 'coordinate_x', + 'coordinate_y': 'coordinate_y', + 'proc_time_state_id': 'proc_time_state_id', + 'deadline_time': 'deadline_time', + 'warning_time': 'warning_time', + 'processing_deadline': '处置时限', + 'new_inst_cond_name': 'new_inst_cond_name', + 'case_closure_condition': '结案条件', + 'reply_intime': 'reply_intime', + 'return_visit_flag': 'return_visit_flag', + 'first_depart_name': 'first_depart_name', + 'second_depart_name': 'second_depart_name', + 'reporter_name': 'reporter_name', + 'reporter_contact': 'reporter_contact', + 'read_flag': 'read_flag', + 'back_color_bit_id': 'back_color_bit_id', + 'font_color_bit_id': 'font_color_bit_id', + 'part_code': 'part_code', + 'display_style_id': 'display_style_id', + 'func_forbid_reporter_info_flag': 'func_forbid_reporter_info_flag', + } + """ + 任务数据映射 + """ + + @classmethod + async def exist_other(cls, id: Union[str, int], rec_id: str): + """ + 检查是否存在除当前任务外的其他同任务号或同显示编号的任务。 + + :param id: 当前任务ID + :param task_num: 任务号 + :param rec_disp_num: 显示编号 + :return: 存在返回任务对象,不存在返回None + """ + _query = select(cls).where(cls.id != id, cls.rec_id == rec_id) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """ + 根据ID列表批量查找任务数据。 + """ + _query = select(cls).where(cls.id.in_(ids)) + _task_list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _task_list + + @classmethod + async def is_exist(cls, rec_id: str): + """ + 检查任务是否已经存在(根据任务号或显示编号)。 + """ + _query = select(cls).where(cls.rec_id == rec_id) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索任务数据的基础方法。 + + 支持字段: + - task_num, rec_disp_num, event_type_name, district_name, urgency_level, read_flag 等 + - 支持模糊匹配:event_type_name, rec_type_name, event_src_name, first_depart_name, second_depart_name + - 支持精确匹配:biz_id, sys_id, urgency_level, read_flag, rec_type_id, act_time_state_id 等 + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_num': 'asc'} + :key str task_num: 精确匹配任务号 + :key str rec_disp_num: 精确匹配显示编号 + :key str event_type_name: 模糊匹配问题类型 + :key str district_name: 精确匹配区域 + :key int urgency_level: 精确匹配紧急程度 + :key int read_flag: 精确匹配是否已读 + :key int biz_id: 精确匹配业务ID + :key int sys_id: 精确匹配系统ID + :key int rec_type_id: 精确匹配类型ID + :key int act_time_state_id: 精确匹配阶段状态ID + :key int deadline_time: 精确匹配处理截止时间戳 + :key int act_deadline_time: 精确匹配任务截止时间戳 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.event_type_name.key: '%{}%', + cls.rec_type_name.key: '%{}%', + cls.event_src_name.key: '%{}%', + cls.first_depart_name.key: '%{}%', + cls.second_depart_name.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_num, cls.rec_disp_num) + + _task_df = await cls.query_as_df(_data_query) + if not _task_df.empty: + _task_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _task_df[cls.id.key] = _task_df[cls.id.key].astype(str) + + return _task_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索任务数据,返回分页格式数据。 + """ + _task_df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _task_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_rec_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 rec_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 rec_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 rec_id 列表(去重) + rec_ids = data_df[cls.rec_id.key].unique().tolist() + if not rec_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 rec_id + _query = select(cls.id, cls.rec_id).where(cls.rec_id.in_(rec_ids)) + rec_ids_df = await cls.query_as_df(_query) + + if rec_ids_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 rec_id -> id 的映射字典 + rec_id_to_id_map = dict(zip(rec_ids_df[cls.rec_id.key], rec_ids_df[cls.id.key])) + + # 根据 rec_id 是否在数据库中,划分数据 + mask_exists = data_df[cls.rec_id.key].isin(rec_ids_df[cls.rec_id.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.rec_id.key].map(rec_id_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class DcmTask(DcmTaskBase): + """ + 部门待办任务类(主业务类,完全继承 TD3iDcmTask 字段)。 + + --- + description: 数字城管-部门待办任务 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + rec_id: + description: 记录ID + type: integer + example: 2001 + rec_disp_num: + description: 显示编号 + type: string + example: "D20240501001" + maxLength: 50 + rec_type_id: + description: 类型ID + type: integer + example: 101 + rec_type_name: + description: 案件类型 + type: string + example: "市容环境" + maxLength: 100 + act_id: + description: 任务ID + type: integer + example: 3001 + act_deadline_time: + description: 任务截止时间戳(毫秒) + type: integer + example: 1714567890000 + act_warning_time: + description: 预警时间戳(毫秒) + type: integer + example: 1714560000000 + act_property_id: + description: 任务属性ID + type: integer + example: 5 + act_ard_state_name: + description: 阶段授权状态 + type: string + example: "已授权" + maxLength: 50 + act_time_state_id: + description: 阶段状态ID + type: integer + example: 1 + biz_id: + description: 业务ID + type: integer + example: 10 + sys_id: + description: 系统ID + type: integer + example: 1 + task_num: + description: 任务号 + type: string + example: "TASK20240501001" + maxLength: 50 + other_task_num: + description: 第三方任务号 + type: string + example: "THIRD-2024-001" + maxLength: 100 + bundle_remain_char: + description: 剩余时间描述 + type: string + example: "3天" + maxLength: 20 + bundle_deadline_time: + description: 捆绑截止时间戳 + type: integer + example: 1714578000000 + bundle_deadline_char: + description: 捆绑截止时间描述 + type: string + example: "3天" + maxLength: 20 + bundle_warning_time: + description: 捆绑预警时间戳 + type: integer + example: 1714570000000 + bundle_time_state_id: + description: 捆绑阶段红绿灯状态 + type: integer + example: 0 + rollback_deadline: + description: 拒绝超时截止时间戳 + type: integer + example: 1714580000000 + event_type_id: + description: 问题类型ID + type: integer + example: 1001 + max_event_type_id: + description: 最大事件类型ID + type: integer + example: 1002 + event_type_name: + description: 问题类型 + type: string + example: "道路破损" + maxLength: 100 + event_src_name: + description: 问题来源 + type: string + example: "市民举报" + maxLength: 100 + event_desc: + description: 问题描述 + type: string + example: "中山路与解放路交叉口路面大面积破损" + maxLength: 65535 + urgency_level: + description: 紧急程度(0正常,1紧急) + type: integer + example: 1 + main_type_id: + description: 大类ID + type: integer + example: 101 + main_type_name: + description: 大类名称 + type: string + example: "市容环境" + maxLength: 100 + sub_type_id: + description: 小类ID + type: integer + example: 10101 + sub_type_name: + description: 小类名称 + type: string + example: "道路破损" + maxLength: 100 + address: + description: 地址描述 + type: string + example: "中山路与解放路交叉口" + maxLength: 65535 + district_name: + description: 所属区域 + type: string + example: "鼓楼区" + maxLength: 50 + coordinate_x: + description: 经度 + type: number + format: decimal + example: 118.789012 + coordinate_y: + description: 纬度 + type: number + format: decimal + example: 32.045678 + proc_time_state_id: + description: 处理流程状态ID + type: integer + example: 2 + deadline_time: + description: 处理截止时间戳 + type: integer + example: 1714578000000 + warning_time: + description: 处理预警时间戳 + type: integer + example: 1714570000000 + processing_deadline: + description: 处置时限描述 + type: string + example: "24小时" + maxLength: 50 + new_inst_cond_name: + description: 立案条件 + type: string + example: "破损面积大于0.5㎡" + maxLength: 200 + case_closure_condition: + description: 结案条件 + type: string + example: "修复完成并验收" + maxLength: 200 + reply_intime: + description: 是否两小时回复(0无需回复,1待回复,2已回复,3超时,4无需回复已恢复) + type: integer + example: 2 + return_visit_flag: + description: 回访标识(0无需,1待回访,2已回访) + type: integer + example: 1 + first_depart_name: + description: 一级专业部门 + type: string + example: "市政工程处" + maxLength: 100 + second_depart_name: + description: 二级专业部门 + type: string + example: "道路养护科" + maxLength: 100 + reporter_name: + description: 举报人姓名 + type: string + example: "张三" + maxLength: 100 + reporter_contact: + description: 举报电话 + type: string + example: "13800138000" + maxLength: 50 + read_flag: + description: 是否已读(0未读,1已读) + type: integer + example: 1 + back_color_bit_id: + description: 背景色ID + type: integer + example: 10 + font_color_bit_id: + description: 字体色ID + type: integer + example: 20 + part_code: + description: 部件编码 + type: string + example: "P0012345" + maxLength: 100 + display_style_id: + description: 显示样式ID + type: integer + example: 5 + func_forbid_reporter_info_flag: + description: 是否禁止举报人信息 + type: integer + example: 0 + operation: + description: 操作(工单上的操作按钮) + type: string + example: "批转" + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的任务。 + + 业务流程: + 1. 使用 DcmDepartmentTaskForm 验证表单数据完整性 + 2. 检查任务是否已存在(根据 task_num 或 rec_disp_num) + 3. 创建新任务对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的任务对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 任务参数字典 + :return: 新建任务对象 + :rtype: DcmTask + :raises AssertionError: 当任务已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _task_form = DcmTaskForm(formdata=kwargs) + _task_form.validate_form() + + # 检查是否存在同任务号或同显示编号的任务 + _task: cls = await cls.is_exist(_task_form.rec_id.data) + assert _task is None, "任务号或显示编号已存在,不能重复创建。" + + # 创建任务对象 + _task = cls().copy_from_dict(_task_form.data, skip_none=True).before_save() + if user: + _task.created_by = user.username + _task.updated_by = user.username + await _task.async_save() + return _task + + @classmethod + async def delete(cls, task_id: Union[str, int]): + """ + 删除任务。 + + 注意:任务删除需根据业务规则判断是否允许(如是否已处理、是否有附件等)。 + + 业务流程: + 1. 根据ID查找任务 + 2. 验证任务存在性 + 3. 执行删除操作 + + :param task_id: 要删除的任务ID + :return: 删除的任务对象 + :rtype: DcmTask + :raises AssertionError: 当任务不存在时抛出 + """ + _task: cls = await cls.async_find_by_id(task_id) + assert _task, f"根据 ID {task_id} 未找到任务。" + + # 执行删除 + _del_query = delete(cls).where(cls.id == _task.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除任务(任务号:{_task.task_num},ID:{_task.id}).') + return _task + + @classmethod + async def modify(cls, task_id: Union[str, int], user: RbacUser=None, **kwargs): + """ + 修改已有任务信息。 + + 注意:修改任务号或显示编号时需检查是否与其他任务重复。 + + 业务流程: + 1. 将 task_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 DcmDepartmentTaskForm 验证表单数据 + 4. 检查是否有其他任务使用了相同的 task_num 或 rec_disp_num + 5. 查询原任务对象 + 6. 验证任务存在性 + 7. 更新字段并设置更新者 + 8. 保存到数据库 + 9. 返回更新后的任务对象 + + :param task_id: 要修改的任务ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的任务对象 + :rtype: DcmTask + :raises AssertionError: 当任务不存在或信息重复时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _task_form = DcmTaskForm(formdata=kwargs) + _task_form.validate_form() + + # 检查是否与其他任务重复(排除自身) + _other = await cls.exist_other(task_id, _task_form.rec_id.data) + assert _other is None, "待办任务号或显示编号已存在,不能重复修改。" + + # 查询原任务 + _task: cls = await cls.async_find_by_id(task_id) + assert _task, f'查无此待办信息。' + + # 更新字段 + _task.copy_from_dict(_task_form.data, skip_none=True).before_save() + _task.updated_by = user.username + await _task.async_save() + return _task + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建新任务(传入数据应为全新记录,无需校验是否存在)。 + + :param data_df: 包含任务数据的 DataFrame,字段需与模型属性匹配(如 rec_id, task_num 等) + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的任务数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 向量化设置用户字段(无循环) + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + # 一次性转为字典列表(C 层高效) + records = data_df.to_dict('records') + + # 用列表推导式构造对象 + tasks = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + # 批量插入 + session = cls.get_aio_session() + try: + session.add_all(tasks) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(tasks)} 条新待办。") + return len(tasks) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有任务。 + + :param data_df: 包含任务数据的 DataFrame + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的任务数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条待办。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await DcmTask.exists_rec_id(data_df) + # 保存到数据库 + _created_count = await DcmTask.create_batch(_latest_df, user) + _updated_count = await DcmTask.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/dcm_task_attachment.py b/models/dcm_task_attachment.py new file mode 100644 index 0000000..0e58de0 --- /dev/null +++ b/models/dcm_task_attachment.py @@ -0,0 +1,734 @@ +import random +from typing import Union, Optional, Callable + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iDcmTaskAttachment +from paste.core.logging import echo_log +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class DcmTaskAttachmentForm(ModelForm): + """ + 附件表单验证类(完全映射 TD3iDcmTaskAttachment 字段)。 + + 用于验证和处理数字城管-部门待办任务附件的上传/修改表单数据。 + 字段完全映射数据库表 t_d3i_dcm_task_attachment 的字段结构。 + """ + + # 关联信息 + relation_type_id = IntegerField('关联类型ID') + relation_id = IntegerField('主关联ID') + relation_main_id = IntegerField('主关联ID(可空)') + relation_sub_id = IntegerField('子关联ID(可空)') + + # 媒体信息 + act_def_name = StringField('流程节点名称', validators=[Length(max=255, message='流程节点名称长度不能超过255字符')]) + media_id = IntegerField('媒体唯一ID') + media_path = StringField('服务器存储路径', validators=[Length(max=512, message='存储路径长度不能超过512字符')]) + media_type = StringField('媒体类型', validators=[Length(max=50, message='媒体类型长度不能超过50字符')]) + media_name = StringField('原始文件名', validators=[Length(max=255, message='原始文件名长度不能超过255字符')]) + media_usage = StringField('使用场景', validators=[Length(max=100, message='使用场景长度不能超过100字符')]) + media_server_name = StringField('媒体服务器名称', validators=[Length(max=100, message='媒体服务器名称长度不能超过100字符')]) + media_property = IntegerField('媒体属性') + media_uploaded_name = StringField('上传时的原始文件名', validators=[Length(max=255, message='上传文件名长度不能超过255字符')]) + media_shot = StringField('截图标识或路径', validators=[Length(max=255, message='截图路径长度不能超过255字符')]) + media_label_type_id = IntegerField('标签类型ID') + media_url = StringField('内部访问URL', validators=[Length(max=512, message='内部URL长度不能超过512字符')]) + media_default_url = StringField('外部可访问URL', validators=[Length(max=512, message='外部URL长度不能超过512字符')]) + display_order = IntegerField('显示顺序') + store_type_id = IntegerField('存储类型ID') + + # 图像信息 + special_item_image_type = StringField('特殊图片类型', validators=[Length(max=100, message='特殊图片类型长度不能超过100字符')]) + height = IntegerField('图片高度') + width = IntegerField('图片宽度') + + # 上传与状态 + send_flag = IntegerField('发送标志') + public_flag = IntegerField('公开标志(0私有,1公开)') + unit_name = StringField('所属单位', validators=[Length(max=255, message='单位名称长度不能超过255字符')]) + gen_thumb = IntegerField('是否生成缩略图(0否,1是)') + can_delete = IntegerField('是否可删除(0否,1是)') + + # 时间与人员 + upload_time = IntegerField('上传时间戳(毫秒)') + create_human_id = IntegerField('创建人ID') + human_name = StringField('创建人姓名', validators=[Length(max=255, message='创建人姓名长度不能超过255字符')]) + create_time = IntegerField('创建时间戳(毫秒)') + update_time = IntegerField('更新时间戳(毫秒)') + delete_reason = TextAreaField('删除原因', validators=[Length(max=65535, message='删除原因长度不能超过65535字符')]) + delete_flag = IntegerField('删除标记(0未删,1已删)') + delete_human_id = IntegerField('删除人ID') + delete_time = IntegerField('删除时间戳(毫秒)') + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class DcmTaskAttachmentBase(TD3iDcmTaskAttachment, CommonModel): + """ + 附件基础类(完全映射 TD3iDcmTaskAttachment 字段)。 + + 封装所有与任务附件相关的通用操作方法。 + """ + + FieldMapping = { + 'store_type_id': 'storeTypeID', + 'relation_type_id': 'relationTypeID', + 'relation_id': 'relationID', + 'relation_main_id': 'relationMainID', + 'relation_sub_id': 'relationSubID', + 'media_type': 'mediaType', + 'media_name': 'mediaName', + 'media_usage': 'mediaUsage', + 'create_time': 'createTime', + 'update_time': 'updateTime', + 'display_order': 'displayOrder', + 'delete_reason': 'deleteReason', + 'delete_flag': 'deleteFlag', + 'create_human_id': 'createHumanID', + 'delete_human_id': 'deleteHumanID', + 'delete_time': 'deleteTime', + 'media_path': 'mediaPath', + 'media_server_name': 'mediaServerName', + 'media_property': 'mediaProperty', + 'special_item_image_type': 'specialitemImageType', + 'media_uploaded_name': 'mediaUploadedName', + 'height': 'height', + 'width': 'width', + 'send_flag': 'sendFlag', + 'media_shot': 'mediaShot', + 'public_flag': 'publicFlag', + 'media_label_type_id': 'mediaLabelTypeID', + 'media_url': 'mediaURL', + 'media_default_url': 'mediaDefaultURL', + 'human_name': 'humanName', + 'unit_name': 'unitName', + 'act_def_name': 'actDefName', + 'upload_time': 'uploadTime', + 'gen_thumb': 'genThumb', + 'can_delete': 'canDelete', + 'media_id': 'mediaID', + } + """ + 附件数据映射 + """ + + @classmethod + async def exist_other(cls, id: Union[str, int], relation_id: Union[str, int], media_id: Union[str, int]): + """ + 检查是否存在除当前附件外的其他同关联ID和类型附件。 + + :param id: 当前附件ID + :param relation_id: 关联主ID + :param media_id: 媒体ID + :return: 存在返回附件对象,不存在返回None + """ + _query = select(cls).where(cls.id != id, cls.relation_id == relation_id, cls.media_id == media_id) + _attachment: cls = await cls.query_first(_query) + return _attachment + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """ + 根据ID列表批量查找附件数据。 + """ + _query = select(cls).where(cls.id.in_(ids)) + _attachment_list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _attachment_list + + @classmethod + async def is_exist(cls, relation_id: Union[str, int], media_id: Union[str, int]): + """ + 检查附件是否已经存在(根据关联ID和类型)。 + """ + _query = select(cls).where(cls.relation_id == relation_id, cls.media_id == media_id) + _attachment: cls = await cls.query_first(_query) + return _attachment + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索附件数据的基础方法。 + + 支持字段: + - relation_type_id, relation_id, media_type, unit_name, delete_flag + - 支持模糊匹配:media_name, act_def_name, media_usage + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'display_order': 'asc'} + :key int relation_type_id: 精确匹配关联类型 + :key int relation_id: 精确匹配主关联ID + :key str media_type: 精确匹配媒体类型 + :key str unit_name: 精确匹配单位 + :key int delete_flag: 精确匹配删除标记 + :key str media_name: 模糊匹配原始文件名 + :key str act_def_name: 模糊匹配流程节点名称 + :key str media_usage: 模糊匹配使用场景 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.media_name.key: '%{}%', + cls.act_def_name.key: '%{}%', + cls.media_usage.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.display_order) + + _attachment_df = await cls.query_as_df(_data_query) + if not _attachment_df.empty: + _attachment_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + return _attachment_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索附件数据,返回分页格式数据。 + """ + _attachment_df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _attachment_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_relation(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 relation_id + relation_type_id 判断。 + + :param data_df: 输入的数据框架,必须包含 relation_id 和 relation_type_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 (relation_id, media_id) 组合 + pairs = data_df[[cls.relation_id.key, cls.media_id.key]].drop_duplicates().values.tolist() + if not pairs: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的记录 + _query = select(cls.id, cls.relation_id, cls.media_id).where( + (cls.relation_id.in_([p[0] for p in pairs])) & + (cls.media_id.in_([p[1] for p in pairs])) + ) + exists_df = await cls.query_as_df(_query) + + if exists_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 (relation_id, media_id) -> id 的映射 + key_to_id_map = dict(zip( + zip(exists_df[cls.relation_id.key], exists_df[cls.media_id.key]), + exists_df[cls.id.key]) + ) + + # 根据组合是否在数据库中划分数据 + mask_exists = data_df.apply(lambda row: (row[cls.relation_id.key], row[cls.media_id.key]) in key_to_id_map, axis=1) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df.apply(lambda row: key_to_id_map[(row[cls.relation_id.key], row[cls.media_id.key])], axis=1) + latest_df = data_df[~mask_exists].copy() + + return exists_df, latest_df + + @classmethod + async def fill_attachment(cls, data_df: pd.DataFrame, index_field: str = 'id', + column_name: str = 'attachments', is_full: bool = True, + preprocessing: Optional[Callable] = None): + """ + 填充附件数据到数据框架。 + + 用于在查询结果中添加关联的附件信息。 + + :param pandas.DataFrame data_df: 待填充的数据框架 + :param str index_field: 索引字段,一般是任务ID + :param str column_name: 填充时,新增加的列名称,默认为`attachments` + :param is_full: 是否填充上传数据 + :param preprocessing: 预处理,注意预处理必须要返回处理后的结果 + :return: 附件数据框架(已填充) + :rtype: pandas.DataFrame + """ + if data_df.empty: + return pd.DataFrame() + + _task_ids = list(set(data_df[index_field].unique().tolist())) + if not _task_ids: + return pd.DataFrame() + + _query = select(cls).where(cls.dcm_task_id.in_(_task_ids)) + + if is_full: + # 默认加入文件上传得到的 OA MediaId 值 + from models.dcm_task_file_upload import DcmTaskFileUpload + _query = _query.add_columns( + DcmTaskFileUpload.oa_media_id + ).join( + DcmTaskFileUpload, DcmTaskFileUpload.dcm_task_attachment_id == cls.id + ) + + _atta_df: pd.DataFrame = await cls.query_as_df(_query) + if not _atta_df.empty: + _atta_df.replace(models.EmptyInDF+models.EmptyDatetimeInDF, '', inplace=True) + # 整理输出数据类型 + _atta_df[cls.id.key] = _atta_df[cls.id.key].astype(str) + _atta_df[cls.dcm_task_id.key] = _atta_df[cls.dcm_task_id.key].astype(str) + + # 设置索引 + _atta_df['index_id'] = _atta_df[cls.id.key] + _atta_df.set_index(['index_id'], inplace=True) + # 对数据进行预处理 + if isinstance(preprocessing, Callable): + _atta_df = preprocessing(_atta_df) + # 增加数据填充列 + data_df[column_name] = data_df[index_field].apply( + lambda x: _atta_df.query(f"{cls.dcm_task_id.key}=='{x}'").to_dict('records') + ) + else: + data_df[column_name] = [[] for _ in range(len(data_df))] + + return _atta_df + + +@register_swagger_model +class DcmTaskAttachment(DcmTaskAttachmentBase): + """ + 附件业务模型类(主业务类,完全继承 TD3iDcmTaskAttachment 字段)。 + + --- + description: 数字城管-部门待办任务附件 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + relation_type_id: + description: 关联类型ID + type: integer + example: 1 + relation_id: + description: 主关联ID + type: integer + example: 2001 + relation_main_id: + description: 主关联ID(可空) + type: integer + example: 2001 + relation_sub_id: + description: 子关联ID(可空) + type: integer + example: 2002 + act_def_name: + description: 流程节点名称 + type: string + example: "受理" + maxLength: 255 + media_id: + description: 媒体唯一ID + type: integer + example: 3001 + media_path: + description: 服务器存储路径 + type: string + example: "/uploads/2024/05/01/photo.jpg" + maxLength: 512 + media_type: + description: 媒体类型(IMAGE, VIDEO等) + type: string + example: "IMAGE" + maxLength: 50 + media_name: + description: 原始文件名 + type: string + example: "IMG_20240501.jpg" + maxLength: 255 + media_usage: + description: 使用场景 + type: string + example: "上报" + maxLength: 100 + media_server_name: + description: 媒体服务器名称 + type: string + example: "oss-1" + maxLength: 100 + media_property: + description: 媒体属性 + type: integer + example: 1 + media_uploaded_name: + description: 上传时的原始文件名 + type: string + example: "DSC_001.jpg" + maxLength: 255 + media_shot: + description: 截图标识或路径 + type: string + example: "/thumbs/photo.jpg" + maxLength: 255 + media_label_type_id: + description: 标签类型ID + type: integer + example: 5 + media_url: + description: 内部访问URL + type: string + example: "http://internal/oss/123" + maxLength: 512 + media_default_url: + description: 外部可访问URL + type: string + example: "https://public.example.com/oss/123" + maxLength: 512 + display_order: + description: 显示顺序 + type: integer + example: 1 + store_type_id: + description: 存储类型ID + type: integer + example: 1 + special_item_image_type: + description: 特殊图片类型 + type: string + example: "现场照片" + maxLength: 100 + height: + description: 图片高度 + type: integer + example: 1080 + width: + description: 图片宽度 + type: integer + example: 1920 + send_flag: + description: 发送标志 + type: integer + example: 1 + public_flag: + description: 公开标志(0私有,1公开) + type: integer + example: 1 + unit_name: + description: 所属单位 + type: string + example: "市政工程处" + maxLength: 255 + gen_thumb: + description: 是否生成缩略图(0否,1是) + type: integer + example: 1 + can_delete: + description: 是否可删除(0否,1是) + type: integer + example: 1 + upload_time: + description: 上传时间戳(毫秒) + type: integer + example: 1714567890000 + create_human_id: + description: 创建人ID + type: integer + example: 101 + human_name: + description: 创建人姓名 + type: string + example: "张三" + maxLength: 255 + create_time: + description: 创建时间戳(毫秒) + type: integer + example: 1714567890000 + update_time: + description: 更新时间戳(毫秒) + type: integer + example: 1714567900000 + delete_reason: + description: 删除原因 + type: string + example: "重复上传" + maxLength: 65535 + delete_flag: + description: 删除标记(0未删,1已删) + type: integer + example: 0 + delete_human_id: + description: 删除人ID + type: integer + example: 102 + delete_time: + description: 删除时间戳(毫秒) + type: integer + example: 1714567910000 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, **kwargs): + """ + 创建新的附件。 + + 业务流程: + 1. 使用 D3iDcmTaskAttachmentForm 验证表单数据完整性 + 2. 检查是否已存在相同关联ID和类型的附件(避免重复) + 3. 创建新附件对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的附件对象 + + :param kwargs: 附件参数字典 + :return: 新建附件对象 + :rtype: DcmTaskAttachment + :raises AssertionError: 当附件已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = DcmTaskAttachmentForm(formdata=kwargs) + _form.validate_form() + + # 检查是否存在同关联ID和类型的附件(排除自身) + _attachment: cls = await cls.is_exist(_form.relation_id.data, _form.relation_type_id.data) + assert _attachment is None, "相同关联ID和类型的附件已存在,不能重复创建。" + + # 创建附件对象 + _attachment = cls().copy_from_dict(_form.data, skip_none=True).before_save() + await _attachment.async_save() + return _attachment + + @classmethod + async def delete(cls, attachment_id: Union[str, int]): + """ + 删除附件(软删除,设置 delete_flag=1)。 + + 注意:物理删除需谨慎,建议使用软删除机制。 + + 业务流程: + 1. 根据ID查找附件 + 2. 验证附件存在性 + 3. 设置删除标记和删除信息 + 4. 保存更新 + + :param attachment_id: 要删除的附件ID + :return: 更新后的附件对象 + :rtype: DcmTaskAttachment + :raises AssertionError: 当附件不存在时抛出 + """ + _attachment: cls = await cls.async_find_by_id(attachment_id) + assert _attachment, f"根据 ID {attachment_id} 未找到附件。" + + # 执行删除 + _del_query = delete(cls).where(cls.id == _attachment.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除任务附件(记录ID:{_attachment.rec_id},ID:{_attachment.id}).') + return _attachment + + @classmethod + async def modify(cls, attachment_id: Union[str, int], **kwargs): + """ + 修改已有附件信息。 + + 注意:不允许修改 media_id、media_path 等核心存储字段,仅允许修改 metadata(如显示顺序、公开标志、备注等)。 + + 业务流程: + 1. 将 attachment_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 D3iDcmTaskAttachmentForm 验证表单数据 + 4. 检查是否有其他附件使用了相同的关联ID和类型(排除自身) + 5. 查询原附件对象 + 6. 验证附件存在性 + 7. 更新允许字段并设置更新者 + 8. 保存到数据库 + 9. 返回更新后的附件对象 + + :param attachment_id: 要修改的附件ID + :param kwargs: 需要更新的字段 + :return: 修改后的附件对象 + :rtype: DcmTaskAttachment + :raises AssertionError: 当附件不存在或信息重复时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = DcmTaskAttachmentForm(formdata=kwargs) + _form.validate_form() + + # 检查是否与其他附件重复(排除自身) + _other = await cls.exist_other(attachment_id, _form.relation_id.data, _form.relation_type_id.data) + assert _other is None, "相同关联ID和类型的附件已存在,不能重复修改。" + + # 查询原附件 + _attachment: cls = await cls.async_find_by_id(attachment_id) + assert _attachment, f'查无此附件信息。' + + # 仅允许更新非核心存储字段 + allowed_fields = { + 'display_order', 'public_flag', 'unit_name', 'media_usage', + 'media_label_type_id', 'media_shot', 'media_property', + 'gen_thumb', 'can_delete', 'delete_reason', 'delete_flag', + 'act_def_name', 'human_name' + } + + update_data = {k: v for k, v in _form.data.items() if k in allowed_fields and v is not None} + _attachment.copy_from_dict(update_data, skip_none=True).before_save() + await _attachment.async_save() + return _attachment + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame): + """ + 批量创建新附件(传入数据应为全新记录,无需校验是否存在)。 + + :param data_df: 包含附件数据的 DataFrame,字段需与模型属性匹配(如 relation_id, relation_type_id 等) + :return: 成功创建的附件数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 一次性转为字典列表(C 层高效) + records = data_df.to_dict('records') + + # 用列表推导式构造对象 + attachments = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + # 批量插入 + session = cls.get_aio_session() + try: + session.add_all(attachments) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(attachments)} 条附件。") + return len(attachments) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame): + """ + 批量修改已有附件。 + + :param data_df: 包含附件数据的 DataFrame,必须包含 id 列 + :return: 成功更新的附件数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条附件。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame): + """ + 批量保存数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await DcmTaskAttachment.exists_relation(data_df) + # 保存到数据库 + _created_count = await DcmTaskAttachment.create_batch(_latest_df) + _updated_count = await DcmTaskAttachment.modify_batch(_exists_df) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/dcm_task_extend_info.py b/models/dcm_task_extend_info.py new file mode 100644 index 0000000..34f40b3 --- /dev/null +++ b/models/dcm_task_extend_info.py @@ -0,0 +1,236 @@ +from typing import Optional, Callable +from paste.web.form import ModelForm +from paste.core.logging import echo_log +from wtforms import StringField, IntegerField,TextAreaField +from wtforms.validators import Length +from tornado_swagger.model import register_swagger_model +import models +from models.common_model import CommonModel +from models.db_models import TD3iDcmTaskExtendedInfo +import pandas as pd +from sqlalchemy import select + + +class DcmTaskExtendedInfoForm(ModelForm): + """ + 更多信息表单验证类(完全映射 TD3iDcmTaskExtendedInfo 字段)。 + + 用于验证和处理数字城管-部门待办任务扩展信息数据。 + 字段完全映射数据库表 t_d3i_dcm_task_extend_info 的字段结构。 + """ + rec_id=IntegerField('记录ID') + subtype_id=StringField('子类型ID',validators=[Length(max=50,message='子类型ID长度不能超过50个字符')]) + content_range=StringField('内容范围',validators=[Length(max=255,message='内容范围长度不能超过255个字符')]) + control_type=StringField('控件类型',validators=[Length(max=50,message='控件类型长度不能超过50个字符')]) + data_type_id=StringField('数据类型ID',validators=[Length(max=50,message='数据类型ID长度不能超过50个字符')]) + display_name=StringField('显示名称',validators=[Length(max=100,message='显示名称长度不能超过100个字符')]) + field_id=StringField('字段ID',validators=[Length(max=50,message='字段ID长度不能超过50个字符')]) + field_value=StringField('字段值',validators=[Length(max=255,message='字段值长度不能超过255个字符')]) + list_content=TextAreaField('下拉框选项内容') + null_flag=StringField('是否可空标识(0:不可空,1:可空)',validators=[Length(max=20,message='标识长度不能超过20个字符')]) + subtype_field_name=StringField('子类型字段名称',validators=[Length(max=100,message='子类型字段名称长度不能超过100个字符')]) + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class DcmTaskExtendedInfoBase(TD3iDcmTaskExtendedInfo, CommonModel): + """ + 扩展信息基础类(完全映射 TD3iDcmTaskExtendedInfo 字段)。 + + 封装所有与扩展信息相关的通用操作方法。 + """ + FieldMapping = { + 'rec_id': 'recID', + 'subtype_id': 'subtypeID', + 'content_range': 'contentRange', + 'control_type': 'controlType', + 'data_type_id': 'dataTypeID', + 'display_name': 'displayName', + 'field_id': 'fieldID', + 'field_value': 'fieldValue', + 'list_content': 'listContent', + 'null_flag': 'nullFlag', + 'subtype_field_name': 'subtypeFieldName' + } + + @classmethod + async def exists_rec_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。仅根据 rec_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 raw_id(rec_id)列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录(已匹配数据库id) + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 rec_id(去重) + rec_ids = data_df[cls.rec_id.key].drop_duplicates().tolist() + if not rec_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库仅根据 rec_id 匹配 + _query = select(cls.id, cls.rec_id).where( + cls.rec_id.in_(rec_ids) + ) + exists_df = await cls.query_as_df(_query) + + if exists_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 rec_id -> 数据库id 的映射(单字段) + key_to_id_map = dict(zip(exists_df[cls.rec_id.key], exists_df[cls.id.key])) + + # 根据 rec_id 判断是否存在 + mask_exists = data_df.apply(lambda row: row[cls.rec_id.key] in key_to_id_map, axis=1) + + # 拆分存在/不存在的数据 + exists_df = data_df[mask_exists].copy() + # 通过 rec_id 匹配数据库主键 + exists_df[cls.id.key] = exists_df.apply(lambda row: key_to_id_map[row[cls.rec_id.key]], axis=1) + latest_df = data_df[~mask_exists].copy() + + return exists_df, latest_df + + @classmethod + async def fill_extend_info(cls, data_df: pd.DataFrame, index_field: str = 'id', + column_name: str = 'extend_infos', + preprocessing: Optional[Callable] = None): + """ + 填充扩展信息数据到数据框架。 + + 用于在查询结果中添加关联的扩展信息。 + + :param pandas.DataFrame data_df: 待填充的数据框架 + :param str index_field: 索引字段,一般是任务ID + :param str column_name: 填充时,新增加的列名称,默认为`extend_info` + :param preprocessing: 预处理,注意预处理必须要返回处理后的结果 + :return: 扩展信息数据框架(已填充) + :rtype: pandas.DataFrame + """ + if data_df.empty: + return pd.DataFrame() + + _task_ids = list(set(data_df[index_field].unique().tolist())) + if not _task_ids: + return pd.DataFrame() + + _query = select(cls).where(cls.dcm_task_id.in_(_task_ids)) + _extend_info_df: pd.DataFrame = await cls.query_as_df(_query) + if not _extend_info_df.empty: + _extend_info_df.replace(models.EmptyInDF+models.EmptyDatetimeInDF, '', inplace=True) + # 整理输出数据类型 + _extend_info_df[cls.id.key] = _extend_info_df[cls.id.key].astype(str) + _extend_info_df[cls.dcm_task_id.key] = _extend_info_df[cls.dcm_task_id.key].astype(str) + # 设置索引 + _extend_info_df['index_id'] = _extend_info_df[cls.dcm_task_id.key] + _extend_info_df.set_index(['index_id'], inplace=True) + # 对数据进行预处理 + if isinstance(preprocessing, Callable): + _extend_info_df = preprocessing(_extend_info_df) + # 增加数据填充列 + data_df[column_name] = data_df[index_field].apply( + lambda x: _extend_info_df.query(f"{cls.dcm_task_id.key}=='{x}'").to_dict('records') + ) + else: + data_df[column_name] = [[] for _ in range(len(data_df))] + return _extend_info_df + + +@register_swagger_model +class DcmTaskExtendedInfo(DcmTaskExtendedInfoBase): + """ + 扩展信息模型类(主业务类,完全继承 TD3iDcmTaskExtendedInfo 字段)。 + """ + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame): + """ + 批量创建新扩展信息(传入数据应为全新记录,无需校验是否存在)。 + + :param data_df: 包含扩展信息数据的 DataFrame,字段需与模型属性匹配 + :return: 成功创建的记录数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 一次性转为字典列表(C 层高效) + records = data_df.to_dict('records') + + # 用列表推导式构造对象 + records = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + # 批量插入 + session = cls.get_aio_session() + try: + session.add_all(records) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(records)} 条任务扩展信息。") + return len(records) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame): + """ + 批量修改已有扩展信息。 + + :param data_df: 包含扩展信息数据的 DataFrame + :return: 成功更新的记录数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条任务扩展信息。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame): + """ + 批量保存数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await DcmTaskExtendedInfo.exists_rec_id(data_df) + # 保存到数据库 + _created_count = await DcmTaskExtendedInfo.create_batch(_latest_df) + _updated_count = await DcmTaskExtendedInfo.modify_batch(_exists_df) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/dcm_task_file_upload.py b/models/dcm_task_file_upload.py new file mode 100644 index 0000000..0901179 --- /dev/null +++ b/models/dcm_task_file_upload.py @@ -0,0 +1,462 @@ +import random +from typing import Union, Optional, Callable + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, IntegerField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iDcmTaskFileUpload +from paste.core.logging import echo_log +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class DcmTaskFileUploadForm(ModelForm): + """ + 文件上传关联表单验证类(完全映射 TD3iDcmTaskFileUpload 字段)。 + + 用于验证和处理文件上传关联数据的表单。 + 字段完全映射数据库表 t_d3i_dcm_task_file_upload 的字段结构。 + """ + + # 关联信息 + dcm_task_id = IntegerField('任务ID', validators=[Length(min=1, message='任务ID不能为空')]) + dcm_task_attachment_id = IntegerField('附件ID', validators=[Length(min=1, message='附件ID不能为空')]) + dcm_media_id = IntegerField('数字城管附件ID', validators=[Length(min=1, message='数字城管附件ID不能为空')]) + oa_media_id = IntegerField('OA附件ID', validators=[Length(min=1, message='OA附件ID不能为空')]) + + # 文件信息 + file_hash = StringField('文件哈希值', validators=[Length(max=256, message='文件哈希值长度不能超过256字符')]) + + # 状态与时间 + status = IntegerField('上传状态', validators=[Length(min=0, max=1, message='状态只能是0或1')]) + created_at = IntegerField('创建时间戳(毫秒)', validators=[Length(min=1, message='创建时间不能为空')]) + created_by = StringField('创建者', validators=[Length(max=64, message='创建者长度不能超过64字符')]) + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class DcmTaskFileUploadBase(TD3iDcmTaskFileUpload, CommonModel): + """ + 文件上传关联基础类(完全映射 TD3iDcmTaskFileUpload 字段)。 + + 封装所有与文件上传关联相关的通用操作方法。 + """ + + FieldMapping = { + 'dcm_task_id': 'dcmTaskId', + 'dcm_task_attachment_id': 'dcmTaskAttachmentId', + 'dcm_media_id': 'dcmMediaId', + 'oa_media_id': 'oaMediaId', + 'file_hash': 'fileHash', + 'status': 'status', + 'created_at': 'createdAt', + 'created_by': 'createdBy', + } + """ + 文件上传关联数据映射 + """ + + @classmethod + async def is_exist(cls, dcm_task_id: Union[str, int], dcm_task_attachment_id: Union[str, int]): + """ + 检查是否已存在相同任务ID和附件ID的记录。 + """ + _query = select(cls).where( + cls.dcm_task_id == dcm_task_id, cls.dcm_task_attachment_id == dcm_task_attachment_id + ) + _record: cls = await cls.query_first(_query) + return _record + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """ + 根据ID列表批量查找记录。 + """ + _query = select(cls).where(cls.id.in_(ids)) + _records: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _records + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索文件上传记录的基础方法。 + + 支持字段: + - dcm_task_id, dcm_task_attachment_id, dcm_media_id, oa_media_id, status + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'created_at': 'asc'} + :key int dcm_task_id: 精确匹配任务ID + :key int dcm_task_attachment_id: 精确匹配附件ID + :key int dcm_media_id: 精确匹配数字城管附件ID + :key int oa_media_id: 精确匹配OA附件ID + :key int status: 精确匹配上传状态 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + _query = select(cls).where( + *cls.search_wheres(**kwargs) + ) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.created_at) + + _df = await cls.query_as_df(_data_query) + if not _df.empty: + _df[cls.id.key] = _df[cls.id.key].astype(str) + _df[cls.dcm_task_id.key] = _df[cls.dcm_task_id.key].astype(str) + _df[cls.dcm_task_attachment_id.key] = _df[cls.dcm_task_attachment_id.key].astype(str) + _df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + return _df, _paging + + @classmethod + async def search_by_attachment_ids(cls, attachment_ids: list[Union[str, int]]): + query = select( + cls.dcm_task_attachment_id, cls.oa_media_id, cls.updated_at + ).where( + cls.dcm_task_attachment_id.in_(attachment_ids) + ) + return await cls.query_as_df(query) + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索文件上传记录,返回分页格式数据。 + """ + _df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count if _paging else len(_df), + 'rows': _df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number if _paging else 1, + 'page_count': _paging.page_count if _paging else 1, + 'page_size': _paging.page_size if _paging else 20, + }, + } + + @classmethod + async def exists_relation(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 dcm_task_id + dcm_task_attachment_id 判断。 + + :param data_df: 输入的数据框架,必须包含 dcm_task_id 和 dcm_task_attachment_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + pairs = data_df[[cls.dcm_task_id.key, cls.dcm_task_attachment_id.key]].drop_duplicates().values.tolist() + if not pairs: + return pd.DataFrame(), data_df.copy() + + _query = select(cls.id, cls.dcm_task_id, cls.dcm_task_attachment_id).where( + (cls.dcm_task_id.in_([p[0] for p in pairs])) & + (cls.dcm_task_attachment_id.in_([p[1] for p in pairs])) + ) + exists_df = await cls.query_as_df(_query) + + if exists_df.empty: + return pd.DataFrame(), data_df.copy() + + key_to_id_map = dict(zip(zip(exists_df[cls.dcm_task_id.key], exists_df[cls.dcm_task_attachment_id.key]), exists_df[cls.id.key])) + + mask_exists = data_df.apply(lambda row: (row[cls.dcm_task_id.key], row[cls.dcm_task_attachment_id.key]) in key_to_id_map, axis=1) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df.apply(lambda row: key_to_id_map[(row[cls.dcm_task_id.key], row[cls.dcm_task_attachment_id.key])], axis=1) + latest_df = data_df[~mask_exists].copy() + + return exists_df, latest_df + + @classmethod + async def fill_file_upload(cls, data_df: pd.DataFrame, index_field: str = 'dcm_task_attachment_id', + column_name: str = 'file_uploads', + preprocessing: Optional[Callable] = None): + """ + 填充文件上传数据到数据框架。 + + :param pandas.DataFrame data_df: 待填充的数据框架 + :param str index_field: 索引字段,一般是任务ID + :param str column_name: 填充时新增列名称,默认为`file_uploads` + :param preprocessing: 预处理函数 + :return: 填充后的数据框架 + """ + if data_df.empty: + return pd.DataFrame() + + _task_attachment_ids = list(set(data_df[index_field].unique().tolist())) + if not _task_attachment_ids: + return pd.DataFrame() + + _query = select(cls).where(cls.dcm_task_attachment_id.in_(_task_attachment_ids)) + _upload_df: pd.DataFrame = await cls.query_as_df(_query) + if not _upload_df.empty: + _upload_df.replace(models.EmptyInDF+models.EmptyDatetimeInDF, '', inplace=True) + _upload_df[cls.id.key] = _upload_df[cls.id.key].astype(str) + _upload_df[cls.dcm_task_id.key] = _upload_df[cls.dcm_task_id.key].astype(str) + _upload_df[cls.dcm_task_attachment_id.key] = _upload_df[cls.dcm_task_attachment_id.key].astype(str) + + _upload_df['index_id'] = _upload_df[cls.id.key] + _upload_df.set_index(['index_id'], inplace=True) + + if isinstance(preprocessing, Callable): + _upload_df = preprocessing(_upload_df) + + data_df[column_name] = data_df[index_field].apply( + lambda x: next(iter(_upload_df.query(f"{cls.dcm_task_attachment_id.key}=='{x}'").to_dict('records')), {}) + ) + else: + data_df[column_name] = [[] for _ in range(len(data_df))] + + return _upload_df + + +@register_swagger_model +class DcmTaskFileUpload(DcmTaskFileUploadBase): + """ + 文件上传关联业务模型类(主业务类,完全继承 TD3iDcmTaskFileUpload 字段)。 + + --- + description: 文件上传关联表 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + dcm_task_id: + description: 任务唯一标志 + type: integer + example: 2001 + dcm_task_attachment_id: + description: 附件ID(数字城管附件) + type: integer + example: 3001 + dcm_media_id: + description: 附件ID(数字城管媒体) + type: integer + example: 3002 + oa_media_id: + description: 附件ID(OA媒体) + type: integer + example: 3003 + file_hash: + description: 文件哈希值 + type: string + example: "a1b2c3d4e5..." + maxLength: 256 + status: + description: 上传状态(0:未上传/失败,1:成功) + type: integer + example: 1 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "D3I" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, **kwargs): + """ + 创建新的文件上传关联记录。 + + 业务流程: + 1. 使用 DcmTaskFileUploadForm 验证表单数据 + 2. 检查是否已存在相同任务ID和附件ID的记录 + 3. 创建新对象,设置创建者 + 4. 保存到数据库 + 5. 返回创建对象 + + :param kwargs: 附件参数字典 + :return: 新建的文件上传记录 + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = DcmTaskFileUploadForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在 + _exist = await cls.is_exist(_form.dcm_task_id.data, _form.dcm_task_attachment_id.data) + assert _exist is None, "相同任务ID和附件ID的文件上传记录已存在,不能重复创建。" + + _record = cls().copy_from_dict(_form.data, skip_none=True).before_save() + await _record.async_save() + return _record + + @classmethod + async def delete(cls, id: Union[str, int]): + """ + 软删除(不推荐物理删除,但可设 status=-1 或删除记录) + + 本系统暂不支持软删除,建议直接物理删除。 + """ + _record: cls = await cls.async_find_by_id(id) + assert _record, f"根据 ID {id} 未找到文件上传记录。" + + _del_query = delete(cls).where(cls.id == _record.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除文件上传记录(ID:{_record.id}).') + return _record + + @classmethod + async def modify(cls, id: Union[str, int], **kwargs): + """ + 修改文件上传记录。 + + 注意:仅允许修改 status、created_by 等非核心关联字段。 + 核心字段(dcm_task_id, dcm_task_attachment_id, dcm_media_id, oa_media_id)不允许修改。 + + :param id: 记录ID + :param kwargs: 更新字段 + :return: 更新后的记录 + :raises AssertionError: 当记录不存在或字段被非法修改时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = DcmTaskFileUploadForm(formdata=kwargs) + _form.validate_form() + + # 核心字段禁止修改 + protected_fields = {'dcm_task_id', 'dcm_task_attachment_id', 'dcm_media_id', 'oa_media_id'} + disallowed_fields = set(kwargs.keys()) & protected_fields + assert not disallowed_fields, f"禁止修改核心字段: {disallowed_fields}" + + _record: cls = await cls.async_find_by_id(id) + assert _record, f'查无此文件上传记录(ID:{id})。' + + # 允许更新的字段 + allowed_fields = {'status', 'created_by'} + update_data = {k: v for k, v in _form.data.items() if k in allowed_fields and v is not None} + + _record.copy_from_dict(update_data, skip_none=True).before_save() + await _record.async_save() + return _record + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame): + """ + 批量创建文件上传记录(传入数据应为全新记录)。 + + :param data_df: 包含字段 dcm_task_id, dcm_task_attachment_id, dcm_media_id, oa_media_id, file_hash, status, created_at, created_by 的 DataFrame + :return: 成功创建的数量 + """ + if data_df.empty: + return 0 + + records = data_df.to_dict('records') + records = [cls().copy_from_dict(r, skip_none=True).before_save() for r in records] + + session = cls.get_aio_session() + try: + session.add_all(records) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(records)} 条文件上传记录。") + return len(records) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame): + """ + 批量修改文件上传记录。 + + :param data_df: 必须包含 id 列的 DataFrame + :return: 成功更新的数量 + """ + if data_df.empty: + return 0 + + if 'id' not in data_df.columns: + echo_log("错误:modify_batch 要求输入数据必须包含 'id' 列(主键)") + return 0 + + update_data = data_df.to_dict('records') + + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条文件上传记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame): + """ + 批量保存数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架,必须包含 dcm_task_id 和 dcm_task_attachment_id + :return: (新建数量, 更新数量) + """ + _exists_df, _latest_df = await DcmTaskFileUpload.exists_relation(data_df) + + _created_count = await DcmTaskFileUpload.create_batch(_latest_df) + _updated_count = await DcmTaskFileUpload.modify_batch(_exists_df) + + return _created_count, _updated_count \ No newline at end of file diff --git a/models/dcm_task_form_datum.py b/models/dcm_task_form_datum.py new file mode 100644 index 0000000..be09b25 --- /dev/null +++ b/models/dcm_task_form_datum.py @@ -0,0 +1,2420 @@ +import random +from typing import Union, Optional, Callable + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, FloatField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iDcmTaskFormDatum +from paste.core.logging import echo_log +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class DcmTaskFormDatumForm(ModelForm): + """ + 企业待办表单数据验证类(完全映射 TD3iDcmTaskFormDatum 字段)。 + + 用于验证和处理数字化城市管理信息系统中企业待办的表单数据。 + 字段完全对应数据库表 t_d3i_dcm_task_form_data 的结构。 + """ + + # 基础信息 + id = IntegerField('主键ID') + rec_id = IntegerField('记录ID') + rec_disp_num = StringField('显示编号', validators=[Length(max=50, message='显示编号长度不能超过50字符')]) + rec_type_id = IntegerField('类型ID') + rec_type_name = StringField('案件类型', validators=[Length(max=100, message='案件类型长度不能超过100字符')]) + + # 任务信息 + task_num = StringField('任务号', validators=[Length(max=50, message='任务号长度不能超过50字符')]) + other_task_num = StringField('第三方任务号', validators=[Length(max=100, message='第三方任务号长度不能超过100字符')]) + act_property_id = IntegerField('任务属性ID') + + # 业务信息 + biz_id = IntegerField('业务ID') + biz_name = StringField('业务名称', validators=[Length(max=200, message='业务名称长度不能超过200字符')]) + sys_id = IntegerField('系统ID') + + # 地址与坐标 + address = TextAreaField('地址描述', validators=[Length(max=65535, message='地址描述长度不能超过65535字符')]) + district_name = StringField('所属区域', validators=[Length(max=50, message='所属区域长度不能超过50字符')]) + coordinate_x = FloatField('经度') + coordinate_y = FloatField('纬度') + lonlat_x = FloatField('经纬度X') + lonlat_y = FloatField('经纬度Y') + + # 事件信息 + event_type_id = IntegerField('问题类型ID') + event_type_name = StringField('问题类型', validators=[Length(max=100, message='问题类型长度不能超过100字符')]) + event_src_id = IntegerField('问题来源ID') + event_src_name = StringField('问题来源', validators=[Length(max=100, message='问题来源长度不能超过100字符')]) + event_desc = TextAreaField('问题描述', validators=[Length(max=65535, message='问题描述长度不能超过65535字符')]) + max_event_type_id = IntegerField('最大事件类型ID') + max_event_type_name = StringField('最大事件类型名称', validators=[Length(max=200, message='最大事件类型名称长度不能超过200字符')]) + + # 分类信息 + main_type_id = IntegerField('大类ID') + main_type_name = StringField('大类名称', validators=[Length(max=100, message='大类名称长度不能超过100字符')]) + sub_type_id = IntegerField('小类ID') + sub_type_name = StringField('小类名称', validators=[Length(max=100, message='小类名称长度不能超过100字符')]) + third_type_id = IntegerField('第三级类型ID') + third_type_name = StringField('第三级类型名称', validators=[Length(max=100, message='第三级类型名称长度不能超过100字符')]) + forth_type_id = IntegerField('第四级类型ID') + forth_type_name = StringField('第四级类型名称', validators=[Length(max=100, message='第四级类型名称长度不能超过100字符')]) + fifth_type_id = IntegerField('第五级类型ID') + fifth_type_name = StringField('第五级类型名称', validators=[Length(max=100, message='第五级类型名称长度不能超过100字符')]) + sixth_type_id = IntegerField('第六级类型ID') + sixth_type_name = StringField('第六级类型名称', validators=[Length(max=100, message='第六级类型名称长度不能超过100字符')]) + seventh_type_id = IntegerField('第七级类型ID') + seventh_type_name = StringField('第七级类型名称', validators=[Length(max=100, message='第七级类型名称长度不能超过100字符')]) + + # 时间与状态 + create_time = IntegerField('创建时间戳') + update_time = IntegerField('更新时间戳') + deadline_time = IntegerField('处理截止时间戳') + warning_time = IntegerField('处理预警时间戳') + occur_time = IntegerField('发生时间戳') + dispatch_time = IntegerField('派遣时间戳') + archive_time = IntegerField('归档时间戳') + cancel_time = IntegerField('取消时间戳') + refresh_time = IntegerField('刷新时间戳') + refresh_start_time = IntegerField('刷新开始时间戳') + check_send_time = IntegerField('核查发送时间戳') + check_reply_time = IntegerField('核查回复时间戳') + func_deadline = IntegerField('职能部门截止时间戳') + func_deal_time = IntegerField('职能部门处理时间戳') + proc_start_time = IntegerField('处理开始时间戳') + custom_deadline = IntegerField('自定义截止时间戳') + patroltask_deadline_time = IntegerField('巡查任务截止时间戳') + + # 时限描述 + deadline_char = StringField('时限描述', validators=[Length(max=50, message='时限描述长度不能超过50字符')]) + func_limit_char = StringField('职能部门时限描述', validators=[Length(max=50, message='职能部门时限描述长度不能超过50字符')]) + rec_remain_char = StringField('记录剩余时间描述', validators=[Length(max=50, message='记录剩余时间描述长度不能超过50字符')]) + rec_used_char = StringField('记录已用时间描述', validators=[Length(max=50, message='记录已用时间描述长度不能超过50字符')]) + + # 数值型字段 + rec_remain = FloatField('记录剩余时间') + rec_used = FloatField('记录已用时间') + rec_warning = FloatField('记录预警时间') + rec_deadline = FloatField('记录时限') + + # 部门与网格 + func_part_id = IntegerField('职能部门ID') + func_part_name = StringField('职能部门名称', validators=[Length(max=200, message='职能部门名称长度不能超过200字符')]) + func_part_list_id = StringField('职能部门列表ID', validators=[Length(max=100, message='职能部门列表ID长度不能超过100字符')]) + func_part_list_name = StringField('职能部门列表名称', validators=[Length(max=200, message='职能部门列表名称长度不能超过200字符')]) + specify_func_id = IntegerField('指定职能部门ID') + specify_func_name = StringField('指定职能部门名称', validators=[Length(max=200, message='指定职能部门名称长度不能超过200字符')]) + specify_competent_func_id = IntegerField('指定主管职能部门ID') + specify_competent_func_name = StringField('指定主管职能部门名称', validators=[Length(max=200, message='指定主管职能部门名称长度不能超过200字符')]) + first_depart_name = StringField('一级专业部门', validators=[Length(max=100, message='一级专业部门长度不能超过100字符')]) + second_depart_name = StringField('二级专业部门', validators=[Length(max=100, message='二级专业部门长度不能超过100字符')]) + + # 地理信息 + district_id = IntegerField('区域ID') + street_id = IntegerField('街道ID') + street_name = StringField('街道名称', validators=[Length(max=200, message='街道名称长度不能超过200字符')]) + community_id = IntegerField('社区ID') + community_name = StringField('社区名称', validators=[Length(max=200, message='社区名称长度不能超过200字符')]) + duty_grid_id = IntegerField('责任网格ID') + duty_grid_name = StringField('责任网格名称', validators=[Length(max=200, message='责任网格名称长度不能超过200字符')]) + duty_region_id = IntegerField('责任区域ID') + duty_region_name = StringField('责任区域名称', validators=[Length(max=200, message='责任区域名称长度不能超过200字符')]) + duty_district_id = IntegerField('责任区域ID') + duty_district_name = StringField('责任区域名称', validators=[Length(max=200, message='责任区域名称长度不能超过200字符')]) + duty_street_id = IntegerField('责任街道ID') + duty_street_name = StringField('责任街道名称', validators=[Length(max=200, message='责任街道名称长度不能超过200字符')]) + duty_community_id = IntegerField('责任社区ID') + duty_community_name = StringField('责任社区名称', validators=[Length(max=200, message='责任社区名称长度不能超过200字符')]) + law_duty_grid_id = IntegerField('法律责任网格ID') + law_duty_grid_name = StringField('法律责任网格名称', validators=[Length(max=200, message='法律责任网格名称长度不能超过200字符')]) + deal_duty_grid_id = IntegerField('处置责任网格ID') + deal_duty_grid_name = StringField('处置责任网格名称', validators=[Length(max=200, message='处置责任网格名称长度不能超过200字符')]) + + # 人员信息 + patrol_id = IntegerField('巡查员ID') + patrol_name = StringField('巡查员名称', validators=[Length(max=200, message='巡查员名称长度不能超过200字符')]) + accepter_id = IntegerField('受理人ID') + accepter_name = StringField('受理人姓名', validators=[Length(max=100, message='受理人姓名长度不能超过100字符')]) + human_id = IntegerField('操作人ID') + human_name = StringField('操作人名称', validators=[Length(max=255, message='操作人名称长度不能超过255字符')]) + reporter_name = StringField('举报人姓名', validators=[Length(max=100, message='举报人姓名长度不能超过100字符')]) + reporter_contact = StringField('举报电话', validators=[Length(max=50, message='举报电话长度不能超过50字符')]) + tell_num = StringField('联系电话', validators=[Length(max=50, message='联系电话长度不能超过50字符')]) + + # 状态与标识 + read_flag = IntegerField('是否已读(0未读,1已读)') + reply_intime = IntegerField('是否两小时回复(0无需回复,1待回复,2已回复,3超时,4无需回复已恢复)') + return_visit_flag = IntegerField('回访标识(0无需,1待回访,2已回访)') + urgency_level = IntegerField('紧急程度(0正常,1紧急)') + urgent_flag = IntegerField('紧急标识') + func_forbid_reporter_info_flag = IntegerField('是否禁止举报人信息') + public_flag = IntegerField('公开标志') + locked_flag = IntegerField('锁定标识') + transited_flag = IntegerField('转交标识') + split_rec_flag = IntegerField('拆分记录标识') + enable_check_msg = IntegerField('启用核查消息') + no_return_visit_flag = IntegerField('无需回访标识') + common_rec_type_flag = StringField('通用记录类型标识', validators=[Length(max=50, message='通用记录类型标识长度不能超过50字符')]) + common_rec_attr_flag = StringField('通用记录属性标识', validators=[Length(max=50, message='通用记录属性标识长度不能超过50字符')]) + send_pub_check_task_flag = IntegerField('发送公共核查任务标识') + reply_flag = StringField('回复标识', validators=[Length(max=50, message='回复标识长度不能超过50字符')]) + whistle_flag = StringField('吹哨标识', validators=[Length(max=50, message='吹哨标识长度不能超过50字符')]) + repeat_state = StringField('重复状态', validators=[Length(max=50, message='重复状态长度不能超过50字符')]) + report_state = StringField('上报状态', validators=[Length(max=50, message='上报状态长度不能超过50字符')]) + dispose_state = IntegerField('处置状态') + pre_dispose_state = StringField('预处置状态', validators=[Length(max=50, message='预处置状态长度不能超过50字符')]) + undertake_user_name = StringField('承办人员', validators=[Length(max=50, message='承办人员长度不能超过50字符')]) + undertake_phone = StringField('联系电话', validators=[Length(max=50, message='联系电话长度不能超过50字符')]) + deal_person_org = StringField('承办部门', validators=[Length(max=50, message='承办部门长度不能超过50字符')]) + + # 媒体信息 + media_upload_num = IntegerField('媒体上传数量') + media_upload_total_num = IntegerField('媒体上传总数') + media_upload_state = StringField('媒体上传状态', validators=[Length(max=50, message='媒体上传状态长度不能超过50字符')]) + media_check_num = IntegerField('媒体核查数量') + media_check_total_num = IntegerField('媒体核查总数') + media_verify_num = IntegerField('媒体核实数量') + media_verify_total_num = IntegerField('媒体核实总数') + media_self_deal_num = IntegerField('自行处置媒体数量') + media_self_deal_total_num = IntegerField('自行处置媒体总数') + media_review_num = IntegerField('复核媒体数量') + media_review_total_num = IntegerField('复核媒体总数') + report_pic_num = IntegerField('上报图片数量') + report_pic_total_num = IntegerField('上报图片总数') + report_video_num = IntegerField('上报视频数量') + report_video_total_num = IntegerField('上报视频总数') + report_wav_num = IntegerField('上报音频数量') + report_wav_total_num = IntegerField('上报音频总数') + check_pic_num = IntegerField('核查图片数量') + check_pic_total_num = IntegerField('核查图片总数') + check_video_num = IntegerField('核查视频数量') + check_video_total_num = IntegerField('核查视频总数') + check_wav_num = IntegerField('核查音频数量') + check_wav_total_num = IntegerField('核查音频总数') + verify_pic_num = IntegerField('核实图片数量') + verify_pic_total_num = IntegerField('核实图片总数') + verify_video_num = IntegerField('核实视频数量') + verify_video_total_num = IntegerField('核实视频总数') + verify_wav_num = IntegerField('核实音频数量') + verify_wav_total_num = IntegerField('核实音频总数') + self_deal_pic_num = IntegerField('自行处置图片数量') + self_deal_pic_total_num = IntegerField('自行处置图片总数') + self_deal_video_num = IntegerField('自行处置视频数量') + self_deal_video_total_num = IntegerField('自行处置视频总数') + self_deal_wav_num = IntegerField('自行处置音频数量') + self_deal_wav_total_num = IntegerField('自行处置音频总数') + review_pic_num = IntegerField('复核图片数量') + review_video_total_num = IntegerField('复核视频总数') + review_wav_num = IntegerField('复核音频数量') + review_wav_total_num = IntegerField('复核音频总数') + + # 媒体路径与属性 + media_url = StringField('内部访问URL', validators=[Length(max=512, message='内部访问URL长度不能超过512字符')]) + mms_pic_path = StringField('彩信图片路径', validators=[Length(max=500, message='彩信图片路径长度不能超过500字符')]) + media_path = StringField('服务器存储路径', validators=[Length(max=512, message='服务器存储路径长度不能超过512字符')]) + media_type = StringField('媒体类型', validators=[Length(max=50, message='媒体类型长度不能超过50字符')]) + media_usage = StringField('使用场景', validators=[Length(max=100, message='使用场景长度不能超过100字符')]) + media_server_name = StringField('媒体服务器名称', validators=[Length(max=100, message='媒体服务器名称长度不能超过100字符')]) + media_property = IntegerField('媒体属性') + media_uploaded_name = StringField('上传时的原始文件名', validators=[Length(max=255, message='上传时的原始文件名长度不能超过255字符')]) + media_shot = StringField('截图标识或路径', validators=[Length(max=255, message='截图标识或路径长度不能超过255字符')]) + media_label_type_id = IntegerField('标签类型ID') + media_default_url = StringField('外部可访问URL', validators=[Length(max=512, message='外部可访问URL长度不能超过512字符')]) + display_order = IntegerField('显示顺序') + store_type_id = IntegerField('存储类型ID') + special_item_image_type = StringField('特殊图片类型', validators=[Length(max=100, message='特殊图片类型长度不能超过100字符')]) + height = IntegerField('图片高度') + width = IntegerField('图片宽度') + send_flag = IntegerField('发送标志') + public_flag = IntegerField('公开标志') + gen_thumb = IntegerField('是否生成缩略图') + can_delete = IntegerField('是否可删除') + delete_flag = IntegerField('删除标记') + delete_reason = TextAreaField('删除原因', validators=[Length(max=65535, message='删除原因长度不能超过65535字符')]) + + # 地理与位置 + pos_type = StringField('位置类型', validators=[Length(max=50, message='位置类型长度不能超过50字符')]) + view_angle = StringField('视角', validators=[Length(max=100, message='视角长度不能超过100字符')]) + view_image_name = StringField('视图图片名称', validators=[Length(max=200, message='视图图片名称长度不能超过200字符')]) + view_image_x = FloatField('视图图片X坐标') + view_image_y = FloatField('视图图片Y坐标') + view_pos_x = FloatField('视图位置X坐标') + view_pos_y = FloatField('视图位置Y坐标') + + # 附件与标识 + attach_rec_flag = StringField('附件记录标识', validators=[Length(max=50, message='附件记录标识长度不能超过50字符')]) + gather_flag = StringField('汇总标识', validators=[Length(max=50, message='汇总标识长度不能超过50字符')]) + link_field_value = StringField('关联字段值', validators=[Length(max=500, message='关联字段值长度不能超过500字符')]) + link_field_display_value = StringField('关联字段显示值', validators=[Length(max=500, message='关联字段显示值长度不能超过500字符')]) + unique_id = StringField('唯一标识', validators=[Length(max=100, message='唯一标识长度不能超过100字符')]) + third_unique_id = StringField('第三方唯一标识', validators=[Length(max=100, message='第三方唯一标识长度不能超过100字符')]) + equal_group_id = IntegerField('等值组ID') + rec_category_id = IntegerField('记录类别ID') + + # 处置与审核 + dispatch_opinion = StringField('派遣意见', validators=[Length(max=500, message='派遣意见长度不能超过500字符')]) + revise_opinion = StringField('修订意见', validators=[Length(max=500, message='修订意见长度不能超过500字符')]) + reply_opinion = StringField('回复意见', validators=[Length(max=500, message='回复意见长度不能超过500字符')]) + new_inst_advise = StringField('立案建议', validators=[Length(max=500, message='立案建议长度不能超过500字符')]) + new_inst_cond_id = IntegerField('立案条件ID') + new_inst_cond_name = StringField('立案条件', validators=[Length(max=200, message='立案条件长度不能超过200字符')]) + case_closure_condition = StringField('结案条件', validators=[Length(max=200, message='结案条件长度不能超过200字符')]) + + # 特殊字段 + event_marks = StringField('事件标记', validators=[Length(max=500, message='事件标记长度不能超过500字符')]) + deduction = StringField('扣减', validators=[Length(max=100, message='扣减长度不能超过100字符')]) + event_property_id = IntegerField('事件属性ID') + event_property_name = StringField('事件属性名称', validators=[Length(max=200, message='事件属性名称长度不能超过200字符')]) + city_village_flag = StringField('城乡标识', validators=[Length(max=50, message='城乡标识长度不能超过50字符')]) + force_handle_flag = StringField('强制处理标识', validators=[Length(max=50, message='强制处理标识长度不能超过50字符')]) + auto_check_count = IntegerField('自动核查次数') + deal_evaluate_ids = StringField('处置评价ID列表', validators=[Length(max=200, message='处置评价ID列表长度不能超过200字符')]) + newinst_no_transit = StringField('立案不转交', validators=[Length(max=50, message='立案不转交长度不能超过50字符')]) + super_rec_id = IntegerField('上级记录ID') + site_num = StringField('站点编号', validators=[Length(max=50, message='站点编号长度不能超过50字符')]) + difficult_type_id = IntegerField('困难类型ID') + event_district_grade_id = IntegerField('事件区域等级ID') + event_district_grade_name = StringField('事件区域等级名称', validators=[Length(max=100, message='事件区域等级名称长度不能超过100字符')]) + cus_grid_code = StringField('自定义网格编码', validators=[Length(max=100, message='自定义网格编码长度不能超过100字符')]) + site_id = IntegerField('站点ID') + shop_id = IntegerField('商铺ID') + shop_name = StringField('商铺名称', validators=[Length(max=200, message='商铺名称长度不能超过200字符')]) + spec_type_id = IntegerField('特殊类型ID') + spec_type_name = StringField('特殊类型名称', validators=[Length(max=100, message='特殊类型名称长度不能超过100字符')]) + proc_account_state_id = IntegerField('处理账户状态ID') + check_type_id = IntegerField('核查类型ID') + rec_analysis_type_id = IntegerField('记录分析类型ID') + proc_time_state_id = IntegerField('处理流程状态ID') + proc_ard_state_id = IntegerField('处理仲裁状态ID') + proc_enq_state_id = IntegerField('处理询问状态ID') + proc_sup_state_id = IntegerField('处理监督状态ID') + func_time_state_id = IntegerField('职能部门时间状态ID') + check_msg_state_id = IntegerField('核查消息状态ID') + verify_msg_state_id = IntegerField('核实消息状态ID') + regather_msg_state_id = IntegerField('重新采集消息状态ID') + supervision_check_state_id = IntegerField('监督核查状态ID') + self_deal_msg_state_id = IntegerField('自行处置消息状态ID') + review_msg_state_id = IntegerField('复核消息状态ID') + proc_press_state_id = IntegerField('处理压力状态ID') + hot_area = StringField('热点区域', validators=[Length(max=100, message='热点区域长度不能超过100字符')]) + cg_area = StringField('城管区域', validators=[Length(max=100, message='城管区域长度不能超过100字符')]) + hw_area = StringField('环卫区域', validators=[Length(max=100, message='环卫区域长度不能超过100字符')]) + sz_area = StringField('市政区域', validators=[Length(max=100, message='市政区域长度不能超过100字符')]) + device_guid = StringField('设备GUID', validators=[Length(max=100, message='设备GUID长度不能超过100字符')]) + jx_id = IntegerField('警讯ID') + jx_jxmc = StringField('警讯名称', validators=[Length(max=200, message='警讯名称长度不能超过200字符')]) + jx_design_type = StringField('警讯设计类型', validators=[Length(max=100, message='警讯设计类型长度不能超过100字符')]) + report_time_segment_id = IntegerField('上报时段ID') + archive_cond_id = IntegerField('归档条件ID') + archive_cond = StringField('归档条件', validators=[Length(max=100, message='归档条件长度不能超过100字符')]) + archive_type_id = IntegerField('归档类型ID') + road_type_id = IntegerField('道路类型ID') + road_name = StringField('道路名称', validators=[Length(max=200, message='道路名称长度不能超过200字符')]) + road_id = IntegerField('道路ID') + road_type_name = StringField('道路类型名称', validators=[Length(max=100, message='道路类型名称长度不能超过100字符')]) + area_type_id = IntegerField('区域类型ID') + duty_grid_type_id = IntegerField('责任网格类型ID') + deal_duty_grid_type_id = IntegerField('处置责任网格类型ID') + time_area_id = IntegerField('时段ID') + time_area_name = StringField('时段名称', validators=[Length(max=100, message='时段名称长度不能超过100字符')]) + card_num = StringField('证件号码', validators=[Length(max=100, message='证件号码长度不能超过100字符')]) + cell_id = IntegerField('单元格ID') + cell_name = StringField('单元格名称', validators=[Length(max=200, message='单元格名称长度不能超过200字符')]) + damage_grade_id = IntegerField('损毁等级ID') + damage_grade_name = StringField('损毁等级名称', validators=[Length(max=100, message='损毁等级名称长度不能超过100字符')]) + event_grade_id = IntegerField('事件等级ID') + event_grade_name = StringField('事件等级名称', validators=[Length(max=100, message='事件等级名称长度不能超过100字符')]) + event_level_id = IntegerField('事件级别ID') + event_level_name = StringField('事件级别名称', validators=[Length(max=100, message='事件级别名称长度不能超过100字符')]) + event_district_id = IntegerField('事件区域ID') + event_district_name = StringField('事件区域名称', validators=[Length(max=100, message='事件区域名称长度不能超过100字符')]) + display_property = StringField('显示属性', validators=[Length(max=200, message='显示属性长度不能超过200字符')]) + display_style_id = IntegerField('显示样式ID') + refresh_flag = IntegerField('刷新标识') + video_device_id = IntegerField('视频设备ID') + video_param = StringField('视频参数', validators=[Length(max=500, message='视频参数长度不能超过500字符')]) + video_device_id = IntegerField('视频设备ID') + video_param = StringField('视频参数', validators=[Length(max=500, message='视频参数长度不能超过500字符')]) + patrol_deal_flag = IntegerField('巡查处置标识') + send_from_type = StringField('发送来源类型', validators=[Length(max=50, message='发送来源类型长度不能超过50字符')]) + reply_intime_deadline = IntegerField('两小时回复截止时间戳') + accept_status = StringField('受理状态', validators=[Length(max=50, message='受理状态长度不能超过50字符')]) + squadron_id = IntegerField('中队ID') + squadron_name = StringField('中队名称', validators=[Length(max=200, message='中队名称长度不能超过200字符')]) + property_company_id = IntegerField('物业公司ID') + act_record_id = IntegerField('操作记录ID') + main_rec_id = IntegerField('主记录ID') + force_handle_flag = StringField('强制处理标识', validators=[Length(max=50, message='强制处理标识长度不能超过50字符')]) + func_custom_limit = StringField('职能部门自定义时限', validators=[Length(max=50, message='职能部门自定义时限长度不能超过50字符')]) + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class DcmTaskFormDatumBase(TD3iDcmTaskFormDatum, CommonModel): + """ + 企业待办表单数据基础类(完全映射 TD3iDcmTaskFormDatum 字段)。 + + 封装所有与企业待办表单数据相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'rec_id': 'rec_id', + 'rec_disp_num': 'rec_disp_num', + 'rec_type_id': 'rec_type_id', + 'rec_type_name': 'rec_type_name', + 'task_num': 'task_num', + 'other_task_num': 'other_task_num', + 'act_property_id': 'act_property_id', + 'biz_id': 'biz_id', + 'biz_name': 'biz_name', + 'sys_id': 'sys_id', + 'address': 'address', + 'district_name': 'district_name', + 'coordinate_x': 'coordinate_x', + 'coordinate_y': 'coordinate_y', + 'lonlat_x': 'lonlat_x', + 'lonlat_y': 'lonlat_y', + 'event_type_id': 'event_type_id', + 'event_type_name': 'event_type_name', + 'event_src_id': 'event_src_id', + 'event_src_name': 'event_src_name', + 'event_desc': 'event_desc', + 'max_event_type_id': 'max_event_type_id', + 'max_event_type_name': 'max_event_type_name', + 'main_type_id': 'main_type_id', + 'main_type_name': 'main_type_name', + 'sub_type_id': 'sub_type_id', + 'sub_type_name': 'sub_type_name', + 'third_type_id': 'third_type_id', + 'third_type_name': 'third_type_name', + 'forth_type_id': 'forth_type_id', + 'forth_type_name': 'forth_type_name', + 'fifth_type_id': 'fifth_type_id', + 'fifth_type_name': 'fifth_type_name', + 'sixth_type_id': 'sixth_type_id', + 'sixth_type_name': 'sixth_type_name', + 'seventh_type_id': 'seventh_type_id', + 'seventh_type_name': 'seventh_type_name', + 'create_time': 'create_time', + 'update_time': 'update_time', + 'deadline_time': 'deadline_time', + 'warning_time': 'warning_time', + 'occur_time': 'occur_time', + 'dispatch_time': 'dispatch_time', + 'archive_time': 'archive_time', + 'cancel_time': 'cancel_time', + 'refresh_time': 'refresh_time', + 'refresh_start_time': 'refresh_start_time', + 'check_send_time': 'check_send_time', + 'check_reply_time': 'check_reply_time', + 'func_deadline': 'func_deadline', + 'func_deal_time': 'func_deal_time', + 'proc_start_time': 'proc_start_time', + 'custom_deadline': 'custom_deadline', + 'patroltask_deadline_time': 'patroltask_deadline_time', + 'deadline_char': 'deadline_char', + 'func_limit_char': 'func_limit_char', + 'rec_remain_char': 'rec_remain_char', + 'rec_used_char': 'rec_used_char', + 'rec_remain': 'rec_remain', + 'rec_used': 'rec_used', + 'rec_warning': 'rec_warning', + 'rec_deadline': 'rec_deadline', + 'func_part_id': 'func_part_id', + 'func_part_name': 'func_part_name', + 'func_part_list_id': 'func_part_list_id', + 'func_part_list_name': 'func_part_list_name', + 'specify_func_id': 'specify_func_id', + 'specify_func_name': 'specify_func_name', + 'specify_competent_func_id': 'specify_competent_func_id', + 'specify_competent_func_name': 'specify_competent_func_name', + 'first_depart_name': 'first_depart_name', + 'second_depart_name': 'second_depart_name', + 'district_id': 'district_id', + 'street_id': 'street_id', + 'street_name': 'street_name', + 'community_id': 'community_id', + 'community_name': 'community_name', + 'duty_grid_id': 'duty_grid_id', + 'duty_grid_name': 'duty_grid_name', + 'duty_region_id': 'duty_region_id', + 'duty_region_name': 'duty_region_name', + 'duty_district_id': 'duty_district_id', + 'duty_district_name': 'duty_district_name', + 'duty_street_id': 'duty_street_id', + 'duty_street_name': 'duty_street_name', + 'duty_community_id': 'duty_community_id', + 'duty_community_name': 'duty_community_name', + 'law_duty_grid_id': 'law_duty_grid_id', + 'law_duty_grid_name': 'law_duty_grid_name', + 'deal_duty_grid_id': 'deal_duty_grid_id', + 'deal_duty_grid_name': 'deal_duty_grid_name', + 'patrol_id': 'patrol_id', + 'patrol_name': 'patrol_name', + 'accepter_id': 'accepter_id', + 'accepter_name': 'accepter_name', + 'human_id': 'human_id', + 'human_name': 'human_name', + 'reporter_name': 'reporter_name', + 'reporter_contact': 'reporter_contact', + 'tell_num': 'tell_num', + 'read_flag': 'read_flag', + 'reply_intime': 'reply_intime', + 'return_visit_flag': 'return_visit_flag', + 'urgency_level': 'urgency_level', + 'urgent_flag': 'urgent_flag', + 'func_forbid_reporter_info_flag': 'func_forbid_reporter_info_flag', + 'public_flag': 'public_flag', + 'locked_flag': 'locked_flag', + 'transited_flag': 'transited_flag', + 'split_rec_flag': 'split_rec_flag', + 'enable_check_msg': 'enable_check_msg', + 'no_return_visit_flag': 'no_return_visit_flag', + 'common_rec_type_flag': 'common_rec_type_flag', + 'common_rec_attr_flag': 'common_rec_attr_flag', + 'send_pub_check_task_flag': 'send_pub_check_task_flag', + 'reply_flag': 'reply_flag', + 'whistle_flag': 'whistle_flag', + 'repeat_state': 'repeat_state', + 'report_state': 'report_state', + 'dispose_state': 'dispose_state', + 'pre_dispose_state': 'pre_dispose_state', + 'undertake_user_name': 'undertake_user_name', + 'undertake_phone': 'undertake_phone', + 'deal_person_org': 'deal_person_org', + 'media_upload_num': 'media_upload_num', + 'media_upload_total_num': 'media_upload_total_num', + 'media_upload_state': 'media_upload_state', + 'media_check_num': 'media_check_num', + 'media_check_total_num': 'media_check_total_num', + 'media_verify_num': 'media_verify_num', + 'media_verify_total_num': 'media_verify_total_num', + 'media_self_deal_num': 'media_self_deal_num', + 'media_self_deal_total_num': 'media_self_deal_total_num', + 'media_review_num': 'media_review_num', + 'media_review_total_num': 'media_review_total_num', + 'report_pic_num': 'report_pic_num', + 'report_pic_total_num': 'report_pic_total_num', + 'report_video_num': 'report_video_num', + 'report_video_total_num': 'report_video_total_num', + 'report_wav_num': 'report_wav_num', + 'report_wav_total_num': 'report_wav_total_num', + 'check_pic_num': 'check_pic_num', + 'check_pic_total_num': 'check_pic_total_num', + 'check_video_num': 'check_video_num', + 'check_video_total_num': 'check_video_total_num', + 'check_wav_num': 'check_wav_num', + 'check_wav_total_num': 'check_wav_total_num', + 'verify_pic_num': 'verify_pic_num', + 'verify_pic_total_num': 'verify_pic_total_num', + 'verify_video_num': 'verify_video_num', + 'verify_video_total_num': 'verify_video_total_num', + 'verify_wav_num': 'verify_wav_num', + 'verify_wav_total_num': 'verify_wav_total_num', + 'self_deal_pic_num': 'self_deal_pic_num', + 'self_deal_pic_total_num': 'self_deal_pic_total_num', + 'self_deal_video_num': 'self_deal_video_num', + 'self_deal_video_total_num': 'self_deal_video_total_num', + 'self_deal_wav_num': 'self_deal_wav_num', + 'self_deal_wav_total_num': 'self_deal_wav_total_num', + 'review_pic_num': 'review_pic_num', + 'review_pic_total_num': 'review_pic_total_num', + 'review_video_num': 'review_video_num', + 'review_video_total_num': 'review_video_total_num', + 'review_wav_num': 'review_wav_num', + 'review_wav_total_num': 'review_wav_total_num', + 'media_url': 'media_url', + 'mms_pic_path': 'mms_pic_path', + 'media_path': 'media_path', + 'media_type': 'media_type', + 'media_usage': 'media_usage', + 'media_server_name': 'media_server_name', + 'media_property': 'media_property', + 'media_uploaded_name': 'media_uploaded_name', + 'media_shot': 'media_shot', + 'media_label_type_id': 'media_label_type_id', + 'media_default_url': 'media_default_url', + 'display_order': 'display_order', + 'store_type_id': 'store_type_id', + 'special_item_image_type': 'special_item_image_type', + 'height': 'height', + 'width': 'width', + 'send_flag': 'send_flag', + 'public_flag': 'public_flag', + 'gen_thumb': 'gen_thumb', + 'can_delete': 'can_delete', + 'delete_flag': 'delete_flag', + 'delete_reason': 'delete_reason', + 'pos_type': 'pos_type', + 'view_angle': 'view_angle', + 'view_image_name': 'view_image_name', + 'view_image_x': 'view_image_x', + 'view_image_y': 'view_image_y', + 'view_pos_x': 'view_pos_x', + 'view_pos_y': 'view_pos_y', + 'attach_rec_flag': 'attach_rec_flag', + 'gather_flag': 'gather_flag', + 'link_field_value': 'link_field_value', + 'link_field_display_value': 'link_field_display_value', + 'unique_id': 'unique_id', + 'third_unique_id': 'third_unique_id', + 'equal_group_id': 'equal_group_id', + 'rec_category_id': 'rec_category_id', + 'dispatch_opinion': 'dispatch_opinion', + 'revise_opinion': 'revise_opinion', + 'reply_opinion': 'reply_opinion', + 'new_inst_advise': 'new_inst_advise', + 'new_inst_cond_id': 'new_inst_cond_id', + 'new_inst_cond_name': 'new_inst_cond_name', + 'case_closure_condition': 'case_closure_condition', + 'event_marks': 'event_marks', + 'deduction': 'deduction', + 'event_property_id': 'event_property_id', + 'event_property_name': 'event_property_name', + 'city_village_flag': 'city_village_flag', + 'force_handle_flag': 'force_handle_flag', + 'auto_check_count': 'auto_check_count', + 'deal_evaluate_ids': 'deal_evaluate_ids', + 'newinst_no_transit': 'newinst_no_transit', + 'super_rec_id': 'super_rec_id', + 'site_num': 'site_num', + 'difficult_type_id': 'difficult_type_id', + 'event_district_grade_id': 'event_district_grade_id', + 'event_district_grade_name': 'event_district_grade_name', + 'cus_grid_code': 'cus_grid_code', + 'site_id': 'site_id', + 'shop_id': 'shop_id', + 'shop_name': 'shop_name', + 'spec_type_id': 'spec_type_id', + 'spec_type_name': 'spec_type_name', + 'proc_account_state_id': 'proc_account_state_id', + 'check_type_id': 'check_type_id', + 'rec_analysis_type_id': 'rec_analysis_type_id', + 'proc_time_state_id': 'proc_time_state_id', + 'proc_ard_state_id': 'proc_ard_state_id', + 'proc_enq_state_id': 'proc_enq_state_id', + 'proc_sup_state_id': 'proc_sup_state_id', + 'func_time_state_id': 'func_time_state_id', + 'check_msg_state_id': 'check_msg_state_id', + 'verify_msg_state_id': 'verify_msg_state_id', + 'regather_msg_state_id': 'regather_msg_state_id', + 'supervision_check_state_id': 'supervision_check_state_id', + 'self_deal_msg_state_id': 'self_deal_msg_state_id', + 'review_msg_state_id': 'review_msg_state_id', + 'proc_press_state_id': 'proc_press_state_id', + 'hot_area': 'hot_area', + 'cg_area': 'cg_area', + 'hw_area': 'hw_area', + 'sz_area': 'sz_area', + 'device_guid': 'device_guid', + 'jx_id': 'jx_id', + 'jx_jxmc': 'jx_jxmc', + 'jx_design_type': 'jx_design_type', + 'report_time_segment_id': 'report_time_segment_id', + 'archive_cond_id': 'archive_cond_id', + 'archive_cond': 'archive_cond', + 'archive_type_id': 'archive_type_id', + 'road_type_id': 'road_type_id', + 'road_name': 'road_name', + 'road_id': 'road_id', + 'road_type_name': 'road_type_name', + 'area_type_id': 'area_type_id', + 'duty_grid_type_id': 'duty_grid_type_id', + 'deal_duty_grid_type_id': 'deal_duty_grid_type_id', + 'time_area_id': 'time_area_id', + 'time_area_name': 'time_area_name', + 'card_num': 'card_num', + 'cell_id': 'cell_id', + 'cell_name': 'cell_name', + 'damage_grade_id': 'damage_grade_id', + 'damage_grade_name': 'damage_grade_name', + 'event_grade_id': 'event_grade_id', + 'event_grade_name': 'event_grade_name', + 'event_level_id': 'event_level_id', + 'event_level_name': 'event_level_name', + 'event_district_id': 'event_district_id', + 'event_district_name': 'event_district_name', + 'display_property': 'display_property', + 'display_style_id': 'display_style_id', + 'refresh_flag': 'refresh_flag', + 'video_device_id': 'video_device_id', + 'video_param': 'video_param', + 'patrol_deal_flag': 'patrol_deal_flag', + 'send_from_type': 'send_from_type', + 'reply_intime_deadline': 'reply_intime_deadline', + 'accept_status': 'accept_status', + 'squadron_id': 'squadron_id', + 'squadron_name': 'squadron_name', + 'property_company_id': 'property_company_id', + 'act_record_id': 'act_record_id', + 'main_rec_id': 'main_rec_id', + 'func_custom_limit': 'func_custom_limit', + } + + @classmethod + async def exist_other(cls, id: Union[str, int], rec_id: Union[str, int]): + """ + 检查是否存在除当前任务外的其他同记录ID的任务。 + + :param id: 当前任务ID + :param rec_id: 记录ID + :return: 存在返回任务对象,不存在返回None + """ + _query = select(cls).where(cls.id != id, cls.rec_id == rec_id) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """ + 根据ID列表批量查找任务数据。 + """ + _query = select(cls).where(cls.id.in_(ids)) + _task_list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _task_list + + @classmethod + async def is_exist(cls, rec_id: Union[str, int]): + """ + 检查任务是否已经存在(根据记录ID)。 + """ + _query = select(cls).where(cls.rec_id == rec_id) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索任务数据的基础方法。 + + 支持字段: + - task_num, rec_disp_num, event_type_name, district_name, urgency_level, read_flag 等 + - 支持模糊匹配:event_type_name, rec_type_name, event_src_name, first_depart_name, second_depart_name + - 支持精确匹配:biz_id, sys_id, urgency_level, read_flag, rec_type_id, deadline_time 等 + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_num': 'asc'} + :key str task_num: 精确匹配任务号 + :key str rec_disp_num: 精确匹配显示编号 + :key str event_type_name: 模糊匹配问题类型 + :key str district_name: 精确匹配区域 + :key int urgency_level: 精确匹配紧急程度 + :key int read_flag: 精确匹配是否已读 + :key int biz_id: 精确匹配业务ID + :key int sys_id: 精确匹配系统ID + :key int rec_type_id: 精确匹配类型ID + :key int deadline_time: 精确匹配处理截止时间戳 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.event_type_name.key: '%{}%', + cls.rec_type_name.key: '%{}%', + cls.event_src_name.key: '%{}%', + cls.first_depart_name.key: '%{}%', + cls.second_depart_name.key: '%{}%', + cls.district_name.key: '%{}%', + cls.street_name.key: '%{}%', + cls.community_name.key: '%{}%', + cls.func_part_name.key: '%{}%', + cls.specify_func_name.key: '%{}%', + cls.specify_competent_func_name.key: '%{}%', + cls.main_type_name.key: '%{}%', + cls.sub_type_name.key: '%{}%', + cls.third_type_name.key: '%{}%', + cls.forth_type_name.key: '%{}%', + cls.fifth_type_name.key: '%{}%', + cls.sixth_type_name.key: '%{}%', + cls.seventh_type_name.key: '%{}%', + cls.duty_grid_name.key: '%{}%', + cls.duty_region_name.key: '%{}%', + cls.duty_district_name.key: '%{}%', + cls.duty_street_name.key: '%{}%', + cls.duty_community_name.key: '%{}%', + cls.law_duty_grid_name.key: '%{}%', + cls.deal_duty_grid_name.key: '%{}%', + cls.patrol_name.key: '%{}%', + cls.accepter_name.key: '%{}%', + cls.human_name.key: '%{}%', + cls.reporter_name.key: '%{}%', + cls.shop_name.key: '%{}%', + cls.spec_type_name.key: '%{}%', + cls.squadron_name.key: '%{}%', + cls.road_name.key: '%{}%', + cls.time_area_name.key: '%{}%', + cls.hot_area.key: '%{}%', + cls.cg_area.key: '%{}%', + cls.hw_area.key: '%{}%', + cls.sz_area.key: '%{}%', + cls.event_district_grade_name.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_num, cls.rec_disp_num) + + _task_df = await cls.query_as_df(_data_query) + if not _task_df.empty: + _task_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _task_df[cls.id.key] = _task_df[cls.id.key].astype(str) + + return _task_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索任务数据,返回分页格式数据。 + """ + _task_df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _task_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_rec_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 rec_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 rec_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 rec_id 列表(去重) + rec_ids = data_df[cls.rec_id.key].unique().tolist() + if not rec_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 rec_id + _query = select(cls.id, cls.rec_id).where(cls.rec_id.in_(rec_ids)) + rec_ids_df = await cls.query_as_df(_query) + + if rec_ids_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 rec_id -> id 的映射字典 + rec_id_to_id_map = dict(zip(rec_ids_df[cls.rec_id.key], rec_ids_df[cls.id.key])) + + # 根据 rec_id 是否在数据库中,划分数据 + mask_exists = data_df[cls.rec_id.key].isin(rec_ids_df[cls.rec_id.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.rec_id.key].map(rec_id_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + @classmethod + async def fill_form_datum(cls, data_df: pd.DataFrame, index_field: str = 'id', + column_name: str = 'datums', + preprocessing: Optional[Callable] = None): + """ + 填充详细数据到数据框架。 + + 用于在查询结果中添加关联的详细信息。 + + :param pandas.DataFrame data_df: 待填充的数据框架 + :param str index_field: 索引字段,一般是任务ID + :param str column_name: 填充时,新增加的列名称,默认为`datums` + :param preprocessing: 预处理,注意预处理必须要返回处理后的结果 + :return: 详细数据框架(已填充) + :rtype: pandas.DataFrame + """ + if data_df.empty: + return pd.DataFrame() + + _task_ids = list(set(data_df[index_field].unique().tolist())) + if not _task_ids: + return pd.DataFrame() + + _query = select(cls).where(cls.dcm_task_id.in_(_task_ids)) + _datum_df: pd.DataFrame = await cls.query_as_df(_query) + if not _datum_df.empty: + _datum_df.replace(models.EmptyInDF+models.EmptyDatetimeInDF, '', inplace=True) + # 整理输出数据类型 + _datum_df[cls.id.key] = _datum_df[cls.id.key].astype(str) + _datum_df[cls.dcm_task_id.key] = _datum_df[cls.dcm_task_id.key].astype(str) + + # 设置索引 + _datum_df['index_id'] = _datum_df[cls.id.key] + _datum_df.set_index(['index_id'], inplace=True) + # 对数据进行预处理 + if isinstance(preprocessing, Callable): + _datum_df = preprocessing(_datum_df) + # 增加数据填充列 + data_df[column_name] = data_df[index_field].apply( + lambda x: _datum_df.query(f"{cls.dcm_task_id.key}=='{x}'").to_dict('records') + ) + else: + data_df[column_name] = [[] for _ in range(len(data_df))] + + return _datum_df + + +@register_swagger_model +class DcmTaskFormDatum(DcmTaskFormDatumBase): + """ + 企业待办表单数据主业务类(完全继承 TD3iDcmTaskFormDatum 字段)。 + + --- + description: 数字化城市管理信息系统企业待办表单数据 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + rec_id: + description: 记录ID + type: integer + example: 2001 + rec_disp_num: + description: 显示编号 + type: string + example: "D20240501001" + maxLength: 50 + rec_type_id: + description: 类型ID + type: integer + example: 101 + rec_type_name: + description: 案件类型 + type: string + example: "市容环境" + maxLength: 100 + task_num: + description: 任务号 + type: string + example: "TASK20240501001" + maxLength: 50 + other_task_num: + description: 第三方任务号 + type: string + example: "THIRD-2024-001" + maxLength: 100 + act_property_id: + description: 任务属性ID + type: integer + example: 5 + biz_id: + description: 业务ID + type: integer + example: 10 + biz_name: + description: 业务名称 + type: string + example: "市容巡查" + maxLength: 200 + sys_id: + description: 系统ID + type: integer + example: 1 + address: + description: 地址描述 + type: string + example: "中山路与解放路交叉口" + maxLength: 65535 + district_name: + description: 所属区域 + type: string + example: "鼓楼区" + maxLength: 50 + coordinate_x: + description: 经度 + type: number + format: decimal + example: 118.789012 + coordinate_y: + description: 纬度 + type: number + format: decimal + example: 32.045678 + lonlat_x: + description: 经纬度X + type: number + format: decimal + example: 118.789012 + lonlat_y: + description: 经纬度Y + type: number + format: decimal + example: 32.045678 + event_type_id: + description: 问题类型ID + type: integer + example: 1001 + event_type_name: + description: 问题类型 + type: string + example: "道路破损" + maxLength: 100 + event_src_id: + description: 问题来源ID + type: integer + example: 101 + event_src_name: + description: 问题来源 + type: string + example: "市民举报" + maxLength: 100 + event_desc: + description: 问题描述 + type: string + example: "中山路与解放路交叉口路面大面积破损" + maxLength: 65535 + max_event_type_id: + description: 最大事件类型ID + type: integer + example: 1002 + max_event_type_name: + description: 最大事件类型名称 + type: string + example: "市政设施" + maxLength: 200 + main_type_id: + description: 大类ID + type: integer + example: 101 + main_type_name: + description: 大类名称 + type: string + example: "市容环境" + maxLength: 100 + sub_type_id: + description: 小类ID + type: integer + example: 10101 + sub_type_name: + description: 小类名称 + type: string + example: "道路破损" + maxLength: 100 + third_type_id: + description: 第三级类型ID + type: integer + example: 1010101 + third_type_name: + description: 第三级类型名称 + type: string + example: "人行道破损" + maxLength: 100 + forth_type_id: + description: 第四级类型ID + type: integer + example: 101010101 + forth_type_name: + description: 第四级类型名称 + type: string + example: "沥青路面破损" + maxLength: 100 + fifth_type_id: + description: 第五级类型ID + type: integer + example: 10101010101 + fifth_type_name: + description: 第五级类型名称 + type: string + example: "裂缝" + maxLength: 100 + sixth_type_id: + description: 第六级类型ID + type: integer + example: 1010101010101 + sixth_type_name: + description: 第六级类型名称 + type: string + example: "横向裂缝" + maxLength: 100 + seventh_type_id: + description: 第七级类型ID + type: integer + example: 101010101010101 + seventh_type_name: + description: 第七级类型名称 + type: string + example: "细小横向裂缝" + maxLength: 100 + create_time: + description: 创建时间戳 + type: integer + example: 1714567890000 + update_time: + description: 更新时间戳 + type: integer + example: 1714578000000 + deadline_time: + description: 处理截止时间戳 + type: integer + example: 1714578000000 + warning_time: + description: 处理预警时间戳 + type: integer + example: 1714570000000 + occur_time: + description: 发生时间戳 + type: integer + example: 1714567800000 + dispatch_time: + description: 派遣时间戳 + type: integer + example: 1714568000000 + archive_time: + description: 归档时间戳 + type: integer + example: 1714580000000 + cancel_time: + description: 取消时间戳 + type: integer + example: 1714579000000 + refresh_time: + description: 刷新时间戳 + type: integer + example: 1714572000000 + refresh_start_time: + description: 刷新开始时间戳 + type: integer + example: 1714571000000 + check_send_time: + description: 核查发送时间戳 + type: integer + example: 1714570000000 + check_reply_time: + description: 核查回复时间戳 + type: integer + example: 1714571000000 + func_deadline: + description: 职能部门截止时间戳 + type: integer + example: 1714578000000 + func_deal_time: + description: 职能部门处理时间戳 + type: integer + example: 1714576000000 + proc_start_time: + description: 处理开始时间戳 + type: integer + example: 1714572000000 + custom_deadline: + description: 自定义截止时间戳 + type: integer + example: 1714579000000 + patroltask_deadline_time: + description: 巡查任务截止时间戳 + type: integer + example: 1714575000000 + deadline_char: + description: 时限描述 + type: string + example: "24小时" + maxLength: 50 + func_limit_char: + description: 职能部门时限描述 + type: string + example: "48小时" + maxLength: 50 + rec_remain_char: + description: 记录剩余时间描述 + type: string + example: "3天" + maxLength: 50 + rec_used_char: + description: 记录已用时间描述 + type: string + example: "1天" + maxLength: 50 + rec_remain: + description: 记录剩余时间 + type: number + format: decimal + example: 3.5 + rec_used: + description: 记录已用时间 + type: number + format: decimal + example: 1.2 + rec_warning: + description: 记录预警时间 + type: number + format: decimal + example: 0.5 + rec_deadline: + description: 记录时限 + type: number + format: decimal + example: 5.0 + func_part_id: + description: 职能部门ID + type: integer + example: 101 + func_part_name: + description: 职能部门名称 + type: string + example: "市政工程处" + maxLength: 200 + func_part_list_id: + description: 职能部门列表ID + type: string + example: "LIST-001" + maxLength: 100 + func_part_list_name: + description: 职能部门列表名称 + type: string + example: "市政处置组" + maxLength: 200 + specify_func_id: + description: 指定职能部门ID + type: integer + example: 102 + specify_func_name: + description: 指定职能部门名称 + type: string + example: "城市管理局" + maxLength: 200 + specify_competent_func_id: + description: 指定主管职能部门ID + type: integer + example: 103 + specify_competent_func_name: + description: 指定主管职能部门名称 + type: string + example: "市城管委" + maxLength: 200 + first_depart_name: + description: 一级专业部门 + type: string + example: "市政工程处" + maxLength: 100 + second_depart_name: + description: 二级专业部门 + type: string + example: "道路养护科" + maxLength: 100 + district_id: + description: 区域ID + type: integer + example: 1001 + street_id: + description: 街道ID + type: integer + example: 1002 + street_name: + description: 街道名称 + type: string + example: "中山路" + maxLength: 200 + community_id: + description: 社区ID + type: integer + example: 1003 + community_name: + description: 社区名称 + type: string + example: "鼓楼社区" + maxLength: 200 + duty_grid_id: + description: 责任网格ID + type: integer + example: 1004 + duty_grid_name: + description: 责任网格名称 + type: string + example: "鼓楼网格01" + maxLength: 200 + duty_region_id: + description: 责任区域ID + type: integer + example: 1005 + duty_region_name: + description: 责任区域名称 + type: string + example: "鼓楼区" + maxLength: 200 + duty_district_id: + description: 责任区域ID + type: integer + example: 1005 + duty_district_name: + description: 责任区域名称 + type: string + example: "鼓楼区" + maxLength: 200 + duty_street_id: + description: 责任街道ID + type: integer + example: 1006 + duty_street_name: + description: 责任街道名称 + type: string + example: "中山路" + maxLength: 200 + duty_community_id: + description: 责任社区ID + type: integer + example: 1007 + duty_community_name: + description: 责任社区名称 + type: string + example: "鼓楼社区" + maxLength: 200 + law_duty_grid_id: + description: 法律责任网格ID + type: integer + example: 1008 + law_duty_grid_name: + description: 法律责任网格名称 + type: string + example: "执法网格01" + maxLength: 200 + deal_duty_grid_id: + description: 处置责任网格ID + type: integer + example: 1009 + deal_duty_grid_name: + description: 处置责任网格名称 + type: string + example: "处置网格01" + maxLength: 200 + patrol_id: + description: 巡查员ID + type: integer + example: 2001 + patrol_name: + description: 巡查员名称 + type: string + example: "张三" + maxLength: 200 + accepter_id: + description: 受理人ID + type: integer + example: 2002 + accepter_name: + description: 受理人姓名 + type: string + example: "李四" + maxLength: 100 + human_id: + description: 操作人ID + type: integer + example: 2003 + human_name: + description: 操作人名称 + type: string + example: "王五" + maxLength: 255 + reporter_name: + description: 举报人姓名 + type: string + example: "张三" + maxLength: 100 + reporter_contact: + description: 举报电话 + type: string + example: "13800138000" + maxLength: 50 + tell_num: + description: 联系电话 + type: string + example: "13800138000" + maxLength: 50 + read_flag: + description: 是否已读(0未读,1已读) + type: integer + example: 1 + reply_intime: + description: 是否两小时回复(0无需回复,1待回复,2已回复,3超时,4无需回复已恢复) + type: integer + example: 2 + return_visit_flag: + description: 回访标识(0无需,1待回访,2已回访) + type: integer + example: 1 + urgency_level: + description: 紧急程度(0正常,1紧急) + type: integer + example: 1 + urgent_flag: + description: 紧急标识 + type: integer + example: 1 + func_forbid_reporter_info_flag: + description: 是否禁止举报人信息 + type: integer + example: 0 + public_flag: + description: 公开标志 + type: integer + example: 1 + locked_flag: + description: 锁定标识 + type: integer + example: 0 + transited_flag: + description: 转交标识 + type: integer + example: 1 + split_rec_flag: + description: 拆分记录标识 + type: integer + example: 0 + enable_check_msg: + description: 启用核查消息 + type: integer + example: 1 + no_return_visit_flag: + description: 无需回访标识 + type: integer + example: 0 + common_rec_type_flag: + description: 通用记录类型标识 + type: string + example: "COMMON" + maxLength: 50 + common_rec_attr_flag: + description: 通用记录属性标识 + type: string + example: "AUTO" + maxLength: 50 + send_pub_check_task_flag: + description: 发送公共核查任务标识 + type: integer + example: 1 + reply_flag: + description: 回复标识 + type: string + example: "REPLIED" + maxLength: 50 + whistle_flag: + description: 吹哨标识 + type: string + example: "WHISTLE" + maxLength: 50 + repeat_state: + description: 重复状态 + type: string + example: "NOT_REPEAT" + maxLength: 50 + report_state: + description: 上报状态 + type: string + example: "SUBMITTED" + maxLength: 50 + dispose_state: + description: 处置状态 + type: integer + example: 1 + pre_dispose_state: + description: 预处置状态 + type: string + example: "PENDING" + maxLength: 50 + undertake_user_name: + description: 承办人员 + type: string + example: "张三" + maxLength: 50 + undertake_phone: + description: 联系电话 + type: string + example: "13800138000" + maxLength: 50 + deal_person_org: + description: 承办部门 + type: string + example: "部门名称" + maxLength: 50 + media_upload_num: + description: 媒体上传数量 + type: integer + example: 3 + media_upload_total_num: + description: 媒体上传总数 + type: integer + example: 5 + media_upload_state: + description: 媒体上传状态 + type: string + example: "SUCCESS" + maxLength: 50 + media_check_num: + description: 媒体核查数量 + type: integer + example: 2 + media_check_total_num: + description: 媒体核查总数 + type: integer + example: 5 + media_verify_num: + description: 媒体核实数量 + type: integer + example: 1 + media_verify_total_num: + description: 媒体核实总数 + type: integer + example: 5 + media_self_deal_num: + description: 自行处置媒体数量 + type: integer + example: 1 + media_self_deal_total_num: + description: 自行处置媒体总数 + type: integer + example: 3 + media_review_num: + description: 复核媒体数量 + type: integer + example: 1 + media_review_total_num: + description: 复核媒体总数 + type: integer + example: 3 + report_pic_num: + description: 上报图片数量 + type: integer + example: 2 + report_pic_total_num: + description: 上报图片总数 + type: integer + example: 3 + report_video_num: + description: 上报视频数量 + type: integer + example: 1 + report_video_total_num: + description: 上报视频总数 + type: integer + example: 1 + report_wav_num: + description: 上报音频数量 + type: integer + example: 0 + report_wav_total_num: + description: 上报音频总数 + type: integer + example: 0 + check_pic_num: + description: 核查图片数量 + type: integer + example: 2 + check_pic_total_num: + description: 核查图片总数 + type: integer + example: 3 + check_video_num: + description: 核查视频数量 + type: integer + example: 1 + check_video_total_num: + description: 核查视频总数 + type: integer + example: 1 + check_wav_num: + description: 核查音频数量 + type: integer + example: 0 + check_wav_total_num: + description: 核查音频总数 + type: integer + example: 0 + verify_pic_num: + description: 核实图片数量 + type: integer + example: 1 + verify_pic_total_num: + description: 核实图片总数 + type: integer + example: 1 + verify_video_num: + description: 核实视频数量 + type: integer + example: 0 + verify_video_total_num: + description: 核实视频总数 + type: integer + example: 0 + verify_wav_num: + description: 核实音频数量 + type: integer + example: 0 + verify_wav_total_num: + description: 核实音频总数 + type: integer + example: 0 + self_deal_pic_num: + description: 自行处置图片数量 + type: integer + example: 1 + self_deal_pic_total_num: + description: 自行处置图片总数 + type: integer + example: 2 + self_deal_video_num: + description: 自行处置视频数量 + type: integer + example: 0 + self_deal_video_total_num: + description: 自行处置视频总数 + type: integer + example: 1 + self_deal_wav_num: + description: 自行处置音频数量 + type: integer + example: 0 + self_deal_wav_total_num: + description: 自行处置音频总数 + type: integer + example: 0 + review_pic_num: + description: 复核图片数量 + type: integer + example: 1 + review_pic_total_num: + description: 复核图片总数 + type: integer + example: 1 + review_video_num: + description: 复核视频数量 + type: integer + example: 0 + review_video_total_num: + description: 复核视频总数 + type: integer + example: 0 + review_wav_num: + description: 复核音频数量 + type: integer + example: 0 + review_wav_total_num: + description: 复核音频总数 + type: integer + example: 0 + media_url: + description: 内部访问URL + type: string + example: "http://internal/media/123.jpg" + maxLength: 512 + mms_pic_path: + description: 彩信图片路径 + type: string + example: "/mms/123.jpg" + maxLength: 500 + media_path: + description: 服务器存储路径 + type: string + example: "/storage/media/123.jpg" + maxLength: 512 + media_type: + description: 媒体类型 + type: string + example: "IMAGE" + maxLength: 50 + media_usage: + description: 使用场景 + type: string + example: "上报" + maxLength: 100 + media_server_name: + description: 媒体服务器名称 + type: string + example: "media-server-01" + maxLength: 100 + media_property: + description: 媒体属性 + type: integer + example: 1 + media_uploaded_name: + description: 上传时的原始文件名 + type: string + example: "IMG_20240501.jpg" + maxLength: 255 + media_shot: + description: 截图标识或路径 + type: string + example: "/shots/123.jpg" + maxLength: 255 + media_label_type_id: + description: 标签类型ID + type: integer + example: 101 + media_default_url: + description: 外部可访问URL + type: string + example: "https://external/media/123.jpg" + maxLength: 512 + display_order: + description: 显示顺序 + type: integer + example: 1 + store_type_id: + description: 存储类型ID + type: integer + example: 1 + special_item_image_type: + description: 特殊图片类型 + type: string + example: "SIGNATURE" + maxLength: 100 + height: + description: 图片高度 + type: integer + example: 1080 + width: + description: 图片宽度 + type: integer + example: 1920 + send_flag: + description: 发送标志 + type: integer + example: 1 + gen_thumb: + description: 是否生成缩略图 + type: integer + example: 1 + can_delete: + description: 是否可删除 + type: integer + example: 1 + delete_flag: + description: 删除标记 + type: integer + example: 0 + delete_reason: + description: 删除原因 + type: string + example: "数据重复" + maxLength: 65535 + pos_type: + description: 位置类型 + type: string + example: "GPS" + maxLength: 50 + view_angle: + description: 视角 + type: string + example: "FRONT" + maxLength: 100 + view_image_name: + description: 视图图片名称 + type: string + example: "view_123.jpg" + maxLength: 200 + view_image_x: + description: 视图图片X坐标 + type: number + format: decimal + example: 0.5 + view_image_y: + description: 视图图片Y坐标 + type: number + format: decimal + example: 0.5 + view_pos_x: + description: 视图位置X坐标 + type: number + format: decimal + example: 0.5 + view_pos_y: + description: 视图位置Y坐标 + type: number + format: decimal + example: 0.5 + attach_rec_flag: + description: 附件记录标识 + type: string + example: "ATTACH" + maxLength: 50 + gather_flag: + description: 汇总标识 + type: string + example: "GATHERED" + maxLength: 50 + link_field_value: + description: 关联字段值 + type: string + example: "LINK-123" + maxLength: 500 + link_field_display_value: + description: 关联字段显示值 + type: string + example: "关联值显示" + maxLength: 500 + unique_id: + description: 唯一标识 + type: string + example: "UNIQ-20240501-001" + maxLength: 100 + third_unique_id: + description: 第三方唯一标识 + type: string + example: "THIRD-2024-001" + maxLength: 100 + equal_group_id: + description: 等值组ID + type: integer + example: 1001 + rec_category_id: + description: 记录类别ID + type: integer + example: 101 + dispatch_opinion: + description: 派遣意见 + type: string + example: "请尽快处理" + maxLength: 500 + revise_opinion: + description: 修订意见 + type: string + example: "建议补充图片" + maxLength: 500 + reply_opinion: + description: 回复意见 + type: string + example: "已处理完毕" + maxLength: 500 + new_inst_advise: + description: 立案建议 + type: string + example: "建议立案处理" + maxLength: 500 + new_inst_cond_id: + description: 立案条件ID + type: integer + example: 101 + new_inst_cond_name: + description: 立案条件 + type: string + example: "破损面积大于0.5㎡" + maxLength: 200 + case_closure_condition: + description: 结案条件 + type: string + example: "修复完成并验收" + maxLength: 200 + event_marks: + description: 事件标记 + type: string + example: "HIGH_PRIORITY" + maxLength: 500 + deduction: + description: 扣减 + type: string + example: "扣2分" + maxLength: 100 + event_property_id: + description: 事件属性ID + type: integer + example: 101 + event_property_name: + description: 事件属性名称 + type: string + example: "公共设施" + maxLength: 200 + city_village_flag: + description: 城乡标识 + type: string + example: "CITY" + maxLength: 50 + force_handle_flag: + description: 强制处理标识 + type: string + example: "FORCE" + maxLength: 50 + auto_check_count: + description: 自动核查次数 + type: integer + example: 2 + deal_evaluate_ids: + description: 处置评价ID列表 + type: string + example: "101,102,103" + maxLength: 200 + newinst_no_transit: + description: 立案不转交 + type: string + example: "NO_TRANSIT" + maxLength: 50 + super_rec_id: + description: 上级记录ID + type: integer + example: 2001 + site_num: + description: 站点编号 + type: string + example: "SITE-001" + maxLength: 50 + difficult_type_id: + description: 困难类型ID + type: integer + example: 101 + event_district_grade_id: + description: 事件区域等级ID + type: integer + example: 101 + event_district_grade_name: + description: 事件区域等级名称 + type: string + example: "重点区域" + maxLength: 100 + cus_grid_code: + description: 自定义网格编码 + type: string + example: "CUST-GRID-001" + maxLength: 100 + site_id: + description: 站点ID + type: integer + example: 101 + shop_id: + description: 商铺ID + type: integer + example: 101 + shop_name: + description: 商铺名称 + type: string + example: "幸福便利店" + maxLength: 200 + spec_type_id: + description: 特殊类型ID + type: integer + example: 101 + spec_type_name: + description: 特殊类型名称 + type: string + example: "紧急事件" + maxLength: 100 + proc_account_state_id: + description: 处理账户状态ID + type: integer + example: 1 + check_type_id: + description: 核查类型ID + type: integer + example: 1 + rec_analysis_type_id: + description: 记录分析类型ID + type: integer + example: 1 + proc_time_state_id: + description: 处理流程状态ID + type: integer + example: 1 + proc_ard_state_id: + description: 处理仲裁状态ID + type: integer + example: 1 + proc_enq_state_id: + description: 处理询问状态ID + type: integer + example: 1 + proc_sup_state_id: + description: 处理监督状态ID + type: integer + example: 1 + func_time_state_id: + description: 职能部门时间状态ID + type: integer + example: 1 + check_msg_state_id: + description: 核查消息状态ID + type: integer + example: 1 + verify_msg_state_id: + description: 核实消息状态ID + type: integer + example: 1 + regather_msg_state_id: + description: 重新采集消息状态ID + type: integer + example: 1 + supervision_check_state_id: + description: 监督核查状态ID + type: integer + example: 1 + self_deal_msg_state_id: + description: 自行处置消息状态ID + type: integer + example: 1 + review_msg_state_id: + description: 复核消息状态ID + type: integer + example: 1 + proc_press_state_id: + description: 处理压力状态ID + type: integer + example: 1 + hot_area: + description: 热点区域 + type: string + example: "市中心" + maxLength: 100 + cg_area: + description: 城管区域 + type: string + example: "鼓楼区" + maxLength: 100 + hw_area: + description: 环卫区域 + type: string + example: "鼓楼区" + maxLength: 100 + sz_area: + description: 市政区域 + type: string + example: "鼓楼区" + maxLength: 100 + device_guid: + description: 设备GUID + type: string + example: "A1B2-C3D4-E5F6" + maxLength: 100 + jx_id: + description: 警讯ID + type: integer + example: 1001 + jx_jxmc: + description: 警讯名称 + type: string + example: "道路塌陷警讯" + maxLength: 200 + jx_design_type: + description: 警讯设计类型 + type: string + example: "自动触发" + maxLength: 100 + report_time_segment_id: + description: 上报时段ID + type: integer + example: 101 + archive_cond_id: + description: 归档条件ID + type: integer + example: 101 + archive_cond: + description: 归档条件 + type: string + example: "处理完成" + maxLength: 100 + archive_type_id: + description: 归档类型ID + type: integer + example: 101 + road_type_id: + description: 道路类型ID + type: integer + example: 101 + road_name: + description: 道路名称 + type: string + example: "中山路" + maxLength: 200 + road_id: + description: 道路ID + type: integer + example: 101 + road_type_name: + description: 道路类型名称 + type: string + example: "主干道" + maxLength: 100 + area_type_id: + description: 区域类型ID + type: integer + example: 101 + duty_grid_type_id: + description: 责任网格类型ID + type: integer + example: 101 + deal_duty_grid_type_id: + description: 处置责任网格类型ID + type: integer + example: 101 + time_area_id: + description: 时段ID + type: integer + example: 101 + time_area_name: + description: 时段名称 + type: string + example: "早高峰" + maxLength: 100 + card_num: + description: 证件号码 + type: string + example: "110101199001012345" + maxLength: 100 + cell_id: + description: 单元格ID + type: integer + example: 101 + cell_name: + description: 单元格名称 + type: string + example: "A01单元" + maxLength: 200 + damage_grade_id: + description: 损毁等级ID + type: integer + example: 101 + damage_grade_name: + description: 损毁等级名称 + type: string + example: "严重" + maxLength: 100 + event_grade_id: + description: 事件等级ID + type: integer + example: 101 + event_grade_name: + description: 事件等级名称 + type: string + example: "重大" + maxLength: 100 + event_level_id: + description: 事件级别ID + type: integer + example: 101 + event_level_name: + description: 事件级别名称 + type: string + example: "一级" + maxLength: 100 + event_district_id: + description: 事件区域ID + type: integer + example: 101 + event_district_name: + description: 事件区域名称 + type: string + example: "鼓楼区" + maxLength: 100 + display_property: + description: 显示属性 + type: string + example: "高亮显示" + maxLength: 200 + display_style_id: + description: 显示样式ID + type: integer + example: 1 + refresh_flag: + description: 刷新标识 + type: integer + example: 1 + video_device_id: + description: 视频设备ID + type: integer + example: 101 + video_param: + description: 视频参数 + type: string + example: "1080p,30fps" + maxLength: 500 + patrol_deal_flag: + description: 巡查处置标识 + type: integer + example: 1 + send_from_type: + description: 发送来源类型 + type: string + example: "APP" + maxLength: 50 + reply_intime_deadline: + description: 两小时回复截止时间戳 + type: integer + example: 1714568000000 + accept_status: + description: 受理状态 + type: string + example: "ACCEPTED" + maxLength: 50 + squadron_id: + description: 中队ID + type: integer + example: 101 + squadron_name: + description: 中队名称 + type: string + example: "第一中队" + maxLength: 200 + property_company_id: + description: 物业公司ID + type: integer + example: 101 + act_record_id: + description: 操作记录ID + type: integer + example: 1001 + main_rec_id: + description: 主记录ID + type: integer + example: 2001 + func_custom_limit: + description: 职能部门自定义时限 + type: string + example: "72小时" + maxLength: 50 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, **kwargs): + """ + 创建新的任务表单数据。 + + 业务流程: + 1. 使用 HumanTaskFormDatumForm 验证表单数据完整性 + 2. 检查任务是否已存在(根据 rec_id) + 3. 创建新任务对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的任务对象 + + :param kwargs: 任务参数字典 + :return: 新建任务对象 + :rtype: DcmTaskFormDatum + :raises AssertionError: 当任务已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _task_form = DcmTaskFormDatumForm(formdata=kwargs) + _task_form.validate_form() + + # 检查是否存在同记录ID的任务 + _task: cls = await cls.is_exist(_task_form.rec_id.data) + assert _task is None, "记录ID已存在,不能重复创建。" + + # 创建任务对象 + _task = cls().copy_from_dict(_task_form.data, skip_none=True).before_save() + await _task.async_save() + return _task + + @classmethod + async def delete(cls, task_id: Union[str, int]): + """ + 删除任务表单数据。 + + 业务流程: + 1. 根据ID查找任务 + 2. 验证任务存在性 + 3. 执行删除操作 + + :param task_id: 要删除的任务ID + :return: 删除的任务对象 + :rtype: DcmTaskFormDatum + :raises AssertionError: 当任务不存在时抛出 + """ + _task: cls = await cls.async_find_by_id(task_id) + assert _task, f"根据 ID {task_id} 未找到任务。" + + # 执行删除 + _del_query = delete(cls).where(cls.id == _task.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除任务表单数据(记录ID:{_task.rec_id},ID:{_task.id}).') + return _task + + @classmethod + async def modify(cls, task_id: Union[str, int], **kwargs): + """ + 修改已有任务表单数据。 + + 业务流程: + 1. 将 task_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 HumanTaskFormDatumForm 验证表单数据 + 4. 检查是否有其他任务使用了相同的 rec_id + 5. 查询原任务对象 + 6. 验证任务存在性 + 7. 更新字段并设置更新者 + 8. 保存到数据库 + 9. 返回更新后的任务对象 + + :param task_id: 要修改的任务ID + :param kwargs: 需要更新的字段 + :return: 修改后的任务对象 + :rtype: DcmTaskFormDatum + :raises AssertionError: 当任务不存在或信息重复时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _task_form = DcmTaskFormDatumForm(formdata=kwargs) + _task_form.validate_form() + + # 检查是否与其他任务重复(排除自身) + _other = await cls.exist_other(task_id, _task_form.rec_id.data) + assert _other is None, "记录ID已存在,不能重复修改。" + + # 查询原任务 + _task: cls = await cls.async_find_by_id(task_id) + assert _task, f'查无此任务信息。' + + # 更新字段 + _task.copy_from_dict(_task_form.data, skip_none=True).before_save() + await _task.async_save() + return _task + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame): + """ + 批量创建新任务表单数据(传入数据应为全新记录,无需校验是否存在)。 + + :param data_df: 包含任务数据的 DataFrame,字段需与模型属性匹配(如 rec_id, task_num 等) + :return: 成功创建的任务数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 一次性转为字典列表(C 层高效) + records = data_df.to_dict('records') + + # 用列表推导式构造对象 + tasks = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + # 批量插入 + session = cls.get_aio_session() + try: + session.add_all(tasks) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(tasks)} 条新任务表单数据。") + return len(tasks) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame): + """ + 批量修改已有任务表单数据。 + + :param data_df: 包含任务数据的 DataFrame + :return: 成功更新的任务数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条任务表单数据。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame): + """ + 批量保存数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await DcmTaskFormDatum.exists_rec_id(data_df) + # 保存到数据库 + _created_count = await DcmTaskFormDatum.create_batch(_latest_df) + _updated_count = await DcmTaskFormDatum.modify_batch(_exists_df) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/dcm_task_more_info.py b/models/dcm_task_more_info.py new file mode 100644 index 0000000..070a638 --- /dev/null +++ b/models/dcm_task_more_info.py @@ -0,0 +1,236 @@ +from typing import Optional, Callable +from paste.web.form import ModelForm +from paste.core.logging import echo_log +from wtforms import StringField, IntegerField +from wtforms.validators import Length +from tornado_swagger.model import register_swagger_model +import models +from models.common_model import CommonModel +from models.db_models import TD3iDcmTaskMoreInfo +import pandas as pd +from sqlalchemy import select + + +class DcmTaskMoreInfoForm(ModelForm): + """ + 更多信息表单验证类(完全映射 TD3iDcmTaskMoreInfo 字段)。 + + 用于验证和处理数字城管-部门待办任务更多信息数据。 + 字段完全映射数据库表 t_d3i_dcm_task_more_info 的字段结构。 + """ + rec_id=IntegerField('记录ID') + create_time=StringField('创建时间',validators=[Length(max=64,message='创建时间长度不能超过64个字符')]) + ex_info_id=IntegerField('更多信息ID') + ex_info_msg=StringField('更多信息内容',validators=[Length(max=255,message='更多信息内容长度不能超过255个字符')]) + human_id=IntegerField('人员ID') + human_name=StringField('人员姓名',validators=[Length(max=50,message='人员姓名长度不能超过50个字符')]) + msg_id=IntegerField('消息ID') + msg_info=StringField('消息详情',validators=[Length(max=255,message='消息详情长度不能超过255个字符')]) + msg_type=StringField('消息类型名称',validators=[Length(max=50,message='消息类型名称长度不能超过50个字符')]) + msg_type_id=IntegerField('消息类型ID') + role_name=StringField('角色名称',validators=[Length(max=50,message='角色名称长度不能超过50个字符')]) + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class DcmTaskMoreInfoBase(TD3iDcmTaskMoreInfo, CommonModel): + """ + 更多信息基础类(完全映射 TD3iDcmTaskMoreInfo 字段)。 + + 封装所有与任更多信息相关的通用操作方法。 + """ + FieldMapping = { + 'rec_id': 'recID', + 'create_time': 'createTime', + 'ex_info_id': 'exInfoID', + 'ex_info_msg': 'exInfoMsg', + 'human_id': 'humanID', + 'human_name': 'humanName', + 'msg_id': 'msgID', + 'msg_info': 'msgInfo', + 'msg_type': 'msgType', + 'msg_type_id': 'msgTypeID', + 'role_name': 'roleName' + } + + @classmethod + async def exists_rec_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。仅根据 rec_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 raw_id(rec_id)列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录(已匹配数据库id) + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 rec_id(去重) + rec_ids = data_df[cls.rec_id.key].drop_duplicates().tolist() + if not rec_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库仅根据 rec_id 匹配 + _query = select(cls.id, cls.rec_id).where( + cls.rec_id.in_(rec_ids) + ) + exists_df = await cls.query_as_df(_query) + + if exists_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 rec_id -> 数据库id 的映射(单字段) + key_to_id_map = dict(zip(exists_df[cls.rec_id.key], exists_df[cls.id.key])) + + # 根据 rec_id 判断是否存在 + mask_exists = data_df.apply(lambda row: row[cls.rec_id.key] in key_to_id_map, axis=1) + + # 拆分存在/不存在的数据 + exists_df = data_df[mask_exists].copy() + # 通过 rec_id 匹配数据库主键 + exists_df[cls.id.key] = exists_df.apply(lambda row: key_to_id_map[row[cls.rec_id.key]], axis=1) + latest_df = data_df[~mask_exists].copy() + + return exists_df, latest_df + + @classmethod + async def fill_more_info(cls, data_df: pd.DataFrame, index_field: str = 'id', + column_name: str = 'more_infos', + preprocessing: Optional[Callable] = None): + """ + 填充更多信息数据到数据框架。 + + 用于在查询结果中添加关联的更多信息。 + + :param pandas.DataFrame data_df: 待填充的数据框架 + :param str index_field: 索引字段,一般是任务ID + :param str column_name: 填充时,新增加的列名称,默认为`more_info` + :param preprocessing: 预处理,注意预处理必须要返回处理后的结果 + :return: 更多信息数据框架(已填充) + :rtype: pandas.DataFrame + """ + if data_df.empty: + return pd.DataFrame() + + _task_ids = list(set(data_df[index_field].unique().tolist())) + if not _task_ids: + return pd.DataFrame() + + _query = select(cls).where(cls.dcm_task_id.in_(_task_ids)) + _more_info_df: pd.DataFrame = await cls.query_as_df(_query) + if not _more_info_df.empty: + _more_info_df.replace(models.EmptyInDF+models.EmptyDatetimeInDF, '', inplace=True) + # 整理输出数据类型 + _more_info_df[cls.id.key] = _more_info_df[cls.id.key].astype(str) + _more_info_df[cls.dcm_task_id.key] = _more_info_df[cls.dcm_task_id.key].astype(str) + # 设置索引 + _more_info_df['index_id'] = _more_info_df[cls.dcm_task_id.key] + _more_info_df.set_index(['index_id'], inplace=True) + # 对数据进行预处理 + if isinstance(preprocessing, Callable): + _more_info_df = preprocessing(_more_info_df) + # 增加数据填充列 + data_df[column_name] = data_df[index_field].apply( + lambda x: _more_info_df.query(f"{cls.dcm_task_id.key}=='{x}'").to_dict('records') + ) + else: + data_df[column_name] = [[] for _ in range(len(data_df))] + return _more_info_df + + +@register_swagger_model +class DcmTaskMoreInfo(DcmTaskMoreInfoBase): + """ + 更多信息业务模型类(主业务类,完全继承 TD3iDcmTaskMoreInfo 字段)。 + """ + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame): + """ + 批量创建新更多信息(传入数据应为全新记录,无需校验是否存在)。 + + :param data_df: 包含更多信息数据的 DataFrame,字段需与模型属性匹配 + :return: 成功创建的记录数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 一次性转为字典列表(C 层高效) + records = data_df.to_dict('records') + + # 用列表推导式构造对象 + records = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + # 批量插入 + session = cls.get_aio_session() + try: + session.add_all(records) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(records)} 条任务更多信息。") + return len(records) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame): + """ + 批量修改已有更多信息。 + + :param data_df: 包含更多信息数据的 DataFrame + :return: 成功更新的记录数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条任务的更多信息。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame): + """ + 批量保存数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await DcmTaskMoreInfo.exists_rec_id(data_df) + # 保存到数据库 + _created_count = await DcmTaskMoreInfo.create_batch(_latest_df) + _updated_count = await DcmTaskMoreInfo.modify_batch(_exists_df) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/dcm_task_process_info.py b/models/dcm_task_process_info.py new file mode 100644 index 0000000..30e7b4c --- /dev/null +++ b/models/dcm_task_process_info.py @@ -0,0 +1,757 @@ +import random +from typing import Union, Optional, Callable + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iDcmTaskProcessInfo +from paste.core.logging import echo_log +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class DcmTaskProcessInfoForm(ModelForm): + """ + 办理经过表单验证类(完全映射 TD3iDcmTaskProcessInfo 字段)。 + + 用于验证和处理数字城管-部门待办任务办理经过的记录/更新表单数据。 + 字段完全映射数据库表 t_d3i_dcm_task_process_info 的字段结构。 + """ + + # 基础信息 + raw_id = IntegerField('原始主键ID') + rec_id = IntegerField('记录ID') + act_id = IntegerField('任务ID') + act_def_id = IntegerField('流程节点定义ID') + act_def_name = StringField('流程节点名称', validators=[Length(max=100, message='流程节点名称长度不能超过100字符')]) + act_time_state_id = IntegerField('操作时间状态ID') + act_limit_info = StringField('操作时限信息', validators=[Length(max=255, message='时限信息长度不能超过255字符')]) + act_used_time_char = StringField('已用时间(字符串)', validators=[Length(max=50, message='已用时间描述长度不能超过50字符')]) + act_remain_time_char = StringField('剩余时间(字符串)', validators=[Length(max=50, message='剩余时间描述长度不能超过50字符')]) + act_deadline_time = IntegerField('操作截止时间戳(毫秒)') + act_property_id = IntegerField('操作属性ID') + + # 操作信息 + action_name = StringField('操作动作名称', validators=[Length(max=100, message='操作动作名称长度不能超过100字符')]) + action_time = IntegerField('操作时间戳(毫秒)') + title = StringField('操作标题', validators=[Length(max=100, message='标题长度不能超过100字符')]) + detail = TextAreaField('操作详细意见', validators=[Length(max=65535, message='意见长度不能超过65535字符')]) + backup_detail = TextAreaField('备用意见', validators=[Length(max=65535, message='备用意见长度不能超过65535字符')]) + medias = TextAreaField('附件信息(JSON格式)', validators=[Length(max=65535, message='附件信息长度不能超过65535字符')]) + + # 单位与人员 + unit_name = StringField('当前操作单位', validators=[Length(max=100, message='单位名称长度不能超过100字符')]) + unit_contact = StringField('单位联系方式', validators=[Length(max=255, message='联系方式长度不能超过255字符')]) + human_id = IntegerField('操作人ID') + human_name = StringField('操作人名称(含单位)', validators=[Length(max=255, message='操作人名称长度不能超过255字符')]) + role_name = StringField('当前角色名称', validators=[Length(max=100, message='角色名称长度不能超过100字符')]) + + # 项目信息 + item_id = IntegerField('项目ID') + item_type_id = IntegerField('任务类型ID') + item_content = TextAreaField('任务内容摘要', validators=[Length(max=65535, message='内容摘要长度不能超过65535字符')]) + item_process_info_list = TextAreaField('子流程列表(JSON)', validators=[Length(max=65535, message='子流程列表长度不能超过65535字符')]) + sub_process_info = TextAreaField('子流程信息', validators=[Length(max=65535, message='子流程信息长度不能超过65535字符')]) + + # 捆绑信息 + bundle_time_state_id = IntegerField('组合时间状态ID') + bundle_limit_info = StringField('组合时限信息', validators=[Length(max=255, message='组合时限信息长度不能超过255字符')]) + bundle_used_char = StringField('组合已用时间', validators=[Length(max=50, message='组合已用时间长度不能超过50字符')]) + bundle_remain_char = StringField('组合剩余时间', validators=[Length(max=50, message='组合剩余时间长度不能超过50字符')]) + bundle_deadline_time = IntegerField('组合截止时间戳(毫秒)') + + # 下一节点信息 + show_unit_contact = IntegerField('是否显示单位联系方式') + pre_unit_name = StringField('上一单位', validators=[Length(max=100, message='上一单位名称长度不能超过100字符')]) + pre_action_name = StringField('上一操作名称', validators=[Length(max=100, message='上一操作名称长度不能超过100字符')]) + pre_human_name = StringField('上一操作人', validators=[Length(max=255, message='上一操作人名称长度不能超过255字符')]) + pre_act_opinion = TextAreaField('上一操作意见', validators=[Length(max=65535, message='上一操作意见长度不能超过65535字符')]) + next_act_def_name = StringField('下一节点名称', validators=[Length(max=100, message='下一节点名称长度不能超过100字符')]) + next_role_part_name = StringField('下一角色/单位', validators=[Length(max=255, message='下一角色/单位长度不能超过255字符')]) + next_role_name = StringField('下一角色名称', validators=[Length(max=100, message='下一角色名称长度不能超过100字符')]) + next_act_property_id = IntegerField('下一操作属性ID') + + # 最后节点标识 + last_act_flag = IntegerField('是否为最后一节点(0否,1是)') + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class DcmTaskProcessInfoBase(TD3iDcmTaskProcessInfo, CommonModel): + """ + 办理经过基础类(完全映射 TD3iDcmTaskProcessInfo 字段)。 + + 封装所有与任务办理经过相关的通用操作方法。 + """ + + FieldMapping = { + 'raw_id': 'id', + 'act_id': 'actID', + 'act_def_id': 'actDefID', + 'act_def_name': 'actDefName', + 'act_time_state_id': 'actTimeStateID', + 'act_limit_info': 'actLimitInfo', + 'act_used_time_char': 'actUsedTimeChar', + 'act_remain_time_char': 'actRemainTimeChar', + 'act_deadline_time': 'actDeadlineTime', + 'act_property_id': 'actPropertyID', + 'action_name': 'actionName', + 'action_time': 'actionTime', + 'title': 'title', + 'detail': 'detail', + 'backup_detail': 'backupDetail', + 'medias': 'medias', + 'unit_name': 'unitName', + 'unit_contact': 'unitContact', + 'human_id': 'humanID', + 'human_name': 'humanName', + 'role_name': 'roleName', + 'item_id': 'itemID', + 'item_type_id': 'itemTypeID', + 'item_content': 'itemContent', + 'item_process_info_list': 'itemProcessInfoList', + 'sub_process_info': 'subProcessInfo', + 'bundle_time_state_id': 'bundleTimeStateID', + 'bundle_limit_info': 'bundleLimitInfo', + 'bundle_used_char': 'bundleUsedChar', + 'bundle_remain_char': 'bundleRemainChar', + 'bundle_deadline_time': 'bundleDeadlineTime', + 'show_unit_contact': 'showUnitContact', + 'pre_unit_name': 'preUnitName', + 'pre_action_name': 'preActionName', + 'pre_human_name': 'preHumanName', + 'pre_act_opinion': 'preActOpinion', + 'next_act_def_name': 'nextActDefName', + 'next_role_part_name': 'nextRolePartName', + 'next_role_name': 'nextRoleName', + 'next_act_property_id': 'nextActPropertyID', + 'last_act_flag': 'lastActFlag', + } + """ + 处理流程映射 + """ + + @classmethod + async def exist_other(cls, id: Union[str, int], rec_id: Union[str, int], act_id: Union[str, int]): + """ + 检查是否存在除当前记录外的其他同任务ID或同原始主键ID的记录。 + + :param act_id: 当前任务ID + :param rec_id: 原始主键ID + :return: 存在返回记录对象,不存在返回None + """ + _query = select(cls).where(cls.id != id, cls.rec_id == rec_id, cls.act_id == act_id) + _record: cls = await cls.query_first(_query) + return _record + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """ + 根据ID列表批量查找办理经过。 + """ + _query = select(cls).where(cls.id.in_(ids)) + _record_list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _record_list + + @classmethod + async def is_exist(cls, rec_id: Union[str, int], act_id: Union[str, int]): + """ + 检查办理经过是否已经存在(根据原始主键ID)。 + """ + _query = select(cls).where(cls.rec_id == rec_id, cls.act_id == act_id) + _record: cls = await cls.query_first(_query) + return _record + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索办理流程记录的基础方法。 + + 支持字段: + - act_id, unit_name, human_name, role_name, last_act_flag + - 支持模糊匹配:act_def_name, action_name, title, item_content + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'action_time': 'desc'} + :key int act_id: 精确匹配任务ID + :key str unit_name: 精确匹配单位 + :key str human_name: 精确匹配操作人 + :key str role_name: 精确匹配角色 + :key int last_act_flag: 精确匹配是否为最后一节点 + :key str act_def_name: 模糊匹配流程节点名称 + :key str action_name: 模糊匹配操作动作 + :key str title: 模糊匹配标题 + :key str item_content: 模糊匹配内容摘要 + :key int action_time_start: 时间范围起始(毫秒) + :key int action_time_end: 时间范围结束(毫秒) + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + _name_likes = { + cls.act_def_name.key: '%{}%', + cls.action_name.key: '%{}%', + cls.title.key: '%{}%', + cls.item_content.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ) + + if 'action_time_start' in kwargs and 'action_time_end' in kwargs: + _query = _query.where( + cls.action_time >= kwargs['action_time_start'], + cls.action_time <= kwargs['action_time_end'] + ) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.action_time.desc()) + + _process_df = await cls.query_as_df(_data_query) + if not _process_df.empty: + _process_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + return _process_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索办理流程记录,返回分页格式数据。 + """ + _process_df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _process_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_rec_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 raw_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 raw_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 (rec_id, act_id) 组合 + pairs = data_df[[cls.rec_id.key, cls.act_id.key]].drop_duplicates().values.tolist() + if not pairs: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的记录 + _query = select(cls.id, cls.rec_id, cls.act_id).where( + (cls.rec_id.in_([p[0] for p in pairs])) & + (cls.act_id.in_([p[1] for p in pairs])) + ) + exists_df = await cls.query_as_df(_query) + + if exists_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 (rec_id, act_id) -> id 的映射 + key_to_id_map = dict(zip(zip(exists_df[cls.rec_id.key], exists_df[cls.act_id.key]), exists_df[cls.id.key])) + + # 根据组合是否在数据库中划分数据 + mask_exists = data_df.apply(lambda row: (row[cls.rec_id.key], row[cls.act_id.key]) in key_to_id_map, axis=1) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df.apply(lambda row: key_to_id_map[(row[cls.rec_id.key], row[cls.act_id.key])], axis=1) + latest_df = data_df[~mask_exists].copy() + + return exists_df, latest_df + + @classmethod + async def fill_process_info(cls, data_df: pd.DataFrame, index_field: str = 'id', + column_name: str = 'process_infos', + preprocessing: Optional[Callable] = None): + """ + 填充办理过程数据到数据框架。 + + 用于在查询结果中添加关联的办理过程信息。 + + :param pandas.DataFrame data_df: 待填充的数据框架 + :param str index_field: 索引字段,一般是任务ID + :param str column_name: 填充时,新增加的列名称,默认为`process_infos` + :param preprocessing: 预处理,注意预处理必须要返回处理后的结果 + :return: 办理过程数据框架(已填充) + :rtype: pandas.DataFrame + """ + if data_df.empty: + return pd.DataFrame() + + _task_ids = list(set(data_df[index_field].unique().tolist())) + if not _task_ids: + return pd.DataFrame() + + _query = select(cls).where(cls.dcm_task_id.in_(_task_ids)) + _info_df: pd.DataFrame = await cls.query_as_df(_query) + if not _info_df.empty: + _info_df.replace(models.EmptyInDF+models.EmptyDatetimeInDF, '', inplace=True) + # 整理输出数据类型 + _info_df[cls.id.key] = _info_df[cls.id.key].astype(str) + _info_df[cls.dcm_task_id.key] = _info_df[cls.dcm_task_id.key].astype(str) + + # 设置索引 + _info_df['index_id'] = _info_df[cls.dcm_task_id.key] + _info_df.set_index(['index_id'], inplace=True) + # 对数据进行预处理 + if isinstance(preprocessing, Callable): + _info_df = preprocessing(_info_df) + # 增加数据填充列 + data_df[column_name] = data_df[index_field].apply( + lambda x: _info_df.query(f"{cls.dcm_task_id.key}=='{x}'").to_dict('records') + ) + else: + data_df[column_name] = [[] for _ in range(len(data_df))] + + return _info_df + + +@register_swagger_model +class DcmTaskProcessInfo(DcmTaskProcessInfoBase): + """ + 办理经过业务模型类(主业务类,完全继承 TD3iDcmTaskProcessInfo 字段)。 + + --- + description: 数字城管-部门待办任务办理经过 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + raw_id: + description: 原始主键ID + type: integer + example: 2001 + act_id: + description: 任务ID + type: integer + example: 3001 + act_def_id: + description: 流程节点定义ID + type: integer + example: 101 + act_def_name: + description: 流程节点名称 + type: string + example: "受理" + maxLength: 100 + act_time_state_id: + description: 操作时间状态ID + type: integer + example: 1 + act_limit_info: + description: 操作时限信息 + type: string + example: "24小时内" + maxLength: 255 + act_used_time_char: + description: 已用时间(字符串) + type: string + example: "12小时" + maxLength: 50 + act_remain_time_char: + description: 剩余时间(字符串) + type: string + example: "12小时" + maxLength: 50 + act_deadline_time: + description: 操作截止时间戳(毫秒) + type: integer + example: 1714578000000 + act_property_id: + description: 操作属性ID + type: integer + example: 5 + action_name: + description: 操作动作名称(如批转、回退) + type: string + example: "批转" + maxLength: 100 + action_time: + description: 操作时间戳(毫秒) + type: integer + example: 1714567890000 + title: + description: 操作标题 + type: string + example: "案件受理完成" + maxLength: 100 + detail: + description: 操作详细意见 + type: string + example: "经核查,该案件属实,已转交市政工程处处理。" + maxLength: 65535 + backup_detail: + description: 备用意见 + type: string + example: "系统自动记录" + maxLength: 65535 + medias: + description: 附件信息(JSON格式) + type: string + example: "[{\"media_id\":3001,\"name\":\"photo.jpg\"}]" + maxLength: 65535 + unit_name: + description: 当前操作单位 + type: string + example: "市政工程处" + maxLength: 100 + unit_contact: + description: 单位联系方式 + type: string + example: "025-88888888" + maxLength: 255 + human_id: + description: 操作人ID,-1为系统 + type: integer + example: 101 + human_name: + description: 操作人名称(含单位) + type: string + example: "张三(市政工程处)" + maxLength: 255 + role_name: + description: 当前角色名称 + type: string + example: "审批员" + maxLength: 100 + item_id: + description: 项目ID + type: integer + example: 4001 + item_type_id: + description: 任务类型ID + type: integer + example: 101 + item_content: + description: 任务内容摘要 + type: string + example: "中山路破损路面修复" + maxLength: 65535 + item_process_info_list: + description: 子流程列表(JSON) + type: string + example: "[{\"node\":\"受理\",\"time\":1714567890000}]" + maxLength: 65535 + sub_process_info: + description: 子流程信息 + type: string + example: "{\"sub1\":\"已处理\",\"sub2\":\"待验收\"}" + maxLength: 65535 + bundle_time_state_id: + description: 组合时间状态ID + type: integer + example: 2 + bundle_limit_info: + description: 组合时限信息 + type: string + example: "48小时内" + maxLength: 255 + bundle_used_char: + description: 组合已用时间 + type: string + example: "36小时" + maxLength: 50 + bundle_remain_char: + description: 组合剩余时间 + type: string + example: "12小时" + maxLength: 50 + bundle_deadline_time: + description: 组合截止时间戳(毫秒) + type: integer + example: 1714580000000 + show_unit_contact: + description: 是否显示单位联系方式 + type: integer + example: 1 + pre_unit_name: + description: 上一单位 + type: string + example: "街道办" + maxLength: 100 + pre_action_name: + description: 上一操作名称 + type: string + example: "初审" + maxLength: 100 + pre_human_name: + description: 上一操作人 + type: string + example: "李四(街道办)" + maxLength: 255 + pre_act_opinion: + description: 上一操作意见 + type: string + example: "建议转交专业部门处理" + maxLength: 65535 + next_act_def_name: + description: 下一节点名称 + type: string + example: "审批" + maxLength: 100 + next_role_part_name: + description: 下一角色/单位 + type: string + example: "市政工程处-审批组" + maxLength: 255 + next_role_name: + description: 下一角色名称 + type: string + example: "审批员" + maxLength: 100 + next_act_property_id: + description: 下一操作属性ID + type: integer + example: 6 + last_act_flag: + description: 是否为最后一节点(0否,1是) + type: integer + example: 0 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, **kwargs): + """ + 创建办理经过。 + + 业务流程: + 1. 使用 D3iDcmTaskProcessInfoForm 验证表单数据 + 2. 设置创建者、更新者 + 3. 保存到数据库 + 4. 返回创建的流程记录对象 + + :param kwargs: 办理经过参数字典 + :return: 新建流程记录对象 + :rtype: DcmTaskProcessInfo + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = DcmTaskProcessInfoForm(formdata=kwargs) + _form.validate_form() + + # 检查是否存在同记录ID的任务 + _task: cls = await cls.is_exist(_form.rec_id.data, _form.act_id.data) + assert _task is None, "记录ID已存在,不能重复创建。" + + _record = cls().copy_from_dict(_form.data, skip_none=True).before_save() + await _record.async_save() + return _record + + @classmethod + async def delete(cls, process_id: Union[str, int]): + """ + 软删除办理经过(设置 delete_flag=1)。 + + :param process_id: 要删除的办理经过ID + :return: 删除的流程记录对象 + :rtype: DcmTaskProcessInfo + :raises AssertionError: 当记录不存在时抛出 + """ + _record: cls = await cls.async_find_by_id(process_id) + assert _record, f"根据 ID {process_id} 未找到办理经过。" + + # 执行删除 + _del_query = delete(cls).where(cls.id == _record.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除任务办理经过(记录ID:{_record.rec_id},ID:{_record.id}).') + return _record + + @classmethod + async def modify(cls, process_id: Union[str, int], **kwargs): + """ + 修改办理经过(仅允许修改非核心流程字段,如意见、标题、联系方式等)。 + + 注意:不允许修改 act_id、action_time、act_def_name 等关键流程节点信息。 + + 业务流程: + 1. 将 process_id 加入参数 + 2. 处理字符串字段去除空格 + 3. 使用表单验证 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新允许字段 + 7. 设置更新者 + 8. 保存 + + :param process_id: 要修改的办理经过ID + :param kwargs: 需要更新的字段 + :return: 修改后的流程记录对象 + :rtype: DcmTaskProcessInfo + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + kwargs[cls.id.key] = process_id + + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = DcmTaskProcessInfoForm(formdata=kwargs) + _form.validate_form() + + _record: cls = await cls.async_find_by_id(process_id) + assert _record, f'查无此办理经过。' + + # 仅允许更新非核心流程字段 + allowed_fields = { + 'title', 'detail', 'backup_detail', 'medias', + 'unit_contact', 'human_name', 'role_name', + 'item_content', 'item_process_info_list', 'sub_process_info', + 'show_unit_contact', 'pre_act_opinion', 'pre_human_name' + } + + update_data = {k: v for k, v in _form.data.items() if k in allowed_fields and v is not None} + _record.copy_from_dict(update_data, skip_none=True).before_save() + await _record.async_save() + + return _record + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame): + """ + 批量创建新办理经过(传入数据应为全新记录,无需校验是否存在)。 + + :param data_df: 包含办理经过数据的 DataFrame,字段需与模型属性匹配(如 raw_id, act_id 等) + :return: 成功创建的记录数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 一次性转为字典列表(C 层高效) + records = data_df.to_dict('records') + + # 用列表推导式构造对象 + records = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + # 批量插入 + session = cls.get_aio_session() + try: + session.add_all(records) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(records)} 条任务办理经过。") + return len(records) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame): + """ + 批量修改已有办理经过。 + + :param data_df: 包含办理经过数据的 DataFrame + :return: 成功更新的记录数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条任务办理经过。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame): + """ + 批量保存数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await DcmTaskProcessInfo.exists_rec_id(data_df) + # 保存到数据库 + _created_count = await DcmTaskProcessInfo.create_batch(_latest_df) + _updated_count = await DcmTaskProcessInfo.modify_batch(_exists_df) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/govc_task.py b/models/govc_task.py new file mode 100644 index 0000000..85fac98 --- /dev/null +++ b/models/govc_task.py @@ -0,0 +1,636 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovcTask +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovcTaskForm(ModelForm): + """ + 市12345工单表单验证类(完全映射 TD3iGovcTask 字段)。 + + 用于验证和处理市12345工单主表的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task 的字段结构。 + """ + + # 基础信息 + id = IntegerField('记录ID') + evl_result = StringField('结果满意度', validators=[Length(max=64, message='结果满意度长度不能超过64字符')]) + finish_result = TextAreaField('办结结果', validators=[Length(max=65535, message='办结结果长度不能超过65535字符')]) + serial_num = StringField('工单编号', validators=[Length(max=64, message='工单编号长度不能超过64字符')]) + t_status = StringField('任务单状态', validators=[Length(max=64, message='任务单状态长度不能超过64字符')]) + accord_type = StringField('归口类型', validators=[Length(max=255, message='归口类型长度不能超过255字符')]) + create_date = DateTimeField('交办时间') + back_time_bf = DateTimeField('拒绝时限') + sub_handle_ou_name = StringField('子处办单位', + validators=[Length(max=255, message='子处办单位长度不能超过255字符')]) + sign_time_bf = IntegerField('签收时限时间戳') + is_leaf = StringField('是否叶子节点', validators=[Length(max=32, message='是否叶子节点长度不能超过32字符')]) + row_guid = StringField('rowguid', validators=[Length(max=64, message='rowguid长度不能超过64字符')]) + c_guid = StringField('查询详情使用guid', validators=[Length(max=64, message='查询详情使用guid长度不能超过64字符')]) + finish_time = IntegerField('办结时间戳') + sign_time = IntegerField('签收时间戳') + is_secret = StringField('是否保密', validators=[Length(max=32, message='是否保密长度不能超过32字符')]) + finish_time_bf = DateTimeField('办结时限') + link_number = StringField('联系号码', validators=[Length(max=64, message='联系号码长度不能超过64字符')]) + pvi_guid = StringField('查询详情使用pviguid', + validators=[Length(max=64, message='查询详情使用pviguid长度不能超过64字符')]) + rqst_type = StringField('诉求类型', validators=[Length(max=64, message='诉求类型长度不能超过64字符')]) + rqst_content = TextAreaField('诉求内容', validators=[Length(max=65535, message='诉求内容长度不能超过65535字符')]) + handle_ou_name = StringField('处办单位', validators=[Length(max=255, message='处办单位长度不能超过255字符')]) + rqst_title = StringField('标题', validators=[Length(max=500, message='标题长度不能超过500字符')]) + sign_person = StringField('签收人', validators=[Length(max=128, message='签收人长度不能超过128字符')]) + rqst_person = StringField('诉求人', validators=[Length(max=128, message='诉求人长度不能超过128字符')]) + rqs_channel = StringField('渠道来源', validators=[Length(max=64, message='渠道来源长度不能超过64字符')]) + t_type = StringField('工单类型', validators=[Length(max=64, message='工单类型长度不能超过64字符')]) + solve_situation = StringField('解决情况', validators=[Length(max=64, message='解决情况长度不能超过64字符')]) + evl_style = StringField('态度满意度', validators=[Length(max=64, message='态度满意度长度不能超过64字符')]) + send_opinion = TextAreaField('派送意见', validators=[Length(max=65535, message='派送意见长度不能超过65535字符')]) + created_at = DateTimeField('创建时间') + created_by = StringField('创建者', validators=[Length(max=64, message='创建者长度不能超过64字符')]) + updated_at = DateTimeField('更新时间') + updated_by = StringField('更新者', validators=[Length(max=64, message='更新者长度不能超过64字符')]) + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovcTaskBase(TD3iGovcTask, CommonModel): + """ + 市12345工单基础类(完全映射 TD3iGovcTask 字段)。 + + 继承自数据库模型 TD3iGovcTask 和通用模型 CommonModel。 + 封装所有与市12345工单相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'evl_result': 'evlresult', + 'finish_result': 'finishresult', + 'serial_num': 'serialnum', + 't_status': 'tstatus', + 'accord_type': 'accordtype', + 'create_date': 'createdate', + 'back_time_bf': 'backtime_bf', + 'sub_handle_ou_name': 'subhandleouname', + 'sign_time_bf': 'signtime_bf', + 'is_leaf': 'isLeaf', + 'row_guid': 'rowguid', + 'c_guid': 'cguid', + 'finish_time': 'finishtime', + 'sign_time': 'signtime', + 'is_secret': 'issecret', + 'finish_time_bf': 'finishtime_bf', + 'link_number': 'linknumber', + 'pvi_guid': 'pviguid', + 'rqst_type': 'rqsttype', + 'rqst_content': 'rqstcontent', + 'handle_ou_name': 'handleouname', + 'rqst_title': 'rqsttitle', + 'sign_person': 'signperson', + 'rqst_person': 'rqstperson', + 'rqs_channel': 'rqschannel', + 't_type': 'ttype', + 'solve_situation': 'solvesituation', + 'evl_style': 'evlstyle', + 'send_opinion': 'sendopinion' + } + """ + 工单数据映射 + """ + + @classmethod + async def is_exist(cls, serial_num: str): + """ + 检查工单记录是否已存在(根据工单编号)。 + + :param serial_num: 工单编号 + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.serial_num == serial_num) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索工单数据的基础方法。 + + 支持字段: + - serial_num, row_guid, c_guid, pvi_guid, t_status, t_type, is_leaf, is_secret + - 支持模糊匹配:rqst_content, finish_result, send_opinion, rqst_title + - 支持精确匹配:t_status, t_type, is_leaf, is_secret + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'serial_num': 'asc'} + :key str serial_num: 精确匹配工单编号 + :key str row_guid: 精确匹配rowguid + :key str c_guid: 精确匹配查询详情使用guid + :key str pvi_guid: 精确匹配查询详情使用pviguid + :key str t_status: 精确匹配任务单状态 + :key str t_type: 精确匹配工单类型 + :key str is_leaf: 精确匹配是否叶子节点 + :key str is_secret: 精确匹配是否保密 + :key str rqst_content: 模糊匹配诉求内容 + :key str finish_result: 模糊匹配办结结果 + :key str send_opinion: 模糊匹配派送意见 + :key str rqst_title: 模糊匹配标题 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.rqst_content.key: '%{}%', + cls.finish_result.key: '%{}%', + cls.send_opinion.key: '%{}%', + cls.rqst_title.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.serial_num, cls.id) + + _task_df = await cls.query_as_df(_data_query) + if not _task_df.empty: + _task_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _task_df[cls.id.key] = _task_df[cls.id.key].astype(str) + + return _task_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索工单数据,返回分页格式数据。 + """ + _task_df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _task_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_serial_num(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 serial_num 字段判断。 + + :param data_df: 输入的数据框架,必须包含 serial_num 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 serial_num 列表(去重) + serial_nums = data_df[cls.serial_num.key].unique().tolist() + if not serial_nums: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 serial_num + _query = select(cls.id, cls.serial_num).where(cls.serial_num.in_(serial_nums)) + serial_nums_df = await cls.query_as_df(_query) + + if serial_nums_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 serial_num -> id 的映射字典 + serial_num_to_id_map = dict(zip(serial_nums_df[cls.serial_num.key], serial_nums_df[cls.id.key])) + + # 根据 serial_num 是否在数据库中,划分数据 + mask_exists = data_df[cls.serial_num.key].isin(serial_nums_df[cls.serial_num.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.serial_num.key].map(serial_num_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class GovcTask(GovcTaskBase): + """ + 市12345工单模型类(主业务类,完全继承 TD3iGovcTask 字段)。 + + --- + description: 市12345工单主表接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + evl_result: + description: 结果满意度 + type: string + example: "满意" + maxLength: 64 + finish_result: + description: 办结结果 + type: string + example: "已完成诉求处理,用户反馈满意" + maxLength: 65535 + serial_num: + description: 工单编号 + type: string + example: "GOV20240501001" + maxLength: 64 + t_status: + description: 任务单状态 + type: string + example: "已办结" + maxLength: 64 + accord_type: + description: 归口类型 + type: string + example: "城市管理" + maxLength: 255 + create_date: + description: 交办时间 + type: string + format: date-time + example: "2024-01-15 10:30:00" + back_time_bf: + description: 拒绝时限 + type: string + format: date-time + example: "2024-01-20 18:00:00" + sub_handle_ou_name: + description: 子处办单位 + type: string + example: "XX街道办事处" + maxLength: 255 + sign_time_bf: + description: 签收时限时间戳 + type: integer + example: 1705324800000 + is_leaf: + description: 是否叶子节点 + type: string + example: "1" + maxLength: 32 + row_guid: + description: rowguid + type: string + example: "8f9e7d6c-5b4a-3210-9876-abcdef123456" + maxLength: 64 + c_guid: + description: 查询详情使用guid + type: string + example: "7e8d9c0b-1a2b-3c4d-5e6f-7890abcdef12" + maxLength: 64 + finish_time: + description: 办结时间戳 + type: integer + example: 1705843200000 + sign_time: + description: 签收时间戳 + type: integer + example: 1705411200000 + is_secret: + description: 是否保密 + type: string + example: "0" + maxLength: 32 + finish_time_bf: + description: 办结时限 + type: string + format: date-time + example: "2024-01-25 18:00:00" + link_number: + description: 联系号码 + type: string + example: "13800138000" + maxLength: 64 + pvi_guid: + description: 查询详情使用pviguid + type: string + example: "9d8c7b6a-5f4e-3d2c-1b0a-9876543210fe" + maxLength: 64 + rqst_type: + description: 诉求类型 + type: string + example: "投诉" + maxLength: 64 + rqst_content: + description: 诉求内容 + type: string + example: "XX小区垃圾堆积未及时清理,影响居民生活" + maxLength: 65535 + handle_ou_name: + description: 处办单位 + type: string + example: "XX区城市管理局" + maxLength: 255 + rqst_title: + description: 标题 + type: string + example: "XX小区垃圾清理问题" + maxLength: 500 + sign_person: + description: 签收人 + type: string + example: "张三" + maxLength: 128 + rqst_person: + description: 诉求人 + type: string + example: "李四" + maxLength: 128 + rqs_channel: + description: 渠道来源 + type: string + example: "12345热线" + maxLength: 64 + t_type: + description: 工单类型 + type: string + example: "民生类" + maxLength: 64 + solve_situation: + description: 解决情况 + type: string + example: "已解决" + maxLength: 64 + evl_style: + description: 态度满意度 + type: string + example: "满意" + maxLength: 64 + send_opinion: + description: 派送意见 + type: string + example: "请XX街道办事处尽快处理" + maxLength: 65535 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的工单记录。 + + 业务流程: + 1. 使用 GovcTaskForm 验证表单数据完整性 + 2. 检查是否已存在相同 serial_num 的记录(避免重复提交) + 3. 创建新工单对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 工单参数字典 + :return: 新建工单对象 + :rtype: GovcTask + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovcTaskForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 serial_num 的记录 + _existing = await cls.is_exist(_form.serial_num.data) + assert _existing is None, "该工单编号已存在记录,不能重复提交。" + + # 创建对象 + _task = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _task.created_by = user.username + _task.updated_by = user.username + await _task.async_save() + return _task + + @classmethod + async def delete(cls, task_id: Union[str, int]): + """ + 删除工单记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param task_id: 要删除的工单记录ID + :return: 删除的记录对象 + :rtype: GovcTask + :raises AssertionError: 当记录不存在时抛出 + """ + _task: cls = await cls.async_find_by_id(task_id) + assert _task, f"根据 ID {task_id} 未找到工单记录。" + + _del_query = delete(cls).where(cls.id == _task.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除工单记录(工单编号:{_task.serial_num},ID:{_task.id}).') + return _task + + @classmethod + async def modify(cls, task_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有工单记录。 + + 业务流程: + 1. 将 task_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 GovcTaskForm 验证表单数据 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新字段并设置更新者 + 7. 保存到数据库 + 8. 返回更新后的对象 + + :param task_id: 要修改的工单记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的工单对象 + :rtype: GovcTask + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _task: cls = await cls.async_find_by_id(task_id) + assert _task, f'查无此工单信息。' + + # 更新字段 + _task.copy_from_dict(_form.data, skip_none=True).before_save() + _task.updated_by = user.username + await _task.async_save() + return _task + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建工单记录(传入数据应为全新记录)。 + + :param data_df: 包含工单数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + tasks = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(tasks) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(tasks)} 条工单记录。") + return len(tasks) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有工单记录。 + + :param data_df: 包含工单数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存工单数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await GovcTask.exists_serial_num(data_df) + # 保存到数据库 + _created_count = await GovcTask.create_batch(_latest_df, user) + _updated_count = await GovcTask.modify_batch(_exists_df, user) + return _created_count, _updated_count diff --git a/models/govc_task_attachment.py b/models/govc_task_attachment.py new file mode 100644 index 0000000..bac7cf7 --- /dev/null +++ b/models/govc_task_attachment.py @@ -0,0 +1,527 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, IntegerField, TextAreaField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovcTaskAttachment +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovcTaskAttachmentForm(ModelForm): + """ + 工单附件表单验证类(完全映射 TD3iGovcTaskAttachment 字段)。 + + 用于验证和处理市12345工单附件的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task_attachment 的字段结构。 + """ + + # 基础信息 + id = IntegerField('主键ID') + task_id = IntegerField('关联工单主表ID', validators=[], # 外键非空由数据库约束,此处可补充自定义验证 + description='关联工单主表ID(t_d3i_govc_task.id)') + detail_id = IntegerField('关联工单详情ID', validators=[], + description='关联工单详情ID(t_d3i_govc_task_detail.id)') + name = StringField('附件名称', validators=[Length(max=500, message='附件名称长度不能超过500字符')]) + attach_url = TextAreaField('附件地址', validators=[Length(max=65535, message='附件地址长度不能超过65535字符')]) + type = StringField('附件类型', validators=[Length(max=64, message='附件类型长度不能超过64字符')]) + created_at = StringField('创建时间', render_kw={'readonly': True}) + created_by = StringField('创建者', validators=[Length(max=64, message='创建者长度不能超过64字符')]) + updated_at = StringField('更新时间', render_kw={'readonly': True}) + updated_by = StringField('更新者', validators=[Length(max=64, message='更新者长度不能超过64字符')]) + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovcTaskAttachmentBase(TD3iGovcTaskAttachment, CommonModel): + """ + 工单附件基础类(完全映射 TD3iGovcTaskAttachment 字段)。 + + 继承自数据库模型 TD3iGovcTaskAttachment 和通用模型 CommonModel。 + 封装所有与工单附件相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'task_id': 'task_id', + 'detail_id': 'detail_id', + 'name': 'name', + 'attach_url': 'attach_url', + 'type': 'type', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 工单附件数据映射 + """ + + @classmethod + async def is_exist(cls, task_id: int, detail_id: int, name: str): + """ + 检查工单附件记录是否已存在(根据工单ID+详情ID+附件名称)。 + + :param task_id: 关联工单主表ID + :param detail_id: 关联工单详情ID + :param name: 附件名称 + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where( + cls.task_id == task_id, + cls.detail_id == detail_id, + cls.name == name + ) + _attachment: cls = await cls.query_first(_query) + return _attachment + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索工单附件数据的基础方法。 + + 支持字段: + - 精确匹配:task_id, detail_id, type, created_by, updated_by + - 模糊匹配:name, attach_url + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'name': 'asc'} + :key int task_id: 精确匹配关联工单主表ID + :key int detail_id: 精确匹配关联工单详情ID + :key str name: 模糊匹配附件名称 + :key str attach_url: 模糊匹配附件地址 + :key str type: 精确匹配附件类型 + :key str created_by: 精确匹配创建者 + :key str updated_by: 精确匹配更新者 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.name.key: '%{}%', + cls.attach_url.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_id, cls.detail_id, cls.name) + + _attachment_df = await cls.query_as_df(_data_query) + if not _attachment_df.empty: + _attachment_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _attachment_df[cls.id.key] = _attachment_df[cls.id.key].astype(str) + _attachment_df[cls.task_id.key] = _attachment_df[cls.task_id.key].astype(str) + _attachment_df[cls.detail_id.key] = _attachment_df[cls.detail_id.key].astype(str) + + return _attachment_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索工单附件数据,返回分页格式数据。 + """ + _attachment_df, _paging = await cls.search_base(** kwargs) + return { + 'total': _paging.row_count, + 'rows': _attachment_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_by_unique_key(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。 + 根据 task_id + detail_id + name 组合判断唯一性。 + + :param data_df: 输入的数据框架,必须包含 task_id、detail_id、name 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录(补充id字段) + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 校验必要列 + required_cols = ['task_id', 'detail_id', 'name'] + missing_cols = [col for col in required_cols if col not in data_df.columns] + if missing_cols: + echo_log(f"错误:exists_by_unique_key 要求输入数据必须包含 {missing_cols} 列") + return pd.DataFrame(), data_df.copy() + + # 去重并构建查询条件 + unique_keys = data_df[required_cols].drop_duplicates() + if unique_keys.empty: + return pd.DataFrame(), data_df.copy() + + # 构建批量查询条件 + _query_conditions = [] + for _, row in unique_keys.iterrows(): + _query_conditions.append( + (cls.task_id == row['task_id']) & + (cls.detail_id == row['detail_id']) & + (cls.name == row['name']) + ) + + if not _query_conditions: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的记录 + _query = select(cls.id, cls.task_id, cls.detail_id, cls.name).where( + * _query_conditions + ) + exist_keys_df = await cls.query_as_df(_query) + + if exist_keys_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建唯一键映射(task_id|detail_id|name -> id) + exist_keys_df['unique_key'] = exist_keys_df.apply( + lambda x: f"{x['task_id']}|{x['detail_id']}|{x['name']}", axis=1 + ) + data_df['unique_key'] = data_df.apply( + lambda x: f"{x['task_id']}|{x['detail_id']}|{x['name']}", axis=1 + ) + key_to_id_map = dict(zip(exist_keys_df['unique_key'], exist_keys_df['id'])) + + # 划分存在/不存在的记录 + mask_exists = data_df['unique_key'].isin(exist_keys_df['unique_key']) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df['unique_key'].map(key_to_id_map) + latest_df = data_df[~mask_exists].copy() + + # 清理临时列 + for df in [exists_df, latest_df]: + if 'unique_key' in df.columns: + df.drop('unique_key', axis=1, inplace=True) + + return exists_df, latest_df + + +@register_swagger_model +class GovcTaskAttachment(GovcTaskAttachmentBase): + """ + 工单附件模型类(主业务类,完全继承 TD3iGovcTaskAttachment 字段)。 + + --- + description: 市12345工单附件接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + task_id: + description: 关联工单主表ID + type: integer + example: 5001 + required: true + detail_id: + description: 关联工单详情ID + type: integer + example: 6001 + required: true + name: + description: 附件名称 + type: string + example: "现场照片.jpg" + maxLength: 500 + required: true + attach_url: + description: 附件地址 + type: string + example: "/uploads/2024/05/现场照片.jpg" + maxLength: 65535 + required: true + type: + description: 附件类型 + type: string + example: "image/jpeg" + maxLength: 64 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + maxLength: 64 + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + maxLength: 64 + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的工单附件记录。 + + 业务流程: + 1. 使用 GovcTaskAttachmentForm 验证表单数据完整性 + 2. 检查是否已存在相同 (task_id+detail_id+name) 的记录(避免重复提交) + 3. 创建新附件对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 附件参数字典 + :return: 新建附件对象 + :rtype: GovcTaskAttachment + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovcTaskAttachmentForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同唯一键的记录 + _existing = await cls.is_exist( + task_id=_form.task_id.data, + detail_id=_form.detail_id.data, + name=_form.name.data + ) + assert _existing is None, "该工单下已存在同名附件,不能重复提交。" + + # 创建对象 + _attachment = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _attachment.created_by = user.username + _attachment.updated_by = user.username + await _attachment.async_save() + return _attachment + + @classmethod + async def delete(cls, attachment_id: Union[str, int]): + """ + 删除工单附件记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param attachment_id: 要删除的附件记录ID + :return: 删除的记录对象 + :rtype: GovcTaskAttachment + :raises AssertionError: 当记录不存在时抛出 + """ + _attachment: cls = await cls.async_find_by_id(attachment_id) + assert _attachment, f"根据 ID {attachment_id} 未找到工单附件记录。" + + _del_query = delete(cls).where(cls.id == _attachment.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log( + f'已删除工单附件记录(工单ID:{_attachment.task_id},附件名称:{_attachment.name},ID:{_attachment.id}).') + return _attachment + + @classmethod + async def modify(cls, attachment_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有工单附件记录。 + + 业务流程: + 1. 处理字符串字段去除首尾空格 + 2. 使用 GovcTaskAttachmentForm 验证表单数据 + 3. 查询原记录 + 4. 验证存在性 + 5. 更新字段并设置更新者 + 6. 保存到数据库 + 7. 返回更新后的对象 + + :param attachment_id: 要修改的附件记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的附件对象 + :rtype: GovcTaskAttachment + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskAttachmentForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _attachment: cls = await cls.async_find_by_id(attachment_id) + assert _attachment, f'查无此工单附件信息。' + + # 更新字段 + _attachment.copy_from_dict(_form.data, skip_none=True).before_save() + _attachment.updated_by = user.username if user else _attachment.updated_by + await _attachment.async_save() + return _attachment + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建工单附件记录(传入数据应为全新记录)。 + + :param data_df: 包含附件数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 补充创建者/更新者信息 + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + # 数据预处理(去空格) + str_cols = ['name', 'attach_url', 'type', 'created_by', 'updated_by'] + for col in str_cols: + if col in data_df.columns: + data_df[col] = data_df[col].apply(lambda x: x.strip() if isinstance(x, str) else x) + + records = data_df.to_dict('records') + attachments = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(attachments) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(attachments)} 条工单附件记录。") + return len(attachments) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有工单附件记录。 + + :param data_df: 包含附件数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 数据预处理(去空格) + str_cols = ['name', 'attach_url', 'type', 'updated_by'] + for col in str_cols: + if col in data_df.columns: + data_df[col] = data_df[col].apply(lambda x: x.strip() if isinstance(x, str) else x) + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings 批量更新 + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单附件记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存工单附件数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架(需包含 task_id、detail_id、name 列) + :param user: 操作用户对象 + :return: (created_count, updated_count) 新建和更新的数量 + """ + # 筛选数据状态(按唯一键判断存在性) + _exists_df, _latest_df = await cls.exists_by_unique_key(data_df) + # 批量创建/更新 + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/govc_task_contact.py b/models/govc_task_contact.py new file mode 100644 index 0000000..c41b028 --- /dev/null +++ b/models/govc_task_contact.py @@ -0,0 +1,498 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete, BIGINT, String, DateTime, Text, ForeignKey, text +from sqlalchemy.orm import relationship, Mapped, mapped_column +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length, Optional + +import models +from models.db_models import TD3iGovcTaskContact +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm +from models.common_model import CommonModel + + +# 表单验证类 +class GovcTaskContactForm(ModelForm): + """ + 工单联系信息表单验证类(完全映射 TD3iGovcTaskContact 字段)。 + + 用于验证和处理市12345工单联系信息的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task_contact 的字段结构。 + """ + + # 基础信息 + id = IntegerField('记录ID') + task_id = IntegerField('关联工单主表ID', validators=[Optional()]) # 非空在数据库层约束 + link_person = StringField('联系人', validators=[Length(max=128, message='联系人长度不能超过128字符')]) + link_status = StringField('联系类型', validators=[Length(max=64, message='联系类型长度不能超过64字符')]) + link_date = DateTimeField('联系时间', validators=[Optional()], format='%Y-%m-%d %H:%M:%S') + link_content = TextAreaField('联系内容') # Text类型无长度限制 + created_at = DateTimeField('创建时间', validators=[Optional()], format='%Y-%m-%d %H:%M:%S') + created_by = StringField('创建者', validators=[Length(max=64, message='创建者长度不能超过64字符')]) + updated_at = DateTimeField('更新时间', validators=[Optional()], format='%Y-%m-%d %H:%M:%S') + updated_by = StringField('更新者', validators=[Length(max=64, message='更新者长度不能超过64字符')]) + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +# 基础业务类 +class GovcTaskContactBase(TD3iGovcTaskContact, CommonModel): + """ + 工单联系信息基础类(完全映射 TD3iGovcTaskContact 字段)。 + + 继承自数据库模型 TD3iGovcTaskContact 和通用模型 CommonModel。 + 封装所有与工单联系信息相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'task_id': 'task_id', + 'link_person': 'link_person', + 'link_status': 'link_status', + 'link_date': 'link_date', + 'link_content': 'link_content', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 工单联系信息字段映射 + """ + + @classmethod + async def is_exist(cls, task_id: int, link_person: str = None, link_date: datetime.datetime = None): + """ + 检查工单联系记录是否已存在(根据工单ID+联系人+联系时间组合判断)。 + + :param task_id: 关联工单主表ID + :param link_person: 联系人(可选) + :param link_date: 联系时间(可选) + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.task_id == task_id) + if link_person: + _query = _query.where(cls.link_person == link_person) + if link_date: + _query = _query.where(cls.link_date == link_date) + _contact: cls = await cls.query_first(_query) + return _contact + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索工单联系信息的基础方法。 + + 支持字段: + - 精确匹配:task_id, link_person, link_status + - 模糊匹配:link_content + - 时间范围:link_date (支持 link_date_start/link_date_end) + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_id': 'asc'} + :key int task_id: 精确匹配工单ID + :key str link_person: 精确匹配联系人 + :key str link_status: 精确匹配联系类型 + :key str link_content: 模糊匹配联系内容 + :key str link_date_start: 联系时间起始(格式:%Y-%m-%d %H:%M:%S) + :key str link_date_end: 联系时间结束(格式:%Y-%m-%d %H:%M:%S) + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.link_content.key: '%{}%', + } + + # 基础查询条件 + _wheres = cls.search_wheres(likes=_name_likes, **kwargs) + + # 时间范围条件 + if kwargs.get('link_date_start'): + _link_date_start = datetime.datetime.strptime(kwargs['link_date_start'], '%Y-%m-%d %H:%M:%S') + _wheres.append(cls.link_date >= _link_date_start) + if kwargs.get('link_date_end'): + _link_date_end = datetime.datetime.strptime(kwargs['link_date_end'], '%Y-%m-%d %H:%M:%S') + _wheres.append(cls.link_date <= _link_date_end) + + _query = select(cls).where(*_wheres).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query + + # 排序处理 + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_id.desc(), cls.link_date.desc()) + + _contact_df = await cls.query_as_df(_data_query) + if not _contact_df.empty: + _contact_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _contact_df[cls.id.key] = _contact_df[cls.id.key].astype(str) + # 时间字段格式化 + for dt_field in ['link_date', 'created_at', 'updated_at']: + if dt_field in _contact_df.columns: + _contact_df[dt_field] = _contact_df[dt_field].dt.strftime('%Y-%m-%d %H:%M:%S') + + return _contact_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索工单联系信息,返回分页格式数据。 + """ + _contact_df, _paging = await cls.search_base(** kwargs) + return { + 'total': _paging.row_count if _paging else len(_contact_df), + 'rows': _contact_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number if _paging else 1, + 'page_count': _paging.page_count if _paging else 1, + 'page_size': _paging.page_size if _paging else len(_contact_df), + }, + } + + @classmethod + async def exists_by_task_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 task_id+link_person+link_date 组合判断。 + + :param data_df: 输入的数据框架,必须包含 task_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 检查必要列 + required_cols = ['task_id', 'link_person', 'link_date'] + missing_cols = [col for col in required_cols if col not in data_df.columns] + if missing_cols: + echo_log(f"警告:exists_by_task_id 缺少必要列 {missing_cols},返回空数据") + return pd.DataFrame(), data_df.copy() + + # 格式化时间字段 + data_df['link_date'] = pd.to_datetime(data_df['link_date'], format='%Y-%m-%d %H:%M:%S', errors='coerce') + + # 构建查询条件 + _exists_records = [] + for _, row in data_df.iterrows(): + _contact = await cls.is_exist( + task_id=row['task_id'], + link_person=row['link_person'], + link_date=row['link_date'] + ) + if _contact: + row['id'] = _contact.id + _exists_records.append(row) + + # 划分数据 + exists_df = pd.DataFrame(_exists_records) if _exists_records else pd.DataFrame() + latest_df = data_df[~data_df.index.isin(exists_df.index)].copy() + + return exists_df, latest_df + + +# 主业务模型类(带Swagger文档) +@register_swagger_model +class GovcTaskContact(GovcTaskContactBase): + """ + 工单联系信息模型类(主业务类,完全继承 TD3iGovcTaskContact 字段)。 + + --- + description: 市12345工单联系信息接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + task_id: + description: 关联工单主表ID + type: integer + example: 5001 + required: true + link_person: + description: 联系人 + type: string + example: "张三" + maxLength: 128 + link_status: + description: 联系类型 + type: string + example: "电话联系" + maxLength: 64 + link_date: + description: 联系时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-05-20 14:30:00" + link_content: + description: 联系内容 + type: string + example: "用户反馈问题已解决,确认无异议" + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-05-20 14:35:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + maxLength: 64 + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-05-21 09:15:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + maxLength: 64 + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的工单联系记录。 + + 业务流程: + 1. 使用 GovcTaskContactForm 验证表单数据完整性 + 2. 检查是否已存在相同 task_id+link_person+link_date 的记录(避免重复提交) + 3. 创建新联系对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 联系信息参数字典 + :return: 新建联系对象 + :rtype: GovcTaskContact + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskContactForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在重复记录 + _existing = await cls.is_exist( + task_id=_form.task_id.data, + link_person=_form.link_person.data, + link_date=_form.link_date.data + ) + assert _existing is None, "该工单的该联系人在该时间的联系记录已存在,不能重复提交。" + + # 创建对象 + _contact = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _contact.created_by = user.username + _contact.updated_by = user.username + await _contact.async_save() + return _contact + + @classmethod + async def delete(cls, contact_id: Union[str, int]): + """ + 删除工单联系记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param contact_id: 要删除的联系记录ID + :return: 删除的记录对象 + :rtype: GovcTaskContact + :raises AssertionError: 当记录不存在时抛出 + """ + _contact: cls = await cls.async_find_by_id(contact_id) + assert _contact, f"根据 ID {contact_id} 未找到工单联系记录。" + + _del_query = delete(cls).where(cls.id == _contact.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除工单联系记录(工单ID:{_contact.task_id},联系人:{_contact.link_person},ID:{_contact.id}).') + return _contact + + @classmethod + async def modify(cls, contact_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有工单联系记录。 + + 业务流程: + 1. 处理字符串字段去除首尾空格 + 2. 使用 GovcTaskContactForm 验证表单数据 + 3. 查询原记录 + 4. 验证存在性 + 5. 更新字段并设置更新者 + 6. 保存到数据库 + 7. 返回更新后的对象 + + :param contact_id: 要修改的联系记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的联系对象 + :rtype: GovcTaskContact + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskContactForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _contact: cls = await cls.async_find_by_id(contact_id) + assert _contact, f'查无此工单联系信息。' + + # 更新字段 + _contact.copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _contact.updated_by = user.username + await _contact.async_save() + return _contact + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建工单联系记录(传入数据应为全新记录)。 + + :param data_df: 包含联系信息的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 设置创建者/更新者 + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + # 转换为模型对象 + records = data_df.to_dict('records') + contacts = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + # 批量保存 + session = cls.get_aio_session() + try: + session.add_all(contacts) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量创建成功:创建 {len(contacts)} 条工单联系记录。") + return len(contacts) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有工单联系记录。 + + :param data_df: 包含联系信息的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 设置更新时间和更新者 + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + # 批量更新 + update_data = data_df.to_dict('records') + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单联系记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存工单联系数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态(已存在/新增) + _exists_df, _latest_df = await cls.exists_by_task_id(data_df) + # 批量创建和更新 + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/govc_task_delay.py b/models/govc_task_delay.py new file mode 100644 index 0000000..86ec633 --- /dev/null +++ b/models/govc_task_delay.py @@ -0,0 +1,477 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, IntegerField, DateTimeField +from wtforms.validators import Length, Optional + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovcTaskDelay +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +# 表单验证类 +class GovcTaskDelayForm(ModelForm): + """ + 工单延迟信息表单验证类(完全映射 TD3iGovcTaskDelay 字段)。 + + 用于验证和处理市12345工单延迟信息的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task_delay 的字段结构。 + """ + + # 基础信息 + id = IntegerField('记录ID') + task_id = IntegerField('关联工单主表ID', validators=[Optional()]) # 外键非空,实际业务中需确保传入 + delay_status = StringField('审核状态', validators=[Length(max=64, message='审核状态长度不能超过64字符')]) + delay_num_unit = StringField('通过时长', validators=[Length(max=64, message='通过时长长度不能超过64字符')]) + delay_type = StringField('申请类型', validators=[Length(max=64, message='申请类型长度不能超过64字符')]) + delay_num = IntegerField('延迟时长') + apply_ou = StringField('申请部门', validators=[Length(max=255, message='申请部门长度不能超过255字符')]) + apply_time = DateTimeField('申请时间', validators=[Optional()], format='%Y-%m-%d %H:%M:%S') + status = IntegerField('提交状态') # 若业务需要可保留,无则删除 + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +# 基础业务类 +class GovcTaskDelayBase(TD3iGovcTaskDelay, CommonModel): + """ + 工单延迟信息基础类(完全映射 TD3iGovcTaskDelay 字段)。 + + 继承自数据库模型 TD3iGovcTaskDelay 和通用模型 CommonModel。 + 封装所有与工单延迟信息相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'task_id': 'task_id', + 'delay_status': 'delay_status', + 'delay_num_unit': 'delay_num_unit', + 'delay_type': 'delay_type', + 'delay_num': 'delay_num', + 'apply_ou': 'apply_ou', + 'apply_time': 'apply_time', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 工单延迟数据映射 + """ + + @classmethod + async def is_exist(cls, task_id: int): + """ + 检查延迟记录是否已存在(根据工单ID)。 + + :param task_id: 关联工单主表ID + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.task_id == task_id) + _delay: cls = await cls.query_first(_query) + return _delay + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索工单延迟数据的基础方法。 + + 支持字段: + - task_id, delay_status, delay_type, delay_num_unit + - 支持模糊匹配:apply_ou + - 支持精确匹配:delay_num, apply_time + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_id': 'asc'} + :key int task_id: 精确匹配工单ID + :key str delay_status: 精确匹配审核状态 + :key str delay_type: 精确匹配申请类型 + :key str delay_num_unit: 精确匹配通过时长 + :key int delay_num: 精确匹配延迟时长 + :key str apply_ou: 模糊匹配申请部门 + :key datetime apply_time: 精确匹配申请时间 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.apply_ou.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_id, cls.id) + + _delay_df = await cls.query_as_df(_data_query) + if not _delay_df.empty: + _delay_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _delay_df[cls.id.key] = _delay_df[cls.id.key].astype(str) + + return _delay_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索工单延迟数据,返回分页格式数据。 + """ + _delay_df, _paging = await cls.search_base(** kwargs) + return { + 'total': _paging.row_count, + 'rows': _delay_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_task_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 task_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 task_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 task_id 列表(去重) + task_ids = data_df[cls.task_id.key].unique().tolist() + if not task_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 task_id + _query = select(cls.id, cls.task_id).where(cls.task_id.in_(task_ids)) + task_ids_df = await cls.query_as_df(_query) + + if task_ids_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 task_id -> id 的映射字典 + task_id_to_id_map = dict(zip(task_ids_df[cls.task_id.key], task_ids_df[cls.id.key])) + + # 根据 task_id 是否在数据库中,划分数据 + mask_exists = data_df[cls.task_id.key].isin(task_ids_df[cls.task_id.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.task_id.key].map(task_id_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class GovcTaskDelay(GovcTaskDelayBase): + """ + 工单延迟信息模型类(主业务类,完全继承 TD3iGovcTaskDelay 字段)。 + + --- + description: 市12345工单延迟信息接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + task_id: + description: 关联工单主表ID + type: integer + example: 2001 + required: true + delay_status: + description: 审核状态 + type: string + example: "审核通过" + maxLength: 64 + delay_num_unit: + description: 通过时长 + type: string + example: "小时" + maxLength: 64 + delay_type: + description: 申请类型 + type: string + example: "紧急延迟" + maxLength: 64 + delay_num: + description: 延迟时长 + type: integer + example: 24 + apply_ou: + description: 申请部门 + type: string + example: "城市管理局" + maxLength: 255 + apply_time: + description: 申请时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的工单延迟记录。 + + 业务流程: + 1. 使用 GovcTaskDelayForm 验证表单数据完整性 + 2. 检查是否已存在相同 task_id 的记录(避免重复提交) + 3. 创建新延迟对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 延迟参数字典 + :return: 新建延迟对象 + :rtype: GovcTaskDelay + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovcTaskDelayForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 task_id 的记录 + _existing = await cls.is_exist(_form.task_id.data) + assert _existing is None, f"工单ID {_form.task_id.data} 已存在延迟记录,不能重复提交。" + + # 创建对象 + _delay = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _delay.created_by = user.username + _delay.updated_by = user.username + await _delay.async_save() + return _delay + + @classmethod + async def delete(cls, delay_id: Union[str, int]): + """ + 删除工单延迟记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param delay_id: 要删除的延迟记录ID + :return: 删除的记录对象 + :rtype: GovcTaskDelay + :raises AssertionError: 当记录不存在时抛出 + """ + _delay: cls = await cls.async_find_by_id(delay_id) + assert _delay, f"根据 ID {delay_id} 未找到工单延迟记录。" + + _del_query = delete(cls).where(cls.id == _delay.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除工单延迟记录(工单ID:{_delay.task_id},ID:{_delay.id}).') + return _delay + + @classmethod + async def modify(cls, delay_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有工单延迟记录。 + + 业务流程: + 1. 将 delay_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 GovcTaskDelayForm 验证表单数据 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新字段并设置更新者 + 7. 保存到数据库 + 8. 返回更新后的对象 + + :param delay_id: 要修改的延迟记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的延迟对象 + :rtype: GovcTaskDelay + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskDelayForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _delay: cls = await cls.async_find_by_id(delay_id) + assert _delay, f'查无此工单延迟信息。' + + # 更新字段 + _delay.copy_from_dict(_form.data, skip_none=True).before_save() + _delay.updated_by = user.username + await _delay.async_save() + return _delay + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建工单延迟记录(传入数据应为全新记录)。 + + :param data_df: 包含延迟数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + delays = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(delays) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(delays)} 条工单延迟记录。") + return len(delays) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有工单延迟记录。 + + :param data_df: 包含延迟数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单延迟记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存工单延迟数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await GovcTaskDelay.exists_task_id(data_df) + # 保存到数据库 + _created_count = await GovcTaskDelay.create_batch(_latest_df, user) + _updated_count = await GovcTaskDelay.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/govc_task_department_feedback.py b/models/govc_task_department_feedback.py new file mode 100644 index 0000000..f2751db --- /dev/null +++ b/models/govc_task_department_feedback.py @@ -0,0 +1,545 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length, Optional + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovcTaskDepartmentFeedback +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovcTaskDeptFeedbackForm(ModelForm): + """ + 部门处置反馈表单验证类(完全映射 TD3iGovcTaskDepartmentFeedback 字段)。 + + 用于验证和处理市12345部门处置信息的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task_department_feedback 的字段结构。 + """ + # 基础信息 + id = IntegerField('主键ID') + task_id = IntegerField('关联工单主表ID', validators=[Optional()]) # 实际应根据业务调整必填性 + zxhf_info = TextAreaField('专项回复信息') + back_info = TextAreaField('退回信息') + sign_time_bf = DateTimeField('签收时限', validators=[Optional()]) + operation_text = StringField('操作描述', validators=[Length(max=255, message='操作描述长度不能超过255字符')]) + opinion = TextAreaField('反馈意见') + unit = StringField('承办单位', validators=[Length(max=255, message='承办单位长度不能超过255字符')]) + finish_time_bf = DateTimeField('反馈时限', validators=[Optional()]) + person = StringField('承办人', validators=[Length(max=128, message='承办人长度不能超过128字符')]) + sign_time = DateTimeField('签收时间', validators=[Optional()]) + name = StringField('负责人', validators=[Length(max=128, message='负责人长度不能超过128字符')]) + tel = StringField('联系电话', validators=[Length(max=64, message='联系电话长度不能超过64字符')]) + time = DateTimeField('反馈时间', validators=[Optional()]) + department = StringField('部门', validators=[Length(max=255, message='部门长度不能超过255字符')]) + status = IntegerField('状态') + back_time_bf = DateTimeField('拒绝时限', validators=[Optional()]) + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovcTaskDeptFeedbackBase(TD3iGovcTaskDepartmentFeedback, CommonModel): + """ + 部门处置反馈基础类(完全映射 TD3iGovcTaskDepartmentFeedback 字段)。 + + 继承自数据库模型 TD3iGovcTaskDepartmentFeedback 和通用模型 CommonModel。 + 封装所有与部门处置反馈相关的通用操作方法。 + """ + FieldMapping = { + 'id': 'id', + 'task_id': 'task_id', + 'zxhf_info': 'zxhfinfo', + 'back_info':'backinfo', + 'sign_time_bf': 'signtimebf', + 'operation_text': 'operationText', + 'opinion': 'opinion', + 'unit': 'unit', + 'finish_time_bf': 'finishtimebf', + 'person': 'person', + 'sign_time': 'signtime', + 'name': 'name', + 'tel': 'tel', + 'time': 'time', + 'department': 'department', + 'status': 'status', + 'back_time_bf': 'backtimebf', + } + """部门处置反馈数据映射""" + + @classmethod + async def is_exist(cls, task_id: int): + """ + 检查部门处置反馈记录是否已存在(根据工单ID)。 + + :param task_id: 关联工单主表ID + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.task_id == task_id) + _feedback: cls = await cls.query_first(_query) + return _feedback + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索部门处置反馈数据的基础方法。 + + 支持字段: + - task_id, status, unit, department(精确匹配) + - 支持模糊匹配:operation_text, opinion, person, name + - 支持时间范围:sign_time_bf, finish_time_bf, sign_time, time, back_time_bf + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_id': 'asc'} + :key int task_id: 精确匹配工单ID + :key int status: 精确匹配状态 + :key str unit: 精确匹配承办单位 + :key str department: 精确匹配部门 + :key str operation_text: 模糊匹配操作描述 + :key str opinion: 模糊匹配反馈意见 + :key str person: 模糊匹配承办人 + :key str name: 模糊匹配负责人 + :key str sign_time_bf_start: 签收时限开始时间 + :key str sign_time_bf_end: 签收时限结束时间 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.operation_text.key: '%{}%', + cls.opinion.key: '%{}%', + cls.person.key: '%{}%', + cls.name.key: '%{}%', + } + + # 构建基础查询 + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + # 处理时间范围查询(示例:签收时限) + if kwargs.get('sign_time_bf_start'): + _query = _query.where(cls.sign_time_bf >= kwargs['sign_time_bf_start']) + if kwargs.get('sign_time_bf_end'): + _query = _query.where(cls.sign_time_bf <= kwargs['sign_time_bf_end']) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query + + # 处理排序 + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_id, cls.id) + + # 执行查询并处理结果 + _feedback_df = await cls.query_as_df(_data_query) + if not _feedback_df.empty: + _feedback_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _feedback_df[cls.id.key] = _feedback_df[cls.id.key].astype(str) + # 处理时间字段格式化 + datetime_fields = ['sign_time_bf', 'finish_time_bf', 'sign_time', 'time', 'back_time_bf', 'created_at', + 'updated_at'] + for field in datetime_fields: + if field in _feedback_df.columns: + _feedback_df[field] = _feedback_df[field].dt.strftime('%Y-%m-%d %H:%M:%S').fillna('') + + return _feedback_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索部门处置反馈数据,返回分页格式数据。 + """ + _feedback_df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _feedback_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_task_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 task_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 task_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 task_id 列表(去重) + task_ids = data_df[cls.task_id.key].unique().tolist() + if not task_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 task_id + _query = select(cls.id, cls.task_id).where(cls.task_id.in_(task_ids)) + task_ids_df = await cls.query_as_df(_query) + + if task_ids_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 task_id -> id 的映射字典 + task_id_to_id_map = dict(zip(task_ids_df[cls.task_id.key], task_ids_df[cls.id.key])) + + # 根据 task_id 是否在数据库中,划分数据 + mask_exists = data_df[cls.task_id.key].isin(task_ids_df[cls.task_id.key]) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df[cls.task_id.key].map(task_id_to_id_map) + latest_df = data_df[~mask_exists].copy() + + return exists_df, latest_df + + +@register_swagger_model +class GovcTaskDeptFeedback(GovcTaskDeptFeedbackBase): + """ + 部门处置反馈模型类(主业务类,完全继承 TD3iGovcTaskDepartmentFeedback 字段)。 + + --- + description: 市12345部门处置信息接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + task_id: + description: 关联工单主表ID + type: integer + example: 20240501001 + zxhf_info: + description: 专项回复信息 + type: string + example: "该工单已完成处置,符合要求" + sign_time_bf: + description: 签收时限 + type: string + format: date-time + example: "2024-05-01 10:00:00" + operation_text: + description: 操作描述 + type: string + example: "接收工单并开始处置" + maxLength: 255 + opinion: + description: 反馈意见 + type: string + example: "经核查,该问题已妥善解决" + unit: + description: 承办单位 + type: string + example: "XX市城市管理局" + maxLength: 255 + finish_time_bf: + description: 反馈时限 + type: string + format: date-time + example: "2024-05-05 18:00:00" + person: + description: 承办人 + type: string + example: "张三" + maxLength: 128 + sign_time: + description: 签收时间 + type: string + format: date-time + example: "2024-05-01 10:10:00" + name: + description: 负责人 + type: string + example: "李四" + maxLength: 128 + tel: + description: 联系电话 + type: string + example: "13800138000" + maxLength: 64 + time: + description: 反馈时间 + type: string + format: date-time + example: "2024-05-05 17:30:00" + department: + description: 部门 + type: string + example: "市容管理科" + maxLength: 255 + status: + description: 状态(1:已签收,2:处置中,3:已反馈,4:已拒绝) + type: integer + example: 3 + back_time_bf: + description: 拒绝时限 + type: string + format: date-time + example: "2024-05-03 18:00:00" + created_at: + description: 创建时间 + type: string + format: date-time + example: "2024-05-01 10:00:00" + readOnly: true + created_by: + description: 创建者 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间 + type: string + format: date-time + example: "2024-05-05 17:30:00" + readOnly: true + updated_by: + description: 修改者 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的部门处置反馈记录。 + + 业务流程: + 1. 使用 GovcTaskDeptFeedbackForm 验证表单数据完整性 + 2. 检查是否已存在相同 task_id 的记录(避免重复提交) + 3. 创建新反馈对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 反馈参数字典 + :return: 新建反馈对象 + :rtype: GovcTaskDeptFeedback + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskDeptFeedbackForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 task_id 的记录 + if _form.task_id.data: + _existing = await cls.is_exist(_form.task_id.data) + assert _existing is None, f"工单ID {_form.task_id.data} 已存在处置反馈记录,不能重复提交。" + + # 创建对象 + _feedback = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _feedback.created_by = user.username + _feedback.updated_by = user.username + await _feedback.async_save() + return _feedback + + @classmethod + async def delete(cls, feedback_id: Union[str, int]): + """ + 删除部门处置反馈记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param feedback_id: 要删除的反馈记录ID + :return: 删除的记录对象 + :rtype: GovcTaskDeptFeedback + :raises AssertionError: 当记录不存在时抛出 + """ + _feedback: cls = await cls.async_find_by_id(feedback_id) + assert _feedback, f"根据 ID {feedback_id} 未找到部门处置反馈记录。" + + _del_query = delete(cls).where(cls.id == _feedback.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除部门处置反馈记录(工单ID:{_feedback.task_id},ID:{_feedback.id}).') + return _feedback + + @classmethod + async def modify(cls, feedback_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有部门处置反馈记录。 + + 业务流程: + 1. 处理字符串字段去除首尾空格 + 2. 使用 GovcTaskDeptFeedbackForm 验证表单数据 + 3. 查询原记录 + 4. 验证存在性 + 5. 更新字段并设置更新者 + 6. 保存到数据库 + 7. 返回更新后的对象 + + :param feedback_id: 要修改的反馈记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的反馈对象 + :rtype: GovcTaskDeptFeedback + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskDeptFeedbackForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _feedback: cls = await cls.async_find_by_id(feedback_id) + assert _feedback, f'查无此部门处置反馈信息。' + + # 更新字段 + _feedback.copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _feedback.updated_by = user.username + await _feedback.async_save() + return _feedback + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建部门处置反馈记录(传入数据应为全新记录)。 + + :param data_df: 包含反馈数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 设置创建者/更新者信息 + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + # 转换为对象列表 + records = data_df.to_dict('records') + feedbacks = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + # 批量保存 + session = cls.get_aio_session() + try: + session.add_all(feedbacks) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量创建成功:创建 {len(feedbacks)} 条部门处置反馈记录。") + return len(feedbacks) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有部门处置反馈记录。 + + :param data_df: 包含反馈数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 设置更新信息 + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 批量更新 + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条部门处置反馈记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存部门处置反馈数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态(按task_id判断存在性) + _exists_df, _latest_df = await cls.exists_task_id(data_df) + # 批量创建和更新 + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count diff --git a/models/govc_task_detail.py b/models/govc_task_detail.py new file mode 100644 index 0000000..dde538b --- /dev/null +++ b/models/govc_task_detail.py @@ -0,0 +1,626 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete, text +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length, Optional + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovcTaskDetail +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovcTaskDetailForm(ModelForm): + """ + 市12345工单详情表单验证类(完全映射 TD3iGovcTaskDetail 字段)。 + + 用于验证和处理市12345工单详情的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task_detail 的字段结构。 + """ + + # 基础信息 + id = IntegerField('主键ID') + task_id = IntegerField('关联工单主表ID', validators=[Optional()]) + note = TextAreaField('备注') + purpose = StringField('诉求目的', validators=[Length(max=255, message='诉求目的长度不能超过255字符')]) + type_level = StringField('诉求类型等级', validators=[Length(max=64, message='诉求类型等级长度不能超过64字符')]) + type = StringField('诉求类型', validators=[Length(max=64, message='诉求类型长度不能超过64字符')]) + sign_time_bf = DateTimeField('签收时限', validators=[Optional()]) + matter = StringField('窗口进驻事项', validators=[Length(max=32, message='窗口进驻事项长度不能超过32字符')]) + case_form_type = StringField('个性化表单类型', validators=[Length(max=64, message='个性化表单类型长度不能超过64字符')]) + content = TextAreaField('诉求内容') + handle_ou = StringField('处办单位', validators=[Length(max=255, message='处办单位长度不能超过255字符')]) + urgency = IntegerField('是否紧急', validators=[Optional()]) + sj_handle_ou = StringField('涉及单位', validators=[Length(max=255, message='涉及单位长度不能超过255字符')]) + ccb_content = TextAreaField('催补撤内容') + is_secret = StringField('是否保密', validators=[Length(max=32, message='是否保密长度不能超过32字符')]) + theme = StringField('主题工单', validators=[Length(max=32, message='主题工单长度不能超过32字符')]) + attribute = StringField('归口类型', validators=[Length(max=255, message='归口类型长度不能超过255字符')]) + zqt = StringField('企业名称', validators=[Length(max=255, message='企业名称长度不能超过255字符')]) + address = StringField('详细地址', validators=[Length(max=500, message='详细地址长度不能超过500字符')]) + seng_again_num = IntegerField('再交办次数', validators=[Optional()]) + epidemic = StringField('是否疫情工单', validators=[Length(max=32, message='是否疫情工单长度不能超过32字符')]) + has_ccb = IntegerField('是否有催补撤信息', validators=[Optional()]) + way = StringField('受理方式', validators=[Length(max=64, message='受理方式长度不能超过64字符')]) + return_visit = StringField('回访类型', validators=[Length(max=64, message='回访类型长度不能超过64字符')]) + finish_time_bf = DateTimeField('反馈时限', validators=[Optional()]) + is_email = IntegerField('是否邮箱提交', validators=[Optional()]) + time = DateTimeField('事发时间', validators=[Optional()]) + called_tx = StringField('被叫号码', validators=[Length(max=64, message='被叫号码长度不能超过64字符')]) + back_time_bf = DateTimeField('拒绝时限', validators=[Optional()]) + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovcTaskDetailBase(TD3iGovcTaskDetail, CommonModel): + """ + 市12345工单详情基础类(完全映射 TD3iGovcTaskDetail 字段)。 + + 继承自数据库模型 TD3iGovcTaskDetail 和通用模型 CommonModel。 + 封装所有与工单详情相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'task_id': 'task_id', + 'note': 'note', + 'purpose': 'purpose', + 'type_level': 'type_level', + 'type': 'type', + 'sign_time_bf': 'sign_time_bf', + 'matter': 'matter', + 'case_form_type': 'case_form_type', + 'content': 'content', + 'handle_ou': 'handle_ou', + 'urgency': 'urgency', + 'sj_handle_ou': 'sj_handle_ou', + 'ccb_content': 'ccb_content', + 'is_secret': 'is_secret', + 'theme': 'theme', + 'attribute': 'attribute', + 'zqt': 'zqt', + 'address': 'address', + 'seng_again_num': 'seng_again_num', + 'epidemic': 'epidemic', + 'has_ccb': 'has_ccb', + 'way': 'way', + 'return_visit': 'return_visit', + 'finish_time_bf': 'finish_time_bf', + 'is_email': 'is_email', + 'time': 'time', + 'called_tx': 'called_tx', + 'back_time_bf': 'back_time_bf', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 工单详情数据映射 + """ + + @classmethod + async def is_exist(cls, task_id: int): + """ + 检查工单详情记录是否已存在(根据关联工单主表ID)。 + + :param task_id: 关联工单主表ID + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.task_id == task_id) + _detail: cls = await cls.query_first(_query) + return _detail + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索工单详情数据的基础方法。 + + 支持字段: + - task_id, type_level, type, urgency, epidemic, has_ccb, way, return_visit, is_email + - 支持模糊匹配:note, purpose, content, handle_ou, sj_handle_ou, ccb_content, address + - 支持精确匹配:matter, case_form_type, is_secret, theme, attribute, zqt, called_tx + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_id': 'asc'} + :key int task_id: 精确匹配关联工单主表ID + :key str type_level: 精确匹配诉求类型等级 + :key str type: 精确匹配诉求类型 + :key int urgency: 精确匹配是否紧急 + :key str epidemic: 精确匹配是否疫情工单 + :key int has_ccb: 精确匹配是否有催补撤信息 + :key str way: 精确匹配受理方式 + :key str return_visit: 精确匹配回访类型 + :key int is_email: 精确匹配是否邮箱提交 + :key str note: 模糊匹配备注 + :key str purpose: 模糊匹配诉求目的 + :key str content: 模糊匹配诉求内容 + :key str handle_ou: 模糊匹配处办单位 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.note.key: '%{}%', + cls.purpose.key: '%{}%', + cls.content.key: '%{}%', + cls.handle_ou.key: '%{}%', + cls.sj_handle_ou.key: '%{}%', + cls.ccb_content.key: '%{}%', + cls.address.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_id, cls.id) + + _detail_df = await cls.query_as_df(_data_query) + if not _detail_df.empty: + _detail_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _detail_df[cls.id.key] = _detail_df[cls.id.key].astype(str) + + return _detail_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索工单详情数据,返回分页格式数据。 + """ + _detail_df, _paging = await cls.search_base(** kwargs) + return { + 'total': _paging.row_count, + 'rows': _detail_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_task_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 task_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 task_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 task_id 列表(去重) + task_ids = data_df[cls.task_id.key].unique().tolist() + if not task_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 task_id + _query = select(cls.id, cls.task_id).where(cls.task_id.in_(task_ids)) + task_ids_df = await cls.query_as_df(_query) + + if task_ids_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 task_id -> id 的映射字典 + task_id_to_id_map = dict(zip(task_ids_df[cls.task_id.key], task_ids_df[cls.id.key])) + + # 根据 task_id 是否在数据库中,划分数据 + mask_exists = data_df[cls.task_id.key].isin(task_ids_df[cls.task_id.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.task_id.key].map(task_id_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class GovcTaskDetail(GovcTaskDetailBase): + """ + 市12345工单详情模型类(主业务类,完全继承 TD3iGovcTaskDetail 字段)。 + + --- + description: 市12345工单详情接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + task_id: + description: 关联工单主表ID + type: integer + example: 2001 + note: + description: 备注 + type: string + example: "该工单需加急处理" + purpose: + description: 诉求目的 + type: string + example: "投诉小区物业不作为" + maxLength: 255 + type_level: + description: 诉求类型等级 + type: string + example: "一级诉求" + maxLength: 64 + type: + description: 诉求类型 + type: string + example: "投诉" + maxLength: 64 + sign_time_bf: + description: 签收时限 + type: string + format: date-time + example: "2024-01-15 10:30:00" + matter: + description: 窗口进驻事项 + type: string + example: "民生服务" + maxLength: 32 + case_form_type: + description: 个性化表单类型 + type: string + example: "通用投诉表单" + maxLength: 64 + content: + description: 诉求内容 + type: string + example: "小区垃圾堆积无人清理,物业未及时处理" + handle_ou: + description: 处办单位 + type: string + example: "XX街道办事处" + maxLength: 255 + urgency: + description: 是否紧急(1:紧急,0:不紧急) + type: integer + example: 1 + sj_handle_ou: + description: 涉及单位 + type: string + example: "XX物业公司,XX社区" + maxLength: 255 + ccb_content: + description: 催补撤内容 + type: string + example: "请尽快补充工单相关证明材料" + is_secret: + description: 是否保密 + type: string + example: "0" + maxLength: 32 + theme: + description: 主题工单 + type: string + example: "民生保障" + maxLength: 32 + attribute: + description: 归口类型 + type: string + example: "城乡建设" + maxLength: 255 + zqt: + description: 企业名称 + type: string + example: "XX物业服务有限公司" + maxLength: 255 + address: + description: 详细地址 + type: string + example: "XX市XX区XX街道XX小区1号楼" + maxLength: 500 + seng_again_num: + description: 再交办次数 + type: integer + example: 2 + epidemic: + description: 是否疫情工单 + type: string + example: "0" + maxLength: 32 + has_ccb: + description: 是否有催补撤信息(1:有,0:无) + type: integer + example: 1 + way: + description: 受理方式 + type: string + example: "电话受理" + maxLength: 64 + return_visit: + description: 回访类型 + type: string + example: "电话回访" + maxLength: 64 + finish_time_bf: + description: 反馈时限 + type: string + format: date-time + example: "2024-01-20 17:00:00" + is_email: + description: 是否邮箱提交(1:是,0:否) + type: integer + example: 0 + time: + description: 事发时间 + type: string + format: date-time + example: "2024-01-14 09:15:00" + called_tx: + description: 被叫号码 + type: string + example: "021-12345678" + maxLength: 64 + back_time_bf: + description: 拒绝时限 + type: string + format: date-time + example: "2024-01-16 12:00:00" + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的工单详情记录。 + + 业务流程: + 1. 使用 GovcTaskDetailForm 验证表单数据完整性 + 2. 检查是否已存在相同 task_id 的记录(避免重复提交) + 3. 创建新工单详情对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 工单详情参数字典 + :return: 新建工单详情对象 + :rtype: GovcTaskDetail + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovcTaskDetailForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 task_id 的记录 + _existing = await cls.is_exist(_form.task_id.data) + assert _existing is None, "该工单已存在详情记录,不能重复提交。" + + # 创建对象 + _detail = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _detail.created_by = user.username + _detail.updated_by = user.username + await _detail.async_save() + return _detail + + @classmethod + async def delete(cls, detail_id: Union[str, int]): + """ + 删除工单详情记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param detail_id: 要删除的工单详情记录ID + :return: 删除的记录对象 + :rtype: GovcTaskDetail + :raises AssertionError: 当记录不存在时抛出 + """ + _detail: cls = await cls.async_find_by_id(detail_id) + assert _detail, f"根据 ID {detail_id} 未找到工单详情记录。" + + _del_query = delete(cls).where(cls.id == _detail.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除工单详情记录(工单ID:{_detail.task_id},ID:{_detail.id}).') + return _detail + + @classmethod + async def modify(cls, detail_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有工单详情记录。 + + 业务流程: + 1. 将 detail_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 GovcTaskDetailForm 验证表单数据 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新字段并设置更新者 + 7. 保存到数据库 + 8. 返回更新后的对象 + + :param detail_id: 要修改的工单详情记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的工单详情对象 + :rtype: GovcTaskDetail + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskDetailForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _detail: cls = await cls.async_find_by_id(detail_id) + assert _detail, f'查无此工单详情信息。' + + # 更新字段 + _detail.copy_from_dict(_form.data, skip_none=True).before_save() + _detail.updated_by = user.username + await _detail.async_save() + return _detail + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建工单详情记录(传入数据应为全新记录)。 + + :param data_df: 包含工单详情数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + details = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(details) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(details)} 条工单详情记录。") + return len(details) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有工单详情记录。 + + :param data_df: 包含工单详情数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单详情记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存工单详情数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await GovcTaskDetail.exists_task_id(data_df) + # 保存到数据库 + _created_count = await GovcTaskDetail.create_batch(_latest_df, user) + _updated_count = await GovcTaskDetail.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/govc_task_finish.py b/models/govc_task_finish.py new file mode 100644 index 0000000..d78f593 --- /dev/null +++ b/models/govc_task_finish.py @@ -0,0 +1,493 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length, Optional + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovcTaskFinish +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovcTaskFinishForm(ModelForm): + """ + 工单办结信息表单验证类(完全映射 TD3iGovcTaskFinish 字段)。 + + 用于验证和处理市12345工单办结信息的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task_finish 的字段结构。 + """ + + # 基础信息 + id = IntegerField('记录ID') + task_id = IntegerField('关联工单主表ID', validators=[Optional()]) # 外键,非空由数据库约束 + bj_result = TextAreaField('办结意见', validators=[Length(max=65535, message='办结意见长度不能超过65535字符')]) + evl_result = StringField('结果满意度', validators=[Length(max=64, message='结果满意度长度不能超过64字符')]) + replay_person = StringField('回访人', validators=[Length(max=128, message='回访人长度不能超过128字符')]) + processing_results = StringField('处理结果', validators=[Length(max=255, message='处理结果长度不能超过255字符')]) + solve_situation = StringField('解决情况', validators=[Length(max=64, message='解决情况长度不能超过64字符')]) + replay_time = DateTimeField('回访时间', validators=[Optional()]) + evl_style = StringField('态度满意度', validators=[Length(max=64, message='态度满意度长度不能超过64字符')]) + is_citizen = IntegerField('是否市民', validators=[Optional()]) + status = IntegerField('提交状态') # 兼容通用状态字段,若有需要可调整 + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovcTaskFinishBase(TD3iGovcTaskFinish, CommonModel): + """ + 工单办结信息基础类(完全映射 TD3iGovcTaskFinish 字段)。 + + 继承自数据库模型 TD3iGovcTaskFinish 和通用模型 CommonModel。 + 封装所有与工单办结操作相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'task_id': 'task_id', + 'bj_result': 'bj_result', + 'evl_result': 'evl_result', + 'replay_person': 'replay_person', + 'processing_results': 'processing_results', + 'solve_situation': 'solve_situation', + 'replay_time': 'replay_time', + 'evl_style': 'evl_style', + 'is_citizen': 'is_citizen', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 工单办结数据映射 + """ + + @classmethod + async def is_exist(cls, task_id: int): + """ + 检查工单办结记录是否已存在(根据关联工单主表ID)。 + + :param task_id: 关联工单主表ID + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.task_id == task_id) + _finish: cls = await cls.query_first(_query) + return _finish + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索工单办结数据的基础方法。 + + 支持字段: + - task_id, evl_result, replay_person, solve_situation, evl_style, is_citizen + - 支持模糊匹配:bj_result, processing_results + - 支持精确匹配:is_citizen, evl_result + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_id': 'asc'} + :key int task_id: 精确匹配关联工单主表ID + :key str evl_result: 精确匹配结果满意度 + :key str replay_person: 精确匹配回访人 + :key str solve_situation: 精确匹配解决情况 + :key str evl_style: 精确匹配态度满意度 + :key int is_citizen: 精确匹配是否市民 + :key str bj_result: 模糊匹配办结意见 + :key str processing_results: 模糊匹配处理结果 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.bj_result.key: '%{}%', + cls.processing_results.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_id, cls.id) + + _finish_df = await cls.query_as_df(_data_query) + if not _finish_df.empty: + _finish_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _finish_df[cls.id.key] = _finish_df[cls.id.key].astype(str) + # 处理时间字段格式化 + if cls.replay_time.key in _finish_df.columns: + _finish_df[cls.replay_time.key] = _finish_df[cls.replay_time.key].dt.strftime('%Y-%m-%d %H:%M:%S') + + return _finish_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索工单办结数据,返回分页格式数据。 + """ + _finish_df, _paging = await cls.search_base(** kwargs) + return { + 'total': _paging.row_count, + 'rows': _finish_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_task_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 task_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 task_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 task_id 列表(去重) + task_ids = data_df[cls.task_id.key].unique().tolist() + if not task_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 task_id + _query = select(cls.id, cls.task_id).where(cls.task_id.in_(task_ids)) + task_ids_df = await cls.query_as_df(_query) + + if task_ids_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 task_id -> id 的映射字典 + task_id_to_id_map = dict(zip(task_ids_df[cls.task_id.key], task_ids_df[cls.id.key])) + + # 根据 task_id 是否在数据库中,划分数据 + mask_exists = data_df[cls.task_id.key].isin(task_ids_df[cls.task_id.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.task_id.key].map(task_id_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class GovcTaskFinish(GovcTaskFinishBase): + """ + 工单办结信息模型类(主业务类,完全继承 TD3iGovcTaskFinish 字段)。 + + --- + description: 市12345工单办结接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + task_id: + description: 关联工单主表ID + type: integer + example: 2001 + bj_result: + description: 办结意见 + type: string + example: "经核实,该问题已妥善解决,市民表示满意。" + maxLength: 65535 + evl_result: + description: 结果满意度 + type: string + example: "满意" + maxLength: 64 + replay_person: + description: 回访人 + type: string + example: "张三" + maxLength: 128 + processing_results: + description: 处理结果 + type: string + example: "已协调相关部门完成整改,问题闭环。" + maxLength: 255 + solve_situation: + description: 解决情况 + type: string + example: "完全解决" + maxLength: 64 + replay_time: + description: 回访时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + evl_style: + description: 态度满意度 + type: string + example: "满意" + maxLength: 64 + is_citizen: + description: 是否市民(1:是,0:否) + type: integer + example: 1 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的工单办结记录。 + + 业务流程: + 1. 使用 GovcTaskFinishForm 验证表单数据完整性 + 2. 检查是否已存在相同 task_id 的记录(避免重复提交) + 3. 创建新办结对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 办结参数字典 + :return: 新建办结对象 + :rtype: GovcTaskFinish + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovcTaskFinishForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 task_id 的记录 + _existing = await cls.is_exist(_form.task_id.data) + assert _existing is None, f"工单ID {_form.task_id.data} 已存在办结记录,不能重复提交。" + + # 创建对象 + _finish = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _finish.created_by = user.username + _finish.updated_by = user.username + await _finish.async_save() + return _finish + + @classmethod + async def delete(cls, finish_id: Union[str, int]): + """ + 删除工单办结记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param finish_id: 要删除的办结记录ID + :return: 删除的记录对象 + :rtype: GovcTaskFinish + :raises AssertionError: 当记录不存在时抛出 + """ + _finish: cls = await cls.async_find_by_id(finish_id) + assert _finish, f"根据 ID {finish_id} 未找到工单办结记录。" + + _del_query = delete(cls).where(cls.id == _finish.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除工单办结记录(工单ID:{_finish.task_id},ID:{_finish.id}).') + return _finish + + @classmethod + async def modify(cls, finish_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有工单办结记录。 + + 业务流程: + 1. 将 finish_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 GovcTaskFinishForm 验证表单数据 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新字段并设置更新者 + 7. 保存到数据库 + 8. 返回更新后的对象 + + :param finish_id: 要修改的办结记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的办结对象 + :rtype: GovcTaskFinish + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskFinishForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _finish: cls = await cls.async_find_by_id(finish_id) + assert _finish, f'查无此工单办结信息。' + + # 更新字段 + _finish.copy_from_dict(_form.data, skip_none=True).before_save() + _finish.updated_by = user.username + await _finish.async_save() + return _finish + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建工单办结记录(传入数据应为全新记录)。 + + :param data_df: 包含办结数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + finishes = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(finishes) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(finishes)} 条工单办结记录。") + return len(finishes) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有工单办结记录。 + + :param data_df: 包含办结数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单办结记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存工单办结数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await GovcTaskFinish.exists_task_id(data_df) + # 保存到数据库 + _created_count = await GovcTaskFinish.create_batch(_latest_df, user) + _updated_count = await GovcTaskFinish.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/govc_task_history.py b/models/govc_task_history.py new file mode 100644 index 0000000..fb34103 --- /dev/null +++ b/models/govc_task_history.py @@ -0,0 +1,469 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, IntegerField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovcTaskHistory +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovcTaskHistoryForm(ModelForm): + """ + 历史工单表单验证类(完全映射 TD3iGovcTaskHistory 字段)。 + + 用于验证和处理市12345历史工单的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task_history 的字段结构。 + """ + + # 基础信息 + id = IntegerField('记录ID') + task_id = IntegerField('关联工单主表ID', validators=[], message='关联工单主表ID必须为整数') + history_date = StringField('日期', validators=[Length(max=32, message='日期长度不能超过32字符')]) + serial_num = StringField('历史工单号', validators=[Length(max=64, message='历史工单号长度不能超过64字符')]) + detail_url = StringField('详情页URL', validators=[Length(max=65535, message='详情页URL长度不能超过65535字符')]) + rqst_title = StringField('工单标题', validators=[Length(max=500, message='工单标题长度不能超过500字符')]) + state = StringField('状态', validators=[Length(max=64, message='状态长度不能超过64字符')]) + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovcTaskHistoryBase(TD3iGovcTaskHistory, CommonModel): + """ + 历史工单基础类(完全映射 TD3iGovcTaskHistory 字段)。 + + 继承自数据库模型 TD3iGovcTaskHistory 和通用模型 CommonModel。 + 封装所有与历史工单相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'task_id': 'task_id', + 'history_date': 'history_date', + 'serial_num': 'serial_num', + 'detail_url': 'detail_url', + 'rqst_title': 'rqst_title', + 'state': 'state', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 历史工单数据映射 + """ + + @classmethod + async def is_exist(cls, serial_num: str): + """ + 检查历史工单记录是否已存在(根据历史工单号)。 + + :param serial_num: 历史工单号 + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.serial_num == serial_num) + _history: cls = await cls.query_first(_query) + return _history + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索历史工单数据的基础方法。 + + 支持字段: + - task_id, serial_num, state, history_date + - 支持模糊匹配:rqst_title, detail_url + - 支持精确匹配:task_id, state + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'serial_num': 'asc'} + :key int task_id: 精确匹配关联工单主表ID + :key str serial_num: 精确匹配历史工单号 + :key str history_date: 精确匹配日期 + :key str rqst_title: 模糊匹配工单标题 + :key str detail_url: 模糊匹配详情页URL + :key str state: 精确匹配状态 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.rqst_title.key: '%{}%', + cls.detail_url.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.serial_num, cls.task_id) + + _history_df = await cls.query_as_df(_data_query) + if not _history_df.empty: + _history_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _history_df[cls.id.key] = _history_df[cls.id.key].astype(str) + _history_df[cls.task_id.key] = _history_df[cls.task_id.key].astype(str) + + return _history_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索历史工单数据,返回分页格式数据。 + """ + _history_df, _paging = await cls.search_base(** kwargs) + return { + 'total': _paging.row_count, + 'rows': _history_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_serial_num(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 serial_num 字段判断。 + + :param data_df: 输入的数据框架,必须包含 serial_num 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 serial_num 列表(去重) + serial_nums = data_df[cls.serial_num.key].unique().tolist() + if not serial_nums: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 serial_num + _query = select(cls.id, cls.serial_num).where(cls.serial_num.in_(serial_nums)) + serial_nums_df = await cls.query_as_df(_query) + + if serial_nums_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 serial_num -> id 的映射字典 + serial_num_to_id_map = dict(zip(serial_nums_df[cls.serial_num.key], serial_nums_df[cls.id.key])) + + # 根据 serial_num 是否在数据库中,划分数据 + mask_exists = data_df[cls.serial_num.key].isin(serial_nums_df[cls.serial_num.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.serial_num.key].map(serial_num_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class GovcTaskHistory(GovcTaskHistoryBase): + """ + 历史工单模型类(主业务类,完全继承 TD3iGovcTaskHistory 字段)。 + + --- + description: 市12345历史工单接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + task_id: + description: 关联工单主表ID + type: integer + example: 5001 + maxLength: 20 + history_date: + description: 日期 + type: string + example: "2024-05-01" + maxLength: 32 + serial_num: + description: 历史工单号 + type: string + example: "HIST20240501001" + maxLength: 64 + detail_url: + description: 详情页URL + type: string + example: "http://12345.gov.cn/detail/1001" + maxLength: 65535 + rqst_title: + description: 工单标题 + type: string + example: "市民反映小区垃圾分类问题" + maxLength: 500 + state: + description: 状态 + type: string + example: "已办结" + maxLength: 64 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的历史工单记录。 + + 业务流程: + 1. 使用 GovcTaskHistoryForm 验证表单数据完整性 + 2. 检查是否已存在相同 serial_num 的记录(避免重复提交) + 3. 创建新历史工单对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 历史工单参数字典 + :return: 新建历史工单对象 + :rtype: GovcTaskHistory + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovcTaskHistoryForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 serial_num 的记录 + _existing = await cls.is_exist(_form.serial_num.data) + assert _existing is None, "该历史工单已存在,不能重复提交。" + + # 创建对象 + _history = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _history.created_by = user.username + _history.updated_by = user.username + await _history.async_save() + return _history + + @classmethod + async def delete(cls, history_id: Union[str, int]): + """ + 删除历史工单记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param history_id: 要删除的历史工单记录ID + :return: 删除的记录对象 + :rtype: GovcTaskHistory + :raises AssertionError: 当记录不存在时抛出 + """ + _history: cls = await cls.async_find_by_id(history_id) + assert _history, f"根据 ID {history_id} 未找到历史工单记录。" + + _del_query = delete(cls).where(cls.id == _history.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除历史工单记录(历史工单号:{_history.serial_num},ID:{_history.id}).') + return _history + + @classmethod + async def modify(cls, history_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有历史工单记录。 + + 业务流程: + 1. 将 history_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 GovcTaskHistoryForm 验证表单数据 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新字段并设置更新者 + 7. 保存到数据库 + 8. 返回更新后的对象 + + :param history_id: 要修改的历史工单记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的历史工单对象 + :rtype: GovcTaskHistory + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskHistoryForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _history: cls = await cls.async_find_by_id(history_id) + assert _history, f'查无此历史工单信息。' + + # 更新字段 + _history.copy_from_dict(_form.data, skip_none=True).before_save() + _history.updated_by = user.username + await _history.async_save() + return _history + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建历史工单记录(传入数据应为全新记录)。 + + :param data_df: 包含历史工单数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + histories = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(histories) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(histories)} 条历史工单记录。") + return len(histories) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有历史工单记录。 + + :param data_df: 包含历史工单数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条历史工单记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存历史工单数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await GovcTaskHistory.exists_serial_num(data_df) + # 保存到数据库 + _created_count = await GovcTaskHistory.create_batch(_latest_df, user) + _updated_count = await GovcTaskHistory.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/govc_task_process.py b/models/govc_task_process.py new file mode 100644 index 0000000..eb64bfb --- /dev/null +++ b/models/govc_task_process.py @@ -0,0 +1,519 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length, Optional + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovcTaskProces +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovcTaskProcessForm(ModelForm): + """ + 工单流程追踪表单验证类(完全映射 TD3iGovcTaskProcess 字段)。 + + 用于验证和处理市12345工单流程追踪的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task_process 的字段结构。 + """ + + # 基础信息 + id = IntegerField('记录ID') + task_id = IntegerField('关联工单主表ID', validators=[Optional()]) + handle_time = DateTimeField('办理时间', validators=[Optional()]) + operate_status = StringField('办理状态', validators=[Length(max=128, message='办理状态长度不能超过128字符')]) + activity_guid = StringField('办理环节名称', validators=[Length(max=255, message='办理环节名称长度不能超过255字符')]) + handle_opinion = TextAreaField('办理意见') + is_finish = IntegerField('是否结束') + operator_ou_name = StringField('部门', validators=[Length(max=255, message='部门长度不能超过255字符')]) + is_back = IntegerField('是否回退') + operator_name = StringField('办理人', validators=[Length(max=128, message='办理人长度不能超过128字符')]) + created_at = DateTimeField('创建时间', validators=[Optional()]) + created_by = StringField('创建者', validators=[Length(max=64, message='创建者长度不能超过64字符')]) + updated_at = DateTimeField('更新时间', validators=[Optional()]) + updated_by = StringField('更新者', validators=[Length(max=64, message='更新者长度不能超过64字符')]) + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovcTaskProcessBase(TD3iGovcTaskProces, CommonModel): + """ + 工单流程追踪基础类(完全映射 TD3iGovcTaskProcess 字段)。 + + 继承自数据库模型 TD3iGovcTaskProcess 和通用模型 CommonModel。 + 封装所有与工单流程追踪相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'task_id': 'task_id', + 'handle_time': 'handle_time', + 'operate_status': 'operate_status', + 'activity_guid': 'activity_guid', + 'handle_opinion': 'handle_opinion', + 'is_finish': 'is_finish', + 'operator_ou_name': 'operator_ou_name', + 'is_back': 'is_back', + 'operator_name': 'operator_name', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 工单流程追踪数据映射 + """ + + @classmethod + async def is_exist(cls, task_id: int, activity_guid: str): + """ + 检查工单流程记录是否已存在(根据工单ID+办理环节名称)。 + + :param task_id: 关联工单主表ID + :param activity_guid: 办理环节名称 + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where( + cls.task_id == task_id, + cls.activity_guid == activity_guid + ) + _process: cls = await cls.query_first(_query) + return _process + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索工单流程追踪数据的基础方法。 + + 支持字段: + - task_id, operate_status, is_finish, is_back, operator_name + - 支持模糊匹配:activity_guid, handle_opinion, operator_ou_name + - 支持精确匹配:task_id, is_finish, is_back + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'handle_time': 'desc'} + :key int task_id: 精确匹配关联工单主表ID + :key str operate_status: 精确匹配办理状态 + :key str activity_guid: 模糊匹配办理环节名称 + :key str handle_opinion: 模糊匹配办理意见 + :key int is_finish: 精确匹配是否结束 + :key str operator_ou_name: 模糊匹配部门 + :key int is_back: 精确匹配是否回退 + :key str operator_name: 精确匹配办理人 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.activity_guid.key: '%{}%', + cls.handle_opinion.key: '%{}%', + cls.operator_ou_name.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_id, cls.handle_time.desc()) + + _process_df = await cls.query_as_df(_data_query) + if not _process_df.empty: + _process_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _process_df[cls.id.key] = _process_df[cls.id.key].astype(str) + _process_df[cls.task_id.key] = _process_df[cls.task_id.key].astype(str) + + return _process_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索工单流程追踪数据,返回分页格式数据。 + """ + _process_df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _process_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_task_activity(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 task_id + activity_guid 判断。 + + :param data_df: 输入的数据框架,必须包含 task_id 和 activity_guid 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 校验必要列 + required_cols = [cls.task_id.key, cls.activity_guid.key] + if not all(col in data_df.columns for col in required_cols): + echo_log(f"错误:exists_task_activity 要求输入数据必须包含 {required_cols} 列") + return pd.DataFrame(), data_df.copy() + + # 构建 task_id+activity_guid 组合键 + data_df['_combine_key'] = data_df[cls.task_id.key].astype(str) + '|' + data_df[ + cls.activity_guid.key].str.strip() + + # 获取待查询的组合键列表(去重) + combine_keys = data_df['_combine_key'].unique().tolist() + if not combine_keys: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的记录 + _query = select(cls.id, cls.task_id, cls.activity_guid) + _process_df = await cls.query_as_df(_query) + if _process_df.empty: + data_df.drop(columns=['_combine_key'], inplace=True) + return pd.DataFrame(), data_df.copy() + + # 构建数据库组合键 + _process_df['_combine_key'] = _process_df[cls.task_id.key].astype(str) + '|' + _process_df[ + cls.activity_guid.key].str.strip() + combine_key_to_id_map = dict(zip(_process_df['_combine_key'], _process_df[cls.id.key])) + + # 根据组合键划分数据 + mask_exists = data_df['_combine_key'].isin(_process_df['_combine_key']) + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df['_combine_key'].map(combine_key_to_id_map) + + latest_df = data_df[~mask_exists].copy() + + # 清理临时列 + for df in [exists_df, latest_df, data_df]: + if '_combine_key' in df.columns: + df.drop(columns=['_combine_key'], inplace=True) + + return exists_df, latest_df + + +@register_swagger_model +class GovcTaskProcess(GovcTaskProcessBase): + """ + 工单流程追踪模型类(主业务类,完全继承 TD3iGovcTaskProcess 字段)。 + + --- + description: 市12345工单流程追踪接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + task_id: + description: 关联工单主表ID + type: integer + example: 5001 + handle_time: + description: 办理时间 + type: string + format: date-time + example: "2024-01-15 10:30:00" + operate_status: + description: 办理状态 + type: string + example: "处理中" + maxLength: 128 + activity_guid: + description: 办理环节名称 + type: string + example: "市级受理" + maxLength: 255 + handle_opinion: + description: 办理意见 + type: string + example: "已接收工单,正在分派处理" + is_finish: + description: 是否结束(0:未结束,1:已结束) + type: integer + example: 0 + operator_ou_name: + description: 部门 + type: string + example: "市12345政务服务中心" + maxLength: 255 + is_back: + description: 是否回退(0:否,1:是) + type: integer + example: 0 + operator_name: + description: 办理人 + type: string + example: "张三" + maxLength: 128 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "system" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的工单流程追踪记录。 + + 业务流程: + 1. 使用 GovcTaskProcessForm 验证表单数据完整性 + 2. 检查是否已存在相同 task_id+activity_guid 的记录(避免重复提交) + 3. 创建新流程追踪对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 工单流程追踪参数字典 + :return: 新建流程追踪对象 + :rtype: GovcTaskProcess + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovcTaskProcessForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 task_id+activity_guid 的记录 + _existing = await cls.is_exist(_form.task_id.data, _form.activity_guid.data) + assert _existing is None, f"工单ID {_form.task_id.data} 的 {_form.activity_guid.data} 环节记录已存在,不能重复提交。" + + # 创建对象 + _process = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _process.created_by = user.username + _process.updated_by = user.username + await _process.async_save() + return _process + + @classmethod + async def delete(cls, process_id: Union[str, int]): + """ + 删除工单流程追踪记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param process_id: 要删除的流程追踪记录ID + :return: 删除的记录对象 + :rtype: GovcTaskProcess + :raises AssertionError: 当记录不存在时抛出 + """ + _process: cls = await cls.async_find_by_id(process_id) + assert _process, f"根据 ID {process_id} 未找到工单流程追踪记录。" + + _del_query = delete(cls).where(cls.id == _process.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除工单流程追踪记录(工单ID:{_process.task_id},环节:{_process.activity_guid},ID:{_process.id}).') + return _process + + @classmethod + async def modify(cls, process_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有工单流程追踪记录。 + + 业务流程: + 1. 将 process_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 GovcTaskProcessForm 验证表单数据 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新字段并设置更新者 + 7. 保存到数据库 + 8. 返回更新后的对象 + + :param process_id: 要修改的流程追踪记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的流程追踪对象 + :rtype: GovcTaskProcess + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskProcessForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _process: cls = await cls.async_find_by_id(process_id) + assert _process, f'查无此工单流程追踪信息(ID:{process_id})。' + + # 更新字段 + _process.copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _process.updated_by = user.username + await _process.async_save() + return _process + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建工单流程追踪记录(传入数据应为全新记录)。 + + :param data_df: 包含流程追踪数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 设置创建者/更新者信息 + current_time = datetime.datetime.now() + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + # 补充默认时间字段 + data_df['created_at'] = data_df.get('created_at', current_time) + data_df['updated_at'] = data_df.get('updated_at', current_time) + + records = data_df.to_dict('records') + processes = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(processes) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(processes)} 条工单流程追踪记录。") + return len(processes) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有工单流程追踪记录。 + + :param data_df: 包含流程追踪数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if cls.id.key not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳和更新者 + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings 批量更新 + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单流程追踪记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存工单流程追踪数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态(根据 task_id + activity_guid 判断是否已存在) + _exists_df, _latest_df = await cls.exists_task_activity(data_df) + # 保存到数据库 + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/govc_task_requester.py b/models/govc_task_requester.py new file mode 100644 index 0000000..2e3e728 --- /dev/null +++ b/models/govc_task_requester.py @@ -0,0 +1,525 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, IntegerField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovcTaskRequester # 确保该模型已在db_models中定义 +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovcTaskRequesterForm(ModelForm): + """ + 诉求人信息表单验证类(完全映射 TD3iGovcTaskRequester 字段)。 + + 用于验证和处理市12345诉求人信息的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task_requester 的字段结构。 + """ + + # 基础信息 + id = IntegerField('主键ID') + task_id = StringField('关联工单主表ID', validators=[Length(max=64, message='关联工单主表ID长度不能超过64字符')]) + card_num = StringField('身份证号', validators=[Length(max=128, message='身份证号长度不能超过128字符')]) + emotion = StringField('诉求情绪', validators=[Length(max=64, message='诉求情绪长度不能超过64字符')]) + name_scope = StringField('年龄范围', validators=[Length(max=64, message='年龄范围长度不能超过64字符')]) + sex = StringField('性别', validators=[Length(max=32, message='性别长度不能超过32字符')]) + name = StringField('诉求人', validators=[Length(max=128, message='诉求人长度不能超过128字符')]) + secret_flag = StringField('保密标识', validators=[Length(max=32, message='保密标识长度不能超过32字符')]) + is_secret = StringField('是否保密', validators=[Length(max=32, message='是否保密长度不能超过32字符')]) + is_not_show_record = IntegerField('是否不展示记录') + phone_num = StringField('来电号码', validators=[Length(max=64, message='来电号码长度不能超过64字符')]) + limk_num = StringField('联系号码1', validators=[Length(max=64, message='联系号码1长度不能超过64字符')]) + c_guid = StringField('cguid', validators=[Length(max=64, message='cguid长度不能超过64字符')]) + phone_num1 = StringField('联系号码2', validators=[Length(max=64, message='联系号码2长度不能超过64字符')]) + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovcTaskRequesterBase(TD3iGovcTaskRequester, CommonModel): + """ + 诉求人信息基础类(完全映射 TD3iGovcTaskRequester 字段)。 + + 继承自数据库模型 TD3iGovcTaskRequester 和通用模型 CommonModel。 + 封装所有与诉求人信息相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'task_id': 'task_id', + 'card_num': 'card_num', + 'emotion': 'emotion', + 'name_scope': 'name_scope', + 'sex': 'sex', + 'name': 'name', + 'secret_flag': 'secret_flag', + 'is_secret': 'is_secret', + 'is_not_show_record': 'is_not_show_record', + 'phone_num': 'phone_num', + 'limk_num': 'limk_num', + 'c_guid': 'c_guid', + 'phone_num1': 'phone_num1', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 诉求人信息数据映射 + """ + + @classmethod + async def is_exist(cls, task_id: str): + """ + 检查诉求人信息是否已存在(根据工单主表ID)。 + + :param task_id: 关联工单主表ID + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.task_id == task_id) + _requester: cls = await cls.query_first(_query) + return _requester + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索诉求人信息的基础方法。 + + 支持字段: + - task_id, card_num, emotion, name_scope, sex, name, secret_flag, is_secret + - 支持模糊匹配:phone_num, limk_num, c_guid, phone_num1 + - 支持精确匹配:is_not_show_record + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_id': 'asc'} + :key str task_id: 精确匹配关联工单主表ID + :key str card_num: 精确匹配身份证号 + :key str emotion: 精确匹配诉求情绪 + :key str name_scope: 精确匹配年龄范围 + :key str sex: 精确匹配性别 + :key str name: 精确匹配诉求人 + :key str secret_flag: 精确匹配保密标识 + :key str is_secret: 精确匹配是否保密 + :key int is_not_show_record: 精确匹配是否不展示记录 + :key str phone_num: 模糊匹配来电号码 + :key str limk_num: 模糊匹配联系号码1 + :key str c_guid: 模糊匹配cguid + :key str phone_num1: 模糊匹配联系号码2 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.phone_num.key: '%{}%', + cls.limk_num.key: '%{}%', + cls.c_guid.key: '%{}%', + cls.phone_num1.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_id, cls.id) + + _requester_df = await cls.query_as_df(_data_query) + if not _requester_df.empty: + _requester_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _requester_df[cls.id.key] = _requester_df[cls.id.key].astype(str) + + return _requester_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索诉求人信息,返回分页格式数据。 + """ + _requester_df, _paging = await cls.search_base(** kwargs) + return { + 'total': _paging.row_count, + 'rows': _requester_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_task_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 task_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 task_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 task_id 列表(去重) + task_ids = data_df[cls.task_id.key].unique().tolist() + if not task_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 task_id + _query = select(cls.id, cls.task_id).where(cls.task_id.in_(task_ids)) + task_ids_df = await cls.query_as_df(_query) + + if task_ids_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 task_id -> id 的映射字典 + task_id_to_id_map = dict(zip(task_ids_df[cls.task_id.key], task_ids_df[cls.id.key])) + + # 根据 task_id 是否在数据库中,划分数据 + mask_exists = data_df[cls.task_id.key].isin(task_ids_df[cls.task_id.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.task_id.key].map(task_id_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class GovcTaskRequester(GovcTaskRequesterBase): + """ + 诉求人信息模型类(主业务类,完全继承 TD3iGovcTaskRequester 字段)。 + + --- + description: 市12345诉求人信息接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + task_id: + description: 关联工单主表ID + type: string + example: "TASK20240501001" + maxLength: 64 + card_num: + description: 身份证号 + type: string + example: "110101199001011234" + maxLength: 128 + emotion: + description: 诉求情绪 + type: string + example: "愤怒" + maxLength: 64 + name_scope: + description: 年龄范围 + type: string + example: "20-30岁" + maxLength: 64 + sex: + description: 性别 + type: string + example: "男" + maxLength: 32 + name: + description: 诉求人 + type: string + example: "张三" + maxLength: 128 + secret_flag: + description: 保密标识 + type: string + example: "0" + maxLength: 32 + is_secret: + description: 是否保密 + type: string + example: "否" + maxLength: 32 + is_not_show_record: + description: 是否不展示记录 + type: integer + example: 0 + phone_num: + description: 来电号码 + type: string + example: "13800138000" + maxLength: 64 + limk_num: + description: 联系号码1 + type: string + example: "13900139000" + maxLength: 64 + c_guid: + description: cguid + type: string + example: "GUID20240501001" + maxLength: 64 + phone_num1: + description: 联系号码2 + type: string + example: "13700137000" + maxLength: 64 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的诉求人信息记录。 + + 业务流程: + 1. 使用 GovcTaskRequesterForm 验证表单数据完整性 + 2. 检查是否已存在相同 task_id 的记录(避免重复提交) + 3. 创建新诉求人信息对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 诉求人信息参数字典 + :return: 新建诉求人信息对象 + :rtype: GovcTaskRequester + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovcTaskRequesterForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 task_id 的记录 + _existing = await cls.is_exist(_form.task_id.data) + assert _existing is None, "该工单已存在诉求人信息记录,不能重复提交。" + + # 创建对象 + _requester = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _requester.created_by = user.username + _requester.updated_by = user.username + await _requester.async_save() + return _requester + + @classmethod + async def delete(cls, requester_id: Union[str, int]): + """ + 删除诉求人信息记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param requester_id: 要删除的诉求人信息记录ID + :return: 删除的记录对象 + :rtype: GovcTaskRequester + :raises AssertionError: 当记录不存在时抛出 + """ + _requester: cls = await cls.async_find_by_id(requester_id) + assert _requester, f"根据 ID {requester_id} 未找到诉求人信息记录。" + + _del_query = delete(cls).where(cls.id == _requester.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除诉求人信息记录(工单ID:{_requester.task_id},ID:{_requester.id}).') + return _requester + + @classmethod + async def modify(cls, requester_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有诉求人信息记录。 + + 业务流程: + 1. 将 requester_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 GovcTaskRequesterForm 验证表单数据 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新字段并设置更新者 + 7. 保存到数据库 + 8. 返回更新后的对象 + + :param requester_id: 要修改的诉求人信息记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的诉求人信息对象 + :rtype: GovcTaskRequester + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskRequesterForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _requester: cls = await cls.async_find_by_id(requester_id) + assert _requester, f'查无此诉求人信息。' + + # 更新字段 + _requester.copy_from_dict(_form.data, skip_none=True).before_save() + _requester.updated_by = user.username + await _requester.async_save() + return _requester + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建诉求人信息记录(传入数据应为全新记录)。 + + :param data_df: 包含诉求人信息的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + requesters = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(requesters) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(requesters)} 条诉求人信息记录。") + return len(requesters) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有诉求人信息记录。 + + :param data_df: 包含诉求人信息的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条诉求人信息记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存诉求人信息数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await GovcTaskRequester.exists_task_id(data_df) + # 保存到数据库 + _created_count = await GovcTaskRequester.create_batch(_latest_df, user) + _updated_count = await GovcTaskRequester.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/govc_task_return_visit.py b/models/govc_task_return_visit.py new file mode 100644 index 0000000..0035a53 --- /dev/null +++ b/models/govc_task_return_visit.py @@ -0,0 +1,503 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length, Optional + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovcTaskReturnVisit +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +# 表单验证类 +class GovcTaskReturnVisitForm(ModelForm): + """ + 工单回访结果表单验证类(完全映射 TD3iGovcTaskReturnVisit 字段)。 + + 用于验证和处理市12345工单回访结果的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task_return_visit 的字段结构。 + """ + + # 基础信息 + id = IntegerField('记录ID') + task_id = IntegerField('关联工单主表ID', validators=[Optional()]) # 外键字段,非空验证由数据库层保证 + evl_result = StringField('结果满意度', validators=[Length(max=64, message='结果满意度长度不能超过64字符')]) + replay_person = StringField('回访人', validators=[Length(max=128, message='回访人长度不能超过128字符')]) + is_rg_reply = StringField('是否人工回访', validators=[Length(max=32, message='是否人工回访长度不能超过32字符')]) + processing_results = StringField('处理结果', validators=[Length(max=255, message='处理结果长度不能超过255字符')]) + solve_situation = StringField('解决情况', validators=[Length(max=64, message='解决情况长度不能超过64字符')]) + replay_time = DateTimeField('回访时间', validators=[Optional()]) + evl_style = StringField('态度满意度', validators=[Length(max=64, message='态度满意度长度不能超过64字符')]) + is_citizen = IntegerField('是否市民', validators=[Optional()]) + replay_content = TextAreaField('回访内容') + status = IntegerField('提交状态') # 兼容通用状态字段,若表中无则可删除 + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +# 基础业务类 +class GovcTaskReturnVisitBase(TD3iGovcTaskReturnVisit, CommonModel): + """ + 工单回访结果基础类(完全映射 TD3iGovcTaskReturnVisit 字段)。 + + 继承自数据库模型 TD3iGovcTaskReturnVisit 和通用模型 CommonModel。 + 封装所有与工单回访结果相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'task_id': 'task_id', + 'evl_result': 'evl_result', + 'replay_person': 'replay_person', + 'is_rg_reply': 'is_rg_reply', + 'processing_results': 'processing_results', + 'solve_situation': 'solve_situation', + 'replay_time': 'replay_time', + 'evl_style': 'evl_style', + 'is_citizen': 'is_citizen', + 'replay_content': 'replay_content', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 回访结果数据映射 + """ + + @classmethod + async def is_exist(cls, task_id: int): + """ + 检查回访记录是否已存在(根据工单ID)。 + + :param task_id: 关联工单主表ID + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.task_id == task_id) + _visit: cls = await cls.query_first(_query) + return _visit + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索回访数据的基础方法。 + + 支持字段: + - task_id, evl_result, replay_person, is_rg_reply, solve_situation, evl_style, is_citizen + - 支持模糊匹配:processing_results, replay_content + - 支持精确匹配:is_rg_reply, is_citizen + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_id': 'asc'} + :key int task_id: 精确匹配工单ID + :key str evl_result: 精确匹配结果满意度 + :key str replay_person: 精确匹配回访人 + :key str is_rg_reply: 精确匹配是否人工回访 + :key str processing_results: 模糊匹配处理结果 + :key str solve_situation: 精确匹配解决情况 + :key str evl_style: 精确匹配态度满意度 + :key int is_citizen: 精确匹配是否市民 + :key str replay_content: 模糊匹配回访内容 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.processing_results.key: '%{}%', + cls.replay_content.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_id, cls.id) + + _visit_df = await cls.query_as_df(_data_query) + if not _visit_df.empty: + _visit_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _visit_df[cls.id.key] = _visit_df[cls.id.key].astype(str) + # 处理时间字段格式 + if cls.replay_time.key in _visit_df.columns: + _visit_df[cls.replay_time.key] = _visit_df[cls.replay_time.key].dt.strftime('%Y-%m-%d %H:%M:%S') + + return _visit_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索回访数据,返回分页格式数据。 + """ + _visit_df, _paging = await cls.search_base(** kwargs) + return { + 'total': _paging.row_count, + 'rows': _visit_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_task_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 task_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 task_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 task_id 列表(去重) + task_ids = data_df[cls.task_id.key].unique().tolist() + if not task_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 task_id + _query = select(cls.id, cls.task_id).where(cls.task_id.in_(task_ids)) + task_ids_df = await cls.query_as_df(_query) + + if task_ids_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 task_id -> id 的映射字典 + task_id_to_id_map = dict(zip(task_ids_df[cls.task_id.key], task_ids_df[cls.id.key])) + + # 根据 task_id 是否在数据库中,划分数据 + mask_exists = data_df[cls.task_id.key].isin(task_ids_df[cls.task_id.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.task_id.key].map(task_id_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +# 主业务模型类 +@register_swagger_model +class GovcTaskReturnVisit(GovcTaskReturnVisitBase): + """ + 工单回访结果模型类(主业务类,完全继承 TD3iGovcTaskReturnVisit 字段)。 + + --- + description: 市12345工单回访结果接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + task_id: + description: 关联工单主表ID + type: integer + example: 2001 + evl_result: + description: 结果满意度 + type: string + example: "满意" + maxLength: 64 + replay_person: + description: 回访人 + type: string + example: "张三" + maxLength: 128 + is_rg_reply: + description: 是否人工回访 + type: string + example: "是" + maxLength: 32 + processing_results: + description: 处理结果 + type: string + example: "已完成问题整改,用户认可" + maxLength: 255 + solve_situation: + description: 解决情况 + type: string + example: "完全解决" + maxLength: 64 + replay_time: + description: 回访时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + evl_style: + description: 态度满意度 + type: string + example: "满意" + maxLength: 64 + is_citizen: + description: 是否市民(0:否,1:是) + type: integer + example: 1 + replay_content: + description: 回访内容 + type: string + example: "用户反馈问题已解决,对处理态度表示满意" + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的工单回访记录。 + + 业务流程: + 1. 使用 GovcTaskReturnVisitForm 验证表单数据完整性 + 2. 检查是否已存在相同 task_id 的记录(避免重复提交) + 3. 创建新回访对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 回访参数字典 + :return: 新建回访对象 + :rtype: GovcTaskReturnVisit + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovcTaskReturnVisitForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 task_id 的记录 + _existing = await cls.is_exist(_form.task_id.data) + assert _existing is None, "该工单已存在回访记录,不能重复提交。" + + # 创建对象 + _visit = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _visit.created_by = user.username + _visit.updated_by = user.username + await _visit.async_save() + return _visit + + @classmethod + async def delete(cls, visit_id: Union[str, int]): + """ + 删除工单回访记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param visit_id: 要删除的回访记录ID + :return: 删除的记录对象 + :rtype: GovcTaskReturnVisit + :raises AssertionError: 当记录不存在时抛出 + """ + _visit: cls = await cls.async_find_by_id(visit_id) + assert _visit, f"根据 ID {visit_id} 未找到工单回访记录。" + + _del_query = delete(cls).where(cls.id == _visit.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除工单回访记录(工单ID:{_visit.task_id},ID:{_visit.id}).') + return _visit + + @classmethod + async def modify(cls, visit_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有工单回访记录。 + + 业务流程: + 1. 将 visit_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 GovcTaskReturnVisitForm 验证表单数据 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新字段并设置更新者 + 7. 保存到数据库 + 8. 返回更新后的对象 + + :param visit_id: 要修改的回访记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的回访对象 + :rtype: GovcTaskReturnVisit + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskReturnVisitForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _visit: cls = await cls.async_find_by_id(visit_id) + assert _visit, f'查无此工单回访信息。' + + # 更新字段 + _visit.copy_from_dict(_form.data, skip_none=True).before_save() + _visit.updated_by = user.username + await _visit.async_save() + return _visit + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建工单回访记录(传入数据应为全新记录)。 + + :param data_df: 包含回访数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + visits = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(visits) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(visits)} 条工单回访记录。") + return len(visits) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有工单回访记录。 + + :param data_df: 包含回访数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单回访记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存工单回访数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await cls.exists_task_id(data_df) + # 保存到数据库 + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/govc_task_status.py b/models/govc_task_status.py new file mode 100644 index 0000000..c8dc588 --- /dev/null +++ b/models/govc_task_status.py @@ -0,0 +1,467 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, IntegerField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovcTaskStatu # 确保数据库模型已导入 +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovcTaskStatusForm(ModelForm): + """ + 12345工单办理状态表单验证类(完全映射 TD3iGovcTaskStatu 字段)。 + + 用于验证和处理市12345工单办理状态的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task_status 的字段结构。 + """ + + # 基础信息 + id = IntegerField('记录ID') + task_id = IntegerField('关联工单主表ID', validators=[Length(max=20, message='关联工单主表ID长度不能超过20字符')]) + shou_li = StringField('受理状态', validators=[Length(max=32, message='受理状态长度不能超过32字符')]) + jie_dan = StringField('接单状态', validators=[Length(max=32, message='接单状态长度不能超过32字符')]) + hui_fang = StringField('回访状态', validators=[Length(max=32, message='回访状态长度不能超过32字符')]) + ban_li = StringField('办理状态', validators=[Length(max=32, message='办理状态长度不能超过32字符')]) + created_at = StringField('创建时间') # 只读,表单仅用于展示 + created_by = StringField('创建者') # 只读,表单仅用于展示 + updated_at = StringField('更新时间') # 只读,表单仅用于展示 + updated_by = StringField('更新者') # 只读,表单仅用于展示 + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovcTaskStatusBase(TD3iGovcTaskStatu, CommonModel): + """ + 12345工单办理状态基础类(完全映射 TD3iGovcTaskStatu 字段)。 + + 继承自数据库模型 TD3iGovcTaskStatu 和通用模型 CommonModel。 + 封装所有与工单办理状态相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'task_id': 'task_id', + 'shou_li': 'shou_li', + 'jie_dan': 'jie_dan', + 'hui_fang': 'hui_fang', + 'ban_li': 'ban_li', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 工单办理状态数据映射 + """ + + @classmethod + async def is_exist(cls, task_id: int): + """ + 检查工单办理状态记录是否已存在(根据关联工单主表ID)。 + + :param task_id: 关联工单主表ID + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.task_id == task_id) + _status: cls = await cls.query_first(_query) + return _status + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索工单办理状态数据的基础方法。 + + 支持字段: + - task_id, shou_li, jie_dan, hui_fang, ban_li + - 支持精确匹配:所有字段 + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'task_id': 'asc'} + :key int task_id: 精确匹配关联工单主表ID + :key str shou_li: 精确匹配受理状态 + :key str jie_dan: 精确匹配接单状态 + :key str hui_fang: 精确匹配回访状态 + :key str ban_li: 精确匹配办理状态 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 精确查询字段(无模糊查询) + _query = select(cls).where( + *cls.search_wheres(likes={}, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_id, cls.id) + + _status_df = await cls.query_as_df(_data_query) + if not _status_df.empty: + _status_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _status_df[cls.id.key] = _status_df[cls.id.key].astype(str) + _status_df[cls.task_id.key] = _status_df[cls.task_id.key].astype(str) + + return _status_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索工单办理状态数据,返回分页格式数据。 + """ + _status_df, _paging = await cls.search_base(** kwargs) + return { + 'total': _paging.row_count, + 'rows': _status_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_task_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 task_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 task_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 task_id 列表(去重) + task_ids = data_df[cls.task_id.key].unique().tolist() + if not task_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 task_id + _query = select(cls.id, cls.task_id).where(cls.task_id.in_(task_ids)) + task_ids_df = await cls.query_as_df(_query) + + if task_ids_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 task_id -> id 的映射字典 + task_id_to_id_map = dict(zip(task_ids_df[cls.task_id.key], task_ids_df[cls.id.key])) + + # 根据 task_id 是否在数据库中,划分数据 + mask_exists = data_df[cls.task_id.key].isin(task_ids_df[cls.task_id.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.task_id.key].map(task_id_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class GovcTaskStatus(GovcTaskStatusBase): + """ + 12345工单办理状态模型类(主业务类,完全继承 TD3iGovcTaskStatu 字段)。 + + --- + description: 市12345工单办理状态接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + task_id: + description: 关联工单主表ID + type: integer + example: 20240501001 + maxLength: 20 + shou_li: + description: 受理状态 + type: string + example: "已受理" + maxLength: 32 + jie_dan: + description: 接单状态 + type: string + example: "已接单" + maxLength: 32 + hui_fang: + description: 回访状态 + type: string + example: "已回访" + maxLength: 32 + ban_li: + description: 办理状态 + type: string + example: "已办结" + maxLength: 32 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "D3I" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "admin" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的工单办理状态记录。 + + 业务流程: + 1. 使用 GovcTaskStatusForm 验证表单数据完整性 + 2. 检查是否已存在相同 task_id 的记录(避免重复提交) + 3. 创建新状态对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 工单办理状态参数字典 + :return: 新建状态对象 + :rtype: GovcTaskStatus + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovcTaskStatusForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 task_id 的记录 + _existing = await cls.is_exist(_form.task_id.data) + assert _existing is None, "该工单已存在办理状态记录,不能重复提交。" + + # 创建对象 + _status = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _status.created_by = user.username + _status.updated_by = user.username + await _status.async_save() + return _status + + @classmethod + async def delete(cls, status_id: Union[str, int]): + """ + 删除工单办理状态记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param status_id: 要删除的状态记录ID + :return: 删除的记录对象 + :rtype: GovcTaskStatus + :raises AssertionError: 当记录不存在时抛出 + """ + _status: cls = await cls.async_find_by_id(status_id) + assert _status, f"根据 ID {status_id} 未找到工单办理状态记录。" + + _del_query = delete(cls).where(cls.id == _status.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除工单办理状态记录(工单ID:{_status.task_id},ID:{_status.id}).') + return _status + + @classmethod + async def modify(cls, status_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有工单办理状态记录。 + + 业务流程: + 1. 将 status_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 GovcTaskStatusForm 验证表单数据 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新字段并设置更新者 + 7. 保存到数据库 + 8. 返回更新后的对象 + + :param status_id: 要修改的状态记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的状态对象 + :rtype: GovcTaskStatus + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskStatusForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _status: cls = await cls.async_find_by_id(status_id) + assert _status, f'查无此工单办理状态信息。' + + # 更新字段 + _status.copy_from_dict(_form.data, skip_none=True).before_save() + _status.updated_by = user.username if user else _status.updated_by + await _status.async_save() + return _status + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建工单办理状态记录(传入数据应为全新记录)。 + + :param data_df: 包含状态数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 设置创建/更新者信息 + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + else: + data_df['created_by'] = data_df.get('created_by', 'D3I') + data_df['updated_by'] = data_df.get('updated_by', 'D3I') + + # 补充创建/更新时间 + data_df['created_at'] = datetime.datetime.now() + data_df['updated_at'] = datetime.datetime.now() + + records = data_df.to_dict('records') + statuses = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(statuses) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(statuses)} 条工单办理状态记录。") + return len(statuses) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有工单办理状态记录。 + + :param data_df: 包含状态数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings 批量更新 + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单办理状态记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存工单办理状态数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态(按task_id判断存在性) + _exists_df, _latest_df = await GovcTaskStatus.exists_task_id(data_df) + # 保存到数据库 + _created_count = await GovcTaskStatus.create_batch(_latest_df, user) + _updated_count = await GovcTaskStatus.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/govc_task_supervision.py b/models/govc_task_supervision.py new file mode 100644 index 0000000..b228de9 --- /dev/null +++ b/models/govc_task_supervision.py @@ -0,0 +1,504 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, DateTimeField, IntegerField +from wtforms.validators import Length, Optional + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovcTaskSupervision +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovcTaskSupervisionForm(ModelForm): + """ + 工单监察信息表单验证类(完全映射 TD3iGovcTaskSupervision 字段)。 + + 用于验证和处理市12345工单监察信息的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task_supervision 的字段结构。 + """ + + # 基础信息 + id = IntegerField('记录ID') + task_id = IntegerField('关联工单主表ID', validators=[Optional()]) # 非空在数据库层约束 + supervision_name = StringField('监察点名称', validators=[Length(max=255, message='监察点名称长度不能超过255字符')]) + supervision_type = StringField('监察点类型', validators=[Length(max=255, message='监察点类型长度不能超过255字符')]) + supervision_date = DateTimeField('监察点时间', validators=[Optional()]) + supervision_ou_name = StringField('部门', validators=[Length(max=255, message='部门长度不能超过255字符')]) + hj_date = DateTimeField('核减时间', validators=[Optional()]) + supervise_type = StringField('监察类别', validators=[Length(max=32, message='监察类别长度不能超过32字符')]) + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovcTaskSupervisionBase(TD3iGovcTaskSupervision, CommonModel): + """ + 工单监察信息基础类(完全映射 TD3iGovcTaskSupervision 字段)。 + + 继承自数据库模型 TD3iGovcTaskSupervision 和通用模型 CommonModel。 + 封装所有与工单监察相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'task_id': 'task_id', + 'supervision_name': 'supervision_name', + 'supervision_type': 'supervision_type', + 'supervision_date': 'supervision_date', + 'supervision_ou_name': 'supervision_ou_name', + 'hj_date': 'hj_date', + 'supervise_type': 'supervise_type', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 工单监察数据映射 + """ + + @classmethod + async def is_exist(cls, task_id: int, supervise_type: str = None): + """ + 检查监察记录是否已存在(根据工单ID+监察类别组合,可扩展)。 + + :param task_id: 关联工单主表ID + :param supervise_type: 监察类别(可选) + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.task_id == task_id) + if supervise_type: + _query = _query.where(cls.supervise_type == supervise_type) + _supervision: cls = await cls.query_first(_query) + return _supervision + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索工单监察数据的基础方法。 + + 支持字段: + - task_id, supervision_name, supervision_type, supervise_type + - 支持模糊匹配:supervision_ou_name + - 支持精确匹配:id, supervise_type + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'supervision_date': 'desc'} + :key int id: 精确匹配记录ID + :key int task_id: 精确匹配工单ID + :key str supervision_name: 精确匹配监察点名称 + :key str supervision_type: 精确匹配监察点类型 + :key str supervision_ou_name: 模糊匹配部门 + :key str supervise_type: 精确匹配监察类别 + :key datetime supervision_date: 精确匹配监察点时间 + :key datetime hj_date: 精确匹配核减时间 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.supervision_ou_name.key: '%{}%', + cls.supervision_name.key: '%{}%', # 补充名称模糊匹配 + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.task_id, cls.supervision_date.desc()) + + _supervision_df = await cls.query_as_df(_data_query) + if not _supervision_df.empty: + _supervision_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _supervision_df[cls.id.key] = _supervision_df[cls.id.key].astype(str) + # 日期字段格式化 + for dt_field in ['supervision_date', 'hj_date', 'created_at', 'updated_at']: + if dt_field in _supervision_df.columns: + _supervision_df[dt_field] = _supervision_df[dt_field].dt.strftime('%Y-%m-%d %H:%M:%S') + + return _supervision_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索工单监察数据,返回分页格式数据。 + """ + _supervision_df, _paging = await cls.search_base(** kwargs) + return { + 'total': _paging.row_count, + 'rows': _supervision_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_task_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 task_id+supervise_type 组合判断。 + + :param data_df: 输入的数据框架,必须包含 task_id 列(可选supervise_type) + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 task_id 列表(去重) + task_ids = data_df[cls.task_id.key].unique().tolist() + if not task_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 task_id+supervise_type 组合 + _query = select(cls.id, cls.task_id, cls.supervise_type).where(cls.task_id.in_(task_ids)) + task_ids_df = await cls.query_as_df(_query) + + if task_ids_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建复合键映射 (task_id, supervise_type) -> id + task_supervise_map = {} + for _, row in task_ids_df.iterrows(): + key = (row[cls.task_id.key], row[cls.supervise_type.key]) + task_supervise_map[key] = row[cls.id.key] + + # 根据复合键划分数据 + def is_exist(row): + key = (row[cls.task_id.key], row.get(cls.supervise_type.key, '')) + return key in task_supervise_map + + mask_exists = data_df.apply(is_exist, axis=1) + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df.apply( + lambda row: task_supervise_map.get((row[cls.task_id.key], row.get(cls.supervise_type.key, '')), ''), + axis=1 + ) + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class GovcTaskSupervision(GovcTaskSupervisionBase): + """ + 工单监察信息模型类(主业务类,完全继承 TD3iGovcTaskSupervision 字段)。 + + --- + description: 市12345工单监察信息接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + task_id: + description: 关联工单主表ID + type: integer + example: 5001 + supervision_name: + description: 监察点名称 + type: string + example: "超时未办结监察" + maxLength: 255 + supervision_type: + description: 监察点类型 + type: string + example: "时限监察" + maxLength: 255 + supervision_date: + description: 监察点时间 + type: string + format: date-time + example: "2024-05-01 10:00:00" + supervision_ou_name: + description: 部门 + type: string + example: "市政务服务中心" + maxLength: 255 + hj_date: + description: 核减时间 + type: string + format: date-time + example: "2024-05-02 15:30:00" + supervise_type: + description: 监察类别(zx/bm/bmhj) + type: string + example: "zx" + maxLength: 32 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的工单监察记录。 + + 业务流程: + 1. 使用 GovcTaskSupervisionForm 验证表单数据完整性 + 2. 检查是否已存在相同 task_id+supervise_type 的记录(避免重复提交) + 3. 创建新监察对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 监察参数字典 + :return: 新建监察对象 + :rtype: GovcTaskSupervision + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovcTaskSupervisionForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 task_id+supervise_type 的记录 + _existing = await cls.is_exist(_form.task_id.data, _form.supervise_type.data) + assert _existing is None, f"工单ID {_form.task_id.data} 已存在[{_form.supervise_type.data}]类型的监察记录,不能重复提交。" + + # 创建对象 + _supervision = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _supervision.created_by = user.username + _supervision.updated_by = user.username + await _supervision.async_save() + return _supervision + + @classmethod + async def delete(cls, supervision_id: Union[str, int]): + """ + 删除工单监察记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param supervision_id: 要删除的监察记录ID + :return: 删除的记录对象 + :rtype: GovcTaskSupervision + :raises AssertionError: 当记录不存在时抛出 + """ + _supervision: cls = await cls.async_find_by_id(supervision_id) + assert _supervision, f"根据 ID {supervision_id} 未找到工单监察记录。" + + _del_query = delete(cls).where(cls.id == _supervision.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除工单监察记录(工单ID:{_supervision.task_id},ID:{_supervision.id}).') + return _supervision + + @classmethod + async def modify(cls, supervision_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有工单监察记录。 + + 业务流程: + 1. 将 supervision_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 GovcTaskSupervisionForm 验证表单数据 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新字段并设置更新者 + 7. 保存到数据库 + 8. 返回更新后的对象 + + :param supervision_id: 要修改的监察记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的监察对象 + :rtype: GovcTaskSupervision + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskSupervisionForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _supervision: cls = await cls.async_find_by_id(supervision_id) + assert _supervision, f'查无此工单监察信息。' + + # 更新字段 + _supervision.copy_from_dict(_form.data, skip_none=True).before_save() + _supervision.updated_by = user.username if user else _supervision.updated_by + await _supervision.async_save() + return _supervision + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建工单监察记录(传入数据应为全新记录)。 + + :param data_df: 包含监察数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 补充创建者/更新者信息 + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + else: + data_df['created_by'] = 'D3I' + data_df['updated_by'] = 'D3I' + + # 处理日期字段格式 + for dt_field in ['supervision_date', 'hj_date']: + if dt_field in data_df.columns: + data_df[dt_field] = pd.to_datetime(data_df[dt_field], errors='coerce') + + records = data_df.to_dict('records') + supervisions = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(supervisions) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(supervisions)} 条工单监察记录。") + return len(supervisions) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有工单监察记录。 + + :param data_df: 包含监察数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳和更新者 + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + # 处理日期字段格式 + for dt_field in ['supervision_date', 'hj_date']: + if dt_field in data_df.columns: + data_df[dt_field] = pd.to_datetime(data_df[dt_field], errors='coerce') + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings 批量更新 + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单监察记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存工单监察数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态(按task_id+supervise_type判断存在性) + _exists_df, _latest_df = await cls.exists_task_id(data_df) + # 保存到数据库 + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/govc_task_title.py b/models/govc_task_title.py new file mode 100644 index 0000000..6e9f94f --- /dev/null +++ b/models/govc_task_title.py @@ -0,0 +1,465 @@ +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, IntegerField +from wtforms.validators import Length, Optional + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovcTaskTitle +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +# 表单验证类 +class GovcTaskTitleForm(ModelForm): + """ + 工单标题表单验证类(完全映射 TD3iGovcTaskTitle 字段)。 + + 用于验证和处理市12345工单标题表的创建/修改表单数据。 + 字段完全映射数据库表 t_d3i_govc_task_title 的字段结构。 + """ + + # 基础信息 + id = IntegerField('记录ID') + task_id = IntegerField('关联工单主表ID', validators=[Optional()]) # 外键字段,非空由数据库约束 + urgency = IntegerField('是否紧急', validators=[Optional()]) + order_num = StringField('工单编号', validators=[Length(max=64, message='工单编号长度不能超过64字符')]) + source = StringField('来源', validators=[Length(max=64, message='来源长度不能超过64字符')]) + title = StringField('标题', validators=[Length(max=500, message='标题长度不能超过500字符')]) + created_at = StringField('创建时间', validators=[Optional()]) # 只读字段,仅用于展示 + created_by = StringField('创建者', validators=[Optional()]) # 只读字段,仅用于展示 + updated_at = StringField('更新时间', validators=[Optional()]) # 只读字段,仅用于展示 + updated_by = StringField('更新者', validators=[Optional()]) # 只读字段,仅用于展示 + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +# 基础业务类 +class GovcTaskTitleBase(TD3iGovcTaskTitle, CommonModel): + """ + 工单标题基础类(完全映射 TD3iGovcTaskTitle 字段)。 + + 继承自数据库模型 TD3iGovcTaskTitle 和通用模型 CommonModel。 + 封装所有与工单标题相关的通用操作方法。 + """ + + FieldMapping = { + 'id': 'id', + 'task_id': 'task_id', + 'urgency': 'urgency', + 'order_num': 'order_num', + 'source': 'source', + 'title': 'title', + 'created_at': 'created_at', + 'created_by': 'created_by', + 'updated_at': 'updated_at', + 'updated_by': 'updated_by', + } + """ + 工单标题数据映射 + """ + + @classmethod + async def is_exist(cls, task_id: int): + """ + 检查工单标题记录是否已存在(根据关联工单主表ID)。 + + :param task_id: 关联工单主表ID + :return: 存在返回对象,不存在返回None + """ + _query = select(cls).where(cls.task_id == task_id) + _title: cls = await cls.query_first(_query) + return _title + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索工单标题数据的基础方法。 + + 支持字段: + - task_id, urgency, order_num, source + - 支持模糊匹配:title + - 支持精确匹配:urgency, order_num, source + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'order_num': 'asc'} + :key int task_id: 精确匹配关联工单主表ID + :key int urgency: 精确匹配是否紧急 + :key str order_num: 精确匹配工单编号 + :key str source: 精确匹配来源 + :key str title: 模糊匹配标题 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段 + _name_likes = { + cls.title.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.order_num, cls.task_id) + + _title_df = await cls.query_as_df(_data_query) + if not _title_df.empty: + _title_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _title_df[cls.id.key] = _title_df[cls.id.key].astype(str) + _title_df[cls.task_id.key] = _title_df[cls.task_id.key].astype(str) + + return _title_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索工单标题数据,返回分页格式数据。 + """ + _title_df, _paging = await cls.search_base(** kwargs) + return { + 'total': _paging.row_count, + 'rows': _title_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_task_id(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 task_id 字段判断。 + + :param data_df: 输入的数据框架,必须包含 task_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 task_id 列表(去重) + task_ids = data_df[cls.task_id.key].unique().tolist() + if not task_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的 task_id + _query = select(cls.id, cls.task_id).where(cls.task_id.in_(task_ids)) + task_ids_df = await cls.query_as_df(_query) + + if task_ids_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 task_id -> id 的映射字典 + task_id_to_id_map = dict(zip(task_ids_df[cls.task_id.key], task_ids_df[cls.id.key])) + + # 根据 task_id 是否在数据库中,划分数据 + mask_exists = data_df[cls.task_id.key].isin(task_ids_df[cls.task_id.key]) + # 数据库已经有的记录 + exists_df = data_df[mask_exists].copy() + # 自动补充从数据库查到的 id 字段 + exists_df[cls.id.key] = exists_df[cls.task_id.key].map(task_id_to_id_map) + # 新的数据 + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +# 主业务模型类(带Swagger文档) +@register_swagger_model +class GovcTaskTitle(GovcTaskTitleBase): + """ + 工单标题模型类(主业务类,完全继承 TD3iGovcTaskTitle 字段)。 + + --- + description: 市12345工单标题接口 + type: object + properties: + id: + description: 主键ID + type: integer + example: 1001 + readOnly: true + task_id: + description: 关联工单主表ID + type: integer + example: 2001 + urgency: + description: 是否紧急(0:不紧急,1:紧急) + type: integer + example: 1 + order_num: + description: 工单编号 + type: string + example: "GOV20240501001" + maxLength: 64 + source: + description: 来源 + type: string + example: "市民来电" + maxLength: 64 + title: + description: 标题 + type: string + example: "XX小区垃圾分类设施缺失问题" + maxLength: 500 + created_at: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + created_by: + description: 创建者用户名 + type: string + example: "admin" + readOnly: true + updated_at: + description: 修改时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + updated_by: + description: 修改者用户名 + type: string + example: "editor" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """ + 创建新的工单标题记录。 + + 业务流程: + 1. 使用 GovcTaskTitleForm 验证表单数据完整性 + 2. 检查是否已存在相同 task_id 的记录(避免重复提交) + 3. 创建新工单标题对象 + 4. 设置创建者和更新者为当前用户 + 5. 保存到数据库 + 6. 返回创建的对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: 工单标题参数字典 + :return: 新建工单标题对象 + :rtype: GovcTaskTitle + :raises AssertionError: 当记录已存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovcTaskTitleForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在相同 task_id 的记录 + _existing = await cls.is_exist(_form.task_id.data) + assert _existing is None, f"工单ID {_form.task_id.data} 已存在标题记录,不能重复提交。" + + # 创建对象 + _title = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _title.created_by = user.username + _title.updated_by = user.username + await _title.async_save() + return _title + + @classmethod + async def delete(cls, title_id: Union[str, int]): + """ + 删除工单标题记录。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行删除 + + :param title_id: 要删除的工单标题记录ID + :return: 删除的记录对象 + :rtype: GovcTaskTitle + :raises AssertionError: 当记录不存在时抛出 + """ + _title: cls = await cls.async_find_by_id(title_id) + assert _title, f"根据 ID {title_id} 未找到工单标题记录。" + + _del_query = delete(cls).where(cls.id == _title.id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除工单标题记录(工单编号:{_title.order_num},ID:{_title.id}).') + return _title + + @classmethod + async def modify(cls, title_id: Union[str, int], user: RbacUser = None, **kwargs): + """ + 修改已有工单标题记录。 + + 业务流程: + 1. 将 title_id 添加到参数中 + 2. 处理字符串字段去除首尾空格 + 3. 使用 GovcTaskTitleForm 验证表单数据 + 4. 查询原记录 + 5. 验证存在性 + 6. 更新字段并设置更新者 + 7. 保存到数据库 + 8. 返回更新后的对象 + + :param title_id: 要修改的工单标题记录ID + :param RbacUser user: 操作用户对象 + :param kwargs: 需要更新的字段 + :return: 修改后的工单标题对象 + :rtype: GovcTaskTitle + :raises AssertionError: 当记录不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 表单验证 + _form = GovcTaskTitleForm(formdata=kwargs) + _form.validate_form() + + # 查询原记录 + _title: cls = await cls.async_find_by_id(title_id) + assert _title, f'查无此工单标题信息。' + + # 更新字段 + _title.copy_from_dict(_form.data, skip_none=True).before_save() + _title.updated_by = user.username + await _title.async_save() + return _title + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量创建工单标题记录(传入数据应为全新记录)。 + + :param data_df: 包含工单标题数据的 DataFrame + :param user: 操作用户对象,用于设置 created_by / updated_by + :return: 成功创建的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + titles = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(titles) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(titles)} 条工单标题记录。") + return len(titles) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量修改已有工单标题记录。 + + :param data_df: 包含工单标题数据的 DataFrame(必须包含 id 列) + :param user: 操作用户对象,用于设置 updated_by + :return: 成功更新的数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 手动添加更新时间戳 + data_df['updated_at'] = datetime.datetime.now() + # 添加更新者信息 + if user: + data_df['updated_by'] = user.username + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单标题记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """ + 批量保存工单标题数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :param user: 用户 + :return: 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await GovcTaskTitle.exists_task_id(data_df) + # 保存到数据库 + _created_count = await GovcTaskTitle.create_batch(_latest_df, user) + _updated_count = await GovcTaskTitle.modify_batch(_exists_df, user) + return _created_count, _updated_count \ No newline at end of file diff --git a/models/govs_create_delay.py b/models/govs_create_delay.py new file mode 100644 index 0000000..65adf79 --- /dev/null +++ b/models/govs_create_delay.py @@ -0,0 +1,346 @@ +# coding: utf-8 +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length + +import models +from models.db_models import TD3iGovsApplicationForDelay +from models.common_model import CommonModel +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovsApplicationForDelayForm(ModelForm): + """延时申请表单验证类""" + + id = IntegerField('主键') + master_id = IntegerField('主表ID') + gd_id = StringField('代签收唯一标志', validators=[Length(max=64)]) + finally_time_after_approve = StringField('延时申请通过后时间', validators=[Length(max=64)]) + finally_time_before_approve = StringField('计划完成时间', validators=[Length(max=64)]) + request_delay = StringField('申请延时时长', validators=[Length(max=64)]) + is_nature_day = StringField('延时时长类型', validators=[Length(max=10)]) + already_notify_order_user = StringField('是否已告知诉求人', validators=[Length(max=10)]) + request_reason = TextAreaField('延时原因') + remarks = StringField('备注', validators=[Length(max=500)]) + contact_name = StringField('何人', validators=[Length(max=100)]) + contact_time = StringField('何时', validators=[Length(max=64)]) + contact_type = StringField('何方式(编码)', validators=[Length(max=64)]) + contact_type_name = StringField('何方式(名称)', validators=[Length(max=100)]) + reply_script = TextAreaField('答复脚本') + file_id_str = TextAreaField('OA文件id') + order_no = StringField('工单号', validators=[Length(max=64)]) + process_instance_id = StringField('流程实例ID', validators=[Length(max=64)]) + request_delay_time = StringField('申请延时时长(字符串)', validators=[Length(max=64)]) + save_id = StringField('提交数据ID', validators=[Length(max=64)]) + order_id = StringField('工单ID', validators=[Length(max=64)]) + save_status = IntegerField('提交状态') + oa_feedback_status = IntegerField('OA反馈状态') + flow_token = StringField('流令牌', validators=[Length(max=256)]) + created_at = DateTimeField('创建时间') + created_by = StringField('创建者', validators=[Length(max=64)]) + updated_at = DateTimeField('更新时间') + updated_by = StringField('更新者', validators=[Length(max=64)]) + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovsApplicationForDelayBase(TD3iGovsApplicationForDelay, CommonModel): + """延时申请业务基类""" + + FieldMapping = { + # 主键与关联 + 'id': 'id', + 'master_id': 'gdId', + 'order_id': 'orderId', + 'order_no': 'orderNo', + 'process_instance_id': 'processInstanceId', + 'gd_id': 'gdId', + + # 延时核心字段 + 'finally_time_after_approve': 'finallyTimeAfterApprove', + 'finally_time_before_approve': 'finallyTimeBeforeApprove', + 'request_delay': 'requestDelay', + 'is_nature_day': 'isNatureDay', + 'request_delay_time': 'requestDelayTime', + + # 沟通告知字段 + 'already_notify_order_user': 'alreadyNotifyOrderUser', + 'contact_name': 'contactName', + 'contact_time': 'contactTime', + 'contact_type': 'contactType', + 'contact_type_name': 'contactTypeName', + 'reply_script': 'replyScript', + + # 原因与附件 + 'request_reason': 'requestReason', + 'remarks': 'remarks', + 'file_id_str': 'fileIdStr' + } + + @classmethod + async def exist_other(cls, id: Union[str, int], master_id: Union[str, int] = None, order_id: str = None, + order_no: str = None): + """检查是否存在除当前记录外的其他同唯一标识延时申请""" + _query = select(cls).where(cls.id != id) + if master_id: + _query = _query.where(cls.master_id == master_id) + if order_id: + _query = _query.where(cls.order_id == order_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """根据ID列表批量查找延时申请""" + _query = select(cls).where(cls.id.in_(ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + @classmethod + async def is_exist(cls, master_id: Union[str, int] = None, order_id: str = None, order_no: str = None): + """检查延时申请是否已经存在""" + _query = select(cls) + if master_id: + _query = _query.where(cls.master_id == master_id) + if order_id: + _query = _query.where(cls.order_id == order_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """按参数搜索延时申请的基础方法""" + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + _name_likes = { + cls.master_id.key: '%{}%', + cls.order_no.key: '%{}%' + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.created_at.desc()) + + _df = await cls.query_as_df(_data_query) + if not _df.empty: + _df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _df[cls.id.key] = _df[cls.id.key].astype(str) + + return _df, _paging + + @classmethod + async def search(cls, **kwargs): + """按参数搜索延时申请,返回分页格式数据""" + _df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_master_id(cls, data_df: pd.DataFrame): + """根据 master_id 判断数据是否存在""" + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + master_ids = data_df[cls.master_id.key].unique().tolist() + if not master_ids: + return pd.DataFrame(), data_df.copy() + + _query = select(cls.id, cls.master_id).where(cls.master_id.in_(master_ids)) + existing_df = await cls.query_as_df(_query) + + if existing_df.empty: + return pd.DataFrame(), data_df.copy() + + master_id_to_id_map = dict(zip(existing_df[cls.master_id.key], existing_df[cls.id.key])) + + mask_exists = data_df[cls.master_id.key].isin(existing_df[cls.master_id.key]) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df[cls.master_id.key].map(master_id_to_id_map) + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + @classmethod + async def find_by_master_id(cls, master_id: Union[str, int]): + """根据主表ID查找延时申请""" + _query = select(cls).where(cls.master_id == master_id) + return await cls.query_first(_query) + + @classmethod + async def find_by_order_id(cls, order_id: str): + """根据工单ID查找延时申请""" + _query = select(cls).where(cls.order_id == order_id) + return await cls.query_first(_query) + + @classmethod + async def find_by_master_ids(cls, master_ids: list[Union[str, int]]): + """根据主表ID列表批量查找延时申请""" + _query = select(cls).where(cls.master_id.in_(master_ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + +@register_swagger_model +class GovsApplicationForDelay(GovsApplicationForDelayBase): + """延时申请业务类""" + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """创建新延时申请""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsApplicationForDelayForm(formdata=kwargs) + _form.validate_form() + + _existing = await cls.is_exist( + master_id=_form.master_id.data, + order_id=_form.order_id.data, + order_no=_form.order_no.data + ) + assert _existing is None, "该任务已存在申请延期记录,不能重复创建。" + + _delay = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _delay.created_by = user.username + _delay.updated_by = user.username + await _delay.async_save() + return _delay + + @classmethod + async def delete(cls, delay_id: Union[str, int]): + """删除延时申请""" + _delay: cls = await cls.async_find_by_id(delay_id) + assert _delay, f"根据 ID {delay_id} 未找到延时申请。" + + _del_query = delete(cls).where(cls.id == _delay.id) + await cls.raw_execute(_del_query) + echo_log(f'已删除延时申请(工单ID:{_delay.master_id},ID:{_delay.id}).') + return _delay + + @classmethod + async def modify(cls, delay_id: Union[str, int], user: RbacUser = None, **kwargs): + """修改延时申请信息""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsApplicationForDelayForm(formdata=kwargs) + _form.validate_form() + + _delay: cls = await cls.async_find_by_id(delay_id) + assert _delay, f'查无此延时申请信息。' + + _delay.copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _delay.updated_by = user.username + await _delay.async_save() + return _delay + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量创建延时申请""" + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + delays = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(delays) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(delays)} 条延时申请。") + return len(delays) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量修改延时申请""" + if data_df.empty: + return 0 + + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列") + return 0 + + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + update_data = data_df.to_dict('records') + + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条延时申请。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量保存数据,自动处理新建和更新""" + _exists_df, _latest_df = await cls.exists_master_id(data_df) + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count diff --git a/models/govs_create_reply.py b/models/govs_create_reply.py new file mode 100644 index 0000000..e1e81dd --- /dev/null +++ b/models/govs_create_reply.py @@ -0,0 +1,348 @@ +# coding: utf-8 +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length + +import models +from models.db_models import TD3iGovsReplyFormal +from models.common_model import CommonModel +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovsReplyFormalForm(ModelForm): + """答复办结表单验证类""" + + id = IntegerField('主键') + master_id = IntegerField('主表ID') + master_id = StringField('代签收唯一标志', validators=[Length(max=64)]) + is_contact = StringField('是否联系服务对象', validators=[Length(max=10)]) + contact_name = StringField('联系人员', validators=[Length(max=100)]) + contact_time = StringField('联系时间', validators=[Length(max=64)]) + contact_type = StringField('联系情况', validators=[Length(max=255)]) + advice = TextAreaField('处理意见(面向群众公开)') + reason = TextAreaField('处理意见(面向群众公开2)') + remarks = StringField('备注', validators=[Length(max=500)]) + file_id_str = TextAreaField('OA文件id') + save_id = StringField('提交数据ID', validators=[Length(max=64)]) + process_instance_id = StringField('流程实例ID', validators=[Length(max=64)]) + business_key = StringField('业务键', validators=[Length(max=64)]) + order_no = StringField('工单号', validators=[Length(max=64)]) + action_name = StringField('操作名称', validators=[Length(max=255)]) + case_accord_type_one_name = StringField('诉求归口一级名称', validators=[Length(max=255)]) + case_accord_type_two_name = StringField('诉求归口二级名称', validators=[Length(max=255)]) + case_accord_type_three_name = StringField('诉求归口三级名称', validators=[Length(max=255)]) + save_status = IntegerField('提交状态') + oa_feedback_status = IntegerField('OA反馈状态') + flow_token = StringField('流令牌', validators=[Length(max=256)]) + created_at = DateTimeField('创建时间') + created_by = StringField('创建者', validators=[Length(max=64)]) + updated_at = DateTimeField('更新时间') + updated_by = StringField('更新者', validators=[Length(max=64)]) + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj=None, **kwargs) + + +class GovsReplyFormalBase(TD3iGovsReplyFormal, CommonModel): + """答复办结业务基类""" + + FieldMapping = { + # 主键 & 关联ID + 'id': 'id', + 'master_id': 'masterId', + 'gd_id': 'gdId', + 'order_no': 'orderNo', + 'process_instance_id': 'processInstanceId', + 'business_key': 'businessKey', + 'save_id': 'saveId', + 'action_name': 'actionName', + + # 联系服务对象信息 + 'is_contact': 'isContact', + 'contact_name': 'contactName', + 'contact_time': 'contactTime', + 'contact_type': 'contactType', + + # 处理意见 & 备注 + 'advice': 'advice', + 'reason': 'reason', + 'remarks': 'remarks', + 'file_id_str': 'fileIdStr', + + # 诉求归口分类 + 'case_accord_type_one_name': 'caseAccordTypeOneName', + 'case_accord_type_two_name': 'caseAccordTypeTwoName', + 'case_accord_type_three_name': 'caseAccordTypeThreeName', + + # 流令牌 + 'flow_token': 'flowToken', + } + + @classmethod + async def exist_other(cls, id: Union[str, int], master_id: Union[str, int] = None, order_no: str = None, + business_key: str = None): + """检查是否存在除当前记录外的同唯一标识答复办结记录""" + _query = select(cls).where(cls.id != id) + if master_id: + _query = _query.where(cls.master_id == master_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + if business_key: + _query = _query.where(cls.business_key == business_key) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """根据ID列表批量查询答复办结记录""" + _query = select(cls).where(cls.id.in_(ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + @classmethod + async def is_exist(cls, master_id: Union[str, int] = None, order_no: str = None, business_key: str = None): + """检查答复办结记录是否已存在""" + _query = select(cls) + if master_id: + _query = _query.where(cls.master_id == master_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + if business_key: + _query = _query.where(cls.business_key == business_key) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """答复办结基础搜索(分页/不分页)""" + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段配置 + _name_likes = { + cls.master_id.key: '%{}%', + cls.order_no.key: '%{}%', + cls.master_id.key: '%{}%', + cls.contact_name.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + # 排序处理 + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.created_at.desc()) + + _df = await cls.query_as_df(_data_query) + if not _df.empty: + _df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _df[cls.id.key] = _df[cls.id.key].astype(str) + + return _df, _paging + + @classmethod + async def search(cls, **kwargs): + """分页搜索答复办结记录,返回标准分页结构""" + _df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_master_id(cls, data_df: pd.DataFrame): + """根据 master_id 区分已有数据/新增数据(批量保存用)""" + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + master_ids = data_df[cls.master_id.key].unique().tolist() + if not master_ids: + return pd.DataFrame(), data_df.copy() + + _query = select(cls.id, cls.master_id).where(cls.master_id.in_(master_ids)) + existing_df = await cls.query_as_df(_query) + + if existing_df.empty: + return pd.DataFrame(), data_df.copy() + + master_id_to_id_map = dict(zip(existing_df[cls.master_id.key], existing_df[cls.id.key])) + mask_exists = data_df[cls.master_id.key].isin(existing_df[cls.master_id.key]) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df[cls.master_id.key].map(master_id_to_id_map) + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + @classmethod + async def find_by_master_id(cls, master_id: Union[str, int]): + """根据主表ID查询单条答复办结记录""" + _query = select(cls).where(cls.master_id == master_id) + return await cls.query_first(_query) + + @classmethod + async def find_by_business_key(cls, business_key: str): + """根据业务键查询答复办结记录""" + _query = select(cls).where(cls.business_key == business_key) + return await cls.query_first(_query) + + @classmethod + async def find_by_master_ids(cls, master_ids: list[Union[str, int]]): + """根据主表ID列表批量查询答复办结记录""" + _query = select(cls).where(cls.master_id.in_(master_ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + +@register_swagger_model +class GovsReplyFormal(GovsReplyFormalBase): + """答复办结业务操作类""" + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """新增答复办结记录""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsReplyFormalForm(formdata=kwargs) + _form.validate_form() + + _existing = await cls.is_exist( + master_id=_form.master_id.data, + order_no=_form.order_no.data, + business_key=_form.business_key.data + ) + assert _existing is None, "该任务已存在答复办结记录,无法重复创建。" + + _reply_info = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _reply_info.created_by = user.username + _reply_info.updated_by = user.username + await _reply_info.async_save() + return _reply_info + + @classmethod + async def delete(cls, reply_id: Union[str, int]): + """删除答复办结记录""" + _reply_info: cls = await cls.async_find_by_id(reply_id) + assert _reply_info, f"根据 ID {reply_id} 未找到答复办结记录。" + + _del_query = delete(cls).where(cls.id == _reply_info.id) + await cls.raw_execute(_del_query) + echo_log(f'已删除答复办结记录(工单ID:{_reply_info.master_id},ID:{_reply_info.id}).') + return _reply_info + + @classmethod + async def modify(cls, reply_id: Union[str, int], user: RbacUser = None, **kwargs): + """修改答复办结记录""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsReplyFormalForm(formdata=kwargs) + _form.validate_form() + + _reply_info: cls = await cls.async_find_by_id(reply_id) + assert _reply_info, f'查无此答复办结记录。' + + _reply_info.copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _reply_info.updated_by = user.username + await _reply_info.async_save() + return _reply_info + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量新增答复办结记录""" + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + reply_list = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(reply_list) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(reply_list)} 条答复办结记录。") + return len(reply_list) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量修改答复办结记录""" + if data_df.empty: + return 0 + + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列") + return 0 + + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + update_data = data_df.to_dict('records') + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条答复办结记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量保存(自动区分新增/更新)""" + _exists_df, _latest_df = await cls.exists_master_id(data_df) + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count diff --git a/models/govs_create_return.py b/models/govs_create_return.py new file mode 100644 index 0000000..6d5d1df --- /dev/null +++ b/models/govs_create_return.py @@ -0,0 +1,353 @@ +# coding: utf-8 +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length + +import models +from models.db_models import TD3iGovsWorkOrderReturnFormal +from models.common_model import CommonModel +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovsWorkOrderReturnFormalForm(ModelForm): + """工单退回表单验证类""" + + id = IntegerField('主键') + master_id = IntegerField('主表ID') + master_id = StringField('代签收唯一标志', validators=[Length(max=64)]) + return_reason = StringField('退回原因', validators=[Length(max=255)]) + return_reason_name = StringField('退回原因名称', validators=[Length(max=255)]) + return_auditor_name = StringField('退回审核人', validators=[Length(max=100)]) + return_auditor_id = StringField('退回审核人ID', validators=[Length(max=64)]) + deal_opinion = TextAreaField('处理意见') + reason = TextAreaField('处理意见2') + remark = StringField('备注', validators=[Length(max=500)]) + file_id_str = TextAreaField('OA文件id') + process_instance_id = StringField('流程实例ID', validators=[Length(max=64)]) + action_name = StringField('操作名称', validators=[Length(max=255)]) + order_id = StringField('工单ID', validators=[Length(max=64)]) + task_id = StringField('任务ID', validators=[Length(max=64)]) + order_no = StringField('工单号', validators=[Length(max=64)]) + case_accord_type_one_name = StringField('诉求归口一级名称', validators=[Length(max=255)]) + case_accord_type_two_name = StringField('诉求归口二级名称', validators=[Length(max=255)]) + case_accord_type_three_name = StringField('诉求归口三级名称', validators=[Length(max=255)]) + save_status = IntegerField('提交状态') + oa_feedback_status = IntegerField('OA反馈状态') + flow_token = StringField('流令牌', validators=[Length(max=256)]) + created_at = DateTimeField('创建时间') + created_by = StringField('创建者', validators=[Length(max=64)]) + updated_at = DateTimeField('更新时间') + updated_by = StringField('更新者', validators=[Length(max=64)]) + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovsWorkOrderReturnFormalBase(TD3iGovsWorkOrderReturnFormal, CommonModel): + """工单退回业务基类""" + + FieldMapping = { + # 主键 & 关联ID + 'id': 'id', + 'master_id': 'gdId', + 'gd_id': 'gdId', + 'order_id': 'orderId', + 'order_no': 'orderNo', + 'process_instance_id': 'processInstanceId', + 'task_id': 'taskId', + + # 退回核心信息 + 'return_reason': 'returnReason', + 'return_reason_name': 'returnReasonName', + 'return_auditor_name': 'returnAuditorName', + 'return_auditor_id': 'returnAuditorId', + + # 处理意见 & 备注 + 'deal_opinion': 'dealOpinion', + 'reason': 'reason', + 'remark': 'remark', + 'file_id_str': 'fileIdStr', + + # 流程 & 分类信息 + 'action_name': 'actionName', + 'case_accord_type_one_name': 'caseAccordTypeOneName', + 'case_accord_type_two_name': 'caseAccordTypeTwoName', + 'case_accord_type_three_name': 'caseAccordTypeThreeName', + + # 状态 & 令牌 + 'flow_token': 'flowToken', + } + + @classmethod + async def exist_other(cls, id: Union[str, int], master_id: Union[str, int] = None, order_id: str = None, + order_no: str = None): + """检查是否存在除当前记录外的同唯一标识工单退回记录""" + _query = select(cls).where(cls.id != id) + if master_id: + _query = _query.where(cls.master_id == master_id) + if order_id: + _query = _query.where(cls.order_id == order_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """根据ID列表批量查询工单退回记录""" + _query = select(cls).where(cls.id.in_(ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + @classmethod + async def is_exist(cls, master_id: Union[str, int] = None, order_id: str = None, order_no: str = None): + """检查工单退回记录是否已存在""" + _query = select(cls) + if master_id: + _query = _query.where(cls.master == master_id) + if order_id: + _query = _query.where(cls.order_id == order_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """工单退回基础搜索(分页/不分页)""" + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段配置 + _name_likes = { + cls.master_id.key: '%{}%', + cls.order_no.key: '%{}%', + cls.return_reason.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + # 排序处理 + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.created_at.desc()) + + _df = await cls.query_as_df(_data_query) + if not _df.empty: + _df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _df[cls.id.key] = _df[cls.id.key].astype(str) + + return _df, _paging + + @classmethod + async def search(cls, **kwargs): + """分页搜索工单退回记录,返回标准分页结构""" + _df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_master_id(cls, data_df: pd.DataFrame): + """根据 master_id 区分已有数据/新增数据(批量保存用)""" + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + master_ids = data_df[cls.master_id.key].unique().tolist() + if not master_ids: + return pd.DataFrame(), data_df.copy() + + _query = select(cls.id, cls.master_id).where(cls.master_id.in_(master_ids)) + existing_df = await cls.query_as_df(_query) + + if existing_df.empty: + return pd.DataFrame(), data_df.copy() + + master_id_to_id_map = dict(zip(existing_df[cls.master_id.key], existing_df[cls.id.key])) + mask_exists = data_df[cls.master_id.key].isin(existing_df[cls.master_id.key]) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df[cls.master_id.key].map(master_id_to_id_map) + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + @classmethod + async def find_by_master_id(cls, master_id: Union[str, int]): + """根据主表ID查询单条退回记录""" + _query = select(cls).where(cls.master_id == master_id) + return await cls.query_first(_query) + + @classmethod + async def find_by_master_id(cls, master_id: str): + """根据代签收唯一标志查询退回记录""" + _query = select(cls).where(cls.master_id == master_id) + return await cls.query_first(_query) + + @classmethod + async def find_by_order_id(cls, order_id: str): + """根据工单ID查询退回记录""" + _query = select(cls).where(cls.order_id == order_id) + return await cls.query_first(_query) + + @classmethod + async def find_by_master_ids(cls, master_ids: list[Union[str, int]]): + """根据主表ID列表批量查询退回记录""" + _query = select(cls).where(cls.master_id.in_(master_ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + +@register_swagger_model +class GovsWorkOrderReturnFormal(GovsWorkOrderReturnFormalBase): + """工单退回业务操作类""" + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """新增工单退回记录""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsWorkOrderReturnFormalForm(formdata=kwargs) + _form.validate_form() + + _existing = await cls.is_exist( + master_id=_form.master_id.data, + order_id=_form.order_id.data, + order_no=_form.order_no.data + ) + assert _existing is None, "该任务已存在工单退回记录,无法重复创建。" + + _return_info = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _return_info.created_by = user.username + _return_info.updated_by = user.username + await _return_info.async_save() + return _return_info + + @classmethod + async def delete(cls, return_id: Union[str, int]): + """删除工单退回记录""" + _return_info: cls = await cls.async_find_by_id(return_id) + assert _return_info, f"根据 ID {return_id} 未找到工单退回记录。" + + _del_query = delete(cls).where(cls.id == _return_info.id) + await cls.raw_execute(_del_query) + echo_log(f'已删除工单退回记录(工单ID:{_return_info.master_id},ID:{_return_info.id}).') + return _return_info + + @classmethod + async def modify(cls, return_id: Union[str, int], user: RbacUser = None, **kwargs): + """修改工单退回记录""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsWorkOrderReturnFormalForm(formdata=kwargs) + _form.validate_form() + + _return_info: cls = await cls.async_find_by_id(return_id) + assert _return_info, f'查无此工单退回记录。' + + _return_info.copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _return_info.updated_by = user.username + await _return_info.async_save() + return _return_info + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量新增工单退回记录""" + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + return_list = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(return_list) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(return_list)} 条工单退回记录。") + return len(return_list) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量修改工单退回记录""" + if data_df.empty: + return 0 + + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列") + return 0 + + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + update_data = data_df.to_dict('records') + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单退回记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量保存(自动区分新增/更新)""" + _exists_df, _latest_df = await cls.exists_master_id(data_df) + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count diff --git a/models/govs_order_attachment.py b/models/govs_order_attachment.py new file mode 100644 index 0000000..b37cbee --- /dev/null +++ b/models/govs_order_attachment.py @@ -0,0 +1,290 @@ +# coding: utf-8 +import datetime +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, IntegerField +from wtforms.validators import Length + +from models.common_model import CommonModel +from models.db_models import TD3iGovsOrderAttachment +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.web.form import ModelForm + + +class GovsOrderAttachmentForm(ModelForm): + """工单附件表单验证类""" + + id = IntegerField('附件唯一ID') + master_id = IntegerField('关联工单主表ID') + order_id = StringField('工单编号', validators=[Length(max=50)]) + file_path = StringField('文件路径(内网地址)', validators=[Length(max=500)]) + out_file_path = StringField('外网文件路径', validators=[Length(max=500)]) + attach_name = StringField('附件名称', validators=[Length(max=200)]) + to_tenant_id = StringField('目标租户ID', validators=[Length(max=50)]) + create_date = StringField('记录创建时间') + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovsOrderAttachmentBase(TD3iGovsOrderAttachment, CommonModel): + """工单附件业务基类""" + + FieldMapping = { + # ==================== 主键与关联 ==================== + 'id': 'id', # 附件唯一ID + + # ==================== 文件信息 ==================== + 'file_path': 'filePath', # 文件路径(内网地址) + 'out_file_path': 'outFilePath', # 外网文件路径 + 'attach_name': 'attachName', # 附件名称 + 'to_tenant_id': 'toTenantId', # 目标租户ID + } + + @classmethod + async def find_by_order_id(cls, order_id: str): + """根据工单编号查找附件""" + _query = select(cls).where(cls.order_id == order_id) + return (await cls.orm_execute_scalars(_query)).all() + + @classmethod + async def find_by_master_id(cls, master_id: Union[str, int]): + """根据主工单ID查找附件""" + _query = select(cls).where(cls.master_id == master_id) + return (await cls.orm_execute_scalars(_query)).all() + + @classmethod + async def find_by_attach_name(cls, attach_name: str): + """根据附件名称查找""" + _query = select(cls).where(cls.attach_name == attach_name) + return (await cls.orm_execute_scalars(_query)).all() + + @classmethod + async def exists_order_id(cls, data_df: pd.DataFrame): + """根据 order_id 判断数据是否存在 + :param data_df: 输入的数据框架,必须包含 id 和 order_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录(附带数据库中的 id) + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 (id, order_id) 组合 + pairs = data_df[[cls.id.key, cls.order_id.key]].drop_duplicates().values.tolist() + if not pairs: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的记录(使用 IN 批量查询) + _query = select(cls.id, cls.order_id).where( + (cls.id.in_([p[0] for p in pairs])) & + (cls.order_id.in_([p[1] for p in pairs])) + ) + exists_db_df = await cls.query_as_df(_query) + + if exists_db_df.empty: + return pd.DataFrame(), data_df.copy() + + exists_db_df[cls.id.key] = exists_db_df[cls.id.key].astype(str) + exists_db_df[cls.order_id.key] = exists_db_df[cls.order_id.key].astype(str) + # 构建 (id, order_id) -> id 的映射(用于快速查找) + key_to_id_map = dict(zip( + zip(exists_db_df[cls.id.key], exists_db_df[cls.order_id.key]), + exists_db_df[cls.id.key] + )) + + # 标记 data_df 中哪些行在数据库中存在 + mask_exists = data_df.apply( + lambda row: (row[cls.id.key], row[cls.order_id.key]) in key_to_id_map, + axis=1 + ) + # 提取存在的记录,并补充数据库中的 id(虽然输入中已有 id,但为一致性保留) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df.apply( + lambda row: key_to_id_map[(row[cls.id.key], row[cls.order_id.key])], + axis=1 + ) + # 提取不存在的记录 + latest_df = data_df[~mask_exists].copy() + + return exists_df, latest_df + + +@register_swagger_model +class GovsOrderAttachment(GovsOrderAttachmentBase): + """工单附件业务类""" + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """创建附件记录""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsOrderAttachmentForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在(根据 id) + _existing = await cls.async_find_by_id(_form.id.data) + if _existing: + # 更新已有记录 + _existing.copy_from_dict(_form.data, skip_none=True) + if user: + _existing.updated_by = user.username + await _existing.async_save() + return _existing + + _obj = cls().copy_from_dict(_form.data, skip_none=True) + if user: + _obj.created_by = user.username + _obj.updated_by = user.username + await _obj.async_save() + return _obj + + @classmethod + async def delete(cls, obj_id: Union[str, int]): + """删除附件记录""" + _obj: cls = await cls.async_find_by_id(obj_id) + assert _obj, f"根据 ID {obj_id} 未找到附件记录。" + + _del_query = delete(cls).where(cls.id == _obj.id) + await cls.raw_execute(_del_query) + echo_log(f'已删除附件记录(order_id:{_obj.order_id},ID:{_obj.id}).') + return _obj + + @classmethod + async def modify(cls, obj_id: Union[str, int], user: RbacUser = None, **kwargs): + """修改附件记录""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsOrderAttachmentForm(formdata=kwargs) + _form.validate_form() + + _obj: cls = await cls.async_find_by_id(obj_id) + assert _obj, f'查无此附件记录。' + + _obj.copy_from_dict(_form.data, skip_none=True) + if user: + _obj.updated_by = user.username + await _obj.async_save() + return _obj + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量创建附件记录""" + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + objs = [cls().copy_from_dict(record, skip_none=True) for record in records] + + session = cls.get_aio_session() + try: + session.add_all(objs) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(objs)} 条附件记录。") + return len(objs) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量修改附件记录""" + if data_df.empty: + return 0 + + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列") + return 0 + + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + update_data = data_df.to_dict('records') + + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条附件记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量保存数据,自动处理新建和更新""" + _exists_df, _latest_df = await cls.exists_order_id(data_df) + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count + + @classmethod + async def create_or_update_by_id(cls, user: RbacUser = None, **kwargs): + """根据 id 创建或更新附件记录""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsOrderAttachmentForm(formdata=kwargs) + _form.validate_form() + + _existing = await cls.async_find_by_id(_form.id.data) + if _existing: + _existing.copy_from_dict(_form.data, skip_none=True) + if user: + _existing.updated_by = user.username + await _existing.async_save() + return _existing + + _obj = cls().copy_from_dict(_form.data, skip_none=True) + if user: + _obj.created_by = user.username + _obj.updated_by = user.username + await _obj.async_save() + return _obj + + @classmethod + async def delete_by_master_id(cls, master_id: Union[str, int]): + """根据主工单ID删除附件记录""" + attachments = await cls.find_by_master_id(master_id) + if attachments: + for att in attachments: + await cls.delete(att.id) + return attachments + + @classmethod + async def delete_by_order_id(cls, order_id: str): + """根据工单编号删除附件记录""" + attachments = await cls.find_by_order_id(order_id) + if attachments: + for att in attachments: + await cls.delete(att.id) + return attachments \ No newline at end of file diff --git a/models/govs_order_detail.py b/models/govs_order_detail.py new file mode 100644 index 0000000..2e52012 --- /dev/null +++ b/models/govs_order_detail.py @@ -0,0 +1,601 @@ +# coding: utf-8 +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovsOrderDetail +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovsOrderDetailForm(ModelForm): + """省12345工单详情表单验证类""" + + id = IntegerField('详情记录唯一ID') + master_id = IntegerField('关联工单主表ID') + order_id = StringField('工单编号', validators=[Length(max=50)]) + order_no = StringField('工单号', validators=[Length(max=50)]) + master_id_origin = StringField('原始主表ID', validators=[Length(max=50)]) + tenant_id = StringField('租户ID', validators=[Length(max=50)]) + tenant_name = StringField('租户名称', validators=[Length(max=50)]) + order_status = StringField('工单状态码', validators=[Length(max=10)]) + order_status_for_view = StringField('工单状态显示值', validators=[Length(max=50)]) + claim_status = StringField('签收状态', validators=[Length(max=10)]) + over_due = StringField('是否超期', validators=[Length(max=10)]) + is_supervise = StringField('是否督办', validators=[Length(max=10)]) + first_order_status = StringField('一级状态编码', validators=[Length(max=10)]) + secord_order_status = StringField('二级状态编码', validators=[Length(max=10)]) + atomic_order_status = StringField('原子状态编码', validators=[Length(max=10)]) + order_invalid_type = TextAreaField('工单作废原因') + order_finish_time = DateTimeField('工单完成时间') + case_content = TextAreaField('诉求内容') + case_goal = TextAreaField('诉求目的') + title = StringField('工单标题', validators=[Length(max=500)]) + case_labels = TextAreaField('工单标签列表') + case_public = StringField('是否公开', validators=[Length(max=10)]) + hotspot = StringField('是否热点事件', validators=[Length(max=10)]) + case_is_urgent = StringField('紧急程度', validators=[Length(max=10)]) + case_is_visit = StringField('是否回访', validators=[Length(max=10)]) + info_protect = StringField('信息保护', validators=[Length(max=10)]) + case_accord_type_one_name = StringField('诉求归口一级', validators=[Length(max=50)]) + case_accord_type_two_name = StringField('诉求归口二级', validators=[Length(max=50)]) + case_accord_type_three_name = StringField('诉求归口三级', validators=[Length(max=50)]) + case_accord_type_four_name = StringField('诉求归口四级', validators=[Length(max=50)]) + case_accord_type_five_name = StringField('诉求归口五级', validators=[Length(max=50)]) + case_accord_code = StringField('事项编码', validators=[Length(max=50)]) + first_level_affiliation = TextAreaField('一级归属单位') + second_level_affiliation = TextAreaField('二级归属单位') + third_level_affiliation = TextAreaField('三级归属单位') + fourth_level_affiliation = TextAreaField('四级归属单位') + fifth_level_affiliation = TextAreaField('五级归属单位') + sixth_level_affiliation = TextAreaField('六级归属单位') + seventh_level_affiliation = TextAreaField('七级归属单位') + appeal_dept = StringField('诉求部门', validators=[Length(max=100)]) + order_source = StringField('诉求来源', validators=[Length(max=50)]) + order_source_detail = StringField('诉求来源详情', validators=[Length(max=50)]) + order_source_for_view = StringField('诉求来源显示值', validators=[Length(max=50)]) + belong_platform = StringField('所属平台代码', validators=[Length(max=50)]) + belong_platform_name = StringField('受理平台名称', validators=[Length(max=50)]) + current_processing_platform = TextAreaField('当前处理平台') + service_object_type = StringField('服务对象类型', validators=[Length(max=50)]) + order_type = StringField('表单类型', validators=[Length(max=50)]) + form_type = StringField('表单类型代码', validators=[Length(max=50)]) + area_code = StringField('区域代码', validators=[Length(max=10)]) + area_code_city = StringField('市区域代码', validators=[Length(max=50)]) + area_code_area = StringField('区区域代码', validators=[Length(max=50)]) + area_code_street = StringField('街道区域代码', validators=[Length(max=50)]) + address_detail = StringField('详细地址', validators=[Length(max=500)]) + case_lnglat = StringField('地理坐标', validators=[Length(max=100)]) + call_number = StringField('来电号码', validators=[Length(max=20)]) + call_number_for_dh = StringField('来电号码(脱敏)', validators=[Length(max=20)]) + raw_call_numer = StringField('原始来电号码', validators=[Length(max=20)]) + contact_number = StringField('联系电话', validators=[Length(max=20)]) + raw_contact_number = StringField('原始联系电话', validators=[Length(max=20)]) + contact_number_for_dh = StringField('联系电话(脱敏)', validators=[Length(max=20)]) + call_time = DateTimeField('来电时间') + order_sound_record_id = StringField('通话记录ID', validators=[Length(max=50)]) + create_date = DateTimeField('创建日期') + update_date = DateTimeField('更新日期') + plan_finish_time = DateTimeField('计划完成时间') + plan_sign_time = DateTimeField('计划签收时间') + judgment_flag = StringField('判定标志', validators=[Length(max=10)]) + is_coordination = StringField('是否协调', validators=[Length(max=10)]) + coordination_time = DateTimeField('协调时间') + thrid_order_id = TextAreaField('第三方工单ID') + relate_order_ids = TextAreaField('关联工单ID列表') + relate_order_count = IntegerField('关联工单数量') + order_user_id = StringField('用户ID', validators=[Length(max=50)]) + user_word = TextAreaField('用户反馈') + show_flag = StringField('显示标志', validators=[Length(max=10)]) + origin_show = IntegerField('原始显示标志') + order_user = TextAreaField('诉求人信息(JSON对象)') + order_phone_dto = TextAreaField('电话号码信息(JSON对象)') + order_attachment_list = TextAreaField('附件列表(JSON数组)') + pre_process_list = TextAreaField('预处理流程列表(JSON数组)') + tripartite_call_records = TextAreaField('三方通话记录(JSON对象)') + tripartite_call_records_list = TextAreaField('三方通话记录列表(JSON数组)') + order_custom_form_fields = TextAreaField('自定义表单字段(JSON数组)') + knowledge_references = TextAreaField('知识参考(JSON对象)') + sound_recording_address_list = TextAreaField('录音文件路径列表(JSON数组)') + active_dept_ids = TextAreaField('当前处理部门ID列表') + attachment_ids = TextAreaField('附件ID列表') + attachment_list = TextAreaField('附件列表JSON') + contactor_list = TextAreaField('联系人列表(JSON数组)') + tsjb_entry_info = TextAreaField('投诉举报入口信息(JSON对象)') + order_erge_revoke_plug_dto_list = TextAreaField('撤销插件信息(JSON数组)') + order_environmental = TextAreaField('环境信息(JSON对象)') + order_demands_dto = TextAreaField('诉求DTO(JSON对象)') + order_appeal_list = TextAreaField('申诉列表(JSON数组)') + torder_process_list = TextAreaField('流程列表(JSON数组)') + pre_process = TextAreaField('预处理信息(JSON对象)') + extension = TextAreaField('扩展字段') + remark = TextAreaField('备注') + file_exist = IntegerField('是否存在附件') + exist_quoto_info = TextAreaField('是否存在引用信息') + residue_date = TextAreaField('剩余天数') + whether_approval = StringField('是否审批', validators=[Length(max=10)]) + over_time_warning_flag = StringField('超时预警标志', validators=[Length(max=10)]) + create_no = StringField('创建编号', validators=[Length(max=20)]) + return_visit_reason = TextAreaField('回访原因') + back_count = StringField('回退次数', validators=[Length(max=100)]) + visit_adv_content = TextAreaField('走访建议内容') + is_dispatch_accurate = StringField('是否精准分派', validators=[Length(max=10)]) + process_instance_id = StringField('流程实例ID', validators=[Length(max=100)]) + knowledge_quote = TextAreaField('知识引用') + special_type = TextAreaField('特殊类型') + supervise_type = TextAreaField('监督类型') + leader_indicate = TextAreaField('领导批示') + case_solve = TextAreaField('处理结果') + result_satisfied = TextAreaField('结果满意度') + first_vist_satisfied = TextAreaField('首次走访满意度') + contact_timely = StringField('是否及时联系', validators=[Length(max=50)]) + distribute_type = StringField('分派类型', validators=[Length(max=50)]) + dept_type = TextAreaField('部门类型') + dept_name = TextAreaField('部门名称') + active_dept_name = StringField('当前处理部门名称', validators=[Length(max=50)]) + org_id = StringField('组织ID', validators=[Length(max=50)]) + org_name = TextAreaField('组织名称') + snapshot_time = DateTimeField('快照抓取时间') + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovsOrderDetailBase(TD3iGovsOrderDetail, CommonModel): + """省12345工单详情业务基类""" + + FieldMapping = { + # ==================== 主键与关联 ==================== + 'master_id': 'masterId', # 关联工单主表ID(从外部传入) + 'order_id': 'orderId', # 工单编号 + 'order_no': 'orderNo', # 工单号 + 'tenant_id': 'tenantId', # 租户ID + + # ==================== 工单状态 ==================== + 'order_status': 'orderStatus', # 工单状态码 + 'order_status_for_view': 'orderStatusForView', # 工单状态显示值 + 'first_order_status': 'firstOrderStatus', # 一级状态编码 + 'secord_order_status': 'secordOrderStatus', # 二级状态编码 + 'atomic_order_status': 'atomicOrderStatus', # 原子状态编码 + 'order_invalid_type': 'orderInvalidType', # 工单作废原因 + 'order_finish_time': 'orderFinishTime', # 工单完成时间 + + # ==================== 诉求内容 ==================== + 'case_content': 'caseContent', # 诉求内容 + 'case_goal': 'caseGoal', # 诉求目的 + 'title': 'title', # 工单标题 + 'case_labels': 'caseLabels', # 工单标签列表 + 'case_public': 'casePublic', # 是否公开 + 'hotspot': 'hotspot', # 是否热点事件 + + # ==================== 紧急与保护 ==================== + 'case_is_urgent': 'caseIsUrgent', # 紧急程度(一般/紧急/特急) + 'case_is_visit': 'caseIsVisit', # 是否回访(是/否) + 'info_protect': 'infoProtect', # 信息保护(是/否) + + # ==================== 诉求归口分类 ==================== + 'case_accord_type_one_name': 'caseAccordTypeOneName', # 诉求归口一级 + 'case_accord_type_two_name': 'caseAccordTypeTwoName', # 诉求归口二级 + 'case_accord_type_three_name': 'caseAccordTypeThreeName', # 诉求归口三级 + 'case_accord_type_four_name': 'caseAccordTypeFourName', # 诉求归口四级 + 'case_accord_type_five_name': 'caseAccordTypeFiveName', # 诉求归口五级 + 'case_accord_code': 'caseAccordCode', # 事项编码 + + # ==================== 归属单位 ==================== + 'first_level_affiliation': 'firstLevelAffiliation', # 一级归属单位 + 'second_level_affiliation': 'secondLevelAffiliation', # 二级归属单位 + 'third_level_affiliation': 'thirdLevelAffiliation', # 三级归属单位 + 'fourth_level_affiliation': 'fourthLevelAffiliation', # 四级归属单位 + 'fifth_level_affiliation': 'fifthLevelAffiliation', # 五级归属单位 + 'sixth_level_affiliation': 'sixthLevelAffiliation', # 六级归属单位 + 'seventh_level_affiliation': 'seventhLevelAffiliation', # 七级归属单位 + 'appeal_dept': 'appealDept', # 诉求部门 + + # ==================== 来源与平台 ==================== + 'order_source': 'orderSource', # 诉求来源(电话/互联网) + 'order_source_detail': 'orderSourceDetail', # 诉求来源详情(12345/随手拍) + 'order_source_for_view': 'orderSourceForView', # 诉求来源显示值 + 'belong_platform': 'belongPlatform', # 所属平台代码 + 'belong_platform_name': 'belongPlatformName', # 受理平台名称 + 'current_processing_platform': 'currentProcessingPlatform', # 当前处理平台 + + # ==================== 服务对象与类型 ==================== + 'service_object_type': 'serviceObjectType', # 服务对象类型(投诉举报/咨询/建议等) + 'order_type': 'orderType', # 表单类型(个人/企业/其他) + 'form_type': 'formType', # 表单类型代码 + + # ==================== 区域信息 ==================== + 'area_code_city': 'areaCodeCity', # 市区域代码 + 'area_code_area': 'areaCodeArea', # 区区域代码 + 'area_code_street': 'areaCodeStreet', # 街道区域代码 + 'address_detail': 'addressDetail', # 详细地址 + 'case_lnglat': 'caseLnglat', # 地理坐标 + + # ==================== 联系方式 ==================== + 'call_number': 'callNumber', # 来电号码 + 'call_number_for_dh': 'callNumberForDH', # 来电号码(脱敏) + 'raw_call_numer': 'rawCallNumer', # 原始来电号码 + 'contact_number': 'contactNumber', # 联系电话 + 'raw_contact_number': 'rawContactNumber', # 原始联系电话 + 'contact_number_for_dh': 'contactNumberForDH', # 联系电话(脱敏) + 'call_time': 'callTime', # 来电时间 + 'order_sound_record_id': 'orderSoundRecordId', # 通话记录ID + + # ==================== 时间节点 ==================== + 'create_date': 'createDate', # 创建日期 + 'update_date': 'updateDate', # 更新日期 + 'plan_finish_time': 'planFinishTime', # 计划完成时间 + 'plan_sign_time': 'planSignTime', # 计划签收时间 + + # ==================== 判定与协调 ==================== + 'judgment_flag': 'judgmentFlag', # 判定标志 + 'is_coordination': 'isCoordination', # 是否协调 + 'coordination_time': 'coordinationTime', # 协调时间 + + # ==================== 第三方关联 ==================== + 'thrid_order_id': 'thridOrderId', # 第三方工单ID + 'relate_order_ids': 'relateOrderIds', # 关联工单ID列表 + 'relate_order_count': 'relateOrderCount', # 关联工单数量 + + # ==================== 用户相关 ==================== + 'order_user_id': 'orderUserId', # 用户ID(身份证号) + 'user_word': 'userWord', # 用户反馈 + 'show_flag': 'showFlag', # 显示标志 + 'origin_show': 'originShow', # 原始显示标志 + + # ==================== JSON 对象字段(需序列化存储) ==================== + 'order_user': 'orderUser', # 诉求人信息(JSON对象) + 'order_phone_dto': 'orderPhoneDTO', # 电话号码信息(JSON对象) + 'order_attachment_list': 'orderAttachmentList', # 附件列表(JSON数组) + 'pre_process_list': 'preProcessList', # 预处理流程列表(JSON数组) + 'tripartite_call_records': 'tripartiteCallRecords', # 三方通话记录(JSON对象) + 'tripartite_call_records_list': 'tripartiteCallRecordsList', # 三方通话记录列表(JSON数组) + 'order_custom_form_fields': 'orderCustomFormFields', # 自定义表单字段(JSON数组) + 'knowledge_references': 'knowledgeReferences', # 知识参考(JSON对象) + 'sound_recording_address_list': 'soundRecordingAddressList', # 录音文件路径列表(JSON数组) + 'active_dept_ids': 'activeDeptIds', # 当前处理部门ID列表 + 'attachment_ids': 'attachmentIds', # 附件ID列表 + 'attachment_list': 'attachmentList', # 附件列表JSON + 'contactor_list': 'contactorList', # 联系人列表(JSON数组) + 'tsjb_entry_info': 'tsjbEntryInfo', # 投诉举报入口信息(JSON对象) + 'order_erge_revoke_plug_dto_list': 'orderErgeRevokePlugDTOList', # 撤销插件信息(JSON数组) + 'order_environmental': 'orderEnvironmental', # 环境信息(JSON对象) + 'order_demands_dto': 'orderDemandsDTO', # 诉求DTO(JSON对象) + 'order_appeal_list': 'orderAppealList', # 申诉列表(JSON数组) + 'torder_process_list': 'torderProcessList', # 流程列表(JSON数组) + 'pre_process': 'preProcess', # 预处理信息(JSON对象) + 'extension': 'extension', # 扩展字段 + 'remark': 'remark', # 备注 + + # ==================== 其他字段 ==================== + 'file_exist': None, # 是否存在附件(根据 orderAttachmentList 计算) + 'exist_quoto_info': 'existQuotoInfo', # 是否存在引用信息 + 'residue_date': 'residueDate', # 剩余天数 + 'whether_approval': 'whetherApproval', # 是否审批 + 'over_time_warning_flag': 'overTimeWarningFlag', # 超时预警标志 + 'create_no': 'createNo', # 创建编号 + 'return_visit_reason': 'returnVisitReason', # 回访原因 + 'back_count': 'backCount', # 回退次数 + 'visit_adv_content': 'visitAdvContent', # 走访建议内容 + 'is_dispatch_accurate': 'isDispatchAccurate', # 是否精准分派 + 'process_instance_id': 'processInstanceId', # 流程实例ID + 'knowledge_quote': 'knowledgeQuote', # 知识引用 + 'special_type': 'specialType', # 特殊类型 + 'supervise_type': 'superviseType', # 监督类型 + 'leader_indicate': 'leaderIndicate', # 领导批示 + 'case_solve': 'caseSolve', # 处理结果 + 'result_satisfied': 'resultSatisfied', # 结果满意度 + 'first_vist_satisfied': 'firstVistSatisfied', # 首次走访满意度 + 'contact_timely': 'contactTimely', # 是否及时联系 + 'distribute_type': 'distributeType', # 分派类型 + 'dept_type': 'deptType', # 部门类型 + 'dept_name': 'deptName', # 部门名称 + 'active_dept_name': 'activeDeptName', # 当前处理部门名称 + 'org_id': 'orgId', # 组织ID + 'org_name': 'orgName', # 组织名称 + } + + @classmethod + async def exist_other(cls, id: Union[str, int], order_id: str = None, order_no: str = None): + """检查是否存在除当前详情外的其他同编号工单详情""" + _query = select(cls).where(cls.id != id) + if order_id: + _query = _query.where(cls.order_id == order_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """根据ID列表批量查找工单详情""" + _query = select(cls).where(cls.id.in_(ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + @classmethod + async def is_exist(cls, order_id: str = None, order_no: str = None): + """检查工单详情是否已经存在""" + _query = select(cls) + if order_id: + _query = _query.where(cls.order_id == order_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """按参数搜索工单详情的基础方法""" + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + _name_likes = { + cls.order_status.key: '%{}%', + cls.order_source.key: '%{}%', + cls.case_accord_type_one_name.key: '%{}%', + cls.belong_platform_name.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.create_date.desc()) + + _df = await cls.query_as_df(_data_query) + if not _df.empty: + _df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _df[cls.id.key] = _df[cls.id.key].astype(str) + + return _df, _paging + + @classmethod + async def search(cls, **kwargs): + """按参数搜索工单详情,返回分页格式数据""" + _df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_order_id(cls, data_df: pd.DataFrame): + """根据 order_id 判断数据是否存在""" + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + order_ids = data_df[cls.order_id.key].unique().tolist() + if not order_ids: + return pd.DataFrame(), data_df.copy() + + _query = select(cls.id, cls.order_id).where(cls.order_id.in_(order_ids)) + existing_df = await cls.query_as_df(_query) + + if existing_df.empty: + return pd.DataFrame(), data_df.copy() + + order_id_to_id_map = dict(zip(existing_df[cls.order_id.key], existing_df[cls.id.key])) + + mask_exists = data_df[cls.order_id.key].isin(existing_df[cls.order_id.key]) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df[cls.order_id.key].map(order_id_to_id_map) + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + @classmethod + async def find_by_master_id(cls, master_id: Union[str, int]): + """根据主表ID查找工单详情""" + _query = select(cls).where(cls.master_id == master_id) + return await cls.query_first(_query) + + @classmethod + async def find_by_order_id(cls, order_id: str): + """根据工单编号查找详情""" + _query = select(cls).where(cls.order_id == order_id) + return await cls.query_first(_query) + + @classmethod + async def find_latest_snapshot(cls, order_id: str): + """查找最新的快照""" + _query = select(cls).where(cls.order_id == order_id).order_by(cls.snapshot_time.desc()) + return await cls.query_first(_query) + + @classmethod + async def find_by_master_ids(cls, master_ids: list[Union[str, int]]): + """根据主表ID列表批量查找工单详情""" + _query = select(cls).where(cls.master_id.in_(master_ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + +@register_swagger_model +class GovsOrderDetail(GovsOrderDetailBase): + """省12345工单详情业务类""" + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """创建新工单详情""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsOrderDetailForm(formdata=kwargs) + _form.validate_form() + + _existing = await cls.is_exist( + order_id=_form.order_id.data, + order_no=_form.order_no.data + ) + assert _existing is None, "工单编号或工单号已存在,不能重复创建。" + + _detail = cls().copy_from_dict(_form.data, skip_none=True).before_save() + _detail.snapshot_time = datetime.datetime.now() + if user: + _detail.created_by = user.username + _detail.updated_by = user.username + await _detail.async_save() + return _detail + + @classmethod + async def delete(cls, detail_id: Union[str, int]): + """删除工单详情""" + _detail: cls = await cls.async_find_by_id(detail_id) + assert _detail, f"根据 ID {detail_id} 未找到工单详情。" + + _del_query = delete(cls).where(cls.id == _detail.id) + await cls.raw_execute(_del_query) + echo_log(f'已删除工单详情(工单号:{_detail.order_no},ID:{_detail.id}).') + return _detail + + @classmethod + async def modify(cls, detail_id: Union[str, int], user: RbacUser = None, **kwargs): + """修改工单详情信息""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsOrderDetailForm(formdata=kwargs) + _form.validate_form() + + _other = await cls.exist_other( + detail_id, + order_id=_form.order_id.data, + order_no=_form.order_no.data + ) + assert _other is None, "工单编号或工单号已存在,不能重复修改。" + + _detail: cls = await cls.async_find_by_id(detail_id) + assert _detail, f'查无此工单详情信息。' + + _detail.copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _detail.updated_by = user.username + await _detail.async_save() + return _detail + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量创建工单详情""" + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + details = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(details) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(details)} 条工单详情。") + return len(details) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量修改工单详情""" + if data_df.empty: + return 0 + + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列") + return 0 + + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + update_data = data_df.to_dict('records') + + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单详情。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量保存数据,自动处理新建和更新""" + _exists_df, _latest_df = await cls.exists_order_id(data_df) + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count + + @classmethod + async def create_or_update(cls, user: RbacUser = None, **kwargs): + """创建或更新工单详情(单条)""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsOrderDetailForm(formdata=kwargs) + _form.validate_form() + + _existing = await cls.find_by_order_id(_form.order_id.data) + if _existing: + _existing.copy_from_dict(_form.data, skip_none=True).before_save() + _existing.snapshot_time = datetime.datetime.now() + if user: + _existing.updated_by = user.username + await _existing.async_save() + return _existing + + _detail = cls().copy_from_dict(_form.data, skip_none=True).before_save() + _detail.snapshot_time = datetime.datetime.now() + if user: + _detail.created_by = user.username + _detail.updated_by = user.username + await _detail.async_save() + return _detail \ No newline at end of file diff --git a/models/govs_order_master.py b/models/govs_order_master.py new file mode 100644 index 0000000..bd4ef79 --- /dev/null +++ b/models/govs_order_master.py @@ -0,0 +1,481 @@ +# coding: utf-8 +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovsOrderMaster +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovsOrderMasterForm(ModelForm): + """省12345工单主表表单验证类""" + + id = IntegerField('工单唯一ID') + belong_dept = StringField('所属部门', validators=[Length(max=100)]) + order_id = StringField('工单编号', validators=[Length(max=50)]) + order_no = StringField('工单号', validators=[Length(max=50)]) + order_type = IntegerField('表单类型') + order_source = StringField('诉求来源', validators=[Length(max=30)]) + order_source_detail = StringField('诉求来源详情', validators=[Length(max=30)]) + order_status = StringField('工单状态', validators=[Length(max=50)]) + order_user_id = StringField('用户ID', validators=[Length(max=50)]) + order_user_name = StringField('来电人姓名', validators=[Length(max=50)]) + order_user_sex = StringField('来电人性别', validators=[Length(max=50)]) + order_user_phone2 = StringField('备用联系电话', validators=[Length(max=20)]) + order_handle_way = StringField('处理方式', validators=[Length(max=50)]) + order_invalid_type = StringField('工单作废原因', validators=[Length(max=50)]) + master_id = IntegerField('工单主表ID') + call_number = StringField('来电号码', validators=[Length(max=20)]) + contact_number = StringField('联系电话', validators=[Length(max=20)]) + title = StringField('工单标题', validators=[Length(max=100)]) + call_time = DateTimeField('来电时间') + first_order_status = StringField('一级状态编码', validators=[Length(max=10)]) + secord_order_status = StringField('二级状态编码', validators=[Length(max=10)]) + atomic_order_status = StringField('原子状态编码', validators=[Length(max=10)]) + area_code = StringField('区域代码', validators=[Length(max=10)]) + area_code_city = StringField('市区域代码', validators=[Length(max=50)]) + area_code_area = StringField('区区域代码', validators=[Length(max=50)]) + area_code_street = StringField('街道区域代码', validators=[Length(max=50)]) + address_detail = TextAreaField('详细地址') + case_lnglat = StringField('地理坐标', validators=[Length(max=50)]) + case_accord_type_one_name = StringField('诉求归口一级', validators=[Length(max=50)]) + case_accord_type_two_name = StringField('诉求归口二级', validators=[Length(max=50)]) + case_accord_type_three_name = StringField('诉求归口三级', validators=[Length(max=50)]) + case_accord_type_four_name = StringField('四级事项分类', validators=[Length(max=50)]) + case_accord_type_five_name = StringField('五级事项分类', validators=[Length(max=50)]) + case_content = TextAreaField('诉求内容') + case_goal = TextAreaField('诉求目的') + case_labels = TextAreaField('工单标签列表') + case_public = StringField('是否公开', validators=[Length(max=10)]) + case_is_urgent = IntegerField('紧急程度') + case_comple_time = DateTimeField('案件办结时间') + first_level_affiliation = StringField('一级归属单位', validators=[Length(max=50)]) + second_level_affiliation = StringField('二级归属单位', validators=[Length(max=50)]) + third_level_affiliation = StringField('三级归属单位', validators=[Length(max=50)]) + fourth_level_affiliation = StringField('四级归属单位', validators=[Length(max=50)]) + fifth_level_affiliation = StringField('五级归属单位', validators=[Length(max=50)]) + case_accord_code = StringField('事项编码', validators=[Length(max=50)]) + sixth_level_affiliation = StringField('六级归属单位', validators=[Length(max=50)]) + seventh_level_affiliation = StringField('七级归属单位', validators=[Length(max=50)]) + info_protect = IntegerField('信息保护') + case_is_visit = IntegerField('是否回访') + service_object_type = IntegerField('服务对象类型') + hotspot = StringField('是否热点事件', validators=[Length(max=10)]) + result_satisfied = StringField('结果满意度', validators=[Length(max=10)]) + first_vist_satisfied = StringField('首次走访满意度', validators=[Length(max=10)]) + contact_timely = StringField('是否及时联系', validators=[Length(max=50)]) + distribute_type = StringField('分派类型', validators=[Length(max=50)]) + active_dept_ids = StringField('当前处理部门ID列表', validators=[Length(max=255)]) + active_dept_name = StringField('当前处理部门名称', validators=[Length(max=50)]) + case_solve = TextAreaField('处理结果') + supervise_type = StringField('监督类型', validators=[Length(max=30)]) + leader_indicate = TextAreaField('领导批示') + extension = TextAreaField('扩展字段') + org_id = StringField('组织ID', validators=[Length(max=50)]) + org_name = StringField('组织名称', validators=[Length(max=50)]) + knowledge_quote = TextAreaField('知识引用') + special_type = StringField('特殊类型', validators=[Length(max=30)]) + attachment_ids = TextAreaField('附件ID列表') + file_exist = IntegerField('是否存在附件') + record_id = StringField('通话记录ID', validators=[Length(max=50)]) + call_end_time = DateTimeField('通话结束时间') + call_total_time = StringField('通话总时长', validators=[Length(max=20)]) + plan_finish_time = DateTimeField('计划完成时间') + remark = TextAreaField('备注') + tenant_id = IntegerField('租户ID') + process_instance_id = StringField('流程实例ID', validators=[Length(max=100)]) + visit_count = IntegerField('走访次数') + residue_date = StringField('剩余天数', validators=[Length(max=30)]) + whether_approval = StringField('是否审批', validators=[Length(max=10)]) + over_time_warning_flag = StringField('超时预警标志', validators=[Length(max=10)]) + create_no = StringField('创建编号', validators=[Length(max=20)]) + belong_platform = StringField('所属平台', validators=[Length(max=50)]) + next_task_id = StringField('下一个任务ID', validators=[Length(max=64)]) + return_visit_reason = TextAreaField('回访原因') + back_count = StringField('回退次数', validators=[Length(max=100)]) + visit_adv_content = TextAreaField('走访建议内容') + current_processing_platform = StringField('当前处理平台', validators=[Length(max=100)]) + judgment_flag = StringField('判定标志', validators=[Length(max=10)]) + thrid_order_id = StringField('第三方工单ID', validators=[Length(max=50)]) + is_dispatch_accurate = StringField('是否精准分派', validators=[Length(max=10)]) + is_coordination = StringField('是否协调', validators=[Length(max=10)]) + coordination_time = DateTimeField('协调时间') + govs_sign = IntegerField('是否已在省12345签收,1:签收,0:未签收') + creator_id = IntegerField('创建人ID') + create_by = StringField('创建人姓名', validators=[Length(max=50)]) + updator_id = IntegerField('更新人ID') + update_by = StringField('更新人姓名', validators=[Length(max=50)]) + create_date = DateTimeField('工单创建时间') + update_date = DateTimeField('工单更新时间') + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovsOrderMasterBase(TD3iGovsOrderMaster, CommonModel): + """省12345工单主表业务基类""" + + FieldMapping = { + "id": "id", + 'plan_sign_time': 'planSignTime', + 'claim_status': 'claimStatus', + 'plan_back_time': 'planBackTime', + 'handle_time': 'handleTime', + 'back_time': 'backTime', + 'complete_time': 'completeTime', + "belong_dept": "belongDept", + "order_id": "orderId", + "order_no": "orderNo", + "order_type": "orderType", + "order_source": "orderSource", + "order_source_detail": "orderSourceDetail", + "order_status": "orderStatus", + "order_user_id": "orderUserId", + "order_user_name": "orderUserName", + "order_user_sex": "orderUserSex", + "order_user_phone2": "orderUserPhone2", + "order_handle_way": "orderHandleWay", + "order_invalid_type": "orderInvalidType", + "next_task_id": "nextTaskId", + "master_id": "masterId", + "call_number": "callNumber", + "contact_number": "contactNumber", + "title": "title", + "call_time": "callTime", + "first_order_status": "firstOrderStatus", + "secord_order_status": "secordOrderStatus", + "atomic_order_status": "atomicOrderStatus", + "area_code": "areaCode", + "area_code_city": "areaCodeCity", + "area_code_area": "areaCodeArea", + "area_code_street": "areaCodeStreet", + "address_detail": "addressDetail", + "case_lnglat": "caseLnglat", + "case_accord_type_one_name": "caseAccordTypeOneName", + "case_accord_type_two_name": "caseAccordTypeTwoName", + "case_accord_type_three_name": "caseAccordTypeThreeName", + "case_accord_type_four_name": "caseAccordTypeFourName", + "case_accord_type_five_name": "caseAccordTypeFiveName", + "case_accord_ext": "caseAccordExt", + "case_content": "caseContent", + "case_goal": "caseGoal", + "case_labels": "caseLabels", + "case_public": "casePublic", + "case_type": "caseType", + "case_is_urgent": "caseIsUrgent", + "case_comple_time": "caseCompleTime", + "first_level_affiliation": "firstLevelAffiliation", + "second_level_affiliation": "secondLevelAffiliation", + "third_level_affiliation": "thirdLevelAffiliation", + "fourth_level_affiliation": "fourthLevelAffiliation", + "fifth_level_affiliation": "fifthLevelAffiliation", + "case_accord_code": "caseAccordCode", + "sixth_level_affiliation": "sixthLevelAffiliation", + "seventh_level_affiliation": "seventhLevelAffiliation", + "info_protect": "infoProtect", + "case_is_visit": "caseIsVisit", + "service_object_type": "serviceObjectType", + "hotspot": "hotspot", + "result_satisfied": "resultSatisfied", + "first_vist_satisfied": "firstVistSatisfied", + "contact_timely": "contactTimely", + "distribute_type": "distributeType", + "dept_type": "deptType", + "dept_name": "deptName", + "active_dept_ids": "activeDeptIds", + "active_dept_name": "activeDeptName", + "case_solve": "caseSolve", + "supervise_type": "superviseType", + "leader_indicate": "leaderIndicate", + "extension": "extension", + "org_id": "orgId", + "org_name": "orgName", + "knowledge_quote": "knowledgeQuote", + "special_type": "specialType", + "attachment_ids": "attachmentIds", + "attachment_list": "attachmentList", + "file_exist": "fileExist", + "record_id": "recordId", + "call_end_time": "callEndTime", + "call_total_time": "callTotalTime", + "plan_finish_time": "planFinishTime", + "remark": "remark", + "tenant_id": "tenantId", + "erge_revoke_plug": "ergeRevokePlug", + "exist_quoto_info": "existQuotoInfo", + "process_instance_id": "processInstanceId", + "sound_recording_address_list": "soundRecordingAddressList", + "visit_count": "visitCount", + "residue_date": "residueDate", + "whether_approval": "whetherApproval", + "over_time_warning_flag": "overTimeWarningFlag", + "create_no": "createNo", + "belong_platform": "belongPlatform", + "return_visit_reason": "returnVisitReason", + "back_count": "backCount", + "visit_adv_content": "visitAdvContent", + "tripartite_call_record_info": "tripartiteCallRecordInfo", + "knowledge_references": "knowledgeReferences", + "current_processing_platform": "currentProcessingPlatform", + "judgment_flag": "judgmentFlag", + "thrid_order_id": "thridOrderId", + "is_dispatch_accurate": "isDispatchAccurate", + "is_coordination": "isCoordination", + "coordination_time": "coordinationTime", + "creator_id": "creatorId", + "create_by": "createBy", + "updator_id": "updatorId", + "update_by": "updateBy" + } + + @classmethod + async def exist_other(cls, id: Union[str, int], order_id: str = None, order_no: str = None): + """检查是否存在除当前工单外的其他同编号工单""" + _query = select(cls).where(cls.id != id) + if order_id: + _query = _query.where(cls.order_id == order_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """根据ID列表批量查找工单""" + _query = select(cls).where(cls.id.in_(ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + @classmethod + async def is_exist(cls, order_id: str = None, order_no: str = None): + """检查工单是否已经存在""" + _query = select(cls) + if order_id: + _query = _query.where(cls.order_id == order_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """按参数搜索工单的基础方法""" + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + _name_likes = { + cls.order_status.key: '%{}%', + cls.order_source.key: '%{}%', + cls.case_accord_type_one_name.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.create_date.desc()) + + _df = await cls.query_as_df(_data_query) + if not _df.empty: + _df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _df[cls.id.key] = _df[cls.id.key].astype(str) + + return _df, _paging + + @classmethod + async def search(cls, **kwargs): + """按参数搜索工单,返回分页格式数据""" + _df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_order_id(cls, data_df: pd.DataFrame): + """根据 order_id 判断数据是否存在""" + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + order_ids = data_df[cls.order_id.key].unique().tolist() + if not order_ids: + return pd.DataFrame(), data_df.copy() + + _query = select(cls.id, cls.order_id).where(cls.order_id.in_(order_ids)) + existing_df = await cls.query_as_df(_query) + + if existing_df.empty: + return pd.DataFrame(), data_df.copy() + + order_id_to_id_map = dict(zip(existing_df[cls.order_id.key], existing_df[cls.id.key])) + + mask_exists = data_df[cls.order_id.key].isin(existing_df[cls.order_id.key]) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df[cls.order_id.key].map(order_id_to_id_map) + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + +@register_swagger_model +class GovsOrderMaster(GovsOrderMasterBase): + """省12345工单主表业务类""" + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """创建新工单""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsOrderMasterForm(formdata=kwargs) + _form.validate_form() + + _existing = await cls.is_exist( + order_id=_form.order_id.data, + order_no=_form.order_no.data + ) + assert _existing is None, "工单编号或工单号已存在,不能重复创建。" + + _task = cls().copy_from_dict(_form.data, skip_none=True) + if user: + _task.created_by = user.username + _task.updated_by = user.username + await _task.async_save() + return _task + + @classmethod + async def delete(cls, task_id: Union[str, int]): + """删除工单""" + _task: cls = await cls.async_find_by_id(task_id) + assert _task, f"根据 ID {task_id} 未找到工单。" + + _del_query = delete(cls).where(cls.id == _task.id) + await cls.raw_execute(_del_query) + echo_log(f'已删除工单(工单号:{_task.order_no},ID:{_task.id}).') + return _task + + @classmethod + async def modify(cls, task_id: Union[str, int], user: RbacUser = None, **kwargs): + """修改工单信息""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsOrderMasterForm(formdata=kwargs) + _form.validate_form() + + _other = await cls.exist_other( + task_id, + order_id=_form.order_id.data, + order_no=_form.order_no.data + ) + assert _other is None, "工单编号或工单号已存在,不能重复修改。" + + _task: cls = await cls.async_find_by_id(task_id) + assert _task, f'查无此工单信息。' + + _task.copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _task.updated_by = user.username + await _task.async_save() + return _task + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量创建工单""" + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + tasks = [cls().copy_from_dict(record, skip_none=True) for record in records] + + session = cls.get_aio_session() + try: + session.add_all(tasks) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(tasks)} 条工单。") + return len(tasks) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量修改工单""" + if data_df.empty: + return 0 + + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列") + return 0 + + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + update_data = data_df.to_dict('records') + + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量保存数据,自动处理新建和更新""" + _exists_df, _latest_df = await cls.exists_order_id(data_df) + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count diff --git a/models/govs_order_process.py b/models/govs_order_process.py new file mode 100644 index 0000000..eae30b4 --- /dev/null +++ b/models/govs_order_process.py @@ -0,0 +1,519 @@ +# coding: utf-8 +import datetime +import random +from typing import Union,Optional,Callable + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovsOrderProces +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovsOrderProcessForm(ModelForm): + """省12345工单处理流程表单验证类""" + + id = IntegerField('工单处理记录唯一ID') + master_id = IntegerField('主工单ID') + tenant_id = IntegerField('租户ID') + plan_sign_time = DateTimeField('计划签收时间') + plan_finish_time = DateTimeField('计划完成时间') + plan_back_time = DateTimeField('计划退回时间') + deal_date = DateTimeField('实际处理时间') + hand_over_time = StringField('交接时间', validators=[Length(max=20)]) + sign_over_time = StringField('签收超时时间', validators=[Length(max=20)]) + origin_plan_finish_time = DateTimeField('原始计划完成时间') + origin_plan_sign_time = DateTimeField('原始计划签收时间') + order_id = StringField('工单编号', validators=[Length(max=50)]) + order_no = StringField('工单流水号', validators=[Length(max=100)]) + process_instance_id = StringField('流程实例ID', validators=[Length(max=64)]) + order_status = StringField('工单状态编码', validators=[Length(max=10)]) + is_over_time = IntegerField('是否超期') + is_sign_over_time = IntegerField('是否签收超时') + action_name = StringField('当前操作动作名称', validators=[Length(max=100)]) + deal_type = StringField('处理类型', validators=[Length(max=100)]) + task_id = StringField('当前任务ID', validators=[Length(max=64)]) + next_task_id = StringField('下一任务ID', validators=[Length(max=64)]) + next_action_name = StringField('下一处理动作名称', validators=[Length(max=100)]) + next_handle = StringField('下一处理动作名称', validators=[Length(max=50)]) + next_handle_name = StringField('下一处理动作详细名称', validators=[Length(max=100)]) + handler_user_ids = StringField('当前处理人ID列表', validators=[Length(max=500)]) + handler_user_names = StringField('当前处理人姓名列表', validators=[Length(max=500)]) + handler_org_ids = StringField('当前处理部门ID列表', validators=[Length(max=1000)]) + handler_org_names = StringField('当前处理部门名称列表', validators=[Length(max=500)]) + next_handler_user_ids = StringField('下一处理人ID列表', validators=[Length(max=500)]) + next_handler_user_names = StringField('下一处理人姓名列表', validators=[Length(max=500)]) + next_org_ids = StringField('下一处理部门ID列表', validators=[Length(max=500)]) + next_org_names = StringField('下一处理部门名称列表', validators=[Length(max=500)]) + dispatch_order_id = StringField('派发工单ID', validators=[Length(max=100)]) + to_master_id = IntegerField('目标主表ID') + to_tenant_id = IntegerField('目标租户ID') + to_area_code = StringField('目标区域代码', validators=[Length(max=20)]) + to_dept_id = IntegerField('目标部门ID') + dispatch_value = StringField('派发值', validators=[Length(max=20)]) + has_dispatch_process = IntegerField('是否有派发流程') + contact_name = StringField('联系人姓名', validators=[Length(max=100)]) + contact_time = DateTimeField('联系时间') + contact_type = StringField('联系类型', validators=[Length(max=20)]) + adv_content = TextAreaField('处理建议') + remarks = TextAreaField('备注信息') + formal_reply = TextAreaField('正式回复内容') + reply_to_people = StringField('回复对象', validators=[Length(max=100)]) + return_reason = StringField('退回原因', validators=[Length(max=500)]) + notice_org_id = IntegerField('通知组织ID') + line_key = StringField('线路标识', validators=[Length(max=100)]) + current_task_status = StringField('当前任务状态', validators=[Length(max=50)]) + visit_type = StringField('访问类型', validators=[Length(max=50)]) + attachment_dto_list = TextAreaField('附件列表JSON') + child_order_processes = TextAreaField('子流程处理记录JSON') + order_process_index_list = TextAreaField('工单流程索引列表JSON') + created_at = DateTimeField('创建时间') + created_by = StringField('创建者', validators=[Length(max=64)]) + updated_at = DateTimeField('更新时间') + updated_by = StringField('更新者', validators=[Length(max=64)]) + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovsOrderProcessBase(TD3iGovsOrderProces, CommonModel): + """省12345工单处理流程业务基类""" + + FieldMapping = { + # ==================== 主键与关联 ==================== + 'id': 'id', + 'master_id': 'masterId', + 'tenant_id': 'tenantId', + + # ==================== 时间节点 ==================== + 'plan_sign_time': 'planSignTime', + 'plan_finish_time': 'planFinishTime', + 'plan_back_time': 'planBackTime', + 'deal_date': 'dealDate', + 'hand_over_time': 'handOverTime', + 'sign_over_time': 'signOverTime', + 'origin_plan_finish_time': 'originPlanFinishTime', + 'origin_plan_sign_time': 'originPlanSignTime', + + # ==================== 工单标识 ==================== + 'order_id': 'orderId', + 'order_no': 'orderNo', + 'process_instance_id': 'processInstanceId', + + # ==================== 工单状态 ==================== + 'order_status': 'orderStatus', + 'is_over_time': 'isOverTime', + 'is_sign_over_time': 'isSignOverTime', + + # ==================== 处理动作 ==================== + 'action_name': 'actionName', + 'deal_type': 'dealType', + 'task_id': 'taskId', + 'next_task_id': 'nextTaskId', + 'next_action_name': 'nextActionName', + 'next_handle': 'nextHandle', + 'next_handle_name': 'nextHandleName', + + # ==================== 当前处理人/部门 ==================== + 'handler_user_ids': 'handlerUserIds', + 'handler_user_names': 'handlerUserNames', + 'handler_org_ids': 'handlerOrgIds', + 'handler_org_names': 'handlerOrgNames', + + # ==================== 下一处理人/部门 ==================== + 'next_handler_user_ids': 'nextHandlerUserIds', + 'next_handler_user_names': 'nextHandlerUserNames', + 'next_org_ids': 'nextOrgIds', + 'next_org_names': 'nextOrgNames', + + # ==================== 派发信息 ==================== + 'dispatch_order_id': 'dispatchOrderId', + 'to_master_id': 'toMasterId', + 'to_tenant_id': 'toTenantId', + 'to_area_code': 'toAreaCode', + 'to_dept_id': 'toDeptId', + 'dispatch_value': 'dispatchValue', + 'has_dispatch_process': 'hasDispatchProcess', + + # ==================== 联系信息 ==================== + 'contact_name': 'contactName', + 'contact_time': 'contactTime', + 'contact_type': 'contactType', + + # ==================== 内容字段 ==================== + 'adv_content': 'advContent', + 'remarks': 'remarks', + 'formal_reply': 'formalReply', + 'reply_to_people': 'replyToPeople', + 'return_reason': 'returnReason', + + # ==================== 其他字段 ==================== + 'notice_org_id': 'noticeOrgId', + 'line_key': 'lineKey', + 'current_task_status': 'currentTaskStatus', + 'visit_type': 'visitType', + + # ==================== JSON 嵌套字段 ==================== + 'attachment_dto_list': 'attachmentDTOList', + 'child_order_processes': 'childOrderProcesses', + } + + @classmethod + async def exist_other(cls, id: Union[str, int], order_id: str = None, order_no: str = None): + """检查是否存在除当前记录外的其他同编号处理流程""" + _query = select(cls).where(cls.id != id) + if order_id: + _query = _query.where(cls.order_id == order_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """根据ID列表批量查找处理流程""" + _query = select(cls).where(cls.id.in_(ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + @classmethod + async def is_exist(cls, order_id: str = None, order_no: str = None): + """检查处理流程是否已经存在""" + _query = select(cls) + if order_id: + _query = _query.where(cls.order_id == order_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """按参数搜索处理流程的基础方法""" + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + _name_likes = { + cls.order_status.key: '%{}%', + cls.action_name.key: '%{}%', + cls.deal_type.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.created_at.desc()) + + _df = await cls.query_as_df(_data_query) + if not _df.empty: + _df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _df[cls.id.key] = _df[cls.id.key].astype(str) + + return _df, _paging + + @classmethod + async def search(cls, **kwargs): + """按参数搜索处理流程,返回分页格式数据""" + _df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_master_id(cls, data_df: pd.DataFrame): + """根据 master_id 判断数据是否存在 + :param data_df: 输入的数据框架,必须包含 id 和 master_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录(附带数据库中的 id) + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 (id, master_id) 组合 + pairs = data_df[[cls.id.key, cls.master_id.key]].drop_duplicates().values.tolist() + if not pairs: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的记录(使用 IN 批量查询) + _query = select(cls.id, cls.master_id).where( + (cls.id.in_([p[0] for p in pairs])) & + (cls.master_id.in_([p[1] for p in pairs])) + ) + exists_db_df = await cls.query_as_df(_query) + + if exists_db_df.empty: + return pd.DataFrame(), data_df.copy() + + exists_db_df[cls.id.key] = exists_db_df[cls.id.key].astype(str) + exists_db_df[cls.master_id.key] = exists_db_df[cls.master_id.key].astype(str) + # 构建 (id, master_id) -> id 的映射(用于快速查找) + key_to_id_map = dict(zip( + zip(exists_db_df[cls.id.key], exists_db_df[cls.master_id.key]), + exists_db_df[cls.id.key] + )) + + # 标记 data_df 中哪些行在数据库中存在 + mask_exists = data_df.apply( + lambda row: (row[cls.id.key], row[cls.master_id.key]) in key_to_id_map, + axis=1 + ) + # 提取存在的记录,并补充数据库中的 id(虽然输入中已有 id,但为一致性保留) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df.apply( + lambda row: key_to_id_map[(row[cls.id.key], row[cls.master_id.key])], + axis=1 + ) + # 提取不存在的记录 + latest_df = data_df[~mask_exists].copy() + + return exists_df, latest_df + + @classmethod + async def find_by_order_id(cls, order_id: str): + """根据工单编号查找处理流程""" + _query = select(cls).where(cls.order_id == order_id) + return (await cls.orm_execute_scalars(_query)).all() + + @classmethod + async def find_by_master_id(cls, master_id: Union[str, int]): + """根据主工单ID查找处理流程""" + _query = select(cls).where(cls.master_id == master_id) + return (await cls.orm_execute_scalars(_query)).all() + + @classmethod + async def find_latest_by_order_id(cls, order_id: str): + """根据工单编号查找最新的处理流程""" + _query = select(cls).where(cls.order_id == order_id).order_by(cls.deal_date.desc()) + return await cls.query_first(_query) + + @classmethod + async def fill_process_info(cls, data_df: pd.DataFrame, index_field: str = 'id', + column_name: str = 'process_infos', + preprocessing: Optional[Callable] = None): + """ + 填充办理过程数据到数据框架。 + + 用于在查询结果中添加关联的办理过程信息。 + + :param pandas.DataFrame data_df: 待填充的数据框架 + :param str index_field: 索引字段,一般是任务ID + :param str column_name: 填充时,新增加的列名称,默认为`process_infos` + :param preprocessing: 预处理,注意预处理必须要返回处理后的结果 + :return: 办理过程数据框架(已填充) + :rtype: pandas.DataFrame + """ + if data_df.empty: + return pd.DataFrame() + + _task_ids = list(set(data_df[index_field].unique().tolist())) + if not _task_ids: + return pd.DataFrame() + + _query = select(cls).where(cls.master_id.in_(_task_ids)) + _info_df: pd.DataFrame = await cls.query_as_df(_query) + if not _info_df.empty: + _info_df.replace(models.EmptyInDF+models.EmptyDatetimeInDF, '', inplace=True) + # 整理输出数据类型 + _info_df[cls.id.key] = _info_df[cls.id.key].astype(str) + _info_df[cls.master_id.key] = _info_df[cls.master_id.key].astype(str) + + # 设置索引 + _info_df['index_id'] = _info_df[cls.master_id.key] + _info_df.set_index(['index_id'], inplace=True) + # 对数据进行预处理 + if isinstance(preprocessing, Callable): + _info_df = preprocessing(_info_df) + # 增加数据填充列 + data_df[column_name] = data_df[index_field].apply( + lambda x: _info_df.query(f"{cls.master_id.key}=='{x}'").to_dict('records') + ) + else: + data_df[column_name] = [[] for _ in range(len(data_df))] + + return _info_df + + +@register_swagger_model +class GovsOrderProcess(GovsOrderProcessBase): + """省12345工单处理流程业务类""" + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """创建新处理流程记录""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsOrderProcessForm(formdata=kwargs) + _form.validate_form() + + _existing = await cls.is_exist( + order_id=_form.order_id.data, + order_no=_form.order_no.data + ) + if _existing: + # 如果存在相同 order_id 的记录,更新它 + return await cls.modify(_existing.id, user, **kwargs) + + _task = cls().copy_from_dict(_form.data, skip_none=True) + if user: + _task.created_by = user.username + _task.updated_by = user.username + await _task.async_save() + return _task + + @classmethod + async def delete(cls, task_id: Union[str, int]): + """删除处理流程记录""" + _task: cls = await cls.async_find_by_id(task_id) + assert _task, f"根据 ID {task_id} 未找到处理流程记录。" + + _del_query = delete(cls).where(cls.id == _task.id) + await cls.raw_execute(_del_query) + echo_log(f'已删除处理流程记录(工单号:{_task.order_no},ID:{_task.id}).') + return _task + + @classmethod + async def modify(cls, task_id: Union[str, int], user: RbacUser = None, **kwargs): + """修改处理流程信息""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsOrderProcessForm(formdata=kwargs) + _form.validate_form() + + _other = await cls.exist_other( + task_id, + order_id=_form.order_id.data, + order_no=_form.order_no.data + ) + if _other: + # 如果存在其他相同编号的记录,不重复创建 + echo_log(f"处理流程记录已存在(工单号:{_form.order_no.data}),跳过创建。") + return _other + + _task: cls = await cls.async_find_by_id(task_id) + assert _task, f'查无此处理流程信息。' + + _task.copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _task.updated_by = user.username + await _task.async_save() + return _task + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量创建处理流程记录""" + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + tasks = [cls().copy_from_dict(record, skip_none=True) for record in records] + + session = cls.get_aio_session() + try: + session.add_all(tasks) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(tasks)} 条处理流程记录。") + return len(tasks) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量修改处理流程记录""" + if data_df.empty: + return 0 + + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列") + return 0 + + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + update_data = data_df.to_dict('records') + + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条处理流程记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量保存数据,自动处理新建和更新""" + _exists_df, _latest_df = await cls.exists_master_id(data_df) + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count + + @classmethod + async def delete_by_order_id(cls, order_id: str): + """根据工单编号删除处理流程记录""" + _del_query = delete(cls).where(cls.order_id == order_id) + result = await cls.raw_execute(_del_query) + echo_log(f'已删除工单 {order_id} 的处理流程记录,共 {result.rowcount} 条.') + return result.rowcount + + @classmethod + async def delete_by_master_id(cls, master_id: Union[str, int]): + """根据主工单ID删除处理流程记录""" + _del_query = delete(cls).where(cls.master_id == master_id) + result = await cls.raw_execute(_del_query) + echo_log(f'已删除主工单 {master_id} 的处理流程记录,共 {result.rowcount} 条.') + return result.rowcount diff --git a/models/govs_order_user.py b/models/govs_order_user.py new file mode 100644 index 0000000..b25a5e1 --- /dev/null +++ b/models/govs_order_user.py @@ -0,0 +1,329 @@ +# coding: utf-8 +import datetime +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, IntegerField +from wtforms.validators import Length + +from models.common_model import CommonModel +from models.db_models import TD3iGovsOrderUser +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.web.form import ModelForm + + +class GovsOrderUserForm(ModelForm): + """服务对象信息表单验证类""" + + id = IntegerField('服务对象唯一ID') + master_id = IntegerField('关联工单主表ID') + order_id = StringField('工单编号', validators=[Length(max=50)]) + order_no = StringField('工单号', validators=[Length(max=50)]) + customer_id = StringField('服务对象ID', validators=[Length(max=50)]) + customer_name = StringField('姓名', validators=[Length(max=50)]) + customer_sex = StringField('性别', validators=[Length(max=10)]) + customer_connect_phone = StringField('联系电话', validators=[Length(max=20)]) + customer_credentials_type = StringField('证件类型', validators=[Length(max=20)]) + customer_credentials_no = StringField('证件号码', validators=[Length(max=50)]) + customer_address = StringField('联系地址', validators=[Length(max=200)]) + customer_email = StringField('电子邮箱', validators=[Length(max=100)]) + customer_age = IntegerField('年龄') + customer_occupation = StringField('职业', validators=[Length(max=50)]) + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovsOrderUserBase(TD3iGovsOrderUser, CommonModel): + """服务对象信息业务基类""" + + FieldMapping = { + # ==================== 主键与关联 ==================== + 'id': 'id', # 服务对象唯一ID + 'master_id': 'masterId', # 关联工单主表ID(需从外部传入) + 'order_id': 'orderId', # 工单编号 + 'tenant_id': 'tenantId', # 租户ID + 'area_code': 'areaCode', # 区域代码 + + # ==================== 基本信息 ==================== + 'customer_name': 'customerName', # 姓名 + 'raw_customer_name': 'rawCustomerName', # 原始姓名 + 'customer_sex': 'customerSex', # 性别(男/女/未知) + 'customer_type': 'customerType', # 客户类型(个人/企业) + 'customer_age_range': 'customerAgeRange', # 年龄段 + + # ==================== 联系方式 ==================== + 'customer_connect_phone': 'customerConnectPhone', # 联系电话 + 'raw_customer_connect_phone': 'rawCustomerConnectPhone', # 原始联系电话 + 'customer_incoming_phone': 'customerIncomingPhone', # 来电号码 + 'raw_customer_incoming_phone': 'rawCustomerIncomingPhone', # 原始来电号码 + 'customer_phone_backup': 'customerPhoneBackup', # 备用电话 + 'raw_customer_phone_backup': 'rawCustomerPhoneBackup', # 原始备用电话 + 'customer_phone_backup_for_dh': 'customerPhoneBackupForDH', # 备用电话(脱敏) + 'customer_internet_nickname': 'customerInternetNickname', # 网名 + 'customer_email': 'customerEmail', # 电子邮箱 + + # ==================== 证件信息 ==================== + 'customer_credentials_type': 'customerCredentialsType', # 证件类型(如:身份证、护照) + 'customer_credentials_no': 'customerCredentialsNo', # 证件号码 + 'raw_customer_credentials_no': 'rawCustomerCredentialsNo', # 原始证件号码 + + # ==================== 企业信息 ==================== + 'enterprise_type': 'enterpriseType', # 企业类型 + 'enterprise_name': 'enterpriseName', # 企业名称 + 'enterprise_register_address': 'enterpriseRegisterAddress', # 企业注册地址 + 'enterprise_address': 'enterpriseAddress', # 企业地址 + 'enterprise_credit_code': 'enterpriseCreditCode', # 企业信用代码 + + # ==================== 系统字段 ==================== + 'delete_flag': 'deleteFlag', # 删除标志(0-未删除,1-已删除) + + # ==================== 系统信息 ==================== + 'created_at': 'createDate', + 'created_by': 'createBy', + 'updated_at': 'updateDate', + 'updated_by': 'updateBy', + } + + @classmethod + async def find_by_order_id(cls, order_id: str): + """根据工单编号查找服务对象""" + _query = select(cls).where(cls.order_id == order_id) + return await cls.query_first(_query) + + @classmethod + async def find_by_master_id(cls, master_id: Union[str, int]): + """根据主工单ID查找服务对象""" + _query = select(cls).where(cls.master_id == master_id) + return await cls.query_first(_query) + + @classmethod + async def find_by_customer_id(cls, customer_id: str): + """根据服务对象ID查找""" + _query = select(cls).where(cls.customer_id == customer_id) + return (await cls.orm_execute_scalars(_query)).all() + + @classmethod + async def find_by_phone(cls, phone: str): + """根据联系电话查找""" + _query = select(cls).where(cls.customer_connect_phone == phone) + return (await cls.orm_execute_scalars(_query)).all() + + @classmethod + async def exists_order_id(cls, data_df: pd.DataFrame): + """根据 order_id 判断数据是否存在 + :param data_df: 输入的数据框架,必须包含 id 和 order_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录(附带数据库中的 id) + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 (id, order_id) 组合 + pairs = data_df[[cls.id.key, cls.order_id.key]].drop_duplicates().values.tolist() + if not pairs: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的记录(使用 IN 批量查询) + _query = select(cls.id, cls.order_id).where( + (cls.id.in_([p[0] for p in pairs])) & + (cls.order_id.in_([p[1] for p in pairs])) + ) + exists_db_df = await cls.query_as_df(_query) + + if exists_db_df.empty: + return pd.DataFrame(), data_df.copy() + + exists_db_df[cls.id.key] = exists_db_df[cls.id.key].astype(str) + exists_db_df[cls.order_id.key] = exists_db_df[cls.order_id.key].astype(str) + # 构建 (id, order_id) -> id 的映射(用于快速查找) + key_to_id_map = dict(zip( + zip(exists_db_df[cls.id.key], exists_db_df[cls.order_id.key]), + exists_db_df[cls.id.key] + )) + + # 标记 data_df 中哪些行在数据库中存在 + mask_exists = data_df.apply( + lambda row: (row[cls.id.key], row[cls.order_id.key]) in key_to_id_map, + axis=1 + ) + # 提取存在的记录,并补充数据库中的 id(虽然输入中已有 id,但为一致性保留) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df.apply( + lambda row: key_to_id_map[(row[cls.id.key], row[cls.order_id.key])], + axis=1 + ) + # 提取不存在的记录 + latest_df = data_df[~mask_exists].copy() + + return exists_df, latest_df + + +@register_swagger_model +class GovsOrderUser(GovsOrderUserBase): + """服务对象信息业务类""" + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """创建服务对象信息""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsOrderUserForm(formdata=kwargs) + _form.validate_form() + + # 检查是否已存在(根据 order_id) + _existing = await cls.find_by_order_id(_form.order_id.data) + if _existing: + # 更新已有记录 + _existing.copy_from_dict(_form.data, skip_none=True) + if user: + _existing.updated_by = user.username + await _existing.async_save() + return _existing + + _obj = cls().copy_from_dict(_form.data, skip_none=True) + if user: + _obj.created_by = user.username + _obj.updated_by = user.username + await _obj.async_save() + return _obj + + @classmethod + async def delete(cls, obj_id: Union[str, int]): + """删除服务对象信息""" + _obj: cls = await cls.async_find_by_id(obj_id) + assert _obj, f"根据 ID {obj_id} 未找到服务对象信息。" + + _del_query = delete(cls).where(cls.id == _obj.id) + await cls.raw_execute(_del_query) + echo_log(f'已删除服务对象信息(order_id:{_obj.order_id},ID:{_obj.id}).') + return _obj + + @classmethod + async def modify(cls, obj_id: Union[str, int], user: RbacUser = None, **kwargs): + """修改服务对象信息""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsOrderUserForm(formdata=kwargs) + _form.validate_form() + + _obj: cls = await cls.async_find_by_id(obj_id) + assert _obj, f'查无此服务对象信息。' + + _obj.copy_from_dict(_form.data, skip_none=True) + if user: + _obj.updated_by = user.username + await _obj.async_save() + return _obj + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量创建服务对象信息""" + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + objs = [cls().copy_from_dict(record, skip_none=True) for record in records] + + session = cls.get_aio_session() + try: + session.add_all(objs) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(objs)} 条服务对象信息。") + return len(objs) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量修改服务对象信息""" + if data_df.empty: + return 0 + + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列") + return 0 + + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + update_data = data_df.to_dict('records') + + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条服务对象信息。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量保存数据,自动处理新建和更新""" + _exists_df, _latest_df = await cls.exists_order_id(data_df) + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count + + @classmethod + async def create_or_update_by_order_id(cls, user: RbacUser = None, **kwargs): + """根据 order_id 创建或更新服务对象信息""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsOrderUserForm(formdata=kwargs) + _form.validate_form() + + _existing = await cls.find_by_order_id(_form.order_id.data) + if _existing: + _existing.copy_from_dict(_form.data, skip_none=True) + if user: + _existing.updated_by = user.username + await _existing.async_save() + return _existing + + _obj = cls().copy_from_dict(_form.data, skip_none=True) + if user: + _obj.created_by = user.username + _obj.updated_by = user.username + await _obj.async_save() + return _obj + + @classmethod + async def delete_by_order_id(cls, order_id: str): + """根据工单编号删除服务对象信息""" + _obj = await cls.find_by_order_id(order_id) + if _obj: + await cls.delete(_obj.id) + return _obj \ No newline at end of file diff --git a/models/govs_phase_wise_completion.py b/models/govs_phase_wise_completion.py new file mode 100644 index 0000000..7cf951b --- /dev/null +++ b/models/govs_phase_wise_completion.py @@ -0,0 +1,340 @@ +# coding: utf-8 +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, TextAreaField, IntegerField, DateTimeField +from wtforms.validators import Length + +import models +from models.db_models import TD3iGovsPhaseWiseCompletion +from models.common_model import CommonModel +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovsPhaseWiseCompletionForm(ModelForm): + """阶段性办结表单验证类""" + + id = IntegerField('主键') + master_id = IntegerField('主表ID') + master_id = StringField('代签收唯一标志', validators=[Length(max=64)]) + is_contact = StringField('联系诉求人情况', validators=[Length(max=10)]) + contact_name = StringField('联系人员', validators=[Length(max=100)]) + contact_time = StringField('联系时间', validators=[Length(max=64)]) + contact_type = StringField('联系情况', validators=[Length(max=255)]) + next_feedback_time = StringField('下一次反馈时间', validators=[Length(max=64)]) + advice = TextAreaField('处理意见') + reason = TextAreaField('处理意见1') + remark = StringField('备注', validators=[Length(max=500)]) + file_id_str = TextAreaField('OA文件id') + action_name = StringField('操作名称', validators=[Length(max=255)]) + case_accord_type_one_name = StringField('诉求归口一级名称', validators=[Length(max=255)]) + case_accord_type_two_name = StringField('诉求归口二级名称', validators=[Length(max=255)]) + case_accord_type_three_name = StringField('诉求归口三级名称', validators=[Length(max=255)]) + order_id = StringField('工单ID', validators=[Length(max=64)]) + task_id = StringField('任务ID', validators=[Length(max=64)]) + save_status = IntegerField('提交状态') + oa_feedback_status = IntegerField('OA反馈状态') + flow_token = StringField('流令牌', validators=[Length(max=256)]) + created_at = DateTimeField('创建时间') + created_by = StringField('创建者', validators=[Length(max=64)]) + updated_at = DateTimeField('更新时间') + updated_by = StringField('更新者', validators=[Length(max=64)]) + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj=None, **kwargs) + + +class GovsPhaseWiseCompletionBase(TD3iGovsPhaseWiseCompletion, CommonModel): + """阶段性办结业务基类""" + + FieldMapping = { + # 主键 & 关联ID + 'id': 'id', + 'master_id': 'masterId', + 'gd_id': 'gdId', + 'order_id': 'orderId', + 'task_id': 'taskId', + 'action_name': 'actionName', + + # 联系诉求人信息 + 'is_contact': 'isContact', + 'contact_name': 'contactName', + 'contact_time': 'contactTime', + 'contact_type': 'contactType', + 'next_feedback_time': 'nextFeedbackTime', + + # 处理意见 & 备注 + 'advice': 'advice', + 'reason': 'reason', + 'remark': 'remark', + 'file_id_str': 'fileIdStr', + + # 诉求归口分类 + 'case_accord_type_one_name': 'caseAccordTypeOneName', + 'case_accord_type_two_name': 'caseAccordTypeTwoName', + 'case_accord_type_three_name': 'caseAccordTypeThreeName', + + # 令牌 + 'flow_token': 'flowToken' + } + + @classmethod + async def exist_other(cls, id: Union[str, int], master_id: Union[str, int] = None, order_id: str = None): + """检查是否存在除当前记录外的同唯一标识阶段性办结记录""" + _query = select(cls).where(cls.id != id) + if master_id: + _query = _query.where(cls.master_id == master_id) + if order_id: + _query = _query.where(cls.order_id == order_id) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """根据ID列表批量查询阶段性办结记录""" + _query = select(cls).where(cls.id.in_(ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + @classmethod + async def is_exist(cls, master_id: Union[str, int] = None, order_id: str = None): + """检查阶段性办结记录是否已存在""" + _query = select(cls) + if master_id: + _query = _query.where(cls.master_id == master_id) + if order_id: + _query = _query.where(cls.order_id == order_id) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """阶段性办结基础搜索(分页/不分页)""" + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段配置 + _name_likes = { + cls.master_id.key: '%{}%', + cls.order_id.key: '%{}%', + cls.master_id.key: '%{}%', + cls.contact_name.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + # 排序处理 + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.created_at.desc()) + + _df = await cls.query_as_df(_data_query) + if not _df.empty: + _df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _df[cls.id.key] = _df[cls.id.key].astype(str) + + return _df, _paging + + @classmethod + async def search(cls, **kwargs): + """分页搜索阶段性办结记录,返回标准分页结构""" + _df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_master_id(cls, data_df: pd.DataFrame): + """根据 master_id 区分已有数据/新增数据(批量保存用)""" + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + master_ids = data_df[cls.master_id.key].unique().tolist() + if not master_ids: + return pd.DataFrame(), data_df.copy() + + _query = select(cls.id, cls.master_id).where(cls.master_id.in_(master_ids)) + existing_df = await cls.query_as_df(_query) + + if existing_df.empty: + return pd.DataFrame(), data_df.copy() + + master_id_to_id_map = dict(zip(existing_df[cls.master_id.key], existing_df[cls.id.key])) + mask_exists = data_df[cls.master_id.key].isin(existing_df[cls.master_id.key]) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df[cls.master_id.key].map(master_id_to_id_map) + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + @classmethod + async def find_by_master_id(cls, master_id: Union[str, int]): + """根据主表ID查询单条阶段性办结记录""" + _query = select(cls).where(cls.master_id == master_id) + return await cls.query_first(_query) + + @classmethod + async def find_by_order_id(cls, order_id: str): + """根据工单ID查询阶段性办结记录""" + _query = select(cls).where(cls.order_id == order_id) + return await cls.query_first(_query) + + @classmethod + async def find_by_master_ids(cls, master_ids: list[Union[str, int]]): + """根据主表ID列表批量查询阶段性办结记录""" + _query = select(cls).where(cls.master_id.in_(master_ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + +@register_swagger_model +class GovsPhaseWiseCompletion(GovsPhaseWiseCompletionBase): + """阶段性办结业务操作类""" + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """新增阶段性办结记录""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsPhaseWiseCompletionForm(formdata=kwargs) + _form.validate_form() + + _existing = await cls.is_exist( + master_id=_form.master_id.data, + order_id=_form.order_id.data + ) + assert _existing is None, "该任务已存在阶段性办结记录,无法重复创建。" + + _phase_info = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _phase_info.created_by = user.username + _phase_info.updated_by = user.username + await _phase_info.async_save() + return _phase_info + + @classmethod + async def delete(cls, phase_id: Union[str, int]): + """删除阶段性办结记录""" + _phase_info: cls = await cls.async_find_by_id(phase_id) + assert _phase_info, f"根据 ID {phase_id} 未找到阶段性办结记录。" + + _del_query = delete(cls).where(cls.id == _phase_info.id) + await cls.raw_execute(_del_query) + echo_log(f'已删除阶段性办结记录(工单ID:{_phase_info.master_id},ID:{_phase_info.id}).') + return _phase_info + + @classmethod + async def modify(cls, phase_id: Union[str, int], user: RbacUser = None, **kwargs): + """修改阶段性办结记录""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsPhaseWiseCompletionForm(formdata=kwargs) + _form.validate_form() + + _phase_info: cls = await cls.async_find_by_id(phase_id) + assert _phase_info, f'查无此阶段性办结记录。' + + _phase_info.copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _phase_info.updated_by = user.username + await _phase_info.async_save() + return _phase_info + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量新增阶段性办结记录""" + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + phase_list = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(phase_list) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(phase_list)} 条阶段性办结记录。") + return len(phase_list) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量修改阶段性办结记录""" + if data_df.empty: + return 0 + + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列") + return 0 + + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + update_data = data_df.to_dict('records') + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条阶段性办结记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量保存(自动区分新增/更新)""" + _exists_df, _latest_df = await cls.exists_master_id(data_df) + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count diff --git a/models/govs_push_status.py b/models/govs_push_status.py new file mode 100644 index 0000000..f741954 --- /dev/null +++ b/models/govs_push_status.py @@ -0,0 +1,401 @@ +import random +from typing import Union, Optional, Callable + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from paste.core.logging import echo_log +from paste.util.pagination import Pagination + +import models +from models.common_model import CommonModel +from models.db_models import TD3iGovsPushStatu + + +class GovsPushStatuBase(TD3iGovsPushStatu, CommonModel): + """ + 推送状态基础类(完全映射 TD3iGovsPushStatu 字段)。 + + 封装所有与推送OA状态相关的通用操作方法。 + """ + + # 无字段名映射需求,保持原样 + FieldMapping = {} + + @classmethod + async def exist_other(cls, id: Union[str, int], govs_task_id: Union[str, int]): + """ + 检查是否存在除当前记录外的其他同任务ID的推送状态记录。 + + :param id: 当前记录ID + :param govs_task_id: 任务ID(唯一标志) + :return: 存在返回记录对象,不存在返回None + """ + _query = select(cls).where(cls.id != id, cls.master_id == govs_task_id) + _record: cls = await cls.query_first(_query) + return _record + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """ + 根据ID列表批量查找推送状态数据。 + """ + _query = select(cls).where(cls.id.in_(ids)) + _record_list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _record_list + + @classmethod + async def is_exist(cls, govs_task_id: Union[str, int]): + """ + 检查推送状态是否已经存在(根据任务ID)。 + """ + _query = select(cls).where(cls.master_id == govs_task_id) + _record: cls = await cls.query_first(_query) + return _record + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """ + 按参数搜索推送状态数据的基础方法。 + + 支持字段: + - govs_task_id, push_order_status, push_order_detail_status, ... + - 不支持模糊匹配(均为整型状态码) + + :param is_paging: 是否分页 + :param kwargs: 查询参数 + :key int page_number: 页码(缺省随机1~100) + :key int page_size: 每页数量(缺省20) + :key dict sort_clause: 排序配置,如 {'updated_at': 'desc'} + :key int govs_task_id: 精确匹配任务ID + :key int push_order_status: 精确匹配推送待办工单状态 + :key int push_order_attachment_status: 精确匹配附件状态 + :key int push_order_detail_status: 精确匹配扩展信息状态 + :key int push_order_process_status: 精确匹配文件上传状态 + :return: (DataFrame, Pagination) + """ + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 无模糊字段,仅精确匹配 + _query = select(cls).where( + *cls.search_wheres(**kwargs) + ) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.updated_at.desc()) + + _record_df = await cls.query_as_df(_data_query) + if not _record_df.empty: + _record_df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + + return _record_df, _paging + + @classmethod + async def search(cls, **kwargs): + """ + 按参数搜索推送状态数据,返回分页格式数据。 + """ + _record_df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _record_df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_relation(cls, data_df: pd.DataFrame): + """ + 查找 data_df 中在数据库中已存在和不存在的记录。根据 govs_task_id 判断。 + + :param data_df: 输入的数据框架,必须包含 govs_task_id 列 + :return: (exists_df: pd.DataFrame, latest_df: pd.DataFrame) + - exists_df: 在数据库中存在的记录 + - latest_df: 在数据库中不存在的记录 + """ + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + # 获取待查询的 govs_task_id 组合 + task_ids = data_df[cls.master_id.key].unique().tolist() + if not task_ids: + return pd.DataFrame(), data_df.copy() + + # 查询数据库中已存在的记录 + _query = select(cls.id, cls.master_id).where(cls.master_id.in_(task_ids)) + exists_df = await cls.query_as_df(_query) + exists_df[cls.master_id.key] = exists_df[cls.master_id.key].astype(str) + + if exists_df.empty: + return pd.DataFrame(), data_df.copy() + + # 构建 govs_task_id -> id 的映射 + key_to_id_map = dict(zip(exists_df[cls.master_id.key], exists_df[cls.id.key])) + + # 根据 govs_task_id 是否在数据库中划分数据 + mask_exists = data_df[cls.master_id.key].isin(exists_df[cls.master_id.key]) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df[cls.master_id.key].map(key_to_id_map) + latest_df = data_df[~mask_exists].copy() + + return exists_df, latest_df + + +@register_swagger_model +class GovsPushStatus(GovsPushStatuBase): + """ + 推送状态业务模型类(主业务类,完全继承 TD3iGovsPushStatu 字段)。 + """ + + @classmethod + async def create(cls, **kwargs): + """ + 创建新的推送状态记录。 + + 业务流程: + 1. 使用 kwargs 直接构造对象(无需表单验证,因无前端交互) + 2. 检查是否已存在相同任务ID的记录(避免重复) + 3. 创建新记录对象 + 4. 设置创建者和更新者为 'D3I' + 5. 保存到数据库 + 6. 返回创建的对象 + + :param kwargs: 推送状态参数字典 + :return: 新建推送状态对象 + :rtype: TD3iGovsPushStatu + :raises AssertionError: 当记录已存在时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 检查是否已存在同任务ID的记录 + _record: cls = await cls.is_exist(kwargs.get('govs_task_id')) + assert _record is None, "相同任务ID的推送状态已存在,不能重复创建。" + + # 创建记录对象 + _record = cls().copy_from_dict(kwargs, skip_none=True).before_save() + # 强制设置创建者和更新者为 'D3I' + _record.created_by = 'D3I' + _record.updated_by = 'D3I' + await _record.async_save() + return _record + + @classmethod + async def delete(cls, id: Union[str, int]): + """ + 删除推送状态记录(软删除,不实际删除,仅用于逻辑隔离)。 + + 注意:此系统建议保留历史记录,删除操作仅为标记。 + + 业务流程: + 1. 根据ID查找记录 + 2. 验证存在性 + 3. 执行物理删除(因无软删除字段,此处直接删除) + + :param id: 要删除的记录ID + :return: 删除的记录ID + :rtype: int + :raises AssertionError: 当记录不存在时抛出 + """ + _record: cls = await cls.async_find_by_id(id) + assert _record, f"根据 ID {id} 未找到推送状态记录。" + + # 执行物理删除 + _del_query = delete(cls).where(cls.id == id) + _del_count = (await cls.raw_execute(_del_query)).rowcount + echo_log(f'已删除推送状态记录(ID:{id}).') + return _del_count + + @classmethod + async def modify(cls, id: Union[str, int], **kwargs): + """ + 修改已有推送状态信息。 + + 注意:仅允许更新状态码字段,不允许修改 id、created_at、created_by 等系统字段。 + + 业务流程: + 1. 处理字符串字段去除空格 + 2. 查询原记录 + 3. 验证存在性 + 4. 更新允许字段 + 5. 设置 updated_by = 'D3I' + 6. 保存到数据库 + 7. 返回更新后的对象 + + :param id: 要修改的记录ID + :param kwargs: 需要更新的字段(仅限状态字段) + :return: 修改后的推送状态对象 + :rtype: GovsPushStatus + :raises AssertionError: 当记录不存在时抛出 + """ + # 处理字符串字段去除空格 + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + # 查询原记录 + _record: cls = await cls.async_find_by_id(id) + assert _record, f"根据 ID {id} 未找到推送状态记录。" + + # 允许更新的字段(仅状态码) + allowed_fields = { + 'push_order_status', + 'push_order_detail_status', + 'push_order_attachment_status', + 'push_order_process_status' + } + + # 过滤合法字段 + update_data = {k: v for k, v in kwargs.items() if k in allowed_fields and v is not None} + if not update_data: + return _record + + # 更新字段 + _record.copy_from_dict(update_data, skip_none=True) + _record.updated_by = 'D3I' + await _record.async_save() + return _record + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame): + """ + 批量创建新推送状态记录(传入数据应为全新记录,无需校验是否存在)。 + + :param data_df: 包含推送状态数据的 DataFrame,字段需与模型属性匹配(如 govs_task_id, push_order_status 等) + :return: 成功创建的记录数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 一次性转为字典列表(C 层高效) + records = data_df.to_dict('records') + + # 用列表推导式构造对象 + records = [ + cls().copy_from_dict(record, skip_none=True).before_save() + for record in records + ] + + # 批量插入 + session = cls.get_aio_session() + try: + session.add_all(records) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(records)} 条推送状态记录。") + return len(records) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame): + """ + 批量修改已有推送状态记录。 + + :param data_df: 包含推送状态数据的 DataFrame,必须包含 id 列 + :return: 成功更新的记录数量 + :rtype: int + """ + if data_df.empty: + return 0 + + # 必须包含 id 列 + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列(主键)") + return 0 + + # 转换为字典列表 + update_data = data_df.to_dict('records') + + # 使用 bulk_update_mappings + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条推送状态记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame): + """ + 批量保存数据,自动处理新建和更新。 + + :param data_df: 要保存的数据框架 + :return 新建和更新的数量 + """ + # 筛选数据状态 + _exists_df, _latest_df = await GovsPushStatus.exists_relation(data_df) + # 保存到数据库 + _created_count = await GovsPushStatus.create_batch(_latest_df) + _updated_count = await GovsPushStatus.modify_batch(_exists_df) + return _created_count, _updated_count + + @classmethod + async def set_push_order_status(cls, govs_task_id: Union[str, int], status: int = 1): + govs_task: cls = await cls(master_id=govs_task_id).async_find_first() + if govs_task: + govs_task.push_order_status = status + else: + govs_task = cls(master_id=govs_task_id, push_order_status=status) + # 保存数据 + await govs_task.async_save() + + @classmethod + async def set_push_order_detail_status(cls, govs_task_id: Union[str, int], status: int = 1): + govs_task: cls = await cls(master_id=govs_task_id).async_find_first() + if govs_task: + govs_task.push_order_detail_status = status + else: + govs_task = cls(master_id=govs_task_id, push_order_detail_status=status) + # 保存数据 + await govs_task.async_save() + + @classmethod + async def set_push_order_attachment_status(cls, govs_task_id: Union[str, int], status: int = 1): + govs_task: cls = await cls(master_id=govs_task_id).async_find_first() + if govs_task: + govs_task.push_order_attachment_status = status + else: + govs_task = cls(master_id=govs_task_id, push_order_attachment_status=status) + # 保存数据 + await govs_task.async_save() + + @classmethod + async def set_push_order_process_status(cls, govs_task_id: Union[str, int], status: int = 1): + govs_task: cls = await cls(master_id=govs_task_id).async_find_first() + if govs_task: + govs_task.push_order_process_status = status + else: + govs_task = cls(master_id=govs_task_id, push_order_process_status=status) + # 保存数据 + await govs_task.async_save() diff --git a/models/govs_save_sign.py b/models/govs_save_sign.py new file mode 100644 index 0000000..10998ca --- /dev/null +++ b/models/govs_save_sign.py @@ -0,0 +1,321 @@ +# coding: utf-8 +import datetime +import random +from typing import Union + +import pandas as pd +from sqlalchemy import select, delete +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, IntegerField, DateTimeField +from wtforms.validators import Length + +import models +from models.db_models import TD3iGovsSaveSign +from models.common_model import CommonModel +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.util.pagination import Pagination +from paste.web.form import ModelForm + + +class GovsSaveSignForm(ModelForm): + """工单签收表单验证类""" + + id = IntegerField('主键') + master_id = IntegerField('主表ID') + master_id = StringField('代签收唯一标志', validators=[Length(max=64)]) + order_id = StringField('工单ID', validators=[Length(max=64)]) + order_no = StringField('工单号', validators=[Length(max=64)]) + order_process_id = StringField('工单流程ID', validators=[Length(max=64)]) + task_id = StringField('任务ID', validators=[Length(max=64)]) + flag = StringField('签收标识', validators=[Length(max=64)]) + save_status = IntegerField('提交状态') + oa_feedback_status = IntegerField('OA反馈状态') + flow_token = StringField('流令牌', validators=[Length(max=256)]) + created_at = DateTimeField('创建时间') + created_by = StringField('创建者', validators=[Length(max=64)]) + updated_at = DateTimeField('更新时间') + updated_by = StringField('更新者', validators=[Length(max=64)]) + + def process(self, formdata=None, obj=None, **kwargs): + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class GovsSaveSignBase(TD3iGovsSaveSign, CommonModel): + """工单签收业务基类""" + + FieldMapping = { + # 主键 & 关联ID + 'id': 'id', + 'master_id': 'gdId', + 'gd_id': 'gdId', + 'order_id': 'orderId', + 'order_no': 'orderNo', + 'order_process_id': 'orderProcessId', + 'task_id': 'taskId', + + # 业务标识 + 'flag': 'flag', + + # 令牌 + 'flow_token': 'flowToken', + } + + @classmethod + async def exist_other(cls, id: Union[str, int], master_id: Union[int, str] = None, order_id: str = None, + order_no: str = None): + """检查是否存在除当前记录外的同唯一标识工单签收记录""" + _query = select(cls).where(cls.id != id) + if master_id: + _query = _query.where(cls.master_id == master_id) + if order_id: + _query = _query.where(cls.order_id == order_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def find_by_ids(cls, ids: list[Union[str, int]]): + """根据ID列表批量查询工单签收记录""" + _query = select(cls).where(cls.id.in_(ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + @classmethod + async def is_exist(cls, master_id: Union[int, str] = None, order_id: str = None, order_no: str = None): + """检查工单签收记录是否已存在""" + _query = select(cls) + if master_id: + _query = _query.where(cls.master_id == master_id) + if order_id: + _query = _query.where(cls.order_id == order_id) + if order_no: + _query = _query.where(cls.order_no == order_no) + _task: cls = await cls.query_first(_query) + return _task + + @classmethod + async def search_base(cls, is_paging=True, **kwargs): + """工单签收基础搜索(分页/不分页)""" + page_number = kwargs.get('page_number', random.randint(1, 100)) + page_size = kwargs.get('page_size', 20) + kwargs.update({'page_number': page_number, 'page_size': page_size}) + + # 模糊查询字段配置 + _name_likes = { + cls.master_id.key: '%{}%', + cls.order_no.key: '%{}%', + cls.master_id.key: '%{}%', + } + + _query = select(cls).where( + *cls.search_wheres(likes=_name_likes, **kwargs) + ).group_by(cls.id) + + _paging = None + if is_paging: + _row_count = await cls.query_count(_query) + _paging = Pagination(_row_count).paging(page_number, page_size) + _data_query = _query.limit(page_size).offset(_paging.offset_size) + else: + _data_query = _query.where() + + # 排序处理 + _sort_clause = cls.sort_clauses(kwargs.get('sort_clause', {})) + if _sort_clause: + _data_query = _data_query.order_by(*_sort_clause) + else: + _data_query = _data_query.order_by(cls.created_at.desc()) + + _df = await cls.query_as_df(_data_query) + if not _df.empty: + _df.replace(models.EmptyInDF + models.EmptyDatetimeInDF, '', inplace=True) + _df[cls.id.key] = _df[cls.id.key].astype(str) + + return _df, _paging + + @classmethod + async def search(cls, **kwargs): + """分页搜索工单签收记录,返回标准分页结构""" + _df, _paging = await cls.search_base(**kwargs) + return { + 'total': _paging.row_count, + 'rows': _df.to_dict('records'), + 'pagination': { + 'page_number': _paging.page_number, + 'page_count': _paging.page_count, + 'page_size': _paging.page_size, + }, + } + + @classmethod + async def exists_master_id(cls, data_df: pd.DataFrame): + """根据 master_id 区分已有数据/新增数据(批量保存用)""" + if data_df.empty: + return pd.DataFrame(), pd.DataFrame() + + master_ids = data_df[cls.master_id.key].unique().tolist() + if not master_ids: + return pd.DataFrame(), data_df.copy() + + _query = select(cls.id, cls.master_id).where(cls.master_id.in_(master_ids)) + existing_df = await cls.query_as_df(_query) + + if existing_df.empty: + return pd.DataFrame(), data_df.copy() + + master_id_to_id_map = dict(zip(existing_df[cls.master_id.key], existing_df[cls.id.key])) + mask_exists = data_df[cls.master_id.key].isin(existing_df[cls.master_id.key]) + exists_df = data_df[mask_exists].copy() + exists_df[cls.id.key] = exists_df[cls.master_id.key].map(master_id_to_id_map) + latest_df = data_df[~mask_exists].copy() + return exists_df, latest_df + + @classmethod + async def find_by_master_id(cls, master_id: Union[str, int]): + """根据主表ID查询单条签收记录""" + _query = select(cls).where(cls.master_id == master_id) + return await cls.query_first(_query) + + @classmethod + async def find_by_order_id(cls, order_id: str): + """根据工单ID查询签收记录""" + _query = select(cls).where(cls.order_id == order_id) + return await cls.query_first(_query) + + @classmethod + async def find_by_master_ids(cls, master_ids: list[Union[str, int]]): + """根据主表ID列表批量查询签收记录""" + _query = select(cls).where(cls.master_id.in_(master_ids)) + _list: list[cls] = (await cls.orm_execute_scalars(_query)).all() + return _list + + +@register_swagger_model +class GovsSaveSign(GovsSaveSignBase): + """工单签收业务操作类""" + + @classmethod + async def create(cls, user: RbacUser = None, **kwargs): + """新增工单签收记录""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsSaveSignForm(formdata=kwargs) + _form.validate_form() + + _existing = await cls.is_exist( + master_id=_form.master_id.data, + order_id=_form.order_id.data, + order_no=_form.order_no.data + ) + assert _existing is None, "该任务已存在确认签收记录,无法重复创建。" + + _sign_info = cls().copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _sign_info.created_by = user.username + _sign_info.updated_by = user.username + await _sign_info.async_save() + return _sign_info + + @classmethod + async def delete(cls, sign_id: Union[str, int]): + """删除工单签收记录""" + _sign_info: cls = await cls.async_find_by_id(sign_id) + assert _sign_info, f"根据 ID {sign_id} 未找到工单签收记录。" + + _del_query = delete(cls).where(cls.id == _sign_info.id) + await cls.raw_execute(_del_query) + echo_log(f'已删除工单签收记录(工单ID:{_sign_info.master_id},ID:{_sign_info.id}).') + return _sign_info + + @classmethod + async def modify(cls, sign_id: Union[str, int], user: RbacUser = None, **kwargs): + """修改工单签收记录""" + for _k, _v in kwargs.items(): + if isinstance(_v, str): + kwargs[_k] = _v.strip() + + _form = GovsSaveSignForm(formdata=kwargs) + _form.validate_form() + + _sign_info: cls = await cls.async_find_by_id(sign_id) + assert _sign_info, f'查无此工单签收记录。' + + _sign_info.copy_from_dict(_form.data, skip_none=True).before_save() + if user: + _sign_info.updated_by = user.username + await _sign_info.async_save() + return _sign_info + + @classmethod + async def create_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量新增工单签收记录""" + if data_df.empty: + return 0 + + if user: + data_df['created_by'] = user.username + data_df['updated_by'] = user.username + + records = data_df.to_dict('records') + sign_list = [cls().copy_from_dict(record, skip_none=True).before_save() for record in records] + + session = cls.get_aio_session() + try: + session.add_all(sign_list) + await session.commit() + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + echo_log(f"批量创建成功:创建 {len(sign_list)} 条工单签收记录。") + return len(sign_list) + + @classmethod + async def modify_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量修改工单签收记录""" + if data_df.empty: + return 0 + + if 'id' not in data_df.columns: + echo_log(f"错误:modify_batch 要求输入数据必须包含 '{cls.id.key}' 列") + return 0 + + data_df['updated_at'] = datetime.datetime.now() + if user: + data_df['updated_by'] = user.username + + update_data = data_df.to_dict('records') + session = cls.get_aio_session() + try: + await session.run_sync( + lambda sync_session: sync_session.bulk_update_mappings(cls, update_data) + ) + await session.commit() + updated_count = len(update_data) + except Exception as e: + await session.rollback() + raise e + finally: + await session.close() + + echo_log(f"批量修改成功:更新 {updated_count} 条工单签收记录。") + return updated_count + + @classmethod + async def save_batch(cls, data_df: pd.DataFrame, user: RbacUser = None): + """批量保存(自动区分新增/更新)""" + _exists_df, _latest_df = await cls.exists_master_id(data_df) + _created_count = await cls.create_batch(_latest_df, user) + _updated_count = await cls.modify_batch(_exists_df, user) + return _created_count, _updated_count diff --git a/models/token.py b/models/token.py new file mode 100644 index 0000000..8495891 --- /dev/null +++ b/models/token.py @@ -0,0 +1,222 @@ +import datetime +from typing import Union + +from sqlalchemy import select +from tornado_swagger.model import register_swagger_model +from wtforms import StringField, IntegerField +from wtforms.validators import Length + +from models.common_model import CommonModel +from models.db_models import TToken +from paste.core.logging import echo_log +from paste.rbac.rbac_user import RbacUser +from paste.web.form import ModelForm + + +class TTokenForm(ModelForm): + """ + Token 表单验证类(完全映射 TToken 字段)。 + + 用于验证和处理认证令牌的创建/修改表单数据。 + 字段完全映射数据库表 t_token 的字段结构。 + """ + + # 主键 + id = IntegerField('主键ID') + + # 基础信息 + platform = StringField('平台', validators=[Length(max=20, message='平台长度不能超过20字符')]) + token = StringField('令牌', validators=[Length(max=500, message='令牌长度不能超过500字符')]) + deleted = IntegerField('是否删除(0未删,1已删)') + + # 创建与更新信息 + creator = StringField('创建者', validators=[Length(max=64, message='创建者长度不能超过64字符')]) + updater = StringField('更新者', validators=[Length(max=64, message='更新者长度不能超过64字符')]) + + def process(self, formdata=None, obj=None, **kwargs): + """ + 处理表单数据,在数据绑定前进行预处理。 + + 主要功能: + - 遍历所有表单字段 + - 对字符串类型的值去除两端空白字符 + - 调用父类的process方法继续处理 + """ + if formdata: + for name, values in formdata.items(): + if isinstance(values, list) and values: + formdata[name] = [v.strip() if isinstance(v, str) else v for v in values] + elif isinstance(values, str): + formdata[name] = values.strip() + super().process(formdata, obj, **kwargs) + + +class TokenBase(TToken, CommonModel): + """ + Token 基础类(完全映射 TToken 字段)。 + + 继承自数据库模型 TToken 和通用模型 CommonModel。 + 封装所有与认证令牌相关的通用操作方法。 + """ + + @classmethod + async def find_by_platform(cls, platform: str): + """ + 根据平台查找 Token 记录。 + + :param platform: 平台 + :return: Token 对象或 None + """ + _query = select(cls).where(cls.platform == platform, cls.deleted == 0) + _token: cls = await cls.query_first(_query) + assert _token, f'未找到可用 Token,平台:{platform}.' + return _token + + +@register_swagger_model +class TokenModel(TokenBase): + """ + Token 业务模型类(主业务类,完全继承 TToken 字段)。 + + --- + description: 认证 Token + type: object + properties: + id: + description: 主键 + type: integer + example: 1001 + readOnly: true + platform: + description: 平台 + type: string + example: "web" + maxLength: 20 + token: + description: 令牌 + type: string + example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + maxLength: 500 + deleted: + description: 是否删除(0未删,1已删) + type: integer + example: 0 + creator: + description: 创建者 + type: string + example: "admin" + maxLength: 64 + create_time: + description: 创建时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-15 10:30:00" + readOnly: true + updater: + description: 更新者 + type: string + example: "admin" + maxLength: 64 + update_time: + description: 更新时间,ISO格式的日期时间字符串 + type: string + format: date-time + example: "2024-01-16 14:25:00" + readOnly: true + """ + + @classmethod + async def create(cls, user: RbacUser=None, **kwargs): + """ + 创建新的 Token。 + + 业务流程: + 1. 使用 TTokenForm 验证表单数据完整性 + 2. 创建新 Token 对象 + 3. 设置创建者为当前用户 + 4. 保存到数据库 + 5. 返回创建的 Token 对象 + + :param RbacUser user: 操作用户对象 + :param kwargs: Token 参数字典 + :return: 新建 Token 对象 + :rtype: TokenModel + :raises ValidationError: 当表单验证失败时抛出 + """ + _token_form = TTokenForm(formdata=kwargs) + _token_form.validate_form() + + # 创建 Token 对象 + _token = cls().copy_from_dict(_token_form.data, skip_none=True).before_save() + if user: + _token.creator = user.username + _token.updater = user.username + else: + _token.creator = 'D3I' + _token.updater = 'D3I' + + _token.deleted = 0 + _token.create_time = datetime.datetime.now() + _token.update_time = datetime.datetime.now() + await _token.async_save() + return _token + + @classmethod + async def delete(cls, token_id: Union[str, int]): + """ + 逻辑删除 Token(设置 deleted=1)。 + + 业务流程: + 1. 根据ID查找 Token + 2. 验证存在性 + 3. 设置 deleted=1 + 4. 保存更新 + + :param token_id: 要删除的 Token ID + :return: 更新后的 Token 对象 + :rtype: TokenModel + :raises AssertionError: 当 Token 不存在时抛出 + """ + _token: cls = await cls.async_find_by_id(token_id) + assert _token, f"根据 ID {token_id} 未找到 Token。" + + _token.deleted = 1 + _token.updater = "system" + await _token.async_save() + echo_log(f'已逻辑删除 Token(ID:{_token.id})。') + return _token + + @classmethod + async def refresh(cls, platform: str, token: str, user: RbacUser=None): + """ + 刷新 Token 信息(更新 token、updater、update_time)。 + + 业务流程: + 1. 使用 TTokenForm 验证更新字段 + 2. 查询原 Token 对象 + 3. 更新字段并设置更新者,更新后,删除状态自动变为可用 + 4. 保存到数据库 + + :param platform: 要刷新平台 + :param token: 需要更新的 token + :param RbacUser user: 操作用户对象 + :return: 更新后的 Token 对象 + :rtype: TokenModel + :raises AssertionError: 当 Token 不存在时抛出 + :raises ValidationError: 当表单验证失败时抛出 + """ + # 查询原 Token + _token: cls = await cls(platform=platform).async_find_first() + + # 没找到,创建 + if not _token: + _token = await cls.create(platform=platform, token=token, user=user) + return _token + + # 找到,更新 + _token.token = token + _token.deleted = 0 + _token.update_time = datetime.datetime.now() + _token.updater = user.username if user else _token.updater + await _token.async_save() + return _token \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..12361f9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[tool.ruff.lint] +# 指定要检查的规则 +# 常用规则类别: +# E/Pyflakes: 语法错误和基本逻辑问题(重要) +# F/Pyflakes: 未使用的导入/变量(重要) +# I/isort: 导入排序(团队规范) +# B/flake8-bugbear: 常见陷阱(推荐) +# SIM/flake8-simplify: 代码简化建议(可选) +# C4/flake8-comprehensions: 推导式改进(可选) + +select = [ + "E", # pycodestyle 错误 + "F", # Pyflakes(未使用变量/导入) + # "I", # 导入排序 + # "B", # Bugbear(常见错误模式) + # "UP", # pyupgrade(语法现代化) +] + +# 忽略不需要的规则 +ignore = [ + "F841", # 未使用的变量 - 忽略 + "I001", # import 排序 - 忽略 + "B009", # getattr 警告 - 忽略 + "UP007", # Union 语法 - 忽略 + "F541", # f-string without placeholders + "E501", # line too long(行太长) + "E741", # 允许使用 l、O、I 等变量名 + "D", # pydocstyle(文档字符串要求,太严格) + "ANN", # flake8-annotations(类型注解要求,太严格) + "S", # bandit(安全检查,有些太严格) +] + +[tool.ruff.lint.per-file-ignores] +# 测试文件中更宽松 +"tests/*" = [ + "F541", # 测试里用 f-string 不带 placeholder 也没事 + "S101", # 测试中允许 assert +] \ No newline at end of file diff --git a/service/__init__.py b/service/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/service/__pycache__/__init__.cpython-311.pyc b/service/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..92255c3 Binary files /dev/null and b/service/__pycache__/__init__.cpython-311.pyc differ diff --git a/service/__pycache__/api_service.cpython-311.pyc b/service/__pycache__/api_service.cpython-311.pyc new file mode 100644 index 0000000..38606b8 Binary files /dev/null and b/service/__pycache__/api_service.cpython-311.pyc differ diff --git a/service/__pycache__/dcm_service.cpython-311.pyc b/service/__pycache__/dcm_service.cpython-311.pyc new file mode 100644 index 0000000..00686dc Binary files /dev/null and b/service/__pycache__/dcm_service.cpython-311.pyc differ diff --git a/service/api_service.py b/service/api_service.py new file mode 100644 index 0000000..38682b0 --- /dev/null +++ b/service/api_service.py @@ -0,0 +1,149 @@ +import logging +import os.path +import socket + +import redis +import sqlalchemy.exc +from tornado.ioloop import IOLoop, PeriodicCallback + +from base import conn_pool +from paste.core import config +from paste.core.logging import echo_log, get_logger, set_logger_config +from paste.db.baseadapter import BaseAdapter +from paste.service.daemonize import DaemonizeService +from paste.util import udict +from paste.web.application import ApplicationSwagger + +logger_config_name = 'logger.api' +""" +配置文件中日志配置字段名称。 +""" + +callbacks: list[PeriodicCallback] = [] +""" +回调函数对象。 +""" + +current_io_loop = None +""" +Tornado 事件循环对象。 +""" + +pid_file = os.path.join(os.path.curdir, 'api_service.pid') +""" +PID 文件路径。 +""" + +service_name = 'D3I API 服务' +""" +服务名称。 +""" + + +def current_loop() -> IOLoop: + """ + 这里必须采用方法,在适当的时间点创建事件循环对象,否则会导致服务无法启动。 + :return: 事件循环对象 + """ + global current_io_loop + if current_io_loop is None: + current_io_loop = IOLoop.current() + return current_io_loop + + +def daemon(): + """ + 驻守线程,处理诸如 Websocket 推送等事务(已弃用)。 + """ + # 无事发生 + + # 启动回调列表 + for _cb in callbacks: + _cb.start() + echo_log(f"合计启动了 {len(callbacks)} 个驻守任务.") + + +def start_apps(): + """ + 启动配置文件中配置的各种服务。 + """ + apps_config: list[dict] = config.get_config('tornado.api', []) + apps: list[ApplicationSwagger] = [] + + for _app_cfg in apps_config: + handlers_pkg = udict.get_by_path(_app_cfg, 'handlers_pkg') + app = ApplicationSwagger(**_app_cfg) + port = udict.get_by_path(_app_cfg, 'port') + address = udict.get_by_path(_app_cfg, 'address') + app.listen(port, address) + apps.append(app) + echo_log(f"模块 {handlers_pkg} 已加载于:http://127.0.0.1:{port}") + + # 启动驻守线程 + # daemon() + return apps + + +def start_service(): + set_logger_config(logger_config_name) + echo_log(f"正在启动{service_name}...") + + # 启动服务时,重置加密密钥,意味着所有登录 Token 均失效 + # echo_log(json_token.reset_secret_key()) + + try: + # 检测 MySQL 服务是否正常 + echo_log('检测数据库服务...') + # 绑定连接池监听器 + conn_pool.bind_listener() + # 检测数据连接 + BaseAdapter.ping() + echo_log('数据库服务正常.') + + # 启动服务 + start_apps() + echo_log(f"{service_name}启动成功.") + current_loop().start() + except (redis.exceptions.TimeoutError, socket.timeout): + echo_log('Redis 服务异常.', level=logging.ERROR, is_log_exc=True) + echo_log(f"{service_name}启动失败.") + except sqlalchemy.exc.OperationalError: + echo_log('Database 服务异常.', level=logging.ERROR, is_log_exc=True) + echo_log(f"{service_name}启动失败.") + except KeyboardInterrupt: + echo_log(msg='KeyboardInterrupt') + stop_service() + except Exception as e: + echo_log(msg=e, level=logging.ERROR, is_log_exc=True) + + +def stop_service(): + # 停止回调列表 + for _cb in callbacks: + _cb.stop() + current_loop().stop() + echo_log(f"{service_name}已停止.") + + +def start(): + """ + 以驻内存方式启动服务。 + """ + set_logger_config(logger_config_name) + get_logger() + ds = DaemonizeService(pid_file=pid_file, name=service_name) + ds.set_start_callback(start_service) + ds.set_term_callback(stop_service) + ds.start() + + +def stop(): + """ + 停止驻内存服务。 + """ + set_logger_config(logger_config_name) + get_logger() + ds = DaemonizeService(pid_file=pid_file, name=service_name) + ds.set_start_callback(start_service) + ds.set_term_callback(stop_service) + ds.stop() diff --git a/service/dcm_service.py b/service/dcm_service.py new file mode 100644 index 0000000..9b6cdf0 --- /dev/null +++ b/service/dcm_service.py @@ -0,0 +1,110 @@ +""" +系统服务,用于读取服务配置文件,启动或停止相关的服务。 +""" +import logging +import os +import sys +from typing import Optional + +from dock.dcm import dcm_scrape, dcm_security +from dock.oa_dcm import oa_push_order, oa_sign_task +from paste.core.logging import echo_log, set_logger_config +from paste.service.task_service import TaskService + +logger_config_name = 'logger.dcm_service' +""" +配置文件中日志配置字段名称。 +""" + +task_serv: Optional[TaskService] = None +""" +任务服务对象。 +""" + +pid_file = os.path.join(os.path.curdir, 'dcm_service.pid') +""" +PID 文件路径。 +""" + +service_name = '数字城管计划任务服务' +""" +服务名称。 +""" + + +def init_task_service(): + """ + 初始化服务对象并安装具体任务。 + """ + global task_serv + task_serv = TaskService(service_name=service_name, pid_file=pid_file) + + # 每隔 2 小时执行,更新数字城管 Cookies + task_serv.add_task(creator=task_serv.create_delay_task, fn=renew_dcm_token, delay=3600 * 2) + + # 每隔 2 小时执行,抓取 DCM 数据 + task_serv.add_task(creator=task_serv.create_delay_task, fn=scrape_dcm_task, delay=3600 * 2) + + return task_serv + + +async def renew_dcm_token(): + try: + echo_log(f"开始执行数字城管 Cookies 更新...") + await dcm_security.login() + echo_log(f"完成数字城管 Cookies 更新.") + except Exception as e: + echo_log(msg=e, level=logging.ERROR, is_log_exc=True) + + +async def scrape_dcm_task(): + fetch_size = 30 + try: + echo_log(f"开始执行 DCM<=>OA 数据同步...") + await dcm_scrape.fetch_dcm_task(fetch_size) + # 工单推送 OA 平台 + await oa_push_order.push_full_order(fetch_size) + # 推送结束后,签收工单 + await oa_sign_task.sign_task(fetch_size) + echo_log(f"执行 DCM<=>OA 数据同步完成...") + except Exception as e: + echo_log(msg=e, level=logging.ERROR, is_log_exc=True) + + +def start_service(): + """ + 控制台服务方式启动。 + """ + set_logger_config(logger_config_name) + _ts = init_task_service() + _ts.start_service(env_check=False) + + +def start(): + """ + 驻内存服务方式启动。 + """ + set_logger_config(logger_config_name) + _ts = init_task_service() + _ts.start() + + +def stop(): + """ + 驻内存服务方式停止。 + """ + set_logger_config(logger_config_name) + _ts = init_task_service() + _ts.stop() + + +if __name__ == "__main__": + if len(sys.argv) > 1: + if sys.argv[1] == "start": + start_service() + elif sys.argv[1] == "stop": + stop() + else: + print("用法: python service/task_service.py start") + else: + start_service() diff --git a/service/govs_service.py b/service/govs_service.py new file mode 100644 index 0000000..25cd592 --- /dev/null +++ b/service/govs_service.py @@ -0,0 +1,107 @@ +""" +系统服务,用于读取服务配置文件,启动或停止相关的服务。 +""" +import logging +import os +import sys +from typing import Optional + +from dock.govs import govs_scrape, govs_security +from dock.oa_govs import govs_push_order, oa_sign_task +from paste.core.logging import echo_log, set_logger_config +from paste.service.task_service import TaskService + +logger_config_name = 'logger.govs_service' +""" +配置文件中日志配置字段名称。 +""" + +task_serv: Optional[TaskService] = None +""" +任务服务对象。 +""" + +pid_file = os.path.join(os.path.curdir, 'govs_service.pid') +""" +PID 文件路径。 +""" + +service_name = '省12345计划任务服务' +""" +服务名称。 +""" + + +def init_task_service(): + """ + 初始化服务对象并安装具体任务。 + """ + global task_serv + task_serv = TaskService(service_name=service_name, pid_file=pid_file) + + # 每隔 2 小时执行,更新省12345 Token + task_serv.add_task(creator=task_serv.create_delay_task, fn=renew_govs_token, delay=3600 * 2) + + # 每隔 2 小时执行,抓取 省12345 数据 + task_serv.add_task(creator=task_serv.create_delay_task, fn=scrape_govs_task, delay=3600 * 2) + return task_serv + + +async def renew_govs_token(): + try: + echo_log(f"开始执行省12345 Token 更新...") + await govs_security.login() + echo_log(f"开始执行省12345 Token 更新.") + except Exception as e: + echo_log(msg=e, level=logging.ERROR, is_log_exc=True) + + +async def scrape_govs_task(): + fetch_size = 30 + try: + echo_log(f"开始执行 GOVS<=>OA 数据同步...") + await govs_scrape.fetch_govs_task(num_per_page=fetch_size) + # 工单推送 OA 平台并签收 + await govs_push_order.push_full_order(fetch_size) + echo_log(f"执行 GOVS<=>OA 数据同步完成...") + except Exception as e: + echo_log(msg=e, level=logging.ERROR, is_log_exc=True) + + +def start_service(): + """ + 控制台服务方式启动。 + """ + set_logger_config(logger_config_name) + _ts = init_task_service() + _ts.start_service(env_check=False) + + +def start(): + """ + 驻内存服务方式启动。 + """ + set_logger_config(logger_config_name) + _ts = init_task_service() + _ts.start() + + +def stop(): + """ + 驻内存服务方式停止。 + """ + set_logger_config(logger_config_name) + _ts = init_task_service() + _ts.stop() + + +if __name__ == "__main__": + if len(sys.argv) > 1: + if sys.argv[1] == "start": + start_service() + elif sys.argv[1] == "stop": + stop() + else: + print("用法: python service/task_service.py start") + else: + start_service() diff --git a/service/oa_service.py b/service/oa_service.py new file mode 100644 index 0000000..05c8b46 --- /dev/null +++ b/service/oa_service.py @@ -0,0 +1,92 @@ +""" +系统服务,用于读取服务配置文件,启动或停止相关的服务。 +""" +import logging +import os +import sys +from typing import Optional + +from dock.oa import oa_security +from paste.core.logging import echo_log, set_logger_config +from paste.service.task_service import TaskService + +logger_config_name = 'logger.oa_service' +""" +配置文件中日志配置字段名称。 +""" + +task_serv: Optional[TaskService] = None +""" +任务服务对象。 +""" + +pid_file = os.path.join(os.path.curdir, 'oa_service.pid') +""" +PID 文件路径。 +""" + +service_name = 'OA计划任务服务' +""" +服务名称。 +""" + + +def init_task_service(): + """ + 初始化服务对象并安装具体任务。 + """ + global task_serv + task_serv = TaskService(service_name=service_name, pid_file=pid_file) + + # 每隔 10 分钟执行,更新 OA Token + task_serv.add_task(creator=task_serv.create_delay_task, fn=renew_oa_token, delay=60 * 10) + + return task_serv + + +async def renew_oa_token(): + try: + echo_log(f"开始执行 OA Token 更新...") + await oa_security.login() + echo_log(f"完成 OA Token 更新.") + except Exception as e: + echo_log(msg=e, level=logging.ERROR, is_log_exc=True) + + +def start_service(): + """ + 控制台服务方式启动。 + """ + set_logger_config(logger_config_name) + _ts = init_task_service() + _ts.start_service(env_check=False) + + +def start(): + """ + 驻内存服务方式启动。 + """ + set_logger_config(logger_config_name) + _ts = init_task_service() + _ts.start() + + +def stop(): + """ + 驻内存服务方式停止。 + """ + set_logger_config(logger_config_name) + _ts = init_task_service() + _ts.stop() + + +if __name__ == "__main__": + if len(sys.argv) > 1: + if sys.argv[1] == "start": + start_service() + elif sys.argv[1] == "stop": + stop() + else: + print("用法: python service/task_service.py start") + else: + start_service() diff --git a/tp.py b/tp.py new file mode 100644 index 0000000..5891cb7 --- /dev/null +++ b/tp.py @@ -0,0 +1,6 @@ +import apps + +if __name__ == "__main__": + from paste.core import aio_pool + _runner = aio_pool.get_aio_runner() + print(apps.get_version()) \ No newline at end of file