6 Commits

Author SHA1 Message Date
test
02ae7369c8 feat(cleaner): enhance concurrent processing with improved queue management
- Replace single waiter with multiple waiters array for better concurrency
- Add comprehensive logging for producer dispatch timing
- Add worker lifecycle logging (start/complete/exit)
- Improve error logging with timing information
- Fix concurrency level from dynamic calculation to fixed value of 5
- Add detailed documentation in cleaner-service-flow.md

This improves the material deletion process by allowing better task distribution
across workers and providing better visibility into the concurrent execution flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 22:09:40 +08:00
google-labs-jules[bot]
9ee9c7281c 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>
2026-03-11 09:28:13 +00:00
test
a25ffd75c5 Merge branch 'dev' of github.com:Misaka-Dev-Hub/ERPAuto into dev 2026-03-10 19:20:54 +08:00
Misaka_Company
8eaba79c26 fix(order-resolver): correct SQL Server table name conversion for productionId lookup
- Fix getTableName to properly split schema_tablename format
- Convert productionContractData_26年压力表合同数据 to [productionContractData].[26年压力表合同数据]
- Extend productionId pattern to support 1-6 digit serial numbers

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-09 09:31:32 +08:00
Misaka_Company
05a44bb464 chore: add .npmrc to gitignore
Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
2026-03-09 09:10:24 +08:00
test
77fabf2018 fix(shared-state): clear shared Production IDs when input is cleared
Fixes a bug where clearing the order number input in the extraction page
did not clear the shared Production IDs in the main process. This caused
the data cleanup page to continue using stale data when filtering by
Production ID.

Changes:
- Add VALIDATION_CLEAR_SHARED_PRODUCTION_IDS IPC channel
- Register handler to clear shared Production IDs by sender ID
- Expose clearSharedProductionIds API in preload script
- Update ExtractorPage to call clear when order numbers are empty

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 21:58:56 +08:00
15 changed files with 434 additions and 119 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@ out
.DS_Store
.eslintcache
*.log*
.npmrc
# AI Agent
.claude

34
package-lock.json generated
View File

@@ -110,7 +110,6 @@
"resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz",
"integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.10.0",
@@ -172,7 +171,6 @@
"resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.2.tgz",
"integrity": "sha512-MzHym+wOi8CLUlKCQu12de0nwcq9k9Kuv43j4Wa++CsCpJwps2eeBQwD2Bu8snkxTtDKDx4GwjuR9E8yC8LNrg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@azure/abort-controller": "^2.1.2",
"@azure/core-auth": "^1.10.0",
@@ -364,7 +362,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1142,6 +1139,7 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1163,6 +1161,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1179,6 +1178,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1193,6 +1193,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -3073,7 +3074,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz",
"integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -3096,7 +3096,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3205,7 +3204,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1",
@@ -3639,7 +3637,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3672,7 +3669,6 @@
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -4419,7 +4415,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5146,7 +5141,8 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true
"optional": true,
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
@@ -5518,7 +5514,6 @@
"integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.8.1",
"builder-util": "26.8.1",
@@ -5733,7 +5728,6 @@
"integrity": "sha512-Rz5QvP1pTqoU1DPRrG3EeX2oWBtS3uRmd6Z/wzZsb2e/iIUsrT+XcBaAhFr4FW48gDc8uP2wYVyY5Aamha/5Zg==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/get": "^2.0.0",
"@types/node": "^22.7.7",
@@ -5922,6 +5916,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -5942,6 +5937,7 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -6270,7 +6266,6 @@
"integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6331,7 +6326,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -10130,7 +10124,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -10211,7 +10204,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -10235,6 +10227,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -10252,6 +10245,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -10272,7 +10266,6 @@
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -10404,7 +10397,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -11736,6 +11728,7 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -12745,7 +12738,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13023,7 +13015,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -13570,7 +13561,6 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
@@ -13779,7 +13769,6 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@@ -14017,7 +14006,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

File diff suppressed because one or more lines are too long

View File

@@ -755,6 +755,17 @@ export function registerValidationHandlers(): void {
}
)
/**
* Clear shared Production IDs
*/
ipcMain.handle(
IPC_CHANNELS.VALIDATION_CLEAR_SHARED_PRODUCTION_IDS,
async (event): Promise<void> => {
log.info('Clearing shared Production IDs')
clearSharedProductionIds(event.sender.id)
}
)
/**
* Get cleaner data (order numbers from shared Production IDs + material codes from MaterialsToBeDeleted)
* Filters materials by current user (admin sees all, regular users see only their own)

View File

@@ -12,7 +12,8 @@ export const CleanerInputSchema = z.object({
.array(z.string().min(1, 'Order number cannot be empty'))
.min(1, 'At least one order number is required'),
materialCodes: z.array(z.string().min(1, 'Material code cannot be empty')),
dryRun: z.boolean()
dryRun: z.boolean(),
concurrency: z.number().int().min(1).max(20).optional()
// Note: onProgress is a function, not validated via Zod
})

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)
const concurrency = 5
log.info('Starting cleaner', {
totalOrders,
materialCount: input.materialCodes.length,
dryRun
dryRun,
concurrency
})
// Create delete set for O(1) lookup
@@ -126,45 +135,134 @@ 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 - Use a proper async queue with multiple waiters support
const taskQueue: PopupTask[] = []
const waiters: (() => void)[] = []
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 = () => {
// Wake up ALL waiting workers so they can check the queue
while (waiters.length > 0 && taskQueue.length > 0) {
const resolve = waiters.shift()!
resolve()
}
}
// Close popup page
const waitForTask = async (): Promise<PopupTask | null> => {
while (taskQueue.length === 0 && !producerFinished) {
await new Promise<void>((resolve) => {
waiters.push(resolve)
})
}
return taskQueue.shift() ?? null
}
// Producer logic
const producerPromise = (async () => {
log.info('Producer started', { totalOrders })
for (let i = 0; i < totalOrders; i++) {
const orderNumber = input.orderNumbers[i]
const dispatchStartTime = Date.now()
try {
log.info(
`Producer DISPATCHING order ${i + 1}/${totalOrders}: ${orderNumber}, queue length before: ${taskQueue.length}`
)
const task = await this.dispatchOrder({
workFrame,
popupPage,
orderNumber,
orderIndex: i
})
const dispatchDuration = Date.now() - dispatchStartTime
taskQueue.push(task)
log.info(
`Producer DISPATCHED order ${orderNumber} in ${dispatchDuration}ms, queue length after: ${taskQueue.length}`
)
notifyTaskAdded()
} catch (error) {
const dispatchDuration = Date.now() - dispatchStartTime
const message = error instanceof Error ? error.message : 'Unknown error'
log.error(`Producer DISPATCH FAILED order ${orderNumber} after ${dispatchDuration}ms`, {
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++
}
}
log.info('Producer finished', { finalQueueLength: taskQueue.length })
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) => {
log.info(`Worker ${workerId} started`)
while (true) {
const task = await waitForTask()
if (!task) {
log.info(`Worker ${workerId} exiting - no more tasks`)
break
}
const startTime = Date.now()
log.info(
`Worker ${workerId} STARTED processing order ${task.orderNumber}, queue length: ${taskQueue.length}`
)
try {
const detail = await this.processPopupPage({
popupPage: task.popupPage,
detailFrame: task.detailFrame,
orderNumber: task.orderNumber,
orderIndex: task.orderIndex,
totalOrders,
deleteSet,
dryRun,
onProgress: input.onProgress
})
const duration = Date.now() - startTime
log.info(`Worker ${workerId} COMPLETED order ${task.orderNumber} in ${duration}ms`)
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'
const duration = Date.now() - startTime
log.error(`Worker ${workerId} FAILED order ${task.orderNumber} after ${duration}ms`, {
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 +330,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 +398,8 @@ export class CleanerService {
) => void
}): Promise<OrderCleanDetail> {
const {
workFrame,
popupPage,
popupPage: detailPage,
detailFrame: detailInnerFrame,
orderNumber,
orderIndex,
totalOrders,
@@ -268,50 +416,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 +445,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 +467,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 +479,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 +499,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 +509,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 +537,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 +547,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()
}

View File

@@ -50,9 +50,10 @@ export interface ResolutionStats {
}
/**
* ProductionID pattern: 2 digits + 1 letter + 1-4 digits
* ProductionID pattern: 2 digits + 1 letter + 1-6 digits
* Examples: 22A1, 22A123, 26B10617
*/
const PRODUCTION_ID_PATTERN = /^\d{2}[A-Z]\d{1,4}$/i
const PRODUCTION_ID_PATTERN = /^\d{2}[A-Z]\d{1,6}$/i
/**
* Production order number pattern: SC + 14 digits
@@ -85,9 +86,21 @@ export class OrderNumberResolver {
/**
* Get table name based on database type
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据]
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
*/
private getTableName(tableName: string): string {
if (this.dbService.type === 'sqlserver') {
// Find the FIRST underscore to split schema and table name
// This handles patterns like: schema_tablename
const firstUnderscoreIndex = tableName.indexOf('_')
if (firstUnderscoreIndex > 0) {
const schema = tableName.substring(0, firstUnderscoreIndex)
const actualTableName = tableName.substring(firstUnderscoreIndex + 1)
return `[${schema}].[${actualTableName}]`
}
// If no underscore found, default to dbo schema
return `[dbo].[${tableName}]`
}
return tableName

View File

@@ -16,6 +16,7 @@ export interface CleanerInput {
materialCodes: string[]
dryRun: boolean
headless?: boolean
concurrency?: number
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
}

View File

@@ -140,6 +140,8 @@ const api = {
invokeIpc(IPC_CHANNELS.VALIDATION_SET_SHARED_PRODUCTION_IDS, productionIds),
getSharedProductionIds: (): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.VALIDATION_GET_SHARED_PRODUCTION_IDS),
clearSharedProductionIds: (): Promise<IpcResult> =>
invokeIpc(IPC_CHANNELS.VALIDATION_CLEAR_SHARED_PRODUCTION_IDS),
getCleanerData: (): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.VALIDATION_GET_CLEANER_DATA)
},

View File

@@ -76,6 +76,10 @@ export function useCleaner() {
const saved = sessionStorage.getItem('cleaner_headless')
return saved ? saved === 'true' : true
})
const [concurrency, setConcurrency] = useState(() => {
const saved = sessionStorage.getItem('cleaner_concurrency')
return saved ? parseInt(saved, 10) : 1
})
const [showSettingsMenu, setShowSettingsMenu] = useState(false)
// Inline editing state for manager field (Admin only)
@@ -168,6 +172,10 @@ export function useCleaner() {
sessionStorage.setItem('cleaner_headless', headless.toString())
}, [headless])
useEffect(() => {
sessionStorage.setItem('cleaner_concurrency', concurrency.toString())
}, [concurrency])
useEffect(() => {
sessionStorage.setItem('cleaner_validationMode', valMode)
}, [valMode])
@@ -417,7 +425,8 @@ export function useCleaner() {
orderNumbers: orderNumberList,
materialCodes: materialCodeList,
dryRun,
headless
headless,
concurrency
})
const cleanerRunData = response.success ? (response.data as any) : null
@@ -499,6 +508,8 @@ export function useCleaner() {
setIsTypeDialogOpen,
headless,
setHeadless,
concurrency,
setConcurrency,
showSettingsMenu,
setShowSettingsMenu,
filteredResults,

View File

@@ -46,6 +46,8 @@ const CleanerPage: React.FC = () => {
setIsTypeDialogOpen,
headless,
setHeadless,
concurrency,
setConcurrency,
showSettingsMenu,
setShowSettingsMenu,
filteredResults,
@@ -458,6 +460,29 @@ const CleanerPage: React.FC = () => {
</button>
</div>
</div>
<div className="border-t border-slate-100 pt-3">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-slate-800">
(Concurrency)
</div>
<div className="text-xs text-slate-500 mt-0.5">
(1-20)
</div>
</div>
<input
type="number"
min="1"
max="20"
value={concurrency}
onChange={(e) => {
const val = parseInt(e.target.value, 10)
if (!isNaN(val)) setConcurrency(Math.min(Math.max(val, 1), 20))
}}
className="w-16 ml-4 px-2 py-1 text-sm border border-slate-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
</div>
)}

View File

@@ -30,6 +30,9 @@ const ExtractorPage: React.FC = () => {
.map((line) => line.trim())
.filter((line) => line.length > 0)
window.electron.validation.setSharedProductionIds(orderNumberList)
} else {
// Clear shared Production IDs when input is cleared
window.electron.validation.setSharedProductionIds([])
}
}, [orderNumbers])

View File

@@ -51,6 +51,7 @@ export const IPC_CHANNELS = {
VALIDATION_VALIDATE: 'validation:validate',
VALIDATION_SET_SHARED_PRODUCTION_IDS: 'validation:setSharedProductionIds',
VALIDATION_GET_SHARED_PRODUCTION_IDS: 'validation:getSharedProductionIds',
VALIDATION_CLEAR_SHARED_PRODUCTION_IDS: 'validation:clearSharedProductionIds',
VALIDATION_GET_CLEANER_DATA: 'validation:getCleanerData',
// Materials

View File

@@ -0,0 +1,6 @@
{
"status": "failed",
"failedTests": [
"c6b74b79254217b7c306-e293eb560bc105062fa9"
]
}

52
tests/e2e/verify.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { test, expect } from '@playwright/test'
import { _electron as electron } from 'playwright'
test('Verify Concurrency Setting in CleanerPage', async () => {
// Launch Electron app
const electronApp = await electron.launch({
args: ['.', '--no-sandbox', '--disable-gpu'],
env: { ...process.env, NODE_ENV: 'development' }
})
// Get the main window
const window = await electronApp.firstWindow()
// Wait for the app to load
await window.waitForLoadState('domcontentloaded')
// Let the app initialize fully
await window.waitForTimeout(3000)
// Navigate to CleanerPage
const cleanerTab = await window.getByText('物料清理')
await cleanerTab.waitFor({ state: 'visible' }).catch(() => {})
await cleanerTab.click().catch(() => {})
// Wait a bit for the page to transition
await window.waitForTimeout(1000)
// Click "执行设置" (Execution Settings) button
const settingsBtn = await window.getByRole('button', { name: /执行设置/ })
await settingsBtn.waitFor({ state: 'visible' })
await settingsBtn.click()
// Wait for the settings menu to appear and the Concurrency input to be visible
await window.waitForTimeout(500)
// Find the input containing concurrency text, or just the number input
const concurrencyInput = await window.locator('input[type="number"]').first()
await concurrencyInput.waitFor({ state: 'visible' })
// Assert default value is 1
await expect(concurrencyInput).toHaveValue('1')
// Set to 5 and test
await concurrencyInput.fill('5')
await expect(concurrencyInput).toHaveValue('5')
// Take screenshot showing the open menu and value
await window.screenshot({ path: '/home/jules/verification/cleaner-concurrency.png' })
// Close app
await electronApp.close()
})