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

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