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 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-02-10 14:17:37 +08:00
parent 21f5827cd3
commit 5024384c73
16 changed files with 1661 additions and 346 deletions

View File

@@ -1 +1,6 @@
# Config package """配置模块 - 集中管理应用配置"""
from config.ui_constants import UIConstants
from config.cleaner_config import CleanerConfig
__all__ = ["UIConstants", "CleanerConfig"]

20
config/cleaner_config.py Normal file
View File

@@ -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

72
config/ui_constants.py Normal file
View File

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

View File

@@ -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<br/>清理器配置]
UICONST[UIConstants<br/>UI 常量配置]
UICONST -.-> SELECTORS[Selectors<br/>选择器]
UICONST -.-> TIMEOUTS[Timeouts<br/>超时配置]
UICONST -.-> RETRY[RetryConfig<br/>重试配置]
end
subgraph "服务层 (services/)"
NAVIGATOR[PageNavigator<br/>页面导航服务]
EXTRACTOR[MaterialExtractor<br/>物料提取服务]
CHECKER[DatabaseDeletionChecker<br/>删除判断服务]
end
subgraph "业务逻辑层 (utils/)"
CLEANER[DiscreteMaterialCleaner<br/>离散备料清理器]
end
subgraph "数据层 (models/)"
MATERIAL[MaterialInfo<br/>物料信息模型]
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<br/>配置用户名、密码、负责人等]
INIT_CONFIG --> INIT_UI[创建 UIConstants<br/>选择器、超时、重试配置]
INIT_UI --> DEP_INJ[依赖注入<br/>创建服务实例]
DEP_INJ --> CREATE_NAV[创建 PageNavigator<br/>页面导航服务]
DEP_INJ --> CREATE_EXT[创建 MaterialExtractor<br/>物料提取服务]
DEP_INJ --> CREATE_CHK[创建 DatabaseDeletionChecker<br/>删除判断服务]
CREATE_NAV --> CREATE_CLEANER[创建 DiscreteMaterialCleaner<br/>注入所有服务]
CREATE_CHK --> CREATE_CLEANER
CREATE_EXT --> CREATE_CLEANER
CREATE_CLEANER --> READ_FILE[读取 ProductionID.txt<br/>获取生产总排号列表]
READ_FILE --> CALL_CLEAN[调用 cleaner.clean方法]
CALL_CLEAN --> PW_START[启动 Playwright]
PW_START --> LOGIN[登录 ERP 系统<br/>获取 browser, context, page]
LOGIN --> NAV_MAIN[导航到主页面<br/>获取 nested iframe]
NAV_MAIN --> SETUP_QUERY[设置查询界面<br/>打开订单号查询]
SETUP_QUERY --> READ_IDS[读取总排号]
READ_IDS --> QUERY_DB[查询数据库<br/>获取生产订单号列表]
QUERY_DB --> ORDER_LOOP{遍历订单列表}
ORDER_LOOP --> QUERY_ORDER[查询订单<br/>填充订单号并点击查询]
QUERY_ORDER --> WAIT_LOAD[等待页面加载完成]
WAIT_LOAD --> DEBUG_CHECK{调试模式?}
DEBUG_CHECK -->|是| PAUSE[暂停执行<br/>等待调试]
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[进入编辑模式<br/>点击修改按钮]
ENTER_EDIT --> EXPAND[展开物料列表<br/>点击展开按钮]
EXPAND --> EXT_MATERIALS[提取所有物料信息<br/>遍历物料列表]
EXT_MATERIALS --> MAT_LOOP{遍历物料}
MAT_LOOP --> CHECK_DELETE[查询数据库<br/>判断是否需要删除]
CHECK_DELETE --> MARK[标记物料<br/>设置 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<br/>生产总排号列表]
CONFIG[CleanerConfig<br/>配置参数]
end
subgraph "数据处理"
READ[read_production_ids<br/>读取总排号]
QUERY[query_production_order_numbers<br/>查询数据库]
EXTRACT[extract_materials<br/>提取物料信息]
CHECK[should_delete<br/>删除判断]
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

View File

@@ -1,345 +0,0 @@
# main_clean.py 流程说明
## 概述
`main_clean.py` 是离散备料计划维护数据清理工具的主程序入口。该工具通过 Playwright 自动化登录用友 BIP 系统,读取生产订单号,然后逐个清理订单中不需要的物料数据。
---
## 系统架构图
```mermaid
flowchart TB
Start([开始]) --> Init[初始化 DiscreteMaterialPlanCleaner]
Init --> QueryDB[(查询数据库<br/>获取待删除物料列表)]
QueryDB --> Login[登录用友BIP系统]
Login --> OpenMenu[点击功能菜单]
OpenMenu --> OpenPage[打开离散生产订单维护页面]
OpenPage --> SetupQuery[设置查询界面]
SetupQuery --> ReadFile[(读取ProductionID.txt)]
ReadFile --> GetOrders[(查询数据库<br/>获取生产订单号)]
GetOrders --> LoopStart{遍历订单}
LoopStart --> ProcessOrder[处理单个订单]
ProcessOrder --> QueryOrder[查询订单详情]
QueryOrder --> OpenPlanPage[打开备料计划页面]
OpenPlanPage --> WaitLoad[等待页面加载完成]
WaitLoad --> CheckData{检查数据状态}
CheckData -->|detail_count = 0| SkipOrder[跳过该订单]
CheckData -->|status != 审批通过| SkipOrder
CheckData -->|detail_count > 0<br/>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[配置参数:<br/>username, password,<br/>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[查询数据库<br/>MaterialsToBeDeleted表]
B --> C[获取指定负责人的<br/>待删除物料编码列表]
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[重试<br/>最多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[查询数据库<br/>获取生产订单号]
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[提取信息:<br/>- 编码<br/>- 详细信息数量<br/>- 备料状态]
ExtractInfo --> CheckStatus{状态检查}
style Start fill:#e1f5ff
style CheckStatus fill:#fff4e1
```
**代码位置**: `utils/离散备料计划维护数据清理.py:61-167`
---
### 7. 物料清理流程
```mermaid
flowchart TB
Check{detail_count > 0<br/>&<br/>status = 审批通过} -->|否| Skip[跳过处理]
Check -->|是| ClickModify[点击修改按钮]
ClickModify --> WaitSave[等待保存按钮可见]
WaitSave --> Expand[点击展开按钮]
Expand --> GetForm[获取表单容器]
GetForm --> LoopStart{遍历物料}
LoopStart --> GetSerial[定位序号]
GetSerial --> GetCode[获取材料编码]
GetCode --> GetName[获取材料名称]
GetName --> GetQty1[获取累计待发数量]
GetQty1 --> GetQty2[获取累计出库数量]
GetQty2 --> Match{物料编码<br/>在待删除列表中?}
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. **调试模式**: 支持在指定订单处暂停调试

7
interfaces/__init__.py Normal file
View File

@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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

5
models/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""数据模型模块 - 定义数据结构"""
from models.material_info import MaterialInfo
__all__ = ["MaterialInfo"]

19
models/material_info.py Normal file
View File

@@ -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 # 是否需要删除

7
services/__init__.py Normal file
View File

@@ -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"]

View File

@@ -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

View File

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

171
services/page_navigator.py Normal file
View File

@@ -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"警告: 备料计划页面加载超时")

View File

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