From 838453b88f0a2a9e7836b29034c086e24dc486ea Mon Sep 17 00:00:00 2001 From: panda <1415243231@qq.com> Date: Wed, 28 Jan 2026 15:26:48 +0800 Subject: [PATCH] =?UTF-8?q?V2.1=E5=AE=A2=E6=88=B7=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E3=80=81=E9=A1=B9=E7=9B=AE=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=81=9C=E7=94=A8=E3=80=81=E9=A1=B9=E7=9B=AE=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E3=80=81=E6=9D=90=E6=96=99=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E5=8A=9F=E8=83=BD=E4=B8=8A=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/module/F6_Plugin_module.py | 221 +++++++---- app/tasks/common.py | 79 ++-- app/tasks/material_tasks.py | 601 +++++++++++++++++++++++------ requirements.txt | 1 + 模板文件/材料信息批量修改模板.xlsx | Bin 0 -> 10093 bytes 模板文件/项目信息修改模板.xlsx | Bin 0 -> 8983 bytes 6 files changed, 674 insertions(+), 228 deletions(-) create mode 100644 模板文件/材料信息批量修改模板.xlsx create mode 100644 模板文件/项目信息修改模板.xlsx diff --git a/app/module/F6_Plugin_module.py b/app/module/F6_Plugin_module.py index 0ae5d97..1655c88 100644 --- a/app/module/F6_Plugin_module.py +++ b/app/module/F6_Plugin_module.py @@ -16,6 +16,8 @@ F6 后台执行模块 - pandas: Excel 文件处理 - threading: 后台任务处理 """ +import logging +import traceback import requests from urllib.parse import quote import pandas as pd @@ -37,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 @@ -44,6 +47,8 @@ from app.tasks.bi_tasks import bi_task_background # 简道云 API 实例,用于调用简道云 API api_instance = API() +logger = logging.getLogger('app') + class F6PluginModule: """ @@ -171,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 @@ -410,33 +445,45 @@ 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 modify_material(data: Dict[str, Any]) -> Dict[str, str]: @@ -452,33 +499,43 @@ 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}'} - - cookies = requests.utils.dict_from_cookiejar(login_response.cookies) - - 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)}') - - return {'msg': '正在执行', 'msg_details': '正在执行,请稍后看结果'} + logger.error(f"材料批量修改任务执行失败: {str(e)}, 堆栈: {traceback.format_exc()}") + return {'msg': f'执行失败: {str(e)}'} @staticmethod def modify_project(data: Dict[str, Any]) -> Dict[str, str]: @@ -494,33 +551,43 @@ 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_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: - 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, 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 bi_task(data: Dict[str, Any]) -> Dict[str, str]: diff --git a/app/tasks/common.py b/app/tasks/common.py index c329523..6527ab7 100644 --- a/app/tasks/common.py +++ b/app/tasks/common.py @@ -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 - - diff --git a/app/tasks/material_tasks.py b/app/tasks/material_tasks.py index d890160..a710a6f 100644 --- a/app/tasks/material_tasks.py +++ b/app/tasks/material_tasks.py @@ -11,11 +11,9 @@ import logging import traceback import requests import time -from typing import Dict, Any, List, Optional -from datetime import datetime +from typing import Dict, Any from tqdm import tqdm -from app.tasks.common import update_jiandaoyun, approve_workflow, get_operate_org_id, get_card_list, \ - execute_failure_handler +from app.tasks.common import update_jiandaoyun, approve_workflow, get_operate_org_id import pandas as pd import os @@ -45,53 +43,105 @@ def batch_disable_projects(data: Dict[str, Any], cookies: Dict[str, str], df: pd - 执行完成后会自动删除上传的文件 - 执行结果会更新到简道云表单 """ + 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 - org_id = get_operate_org_id(data) + 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': -type_, + 'isDel': ob_type, 'customInvoiceCategory': 0, 'idOwnOrg': org_id, } - response = requests.post( - 'https://ids-goods.f6car.cn/f6-ids-goods/service/getServiceList', - cookies=cookies, - json=json_data, - ) - all_project_list = [] - total_pages = response.json().get("data", {}).get("totalPages", "") - for page in tqdm(range(1, total_pages + 1)): - json_data['currentPage'] = str(page) + try: response = requests.post( 'https://ids-goods.f6car.cn/f6-ids-goods/service/getServiceList', cookies=cookies, json=json_data, ) - project_list = response.json().get("data", {}).get("records", []) - all_project_list.extend(project_list) - + time.sleep(3.5) + 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(3.5) + 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() - res_data_list = [] + 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 custom_code or str(custom_code) not in code_list or not code_list: + 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, @@ -103,28 +153,70 @@ def batch_disable_projects(data: Dict[str, Any], cookies: Dict[str, str], df: pd "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, ) - res_data_list.append(response.json()) + time.sleep(3.5) response.raise_for_status() # 抛出HTTP错误 - results.append({'材料编码': custom_code, '状态': '停用/启用成功'}) + # 检查业务响应码 + 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: - results.append({'材料编码': custom_code, '状态': f'停用/启用失败: {str(e)}'}) - pass + 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}") - os.remove(save_path) - print(f'{save_path}已删除') + + # 删除文件 + 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回写改掉 执行明细与执行状态 - msg = update_jiandaoyun(data, f'{results}') - - if msg.get('msg'): - approve_workflow(data) - print('表单已自动提交至下一步') + 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: @@ -147,6 +239,7 @@ def batch_modify_materials(data: Dict[str, Any], cookies: Dict[str, str], df: pd - 执行完成后会自动删除上传的文件 - 执行结果会更新到简道云表单 """ + logger.info(f"开始执行材料批量修改任务,文件: {save_path},行数: {len(df)}") def safe_str(val): """将值转为字符串,NaN 返回空字符串""" @@ -159,7 +252,13 @@ def batch_modify_materials(data: Dict[str, Any], cookies: Dict[str, str], df: pd return not (pd.isna(new_val) or (isinstance(new_val, str) and new_val.strip() == "")) # 获取门店id - org_id = get_operate_org_id(data) + 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 = { @@ -182,31 +281,46 @@ def batch_modify_materials(data: Dict[str, Any], cookies: Dict[str, str], df: pd 'getThirdPlatformCode': 0, } - 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) - - all_materials_list = [] - for page in tqdm(range(1, total_pages + 1), desc="获取材料列表"): - json_data['currentPage'] = page - resp = requests.post( + try: + response = requests.post( 'https://ids-goods.f6car.com/f6-ids-goods/part/getExactPartStockInfo', cookies=cookies, json=json_data, ) - resp.raise_for_status() - records = resp.json().get("data", {}).get("records", []) - all_materials_list.extend(records) + time.sleep(3.5) + 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(3.5) + 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] = { @@ -215,9 +329,16 @@ def batch_modify_materials(data: Dict[str, Any], cookies: Dict[str, str], df: pd "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: @@ -225,7 +346,15 @@ def batch_modify_materials(data: Dict[str, Any], cookies: Dict[str, str], df: pd part_id = item.get("partId") if not part_id: - results.append({'材料编码': custom_code, '状态': '缺少 partId'}) + 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: @@ -240,13 +369,30 @@ def batch_modify_materials(data: Dict[str, Any], cookies: Dict[str, str], df: pd params=params, cookies=cookies, ) + time.sleep(3.5) if materials_response.status_code != 200: - results.append({'材料编码': custom_code, '状态': f'获取明细失败: {materials_response.status_code}'}) + 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: - results.append({'材料编码': custom_code, '状态': '明细为空'}) + 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)] @@ -277,33 +423,84 @@ def batch_modify_materials(data: Dict[str, Any], cookies: Dict[str, str], df: pd cookies=cookies, json=detail ) + time.sleep(3.5) 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", "未知错误") - results.append({'材料编码': custom_code, '状态': f'修改失败: {msg}'}) + 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: - results.append({'材料编码': custom_code, '状态': f'请求异常: {str(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: - results.append({'材料编码': custom_code, '状态': f'内部错误: {str(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(f"材料批量修改结果: {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"删除文件失败: {e}") + logger.error(f"删除文件失败: {save_path}, 错误信息: {str(e)}, 堆栈信息: {traceback.format_exc()}") # 回写简道云 - msg = update_jiandaoyun(data, str(results)) - if msg.get('msg'): - approve_workflow(data) - print('表单已自动提交至下一步') + 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: @@ -317,16 +514,18 @@ def batch_modify_projects(data: Dict[str, Any], cookies: Dict[str, str], df: pd. data: 包含表单ID(api_key)、表单ID(entry_id)、数据ID(data_id)的字典 cookies: 用户登录 F6 系统的 cookies 信息 df: Excel 文件读取的内容,DataFrame 格式,列顺序为: - [0:原项目编码, 1:新项目编码, 2:新项目名称, 3:业务分类, - 4:销项税率, 5:项目说明, 6:车辆分类, 7:工时单价, 8:工时] + [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 返回空字符串""" @@ -347,20 +546,43 @@ def batch_modify_projects(data: Dict[str, Any], cookies: Dict[str, str], df: pd. """判断是否应该用 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 - org_id = get_operate_org_id(data) + 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) - init_add_resp = requests.post( - 'https://ids-goods.f6car.cn/f6-ids-goods/service/initAdd', - cookies=cookies, - data={'idOwnOrg': org_id} - ) - 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("获取服务分类列表...") + try: + init_add_resp = requests.post( + 'https://ids-goods.f6car.cn/f6-ids-goods/service/initAdd', + cookies=cookies, + data={'idOwnOrg': org_id} + ) + time.sleep(3.5) + 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': '', @@ -372,64 +594,125 @@ def batch_modify_projects(data: Dict[str, Any], cookies: Dict[str, str], df: pd. 'idOwnOrg': org_id, } - 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) - - all_project_list = [] - for page in tqdm(range(1, total_pages + 1), desc="获取项目列表"): - json_data['currentPage'] = page - resp = requests.post( + try: + response = requests.post( 'https://ids-goods.f6car.cn/f6-ids-goods/service/getServiceList', cookies=cookies, json=json_data, ) - resp.raise_for_status() - records = resp.json().get("data", {}).get("records", []) - all_project_list.extend(records) + time.sleep(3.5) + 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(3.5) + 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 = {} - for _, row in df.iterrows(): - orig_code = row.iloc[0] + 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() - # 计算工时费 = 单价 * 工时 - price = safe_float(row.iloc[7]) - work_hour = safe_float(row.iloc[8]) - amount = None - if price is not None and work_hour is not None: - amount = round(price * work_hour, 2) + # 检查新项目名称(第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 = row.iloc[3] + 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}' 未在系统中找到") + 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": row.iloc[1], # 新的自定义编码,取自当前行的第2列(索引为1) - "new_name": row.iloc[2], # 新的名称,取自当前行的第3列(索引为2) - "new_serviceCategoryId": category_pk, # 新的服务分类ID,由变量 category_pk 提供(通常为主键) - "new_taxRate": row.iloc[4], # 新的税率,取自当前行的第5列(索引为4) - "new_memo": row.iloc[5], # 新的备注信息,取自当前行的第6列(索引为5) - "new_carCategoryName": row.iloc[6], # 新的车型分类名称,取自当前行的第7列(索引为6) - "new_price": price, # 新的价格,由变量 price 提供(可能经过处理或计算) - "new_workHour": work_hour, # 新的工时数,由变量 work_hour 提供 - "new_amount": amount, # 新的金额(可能是价格 × 工时等计算结果),由变量 amount 提供 + "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}") # 第三步:遍历项目,按需更新 - results = [] + 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: @@ -437,7 +720,15 @@ def batch_modify_projects(data: Dict[str, Any], cookies: Dict[str, str], df: pd. service_id = item.get("pkId") # 项目主键 if not service_id: - results.append({'项目编码': custom_code, '状态': '缺少 pkId'}) + 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: @@ -451,13 +742,30 @@ def batch_modify_projects(data: Dict[str, Any], cookies: Dict[str, str], df: pd. params=params, cookies=cookies, ) + time.sleep(3.5) if detail_resp.status_code != 200: - results.append({'项目编码': custom_code, '状态': f'获取明细失败: {detail_resp.status_code}'}) + 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: - results.append({'项目编码': custom_code, '状态': '明细为空'}) + 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)] @@ -467,6 +775,18 @@ def batch_modify_projects(data: Dict[str, Any], cookies: Dict[str, str], df: pd. 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 @@ -496,30 +816,79 @@ def batch_modify_projects(data: Dict[str, Any], cookies: Dict[str, str], df: pd. cookies=cookies, json=detail ) + time.sleep(3.5) 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", "未知错误") - results.append({'项目编码': custom_code, '状态': f'修改失败: {msg}'}) + 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()}") + 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(f"项目批量修改结果: {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"删除文件失败: {e}") + logger.error(f"删除文件失败: {save_path}, 错误信息: {str(e)}, 堆栈信息: {traceback.format_exc()}") # 回写简道云 - msg = update_jiandaoyun(data, str(results)) - if msg.get('msg'): - approve_workflow(data) - print('表单已自动提交至下一步') + 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("项目批量修改任务执行完成") diff --git a/requirements.txt b/requirements.txt index 2fce1ab..9d2c42e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ pytesseract==0.3.13 Requests==2.32.5 tqdm==4.67.1 uvicorn==0.40.0 +openpyxl \ No newline at end of file diff --git a/模板文件/材料信息批量修改模板.xlsx b/模板文件/材料信息批量修改模板.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..5a058bd8e87b4fc17b547723985fc7e4ccbacff4 GIT binary patch literal 10093 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
    eXl$ScuVtiXwQ7DI( zUA%-_(1aB$qC4$cCDhpsZA?E86|$P-0St0WB7hr!a<#0k@?)pE=66^s#1dIP9PI0#|=WFov z6u1U~_jj+r!`8rO8Xq*h0^L+H%dx9yPaU7 zaE8bLLmo~SZ zRrVK5!biIt3thi&)m3AMZEIRB7aimzB|jbu0fgyX<7s}%iGd>P|8_hY*^9sb{zjju zn71e`WlKLhC1|CZtR>1Y$$>ACJLD_@NpOT{fcPYMO^1}*WyXd!FT&K;K25gdL#2OrUge66;qovo9Jtw{!Zv5oq(!TU3Z+P%66} zV{*2wPyIuDK4-&MM=Z+NtKpB=0-@Z>Ib9eXo~~?TLKAfgdCk z*3Y!aulAJ3S^_Hy0uq84{7Fpmk2?EB;GeVi560-%c<6NTApC8b{$%>tI6dI}Vw(LI z&duu2(c=!1-`FMK^w`Ed@cAXB z{lBCAhsArm;m7&6-*n!98*u(98~2#P<9x(#3WMN;-UEfdvJ?ONy-S0iK``pSOG|(5 z{c&;WH%H*#(SDbj9xwTEvh6p4JdB@w|1P%ww&;fjj9=qH!=Ha?_yPH^c8ot){6 R;lKnx6N%t8cauJF|34r|Iuif@ literal 0 HcmV?d00001 diff --git a/模板文件/项目信息修改模板.xlsx b/模板文件/项目信息修改模板.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..093bbc67af6d0e6dd2150d4ca2d891693bd4ae96 GIT binary patch literal 8983 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