2 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
10 changed files with 402 additions and 118 deletions

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

File diff suppressed because one or more lines are too long

View File

@@ -12,7 +12,8 @@ export const CleanerInputSchema = z.object({
.array(z.string().min(1, 'Order number cannot be empty')) .array(z.string().min(1, 'Order number cannot be empty'))
.min(1, 'At least one order number is required'), .min(1, 'At least one order number is required'),
materialCodes: z.array(z.string().min(1, 'Material code cannot be empty')), 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 // Note: onProgress is a function, not validated via Zod
}) })

View File

@@ -25,6 +25,13 @@ export interface ShouldDeleteParams {
deleteSet: Set<string> deleteSet: Set<string>
} }
interface PopupTask {
orderNumber: string
orderIndex: number
popupPage: Page
detailFrame: FrameLocator
}
/** /**
* ERP Cleaner Service * ERP Cleaner Service
* Deletes specified materials from production orders in ERP system * Deletes specified materials from production orders in ERP system
@@ -107,11 +114,13 @@ export class CleanerService {
const totalOrders = input.orderNumbers.length const totalOrders = input.orderNumbers.length
const dryRun = input.dryRun ?? this.dryRun const dryRun = input.dryRun ?? this.dryRun
//const concurrency = Math.min(Math.max(input.concurrency ?? 1, 1), 20)
const concurrency = 5
log.info('Starting cleaner', { log.info('Starting cleaner', {
totalOrders, totalOrders,
materialCount: input.materialCodes.length, materialCount: input.materialCodes.length,
dryRun dryRun,
concurrency
}) })
// Create delete set for O(1) lookup // Create delete set for O(1) lookup
@@ -126,45 +135,134 @@ export class CleanerService {
// Setup query interface // Setup query interface
await this.setupQueryInterface(workFrame) await this.setupQueryInterface(workFrame)
// Process each order // Task Queue Setup - Use a proper async queue with multiple waiters support
for (let i = 0; i < totalOrders; i++) { const taskQueue: PopupTask[] = []
const orderNumber = input.orderNumbers[i] const waiters: (() => void)[] = []
let producerFinished = false
try { const notifyTaskAdded = () => {
log.debug('Processing order', { orderNumber, index: i + 1, total: totalOrders }) // Wake up ALL waiting workers so they can check the queue
const detail = await this.processOrder({ while (waiters.length > 0 && taskQueue.length > 0) {
workFrame, const resolve = waiters.shift()!
popupPage, resolve()
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: []
})
} }
} }
// 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() await popupPage.close()
log.info('Cleaner completed', { log.info('Cleaner completed', {
ordersProcessed: result.ordersProcessed, ordersProcessed: result.ordersProcessed,
materialsDeleted: result.materialsDeleted, materialsDeleted: result.materialsDeleted,
@@ -232,14 +330,64 @@ export class CleanerService {
} }
/** /**
* Process a single order * Dispatch an order - search and open the popup page
* Reference: Python process_order() lines 171-443
*/ */
private async processOrder(params: { private async dispatchOrder(params: {
workFrame: FrameLocator workFrame: FrameLocator
popupPage: Page popupPage: Page
orderNumber: string orderNumber: string
orderIndex: number 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 totalOrders: number
deleteSet: Set<string> deleteSet: Set<string>
dryRun: boolean dryRun: boolean
@@ -250,8 +398,8 @@ export class CleanerService {
) => void ) => void
}): Promise<OrderCleanDetail> { }): Promise<OrderCleanDetail> {
const { const {
workFrame, popupPage: detailPage,
popupPage, detailFrame: detailInnerFrame,
orderNumber, orderNumber,
orderIndex, orderIndex,
totalOrders, totalOrders,
@@ -268,50 +416,18 @@ export class CleanerService {
skippedMaterials: [] 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 { try {
// Navigate nested frames in detail page (Python lines 206-207) // Wait for plan code
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)
await detailInnerFrame await detailInnerFrame
.getByText(/^离散备料计划维护:/) .getByText(/^离散备料计划维护:/)
.waitFor({ state: 'visible', timeout: 30000 }) .waitFor({ state: 'visible', timeout: 30000 })
// Extract detail count (Python lines 215-218) // Extract detail count
const detailCountText = await detailInnerFrame.getByText(/^详细信息 \(\d+\)$/).innerText() const detailCountText = await detailInnerFrame.getByText(/^详细信息 \(\d+\)$/).innerText()
const detailCountMatch = detailCountText.match(/\((\d+)\)/) const detailCountMatch = detailCountText.match(/\((\d+)\)/)
const detailCount = detailCountMatch ? parseInt(detailCountMatch[1], 10) : 0 const detailCount = detailCountMatch ? parseInt(detailCountMatch[1], 10) : 0
// Extract status (Python lines 220-225) // Extract status
const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText() const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText()
const statusMatch = statusText.replace(/\n/g, '').match(/备料状态:(.+)$/) const statusMatch = statusText.replace(/\n/g, '').match(/备料状态:(.+)$/)
const detailStatus = statusMatch ? statusMatch[1].trim() : '' 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) { if (detailStatus === '审批通过' && detailCount > 0) {
// Click modify button (Python line 235) // Click modify button
await detailInnerFrame.getByRole('button', { name: '修改' }).click() await detailInnerFrame.getByRole('button', { name: '修改' }).click()
// Wait for save button (Python lines 238-242) // Wait for save button
const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' }) const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' })
await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 }) await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 })
// Expand the form (Python line 245) // Expand the form
await detailInnerFrame.getByText('展开').first().click() await detailInnerFrame.getByText('展开').first().click()
// Get form elements (Python lines 247-253) // Get form elements
const childForm = detailInnerFrame.locator('.card-table-side-box') const childForm = detailInnerFrame.locator('.card-table-side-box')
const buttonWrapper = childForm.locator('.button-wrapper') const buttonWrapper = childForm.locator('.button-wrapper')
const deleteRowBtn = buttonWrapper.getByRole('button', { name: '删行' }) const deleteRowBtn = buttonWrapper.getByRole('button', { name: '删行' })
@@ -351,11 +467,11 @@ export class CleanerService {
let lastRowNumber = '' let lastRowNumber = ''
let materialIdx = 0 let materialIdx = 0
// Process each material row (Python lines 257-423) // Process each material row
while (true) { while (true) {
materialIdx++ materialIdx++
// Wait for row number to stabilize (Python lines 262-265) // Wait for row number to stabilize
const currentRow = await this.getInputValue(childForm, /^行号$/) const currentRow = await this.getInputValue(childForm, /^行号$/)
const rowNumInt = parseInt(currentRow, 10) const rowNumInt = parseInt(currentRow, 10)
@@ -363,13 +479,12 @@ export class CleanerService {
await this.delay(500) await this.delay(500)
} }
// Get material data (Python lines 267-271) // Get material data
const materialCode = await this.getInputValue(childForm, /^材料编码/) const materialCode = await this.getInputValue(childForm, /^材料编码/)
const materialName = await this.getInputValue(childForm, /^材料名称/) const materialName = await this.getInputValue(childForm, /^材料名称/)
const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/) const pendingQty = await this.getInputValue(childForm, /^累计待发数量$/)
// Report progress using formula: (1 + i + j/Mᵢ) / (1 + N) × 100 // 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 const progress = ((1 + orderIndex + materialIdx / detailCount) / (1 + totalOrders)) * 100
onProgress?.( onProgress?.(
@@ -384,7 +499,7 @@ export class CleanerService {
} }
) )
// Check if should delete (Python lines 284-406) // Check if should delete
if (deleteSet.has(materialCode)) { if (deleteSet.has(materialCode)) {
const shouldDelete = this.shouldDeleteMaterial({ const shouldDelete = this.shouldDeleteMaterial({
rowNumber: rowNumInt, rowNumber: rowNumInt,
@@ -394,11 +509,11 @@ export class CleanerService {
}) })
if (shouldDelete && !dryRun) { if (shouldDelete && !dryRun) {
// Delete the material (Python lines 302-340) // Delete the material
const oldRowNumber = currentRow const oldRowNumber = currentRow
await deleteRowBtn.click() 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) const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
if (deleteSuccess) { 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) const isNextEnabled = await this.isButtonEnabled(nextBtn)
if (isNextEnabled) { if (isNextEnabled) {
lastRowNumber = currentRow lastRowNumber = currentRow
@@ -432,17 +547,17 @@ export class CleanerService {
} }
} }
// Collapse form (Python line 424) // Collapse form
await collapseBtn.click() await collapseBtn.click()
// Save changes (Python lines 427-435) // Save changes
if (!dryRun && detail.materialsDeleted > 0) { if (!dryRun && detail.materialsDeleted > 0) {
await saveButtonLocator.click() await saveButtonLocator.click()
await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 }) await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 })
} }
} }
} finally { } finally {
// Close detail page (Python lines 442-443) // Close detail page
await detailPage.close() await detailPage.close()
} }

View File

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

View File

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

View File

@@ -46,6 +46,8 @@ const CleanerPage: React.FC = () => {
setIsTypeDialogOpen, setIsTypeDialogOpen,
headless, headless,
setHeadless, setHeadless,
concurrency,
setConcurrency,
showSettingsMenu, showSettingsMenu,
setShowSettingsMenu, setShowSettingsMenu,
filteredResults, filteredResults,
@@ -458,6 +460,29 @@ const CleanerPage: React.FC = () => {
</button> </button>
</div> </div>
</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>
</div> </div>
)} )}

View File

@@ -32,7 +32,7 @@ const ExtractorPage: React.FC = () => {
window.electron.validation.setSharedProductionIds(orderNumberList) window.electron.validation.setSharedProductionIds(orderNumberList)
} else { } else {
// Clear shared Production IDs when input is cleared // Clear shared Production IDs when input is cleared
window.electron.validation.clearSharedProductionIds() window.electron.validation.setSharedProductionIds([])
} }
}, [orderNumbers]) }, [orderNumbers])

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