Compare commits
6 Commits
c4c4ccc7e9
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f0fcea03bb | |||
| 838453b88f | |||
| 98944ecbdc | |||
| 283f7849f8 | |||
| 5cde7f852a | |||
| 3938c820b5 |
+179
@@ -0,0 +1,179 @@
|
||||
### Python template
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
### 默认 template
|
||||
# IntelliJ 文件
|
||||
.idea
|
||||
*.iml
|
||||
out
|
||||
gen
|
||||
|
||||
# 数据文件
|
||||
*.csv
|
||||
*.xlsx
|
||||
*.xls
|
||||
|
||||
# 环境文件
|
||||
.vscode
|
||||
.conda
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@@ -0,0 +1,840 @@
|
||||
"""
|
||||
简道云 API 接口封装模块
|
||||
|
||||
本模块封装了简道云的所有 API 接口,包括:
|
||||
- 表单数据操作(获取、创建、更新、删除)
|
||||
- 表单字段获取
|
||||
- 工作流操作(获取实例、审批、转交)
|
||||
- 文件操作(获取上传凭证、上传文件)
|
||||
|
||||
特性:
|
||||
- 支持失败重试机制(默认最多重试20次)
|
||||
- 支持字段替换(将字段ID替换为标签名)
|
||||
- 支持批量操作(批量创建、更新、删除)
|
||||
- 支持数据类型转换(NumPy、Decimal等)
|
||||
- 完善的错误处理和日志记录
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from decimal import Decimal
|
||||
import numpy as np
|
||||
from app.config import Config
|
||||
|
||||
# 获取日志记录器
|
||||
logger = logging.getLogger('app')
|
||||
error_logger = logging.getLogger('app.error') # 错误日志记录器
|
||||
|
||||
|
||||
class NpEncoder(json.JSONEncoder):
|
||||
"""
|
||||
NumPy 数据类型 JSON 编码器
|
||||
|
||||
用于将 NumPy 数据类型转换为 JSON 可序列化的类型。
|
||||
支持:
|
||||
- np.integer -> int
|
||||
- np.floating -> float
|
||||
- np.ndarray -> list
|
||||
"""
|
||||
def default(self, obj):
|
||||
if isinstance(obj, np.integer):
|
||||
return int(obj)
|
||||
elif isinstance(obj, np.floating):
|
||||
return float(obj)
|
||||
elif isinstance(obj, np.ndarray):
|
||||
return obj.tolist()
|
||||
else:
|
||||
return super(NpEncoder, self).default(obj)
|
||||
|
||||
|
||||
def replace_decimals(obj):
|
||||
"""
|
||||
递归替换 Decimal 类型为 float
|
||||
|
||||
递归遍历数据结构,将所有 Decimal 类型替换为 float 类型,
|
||||
用于 JSON 序列化。
|
||||
|
||||
Args:
|
||||
obj: 要处理的对象(可以是 dict、list 或其他类型)
|
||||
|
||||
Returns:
|
||||
处理后的对象,所有 Decimal 类型已替换为 float
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return {k: replace_decimals(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [replace_decimals(item) for item in obj]
|
||||
elif isinstance(obj, Decimal):
|
||||
return float(obj)
|
||||
return obj
|
||||
|
||||
|
||||
class API:
|
||||
"""
|
||||
简道云 API 接口封装类
|
||||
|
||||
提供简道云所有 API 接口的封装,包括表单操作、工作流操作、文件操作等。
|
||||
所有方法都支持失败重试机制,提高可靠性。
|
||||
"""
|
||||
|
||||
def entry_data_get(self, data: dict, replace: bool = False, max_retries: int = 20) -> Dict:
|
||||
"""
|
||||
获取单条表单数据
|
||||
|
||||
根据应用ID、表单ID和数据ID获取单条表单数据。
|
||||
|
||||
Args:
|
||||
data: 包含应用ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
|
||||
replace: 是否替换字段(将字段ID替换为标签名),默认为False
|
||||
max_retries: 最大重试次数,默认为20次
|
||||
|
||||
Returns:
|
||||
Dict: 表单数据字典
|
||||
|
||||
Raises:
|
||||
Exception: 如果重试max_retries次后仍然失败
|
||||
"""
|
||||
url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/get'
|
||||
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
payload = json.dumps({
|
||||
"app_id": data['api_key'],
|
||||
"entry_id": data['entry_id'],
|
||||
"data_id": data['data_id']
|
||||
})
|
||||
|
||||
retries = 0
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
res = requests.post(url=url, data=payload, headers=headers, timeout=10)
|
||||
res.raise_for_status()
|
||||
data_get = res.json()
|
||||
print(data_get)
|
||||
|
||||
if replace:
|
||||
data_get = self.field_replacement(data, data_get)
|
||||
|
||||
return data_get
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
if retries <= max_retries:
|
||||
time.sleep(0.1)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(f"任务 {data.get('data_id')} 连续{max_retries}次请求失败,放弃此次请求。")
|
||||
raise Exception(f"获取单条表单数据失败,已重试{max_retries}次")
|
||||
|
||||
def entry_data_list(self, data: dict, replace: bool = True, max_retries: int = 20) -> Dict:
|
||||
"""
|
||||
获取多条表单数据
|
||||
:param max_retries: 最大重试次数
|
||||
:param replace: 是否替换字段,默认为True(保持向后兼容)
|
||||
:param data: 简道云插件发送过来的data,包含应用id、表单id等信息
|
||||
:return: 表单数据列表
|
||||
"""
|
||||
url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/list'
|
||||
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
all_data_batches = []
|
||||
last_data_id = None
|
||||
exit_flag = False
|
||||
|
||||
while True:
|
||||
payload = json.dumps({
|
||||
"app_id": data['api_key'],
|
||||
"entry_id": data['entry_id'],
|
||||
"limit": 100,
|
||||
"data_id": last_data_id
|
||||
})
|
||||
|
||||
retries = 0
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
res = requests.post(url=url, data=payload, headers=headers, timeout=10)
|
||||
res.raise_for_status()
|
||||
data_get = res.json()
|
||||
|
||||
if data_get.get("data"):
|
||||
all_data_batches.extend(data_get['data'])
|
||||
last_data_id = data_get['data'][-1].get('_id')
|
||||
print(f"已获取 {len(all_data_batches)} 条数据")
|
||||
break
|
||||
else:
|
||||
if 'data' not in data_get or len(data_get['data']) == 0:
|
||||
exit_flag = True
|
||||
break
|
||||
logger.warning(f"请求异常, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(0.1)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(0.1)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(f"任务 {last_data_id}组 连续{max_retries}次请求失败,放弃此次请求。")
|
||||
all_data_batches.append(None)
|
||||
|
||||
if exit_flag:
|
||||
break
|
||||
|
||||
final_data = {
|
||||
'data': all_data_batches
|
||||
}
|
||||
|
||||
logger.info(f"获取了{len(all_data_batches)}条数据")
|
||||
|
||||
if replace:
|
||||
print("进行了替换")
|
||||
return self.field_replacement(data, final_data)
|
||||
else:
|
||||
return final_data
|
||||
|
||||
@staticmethod
|
||||
def entry_widget_list(data: dict, max_retries: int = 20) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取表单字段
|
||||
:param max_retries: 最大重试次数
|
||||
:param data: 简道云插件发送过来的data,包含应用id、表单id等信息
|
||||
:return: 表单字段信息
|
||||
"""
|
||||
url = 'https://api.jiandaoyun.com/api/v5/app/entry/widget/list'
|
||||
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
payload = json.dumps({
|
||||
"app_id": data['api_key'],
|
||||
"entry_id": data['entry_id'],
|
||||
})
|
||||
|
||||
retries = 0
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
res = requests.post(url=url, data=payload, headers=headers, timeout=10)
|
||||
res.raise_for_status()
|
||||
return res.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
if retries <= max_retries:
|
||||
time.sleep(0.1)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(f"获取表单字段失败,已重试{max_retries}次")
|
||||
return None
|
||||
|
||||
def field_replacement(self, data: dict, data_get: dict) -> dict:
|
||||
"""
|
||||
字段替换,将id替换为标签名,即唯一值替换为表单中显示字段的名字
|
||||
:param data: 简道云插件发送过来的data,包含表单id、数据id、应用id
|
||||
:param data_get: 简道云请求的数据,一般是根据数据id获取到表单的数据
|
||||
:return: 替换后的数据
|
||||
"""
|
||||
widget_list = self.entry_widget_list(data)
|
||||
|
||||
if not widget_list or 'widgets' not in widget_list or not isinstance(widget_list['widgets'], list):
|
||||
raise ValueError("映射表没有接受到数据")
|
||||
|
||||
name_to_label = {widget['name']: widget['label'] for widget in widget_list['widgets']}
|
||||
|
||||
def replace_keys(obj):
|
||||
"""递归替换字典中的键名"""
|
||||
if isinstance(obj, dict):
|
||||
new_dict = {}
|
||||
for key, value in obj.items():
|
||||
new_key = name_to_label.get(key, key)
|
||||
new_dict[new_key] = replace_keys(value)
|
||||
return new_dict
|
||||
elif isinstance(obj, list):
|
||||
return [replace_keys(item) for item in obj]
|
||||
else:
|
||||
return obj
|
||||
|
||||
data_get_copy = json.loads(json.dumps(data_get))
|
||||
|
||||
if 'data' in data_get_copy:
|
||||
data_get_copy['data'] = replace_keys(data_get_copy['data'])
|
||||
|
||||
return data_get_copy
|
||||
|
||||
@staticmethod
|
||||
def data_batch_create(data: dict, max_retries: int = 20) -> Optional[Dict]:
|
||||
"""
|
||||
新建单条表单数据
|
||||
:param max_retries: 最大重试次数
|
||||
:param data: 应该包含应用id、表单id,以及新建的数据data['data']
|
||||
:return: 返回创建后简道云返回的信息
|
||||
"""
|
||||
url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/create'
|
||||
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
payload = json.dumps({
|
||||
"app_id": data['api_key'],
|
||||
"entry_id": data['entry_id'],
|
||||
"data": data['data'],
|
||||
"is_start_workflow": data.get('is_start_workflow', "false"),
|
||||
"is_start_trigger": data.get('is_start_trigger', "false"),
|
||||
"transaction_id": data.get('transaction_id', "")
|
||||
})
|
||||
|
||||
retries = 0
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
res = requests.post(url=url, data=payload, headers=headers, timeout=10)
|
||||
res.raise_for_status()
|
||||
data_get = res.json()
|
||||
if res.status_code == 200:
|
||||
return data_get
|
||||
else:
|
||||
logger.warning(f"请求异常, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(f"任务 {data.get('data')} 连续{max_retries}次请求失败,放弃此次请求。")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def entry_data_batch_create(
|
||||
data: dict,
|
||||
chunk_size: int = 90,
|
||||
max_retries: int = 20
|
||||
) -> List[Optional[Dict]]:
|
||||
"""
|
||||
新建多条数据
|
||||
:param max_retries: 最大重试次数
|
||||
:param data: 应包含数据id、表单id、以及需要新建的信息,新建信息应该是一个列表
|
||||
:param chunk_size: 简道云限制批量新建一次最多100条,这里默认值设置为90条一次
|
||||
:return: 返回请求后的结果
|
||||
"""
|
||||
data = replace_decimals(data)
|
||||
url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/batch_create'
|
||||
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
total_length = len(data['data_list'])
|
||||
logger.info(f"多数据写入行数: {total_length}")
|
||||
|
||||
num_chunks = (total_length + chunk_size - 1) // chunk_size
|
||||
data_get_list = []
|
||||
|
||||
for i in range(num_chunks):
|
||||
start_index = i * chunk_size
|
||||
end_index = min(start_index + chunk_size, total_length)
|
||||
payload = json.dumps({
|
||||
"app_id": data['api_key'],
|
||||
"entry_id": data['entry_id'],
|
||||
"data_list": data['data_list'][start_index:end_index],
|
||||
"is_start_workflow": data.get('is_start_workflow', "false"),
|
||||
"is_start_trigger": data.get('is_start_trigger', "false"),
|
||||
}, cls=NpEncoder)
|
||||
|
||||
retries = 0
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
res = requests.post(url=url, data=payload, headers=headers, timeout=10)
|
||||
res.raise_for_status()
|
||||
data_get = res.json()
|
||||
if data_get.get("status") == "success":
|
||||
data_get_list.append(data_get)
|
||||
break
|
||||
else:
|
||||
logger.warning(f"请求异常,将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(0.1)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(
|
||||
f"任务 {data['data_list'][start_index:end_index]} 连续{max_retries}次请求失败,放弃此次请求。")
|
||||
data_get_list.append(None)
|
||||
|
||||
return data_get_list
|
||||
|
||||
@staticmethod
|
||||
def entry_data_update(data: dict, max_retries: int = 20) -> dict:
|
||||
"""
|
||||
修改数据
|
||||
:param max_retries: 最大重试次数
|
||||
:param data: 简道云插件发送过来的data,包含应用id、表单id、数据id等信息
|
||||
:return: 修改数据后简道云返回的结果
|
||||
"""
|
||||
url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/update'
|
||||
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
payload = json.dumps({
|
||||
"app_id": data['api_key'],
|
||||
"entry_id": data['entry_id'],
|
||||
"data_id": data['data_id'],
|
||||
"data": data['data']
|
||||
})
|
||||
|
||||
data_get = None
|
||||
retries = 0
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
res = requests.post(url=url, data=payload, headers=headers, timeout=10)
|
||||
res.raise_for_status()
|
||||
data_get = res.json()
|
||||
if res.status_code == 200:
|
||||
break
|
||||
else:
|
||||
logger.warning(f"请求异常, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(10)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(f"任务 {data.get('data_id')} 连续{max_retries}次请求失败,放弃此次请求。")
|
||||
|
||||
return data_get
|
||||
|
||||
@staticmethod
|
||||
def entry_data_banch_update(data: dict, max_retries: int = 20, chunk_size: int = 90) -> List[dict]:
|
||||
"""
|
||||
批量修改数据
|
||||
:param chunk_size: 批量修改块大小
|
||||
:param max_retries: 最大重试次数
|
||||
:param data: 简道云插件发送过来的data,包含应用id、表单id、数据id等信息
|
||||
:return: 修改数据后简道云返回的结果列表
|
||||
"""
|
||||
url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/batch_update'
|
||||
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
total_length = len(data['data_ids'])
|
||||
logger.info(f"多数据修改行数: {total_length}")
|
||||
|
||||
num_chunks = (total_length + chunk_size - 1) // chunk_size
|
||||
data_get_list = []
|
||||
|
||||
for i in range(num_chunks):
|
||||
start_index = i * chunk_size
|
||||
end_index = min(start_index + chunk_size, total_length)
|
||||
payload = {
|
||||
"app_id": data['api_key'],
|
||||
"entry_id": data['entry_id'],
|
||||
"data_ids": data['data_ids'][start_index:end_index],
|
||||
"data": data['data']
|
||||
}
|
||||
|
||||
if "transaction_id" in data:
|
||||
payload["transaction_id"] = data["transaction_id"]
|
||||
payload = json.dumps(payload, cls=NpEncoder)
|
||||
|
||||
retries = 0
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
res = requests.post(url=url, data=payload, headers=headers, timeout=10)
|
||||
res.raise_for_status()
|
||||
data_get = res.json()
|
||||
if res.status_code == 200:
|
||||
data_get_list.append(data_get)
|
||||
break
|
||||
else:
|
||||
logger.warning(f"请求异常, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(10)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(f"任务 {data.get('data_ids')} 连续{max_retries}次请求失败,放弃此次请求。")
|
||||
continue
|
||||
|
||||
return data_get_list
|
||||
|
||||
@staticmethod
|
||||
def entry_data_delete(data: dict, max_retries: int = 20) -> dict:
|
||||
"""
|
||||
删除单条数据
|
||||
:param data: 应包含应用ID、表单ID、数据ID
|
||||
:param max_retries: 最大重试次数,默认20
|
||||
:return: 删除结果
|
||||
"""
|
||||
url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/delete'
|
||||
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
payload = json.dumps({
|
||||
"app_id": data['api_key'],
|
||||
"entry_id": data['entry_id'],
|
||||
"data_id": data['data_id'],
|
||||
})
|
||||
|
||||
retries = 0
|
||||
delete_status = None
|
||||
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
res = requests.post(url=url, data=payload, headers=headers, timeout=10)
|
||||
delete_status = res.json()
|
||||
|
||||
# 手动处理状态码 4001(数据不存在)
|
||||
if delete_status == {
|
||||
"code": 4001,
|
||||
"msg": "Data does not exist."
|
||||
}:
|
||||
logger.info(f"返回结果: {delete_status}")
|
||||
break
|
||||
|
||||
res.raise_for_status()
|
||||
|
||||
if res.status_code == 200:
|
||||
break
|
||||
else:
|
||||
logger.warning(f"请求异常, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(10)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(f"任务 {data.get('data_id')} 连续{max_retries}次请求失败,放弃此次请求。")
|
||||
continue
|
||||
|
||||
return delete_status
|
||||
|
||||
@staticmethod
|
||||
def entry_data_batch_delete(
|
||||
data: dict,
|
||||
chunk_size: int = 90,
|
||||
max_retries: int = 20
|
||||
) -> List[Optional[Dict]]:
|
||||
"""
|
||||
批量删除数据
|
||||
:param data: 应包含应用ID、表单ID、数据ID列表
|
||||
:param chunk_size: 单次删除最大条数,默认90
|
||||
:param max_retries: 重试次数,默认20
|
||||
:return: 删除结果列表
|
||||
"""
|
||||
data = replace_decimals(data)
|
||||
url = 'https://api.jiandaoyun.com/api/v5/app/entry/data/batch_delete'
|
||||
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
total_length = len(data['data_ids'])
|
||||
logger.info(f"多数据删除行数: {total_length}")
|
||||
|
||||
num_chunks = (total_length + chunk_size - 1) // chunk_size
|
||||
data_get_list = []
|
||||
|
||||
for i in range(num_chunks):
|
||||
start_index = i * chunk_size
|
||||
end_index = min(start_index + chunk_size, total_length)
|
||||
payload = json.dumps({
|
||||
"app_id": data['api_key'],
|
||||
"entry_id": data['entry_id'],
|
||||
"data_ids": data['data_ids'][start_index:end_index],
|
||||
}, cls=NpEncoder)
|
||||
|
||||
retries = 0
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
res = requests.post(url=url, data=payload, headers=headers, timeout=10)
|
||||
res.raise_for_status()
|
||||
data_get = res.json()
|
||||
logger.info(f"{i}页 返回结果: {data_get}")
|
||||
if data_get.get("status") == "success":
|
||||
data_get_list.append(data_get)
|
||||
break
|
||||
else:
|
||||
logger.warning(f"请求异常, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(0.1)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(
|
||||
f"批量删除任务第{i+1}批 连续{max_retries}次请求失败,放弃此次请求。")
|
||||
data_get_list.append(None)
|
||||
|
||||
return data_get_list
|
||||
|
||||
@staticmethod
|
||||
def workflow_instance_get(data: dict, max_retries: int = 20) -> dict:
|
||||
"""
|
||||
查询实例流程信息
|
||||
:param max_retries: 最大重试次数
|
||||
:param data: 简道云插件发送过来的data,包含应用id
|
||||
:return: 查询简道云流程实例信息返回的结果
|
||||
"""
|
||||
url = 'https://api.jiandaoyun.com/api/v5/workflow/instance/get'
|
||||
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
payload = json.dumps({
|
||||
"instance_id": data['data_id'],
|
||||
"tasks_type": 1
|
||||
})
|
||||
|
||||
data_get = None
|
||||
retries = 0
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
res = requests.post(url=url, data=payload, headers=headers, timeout=10)
|
||||
res.raise_for_status()
|
||||
data_get = res.json()
|
||||
if res.status_code == 200:
|
||||
break
|
||||
else:
|
||||
logger.warning(f"请求异常, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(0.1)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(f"任务 {data.get('data_id')} 连续{max_retries}次请求失败,放弃此次请求。")
|
||||
|
||||
return data_get
|
||||
|
||||
@staticmethod
|
||||
def workflow_task_approve(data: dict, max_retries: int = 20) -> dict:
|
||||
"""
|
||||
流程待办提交
|
||||
:param max_retries: 最大重试次数
|
||||
:param data: 应包含username、instance_id(data_id)、task_id等信息
|
||||
:return: 返回简道云流程待办提交的结果
|
||||
"""
|
||||
url = 'https://api.jiandaoyun.com/api/v1/workflow/task/approve'
|
||||
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
payload = json.dumps({
|
||||
"username": data["username"],
|
||||
"instance_id": data["instance_id"],
|
||||
"task_id": data['task_id'],
|
||||
"comment": data.get("comment", "自动转交")
|
||||
})
|
||||
|
||||
retries = 0
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
res = requests.post(url=url, data=payload, headers=headers, timeout=10)
|
||||
res.raise_for_status()
|
||||
if res.status_code == 200:
|
||||
return res.json()
|
||||
else:
|
||||
logger.warning(f"请求异常, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(f"任务 {data.get('task_id')} 连续{max_retries}次请求失败,放弃此次请求。")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def workflow_task_hand_over(data: dict, max_retries: int = 10) -> Optional[dict]:
|
||||
"""
|
||||
流程待办转交
|
||||
:param max_retries: 最大重试次数
|
||||
:param data: 应包含username、instance_id(data_id)、task_id、transfer_username等信息
|
||||
:return: 返回简道云流程待办转交的结果
|
||||
"""
|
||||
url = 'https://api.jiandaoyun.com/api/v1/workflow/task/transfer'
|
||||
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
payload = json.dumps({
|
||||
"username": data["username"],
|
||||
"instance_id": data["instance_id"],
|
||||
"task_id": data['task_id'],
|
||||
"transfer_username": data['transfer_username'],
|
||||
"comment": data.get("comment", "转交")
|
||||
})
|
||||
|
||||
retries = 0
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
res = requests.post(url=url, data=payload, headers=headers, timeout=10)
|
||||
res.raise_for_status()
|
||||
if res.status_code == 200:
|
||||
return res.json()
|
||||
else:
|
||||
logger.warning(f"请求异常, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(f"任务转交失败,已重试{max_retries}次")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_upload_token(data: dict, max_retries: int = 10) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取文件上传凭证
|
||||
:param max_retries: 最大重试次数
|
||||
:param data: 应包含应用ID、表单ID、事务ID
|
||||
:return: 返回upload_url、upload_token
|
||||
"""
|
||||
url = 'https://api.jiandaoyun.com/api/v5/app/entry/file/get_upload_token'
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
payload = json.dumps({
|
||||
"app_id": data['api_key'],
|
||||
"entry_id": data['entry_id'],
|
||||
"transaction_id": data['transaction_id'],
|
||||
})
|
||||
|
||||
retries = 0
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
res = requests.post(url=url, data=payload, headers=headers, timeout=10)
|
||||
res.raise_for_status()
|
||||
res_j = res.json()
|
||||
|
||||
# 检查 token_and_url_list 是否存在且不为空
|
||||
token_list = res_j.get('token_and_url_list', [])
|
||||
if not token_list or len(token_list) == 0:
|
||||
logger.warning(f"未获取到上传凭证列表,将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
token_item = token_list[0]
|
||||
upload_url = token_item.get('url')
|
||||
upload_token = token_item.get('token')
|
||||
|
||||
if not upload_url or not upload_token:
|
||||
logger.warning(f"上传凭证信息不完整,将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
logger.info(f"返回结果: {upload_url}, {upload_token}")
|
||||
if res.status_code == 200:
|
||||
return {
|
||||
'upload_url': upload_url,
|
||||
'upload_token': upload_token
|
||||
}
|
||||
else:
|
||||
logger.warning(f"请求异常, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
except (requests.exceptions.RequestException, KeyError, IndexError, TypeError) as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(f"获取上传凭证失败,已重试{max_retries}次")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def upload_file(data: dict, max_retries: int = 10) -> Optional[Any]:
|
||||
"""
|
||||
上传文件
|
||||
:param max_retries: 最大重试次数
|
||||
:param data: 应包含上传文件路径、上传文件url、上传文件token
|
||||
:return: 返回上传文件结果
|
||||
"""
|
||||
url = data['upload_url']
|
||||
headers = {
|
||||
'Authorization': Config.JIANDAOYUN_API_TOKEN,
|
||||
}
|
||||
file_path = data['file_path']
|
||||
payload = {
|
||||
"token": data['upload_token'],
|
||||
}
|
||||
|
||||
retries = 0
|
||||
result = None
|
||||
|
||||
while retries <= max_retries:
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {"file": f}
|
||||
res = requests.post(url=url, data=payload, headers=headers, files=files, timeout=10)
|
||||
res.raise_for_status()
|
||||
data_get = res.json()
|
||||
logger.info(f"返回结果: {data_get}")
|
||||
if res.status_code == 200:
|
||||
result = data_get
|
||||
break
|
||||
else:
|
||||
logger.warning(f"请求异常, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"请求异常: {e}, 将重新请求")
|
||||
retries += 1
|
||||
time.sleep(3)
|
||||
|
||||
if retries > max_retries:
|
||||
error_logger.error(f"上传文件失败,已重试{max_retries}次")
|
||||
|
||||
return result
|
||||
+162
-52
@@ -9,12 +9,15 @@ F6 后台执行模块
|
||||
- 车辆信息管理
|
||||
- 项目信息批量启停
|
||||
- 材料信息批量修改
|
||||
- 项目信息批量修改
|
||||
|
||||
依赖:
|
||||
- requests: HTTP 请求
|
||||
- pandas: Excel 文件处理
|
||||
- threading: 后台任务处理
|
||||
"""
|
||||
import logging
|
||||
import traceback
|
||||
import requests
|
||||
from urllib.parse import quote
|
||||
import pandas as pd
|
||||
@@ -36,6 +39,7 @@ from app.tasks.delete_tasks import (
|
||||
from app.tasks.material_tasks import (
|
||||
batch_disable_projects,
|
||||
batch_modify_materials,
|
||||
batch_modify_projects
|
||||
)
|
||||
from app.tasks.customer_tasks import modify_customer_info_background
|
||||
from app.tasks.bi_tasks import bi_task_background
|
||||
@@ -43,6 +47,8 @@ from app.tasks.bi_tasks import bi_task_background
|
||||
# 简道云 API 实例,用于调用简道云 API
|
||||
api_instance = API()
|
||||
|
||||
logger = logging.getLogger('app')
|
||||
|
||||
|
||||
class F6PluginModule:
|
||||
"""
|
||||
@@ -170,8 +176,38 @@ class F6PluginModule:
|
||||
else:
|
||||
print("'msg':'文件上传格式错误'")
|
||||
return {'msg': '文件上传格式错误'}
|
||||
elif action == 'delete_cars':
|
||||
pass
|
||||
elif action == 'disable_project':
|
||||
df1 = pd.read_excel(save_path, sheet_name=0)
|
||||
if "项目编码" in df1.columns[0]: # 校验表头名字
|
||||
print('文件校验成功')
|
||||
return {'msg': f'{save_path}', 'check': '是'}
|
||||
else:
|
||||
print("'msg':'文件上传格式错误'")
|
||||
return {'msg': '文件上传格式错误'}
|
||||
elif action == 'batch_modify_materials':
|
||||
df2 = pd.read_excel(save_path, sheet_name=0)
|
||||
required_columns = {'原材料编码', '新材料编码', '品牌', '名称', '规格'}
|
||||
actual_columns = set(df2.columns)
|
||||
if required_columns.issubset(actual_columns):
|
||||
print('文件校验成功')
|
||||
return {'msg': f'{save_path}', 'check': '是'}
|
||||
else:
|
||||
missing = required_columns - actual_columns
|
||||
print(f"文件上传格式错误:缺少列 {missing}")
|
||||
return {'msg': '文件上传格式错误'}
|
||||
|
||||
elif action == 'batch_modify_projects':
|
||||
df3 = pd.read_excel(save_path, sheet_name=0)
|
||||
required_columns = {'原项目编码', '新项目编码', '项目名称', '业务分类', '销项税率', '项目说明',
|
||||
}
|
||||
actual_columns = set(df3.columns)
|
||||
if required_columns.issubset(actual_columns):
|
||||
print('文件校验成功')
|
||||
return {'msg': f'{save_path}', 'check': '是'}
|
||||
else:
|
||||
missing = required_columns - actual_columns
|
||||
print(f"文件上传格式错误:缺少列 {missing}")
|
||||
return {'msg': '文件上传格式错误'}
|
||||
else:
|
||||
pass
|
||||
|
||||
@@ -409,40 +445,52 @@ class F6PluginModule:
|
||||
Returns:
|
||||
Dict[str, str]: 包含执行状态的字典,{'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
|
||||
"""
|
||||
entry_data = api_instance.entry_data_get(data=data, replace=True)
|
||||
print('执行 项目批量停用/启用')
|
||||
username = entry_data['data']['账号']
|
||||
password = entry_data['data']['密码']
|
||||
company_name = entry_data['data']['公司名称']
|
||||
save_path = entry_data['data']['文件保存地址']
|
||||
option = entry_data['data']['项目材料批量操作']
|
||||
|
||||
login_response = F6Module.login_in(username, password, company_name)
|
||||
if login_response is None:
|
||||
return {'msg': '登录失败'}
|
||||
|
||||
try:
|
||||
df = pd.read_excel(save_path, sheet_name=0, dtype='string')
|
||||
entry_data = api_instance.entry_data_get(data=data, replace=True)
|
||||
print('执行 项目批量停用/启用')
|
||||
username = entry_data['data']['账号']
|
||||
password = entry_data['data']['密码']
|
||||
company_name = entry_data['data']['公司名称']
|
||||
save_path = entry_data['data']['文件保存地址']
|
||||
option = entry_data['data']['项目材料批量操作']
|
||||
|
||||
login_response = F6Module.login_in(username, password, company_name)
|
||||
if login_response is None:
|
||||
logger.error(f"F6系统登录失败,用户名: {username}")
|
||||
return {'msg': '登录失败'}
|
||||
|
||||
try:
|
||||
df = pd.read_excel(save_path, sheet_name=0, dtype='string')
|
||||
except Exception as e:
|
||||
logger.error(f"读取Excel文件失败: {save_path}, 错误: {str(e)}, 堆栈: {traceback.format_exc()}")
|
||||
return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'}
|
||||
|
||||
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
|
||||
logger.info("当前登录cookies:{}".format(cookies))
|
||||
|
||||
try:
|
||||
thread = threading.Thread(target=batch_disable_projects,
|
||||
args=(data, cookies, df, save_path, option))
|
||||
thread.start()
|
||||
except Exception as e:
|
||||
logger.error(f"创建线程失败: {str(e)}, 堆栈: {traceback.format_exc()}")
|
||||
print(f'创建线程失败: {str(e)}')
|
||||
return {'msg': f'创建后台线程失败: {str(e)}'}
|
||||
|
||||
return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
|
||||
except KeyError as e:
|
||||
logger.error(f"数据字段缺失: {str(e)}, 堆栈: {traceback.format_exc()}")
|
||||
return {'msg': f'数据字段缺失: {str(e)}'}
|
||||
except Exception as e:
|
||||
return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'}
|
||||
|
||||
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
|
||||
|
||||
try:
|
||||
thread = threading.Thread(target=batch_disable_projects,
|
||||
args=(data, cookies, df, save_path,option))
|
||||
thread.start()
|
||||
except Exception as e:
|
||||
print(f'创建线程失败: {str(e)}')
|
||||
|
||||
return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
|
||||
logger.error(f"项目批量启停任务执行失败: {str(e)}, 堆栈: {traceback.format_exc()}")
|
||||
return {'msg': f'执行失败: {str(e)}'}
|
||||
|
||||
@staticmethod
|
||||
def disable_material(data: Dict[str, Any]) -> Dict[str, str]:
|
||||
def modify_material(data: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""
|
||||
材料批量启停
|
||||
材料批量修改
|
||||
|
||||
从简道云获取材料批量启停请求,读取 Excel 文件,并在后台线程中批量启停材料。
|
||||
从简道云获取材料批量修改请求,读取 Excel 文件,并在后台线程中批量修改材料。
|
||||
立即返回"正在执行"的提示,实际创建在后台线程中执行。
|
||||
|
||||
Args:
|
||||
@@ -451,38 +499,100 @@ class F6PluginModule:
|
||||
Returns:
|
||||
Dict[str, str]: 包含执行状态的字典,{'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
|
||||
"""
|
||||
entry_data = api_instance.entry_data_get(data=data, replace=True)
|
||||
print('执行 材料批量停用/启用')
|
||||
username = entry_data['data']['账号']
|
||||
password = entry_data['data']['密码']
|
||||
company_name = entry_data['data']['公司名称']
|
||||
save_path = entry_data['data']['文件保存地址']
|
||||
option = entry_data['data']['项目材料批量操作']
|
||||
|
||||
login_response = F6Module.login_in(username, password, company_name)
|
||||
if login_response is None:
|
||||
return {'msg': '登录失败'}
|
||||
|
||||
try:
|
||||
df = pd.read_excel(save_path, sheet_name=0, dtype='string')
|
||||
entry_data = api_instance.entry_data_get(data=data, replace=True)
|
||||
print('执行 材料信息批量修改')
|
||||
username = entry_data['data']['账号']
|
||||
password = entry_data['data']['密码']
|
||||
company_name = entry_data['data']['公司名称']
|
||||
save_path = entry_data['data']['文件保存地址']
|
||||
|
||||
login_response = F6Module.login_in(username, password, company_name)
|
||||
if login_response is None:
|
||||
logger.error(f"F6系统登录失败,用户名: {username}")
|
||||
return {'msg': '登录失败'}
|
||||
|
||||
try:
|
||||
df = pd.read_excel(save_path, sheet_name=0, dtype='string')
|
||||
except Exception as e:
|
||||
logger.error(f"读取Excel文件失败: {save_path}, 错误: {str(e)}, 堆栈: {traceback.format_exc()}")
|
||||
return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'}
|
||||
|
||||
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
|
||||
|
||||
try:
|
||||
thread = threading.Thread(target=batch_modify_materials,
|
||||
args=(data, cookies, df, save_path))
|
||||
thread.start()
|
||||
except Exception as e:
|
||||
logger.error(f"创建线程失败: {str(e)}, 堆栈: {traceback.format_exc()}")
|
||||
print(f'创建线程失败: {str(e)}')
|
||||
return {'msg': f'创建后台线程失败: {str(e)}'}
|
||||
|
||||
return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
|
||||
except KeyError as e:
|
||||
logger.error(f"数据字段缺失: {str(e)}, 堆栈: {traceback.format_exc()}")
|
||||
return {'msg': f'数据字段缺失: {str(e)}'}
|
||||
except Exception as e:
|
||||
return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'}
|
||||
logger.error(f"材料批量修改任务执行失败: {str(e)}, 堆栈: {traceback.format_exc()}")
|
||||
return {'msg': f'执行失败: {str(e)}'}
|
||||
|
||||
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
|
||||
@staticmethod
|
||||
def modify_project(data: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""
|
||||
项目批量修改
|
||||
|
||||
从简道云获取项目批量修改请求,读取 Excel 文件,并在后台线程中批量修改项目。
|
||||
立即返回"正在执行"的提示,实际创建在后台线程中执行。
|
||||
|
||||
Args:
|
||||
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: 包含执行状态的字典,{'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
|
||||
"""
|
||||
try:
|
||||
thread = threading.Thread(target=batch_modify_materials,
|
||||
args=(data, cookies, df, save_path, option))
|
||||
thread.start()
|
||||
except Exception as e:
|
||||
print(f'创建线程失败: {str(e)}')
|
||||
entry_data = api_instance.entry_data_get(data=data, replace=True)
|
||||
print('执行 项目信息批量修改')
|
||||
username = entry_data['data']['账号']
|
||||
password = entry_data['data']['密码']
|
||||
company_name = entry_data['data']['公司名称']
|
||||
save_path = entry_data['data']['文件保存地址']
|
||||
|
||||
return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
|
||||
login_response = F6Module.login_in(username, password, company_name)
|
||||
if login_response is None:
|
||||
logger.error(f"F6系统登录失败,用户名: {username}")
|
||||
return {'msg': '登录失败'}
|
||||
|
||||
try:
|
||||
df = pd.read_excel(save_path, sheet_name=0, dtype='string')
|
||||
except Exception as e:
|
||||
logger.error(f"读取Excel文件失败: {save_path}, 错误: {str(e)}, 堆栈: {traceback.format_exc()}")
|
||||
return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'}
|
||||
|
||||
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
|
||||
|
||||
try:
|
||||
thread = threading.Thread(target=batch_modify_projects,
|
||||
args=(data, cookies, df, save_path))
|
||||
thread.start()
|
||||
except Exception as e:
|
||||
logger.error(f"创建线程失败: {str(e)}, 堆栈: {traceback.format_exc()}")
|
||||
print(f'创建线程失败: {str(e)}')
|
||||
return {'msg': f'创建后台线程失败: {str(e)}'}
|
||||
|
||||
return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
|
||||
except KeyError as e:
|
||||
logger.error(f"数据字段缺失: {str(e)}, 堆栈: {traceback.format_exc()}")
|
||||
return {'msg': f'数据字段缺失: {str(e)}'}
|
||||
except Exception as e:
|
||||
logger.error(f"项目批量修改任务执行失败: {str(e)}, 堆栈: {traceback.format_exc()}")
|
||||
return {'msg': f'执行失败: {str(e)}'}
|
||||
|
||||
@staticmethod
|
||||
def bi_task(data: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""
|
||||
BI任务
|
||||
BI任务(示例)
|
||||
|
||||
从简道云获取BI任务请求,读取 Excel 文件(如果需要),并在后台线程中执行BI任务。
|
||||
立即返回"正在执行"的提示,实际执行在后台线程中完成。
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.tasks.customer_tasks import modify_customer_info_background
|
||||
from app.tasks.bi_tasks import bi_task_background
|
||||
|
||||
from app.tasks.material_tasks import ( \
|
||||
batch_modify_projects,
|
||||
batch_modify_materials,
|
||||
batch_disable_projects
|
||||
)
|
||||
@@ -51,4 +52,5 @@ __all__ = [
|
||||
# 项目材料任务
|
||||
'batch_disable_projects',
|
||||
'batch_modify_materials',
|
||||
'batch_modify_projects',
|
||||
]
|
||||
|
||||
+44
-35
@@ -73,37 +73,37 @@ def approve_workflow(data: Dict[str, Any]):
|
||||
"""
|
||||
# 获取简道云当前流程列表
|
||||
json = api_instance.workflow_instance_get(data)
|
||||
|
||||
|
||||
# 检查返回数据是否有效
|
||||
if not json:
|
||||
logger.error("未获取到工作流实例信息")
|
||||
return
|
||||
|
||||
|
||||
# 安全地获取任务列表
|
||||
tasks = json.get('tasks', [])
|
||||
if not tasks:
|
||||
logger.error("未找到待处理任务")
|
||||
return
|
||||
|
||||
|
||||
# 将JSON字符串转换为Python字典
|
||||
username = ''
|
||||
instance_id = ''
|
||||
task_id = ''
|
||||
|
||||
|
||||
for task in tasks:
|
||||
if task.get('status') == 0:
|
||||
assignee = task.get('assignee', {})
|
||||
username = assignee.get('username', '')
|
||||
instance_id = task.get('instance_id', '')
|
||||
task_id = task.get('task_id', '')
|
||||
|
||||
|
||||
if username and instance_id and task_id:
|
||||
break
|
||||
|
||||
|
||||
if not username or not instance_id or not task_id:
|
||||
logger.error("未找到有效的待处理任务信息")
|
||||
return
|
||||
|
||||
|
||||
task_data = {
|
||||
"username": username,
|
||||
"instance_id": instance_id,
|
||||
@@ -125,10 +125,10 @@ def execute_failure_handler(data: Dict[str, Any]):
|
||||
"""
|
||||
now = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
|
||||
pay_load = {
|
||||
"api_key":"6694d3c4fcb69ca9a111a6c4",
|
||||
"entry_id":"6938e011b360a1132522a62a",
|
||||
"api_key": "6694d3c4fcb69ca9a111a6c4",
|
||||
"entry_id": "6938e011b360a1132522a62a",
|
||||
"data": {
|
||||
"_widget_1765335060501": {"value": now}, # 失败时间
|
||||
"_widget_1765335060501": {"value": now}, # 失败时间
|
||||
"_widget_1765335060502": {"value": data['failure_name']}, # 任务名称
|
||||
"_widget_1765335060503": {"value": data['failure_details']} # 失败明细
|
||||
}
|
||||
@@ -137,37 +137,47 @@ def execute_failure_handler(data: Dict[str, Any]):
|
||||
api_instance.data_batch_create(pay_load)
|
||||
|
||||
|
||||
def get_operate_org_id(cookies: Dict[str, str]) -> Optional[str]:
|
||||
def get_operate_org_id(cookies: Dict[str, str], data: Dict[str, str] = None) -> Optional[str]:
|
||||
"""
|
||||
获取操作门店ID
|
||||
|
||||
|
||||
从F6系统获取第一个门店的组织ID,用于后续操作。
|
||||
|
||||
|
||||
Args:
|
||||
cookies: 用户登录 F6 系统的 cookies 信息
|
||||
|
||||
data:数据id等
|
||||
|
||||
Returns:
|
||||
Optional[str]: 门店ID,如果获取失败返回 None
|
||||
|
||||
|
||||
注意:
|
||||
如果未获取到门店信息或门店ID为空,会记录错误日志并返回 None
|
||||
"""
|
||||
org_url = "https://yunxiu.f6car.cn/hive/org/getPageOrgGroupMembers?currentPage=1&pageSize=100&name="
|
||||
|
||||
|
||||
try:
|
||||
org_res = requests.get(url=org_url, cookies=cookies)
|
||||
logger.info(org_res.json())
|
||||
org_data = org_res.json().get("data", {})
|
||||
org_list = org_data.get("list", [])
|
||||
|
||||
|
||||
if not org_list or len(org_list) == 0:
|
||||
logger.error("未获取到门店信息")
|
||||
return None
|
||||
|
||||
operate_org_id = org_list[0].get("orgId")
|
||||
if data:
|
||||
entry_data = api_instance.entry_data_get(data=data, replace=True)
|
||||
org_name = entry_data.get("data", {}).get("门店名称")
|
||||
operate_org_id = [
|
||||
item["orgId"]
|
||||
for item in org_list
|
||||
if item.get("abbrName") == org_name
|
||||
]
|
||||
else:
|
||||
operate_org_id = org_list[0].get("orgId")
|
||||
if not operate_org_id:
|
||||
logger.error("门店ID为空")
|
||||
return None
|
||||
|
||||
|
||||
logger.info(f"获取门店ID成功: {operate_org_id}")
|
||||
return operate_org_id
|
||||
except Exception as e:
|
||||
@@ -176,9 +186,9 @@ def get_operate_org_id(cookies: Dict[str, str]) -> Optional[str]:
|
||||
|
||||
|
||||
def get_card_list(
|
||||
cookies: Dict[str, str],
|
||||
operate_org_id: str,
|
||||
extract_func: Callable[[Dict], Optional[str]] = None
|
||||
cookies: Dict[str, str],
|
||||
operate_org_id: str,
|
||||
extract_func: Callable[[Dict], Optional[str]] = None
|
||||
) -> List[str]:
|
||||
"""
|
||||
获取会员卡列表
|
||||
@@ -199,46 +209,45 @@ def get_card_list(
|
||||
- 每页请求间隔0.2秒,避免请求过快
|
||||
"""
|
||||
card_list = []
|
||||
|
||||
|
||||
try:
|
||||
# 获取第一页,确定总页数
|
||||
card_url = f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}&pageSize=100&pageNo=1"
|
||||
card_res = requests.get(url=card_url, cookies=cookies)
|
||||
total_card = int(card_res.json().get("data", {}).get("total", 0))
|
||||
|
||||
|
||||
if total_card == 0:
|
||||
logger.info("未找到会员卡数据")
|
||||
return card_list
|
||||
|
||||
|
||||
total_page = total_card // 100 + (total_card % 100 > 0)
|
||||
logger.info(f"会员卡总数: {total_card}, 总页数: {total_page}")
|
||||
|
||||
|
||||
# 定义默认提取函数(提取客户ID)
|
||||
if extract_func is None:
|
||||
def default_extract(card_item: Dict) -> Optional[str]:
|
||||
return card_item.get("idCustomer")
|
||||
|
||||
extract_func = default_extract
|
||||
|
||||
|
||||
# 分页获取所有会员卡数据
|
||||
for page in tqdm(range(1, total_page + 1), desc="查询会员卡"):
|
||||
card_url = (f"https://yunxiu.f6car.cn/marketing/card/paging?useStationIdOwnOrgList={operate_org_id}"
|
||||
f"&pageSize=100&pageNo={page}")
|
||||
f"&pageSize=100&pageNo={page}")
|
||||
card_res = requests.get(url=card_url, cookies=cookies)
|
||||
card_data_list = card_res.json().get("data", {}).get("data", [])
|
||||
|
||||
|
||||
# 使用提取函数提取ID
|
||||
for card_item in card_data_list:
|
||||
extracted_id = extract_func(card_item)
|
||||
if extracted_id is not None:
|
||||
card_list.append(extracted_id)
|
||||
|
||||
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
logger.info(f"获取会员卡列表成功,共 {len(card_list)} 条")
|
||||
return card_list
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取会员卡列表时发生错误: {e}")
|
||||
return card_list
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,892 @@
|
||||
"""
|
||||
项目材料相关后台任务模块
|
||||
|
||||
本模块包含项目材料相关的后台任务,包括:
|
||||
- 项目信息批量停用
|
||||
- 材料信息批量修改
|
||||
|
||||
这些任务在后台线程中执行,不会阻塞主请求。
|
||||
"""
|
||||
import logging
|
||||
import traceback
|
||||
import requests
|
||||
import time
|
||||
from typing import Dict, Any
|
||||
from tqdm import tqdm
|
||||
from app.tasks.common import update_jiandaoyun, approve_workflow, get_operate_org_id
|
||||
import pandas as pd
|
||||
import os
|
||||
|
||||
logger = logging.getLogger('app')
|
||||
|
||||
|
||||
def batch_disable_projects(data: Dict[str, Any], cookies: Dict[str, str], df: pd.DataFrame, save_path: str,
|
||||
option) -> None:
|
||||
"""
|
||||
项目批量启停后台任务
|
||||
|
||||
在后台线程中批量停用项目,从 Excel 文件中读取项目编码。
|
||||
执行完成后会更新简道云表单并自动提交工作流。
|
||||
|
||||
Args:
|
||||
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
|
||||
cookies: 用户登录 F6 系统的 cookies 信息
|
||||
df: Excel 文件读取的内容,DataFrame 格式,第一列为项目编码
|
||||
save_path: Excel 文件保存的地址,执行完成后会删除此文件
|
||||
option: 批量启用、批量停用
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
注意:
|
||||
- 无效的项目(None、空字符串)会被跳过
|
||||
- 执行完成后会自动删除上传的文件
|
||||
- 执行结果会更新到简道云表单
|
||||
"""
|
||||
logger.info(f"开始执行项目批量启停任务,操作类型: {option}, 文件路径: {save_path}, 数据行数: {len(df)}")
|
||||
if option == "批量启用":
|
||||
type_ = 0 # 1 停用,0启用
|
||||
ob_type = 1
|
||||
else:
|
||||
type_ = 1
|
||||
ob_type = 0
|
||||
logger.info(f"操作类型设置完成: type_={type_}, ob_type={ob_type}")
|
||||
df = df.where(pd.notnull(df), None)
|
||||
|
||||
# 获取门店id
|
||||
logger.info("正在获取门店ID...")
|
||||
try:
|
||||
org_id = get_operate_org_id(cookies)
|
||||
logger.info(f"门店ID获取成功: {org_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取门店ID失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
# 获取项目信息
|
||||
logger.info("开始获取项目列表...")
|
||||
json_data = {
|
||||
'param': '',
|
||||
'name': '',
|
||||
'customCode': '',
|
||||
'currentPage': 1,
|
||||
'pageSize': 100,
|
||||
'isDel': ob_type,
|
||||
'customInvoiceCategory': 0,
|
||||
'idOwnOrg': org_id,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
'https://ids-goods.f6car.cn/f6-ids-goods/service/getServiceList',
|
||||
cookies=cookies,
|
||||
json=json_data,
|
||||
)
|
||||
time.sleep(1)
|
||||
response.raise_for_status()
|
||||
all_project_list = []
|
||||
total_pages = response.json().get("data", {}).get("totalPages", 0)
|
||||
logger.info(f"获取项目列表响应: {response.json()}")
|
||||
try:
|
||||
total_pages = int(total_pages)
|
||||
except (ValueError, TypeError):
|
||||
logger.error(f"无法解析总页数: {total_pages}, 类型: {type(total_pages)}")
|
||||
total_pages = 0
|
||||
logger.info(f"项目列表总页数: {total_pages}")
|
||||
|
||||
for page in tqdm(range(1, total_pages + 1)):
|
||||
json_data['currentPage'] = page
|
||||
try:
|
||||
response = requests.post(
|
||||
'https://ids-goods.f6car.cn/f6-ids-goods/service/getServiceList',
|
||||
cookies=cookies,
|
||||
json=json_data,
|
||||
)
|
||||
time.sleep(1)
|
||||
response.raise_for_status()
|
||||
project_list = response.json().get("data", {}).get("records", [])
|
||||
all_project_list.extend(project_list)
|
||||
logger.debug(f"第{page}页获取到{len(project_list)}条项目")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"获取第{page}页项目列表失败: {str(e)}, 响应状态码: {getattr(e.response, 'status_code', 'N/A')}")
|
||||
raise
|
||||
logger.info(f"项目列表获取完成,总计: {len(all_project_list)}条项目")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"获取项目列表失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
|
||||
raise
|
||||
# 遍历获取到的项目信息停用文件中的项目
|
||||
code_list = df.iloc[:, 0].dropna().astype(str).tolist()
|
||||
logger.info(f"Excel文件中待处理的项目编码数量: {len(code_list)}")
|
||||
results = []
|
||||
consecutive_failures = 0 # 连续失败计数器
|
||||
MAX_CONSECUTIVE_FAILURES = 100 # 最大连续失败次数
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
|
||||
logger.info("开始处理项目启停操作...")
|
||||
for item in tqdm(all_project_list):
|
||||
custom_code = item.get("customCode")
|
||||
if not code_list or not custom_code or str(custom_code) not in code_list:
|
||||
continue
|
||||
|
||||
logger.debug(f"正在处理项目编码: {custom_code}")
|
||||
info_id = item.get("infoId")
|
||||
pk_id = item.get("pkId")
|
||||
if not info_id or not pk_id:
|
||||
logger.warning(f"项目编码 {custom_code} 缺少必要字段: infoId={info_id}, pkId={pk_id}")
|
||||
results.append({'项目编码': custom_code, '状态': '缺少必要字段(infoId或pkId)'})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
|
||||
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
|
||||
break
|
||||
continue
|
||||
|
||||
json_data = {
|
||||
"orgIdList": [
|
||||
org_id,
|
||||
],
|
||||
"isDel": type_, # 1 停用 0启用
|
||||
"infoId": info_id,
|
||||
"pkId": pk_id,
|
||||
"type": 1,
|
||||
"idOwnOrg": org_id
|
||||
}
|
||||
try:
|
||||
logger.debug(f"发送启停请求,项目编码: {custom_code}, 操作类型: {type_}")
|
||||
response = requests.post(
|
||||
'https://ids-goods.f6car.cn/f6-ids-goods/service/editAttributeByType',
|
||||
cookies=cookies,
|
||||
json=json_data,
|
||||
)
|
||||
time.sleep(2)
|
||||
response.raise_for_status() # 抛出HTTP错误
|
||||
# 检查业务响应码
|
||||
resp_data = response.json()
|
||||
if resp_data.get("code") == 200:
|
||||
results.append({'项目编码': custom_code, '状态': '停用/启用成功'})
|
||||
success_count += 1
|
||||
consecutive_failures = 0 # 成功时重置计数器
|
||||
logger.info(f"项目编码 {custom_code} 启停操作成功")
|
||||
else:
|
||||
msg = resp_data.get("message", "未知错误")
|
||||
results.append({'项目编码': custom_code, '状态': f'停用/启用失败: {msg}'})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
logger.error(f"项目编码 {custom_code} 启停操作失败: {msg}, 响应数据: {resp_data}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = str(e)
|
||||
results.append({'项目编码': custom_code, '状态': f'停用/启用失败: {error_msg}'})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
logger.error(f"项目编码 {custom_code} 启停操作请求异常: {error_msg}, 堆栈信息: {traceback.format_exc()}")
|
||||
|
||||
# 检查连续失败次数
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
|
||||
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
|
||||
break
|
||||
|
||||
logger.info(f"项目启停处理完成,成功: {success_count}条, 失败: {failure_count}条, 总计: {len(results)}条")
|
||||
|
||||
print({'msg': '已执行', 'msg_details': f'{results}'})
|
||||
logger.info(f"停用/启用结果: {results}")
|
||||
|
||||
# 删除文件
|
||||
logger.info(f"准备删除文件: {save_path}")
|
||||
try:
|
||||
os.remove(save_path)
|
||||
logger.info(f"文件删除成功: {save_path}")
|
||||
print(f'{save_path}已删除')
|
||||
except Exception as e:
|
||||
logger.error(f"删除文件失败: {save_path}, 错误信息: {str(e)}, 堆栈信息: {traceback.format_exc()}")
|
||||
|
||||
# 调用api回写改掉 执行明细与执行状态
|
||||
logger.info("开始回写简道云表单...")
|
||||
try:
|
||||
msg = update_jiandaoyun(data, f'{results}')
|
||||
logger.info(f"简道云表单回写结果: {msg}")
|
||||
if msg.get('msg'):
|
||||
logger.info("开始自动提交工作流...")
|
||||
approve_workflow(data)
|
||||
logger.info("工作流提交成功")
|
||||
print('表单已自动提交至下一步')
|
||||
else:
|
||||
logger.warning(f"简道云表单回写失败: {msg}")
|
||||
except Exception as e:
|
||||
logger.error(f"回写简道云表单失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
|
||||
|
||||
logger.info(f"项目批量启停任务执行完成,操作类型: {option}")
|
||||
|
||||
|
||||
def batch_modify_materials(data: Dict[str, Any], cookies: Dict[str, str], df: pd.DataFrame, save_path: str) -> None:
|
||||
"""
|
||||
材料批量修改后台任务
|
||||
|
||||
在后台线程中批量修改材料,从 Excel 文件中读取材料编码。
|
||||
执行完成后会更新简道云表单并自动提交工作流。
|
||||
|
||||
Args:
|
||||
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
|
||||
cookies: 用户登录 F6 系统的 cookies 信息
|
||||
df: Excel 文件读取的内容,DataFrame 格式,列顺序为:
|
||||
[原材料编码, 新材料编码, 品牌, 名称, 规格]
|
||||
save_path: Excel 文件保存的地址,执行完成后会删除此文件
|
||||
|
||||
注意:
|
||||
- 无效的材料编码(None、空字符串)会被跳过
|
||||
- Excel 中某字段为空(NaN 或空字符串)时,保留原材料中的对应字段
|
||||
- 执行完成后会自动删除上传的文件
|
||||
- 执行结果会更新到简道云表单
|
||||
"""
|
||||
logger.info(f"开始执行材料批量修改任务,文件: {save_path},行数: {len(df)}")
|
||||
|
||||
def safe_str(val):
|
||||
"""将值转为字符串,NaN 返回空字符串"""
|
||||
if pd.isna(val):
|
||||
return ""
|
||||
return str(val).strip()
|
||||
|
||||
def should_update(new_val):
|
||||
"""判断是否应该用 new_val 更新:非 NaN 且非空字符串"""
|
||||
return not (pd.isna(new_val) or (isinstance(new_val, str) and new_val.strip() == ""))
|
||||
|
||||
# 获取门店id
|
||||
logger.info("正在获取门店ID...")
|
||||
try:
|
||||
org_id = get_operate_org_id(cookies)
|
||||
logger.info(f"门店ID获取成功: {org_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取门店ID失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
# 第一步:获取所有材料列表(分页)
|
||||
json_data = {
|
||||
'keyWord': '',
|
||||
'idOwnOrg': org_id,
|
||||
'currentPage': 1,
|
||||
'pageSize': 100,
|
||||
'name': '',
|
||||
'brand': '',
|
||||
'supplierCode': '',
|
||||
'customCode': '',
|
||||
'categoryName': '',
|
||||
'categoryId': '',
|
||||
'labelId': '',
|
||||
'labelName': '',
|
||||
'spec': '',
|
||||
'applyModel': '',
|
||||
'sellPurchaseStatuses': [2, 3, 4, 5],
|
||||
'customInvoiceCategory': 0,
|
||||
'getThirdPlatformCode': 0,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
'https://ids-goods.f6car.com/f6-ids-goods/part/getExactPartStockInfo',
|
||||
cookies=cookies,
|
||||
json=json_data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
total_pages = response.json().get("data", {}).get("totalPages", 0)
|
||||
logger.info(f"材料列表页数: {total_pages}")
|
||||
|
||||
all_materials_list = []
|
||||
total_pages = int(total_pages)
|
||||
for page in tqdm(range(1, total_pages + 1), desc="获取材料列表"):
|
||||
json_data['currentPage'] = page
|
||||
try:
|
||||
resp = requests.post(
|
||||
'https://ids-goods.f6car.com/f6-ids-goods/part/getExactPartStockInfo',
|
||||
cookies=cookies,
|
||||
json=json_data,
|
||||
)
|
||||
time.sleep(1)
|
||||
resp.raise_for_status()
|
||||
records = resp.json().get("data", {}).get("records", [])
|
||||
all_materials_list.extend(records)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"获取第{page}页材料列表失败: {str(e)}, 响应状态码: {getattr(e.response, 'status_code', 'N/A')}")
|
||||
raise
|
||||
logger.info(f"材料列表获取完成,总计: {len(all_materials_list)}条")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"获取材料列表失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
# 第二步:构建 update_map(只存原始值,不预处理)
|
||||
update_map = {}
|
||||
skipped_count = 0
|
||||
for _, row in df.iterrows():
|
||||
orig_code = row.iloc[0] # 原材料编码
|
||||
if pd.isna(orig_code) or str(orig_code).strip() == "":
|
||||
skipped_count += 1
|
||||
continue
|
||||
orig_code = str(orig_code).strip()
|
||||
update_map[orig_code] = {
|
||||
"new_customCode": row.iloc[1],
|
||||
"new_brand": row.iloc[2],
|
||||
"new_name": row.iloc[3],
|
||||
"new_spec": row.iloc[4],
|
||||
}
|
||||
logger.info(f"待更新材料数: {len(update_map)},跳过空编码行: {skipped_count}")
|
||||
|
||||
# 第三步:遍历材料,按需更新
|
||||
results = []
|
||||
consecutive_failures = 0 # 连续失败计数器
|
||||
MAX_CONSECUTIVE_FAILURES = 100 # 最大连续失败次数
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
skip_count = 0
|
||||
|
||||
for item in tqdm(all_materials_list, desc="处理材料更新"):
|
||||
custom_code = item.get("customCode")
|
||||
if not custom_code or str(custom_code) not in update_map:
|
||||
continue
|
||||
|
||||
part_id = item.get("partId")
|
||||
if not part_id:
|
||||
error_msg = '缺少 partId'
|
||||
results.append({'材料编码': custom_code, '状态': error_msg})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
logger.warning(f"材料编码 {custom_code} 跳过/失败: {error_msg}")
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
results.append({'材料编码': '系统保护', '状态': '连续100次失败已终止'})
|
||||
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
|
||||
break
|
||||
continue
|
||||
|
||||
try:
|
||||
# 获取材料明细
|
||||
params = {
|
||||
'partId': part_id,
|
||||
'customInvoiceCategory': '0',
|
||||
'getThirdPlatformCode': '0',
|
||||
}
|
||||
materials_response = requests.get(
|
||||
'https://ids-goods.f6car.com/f6-ids-goods/part/getPartInfo',
|
||||
params=params,
|
||||
cookies=cookies,
|
||||
)
|
||||
time.sleep(1)
|
||||
if materials_response.status_code != 200:
|
||||
error_msg = f'获取明细失败: {materials_response.status_code}'
|
||||
results.append({'材料编码': custom_code, '状态': error_msg})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
logger.error(f"材料编码 {custom_code} {error_msg}, 响应内容: {materials_response.text[:200]}")
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
results.append({'材料编码': '系统保护', '状态': '连续100次失败已终止'})
|
||||
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
|
||||
break
|
||||
continue
|
||||
|
||||
detail = materials_response.json().get("data")
|
||||
if not detail:
|
||||
error_msg = '明细为空'
|
||||
results.append({'材料编码': custom_code, '状态': error_msg})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
logger.error(f"材料编码 {custom_code} {error_msg}, 响应JSON: {materials_response.json()}")
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
results.append({'材料编码': '系统保护', '状态': '连续100次失败已终止'})
|
||||
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
|
||||
break
|
||||
continue
|
||||
|
||||
updates = update_map[str(custom_code)]
|
||||
|
||||
# 安全更新字段:仅当 Excel 提供有效值时才覆盖
|
||||
if should_update(updates["new_customCode"]):
|
||||
detail["customCode"] = safe_str(updates["new_customCode"])
|
||||
if should_update(updates["new_brand"]):
|
||||
detail["brand"] = safe_str(updates["new_brand"])
|
||||
if should_update(updates["new_name"]):
|
||||
name_val = safe_str(updates["new_name"])
|
||||
detail["name"] = name_val
|
||||
detail["showName"] = name_val # 同步 showName
|
||||
if should_update(updates["new_spec"]):
|
||||
detail["spec"] = safe_str(updates["new_spec"])
|
||||
|
||||
# 修复价格格式和数组(必须!)
|
||||
for f in ["purchasePrice", "sellPrice"]:
|
||||
val = detail.get(f)
|
||||
if isinstance(val, (int, float)):
|
||||
detail[f] = f"{val:.2f}"
|
||||
if detail.get("partBarCodeVos") is None:
|
||||
detail["partBarCodeVos"] = []
|
||||
|
||||
# 提交更新
|
||||
update_resp = requests.post(
|
||||
'https://ids-goods.f6car.com/f6-ids-goods/part/updateAreaPartBasicInfo',
|
||||
cookies=cookies,
|
||||
json=detail
|
||||
)
|
||||
time.sleep(2)
|
||||
|
||||
if update_resp.status_code == 200 and update_resp.json().get("code") == 200:
|
||||
results.append({'材料编码': custom_code, '状态': '修改成功'})
|
||||
success_count += 1
|
||||
consecutive_failures = 0 # 成功时重置计数器
|
||||
else:
|
||||
msg = update_resp.json().get("message", "未知错误")
|
||||
error_msg = f'修改失败: {msg}'
|
||||
results.append({'材料编码': custom_code, '状态': error_msg})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
logger.error(f"材料编码 {custom_code} {error_msg}, 响应数据: {update_resp.json()}")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = f'请求异常: {str(e)}'
|
||||
results.append({'材料编码': custom_code, '状态': error_msg})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
logger.error(f"材料编码 {custom_code} {error_msg}, 堆栈信息: {traceback.format_exc()}")
|
||||
except Exception as e:
|
||||
error_msg = f'内部错误: {str(e)}'
|
||||
results.append({'材料编码': custom_code, '状态': error_msg})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
logger.error(f"材料编码 {custom_code} {error_msg}, 堆栈信息: {traceback.format_exc()}")
|
||||
|
||||
# 检查连续失败次数
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
results.append({'材料编码': '系统保护', '状态': '连续100次失败已终止'})
|
||||
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
|
||||
break
|
||||
|
||||
# 统计汇总(总行数 / 待处理 / 成功 / 失败 / 跳过)
|
||||
total_rows = len(df)
|
||||
to_process = len(update_map)
|
||||
# results 里可能包含 “缺少 partId” 等失败信息;这里跳过数按空编码行计数即可
|
||||
skip_count = skipped_count
|
||||
summary = {
|
||||
"总行数": total_rows,
|
||||
"待处理": to_process,
|
||||
"成功": success_count,
|
||||
"失败": failure_count,
|
||||
"跳过": skip_count,
|
||||
}
|
||||
results.insert(0, {"汇总": summary})
|
||||
logger.info(f"材料修改汇总: {summary}")
|
||||
|
||||
# 第四步:清理与回写
|
||||
print({'msg': '已执行', 'msg_details': results})
|
||||
# 结果回写包含汇总 + 明细
|
||||
logger.info("材料批量修改完成,开始回写结果")
|
||||
|
||||
# 删除文件
|
||||
logger.info(f"准备删除文件: {save_path}")
|
||||
try:
|
||||
os.remove(save_path)
|
||||
logger.info(f"文件删除成功: {save_path}")
|
||||
print(f'{save_path} 已删除')
|
||||
except Exception as e:
|
||||
logger.error(f"删除文件失败: {save_path}, 错误信息: {str(e)}, 堆栈信息: {traceback.format_exc()}")
|
||||
|
||||
# 回写简道云
|
||||
logger.info("开始回写简道云表单...")
|
||||
try:
|
||||
msg = update_jiandaoyun(data, str(results))
|
||||
logger.info(f"简道云表单回写结果: {msg}")
|
||||
if msg.get('msg'):
|
||||
logger.info("开始自动提交工作流...")
|
||||
approve_workflow(data)
|
||||
logger.info("工作流提交成功")
|
||||
print('表单已自动提交至下一步')
|
||||
else:
|
||||
logger.warning(f"简道云表单回写失败: {msg}")
|
||||
except Exception as e:
|
||||
logger.error(f"回写简道云表单失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
|
||||
|
||||
logger.info("材料批量修改任务执行完成")
|
||||
|
||||
|
||||
def batch_modify_projects(data: Dict[str, Any], cookies: Dict[str, str], df: pd.DataFrame, save_path: str) -> None:
|
||||
"""
|
||||
项目批量修改后台任务
|
||||
|
||||
在后台线程中批量修改项目,从 Excel 文件中读取项目编码。
|
||||
执行完成后会更新简道云表单并自动提交工作流。
|
||||
|
||||
Args:
|
||||
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
|
||||
cookies: 用户登录 F6 系统的 cookies 信息
|
||||
df: Excel 文件读取的内容,DataFrame 格式,列顺序为:
|
||||
[0:原项目编码, 1:新项目编码, 2:新项目名称, 3:业务分类, 4:销项税率, 5:项目说明]
|
||||
save_path: Excel 文件保存的地址,执行完成后会删除此文件
|
||||
|
||||
注意:
|
||||
- 无效的项目编码(None、空字符串)会被跳过
|
||||
- Excel 中某字段为空(NaN 或空字符串)时,保留原始项目中的对应字段
|
||||
- 如果新项目名称与系统已有项目名称重复,则跳过处理
|
||||
- 如果Excel中多个行的新项目名称重复,只执行第一条数据,后续重复的会被跳过
|
||||
- 执行完成后会自动删除上传的文件
|
||||
- 执行结果会更新到简道云表单
|
||||
"""
|
||||
logger.info(f"开始执行项目批量修改任务,文件: {save_path},行数: {len(df)}")
|
||||
|
||||
def safe_str(val):
|
||||
"""将值转为字符串,NaN 返回空字符串"""
|
||||
if pd.isna(val):
|
||||
return ""
|
||||
return str(val).strip()
|
||||
|
||||
def safe_float(val):
|
||||
"""安全转换为 float,失败返回 None"""
|
||||
if pd.isna(val):
|
||||
return None
|
||||
try:
|
||||
return float(val)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def should_update(new_val):
|
||||
"""判断是否应该用 new_val 更新:非 NaN 且非空字符串"""
|
||||
return not (pd.isna(new_val) or (isinstance(new_val, str) and new_val.strip() == ""))
|
||||
|
||||
def safe_iloc(row, idx, default=None):
|
||||
"""安全按索引取行值,列数不足时返回 default,避免 IndexError"""
|
||||
try:
|
||||
if idx < len(row):
|
||||
return row.iloc[idx]
|
||||
except (IndexError, KeyError):
|
||||
pass
|
||||
return default
|
||||
|
||||
# 获取门店id
|
||||
logger.info("获取门店ID...")
|
||||
try:
|
||||
org_id = get_operate_org_id(cookies)
|
||||
logger.info(f"门店ID获取成功: {org_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"获取门店ID失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
# 获取服务分类(用于映射业务分类名称 → pkId)
|
||||
logger.info("获取服务分类列表...")
|
||||
try:
|
||||
init_add_resp = requests.post(
|
||||
'https://ids-goods.f6car.cn/f6-ids-goods/service/initAdd',
|
||||
cookies=cookies,
|
||||
data={'idOwnOrg': org_id}
|
||||
)
|
||||
time.sleep(1)
|
||||
init_add_resp.raise_for_status()
|
||||
service_category_list = init_add_resp.json().get("data", {}).get("serviceCategory", [])
|
||||
category_name_to_pk = {item["name"]: item["pkId"] for item in service_category_list}
|
||||
logger.info(f"服务分类列表获取成功,共{len(category_name_to_pk)}个分类")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"获取服务分类列表失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
# 第一步:获取所有项目列表(分页)
|
||||
logger.info("获取项目列表...")
|
||||
json_data = {
|
||||
'param': '',
|
||||
'name': '',
|
||||
'customCode': '',
|
||||
'currentPage': 1,
|
||||
'pageSize': 100,
|
||||
'isDel': 0, # 只查启用的
|
||||
'customInvoiceCategory': 0,
|
||||
'idOwnOrg': org_id,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
'https://ids-goods.f6car.cn/f6-ids-goods/service/getServiceList',
|
||||
cookies=cookies,
|
||||
json=json_data,
|
||||
)
|
||||
response.raise_for_status()
|
||||
total_pages = response.json().get("data", {}).get("totalPages", 0)
|
||||
logger.info(f"项目列表总页数: {total_pages}")
|
||||
|
||||
all_project_list = []
|
||||
total_pages = int(total_pages)
|
||||
for page in tqdm(range(1, total_pages + 1), desc="获取项目列表"):
|
||||
json_data['currentPage'] = page
|
||||
try:
|
||||
resp = requests.post(
|
||||
'https://ids-goods.f6car.cn/f6-ids-goods/service/getServiceList',
|
||||
cookies=cookies,
|
||||
json=json_data,
|
||||
)
|
||||
time.sleep(1)
|
||||
resp.raise_for_status()
|
||||
records = resp.json().get("data", {}).get("records", [])
|
||||
all_project_list.extend(records)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"获取第{page}页项目列表失败: {str(e)}, 响应状态码: {getattr(e.response, 'status_code', 'N/A')}")
|
||||
raise
|
||||
logger.info(f"项目列表获取完成,总计: {len(all_project_list)}条项目")
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"获取项目列表失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
# 构建系统已有项目名称集合(用于检查重复)
|
||||
existing_project_names = set()
|
||||
for project in all_project_list:
|
||||
project_name = project.get("name")
|
||||
if project_name:
|
||||
existing_project_names.add(str(project_name).strip())
|
||||
logger.info(f"系统已有项目名称数: {len(existing_project_names)}")
|
||||
|
||||
# 第二步:构建 update_map
|
||||
logger.info("构建更新映射表...")
|
||||
update_map = {}
|
||||
skipped_count = 0
|
||||
category_not_found_count = 0
|
||||
duplicate_name_in_df_count = 0 # DataFrame中重复的新项目名称数量
|
||||
duplicate_name_in_system_count = 0 # 与系统已有项目名称重复的数量
|
||||
seen_new_names = {} # 用于跟踪DataFrame中已出现的新项目名称,key为新名称,value为第一次出现的原项目编码
|
||||
results = [] # 提前创建results,用于记录跳过的信息
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
orig_code = safe_iloc(row, 0)
|
||||
if pd.isna(orig_code) or str(orig_code).strip() == "":
|
||||
skipped_count += 1
|
||||
results.append({'项目编码': '', '状态': '跳过: 项目编码为空'})
|
||||
continue
|
||||
orig_code = str(orig_code).strip()
|
||||
|
||||
# 检查新项目名称(第2列,索引为2)
|
||||
new_name = safe_iloc(row, 2)
|
||||
if should_update(new_name):
|
||||
new_name_clean = safe_str(new_name)
|
||||
|
||||
# 检查DataFrame中是否有重复的新项目名称
|
||||
if new_name_clean in seen_new_names:
|
||||
duplicate_name_in_df_count += 1
|
||||
logger.warning(f"DataFrame中项目名称重复,跳过: 原项目编码={orig_code}, 新项目名称={new_name_clean}, 首次出现在原项目编码={seen_new_names[new_name_clean]}")
|
||||
results.append({'项目编码': orig_code, '状态': f'跳过: DataFrame中项目名称重复(首次出现在原项目编码={seen_new_names[new_name_clean]})'})
|
||||
continue
|
||||
|
||||
# 检查新项目名称是否与系统已有项目名称重复
|
||||
if new_name_clean in existing_project_names:
|
||||
duplicate_name_in_system_count += 1
|
||||
logger.warning(f"新项目名称与系统已有项目名称重复,跳过: 原项目编码={orig_code}, 新项目名称={new_name_clean}")
|
||||
results.append({'项目编码': orig_code, '状态': f'跳过: 新项目名称与系统已有项目名称重复({new_name_clean})'})
|
||||
continue
|
||||
|
||||
# 记录这个新名称
|
||||
seen_new_names[new_name_clean] = orig_code
|
||||
|
||||
# 业务分类映射
|
||||
category_name = safe_iloc(row, 3)
|
||||
category_pk = None
|
||||
category_not_found = False
|
||||
if should_update(category_name):
|
||||
cat_name_clean = safe_str(category_name)
|
||||
category_pk = category_name_to_pk.get(cat_name_clean)
|
||||
if category_pk is None:
|
||||
logger.warning(f"业务分类 '{cat_name_clean}' 未在系统中找到,项目编码: {orig_code}")
|
||||
category_not_found = True
|
||||
category_not_found_count += 1
|
||||
|
||||
# 列 6/7/8(车辆分类、工时单价、工时)不再传入,固定为 None,不更新这些字段
|
||||
car_category_name = None
|
||||
price = None
|
||||
work_hour = None
|
||||
amount = None
|
||||
|
||||
update_map[orig_code] = {
|
||||
"new_customCode": safe_iloc(row, 1), # 新项目编码
|
||||
"new_name": safe_iloc(row, 2), # 新项目名称
|
||||
"new_serviceCategoryId": category_pk,
|
||||
"new_taxRate": safe_iloc(row, 4),
|
||||
"new_memo": safe_iloc(row, 5),
|
||||
"new_carCategoryName": car_category_name,
|
||||
"new_price": price,
|
||||
"new_workHour": work_hour,
|
||||
"new_amount": amount,
|
||||
}
|
||||
logger.info(f"映射表完成: 待处理={len(update_map)}, 跳过空编码={skipped_count}, 分类未找到={category_not_found_count}, DF重复名={duplicate_name_in_df_count}, 系统重名={duplicate_name_in_system_count}")
|
||||
|
||||
# 第三步:遍历项目,按需更新
|
||||
logger.info("开始处理项目更新...")
|
||||
consecutive_failures = 0 # 连续失败计数器
|
||||
MAX_CONSECUTIVE_FAILURES = 100 # 最大连续失败次数
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
|
||||
for item in tqdm(all_project_list, desc="处理项目更新"):
|
||||
custom_code = item.get("customCode")
|
||||
if not custom_code or str(custom_code) not in update_map:
|
||||
continue
|
||||
|
||||
service_id = item.get("pkId") # 项目主键
|
||||
if not service_id:
|
||||
error_msg = '缺少 pkId'
|
||||
results.append({'项目编码': custom_code, '状态': error_msg})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
logger.warning(f"项目编码 {custom_code} {error_msg}")
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
|
||||
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
|
||||
break
|
||||
continue
|
||||
|
||||
try:
|
||||
# 获取项目明细
|
||||
params = {
|
||||
'serviceId': service_id,
|
||||
'customInvoiceCategory': '0',
|
||||
}
|
||||
detail_resp = requests.get(
|
||||
'https://ids-goods.f6car.cn/f6-ids-goods/service/viewService',
|
||||
params=params,
|
||||
cookies=cookies,
|
||||
)
|
||||
time.sleep(1)
|
||||
if detail_resp.status_code != 200:
|
||||
error_msg = f'获取明细失败: {detail_resp.status_code}'
|
||||
results.append({'项目编码': custom_code, '状态': error_msg})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
logger.error(f"项目编码 {custom_code} {error_msg}, 响应内容: {detail_resp.text[:200]}")
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
|
||||
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
|
||||
break
|
||||
continue
|
||||
|
||||
detail = detail_resp.json().get("data")
|
||||
if not detail:
|
||||
error_msg = '明细为空'
|
||||
results.append({'项目编码': custom_code, '状态': error_msg})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
logger.error(f"项目编码 {custom_code} {error_msg}, 响应JSON: {detail_resp.json()}")
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
|
||||
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
|
||||
break
|
||||
continue
|
||||
|
||||
updates = update_map[str(custom_code)]
|
||||
|
||||
# === 安全更新字段(仅非空时覆盖)===
|
||||
if should_update(updates["new_customCode"]):
|
||||
detail["customCode"] = safe_str(updates["new_customCode"])
|
||||
if should_update(updates["new_name"]):
|
||||
name_val = safe_str(updates["new_name"])
|
||||
# 再次检查新项目名称是否与系统已有项目名称重复(防止在构建update_map后系统状态发生变化)
|
||||
if name_val in existing_project_names:
|
||||
error_msg = f'跳过: 新项目名称与系统已有项目名称重复({name_val})'
|
||||
results.append({'项目编码': custom_code, '状态': error_msg})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
logger.warning(f"项目编码 {custom_code} {error_msg}")
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
|
||||
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
|
||||
break
|
||||
continue
|
||||
detail["name"] = name_val
|
||||
if "showName" in detail:
|
||||
detail["showName"] = name_val
|
||||
|
||||
if updates["new_serviceCategoryId"] is not None:
|
||||
detail["serviceCategoryId"] = updates["new_serviceCategoryId"]
|
||||
|
||||
if should_update(updates["new_taxRate"]):
|
||||
detail["taxRate"] = safe_str(updates["new_taxRate"])
|
||||
|
||||
if should_update(updates["new_memo"]):
|
||||
detail["memo"] = safe_str(updates["new_memo"])
|
||||
|
||||
if should_update(updates["new_carCategoryName"]):
|
||||
detail["carCategoryName"] = safe_str(updates["new_carCategoryName"])
|
||||
|
||||
if updates["new_price"] is not None:
|
||||
detail["price"] = f"{updates['new_price']:.2f}"
|
||||
if updates["new_workHour"] is not None:
|
||||
detail["workHour"] = f"{updates['new_workHour']:.2f}"
|
||||
if updates["new_amount"] is not None:
|
||||
detail["amount"] = f"{updates['new_amount']:.2f}"
|
||||
|
||||
# === 提交更新 ===
|
||||
update_resp = requests.post(
|
||||
'https://ids-goods.f6car.cn/f6-ids-goods/service/editService',
|
||||
cookies=cookies,
|
||||
json=detail
|
||||
)
|
||||
time.sleep(2)
|
||||
|
||||
if update_resp.status_code == 200 and update_resp.json().get("code") == 200:
|
||||
results.append({'项目编码': custom_code, '状态': '修改成功'})
|
||||
success_count += 1
|
||||
consecutive_failures = 0 # 成功时重置计数器
|
||||
else:
|
||||
msg = update_resp.json().get("message", "未知错误")
|
||||
error_msg = f'修改失败: {msg}'
|
||||
results.append({'项目编码': custom_code, '状态': error_msg})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
logger.error(f"项目编码 {custom_code} {error_msg}, 响应数据: {update_resp.json()}")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = f"请求异常: {str(e)}"
|
||||
logger.error(f"项目编码 {custom_code} {error_msg}, 堆栈信息: {traceback.format_exc()}")
|
||||
results.append({'项目编码': custom_code, '状态': error_msg})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
except Exception as e:
|
||||
error_msg = f"异常: {str(e)}"
|
||||
logger.error(f"项目编码 {custom_code} 更新出错: {traceback.format_exc()}")
|
||||
results.append({'项目编码': custom_code, '状态': error_msg})
|
||||
failure_count += 1
|
||||
consecutive_failures += 1
|
||||
|
||||
# 检查连续失败次数
|
||||
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
|
||||
results.append({'项目编码': '系统保护', '状态': '连续100次失败已终止'})
|
||||
logger.warning(f"连续失败{MAX_CONSECUTIVE_FAILURES}次,已中止任务执行")
|
||||
break
|
||||
|
||||
# 汇总插入到结果顶部,便于简道云直接看到统计
|
||||
total_rows = len(df)
|
||||
to_process = len(update_map)
|
||||
skip_count = skipped_count + duplicate_name_in_df_count + duplicate_name_in_system_count
|
||||
summary = {
|
||||
"总行数": total_rows,
|
||||
"待处理": to_process,
|
||||
"成功": success_count,
|
||||
"失败": failure_count,
|
||||
"跳过": skip_count,
|
||||
}
|
||||
results.insert(0, {"汇总": summary})
|
||||
logger.info(f"项目修改汇总: {summary}")
|
||||
|
||||
# 第四步:清理与回写
|
||||
print({'msg': '已执行', 'msg_details': results})
|
||||
logger.info("项目批量修改完成,开始回写结果")
|
||||
|
||||
# 删除文件
|
||||
logger.info(f"准备删除文件: {save_path}")
|
||||
try:
|
||||
os.remove(save_path)
|
||||
logger.info(f"文件删除成功: {save_path}")
|
||||
print(f'{save_path} 已删除')
|
||||
except Exception as e:
|
||||
logger.error(f"删除文件失败: {save_path}, 错误信息: {str(e)}, 堆栈信息: {traceback.format_exc()}")
|
||||
|
||||
# 回写简道云
|
||||
logger.info("开始回写简道云表单...")
|
||||
try:
|
||||
msg = update_jiandaoyun(data, str(results))
|
||||
logger.info(f"简道云表单回写结果: {msg}")
|
||||
if msg.get('msg'):
|
||||
logger.info("开始自动提交工作流...")
|
||||
approve_workflow(data)
|
||||
logger.info("工作流提交成功")
|
||||
print('表单已自动提交至下一步')
|
||||
else:
|
||||
logger.warning(f"简道云表单回写失败: {msg}")
|
||||
except Exception as e:
|
||||
logger.error(f"回写简道云表单失败: {str(e)}, 堆栈信息: {traceback.format_exc()}")
|
||||
|
||||
logger.info("项目批量修改任务执行完成")
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
# 添加新任务类指南
|
||||
|
||||
本文档详细说明如何在项目中添加一个新的任务类,包含立即响应和后台执行功能。
|
||||
|
||||
## 目录
|
||||
|
||||
1. [架构概述](#架构概述)
|
||||
2. [添加步骤](#添加步骤)
|
||||
3. [代码示例](#代码示例)
|
||||
4. [文件结构说明](#文件结构说明)
|
||||
5. [注意事项](#注意事项)
|
||||
|
||||
---
|
||||
|
||||
## 架构概述
|
||||
|
||||
项目采用**立即响应 + 后台执行**的架构模式:
|
||||
|
||||
- **立即响应函数**:位于 `app/module/f6_plugin_handlers.py`,负责快速响应请求,立即返回"正在执行"消息
|
||||
- **后台执行函数**:位于 `app/tasks/` 目录下,负责在后台线程中执行实际任务
|
||||
|
||||
### 工作流程
|
||||
|
||||
```
|
||||
客户端请求
|
||||
↓
|
||||
立即响应函数(F6PluginHandlers)
|
||||
↓
|
||||
启动后台线程
|
||||
↓
|
||||
后台执行函数(tasks/*.py)
|
||||
↓
|
||||
更新简道云表单
|
||||
↓
|
||||
自动提交工作流
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 添加步骤
|
||||
|
||||
### 步骤 1: 创建后台任务文件
|
||||
|
||||
在 `app/tasks/` 目录下创建新的任务文件,例如 `app/tasks/your_task.py`:
|
||||
|
||||
```python
|
||||
"""
|
||||
你的任务相关后台任务模块
|
||||
|
||||
本模块包含你的任务相关的后台任务,包括:
|
||||
- 任务描述1
|
||||
- 任务描述2
|
||||
|
||||
这些任务在后台线程中执行,不会阻塞主请求。
|
||||
执行完成后会更新简道云表单并自动提交工作流。
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import requests
|
||||
import pandas as pd
|
||||
from typing import Dict, Any
|
||||
from tqdm import tqdm
|
||||
from app.tasks.common import update_jiandaoyun, approve_workflow
|
||||
|
||||
logger = logging.getLogger('app')
|
||||
|
||||
|
||||
def your_task_background(data: Dict[str, Any], cookies: Dict[str, str] = None,
|
||||
df: pd.DataFrame = None, save_path: str = None):
|
||||
"""
|
||||
你的任务后台执行函数
|
||||
|
||||
在后台线程中执行你的任务。
|
||||
执行完成后会更新简道云表单并自动提交工作流。
|
||||
|
||||
Args:
|
||||
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
|
||||
cookies: 用户登录 F6 系统的 cookies 信息(可选)
|
||||
df: Excel 文件读取的内容,DataFrame 格式(可选)
|
||||
save_path: Excel 文件保存的地址,执行完成后会删除此文件(可选)
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
注意:
|
||||
- 执行完成后会自动删除上传的文件(如果提供了save_path)
|
||||
- 执行结果会更新到简道云表单
|
||||
"""
|
||||
try:
|
||||
# TODO: 在这里实现具体的任务逻辑
|
||||
results = []
|
||||
|
||||
# 示例:处理数据
|
||||
if df is not None:
|
||||
df = df.where(pd.notnull(df), None)
|
||||
for index, row in tqdm(df.iterrows(), total=df.shape[0], desc="处理数据"):
|
||||
# 实现具体的数据处理逻辑
|
||||
result_item = {
|
||||
'行号': index + 1,
|
||||
'状态': '处理成功'
|
||||
}
|
||||
results.append(result_item)
|
||||
else:
|
||||
# 如果没有DataFrame,执行其他任务
|
||||
results.append({'状态': '任务执行成功'})
|
||||
|
||||
# 删除文件(如果提供了save_path)
|
||||
if save_path and os.path.exists(save_path):
|
||||
os.remove(save_path)
|
||||
logger.info(f'{save_path}已删除')
|
||||
|
||||
# 格式化结果
|
||||
results_str = f'{results}' if results else '任务执行完成'
|
||||
logger.info(f"任务执行结果: {results_str}")
|
||||
|
||||
# 调用api回写改掉 执行明细与执行状态
|
||||
msg = update_jiandaoyun(data, results_str)
|
||||
|
||||
if msg.get('msg'):
|
||||
approve_workflow(data)
|
||||
logger.info('表单已自动提交至下一步')
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f'任务执行失败: {str(e)}'
|
||||
logger.error(error_msg, exc_info=True)
|
||||
msg = update_jiandaoyun(data, error_msg)
|
||||
if msg.get('msg'):
|
||||
approve_workflow(data)
|
||||
```
|
||||
|
||||
### 步骤 2: 在任务导出文件中注册
|
||||
|
||||
在 `app/tasks/__init__.py` 中添加导入和导出:
|
||||
|
||||
```python
|
||||
# 你的任务
|
||||
from app.tasks.your_task import your_task_background
|
||||
|
||||
__all__ = [
|
||||
# ... 其他任务
|
||||
# 你的任务
|
||||
'your_task_background',
|
||||
]
|
||||
```
|
||||
|
||||
### 步骤 3: 添加立即响应函数
|
||||
|
||||
在 `app/module/f6_plugin_handlers.py` 中添加立即响应函数:
|
||||
|
||||
```python
|
||||
from app.tasks.your_task import your_task_background
|
||||
|
||||
class F6PluginHandlers:
|
||||
# ... 其他方法
|
||||
|
||||
@staticmethod
|
||||
def your_task(data: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""
|
||||
你的任务
|
||||
|
||||
从简道云获取任务请求,读取 Excel 文件(如果需要),并在后台线程中执行任务。
|
||||
立即返回"正在执行"的提示,实际执行在后台线程中完成。
|
||||
|
||||
Args:
|
||||
data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: 包含执行状态的字典
|
||||
"""
|
||||
entry_data = api_instance.entry_data_get(data=data)
|
||||
print('执行 你的任务')
|
||||
|
||||
# 获取必要的参数(根据实际需求调整)
|
||||
username = entry_data['data'].get('账号')
|
||||
password = entry_data['data'].get('密码')
|
||||
company_name = entry_data['data'].get('公司名称')
|
||||
save_path = entry_data['data'].get('文件保存地址')
|
||||
|
||||
# 如果需要登录F6系统
|
||||
cookies = None
|
||||
if username and password and company_name:
|
||||
login_response = F6Module.login_in(username, password, company_name)
|
||||
if login_response is None:
|
||||
return {'msg': '登录失败', 'msg_details': '无法登录F6系统'}
|
||||
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
|
||||
|
||||
# 如果需要读取Excel文件
|
||||
df = None
|
||||
if save_path:
|
||||
try:
|
||||
df = pd.read_excel(save_path, sheet_name=0, dtype='string')
|
||||
except Exception as e:
|
||||
return {'msg': f'读取Excel文件失败: {str(e)},文件路径:{save_path}'}
|
||||
|
||||
# 启动后台线程执行任务
|
||||
try:
|
||||
thread = threading.Thread(target=your_task_background,
|
||||
args=(data, cookies, df, save_path))
|
||||
thread.start()
|
||||
except Exception as e:
|
||||
print(f'创建线程失败: {str(e)}')
|
||||
return {'msg': '任务启动失败', 'msg_details': f'无法启动后台任务: {str(e)}'}
|
||||
|
||||
return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
|
||||
```
|
||||
|
||||
### 步骤 4: 注册操作到模块注册表
|
||||
|
||||
在 `main.py` 的 `lifespan` 函数中注册操作:
|
||||
|
||||
```python
|
||||
core_manager.register_action('your_task', f6_plugin_handlers.your_task, 'f6_plugin_module',
|
||||
description='你的任务描述')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 代码示例
|
||||
|
||||
### 完整示例:BI任务
|
||||
|
||||
以下是一个完整的示例,展示如何添加BI任务类:
|
||||
|
||||
#### 1. 后台任务文件 (`app/tasks/bi_tasks.py`)
|
||||
|
||||
```python
|
||||
"""
|
||||
BI相关后台任务模块
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import pandas as pd
|
||||
from typing import Dict, Any
|
||||
from tqdm import tqdm
|
||||
from app.tasks.common import update_jiandaoyun, approve_workflow
|
||||
|
||||
logger = logging.getLogger('app')
|
||||
|
||||
def bi_task_background(data: Dict[str, Any], cookies: Dict[str, str] = None,
|
||||
df: pd.DataFrame = None, save_path: str = None):
|
||||
"""BI任务后台执行函数"""
|
||||
try:
|
||||
results = []
|
||||
if df is not None:
|
||||
# 处理Excel数据
|
||||
for index, row in tqdm(df.iterrows(), total=df.shape[0], desc="处理BI数据"):
|
||||
results.append({'行号': index + 1, '状态': '处理成功'})
|
||||
else:
|
||||
results.append({'状态': 'BI任务执行成功'})
|
||||
|
||||
if save_path and os.path.exists(save_path):
|
||||
os.remove(save_path)
|
||||
|
||||
msg = update_jiandaoyun(data, f'{results}')
|
||||
if msg.get('msg'):
|
||||
approve_workflow(data)
|
||||
except Exception as e:
|
||||
error_msg = f'BI任务执行失败: {str(e)}'
|
||||
logger.error(error_msg, exc_info=True)
|
||||
update_jiandaoyun(data, error_msg)
|
||||
```
|
||||
|
||||
#### 2. 立即响应函数 (`app/module/f6_plugin_handlers.py`)
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
def bi_task(data: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""BI任务立即响应函数"""
|
||||
entry_data = api_instance.entry_data_get(data=data)
|
||||
|
||||
# 获取参数
|
||||
username = entry_data['data'].get('账号')
|
||||
password = entry_data['data'].get('密码')
|
||||
company_name = entry_data['data'].get('公司名称')
|
||||
save_path = entry_data['data'].get('文件保存地址')
|
||||
|
||||
# 登录(如果需要)
|
||||
cookies = None
|
||||
if username and password and company_name:
|
||||
login_response = F6Module.login_in(username, password, company_name)
|
||||
if login_response is None:
|
||||
return {'msg': '登录失败'}
|
||||
cookies = requests.utils.dict_from_cookiejar(login_response.cookies)
|
||||
|
||||
# 读取文件(如果需要)
|
||||
df = None
|
||||
if save_path:
|
||||
df = pd.read_excel(save_path, sheet_name=0, dtype='string')
|
||||
|
||||
# 启动后台线程
|
||||
thread = threading.Thread(target=bi_task_background,
|
||||
args=(data, cookies, df, save_path))
|
||||
thread.start()
|
||||
|
||||
return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'}
|
||||
```
|
||||
|
||||
#### 3. 注册操作 (`main.py`)
|
||||
|
||||
```python
|
||||
core_manager.register_action('bi_task', f6_plugin_handlers.bi_task, 'f6_plugin_module',
|
||||
description='BI任务')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件结构说明
|
||||
|
||||
### 关键文件位置
|
||||
|
||||
```
|
||||
fastapi_app/
|
||||
├── app/
|
||||
│ ├── module/
|
||||
│ │ └── f6_plugin_handlers.py # 立即响应处理器(重命名自 f6_plugin_module.py)
|
||||
│ │
|
||||
│ ├── tasks/
|
||||
│ │ ├── __init__.py # 任务导出文件
|
||||
│ │ ├── common.py # 通用任务函数
|
||||
│ │ ├── bi_tasks.py # BI任务(示例)
|
||||
│ │ ├── brand_tasks.py # 品牌任务
|
||||
│ │ ├── customer_tasks.py # 客户任务
|
||||
│ │ └── delete_tasks.py # 删除任务
|
||||
│ │
|
||||
│ └── api/
|
||||
│ └── routes.py # API路由
|
||||
│
|
||||
└── main.py # 应用入口,注册所有操作
|
||||
```
|
||||
|
||||
### 命名规范
|
||||
|
||||
- **立即响应函数**:位于 `F6PluginHandlers` 类中,使用小写下划线命名(如 `bi_task`)
|
||||
- **后台执行函数**:位于 `app/tasks/` 目录,使用 `{task_name}_background` 命名(如 `bi_task_background`)
|
||||
- **操作名称**:在 `main.py` 中注册时使用,通常与立即响应函数名相同(如 `'bi_task'`)
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 1. 文件命名变更
|
||||
|
||||
**重要**:`f6_plugin_module.py` 已重命名为 `f6_plugin_handlers.py`,类名从 `F6PluginModule` 改为 `F6PluginHandlers`。
|
||||
|
||||
- 文件名更清晰地表达了其功能:处理立即响应的处理器
|
||||
- 所有引用已更新,但 `app.state.f6_plugin_module` 保持向后兼容
|
||||
|
||||
### 2. 参数传递
|
||||
|
||||
后台执行函数通常接收以下参数:
|
||||
|
||||
- `data`: 必需,包含简道云表单信息
|
||||
- `cookies`: 可选,F6系统登录凭证
|
||||
- `df`: 可选,Excel文件数据(DataFrame)
|
||||
- `save_path`: 可选,文件保存路径
|
||||
|
||||
### 3. 错误处理
|
||||
|
||||
- 立即响应函数:应捕获登录、文件读取等错误,立即返回错误信息
|
||||
- 后台执行函数:应使用 try-except 包裹整个逻辑,确保错误能更新到简道云表单
|
||||
|
||||
### 4. 文件清理
|
||||
|
||||
如果任务处理了上传的文件,应在执行完成后删除:
|
||||
|
||||
```python
|
||||
if save_path and os.path.exists(save_path):
|
||||
os.remove(save_path)
|
||||
logger.info(f'{save_path}已删除')
|
||||
```
|
||||
|
||||
### 5. 简道云表单更新
|
||||
|
||||
所有后台任务完成后都应:
|
||||
|
||||
1. 调用 `update_jiandaoyun(data, results_str)` 更新执行结果
|
||||
2. 如果更新成功,调用 `approve_workflow(data)` 自动提交工作流
|
||||
|
||||
### 6. 日志记录
|
||||
|
||||
使用项目统一的日志记录器:
|
||||
|
||||
```python
|
||||
import logging
|
||||
logger = logging.getLogger('app')
|
||||
logger.info("信息日志")
|
||||
logger.error("错误日志", exc_info=True) # exc_info=True 记录异常堆栈
|
||||
```
|
||||
|
||||
### 7. 进度显示
|
||||
|
||||
对于批量处理任务,使用 `tqdm` 显示进度:
|
||||
|
||||
```python
|
||||
from tqdm import tqdm
|
||||
|
||||
for index, row in tqdm(df.iterrows(), total=df.shape[0], desc="处理数据"):
|
||||
# 处理逻辑
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速检查清单
|
||||
|
||||
添加新任务类时,请确认:
|
||||
|
||||
- [ ] 创建了后台任务文件 `app/tasks/{task_name}_tasks.py`
|
||||
- [ ] 在 `app/tasks/__init__.py` 中导出了后台任务函数
|
||||
- [ ] 在 `app/module/f6_plugin_handlers.py` 中添加了立即响应函数
|
||||
- [ ] 在 `main.py` 中注册了操作
|
||||
- [ ] 实现了错误处理逻辑
|
||||
- [ ] 添加了日志记录
|
||||
- [ ] 实现了文件清理(如果处理了文件)
|
||||
- [ ] 实现了简道云表单更新和工作流提交
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何测试新添加的任务?
|
||||
|
||||
A: 启动应用后,通过API调用测试:
|
||||
- 请求头设置 `Action: your_task`
|
||||
- 请求体包含简道云表单数据
|
||||
|
||||
### Q: 任务执行失败怎么办?
|
||||
|
||||
A: 后台执行函数中的异常会被捕获,错误信息会更新到简道云表单的"执行明细"字段。
|
||||
|
||||
### Q: 如何修改任务执行逻辑?
|
||||
|
||||
A: 只需修改 `app/tasks/{task_name}_tasks.py` 中的后台执行函数即可。
|
||||
|
||||
### Q: 可以添加不需要登录的任务吗?
|
||||
|
||||
A: 可以,在立即响应函数中不调用 `F6Module.login_in`,`cookies` 参数传 `None` 即可。
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `app/module/f6_plugin_handlers.py` - 立即响应处理器
|
||||
- `app/tasks/common.py` - 通用任务函数(update_jiandaoyun, approve_workflow)
|
||||
- `app/core/module_registry.py` - 模块注册表
|
||||
- `main.py` - 应用入口和操作注册
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2025年
|
||||
|
||||
**维护者**: 数据组
|
||||
|
||||
+685
@@ -0,0 +1,685 @@
|
||||
# 简道云 FastAPI 服务系统说明书
|
||||
|
||||
## 1. 系统概述
|
||||
|
||||
### 1.1 系统简介
|
||||
|
||||
简道云 FastAPI 服务是一个基于 FastAPI 框架开发的后端服务系统,主要用于处理简道云插件发送的业务请求,与 F6 汽车维修管理系统进行数据交互,实现数据同步、批量处理、信息管理等核心功能。
|
||||
|
||||
### 1.2 系统定位
|
||||
|
||||
- **服务对象**: 简道云插件前端
|
||||
- **集成系统**: F6 汽车维修管理系统、简道云平台
|
||||
- **主要功能**: 数据同步、批量操作、信息管理、工作流自动化
|
||||
|
||||
### 1.3 系统特点
|
||||
|
||||
- **高性能**: 基于 FastAPI 异步框架,支持高并发请求
|
||||
- **模块化**: 采用模块化设计,易于扩展和维护
|
||||
- **可靠性**: 完善的异常处理和重试机制
|
||||
- **自动化**: 支持后台任务自动执行和工作流自动提交
|
||||
- **可维护性**: 清晰的代码结构和完善的日志记录
|
||||
|
||||
## 2. 系统功能
|
||||
|
||||
### 2.1 F6系统集成功能
|
||||
|
||||
#### 2.1.1 登录认证
|
||||
|
||||
**功能描述**: 实现 F6 系统的登录认证,支持用户名密码登录和验证码识别。
|
||||
|
||||
**主要流程**:
|
||||
1. 接收用户名、密码、公司名称
|
||||
2. 对密码进行 MD5 加密
|
||||
3. 发送登录请求到 F6 系统
|
||||
4. 如果触发验证码,自动识别并重试
|
||||
5. 选择指定公司并完成登录
|
||||
6. 返回登录结果和 Cookie 信息
|
||||
|
||||
**技术实现**:
|
||||
- 使用 `requests` 库发送 HTTP 请求
|
||||
- 使用 `pytesseract` 进行验证码 OCR 识别
|
||||
- 使用 `PIL` 进行图像预处理(对比度、亮度调整)
|
||||
|
||||
#### 2.1.2 公司信息获取
|
||||
|
||||
**功能描述**: 获取 F6 系统中用户可访问的公司列表,并保存到简道云表单。
|
||||
|
||||
**主要流程**:
|
||||
1. 使用用户名密码登录 F6 系统
|
||||
2. 获取公司列表(单店或多店)
|
||||
3. 将公司信息批量写入简道云表单
|
||||
4. 返回时间戳用于后续查询
|
||||
|
||||
**输出格式**:
|
||||
- 单店: 返回门店名称
|
||||
- 多店: 返回所有公司名称列表
|
||||
|
||||
#### 2.1.3 门店信息获取
|
||||
|
||||
**功能描述**: 获取指定公司下的门店列表及统计数据。
|
||||
|
||||
**主要流程**:
|
||||
1. 登录 F6 系统并选择公司
|
||||
2. 获取门店列表
|
||||
3. 统计客户车辆数量和客户数量
|
||||
4. 将门店信息批量写入简道云表单
|
||||
5. 返回时间戳和统计数据
|
||||
|
||||
**输出内容**:
|
||||
- 门店名称列表
|
||||
- 客户车辆总数
|
||||
- 客户总数
|
||||
|
||||
#### 2.1.4 保持连接
|
||||
|
||||
**功能描述**: 心跳检测功能,用于保持连接活跃。
|
||||
|
||||
**实现**: 直接返回接收到的数据,不做任何处理。
|
||||
|
||||
### 2.2 文件处理功能
|
||||
|
||||
#### 2.2.1 文件上传和下载
|
||||
|
||||
**功能描述**: 处理简道云插件上传的文件,下载并保存到本地。
|
||||
|
||||
**主要流程**:
|
||||
1. 从简道云获取文件 URL
|
||||
2. 解析文件名和扩展名
|
||||
3. 根据时间戳生成唯一文件名
|
||||
4. 下载文件到 `下载文件/` 目录
|
||||
5. 返回文件保存路径
|
||||
|
||||
**文件命名规则**: `原文件名_时间戳.扩展名`
|
||||
|
||||
#### 2.2.2 文件校验
|
||||
|
||||
**功能描述**: 校验上传的 Excel 文件格式是否符合要求。
|
||||
|
||||
**支持的操作类型**:
|
||||
- `create_brand`: 校验第一列是否为"品牌"
|
||||
- `modify_customer_info`: 校验第一列是否为"客户手机号"
|
||||
- `delete_cars`: 暂不校验
|
||||
|
||||
**校验流程**:
|
||||
1. 读取 Excel 文件第一列
|
||||
2. 检查表头是否符合要求
|
||||
3. 返回校验结果(成功/失败)
|
||||
|
||||
### 2.3 数据管理功能
|
||||
|
||||
#### 2.3.1 品牌批量创建
|
||||
|
||||
**功能描述**: 从 Excel 文件读取品牌名称,批量创建到 F6 系统。
|
||||
|
||||
**主要流程**:
|
||||
1. 从简道云获取任务数据(账号、密码、公司名称、文件路径)
|
||||
2. 登录 F6 系统
|
||||
3. 读取 Excel 文件(第一列为品牌名称)
|
||||
4. 在后台线程中批量创建品牌
|
||||
5. 立即返回"正在执行"提示
|
||||
6. 后台任务完成后更新简道云表单并自动提交工作流
|
||||
|
||||
**后台任务处理**:
|
||||
- 遍历 Excel 文件中的每一行
|
||||
- 调用 F6 API 创建品牌
|
||||
- 记录成功和失败的结果
|
||||
- 执行完成后删除上传的文件
|
||||
- 更新简道云表单的执行明细
|
||||
- 自动提交工作流到下一步
|
||||
|
||||
#### 2.3.2 客户信息修改
|
||||
|
||||
**功能描述**: 从 Excel 文件读取客户修改信息,批量修改 F6 系统中的客户信息。
|
||||
|
||||
**主要流程**:
|
||||
1. 从简道云获取任务数据
|
||||
2. 登录 F6 系统
|
||||
3. 读取 Excel 文件(包含客户手机号、修改字段等)
|
||||
4. 获取所有客户列表
|
||||
5. 获取员工列表(用于匹配专属运营顾问)
|
||||
6. 在后台线程中批量修改客户信息
|
||||
7. 立即返回"正在执行中"提示
|
||||
|
||||
**Excel 文件格式**:
|
||||
- 第一列: 客户手机号(必需,用于匹配)
|
||||
- 其他列: 需要修改的字段(客户姓名、客户类型、客户来源、单位名称、专属运营顾问、客户备注等)
|
||||
|
||||
**匹配逻辑**:
|
||||
- 根据客户手机号匹配 F6 系统中的客户
|
||||
- 获取客户原始信息
|
||||
- 合并 Excel 中的修改字段
|
||||
- 解析地址信息(省市区)
|
||||
- 匹配专属运营顾问的用户ID
|
||||
|
||||
#### 2.3.3 客户信息删除
|
||||
|
||||
**功能描述**: 批量删除 F6 系统中的客户信息。
|
||||
|
||||
**主要流程**:
|
||||
1. 从简道云获取任务数据
|
||||
2. 登录 F6 系统
|
||||
3. 获取所有客户列表
|
||||
4. 获取会员卡列表(提取客户ID)
|
||||
5. 在后台线程中批量删除客户
|
||||
6. 立即返回"正在执行中"提示
|
||||
|
||||
**删除规则**:
|
||||
- 跳过有最近消费时间的客户
|
||||
- 跳过有会员卡的客户
|
||||
- 8-20点之间每3.5秒删除一条,其余时间每1.5秒删除一条
|
||||
- 记录成功和失败次数
|
||||
|
||||
#### 2.3.4 车辆信息删除
|
||||
|
||||
**功能描述**: 批量删除 F6 系统中的客户车辆信息。
|
||||
|
||||
**主要流程**:
|
||||
1. 从简道云获取任务数据
|
||||
2. 登录 F6 系统
|
||||
3. 分页获取所有车辆列表
|
||||
4. 获取会员卡列表(提取车辆ID)
|
||||
5. 在后台线程中批量删除车辆
|
||||
6. 立即返回"正在执行中"提示
|
||||
|
||||
**删除规则**:
|
||||
- 跳过有最近消费时间的客户车辆
|
||||
- 跳过有会员卡的车辆
|
||||
- 8-20点之间每3秒删除一条,其余时间每1秒删除一条
|
||||
- 记录成功和失败次数
|
||||
|
||||
#### 2.3.5 历史记录删除
|
||||
|
||||
**功能描述**: 删除指定门店的历史维修记录。
|
||||
|
||||
**主要流程**:
|
||||
1. 从简道云获取任务数据(账号、密码、公司名称、门店名称)
|
||||
2. 登录 F6 系统
|
||||
3. 获取门店列表并匹配门店ID
|
||||
4. 在后台线程中删除历史维修记录
|
||||
5. 立即返回"正在执行中"提示
|
||||
|
||||
**删除方式**: 调用 F6 系统的删除接口,删除整个门店的历史维修记录。
|
||||
|
||||
### 2.4 BI任务功能
|
||||
|
||||
**功能描述**: 执行 BI 相关的数据处理和报表生成任务。
|
||||
|
||||
**当前状态**: 框架已搭建,具体业务逻辑待实现。
|
||||
|
||||
**预留接口**:
|
||||
- 支持从 Excel 文件读取数据
|
||||
- 支持 F6 系统登录(如需要)
|
||||
- 支持后台线程执行
|
||||
- 支持结果回写到简道云
|
||||
|
||||
### 2.5 工作流自动化
|
||||
|
||||
**功能描述**: 自动获取简道云工作流的待处理任务并提交到下一步。
|
||||
|
||||
**主要流程**:
|
||||
1. 获取工作流实例信息
|
||||
2. 查找状态为待处理(status=0)的任务
|
||||
3. 提取任务信息(username、instance_id、task_id)
|
||||
4. 调用审批接口自动提交
|
||||
5. 记录执行结果
|
||||
|
||||
**应用场景**: 后台任务执行完成后,自动将简道云表单提交到工作流下一步,无需人工干预。
|
||||
|
||||
## 3. 技术架构
|
||||
|
||||
### 3.1 系统架构
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 简道云插件前端 │
|
||||
└────────┬────────┘
|
||||
│ HTTP/JSON
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ FastAPI 服务 │
|
||||
│ ┌───────────┐ │
|
||||
│ │ 路由层 │ │
|
||||
│ └─────┬─────┘ │
|
||||
│ │ │
|
||||
│ ┌─────▼─────┐ │
|
||||
│ │ 业务模块 │ │
|
||||
│ │ - F6Module│ │
|
||||
│ │ - Plugin │ │
|
||||
│ └─────┬─────┘ │
|
||||
│ │ │
|
||||
│ ┌─────▼─────┐ │
|
||||
│ │ 任务队列 │ │
|
||||
│ └─────┬─────┘ │
|
||||
│ │ │
|
||||
│ ┌─────▼─────┐ │
|
||||
│ │ 后台任务 │ │
|
||||
│ └───────────┘ │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────┴────┐
|
||||
▼ ▼
|
||||
┌────────┐ ┌────────┐
|
||||
│ F6系统 │ │ 简道云 │
|
||||
└────────┘ └────────┘
|
||||
```
|
||||
|
||||
### 3.2 核心组件
|
||||
|
||||
#### 3.2.1 应用入口 (main.py)
|
||||
|
||||
**职责**:
|
||||
- 应用初始化和生命周期管理
|
||||
- 模块注册和路由配置
|
||||
- 异常处理器配置
|
||||
- 中间件配置(CORS)
|
||||
|
||||
**关键功能**:
|
||||
- `lifespan`: 管理应用启动和关闭
|
||||
- 全局异常处理
|
||||
- 模块实例化并注册到注册表
|
||||
|
||||
#### 3.2.2 路由层 (app/api/routes.py)
|
||||
|
||||
**职责**:
|
||||
- 定义 API 端点
|
||||
- 请求验证和参数解析
|
||||
- 调用业务模块处理请求
|
||||
- 返回响应
|
||||
|
||||
**主要端点**:
|
||||
- `GET /health`: 健康检查
|
||||
- `POST /webhook`: 业务请求处理
|
||||
|
||||
#### 3.2.3 业务模块层
|
||||
|
||||
**F6Module** (`app/module/module.py`):
|
||||
- F6 系统登录和认证
|
||||
- 公司信息获取
|
||||
- 门店信息获取
|
||||
- 保持连接
|
||||
|
||||
**F6PluginModule** (`app/module/f6_plugin_module.py`):
|
||||
- 文件上传和校验
|
||||
- 品牌批量创建
|
||||
- 客户信息管理(修改、删除)
|
||||
- 车辆信息删除
|
||||
- 历史记录删除
|
||||
- BI任务
|
||||
|
||||
**OtherPluginModule** (`app/module/other_module.py`):
|
||||
- 其他插件功能(如短信签名状态)
|
||||
|
||||
#### 3.2.4 任务处理层
|
||||
|
||||
**任务队列** (`app/utils/app_tools.py`):
|
||||
- 使用 `Queue` 实现任务队列
|
||||
- 后台线程处理任务
|
||||
- 支持同步等待结果
|
||||
|
||||
**后台任务** (`app/tasks/`):
|
||||
- `common.py`: 通用任务函数(更新简道云、工作流审批等)
|
||||
- `brand_tasks.py`: 品牌相关任务
|
||||
- `customer_tasks.py`: 客户相关任务
|
||||
- `delete_tasks.py`: 删除相关任务
|
||||
- `bi_tasks.py`: BI相关任务
|
||||
|
||||
#### 3.2.5 核心工具层
|
||||
|
||||
**模块注册表** (`app/core/module_registry.py`):
|
||||
- 统一管理所有业务操作
|
||||
- 支持操作查询和路由
|
||||
- 存储操作元数据(描述、模块名等)
|
||||
|
||||
**应用工具** (`app/utils/app_tools.py`):
|
||||
- 日志记录器配置(支持轮转)
|
||||
- 任务队列管理
|
||||
- 请求头解码工具
|
||||
- 后台调度器
|
||||
|
||||
**简道云API封装** (`app/api/__init__.py`):
|
||||
- 封装简道云所有API接口
|
||||
- 支持失败重试机制
|
||||
- 字段ID到标签名的替换
|
||||
- 批量操作支持
|
||||
|
||||
### 3.3 数据流
|
||||
|
||||
#### 3.3.1 同步任务流程
|
||||
|
||||
```
|
||||
简道云插件 → FastAPI路由 → 业务模块 → 任务队列 → 处理线程 → 返回结果 → 简道云插件
|
||||
```
|
||||
|
||||
#### 3.3.2 后台任务流程
|
||||
|
||||
```
|
||||
简道云插件 → FastAPI路由 → 业务模块 → 启动后台线程 → 立即返回
|
||||
↓
|
||||
后台任务执行
|
||||
↓
|
||||
更新简道云表单
|
||||
↓
|
||||
自动提交工作流
|
||||
```
|
||||
|
||||
### 3.4 技术选型
|
||||
|
||||
| 技术 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| FastAPI | 0.121.0 | Web框架 |
|
||||
| Uvicorn | 0.38.0 | ASGI服务器 |
|
||||
| Pydantic | - | 数据验证 |
|
||||
| Requests | 2.32.5 | HTTP请求 |
|
||||
| Pandas | 2.3.3 | Excel处理 |
|
||||
| APScheduler | 3.11.1 | 任务调度 |
|
||||
| Pillow | 12.0.0 | 图像处理 |
|
||||
| Pytesseract | 0.3.13 | OCR识别 |
|
||||
|
||||
## 4. 部署运维
|
||||
|
||||
### 4.1 环境要求
|
||||
|
||||
- **操作系统**: Windows/Linux/macOS
|
||||
- **Python版本**: 3.8+
|
||||
- **内存**: 建议 2GB+
|
||||
- **磁盘**: 根据文件存储需求,建议 10GB+
|
||||
|
||||
### 4.2 安装步骤
|
||||
|
||||
1. **克隆代码**:
|
||||
```bash
|
||||
git clone <repository_url>
|
||||
cd fastapi_app
|
||||
```
|
||||
|
||||
2. **安装依赖**:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **配置环境**:
|
||||
- 编辑 `app/config.py`,配置 API Token
|
||||
- 确保目录权限正确(logs、下载文件、模板文件)
|
||||
|
||||
4. **启动服务**:
|
||||
```bash
|
||||
python main.py
|
||||
# 或
|
||||
uvicorn main:app --host 0.0.0.0 --port 5003
|
||||
```
|
||||
|
||||
### 4.3 生产环境部署
|
||||
|
||||
#### 4.3.1 使用 Gunicorn + Uvicorn Workers
|
||||
|
||||
```bash
|
||||
pip install gunicorn
|
||||
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:5003
|
||||
```
|
||||
|
||||
#### 4.3.2 使用 systemd 管理服务(Linux)
|
||||
|
||||
创建服务文件 `/etc/systemd/system/fastapi-app.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=简道云 FastAPI 服务
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=your_user
|
||||
WorkingDirectory=/path/to/fastapi_app
|
||||
Environment="PATH=/path/to/venv/bin"
|
||||
ExecStart=/path/to/venv/bin/uvicorn main:app --host 0.0.0.0 --port 5003
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
启动服务:
|
||||
```bash
|
||||
sudo systemctl enable fastapi-app
|
||||
sudo systemctl start fastapi-app
|
||||
```
|
||||
|
||||
#### 4.3.3 使用 Nginx 反向代理
|
||||
|
||||
Nginx 配置示例:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your_domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5003;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 监控和日志
|
||||
|
||||
#### 4.4.1 日志管理
|
||||
|
||||
- **日志位置**: `logs/简道云.log`
|
||||
- **日志轮转**: 单文件最大 5MB,保留 5 个备份
|
||||
- **日志级别**: INFO
|
||||
- **日志格式**: `时间戳 级别:模块名:消息`
|
||||
|
||||
#### 4.4.2 健康检查
|
||||
|
||||
定期访问 `/health` 端点检查服务状态:
|
||||
|
||||
```bash
|
||||
curl http://localhost:5003/health
|
||||
```
|
||||
|
||||
#### 4.4.3 性能监控
|
||||
|
||||
建议使用以下工具监控服务性能:
|
||||
- **APM工具**: New Relic、Datadog 等
|
||||
- **日志分析**: ELK Stack、Loki 等
|
||||
- **指标监控**: Prometheus + Grafana
|
||||
|
||||
### 4.5 备份和恢复
|
||||
|
||||
#### 4.5.1 数据备份
|
||||
|
||||
需要备份的内容:
|
||||
- 配置文件 (`app/config.py`)
|
||||
- 日志文件 (`logs/`)
|
||||
- 下载的文件 (`下载文件/`)
|
||||
- 模板文件 (`模板文件/`)
|
||||
|
||||
#### 4.5.2 恢复步骤
|
||||
|
||||
1. 恢复代码和配置文件
|
||||
2. 恢复数据文件
|
||||
3. 重新安装依赖
|
||||
4. 重启服务
|
||||
|
||||
## 5. 安全说明
|
||||
|
||||
### 5.1 认证和授权
|
||||
|
||||
- **API Token**: 使用简道云 API Token 进行认证
|
||||
- **F6系统**: 使用用户名密码登录,密码进行 MD5 加密
|
||||
|
||||
### 5.2 数据安全
|
||||
|
||||
- **敏感信息**: API Token 建议使用环境变量管理
|
||||
- **密码处理**: 密码在传输前进行 MD5 加密
|
||||
- **文件存储**: 上传的文件在处理完成后自动删除
|
||||
|
||||
### 5.3 网络安全
|
||||
|
||||
- **CORS配置**: 生产环境建议限制允许的来源
|
||||
- **HTTPS**: 建议使用 HTTPS 加密传输
|
||||
- **防火墙**: 限制服务端口访问
|
||||
|
||||
### 5.4 安全建议
|
||||
|
||||
1. 定期更新依赖包,修复安全漏洞
|
||||
2. 使用强密码策略
|
||||
3. 定期审查日志,发现异常访问
|
||||
4. 限制文件上传大小和类型
|
||||
5. 实施请求频率限制
|
||||
|
||||
## 6. 故障排查
|
||||
|
||||
### 6.1 常见问题
|
||||
|
||||
#### 问题1: 服务启动失败
|
||||
|
||||
**可能原因**:
|
||||
- 端口被占用
|
||||
- 依赖包未安装
|
||||
- 配置文件错误
|
||||
|
||||
**解决方法**:
|
||||
- 检查端口占用: `netstat -ano | findstr 5003`
|
||||
- 重新安装依赖: `pip install -r requirements.txt`
|
||||
- 检查配置文件格式
|
||||
|
||||
#### 问题2: 登录失败
|
||||
|
||||
**可能原因**:
|
||||
- 用户名密码错误
|
||||
- 公司名称不正确
|
||||
- 验证码识别失败
|
||||
- F6系统服务异常
|
||||
|
||||
**解决方法**:
|
||||
- 验证用户名密码和公司名称
|
||||
- 检查 Tesseract OCR 是否正确安装
|
||||
- 查看日志获取详细错误信息
|
||||
|
||||
#### 问题3: 文件上传失败
|
||||
|
||||
**可能原因**:
|
||||
- 文件格式不正确
|
||||
- 文件路径不存在
|
||||
- 磁盘空间不足
|
||||
- 网络连接问题
|
||||
|
||||
**解决方法**:
|
||||
- 检查文件格式是否符合要求
|
||||
- 确保目录存在且有写权限
|
||||
- 检查磁盘空间
|
||||
- 查看网络连接状态
|
||||
|
||||
#### 问题4: 后台任务未执行
|
||||
|
||||
**可能原因**:
|
||||
- 线程启动失败
|
||||
- 任务执行异常
|
||||
- 简道云API调用失败
|
||||
|
||||
**解决方法**:
|
||||
- 查看日志中的错误信息
|
||||
- 检查简道云API Token是否有效
|
||||
- 验证任务数据格式是否正确
|
||||
|
||||
### 6.2 日志分析
|
||||
|
||||
查看日志文件:
|
||||
```bash
|
||||
tail -f logs/简道云.log
|
||||
```
|
||||
|
||||
搜索错误:
|
||||
```bash
|
||||
grep -i error logs/简道云.log
|
||||
```
|
||||
|
||||
### 6.3 性能优化
|
||||
|
||||
1. **并发处理**: 调整 Uvicorn workers 数量
|
||||
2. **数据库连接池**: 如使用数据库,配置连接池
|
||||
3. **缓存**: 对频繁访问的数据使用缓存
|
||||
4. **异步处理**: 更多使用异步函数提高性能
|
||||
|
||||
## 7. 扩展开发
|
||||
|
||||
### 7.1 添加新功能模块
|
||||
|
||||
1. 在 `app/module/` 下创建新模块文件
|
||||
2. 实现业务逻辑方法
|
||||
3. 在 `main.py` 中注册模块和操作
|
||||
4. 在 `app/schemas.py` 中添加数据模型(如需要)
|
||||
|
||||
### 7.2 添加新的API端点
|
||||
|
||||
1. 在 `app/api/routes.py` 中添加路由
|
||||
2. 使用依赖注入获取所需服务
|
||||
3. 实现业务逻辑
|
||||
4. 返回响应
|
||||
|
||||
### 7.3 添加新的后台任务
|
||||
|
||||
1. 在 `app/tasks/` 下创建任务文件
|
||||
2. 实现后台任务函数
|
||||
3. 在业务模块中调用,启动线程
|
||||
4. 使用 `update_jiandaoyun` 更新结果
|
||||
5. 使用 `approve_workflow` 自动提交工作流
|
||||
|
||||
## 8. 附录
|
||||
|
||||
### 8.1 API接口清单
|
||||
|
||||
| 端点 | 方法 | 描述 |
|
||||
|------|------|------|
|
||||
| `/health` | GET | 健康检查 |
|
||||
| `/webhook` | POST | 业务请求处理 |
|
||||
|
||||
### 8.2 操作类型清单
|
||||
|
||||
| 操作名 | 模块 | 类型 |
|
||||
|--------|------|------|
|
||||
| `login_in` | F6Module | 同步 |
|
||||
| `get_company_information` | F6Module | 同步 |
|
||||
| `get_store_information` | F6Module | 同步 |
|
||||
| `keep_alive` | F6Module | 同步 |
|
||||
| `check_file` | F6PluginModule | 同步 |
|
||||
| `create_brand` | F6PluginModule | 后台 |
|
||||
| `delete_history` | F6PluginModule | 后台 |
|
||||
| `delete_customer` | F6PluginModule | 后台 |
|
||||
| `delete_cars` | F6PluginModule | 后台 |
|
||||
| `modify_customer_info` | F6PluginModule | 后台 |
|
||||
| `bi_task` | F6PluginModule | 后台 |
|
||||
| `sms_signature_status` | OtherPluginModule | 同步 |
|
||||
|
||||
### 8.3 配置文件说明
|
||||
|
||||
**app/config.py**:
|
||||
- `BASE_DIR`: 项目根目录
|
||||
- `SAVE_DIRECTORY`: 下载文件目录
|
||||
- `MODE_DIRECTORY`: 模板文件目录
|
||||
- `LOGS_DIRECTORY`: 日志目录
|
||||
- `LOG_FILE`: 日志文件路径
|
||||
- `JIANDAOYUN_API_TOKEN`: 简道云API Token
|
||||
|
||||
### 8.4 目录结构说明
|
||||
|
||||
- `logs/`: 日志文件存储
|
||||
- `下载文件/`: 从简道云下载的文件临时存储
|
||||
- `模板文件/`: 模板文件存储
|
||||
- `app/`: 应用代码目录
|
||||
- `app/api/`: API相关代码
|
||||
- `app/core/`: 核心功能代码
|
||||
- `app/module/`: 业务模块代码
|
||||
- `app/tasks/`: 后台任务代码
|
||||
- `app/utils/`: 工具类代码
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: 2.0.0
|
||||
**最后更新**: 2025年
|
||||
**维护团队**: 数据组
|
||||
|
||||
@@ -74,6 +74,12 @@ async def lifespan(app: FastAPI):
|
||||
description='短信签名状态')
|
||||
core_manager.register_action('modify_customer_info', f6_plugin_module.modify_customer_info, 'f6_plugin_module',
|
||||
description='修改客户信息')
|
||||
core_manager.register_action('disable_project', f6_plugin_module.disable_projects, 'f6_plugin_module',
|
||||
description='项目信息批量启停')
|
||||
core_manager.register_action('batch_modify_materials', f6_plugin_module.modify_material, 'f6_plugin_module',
|
||||
description='材料信息批量修改')
|
||||
core_manager.register_action('batch_modify_projects', f6_plugin_module.modify_project, 'f6_plugin_module',
|
||||
description='项目信息批量修改')
|
||||
core_manager.register_action('bi_task', f6_plugin_module.bi_task, 'f6_plugin_module',
|
||||
description='BI任务')
|
||||
|
||||
@@ -86,7 +92,6 @@ async def lifespan(app: FastAPI):
|
||||
app.state.app_tools.scheduler.shutdown(wait=False)
|
||||
app.state.logger.info("应用关闭")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="简道云FastAPI服务",
|
||||
description="简道云插件后端服务,提供数据同步和处理功能",
|
||||
@@ -202,8 +207,6 @@ async def general_exception_handler(request: Request, exc: Exception):
|
||||
error_code="INTERNAL_ERROR"
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
# 路由已移动到 app/api/routes.py
|
||||
|
||||
|
||||
@@ -214,8 +217,8 @@ if __name__ == '__main__':
|
||||
当直接运行此文件时,启动 uvicorn 服务器。
|
||||
默认配置:
|
||||
- 主机: 0.0.0.0 (监听所有网络接口)
|
||||
- 端口: 5003
|
||||
- 端口: 5000
|
||||
- 热重载: 关闭 (生产环境建议关闭)
|
||||
"""
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=5003, reload=False)
|
||||
uvicorn.run(app, host="0.0.0.0", port=5000, reload=False)
|
||||
|
||||
@@ -9,3 +9,4 @@ pytesseract==0.3.13
|
||||
Requests==2.32.5
|
||||
tqdm==4.67.1
|
||||
uvicorn==0.40.0
|
||||
openpyxl
|
||||
@@ -0,0 +1,54 @@
|
||||
@echo off
|
||||
title 简道云监听服务 端口5000
|
||||
|
||||
:: 切换到应用所在目录(与原始脚本一致)
|
||||
cd /d "C:\Users\Administrator\Desktop\简道云\简道云"
|
||||
|
||||
:: 激活Anaconda基础环境(或你指定的环境)
|
||||
call C:\ProgramData\anaconda3\Scripts\activate.bat
|
||||
if %errorlevel% neq 0 (
|
||||
echo 激活Anaconda环境失败,请检查环境配置。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 验证Python是否可用
|
||||
python --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo 无法找到Python,请检查Anaconda环境是否正确安装。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 验证uvicorn是否已安装(可选但推荐)
|
||||
python -c "import uvicorn" >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo 错误:未检测到 uvicorn。请运行以下命令安装:
|
||||
echo conda install -c conda-forge uvicorn fastapi
|
||||
echo 或
|
||||
echo pip install "uvicorn[standard]" fastapi
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 设置默认参数(允许通过环境变量覆盖)
|
||||
if "%HOST%"=="" set HOST=0.0.0.0
|
||||
if "%PORT%"=="" set PORT=5000
|
||||
if "%WORKERS%"=="" set WORKERS=1
|
||||
if "%LOG_LEVEL%"=="" set LOG_LEVEL=info
|
||||
|
||||
set APP_MODULE=main:app
|
||||
|
||||
echo.
|
||||
echo 启动简道云 FastAPI 服务...
|
||||
echo 模块: %APP_MODULE%
|
||||
echo 地址: http://%HOST%:%PORT%
|
||||
echo 进程数: %WORKERS% (Windows建议设为1)
|
||||
echo 日志级别: %LOG_LEVEL%
|
||||
echo.
|
||||
|
||||
:: 使用 python -m uvicorn 确保调用当前环境中的 uvicorn
|
||||
python -m uvicorn %APP_MODULE% --host %HOST% --port %PORT% --workers %WORKERS% --log-level %LOG_LEVEL%
|
||||
|
||||
:: 服务退出后暂停,便于查看日志
|
||||
pause
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user