Files
playwrite/utils/discrete_material_plan_cleaner.py
Misaka_Company 167fa2893f feat: support multiple managers in DiscreteMaterialPlanCleaner
- Add get_materials_to_delete_by_managers() function in db/materials_to_delete.py
  - Supports querying by single manager, multiple managers, or all managers
  - Uses IN clause for multi-manager queries
- Refactor DiscreteMaterialPlanCleaner to accept manager_names parameter
  - Accepts str, List[str], or None (for all managers)
  - Automatically normalizes parameter types in __init__
  - Updates preload_data() with appropriate log messages

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-02-26 10:01:48 +08:00

338 lines
14 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
离散备料计划维护数据清理工具
功能:自动登录 ERP 系统,根据负责人姓名批量清理指定的备料计划物料。
优化点:
1. 数据库预取:从 $O(n)$ 次数据库查询优化为 $O(1)$ 内存匹配HashSet
2. 日志规范:使用 logging 模块替代 print。
3. 代码整洁:移除方法内导入,增加通用定位辅助函数。
"""
import os
import re
import time
import logging
from typing import Union, List, Optional
from playwright.sync_api import sync_playwright, TimeoutError
# 统一顶部导入
from utils.auth import login, logout
from db.production_order_query import (
read_production_ids,
query_production_order_numbers,
)
from db.materials_to_delete import get_materials_to_delete_by_managers
# --- 日志配置 ---
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
class DiscreteMaterialPlanCleaner:
"""离散备料计划维护数据清理器"""
def __init__(
self,
username,
password,
manager_names: Union[str, List[str], None] = None,
headless=False,
verbose=True,
):
self.username = username
self.password = password
self.headless = headless
self.verbose = verbose
# 参数规范化:支持 str、List[str]、None
if manager_names is None:
self.manager_names = None # 表示全部
elif isinstance(manager_names, str):
self.manager_names = [manager_names] if manager_names.strip() else None
else:
self.manager_names = manager_names if manager_names else None
# 核心优化:使用 set 存储待删除编码,查询复杂度为 $O(1)$
self.to_delete_set = set()
def _log(self, message, level="info"):
"""统一日志输出控制"""
if self.verbose:
if level == "info":
logger.info(message)
elif level == "warn":
logger.warning(message)
elif level == "error":
logger.error(message)
def _is_button_enabled(self, button_locator):
"""判定按钮是否可用"""
try:
return button_locator.is_enabled()
except Exception as e:
self._log(f"检查按钮状态时出错: {e}", "error")
return False
def _get_input_value(self, container, label_regex):
"""通用辅助函数:根据 Label 正则获取 Input 的值"""
return (
container.locator("div")
.filter(has_text=re.compile(label_regex, re.MULTILINE))
.locator("input")
.first.input_value()
)
def preload_data(self):
"""批量预取数据库数据"""
if self.manager_names is None:
self._log("正在从数据库提取所有负责人的待删除物料清单...")
else:
names_str = "".join(self.manager_names)
self._log(f"正在从数据库提取负责人 [{names_str}] 的待删除物料清单...")
raw_list = get_materials_to_delete_by_managers(self.manager_names)
self.to_delete_set = set(raw_list)
self._log(f"预加载完成,共计 {len(self.to_delete_set)} 条不合规物料编码。")
def get_production_order_numbers(self, production_id_file):
"""读取文件并查询生产订单号"""
production_ids = read_production_ids(production_id_file)
order_ids = query_production_order_numbers(production_ids)
self._log(
f"读取到 {len(production_ids)} 个总排号 -> 匹配到 {len(order_ids)} 个生产订单号"
)
return order_ids
def process_order(self, inner_frame, order_id, order_index, page1):
"""清理单个订单的数据"""
# 1. 查询订单
textbox = inner_frame.get_by_role("textbox", name="生产订单号")
textbox.fill(order_id)
inner_frame.locator(".search-component-searchBtn").click()
# 2. 等待加载(改进:增加 60s 安全超时,防止死锁)
loading_locator = inner_frame.locator("div").filter(has_text="加载中").nth(1)
try:
loading_locator.wait_for(state="visible", timeout=3000)
loading_locator.wait_for(state="hidden", timeout=60000)
except TimeoutError:
pass
# 3. 进入备料计划详情
inner_frame.locator("#hot-key-head_list").get_by_text("更多").click()
with page1.expect_popup() as page2_info:
inner_frame.get_by_text("备料计划").click()
page2 = page2_info.value
# 4. 穿透嵌套 Iframe
detail_main_frame = page2.locator("#forwardFrame").content_frame
detail_inner_frame = detail_main_frame.locator("#mainiframe").content_frame
# 5. 提取订单状态信息
plan_code_locator = detail_inner_frame.get_by_text(
re.compile(r"^离散备料计划维护:")
)
plan_code_locator.wait_for(state="visible", timeout=15000)
detail_count_text = detail_inner_frame.get_by_text(
re.compile(r"^详细信息 \(\d+\)$")
).inner_text()
detail_count = int(re.search(r"\((\d+)\)", detail_count_text).group(1))
status_text = detail_inner_frame.get_by_text(
re.compile(r"^备料状态:.+$")
).inner_text()
detail_status = re.search(
r"备料状态:(.+)$", status_text.replace("\n", "")
).group(1)
# 6. 执行清理逻辑
if detail_status == "审批通过":
if detail_count > 0:
# --- 点击修改并等待状态切换 (保留原逻辑) ---
detail_inner_frame.get_by_role("button", name="修改").click()
# 关键判断:等待保存按钮出现,确认进入编辑模式
save_button_locator = detail_inner_frame.get_by_role(
"button", name="保存"
)
save_button_locator.wait_for(state="visible", timeout=10000)
self._log("已进入编辑模式(保存按钮已就绪)")
# ---------------------------------------
detail_inner_frame.get_by_text("展开").first.click()
child_form = detail_inner_frame.locator(".card-table-side-box")
button_wrapper = child_form.locator(".button-wrapper")
delete_row_btn = button_wrapper.get_by_role("button", name="删行")
next_btn = button_wrapper.locator(".icon-jiantouyou")
collapse_btn = button_wrapper.locator(".icon-celashouqi")
last_row_number = None
# page2.pause() # 调试用,正式运行时可删除
while True:
# 稳定性检查:等待行号更新
current_row = self._get_input_value(child_form, r"^行号$")
row_num_int = int(current_row)
if current_row == last_row_number:
time.sleep(0.5)
material_code = self._get_input_value(child_form, r"^材料编码")
material_name = self._get_input_value(child_form, r"^材料名称")
pending_qty = self._get_input_value(child_form, r"^累计待发数量$")
if material_code in self.to_delete_set:
self._log(f"发现匹配物料: {material_name} ({material_code})")
if (not pending_qty) and not (
row_num_int >= 7000 and row_num_int < 8000
):
# 记录删除前的行号
old_row_number = current_row
delete_row_btn.click()
self._log(f"✅ 已点击删行,等待删除完成...")
# 等待行号变化(表示删除完成且新数据已加载)
max_wait_time = 10 # 最大等待10秒
start_time = time.time()
while time.time() - start_time < max_wait_time:
try:
new_row_number = self._get_input_value(
child_form, r"^行号$"
)
if new_row_number != old_row_number:
self._log(
f"✓ 删除完成,行号已从 {old_row_number} 变更为 {new_row_number}"
)
break
time.sleep(0.2) # 每200ms检查一次
except Exception as e:
self._log(f"获取新行号时出错: {e}", "warn")
time.sleep(0.2)
else:
self._log(
f"⚠️ 等待删除完成超时({max_wait_time}秒)", "warn"
)
continue
elif row_num_int >= 7000 and row_num_int < 8000:
self._log(
f"⚠️ 行号 {row_num_int} 在 7000-8000 范围内,跳过删除",
"warn",
)
elif pending_qty:
self._log(f"⚠️ 待发数量为 {pending_qty},跳过删除", "warn")
else:
self._log(
f"⚠️ 不满足删除条件,跳过物料 {material_name} ({material_code})",
"warn",
)
else:
self._log(
f" 物料 {material_name} ({material_code}) 不在删除列表中,不做处理"
)
if self._is_button_enabled(next_btn):
last_row_number = current_row
next_btn.click()
else:
break
collapse_btn.click()
# 执行最终保存逻辑(如业务需要)
save_button_locator.click()
self._log("已点击保存,等待保存完成...")
save_button_locator.wait_for(
state="hidden", timeout=60000
) # 等待按钮消失超时60秒
self._log("✅ 保存成功(保存按钮已消失)")
else:
self._log("订单无备料计划数据,无需处理")
elif detail_status == "完成":
self._log("订单已完成,无需处理")
else:
self._log(f"订单状态为 [{detail_status}],不符合处理条件", "warn")
page2.close()
def setup_query_interface(self, inner_frame):
"""初始化查询界面配置"""
inner_frame.locator(".search-name-wrapper > .iconfont").click()
inner_frame.get_by_text("订单号查询").click()
inner_frame.get_by_role("tab", name="全部").click()
# 填充每页显示条数5000条测试值
input_el = inner_frame.locator("#rc_select_0")
input_el.fill("5000")
input_el.press("Enter")
def clean(self, production_id_file):
"""执行完整清理流程"""
# 0. 预加载数据库数据
self.preload_data()
with sync_playwright() as playwright:
browser, context, page, main_frame = login(
playwright=playwright,
username=self.username,
password=self.password,
headless=self.headless,
ignore_https_errors=True,
)
self._log("=" * 30 + " 开始清理任务 " + "=" * 30)
# 进入功能页面
main_frame.locator("i").first.click()
with page.expect_popup() as page1_info:
main_frame.get_by_title("离散生产订单维护", exact=True).first.click()
page1 = page1_info.value
# 定位主 Iframe
work_main_frame = page1.locator("#forwardFrame").content_frame
inner_frame = work_main_frame.locator("#mainiframe").content_frame
inner_frame.locator("#hot-key-head_list").wait_for(
state="visible", timeout=15000
)
self.setup_query_interface(inner_frame)
order_ids = self.get_production_order_numbers(production_id_file)
# 遍历处理
for index, order_id in enumerate(order_ids):
self._log(f"进度: [{index+1}/{len(order_ids)}] 处理单号: {order_id}")
try:
self.process_order(inner_frame, order_id, index, page1)
except Exception as e:
self._log(f"处理单号 {order_id} 时发生异常: {e}", "error")
continue # 单个失败不影响整体执行
# 登出清理
logout(work_main_frame, verbose=self.verbose)
context.close()
browser.close()
self._log("=" * 30 + " 任务全部完成 " + "=" * 30)
def main():
# 路径配置
base_dir = os.path.dirname(__file__)
id_file = os.path.join(base_dir, "productionID.txt")
cleaner = DiscreteMaterialPlanCleaner(
username="BLDpengqiangqiang",
password="your_password_here",
manager_names="彭羽", # 支持字符串、列表或 None
headless=False,
)
cleaner.clean(id_file)
input("执行完毕,按回车键退出程序...")
if __name__ == "__main__":
main()