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