Compare commits
2 Commits
dev
...
02ae7369c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02ae7369c8 | ||
|
|
9ee9c7281c |
34
package-lock.json
generated
34
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
85
playwright-report/index.html
Normal file
85
playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface CleanerInput {
|
||||
materialCodes: string[]
|
||||
dryRun: boolean
|
||||
headless?: boolean
|
||||
concurrency?: number
|
||||
onProgress?: (message: string, progress?: number, extra?: Partial<CleanerProgress>) => void
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -32,7 +32,7 @@ const ExtractorPage: React.FC = () => {
|
||||
window.electron.validation.setSharedProductionIds(orderNumberList)
|
||||
} else {
|
||||
// Clear shared Production IDs when input is cleared
|
||||
window.electron.validation.clearSharedProductionIds()
|
||||
window.electron.validation.setSharedProductionIds([])
|
||||
}
|
||||
}, [orderNumbers])
|
||||
|
||||
|
||||
6
test-results/.last-run.json
Normal file
6
test-results/.last-run.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"c6b74b79254217b7c306-e293eb560bc105062fa9"
|
||||
]
|
||||
}
|
||||
52
tests/e2e/verify.test.ts
Normal file
52
tests/e2e/verify.test.ts
Normal 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()
|
||||
})
|
||||
Reference in New Issue
Block a user