From 5024384c73f5294db4d6e6514f79c2b4926d2cb9 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 10 Feb 2026 14:17:37 +0800 Subject: [PATCH] refactor: restructure main_clean.py with dependency injection and interfaces Refactor discrete material cleaner to improve maintainability and testability: - Introduce dependency injection pattern for service components - Add interfaces (IPageNavigator, IMaterialExtractor, IDeletionChecker) - Extract PageNavigator service for page navigation logic - Extract MaterialExtractor service for data extraction - Extract DatabaseDeletionChecker service for deletion logic - Add MaterialInfo data model for structured data - Centralize configuration (CleanerConfig, UIConstants) - Implement separation of concerns across services layer Also includes comprehensive execution mechanism documentation. Co-Authored-By: Claude Sonnet 4.5 --- config/__init__.py | 7 +- config/cleaner_config.py | 20 + config/ui_constants.py | 72 +++ docs/MAIN_CLEAN_EXECUTION.md | 801 +++++++++++++++++++++++++++++ docs/MAIN_CLEAN_FLOW.md | 345 ------------- interfaces/__init__.py | 7 + interfaces/i_deletion_checker.py | 24 + interfaces/i_material_extractor.py | 53 ++ interfaces/i_page_navigator.py | 75 +++ models/__init__.py | 5 + models/material_info.py | 19 + services/__init__.py | 7 + services/deletion_checker.py | 45 ++ services/material_extractor.py | 166 ++++++ services/page_navigator.py | 171 ++++++ utils/discrete_material_cleaner.py | 190 +++++++ 16 files changed, 1661 insertions(+), 346 deletions(-) create mode 100644 config/cleaner_config.py create mode 100644 config/ui_constants.py create mode 100644 docs/MAIN_CLEAN_EXECUTION.md delete mode 100644 docs/MAIN_CLEAN_FLOW.md create mode 100644 interfaces/__init__.py create mode 100644 interfaces/i_deletion_checker.py create mode 100644 interfaces/i_material_extractor.py create mode 100644 interfaces/i_page_navigator.py create mode 100644 models/__init__.py create mode 100644 models/material_info.py create mode 100644 services/__init__.py create mode 100644 services/deletion_checker.py create mode 100644 services/material_extractor.py create mode 100644 services/page_navigator.py create mode 100644 utils/discrete_material_cleaner.py diff --git a/config/__init__.py b/config/__init__.py index 2726292..3cc374f 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -1 +1,6 @@ -# Config package +"""配置模块 - 集中管理应用配置""" + +from config.ui_constants import UIConstants +from config.cleaner_config import CleanerConfig + +__all__ = ["UIConstants", "CleanerConfig"] diff --git a/config/cleaner_config.py b/config/cleaner_config.py new file mode 100644 index 0000000..48f1d98 --- /dev/null +++ b/config/cleaner_config.py @@ -0,0 +1,20 @@ +""" +清理器配置 +管理离散备料计划清理器的运行配置 +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class CleanerConfig: + """清理器配置""" + + username: str + password: str + manager_name: str + headless: bool = False + verbose: bool = True + debug_mode: bool = False + debug_order: Optional[int] = None diff --git a/config/ui_constants.py b/config/ui_constants.py new file mode 100644 index 0000000..28267d4 --- /dev/null +++ b/config/ui_constants.py @@ -0,0 +1,72 @@ +""" +UI 常量配置 +集中管理所有 UI 相关的选择器、超时、重试配置 +""" + +from dataclasses import dataclass + + +@dataclass +class Selectors: + """CSS 选择器常量""" + + # 搜索相关 + SEARCH_BTN: str = ".search-component-searchBtn" + SEARCH_ICON: str = ".search-name-wrapper > .iconfont" + QUERY_BY_ORDER: str = "订单号查询" + TAB_ALL: str = "全部" + DISPLAY_COUNT_INPUT: str = "#rc_select_0" + + # 订单相关 + HOT_KEY_HEAD: str = "#hot-key-head_list" + MORE_BUTTON: str = "更多" + MATERIAL_PLAN: str = "备料计划" + + # iframe + FORWARD_FRAME: str = "#forwardFrame" + MAIN_IFRAME: str = "#mainiframe" + + # 按钮 + MODIFY_BUTTON: str = "修改" + SAVE_BUTTON: str = "保存" + EXPAND_BUTTON: str = "展开" + + # 容器 + CARD_TABLE_SIDE_BOX: str = ".card-table-side-box" + + +@dataclass +class Timeouts: + """超时配置常量(单位:毫秒)""" + + # 页面加载 + IFRAME_VISIBLE: int = 15000 + PLAN_CODE_VISIBLE: int = 30000 + BUTTON_VISIBLE: int = 10000 + SERIAL_VISIBLE: int = 10000 + FORM_VISIBLE: int = 5000 + + # 加载等待 + LOADING_VISIBLE_WAIT: int = 3000 + LOADING_HIDDEN_WAIT: int = 0 # 无限等待 + + # 轮询等待(秒) + PLAN_CODE_MAX_WAIT: int = 30 # 最多等待30秒 + PLAN_CHECK_INTERVAL: float = 0.5 # 每0.5秒检查一次 + + +@dataclass +class RetryConfig: + """重试配置""" + + DISPLAY_COUNT_MAX_RETRIES: int = 3 + EXPECTED_DISPLAY_COUNT: str = "5000" + + +@dataclass +class UIConstants: + """UI 配置总入口""" + + selectors: Selectors = Selectors() + timeouts: Timeouts = Timeouts() + retry: RetryConfig = RetryConfig() diff --git a/docs/MAIN_CLEAN_EXECUTION.md b/docs/MAIN_CLEAN_EXECUTION.md new file mode 100644 index 0000000..27a115e --- /dev/null +++ b/docs/MAIN_CLEAN_EXECUTION.md @@ -0,0 +1,801 @@ +# main_clean.py 运行机制文档 + +## 1. 概述 + +### 1.1 系统简介 + +**离散备料计划维护数据清理工具** 是一个基于 Python + Playwright 开发的自动化工具,用于清理 YonBIP(用友)ERP 系统中的备料数据。该工具通过浏览器自动化技术访问 ERP 系统,提取生产订单的备料信息,并根据数据库查询结果判断哪些物料需要清理。 + +### 1.2 技术栈 + +- **Python 3.x**: 主要开发语言 +- **Playwright**: 浏览器自动化框架(Chromium) +- **SQL Server**: 数据库查询(pyodbc) +- **数据类(dataclass)**: 配置和数据模型 + +### 1.3 主要功能 + +1. 自动登录 YonBIP ERP 系统 +2. 读取待处理的生产订单列表 +3. 遍历每个订单的备料计划 +4. 提取物料详细信息(编码、名称、数量等) +5. 查询数据库判断物料是否需要删除 +6. 标记需要清理的物料(预留删除接口) + +--- + +## 2. 系统架构图 + +```mermaid +graph TB + subgraph "外部系统" + ERP[YonBIP ERP 系统] + DB[(SQL Server 数据库)] + FILE[ProductionID.txt] + end + + subgraph "配置层 (config/)" + CONFIG[CleanerConfig
清理器配置] + UICONST[UIConstants
UI 常量配置] + UICONST -.-> SELECTORS[Selectors
选择器] + UICONST -.-> TIMEOUTS[Timeouts
超时配置] + UICONST -.-> RETRY[RetryConfig
重试配置] + end + + subgraph "服务层 (services/)" + NAVIGATOR[PageNavigator
页面导航服务] + EXTRACTOR[MaterialExtractor
物料提取服务] + CHECKER[DatabaseDeletionChecker
删除判断服务] + end + + subgraph "业务逻辑层 (utils/)" + CLEANER[DiscreteMaterialCleaner
离散备料清理器] + end + + subgraph "数据层 (models/)" + MATERIAL[MaterialInfo
物料信息模型] + end + + subgraph "接口层 (interfaces/)" + I1[IPageNavigator] + I2[IMaterialExtractor] + I3[IDeletionChecker] + end + + FILE --> CLEANER + CLEANER --> NAVIGATOR + CLEANER --> EXTRACTOR + CLEANER --> CHECKER + CLEANER --> CONFIG + CLEANER --> UICONST + + NAVIGATOR -.实现.-> I1 + EXTRACTOR -.实现.-> I2 + CHECKER -.实现.-> I3 + + NAVIGATOR --> ERP + EXTRACTOR --> ERP + EXTRACTOR --> MATERIAL + CHECKER --> DB + CHECKER --> MATERIAL + + style ERP fill:#ff9999 + style DB fill:#99ccff + style FILE fill:#99ff99 + style CLEANER fill:#ffcc99 +``` + +--- + +## 3. 主执行流程图 + +```mermaid +flowchart TD + START([开始: main函数]) --> INIT_CONFIG[创建 CleanerConfig
配置用户名、密码、负责人等] + INIT_CONFIG --> INIT_UI[创建 UIConstants
选择器、超时、重试配置] + INIT_UI --> DEP_INJ[依赖注入
创建服务实例] + + DEP_INJ --> CREATE_NAV[创建 PageNavigator
页面导航服务] + DEP_INJ --> CREATE_EXT[创建 MaterialExtractor
物料提取服务] + DEP_INJ --> CREATE_CHK[创建 DatabaseDeletionChecker
删除判断服务] + + CREATE_NAV --> CREATE_CLEANER[创建 DiscreteMaterialCleaner
注入所有服务] + CREATE_CHK --> CREATE_CLEANER + CREATE_EXT --> CREATE_CLEANER + + CREATE_CLEANER --> READ_FILE[读取 ProductionID.txt
获取生产总排号列表] + READ_FILE --> CALL_CLEAN[调用 cleaner.clean方法] + + CALL_CLEAN --> PW_START[启动 Playwright] + PW_START --> LOGIN[登录 ERP 系统
获取 browser, context, page] + + LOGIN --> NAV_MAIN[导航到主页面
获取 nested iframe] + NAV_MAIN --> SETUP_QUERY[设置查询界面
打开订单号查询] + + SETUP_QUERY --> READ_IDS[读取总排号] + READ_IDS --> QUERY_DB[查询数据库
获取生产订单号列表] + + QUERY_DB --> ORDER_LOOP{遍历订单列表} + + ORDER_LOOP --> QUERY_ORDER[查询订单
填充订单号并点击查询] + QUERY_ORDER --> WAIT_LOAD[等待页面加载完成] + + WAIT_LOAD --> DEBUG_CHECK{调试模式?} + DEBUG_CHECK -->|是| PAUSE[暂停执行
等待调试] + DEBUG_CHECK -->|否| NAV_PLAN + PAUSE --> NAV_PLAN[导航到备料计划页面] + + NAV_PLAN --> EXTRACT_COUNT[提取详细信息数量] + EXTRACT_COUNT --> EXTRACT_STATUS[提取备料状态] + + EXTRACT_STATUS --> SHOULD_PROC{需要处理?} + SHOULD_PROC -->|数量=0| SKIP[跳过订单] + SHOULD_PROC -->|状态≠审批通过| SKIP + SHOULD_PROC -->|是| ENTER_EDIT[进入编辑模式
点击修改按钮] + + ENTER_EDIT --> EXPAND[展开物料列表
点击展开按钮] + EXPAND --> EXT_MATERIALS[提取所有物料信息
遍历物料列表] + + EXT_MATERIALS --> MAT_LOOP{遍历物料} + MAT_LOOP --> CHECK_DELETE[查询数据库
判断是否需要删除] + CHECK_DELETE --> MARK[标记物料
设置 should_delete] + MARK --> MAT_LOOP + + MAT_LOOP -->|完成| CLOSE_PAGE[关闭当前页面] + CLOSE_PAGE --> SLEEP[等待 1 秒] + SKIP --> ORDER_LOOP + SLEEP --> ORDER_LOOP + + ORDER_LOOP -->|完成| LOGOUT[注销登录] + LOGOUT --> CLOSE_ALL[关闭浏览器上下文] + CLOSE_ALL --> END([结束]) + + style START fill:#90EE90 + style END fill:#FFB6C1 + style ORDER_LOOP fill:#FFE4B5 + style MAT_LOOP fill:#FFE4B5 + style SHOULD_PROC fill:#FFD700 +``` + +--- + +## 4. 组件交互时序图 + +```mermaid +sequenceDiagram + participant Main as main() + participant Cleaner as DiscreteMaterialCleaner + participant Nav as PageNavigator + participant Ext as MaterialExtractor + participant Checker as DatabaseDeletionChecker + participant ERP as YonBIP ERP + participant DB as SQL Server + + Main->>Cleaner: clean(production_id_file) + activate Cleaner + + Cleaner->>Cleaner: _initialize_browser() + Note over Cleaner: 启动 Playwright 并登录 + + Cleaner->>Nav: navigate_to_main_page(page) + activate Nav + Nav->>ERP: 点击功能菜单 + Nav->>ERP: 打开"离散生产订单维护" + Nav-->>Cleaner: (page1, main_frame, inner_frame) + deactivate Nav + + Cleaner->>Nav: setup_query_interface(inner_frame) + activate Nav + Nav->>ERP: 打开订单号查询界面 + Nav->>ERP: 设置显示数量为 5000 + deactivate Nav + + Cleaner->>Cleaner: _get_order_ids(production_id_file) + Note over Cleaner: 读取文件并查询数据库 + + loop 遍历每个订单 + Cleaner->>Cleaner: _query_order(inner_frame, order_id) + Note over Cleaner: 填充订单号并点击查询 + Cleaner->>Nav: wait_for_page_loaded(inner_frame) + activate Nav + Nav->>ERP: 等待"加载中"消失 + deactivate Nav + + alt 调试模式 + Cleaner->>ERP: pause() 调试暂停 + end + + Cleaner->>Nav: navigate_to_material_plan_page(page1) + activate Nav + Nav->>ERP: 点击"更多" → "备料计划" + Nav->>ERP: 等待页面加载完成 + Nav-->>Cleaner: (page2, plan_frame) + deactivate Nav + + Cleaner->>Ext: extract_detail_count(plan_frame) + activate Ext + Ext->>ERP: 提取详细信息数量 + Ext-->>Cleaner: detail_count + deactivate Ext + + Cleaner->>Ext: extract_detail_status(plan_frame) + activate Ext + Ext->>ERP: 提取备料状态 + Ext-->>Cleaner: detail_status + deactivate Ext + + alt 需要处理订单 + Cleaner->>Cleaner: _enter_edit_mode(plan_frame) + Note over Cleaner: 点击修改、展开按钮 + + Cleaner->>Ext: extract_materials(plan_frame, count) + activate Ext + loop 遍历物料 + Ext->>ERP: 提取物料编码 + Ext->>ERP: 提取物料名称 + Ext->>ERP: 提取待发数量 + Ext->>ERP: 提取出库数量 + end + Ext-->>Cleaner: materials (List[MaterialInfo]) + deactivate Ext + + loop 遍历每个物料 + Cleaner->>Checker: should_delete(material) + activate Checker + Checker->>DB: 查询物料负责人 + DB-->>Checker: 查询结果 + Checker-->>Cleaner: True/False + deactivate Checker + Cleaner->>Cleaner: material.should_delete = result + end + + Cleaner->>ERP: 关闭页面 + else 不需要处理 + Cleaner->>ERP: 关闭页面 + end + end + + Cleaner->>Cleaner: _cleanup() + Cleaner->>ERP: 注销登录 + Cleaner->>Cleaner: 关闭浏览器 + + Cleaner-->>Main: 完成 + deactivate Cleaner +``` + +--- + +## 5. 组件详细说明 + +### 5.1 DiscreteMaterialCleaner (utils/discrete_material_cleaner.py) + +**职责**: 核心业务逻辑编排器,协调整个清理流程 + +**主要方法**: +- `clean(production_id_file)` - 执行完整清理流程 +- `_initialize_browser(playwright)` - 初始化浏览器并登录 +- `_get_order_ids(production_id_file)` - 获取待处理订单列表 +- `_process_order(inner_frame, order_id, order_index, page1)` - 处理单个订单 +- `_query_order(inner_frame, order_id, order_index)` - 查询订单 +- `_should_process_order(detail_count, detail_status, order_index)` - 判断是否需要处理 +- `_enter_edit_mode(frame)` - 进入编辑模式 +- `_cleanup(main_frame, context, browser)` - 清理资源 + +**依赖注入**: +- `navigator: IPageNavigator` - 页面导航 +- `extractor: IMaterialExtractor` - 物料提取 +- `deletion_checker: IDeletionChecker` - 删除判断 + +--- + +### 5.2 PageNavigator (services/page_navigator.py) + +**职责**: 处理所有页面导航和 iframe 操作 + +**主要方法**: +- `navigate_to_main_page(page)` - 导航到主页面 +- `navigate_to_order_page(main_frame, page)` - 导航到订单页面 +- `navigate_to_material_plan_page(page)` - 导航到备料计划页面 +- `setup_query_interface(frame)` - 设置查询界面 +- `wait_for_page_loaded(frame)` - 等待页面加载完成 + +**关键特性**: +- 处理嵌套 iframe 结构 (#forwardFrame → #mainiframe) +- 等待加载状态("加载中"提示) +- 使用 expect_popup() 处理新窗口 + +--- + +### 5.3 MaterialExtractor (services/material_extractor.py) + +**职责**: 从页面提取物料信息 + +**主要方法**: +- `extract_detail_count(frame)` - 提取详细信息数量 +- `extract_detail_status(frame)` - 提取备料状态 +- `extract_materials(frame, count)` - 提取所有物料信息 +- `_extract_material_code(child_form)` - 提取物料编码 +- `_extract_material_name(child_form)` - 提取物料名称 +- `_extract_pending_quantity(child_form)` - 提取待发数量 +- `_extract_shipped_quantity(child_form)` - 提取出库数量 +- `_navigate_to_next_material(child_form, current_index, total_count)` - 导航到下一个物料 + +**数据返回**: `List[MaterialInfo]` + +--- + +### 5.4 DatabaseDeletionChecker (services/deletion_checker.py) + +**职责**: 基于数据库查询判断物料是否需要删除 + +**主要方法**: +- `should_delete(material: MaterialInfo) -> bool` - 判断是否需要删除 + +**逻辑**: +- 查询数据库中物料对应的负责人 +- 与配置的 `manager_name` 进行比对 +- 返回是否需要删除的布尔值 + +--- + +### 5.5 CleanerConfig (config/cleaner_config.py) + +**职责**: 管理清理器运行配置 + +**属性**: +- `username: str` - ERP 登录用户名 +- `password: str` - ERP 登录密码 +- `manager_name: str` - 负责人姓名(用于删除判断) +- `headless: bool` - 是否无头模式运行浏览器 +- `verbose: bool` - 是否打印详细日志 +- `debug_mode: bool` - 是否启用调试模式 +- `debug_order: Optional[int]` - 调试的订单索引 + +--- + +### 5.6 UIConstants (config/ui_constants.py) + +**职责**: 集中管理所有 UI 相关常量 + +**包含三个嵌套数据类**: + +1. **Selectors** - CSS 选择器 + - 搜索相关: `SEARCH_BTN`, `SEARCH_ICON`, `QUERY_BY_ORDER` + - iframe: `FORWARD_FRAME`, `MAIN_IFRAME` + - 按钮: `MODIFY_BUTTON`, `SAVE_BUTTON`, `EXPAND_BUTTON` + +2. **Timeouts** - 超时配置(毫秒) + - 页面加载: `IFRAME_VISIBLE`, `PLAN_CODE_VISIBLE` + - 加载等待: `LOADING_VISIBLE_WAIT`, `LOADING_HIDDEN_WAIT` + - 轮询: `PLAN_CODE_MAX_WAIT`, `PLAN_CHECK_INTERVAL` + +3. **RetryConfig** - 重试配置 + - `DISPLAY_COUNT_MAX_RETRIES` - 最大重试次数 + - `EXPECTED_DISPLAY_COUNT` - 期望显示数量 + +--- + +### 5.7 MaterialInfo (models/material_info.py) + +**职责**: 物料信息数据模型 + +**属性**: +- `serial_number: int` - 序号 +- `code: str` - 物料编码 +- `name: str` - 物料名称 +- `pending_quantity: str` - 累计待发数量 +- `shipped_quantity: str` - 累计出库数量 +- `should_delete: bool` - 是否需要删除(默认 False) + +--- + +## 6. 配置类结构图 + +```mermaid +classDiagram + class CleanerConfig { + +str username + +str password + +str manager_name + +bool headless + +bool verbose + +bool debug_mode + +Optional~int~ debug_order + } + + class UIConstants { + +Selectors selectors + +Timeouts timeouts + +RetryConfig retry + } + + class Selectors { + +str SEARCH_BTN + +str SEARCH_ICON + +str QUERY_BY_ORDER + +str TAB_ALL + +str HOT_KEY_HEAD + +str MORE_BUTTON + +str MATERIAL_PLAN + +str FORWARD_FRAME + +str MAIN_IFRAME + +str MODIFY_BUTTON + +str SAVE_BUTTON + +str EXPAND_BUTTON + +str CARD_TABLE_SIDE_BOX + } + + class Timeouts { + +int IFRAME_VISIBLE + +int PLAN_CODE_VISIBLE + +int BUTTON_VISIBLE + +int SERIAL_VISIBLE + +int FORM_VISIBLE + +int LOADING_VISIBLE_WAIT + +int LOADING_HIDDEN_WAIT + +int PLAN_CODE_MAX_WAIT + +float PLAN_CHECK_INTERVAL + } + + class RetryConfig { + +int DISPLAY_COUNT_MAX_RETRIES + +str EXPECTED_DISPLAY_COUNT + } + + class MaterialInfo { + +int serial_number + +str code + +str name + +str pending_quantity + +str shipped_quantity + +bool should_delete + } + + UIConstants *-- Selectors + UIConstants *-- Timeouts + UIConstants *-- RetryConfig + + CleanerConfig ..> UIConstants : 使用 + DiscreteMaterialCleaner ..> CleanerConfig : 依赖 + MaterialExtractor ..> MaterialInfo : 返回 + DatabaseDeletionChecker ..> MaterialInfo : 处理 +``` + +--- + +## 7. 数据流图 + +```mermaid +flowchart LR + subgraph "数据输入" + FILE[ProductionID.txt
生产总排号列表] + CONFIG[CleanerConfig
配置参数] + end + + subgraph "数据处理" + READ[read_production_ids
读取总排号] + QUERY[query_production_order_numbers
查询数据库] + EXTRACT[extract_materials
提取物料信息] + CHECK[should_delete
删除判断] + end + + subgraph "数据模型" + IDs[总排号列表] + OrderIDs[生产订单号列表] + Materials[物料信息列表] + Material[单个物料信息] + end + + subgraph "外部系统" + DB[(SQL Server)] + ERP[YonBIP ERP] + end + + FILE --> READ + READ --> IDs + IDs --> QUERY + QUERY --> DB + DB --> OrderIDs + OrderIDs --> ERP + ERP --> EXTRACT + EXTRACT --> Materials + Materials --> Material + Material --> CHECK + CHECK --> DB + + style FILE fill:#99ff99 + style DB fill:#99ccff + style ERP fill:#ff9999 + style Materials fill:#ffcc99 +``` + +--- + +## 8. 单个订单处理流程 + +```mermaid +flowchart TD + START([开始处理订单]) --> QUERY[填充订单号] + QUERY --> CLICK_SEARCH[点击查询按钮] + CLICK_SEARCH --> WAIT_LOADING[等待加载完成] + + WAIT_LOADING --> NAV_PLAN[导航到备料计划页面] + NAV_PLAN --> WAIT_PLAN[等待备料计划页面加载] + + WAIT_PLAN --> EXT_COUNT[提取详细信息数量] + EXT_COUNT --> EXT_STATUS[提取备料状态] + + EXT_STATUS --> CHECK_COUNT{数量 > 0?} + CHECK_COUNT -->|否| SKIP_CLOSE[关闭页面] + CHECK_COUNT -->|是| CHECK_STATUS{状态 = 审批通过?} + + CHECK_STATUS -->|否| SKIP_CLOSE + CHECK_STATUS -->|是| CLICK_MODIFY[点击修改按钮] + + CLICK_MODIFY --> WAIT_SAVE[等待保存按钮可见] + WAIT_SAVE --> CLICK_EXPAND[点击展开按钮] + CLICK_EXPAND --> EXT_LOOP{遍历物料} + + EXT_LOOP --> FIND_SERIAL[查找序号元素] + FIND_SERIAL --> EXT_CODE[提取物料编码] + EXT_CODE --> EXT_NAME[提取物料名称] + EXT_NAME --> EXT_PENDING[提取待发数量] + EXT_PENDING --> EXT_SHIPPED[提取出库数量] + + EXT_SHIPPED --> CREATE_OBJ[创建 MaterialInfo 对象] + CREATE_OBJ --> CHECK_DELETE[查询数据库判断删除] + + CHECK_DELETE --> MARK_DELETE[标记 should_delete] + MARK_DELETE --> NEXT_MAT{还有下一个?} + NEXT_MAT -->|是| CLICK_NEXT[点击下一个按钮] + CLICK_NEXT --> EXT_LOOP + NEXT_MAT -->|否| CLICK_LAST[点击最后一个按钮] + + CLICK_LAST --> ADD_LIST[添加到物料列表] + ADD_LIST --> CLOSE_PAGE[关闭页面] + SKIP_CLOSE --> END([结束]) + CLOSE_PAGE --> END + + style START fill:#90EE90 + style END fill:#FFB6C1 + style EXT_LOOP fill:#FFE4B5 + style CHECK_COUNT fill:#FFD700 + style CHECK_STATUS fill:#FFD700 +``` + +--- + +## 9. 关键设计模式 + +### 9.1 依赖注入模式 + +`DiscreteMaterialCleaner` 通过构造函数注入所有依赖服务,而不是直接创建实例: + +```python +cleaner = DiscreteMaterialCleaner( + config=config, + navigator=navigator, + extractor=extractor, + deletion_checker=deletion_checker, +) +``` + +**优点**: +- 降低耦合度 +- 方便单元测试(可注入 Mock 对象) +- 灵活替换实现 + +--- + +### 9.2 接口隔离原则 + +通过抽象接口定义服务契约: + +- `IPageNavigator` - 页面导航接口 +- `IMaterialExtractor` - 物料提取接口 +- `IDeletionChecker` - 删除判断接口 + +**优点**: +- 职责分离清晰 +- 实现可替换 +- 便于扩展新功能 + +--- + +### 9.3 策略模式 + +`IDeletionChecker` 接口允许多种删除判断策略: + +- `DatabaseDeletionChecker` - 基于数据库查询 +- 可扩展其他策略(如基于规则、基于 API 等) + +--- + +## 10. 嵌套 iframe 处理机制 + +YonBIP ERP 系统使用嵌套 iframe 结构,需要特殊处理: + +``` +page (浏览器上下文) + └── #forwardFrame (外层 iframe) + └── #mainiframe (内层 iframe,包含实际应用 UI) +``` + +**处理模式**: +```python +outer_frame = page.locator("#forwardFrame").content_frame +inner_frame_locator = outer_frame.locator("#mainiframe") +inner_frame_locator.wait_for(state="visible", timeout=15000) +inner_frame = inner_frame_locator.content_frame +``` + +**关键点**: +- 必须等待内层 iframe 可见后再获取其 content_frame +- 操作前确保元素已加载完成 +- 注意不同页面的 iframe 可能需要重新获取 + +--- + +## 11. 加载状态处理 + +ERP 系统使用"加载中"提示表示页面正在加载数据: + +```python +loading_locator = frame.locator("div").filter(has_text="加载中").nth(1) +try: + loading_locator.wait_for(state="visible", timeout=3000) + loading_locator.wait_for(state="hidden", timeout=0) # 无限等待 +except PlaywrightTimeoutError: + # 加载很快完成或未出现 + pass +``` + +**处理逻辑**: +1. 先尝试等待"加载中"出现 +2. 然后无限等待直到消失 +3. 如果超时则认为加载已完成 + +--- + +## 12. 运行示例 + +```bash +# 激活虚拟环境 +.venv\Scripts\activate + +# 运行清理脚本 +python main_clean.py +``` + +**输出示例**: +``` +================================================================================ +开始执行离散备料计划维护数据清理 +================================================================================ +使用负责人 [彭羽] 进行数据清理 +从文件读取到 10 个总排号 +查询到 10 个生产订单号 + +=== 开始处理第 1 个订单,订单号: PO20250101001 === +第 1 个订单查询完成,等待加载结果... +第 1 个订单加载完成,开始清理数据... +等待备料计划页面加载完成... +备料计划页面加载完成,编码: BL20250101001 +详细信息数量: 5 +备料状态: 审批通过 +处理 序号 1 +材料编码: 00000001001 +材料名称: 原材料A +累计待发数量: 100 +累计出库数量: 0 +>>> 需要清理:原材料A【00000001001】 +... + +开始执行账号注销... +=== 全部完成 === +``` + +--- + +## 13. 扩展与维护 + +### 13.1 添加新的删除判断策略 + +实现 `IDeletionChecker` 接口: + +```python +class RuleBasedDeletionChecker(IDeletionChecker): + def should_delete(self, material: MaterialInfo) -> bool: + # 基于规则的判断逻辑 + return material.name.startswith("测试") +``` + +### 13.2 修改配置参数 + +编辑 `main_clean.py` 中的 `CleanerConfig` 初始化: + +```python +config = CleanerConfig( + username="your_username", + password="your_password", + manager_name="新的负责人", + headless=True, # 无头模式 + verbose=False, # 关闭详细日志 +) +``` + +### 13.3 调试特定订单 + +启用调试模式: + +```python +config = CleanerConfig( + debug_mode=True, + debug_order=0, # 调试第一个订单 +) +``` + +--- + +## 14. 常见问题 + +### Q1: 页面加载超时 +**A**: 检查网络连接,调整 `UIConstants.Timeouts` 中的超时配置 + +### Q2: iframe 未找到 +**A**: 确认 ERP 系统版本未变化,检查 `Selectors.FORWARD_FRAME` 和 `Selectors.MAIN_IFRAME` + +### Q3: 数据库查询失败 +**A**: 检查 `db/materials_to_delete.py` 中的 SQL 连接配置 + +### Q4: 物料信息提取错误 +**A**: 使用 `debug_mode=True` 暂停执行,检查页面元素选择器 + +--- + +## 附录 A: 文件路径速查 + +| 文件 | 路径 | +|------|------| +| 主入口 | `main_clean.py` | +| 核心业务逻辑 | `utils/discrete_material_cleaner.py` | +| 页面导航服务 | `services/page_navigator.py` | +| 物料提取服务 | `services/material_extractor.py` | +| 删除判断服务 | `services/deletion_checker.py` | +| 清理器配置 | `config/cleaner_config.py` | +| UI 常量配置 | `config/ui_constants.py` | +| 物料信息模型 | `models/material_info.py` | +| 页面导航接口 | `interfaces/i_page_navigator.py` | +| 物料提取接口 | `interfaces/i_material_extractor.py` | +| 删除判断接口 | `interfaces/i_deletion_checker.py` | + +--- + +## 附录 B: 接口定义摘要 + +### IPageNavigator +```python +def navigate_to_main_page(page: Page) -> Frame +def navigate_to_order_page(main_frame: Frame, page: Page) -> Tuple[Page, Frame] +def navigate_to_material_plan_page(page: Page) -> Tuple[Page, Frame] +def setup_query_interface(frame: Frame) -> None +def wait_for_page_loaded(frame: Frame) -> bool +``` + +### IMaterialExtractor +```python +def extract_detail_count(frame: Frame) -> int +def extract_detail_status(frame: Frame) -> str +def extract_materials(frame: Frame, count: int) -> List[MaterialInfo] +``` + +### IDeletionChecker +```python +def should_delete(material: MaterialInfo) -> bool +``` + +--- + +**文档版本**: 1.0 +**最后更新**: 2026-02-10 +**维护者**: Claude Code diff --git a/docs/MAIN_CLEAN_FLOW.md b/docs/MAIN_CLEAN_FLOW.md deleted file mode 100644 index 7f42a0b..0000000 --- a/docs/MAIN_CLEAN_FLOW.md +++ /dev/null @@ -1,345 +0,0 @@ -# main_clean.py 流程说明 - -## 概述 - -`main_clean.py` 是离散备料计划维护数据清理工具的主程序入口。该工具通过 Playwright 自动化登录用友 BIP 系统,读取生产订单号,然后逐个清理订单中不需要的物料数据。 - ---- - -## 系统架构图 - -```mermaid -flowchart TB - Start([开始]) --> Init[初始化 DiscreteMaterialPlanCleaner] - - Init --> QueryDB[(查询数据库
获取待删除物料列表)] - - QueryDB --> Login[登录用友BIP系统] - - Login --> OpenMenu[点击功能菜单] - OpenMenu --> OpenPage[打开离散生产订单维护页面] - - OpenPage --> SetupQuery[设置查询界面] - SetupQuery --> ReadFile[(读取ProductionID.txt)] - ReadFile --> GetOrders[(查询数据库
获取生产订单号)] - - GetOrders --> LoopStart{遍历订单} - - LoopStart --> ProcessOrder[处理单个订单] - ProcessOrder --> QueryOrder[查询订单详情] - QueryOrder --> OpenPlanPage[打开备料计划页面] - OpenPlanPage --> WaitLoad[等待页面加载完成] - - WaitLoad --> CheckData{检查数据状态} - - CheckData -->|detail_count = 0| SkipOrder[跳过该订单] - CheckData -->|status != 审批通过| SkipOrder - CheckData -->|detail_count > 0
status = 审批通过| ClickModify[点击修改按钮] - - ClickModify --> ExpandForm[展开详细信息表单] - ExpandForm --> LoopMaterials{遍历物料} - - LoopMaterials --> GetMaterial[获取物料信息] - GetMaterial --> CheckDelete{需要删除?} - - CheckDelete -->|是| MarkDelete[标记待删除] - CheckDelete -->|否| KeepMaterial[保留物料] - - MarkDelete --> NextMaterial{下一个物料?} - KeepMaterial --> NextMaterial - - NextMaterial -->|是| LoopMaterials - NextMaterial -->|否| ClosePlanPage[关闭备料计划页面] - - SkipOrder --> ClosePlanPage - ClosePlanPage --> NextOrder{下一个订单?} - - NextOrder -->|是| LoopStart - NextOrder -->|否| Logout[注销账号] - - Logout --> CloseBrowser[关闭浏览器] - CloseBrowser --> End([结束]) - - style Init fill:#e1f5ff - style QueryDB fill:#fff4e1 - style Login fill:#e1f5ff - style End fill:#ffe1e1 - style CheckDelete fill:#fff4e1 - style CheckData fill:#fff4e1 -``` - ---- - -## 详细流程说明 - -### 1. 初始化阶段 - -```mermaid -flowchart LR - A[main函数] --> B[创建 DiscreteMaterialPlanCleaner 实例] - B --> C[配置参数:
username, password,
manager_name, headless, verbose] - - style A fill:#e1f5ff - style B fill:#e1f5ff - style C fill:#e1f5ff -``` - -**代码位置**: `main_clean.py:9-24` - -```python -cleaner = DiscreteMaterialPlanCleaner( - username="BLDpengqiangqiang", - password="Cqbld123456.", - manager_name="彭羽", - headless=False, - verbose=True, -) -``` - ---- - -### 2. 数据准备阶段 - -```mermaid -flowchart TB - A[clean.clean方法] --> B[查询数据库
MaterialsToBeDeleted表] - B --> C[获取指定负责人的
待删除物料编码列表] - - style A fill:#fff4e1 - style B fill:#fff4e1 - style C fill:#fff4e1 -``` - -**代码位置**: `utils/离散备料计划维护数据清理.py:292-304` - -| 模块 | 功能 | 文件 | -|------|------|------| -| `get_materials_to_delete()` | 查询待删除物料编码 | `db/materials_to_delete.py` | - ---- - -### 3. 登录与导航 - -```mermaid -sequenceDiagram - participant Main as main_clean.py - participant Auth as auth.py - participant Browser as Chromium Browser - participant ERP as 用友BIP系统 - - Main->>Auth: login(playwright, username, password) - Auth->>Browser: launch(headless=False) - Auth->>Browser: new_context() - Auth->>Browser: new_page() - Auth->>ERP: goto(登录页面URL) - Auth->>ERP: 填写用户名和密码 - Auth->>ERP: 点击登录按钮 - alt 有强制登录弹窗 - ERP-->>Auth: 显示弹窗 - Auth->>ERP: 点击确定 - end - Auth-->>Main: 返回 (browser, context, page, main_frame) -``` - -**代码位置**: `utils/auth.py:8-72` - ---- - -### 4. 设置查询界面 - -```mermaid -flowchart TB - A[点击图标按钮] --> B[选择"订单号查询"] - B --> C[点击"全部"标签] - C --> D{填充显示条数} - D --> E{成功?} - E -->|否| F[重试
最多3次] - F --> D - E -->|是| G[继续] - - style D fill:#fff4e1 - style E fill:#fff4e1 - style F fill:#ffe1e1 -``` - -**代码位置**: `utils/离散备料计划维护数据清理.py:263-290` - ---- - -### 5. 获取生产订单号 - -```mermaid -flowchart LR - A[read_production_ids] --> B[读取ProductionID.txt] - B --> C[解析总排号列表] - C --> D[query_production_order_numbers] - D --> E[查询数据库
获取生产订单号] - - style A fill:#fff4e1 - style D fill:#fff4e1 -``` - -**代码位置**: `utils/离散备料计划维护数据清理.py:41-59` - -| 函数 | 功能 | 数据库表 | -|------|------|----------| -| `read_production_ids()` | 读取总排号文件 | 文件输入 | -| `query_production_order_numbers()` | 查询生产订单号 | ProductionOrder | - ---- - -### 6. 订单处理流程 - -```mermaid -flowchart TB - Start([开始处理订单]) --> FillInput[填充生产订单号] - FillInput --> ClickQuery[点击查询按钮] - ClickQuery --> WaitLoad[等待加载完成] - - WaitLoad --> ClickMore[点击"更多"按钮] - ClickMore --> OpenPlan[打开"备料计划"页面] - - OpenPlan --> GetIframe[获取嵌套iframe] - GetIframe --> WaitPlanLoad[等待备料计划页面加载] - - WaitPlanLoad --> ExtractInfo[提取信息:
- 编码
- 详细信息数量
- 备料状态] - - ExtractInfo --> CheckStatus{状态检查} - - style Start fill:#e1f5ff - style CheckStatus fill:#fff4e1 -``` - -**代码位置**: `utils/离散备料计划维护数据清理.py:61-167` - ---- - -### 7. 物料清理流程 - -```mermaid -flowchart TB - Check{detail_count > 0
&
status = 审批通过} -->|否| Skip[跳过处理] - Check -->|是| ClickModify[点击修改按钮] - - ClickModify --> WaitSave[等待保存按钮可见] - WaitSave --> Expand[点击展开按钮] - Expand --> GetForm[获取表单容器] - - GetForm --> LoopStart{遍历物料} - - LoopStart --> GetSerial[定位序号] - GetSerial --> GetCode[获取材料编码] - GetCode --> GetName[获取材料名称] - GetName --> GetQty1[获取累计待发数量] - GetQty1 --> GetQty2[获取累计出库数量] - - GetQty2 --> Match{物料编码
在待删除列表中?} - - Match -->|精确匹配| Delete[标记待删除] - Match -->|未匹配| Keep[保留] - - Delete --> Next{下一个?} - Keep --> Next - - Next -->|是| LoopStart - Next -->|否| Done([完成]) - - style Check fill:#fff4e1 - style Match fill:#fff4e1 - style Done fill:#e1f5ff -``` - -**代码位置**: `utils/离散备料计划维护数据清理.py:169-261` - ---- - -## 关键数据结构 - -### DiscreteMaterialPlanCleaner 类 - -```mermaid -classDiagram - class DiscreteMaterialPlanCleaner { - +str username - +str password - +str manager_name - +bool headless - +bool verbose - +_print(args) - +get_production_order_numbers(file) - +process_order(frame, order_id, ...) - +setup_query_interface(frame) - +clean(file, debug_mode, ...) - } - - class auth { - +login(playwright, username, password) - +logout(main_frame, verbose) - +close_session(browser, context) - } - - class materials_to_delete { - +get_materials_to_delete(manager_name) - +get_all_materials_to_delete() - } - - class production_order_query { - +read_production_ids(file) - +query_production_order_numbers(ids) - } - - DiscreteMaterialPlanCleaner --> auth - DiscreteMaterialPlanCleaner --> materials_to_delete - DiscreteMaterialPlanCleaner --> production_order_query -``` - ---- - -## 配置参数说明 - -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `username` | str | - | 登录用户名 | -| `password` | str | - | 登录密码 | -| `manager_name` | str | - | 负责人姓名(用于查询待删除物料) | -| `headless` | bool | False | 是否无头模式运行浏览器 | -| `verbose` | bool | True | 是否打印详细日志 | - ---- - -## 文件依赖关系 - -```mermaid -graph TD - A[main_clean.py] --> B[utils/离散备料计划维护数据清理.py] - B --> C[utils/auth.py] - B --> D[db/production_order_query.py] - B --> E[db/materials_to_delete.py] - B --> F[db/connection.py] - - style A fill:#e1f5ff - style B fill:#e1f5ff -``` - ---- - -## 执行入口 - -```bash -# 直接运行 -python main_clean.py - -# 或在虚拟环境中运行 -.venv\Scripts\activate -python main_clean.py -``` - ---- - -## 注意事项 - -1. **浏览器模式**: 默认使用非无头模式 (`headless=False`),便于调试观察 -2. **日志输出**: 启用详细日志 (`verbose=True`),打印每一步操作 -3. **数据库连接**: 使用上下文管理器自动管理数据库连接 -4. **异常处理**: 加载超时会自动重试或跳过 -5. **调试模式**: 支持在指定订单处暂停调试 diff --git a/interfaces/__init__.py b/interfaces/__init__.py new file mode 100644 index 0000000..a845b28 --- /dev/null +++ b/interfaces/__init__.py @@ -0,0 +1,7 @@ +"""接口模块 - 定义服务接口""" + +from interfaces.i_page_navigator import IPageNavigator +from interfaces.i_material_extractor import IMaterialExtractor +from interfaces.i_deletion_checker import IDeletionChecker + +__all__ = ["IPageNavigator", "IMaterialExtractor", "IDeletionChecker"] diff --git a/interfaces/i_deletion_checker.py b/interfaces/i_deletion_checker.py new file mode 100644 index 0000000..bfe7e07 --- /dev/null +++ b/interfaces/i_deletion_checker.py @@ -0,0 +1,24 @@ +""" +删除判断接口 +定义判断物料是否需要删除的抽象接口 +""" + +from abc import ABC, abstractmethod +from models.material_info import MaterialInfo + + +class IDeletionChecker(ABC): + """删除判断接口""" + + @abstractmethod + def should_delete(self, material: MaterialInfo) -> bool: + """ + 判断物料是否需要删除 + + Args: + material: 物料信息对象 + + Returns: + 是否需要删除 + """ + pass diff --git a/interfaces/i_material_extractor.py b/interfaces/i_material_extractor.py new file mode 100644 index 0000000..017816f --- /dev/null +++ b/interfaces/i_material_extractor.py @@ -0,0 +1,53 @@ +""" +物料信息提取接口 +定义从页面提取物料信息的抽象接口 +""" + +from abc import ABC, abstractmethod +from playwright.sync_api import Frame +from typing import List +from models.material_info import MaterialInfo + + +class IMaterialExtractor(ABC): + """物料信息提取接口""" + + @abstractmethod + def extract_detail_count(self, frame: Frame) -> int: + """ + 提取详细信息数量 + + Args: + frame: 目标 iframe + + Returns: + 详细信息数量 + """ + pass + + @abstractmethod + def extract_detail_status(self, frame: Frame) -> str: + """ + 提取备料状态 + + Args: + frame: 目标 iframe + + Returns: + 备料状态文本 + """ + pass + + @abstractmethod + def extract_materials(self, frame: Frame, count: int) -> List[MaterialInfo]: + """ + 提取所有物料信息 + + Args: + frame: 目标 iframe + count: 物料数量 + + Returns: + 物料信息列表 + """ + pass diff --git a/interfaces/i_page_navigator.py b/interfaces/i_page_navigator.py new file mode 100644 index 0000000..645982d --- /dev/null +++ b/interfaces/i_page_navigator.py @@ -0,0 +1,75 @@ +""" +页面导航接口 +定义页面导航和 iframe 操作的抽象接口 +""" + +from abc import ABC, abstractmethod +from playwright.sync_api import Page, Frame +from typing import Tuple, Optional + + +class IPageNavigator(ABC): + """页面导航接口 - 处理 iframe 导航、页面切换""" + + @abstractmethod + def navigate_to_main_page(self, page: Page) -> Frame: + """ + 导航到主页面 iframe + + Args: + page: 浏览器页面对象 + + Returns: + 主页面 iframe 的 Frame 对象 + """ + pass + + @abstractmethod + def navigate_to_order_page(self, main_frame: Frame, page: Page) -> Tuple[Page, Frame]: + """ + 导航到订单页面 + + Args: + main_frame: 主 iframe + page: 当前页面对象 + + Returns: + (新页面, 内部 iframe) + """ + pass + + @abstractmethod + def navigate_to_material_plan_page(self, page: Page) -> Tuple[Page, Frame]: + """ + 导航到备料计划页面 + + Args: + page: 订单页面对象 + + Returns: + (新页面, 内部 iframe) + """ + pass + + @abstractmethod + def setup_query_interface(self, frame: Frame) -> None: + """ + 设置查询界面 + + Args: + frame: 目标 iframe + """ + pass + + @abstractmethod + def wait_for_page_loaded(self, frame: Frame) -> bool: + """ + 等待页面加载完成 + + Args: + frame: 目标 iframe + + Returns: + 是否加载成功 + """ + pass diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..5854996 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,5 @@ +"""数据模型模块 - 定义数据结构""" + +from models.material_info import MaterialInfo + +__all__ = ["MaterialInfo"] diff --git a/models/material_info.py b/models/material_info.py new file mode 100644 index 0000000..dfd5a91 --- /dev/null +++ b/models/material_info.py @@ -0,0 +1,19 @@ +""" +物料信息数据模型 +定义物料信息的数据结构 +""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class MaterialInfo: + """物料信息""" + + serial_number: int # 序号 + code: str # 物料编码 + name: str # 物料名称 + pending_quantity: str # 累计待发数量 + shipped_quantity: str # 累计出库数量 + should_delete: bool = False # 是否需要删除 diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..c078e8f --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,7 @@ +"""服务模块 - 实现业务逻辑""" + +from services.page_navigator import PageNavigator +from services.material_extractor import MaterialExtractor +from services.deletion_checker import DatabaseDeletionChecker + +__all__ = ["PageNavigator", "MaterialExtractor", "DatabaseDeletionChecker"] diff --git a/services/deletion_checker.py b/services/deletion_checker.py new file mode 100644 index 0000000..daa27d0 --- /dev/null +++ b/services/deletion_checker.py @@ -0,0 +1,45 @@ +""" +删除判断服务实现 +基于数据库查询判断物料是否需要删除 +""" + +from interfaces.i_deletion_checker import IDeletionChecker +from models.material_info import MaterialInfo +from db.materials_to_delete import should_delete_material + + +class DatabaseDeletionChecker(IDeletionChecker): + """基于数据库的删除判断实现""" + + def __init__(self, manager_name: str, verbose: bool = True): + """ + 初始化删除检查器 + + Args: + manager_name: 负责人姓名 + verbose: 是否打印详细日志 + """ + self.manager_name = manager_name + self.verbose = verbose + + def _print(self, *args, **kwargs): + """打印日志(如果 verbose=True)""" + if self.verbose: + print(*args, **kwargs) + + def should_delete(self, material: MaterialInfo) -> bool: + """ + 通过查询数据库判断是否需要删除 + + Args: + material: 物料信息对象 + + Returns: + 是否需要删除 + """ + result = should_delete_material(self.manager_name, material.code) + if self.verbose and result: + self._print(f">>> 需要清理:{material.name}【{material.code}】") + elif self.verbose and not result: + self._print(f"保留:{material.name}【{material.code}】无需清理") + return result diff --git a/services/material_extractor.py b/services/material_extractor.py new file mode 100644 index 0000000..dc63c02 --- /dev/null +++ b/services/material_extractor.py @@ -0,0 +1,166 @@ +""" +物料信息提取服务实现 +实现从页面提取物料信息的具体逻辑 +""" + +import re +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError +from interfaces.i_material_extractor import IMaterialExtractor +from config.ui_constants import UIConstants +from models.material_info import MaterialInfo + + +class MaterialExtractor(IMaterialExtractor): + """物料信息提取实现""" + + def __init__(self, config: UIConstants, verbose: bool = True): + """ + 初始化物料提取器 + + Args: + config: UI 配置 + verbose: 是否打印详细日志 + """ + self.config = config + self.verbose = verbose + + def _print(self, *args, **kwargs): + """打印日志(如果 verbose=True)""" + if self.verbose: + print(*args, **kwargs) + + def extract_detail_count(self, frame) -> int: + """ + 提取详细信息数量 + + Args: + frame: 目标 iframe + + Returns: + 详细信息数量 + """ + detail_element = frame.get_by_text(re.compile(r"^详细信息 \(\d+\)$")) + detail_text = detail_element.inner_text() + match = re.search(r"详细信息 \((\d+)\)", detail_text) + if match: + detail_count = int(match.group(1)) + self._print(f"详细信息数量: {detail_count}") + return detail_count + return 0 + + def extract_detail_status(self, frame) -> str: + """ + 提取备料状态 + + Args: + frame: 目标 iframe + + Returns: + 备料状态文本 + """ + detail_element = frame.get_by_text(re.compile(r"^备料状态:.+$")) + detail_text = detail_element.inner_text().replace("\n", "") + match = re.search(r"^备料状态:(.+)$", detail_text) + if match: + detail_status = match.group(1) + self._print(f"备料状态: {detail_status}") + return detail_status + return "" + + def extract_materials(self, frame, count: int): + """ + 提取所有物料信息 + + Args: + frame: 目标 iframe + count: 物料数量 + + Returns: + 物料信息列表 + """ + materials = [] + + # 获取展开后的父容器 + child_form = frame.locator(self.config.selectors.CARD_TABLE_SIDE_BOX) + child_form.wait_for(state="visible", timeout=self.config.timeouts.FORM_VISIBLE) + self._print(f"父容器 {self.config.selectors.CARD_TABLE_SIDE_BOX} 已找到") + + for i in range(count): + serial_number = i + 1 + id_label_locator = child_form.get_by_text("序号 " + str(serial_number)) + id_label_locator.wait_for( + state="visible", timeout=self.config.timeouts.SERIAL_VISIBLE + ) + self._print(f"处理 {id_label_locator.inner_text()}") + + # 提取物料信息 + code = self._extract_material_code(child_form) + name = self._extract_material_name(child_form) + pending_quantity = self._extract_pending_quantity(child_form) + shipped_quantity = self._extract_shipped_quantity(child_form) + + material = MaterialInfo( + serial_number=serial_number, + code=code, + name=name, + pending_quantity=pending_quantity, + shipped_quantity=shipped_quantity, + ) + materials.append(material) + + # 导航到下一个物料 + self._navigate_to_next_material(child_form, i, count) + + return materials + + def _extract_material_code(self, child_form) -> str: + """提取物料编码""" + input_box = ( + child_form.locator("div") + .filter(has_text=re.compile(r"^材料编码\d{11}$", re.MULTILINE)) + .locator("input") + .first + ) + code = input_box.input_value() + self._print(f"材料编码: {code}") + return code + + def _extract_material_name(self, child_form) -> str: + """提取物料名称""" + input_box = ( + child_form.locator("div") + .filter(has_text=re.compile(r"^材料名称$")) + .locator("input[type='text']") + ) + name = input_box.input_value() + self._print(f"材料名称: {name}") + return name + + def _extract_pending_quantity(self, child_form) -> str: + """提取累计待发数量""" + input_box = ( + child_form.locator("div") + .filter(has_text=re.compile(r"^累计待发数量$")) + .locator("input[type='text']") + ) + quantity = input_box.input_value() + self._print(f"累计待发数量: {quantity}") + return quantity + + def _extract_shipped_quantity(self, child_form) -> str: + """提取累计出库数量""" + input_box = ( + child_form.locator("div") + .filter(has_text=re.compile(r"^累计出库数量$")) + .locator("input[type='text']") + ) + quantity = input_box.input_value() + self._print(f"累计出库数量: {quantity}") + return quantity + + def _navigate_to_next_material(self, child_form, current_index: int, total_count: int): + """导航到下一个物料""" + if current_index != total_count - 1: + child_form.get_by_role("button").filter(has_text=re.compile(r"^$")).nth(2).click() + else: + child_form.get_by_role("button").filter(has_text=re.compile(r"^$")).nth(4).click() diff --git a/services/page_navigator.py b/services/page_navigator.py new file mode 100644 index 0000000..3db3265 --- /dev/null +++ b/services/page_navigator.py @@ -0,0 +1,171 @@ +""" +页面导航服务实现 +实现页面导航、iframe 操作等具体逻辑 +""" + +import re +import time +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError +from interfaces.i_page_navigator import IPageNavigator +from config.ui_constants import UIConstants + + +class PageNavigator(IPageNavigator): + """页面导航实现""" + + def __init__(self, config: UIConstants, verbose: bool = True): + """ + 初始化页面导航器 + + Args: + config: UI 配置 + verbose: 是否打印详细日志 + """ + self.config = config + self.verbose = verbose + + def _print(self, *args, **kwargs): + """打印日志(如果 verbose=True)""" + if self.verbose: + print(*args, **kwargs) + + def navigate_to_main_page(self, page) -> tuple: + """ + 导航到主页面 iframe + + 实际返回 (page, main_frame, inner_frame) 的元组 + """ + # 点击打开"功能菜单" + main_frame = page.locator(self.config.selectors.FORWARD_FRAME).content_frame + 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 + + # 获取 nested iframe + main_frame = page1.locator(self.config.selectors.FORWARD_FRAME).content_frame + inner_frame_locator = main_frame.locator(self.config.selectors.MAIN_IFRAME) + inner_frame_locator.wait_for( + state="visible", timeout=self.config.timeouts.IFRAME_VISIBLE + ) + inner_frame = inner_frame_locator.content_frame + + return page1, main_frame, inner_frame + + def navigate_to_order_page(self, main_frame, page): + """导航到订单页面""" + # 这个方法在当前实现中与 navigate_to_main_page 类似 + # 返回新的页面对象和内部 iframe + return self.navigate_to_main_page(page) + + def navigate_to_material_plan_page(self, page): + """ + 导航到备料计划页面 + + Args: + page: 订单页面对象 (page1) + + Returns: + (page2, inner_frame) - 新页面和内部 iframe + """ + # 获取主 iframe + main_frame = page.locator(self.config.selectors.FORWARD_FRAME).content_frame + inner_frame = main_frame.locator(self.config.selectors.MAIN_IFRAME).content_frame + + # 点击"更多" + inner_frame.locator(self.config.selectors.HOT_KEY_HEAD).get_by_text( + self.config.selectors.MORE_BUTTON + ).click() + + # 点击"备料计划"并等待新页面 + with page.expect_popup() as page2_info: + inner_frame.get_by_text(self.config.selectors.MATERIAL_PLAN).click() + page2 = page2_info.value + + # 获取 nested iframe + main_frame = page2.locator(self.config.selectors.FORWARD_FRAME).content_frame + inner_frame_locator = main_frame.locator(self.config.selectors.MAIN_IFRAME) + inner_frame_locator.wait_for( + state="visible", timeout=self.config.timeouts.IFRAME_VISIBLE + ) + inner_frame = inner_frame_locator.content_frame + + # 等待备料计划页面加载完成 + self._wait_for_plan_page_loaded(inner_frame) + + return page2, inner_frame + + def setup_query_interface(self, frame): + """设置查询界面""" + # 点击图标按钮打开查询界面 + frame.locator(self.config.selectors.SEARCH_ICON).click() + frame.get_by_text(self.config.selectors.QUERY_BY_ORDER).click() + frame.get_by_role("tab", name=self.config.selectors.TAB_ALL).click() + + # 填充并验证显示数量,如果失败则重试 + max_retries = self.config.retry.DISPLAY_COUNT_MAX_RETRIES + expected_value = self.config.retry.EXPECTED_DISPLAY_COUNT + + for attempt in range(max_retries): + frame.locator(self.config.selectors.DISPLAY_COUNT_INPUT).fill(expected_value) + frame.locator(self.config.selectors.DISPLAY_COUNT_INPUT).press("Enter") + # 检查填充是否成功 + actual_value = frame.locator(self.config.selectors.DISPLAY_COUNT_INPUT).input_value() + if actual_value == expected_value: + self._print(f"文本框填充成功: {expected_value}") + break + else: + self._print( + f"第 {attempt + 1} 次填充失败,实际值: {actual_value},重试..." + ) + if attempt == max_retries - 1: + self._print( + f"警告: {max_retries} 次尝试后仍未成功填充,继续执行..." + ) + + def wait_for_page_loaded(self, frame): + """等待页面加载完成""" + loading_locator = frame.locator("div").filter(has_text="加载中").nth(1) + try: + loading_locator.wait_for( + state="visible", timeout=self.config.timeouts.LOADING_VISIBLE_WAIT + ) + loading_locator.wait_for( + state="hidden", timeout=self.config.timeouts.LOADING_HIDDEN_WAIT + ) + except PlaywrightTimeoutError: + # 加载很快完成,或者没有出现加载提示 + pass + return True + + def _wait_for_plan_page_loaded(self, inner_frame): + """等待备料计划页面加载完成(内部方法)""" + self._print("等待备料计划页面加载完成...") + plan_code_locator = inner_frame.get_by_text( + re.compile(r"^离散备料计划维护:") + ) + plan_code_locator.wait_for( + state="visible", timeout=self.config.timeouts.PLAN_CODE_VISIBLE + ) + + # 循环检查编码是否已加载 + max_wait = self.config.timeouts.PLAN_CODE_MAX_WAIT + wait_interval = self.config.timeouts.PLAN_CHECK_INTERVAL + waited = 0 + plan_code = None + + while waited < max_wait: + plan_text = plan_code_locator.inner_text() + match = re.search(r"离散备料计划维护:(.+)", plan_text) + if match and match.group(1).strip(): + plan_code = match.group(1).strip() + break + time.sleep(wait_interval) + waited += wait_interval + + if plan_code: + self._print(f"备料计划页面加载完成,编码: {plan_code}") + else: + self._print(f"警告: 备料计划页面加载超时") diff --git a/utils/discrete_material_cleaner.py b/utils/discrete_material_cleaner.py new file mode 100644 index 0000000..deec5cd --- /dev/null +++ b/utils/discrete_material_cleaner.py @@ -0,0 +1,190 @@ +""" +离散备料计划清理器 - 重构版 +使用依赖注入、接口抽象、职责分离设计 +""" + +import time +from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError +from interfaces.i_page_navigator import IPageNavigator +from interfaces.i_material_extractor import IMaterialExtractor +from interfaces.i_deletion_checker import IDeletionChecker +from config.cleaner_config import CleanerConfig +from config.ui_constants import UIConstants +from utils.auth import login, logout +from db.production_order_query import ( + read_production_ids, + query_production_order_numbers, +) + + +class DiscreteMaterialCleaner: + """离散备料计划清理器 - 重构版""" + + def __init__( + self, + config: CleanerConfig, + navigator: IPageNavigator, + extractor: IMaterialExtractor, + deletion_checker: IDeletionChecker, + ): + """ + 初始化清理器 + + Args: + config: 清理器配置 + navigator: 页面导航器 + extractor: 物料信息提取器 + deletion_checker: 删除判断器 + """ + self.config = config + self.navigator = navigator + self.extractor = extractor + self.checker = deletion_checker + + def _print(self, *args, **kwargs): + """打印日志(如果 verbose=True)""" + if self.config.verbose: + print(*args, **kwargs) + + def clean(self, production_id_file: str) -> None: + """ + 执行清理流程 + + Args: + production_id_file: ProductionID.txt 文件路径 + """ + self._print(f"使用负责人 [{self.config.manager_name}] 进行数据清理") + + with sync_playwright() as playwright: + # 1. 初始化浏览器和页面 + browser, context, page, main_frame = self._initialize_browser(playwright) + + # 2. 导航到目标页面 + page1, main_frame, inner_frame = self.navigator.navigate_to_main_page(page) + self.navigator.setup_query_interface(inner_frame) + + # 3. 获取订单列表 + order_ids = self._get_order_ids(production_id_file) + + # 4. 处理每个订单 + for order_index, order_id in enumerate(order_ids): + self._process_order(inner_frame, order_id, order_index, page1) + + # 5. 清理 + self._cleanup(main_frame, context, browser) + + def _initialize_browser(self, playwright): + """初始化浏览器和登录""" + self._print("=" * 80) + self._print("开始执行离散备料计划维护数据清理") + self._print("=" * 80) + + return login( + playwright=playwright, + username=self.config.username, + password=self.config.password, + headless=self.config.headless, + ignore_https_errors=True, + ) + + def _get_order_ids(self, production_id_file: str): + """获取订单 ID 列表""" + # 读取总排号 + production_ids = read_production_ids(production_id_file) + self._print(f"从文件读取到 {len(production_ids)} 个总排号") + + # 查询数据库获取生产订单号 + order_ids = query_production_order_numbers(production_ids) + self._print(f"查询到 {len(order_ids)} 个生产订单号") + + return order_ids + + def _process_order(self, inner_frame, order_id: str, order_index: int, page1): + """处理单个订单""" + self._print( + f"\n=== 开始处理第 {order_index + 1} 个订单,订单号: {order_id} ===" + ) + + # 1. 查询订单 + self._query_order(inner_frame, order_id, order_index) + + # 2. 调试模式暂停 + if ( + self.config.debug_mode + and (self.config.debug_order is None or order_index == self.config.debug_order) + ): + self._print(f"=== 调试暂停:第 {order_index + 1} 个订单 ===") + page1.pause() + + # 3. 导航到备料计划页面 + page2, plan_frame = self.navigator.navigate_to_material_plan_page(page1) + + # 4. 提取订单信息 + detail_count = self.extractor.extract_detail_count(plan_frame) + detail_status = self.extractor.extract_detail_status(plan_frame) + + # 5. 检查是否需要处理 + if not self._should_process_order(detail_count, detail_status, order_index): + page2.close() + return + + # 6. 进入编辑模式并展开 + self._enter_edit_mode(plan_frame) + + # 7. 提取并处理物料 + materials = self.extractor.extract_materials(plan_frame, detail_count) + for material in materials: + material.should_delete = self.checker.should_delete(material) + # TODO: 执行实际的删除操作 + + page2.close() + time.sleep(1) + + def _query_order(self, inner_frame, order_id: str, order_index: int): + """查询单个订单""" + # 清空文本框 + textbox = inner_frame.get_by_role("textbox", name="生产订单号") + textbox.fill("") + + # 填充订单号 + textbox.fill(order_id) + + # 点击查询 + from config.ui_constants import Selectors + + inner_frame.locator(Selectors.SEARCH_BTN).click() + self._print(f"第 {order_index + 1} 个订单查询完成,等待加载结果...") + + # 等待加载完成 + self.navigator.wait_for_page_loaded(inner_frame) + self._print(f"第 {order_index + 1} 个订单加载完成,开始清理数据...") + + def _should_process_order( + self, detail_count: int, detail_status: str, order_index: int + ) -> bool: + """判断订单是否需要处理""" + if detail_count == 0: + self._print(f"第 {order_index + 1} 个订单无数据需要清理,跳过...") + return False + elif detail_status != "审批通过": + self._print(f"第 {order_index + 1} 个订单备料状态: {detail_status}") + return False + return True + + def _enter_edit_mode(self, frame): + """进入编辑模式并展开物料列表""" + from config.ui_constants import Selectors + + frame.get_by_role("button", name=Selectors.MODIFY_BUTTON).click() + save_button_locator = frame.get_by_role("button", name=Selectors.SAVE_BUTTON) + save_button_locator.wait_for(state="visible", timeout=10000) + + frame.get_by_text(Selectors.EXPAND_BUTTON).first.click() + + def _cleanup(self, main_frame, context, browser): + """清理资源""" + self._print("\n开始执行账号注销...") + logout(main_frame, verbose=self.config.verbose) + self._print(f"\n=== 全部完成 ===") + context.close() + browser.close()