feat(cleaner): implement concurrent processing for material deletion

- Add `concurrency` option to `CleanerInput` and validate via schema
- Refactor `CleanerService` to extract `dispatchOrder` and `processPopupPage`
- Implement producer-consumer pattern in `CleanerService.clean()` to support processing multiple ERP popup windows simultaneously
- Add concurrency selection to UI in `CleanerPage.tsx` Execution Settings (persisted via `sessionStorage` in `useCleaner.ts`)
- Maintain existing order processing progress reporting calculation

Co-authored-by: luwamgere15-crypto <255338376+luwamgere15-crypto@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot]
2026-03-11 09:28:13 +00:00
parent a25ffd75c5
commit 9ee9c7281c
10 changed files with 373 additions and 117 deletions

View File

@@ -25,6 +25,13 @@ export interface ShouldDeleteParams {
deleteSet: Set<string>
}
interface PopupTask {
orderNumber: string
orderIndex: number
popupPage: Page
detailFrame: FrameLocator
}
/**
* ERP Cleaner Service
* Deletes specified materials from production orders in ERP system
@@ -107,11 +114,13 @@ export class CleanerService {
const totalOrders = input.orderNumbers.length
const dryRun = input.dryRun ?? this.dryRun
const concurrency = Math.min(Math.max(input.concurrency ?? 1, 1), 20)
log.info('Starting cleaner', {
totalOrders,
materialCount: input.materialCodes.length,
dryRun
dryRun,
concurrency
})
// Create delete set for O(1) lookup
@@ -126,45 +135,106 @@ export class CleanerService {
// Setup query interface
await this.setupQueryInterface(workFrame)
// Process each order
for (let i = 0; i < totalOrders; i++) {
const orderNumber = input.orderNumbers[i]
// Task Queue Setup
const taskQueue: PopupTask[] = []
let notEmptyResolver: (() => void) | null = null
let producerFinished = false
try {
log.debug('Processing order', { orderNumber, index: i + 1, total: totalOrders })
const detail = await this.processOrder({
workFrame,
popupPage,
orderNumber,
orderIndex: i,
totalOrders,
deleteSet,
dryRun: input.dryRun ?? this.dryRun,
onProgress: input.onProgress
})
result.details.push(detail)
result.ordersProcessed++
result.materialsDeleted += detail.materialsDeleted
result.materialsSkipped += detail.materialsSkipped
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Order processing failed', { orderNumber, error: message })
result.errors.push(`Order ${orderNumber}: ${message}`)
// Add error detail
result.details.push({
orderNumber,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [message],
skippedMaterials: []
})
const notifyTaskAdded = () => {
if (notEmptyResolver) {
const resolve = notEmptyResolver
notEmptyResolver = null
resolve()
}
}
// Close popup page
const waitForTask = async (): Promise<PopupTask | null> => {
while (taskQueue.length === 0 && !producerFinished) {
await new Promise<void>((resolve) => {
notEmptyResolver = resolve
})
}
return taskQueue.shift() ?? null
}
// Producer logic
const producerPromise = (async () => {
for (let i = 0; i < totalOrders; i++) {
const orderNumber = input.orderNumbers[i]
try {
log.debug('Dispatching order', { orderNumber, index: i + 1, total: totalOrders })
const task = await this.dispatchOrder({
workFrame,
popupPage,
orderNumber,
orderIndex: i
})
taskQueue.push(task)
notifyTaskAdded()
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Order dispatch failed', { orderNumber, error: message })
result.errors.push(`Order ${orderNumber} dispatch failed: ${message}`)
result.details.push({
orderNumber,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [message],
skippedMaterials: []
})
// Still increment ordersProcessed for failures so they count towards progress
result.ordersProcessed++
}
}
producerFinished = true
notifyTaskAdded() // Wake up workers so they can exit if queue is empty
})()
// Consumer worker logic
const workerPromises = Array.from({ length: concurrency }).map(async (_, workerId) => {
while (true) {
const task = await waitForTask()
if (!task) break // Queue is empty and producer finished
try {
log.debug(`Worker ${workerId} processing order`, { orderNumber: task.orderNumber })
const detail = await this.processPopupPage({
popupPage: task.popupPage,
detailFrame: task.detailFrame,
orderNumber: task.orderNumber,
orderIndex: task.orderIndex,
totalOrders,
deleteSet,
dryRun,
onProgress: input.onProgress
})
result.details.push(detail)
result.ordersProcessed++
result.materialsDeleted += detail.materialsDeleted
result.materialsSkipped += detail.materialsSkipped
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error(`Worker ${workerId} order processing failed`, { orderNumber: task.orderNumber, error: message })
result.errors.push(`Order ${task.orderNumber}: ${message}`)
result.details.push({
orderNumber: task.orderNumber,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [message],
skippedMaterials: []
})
result.ordersProcessed++
}
}
})
// Wait for everything to finish
await Promise.all([producerPromise, ...workerPromises])
// Close main popup page
await popupPage.close()
log.info('Cleaner completed', {
ordersProcessed: result.ordersProcessed,
materialsDeleted: result.materialsDeleted,
@@ -232,14 +302,64 @@ export class CleanerService {
}
/**
* Process a single order
* Reference: Python process_order() lines 171-443
* Dispatch an order - search and open the popup page
*/
private async processOrder(params: {
private async dispatchOrder(params: {
workFrame: FrameLocator
popupPage: Page
orderNumber: string
orderIndex: number
}): Promise<PopupTask> {
const { workFrame, popupPage, orderNumber, orderIndex } = params
// Query the order
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
await textbox.fill(orderNumber)
await workFrame.locator('.search-component-searchBtn').click()
// Wait for loading
await this.waitForLoading(workFrame)
// Click "更多" to open menu
await workFrame.locator('#hot-key-head_list').getByText('更多').click()
// Click "备料计划" and expect popup
const detailPagePromise = popupPage.waitForEvent('popup')
await workFrame.getByText('备料计划').click()
const detailPage = await detailPagePromise
// Navigate nested frames in detail page
const detailMainFrame = detailPage.locator('#forwardFrame')
const dFrame = await detailMainFrame.contentFrame()
if (!dFrame) {
throw new Error('Failed to access detail page forward frame')
}
const detailInnerLocator = dFrame.locator('#mainiframe')
await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 })
const detailInnerFrame = await detailInnerLocator.contentFrame()
if (!detailInnerFrame) {
throw new Error('Failed to access detail inner frame')
}
return {
orderNumber,
orderIndex,
popupPage: detailPage,
detailFrame: detailInnerFrame
}
}
/**
* Process a popup page for an order
*/
private async processPopupPage(params: {
popupPage: Page
detailFrame: FrameLocator
orderNumber: string
orderIndex: number
totalOrders: number
deleteSet: Set<string>
dryRun: boolean
@@ -250,8 +370,8 @@ export class CleanerService {
) => void
}): Promise<OrderCleanDetail> {
const {
workFrame,
popupPage,
popupPage: detailPage,
detailFrame: detailInnerFrame,
orderNumber,
orderIndex,
totalOrders,
@@ -268,50 +388,18 @@ export class CleanerService {
skippedMaterials: []
}
// Query the order (Python lines 187-189)
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
await textbox.fill(orderNumber)
await workFrame.locator('.search-component-searchBtn').click()
// Wait for loading (Python lines 192-197)
await this.waitForLoading(workFrame)
// Click "更多" to open menu (Python line 200)
await workFrame.locator('#hot-key-head_list').getByText('更多').click()
// Click "备料计划" and expect popup (Python lines 201-203)
const detailPagePromise = popupPage.waitForEvent('popup')
await workFrame.getByText('备料计划').click()
const detailPage = await detailPagePromise
try {
// Navigate nested frames in detail page (Python lines 206-207)
const detailMainFrame = detailPage.locator('#forwardFrame')
const dFrame = await detailMainFrame.contentFrame()
if (!dFrame) {
throw new Error('Failed to access detail page forward frame')
}
const detailInnerLocator = dFrame.locator('#mainiframe')
await detailInnerLocator.waitFor({ state: 'visible', timeout: 30000 })
const detailInnerFrame = await detailInnerLocator.contentFrame()
if (!detailInnerFrame) {
throw new Error('Failed to access detail inner frame')
}
// Wait for plan code (Python lines 210-213)
// Wait for plan code
await detailInnerFrame
.getByText(/^离散备料计划维护:/)
.waitFor({ state: 'visible', timeout: 30000 })
// Extract detail count (Python lines 215-218)
// Extract detail count
const detailCountText = await detailInnerFrame.getByText(/^详细信息 \(\d+\)$/).innerText()
const detailCountMatch = detailCountText.match(/\((\d+)\)/)
const detailCount = detailCountMatch ? parseInt(detailCountMatch[1], 10) : 0
// Extract status (Python lines 220-225)
// Extract status
const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText()
const statusMatch = statusText.replace(/\n/g, '').match(/备料状态:(.+)$/)
const detailStatus = statusMatch ? statusMatch[1].trim() : ''
@@ -329,19 +417,19 @@ export class CleanerService {
}
)
// Process based on status (Python lines 228-441)
// Process based on status
if (detailStatus === '审批通过' && detailCount > 0) {
// Click modify button (Python line 235)
// Click modify button
await detailInnerFrame.getByRole('button', { name: '修改' }).click()
// Wait for save button (Python lines 238-242)
// Wait for save button
const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' })
await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 })
// Expand the form (Python line 245)
// Expand the form
await detailInnerFrame.getByText('展开').first().click()
// Get form elements (Python lines 247-253)
// Get form elements
const childForm = detailInnerFrame.locator('.card-table-side-box')
const buttonWrapper = childForm.locator('.button-wrapper')
const deleteRowBtn = buttonWrapper.getByRole('button', { name: '删行' })
@@ -351,11 +439,11 @@ export class CleanerService {
let lastRowNumber = ''
let materialIdx = 0
// Process each material row (Python lines 257-423)
// Process each material row
while (true) {
materialIdx++
// Wait for row number to stabilize (Python lines 262-265)
// Wait for row number to stabilize
const currentRow = await this.getInputValue(childForm, /^行号$/)
const rowNumInt = parseInt(currentRow, 10)
@@ -363,13 +451,12 @@ export class CleanerService {
await this.delay(500)
}
// Get material data (Python lines 267-271)
// Get material data
const materialCode = await this.getInputValue(childForm, /^材料编码/)
const materialName = await this.getInputValue(childForm, /^材料名称/)
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
// Report progress using formula: (1 + i + j/Mᵢ) / (1 + N) × 100
// where i = orderIndex (0-based), j = materialIdx (1-based), Mᵢ = detailCount, N = totalOrders
const progress = ((1 + orderIndex + materialIdx / detailCount) / (1 + totalOrders)) * 100
onProgress?.(
@@ -384,7 +471,7 @@ export class CleanerService {
}
)
// Check if should delete (Python lines 284-406)
// Check if should delete
if (deleteSet.has(materialCode)) {
const shouldDelete = this.shouldDeleteMaterial({
rowNumber: rowNumInt,
@@ -394,11 +481,11 @@ export class CleanerService {
})
if (shouldDelete && !dryRun) {
// Delete the material (Python lines 302-340)
// Delete the material
const oldRowNumber = currentRow
await deleteRowBtn.click()
// Wait for row number to change (Python lines 306-324)
// Wait for row number to change
const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
if (deleteSuccess) {
@@ -422,7 +509,7 @@ export class CleanerService {
}
}
// Move to next row (Python lines 419-423)
// Move to next row
const isNextEnabled = await this.isButtonEnabled(nextBtn)
if (isNextEnabled) {
lastRowNumber = currentRow
@@ -432,17 +519,17 @@ export class CleanerService {
}
}
// Collapse form (Python line 424)
// Collapse form
await collapseBtn.click()
// Save changes (Python lines 427-435)
// Save changes
if (!dryRun && detail.materialsDeleted > 0) {
await saveButtonLocator.click()
await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 })
}
}
} finally {
// Close detail page (Python lines 442-443)
// Close detail page
await detailPage.close()
}