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()