Merge branch 'dev'

This commit is contained in:
test
2026-03-17 22:03:01 +08:00
24 changed files with 3972 additions and 234 deletions

View File

@@ -7,6 +7,7 @@ import { SqlServerService } from '../services/database/sql-server'
import { ConfigManager } from '../services/config/config-manager'
import { ResultExporter } from '../services/excel/result-exporter'
import { CleanerReportGenerator } from '../services/report/cleaner-report-generator'
import { RustfsService } from '../services/rustfs'
import { SessionManager } from '../services/user/session-manager'
import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger'
@@ -210,7 +211,11 @@ export function registerCleanerHandlers(): void {
}
}
log.info('Starting cleaning', { orderCount: validOrderNumbers.length })
log.info('Starting cleaning', {
orderCount: validOrderNumbers.length,
queryBatchSize: input.queryBatchSize ?? 100,
processConcurrency: input.processConcurrency ?? 1
})
const result = await cleaner.clean(modifiedInput)
if (warnings.length > 0) {
@@ -249,6 +254,8 @@ export function registerCleanerHandlers(): void {
metadata: {
orderCount: validOrderNumbers.length,
dryRun: input.dryRun ?? false,
queryBatchSize: input.queryBatchSize ?? 100,
processConcurrency: input.processConcurrency ?? 1,
materialsDeleted: result.materialsDeleted,
materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length
@@ -256,7 +263,7 @@ export function registerCleanerHandlers(): void {
}).catch((err) => log.warn('Failed to write audit log', { err }))
}
// Generate report (silent, user unaware)
// Generate report and upload to RustFS (silent, user unaware)
try {
const endTime = Date.now()
const currentUser = SessionManager.getInstance().getUserInfo()
@@ -270,6 +277,47 @@ export function registerCleanerHandlers(): void {
endTime
})
log.info('Report generated', { path: reportPath })
// Upload to RustFS if enabled
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
if (config.rustfs?.enabled && config.rustfs.endpoint) {
try {
const rustfs = new RustfsService({ config: config.rustfs })
const reportFileName = reportPath.split(/[\\/]/).pop() || 'report.md'
const storageKey = rustfs.generateReportKey(reportFileName, username)
log.info('Uploading report to RustFS', {
localPath: reportPath,
storageKey
})
const uploadResult = await rustfs.uploadFile(
reportPath,
storageKey,
'text/markdown; charset=utf-8'
)
if (uploadResult.success) {
log.info('Report uploaded to RustFS successfully', {
key: storageKey,
etag: uploadResult.etag
})
} else {
log.warn('Failed to upload report to RustFS', {
error: uploadResult.error,
key: storageKey
})
}
} catch (rustfsError) {
log.error('RustFS upload failed', {
error: rustfsError instanceof Error ? rustfsError.message : String(rustfsError)
})
}
} else {
log.debug('RustFS is not enabled, skipping upload')
}
} catch (reportError) {
log.warn('Failed to generate report', {
error: reportError instanceof Error ? reportError.message : String(reportError)

View File

@@ -10,6 +10,7 @@ import type { UserType, ConnectionTestResult, SaveSettingsResult } from '../type
import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors'
import { withErrorHandling, type IpcResult } from './index'
import type { CleanerConfig } from '../types/config.schema'
const log = createLogger('SettingsHandler')
@@ -174,4 +175,26 @@ export function registerSettingsHandlers(): void {
}, 'settings:testDbConnection')
}
)
ipcMain.handle(IPC_CHANNELS.CONFIG_GET_CLEANER, async (): Promise<IpcResult<CleanerConfig>> => {
return withErrorHandling(async () => {
const configManager = ConfigManager.getInstance()
const config = configManager.getConfig()
return config.cleaner
}, 'config:getCleaner')
})
ipcMain.handle(
IPC_CHANNELS.CONFIG_UPDATE_CLEANER,
async (_event, updates: Partial<CleanerConfig>): Promise<IpcResult<CleanerConfig>> => {
return withErrorHandling(async () => {
const configManager = ConfigManager.getInstance()
const result = await configManager.updateConfig({ cleaner: updates as CleanerConfig })
if (!result.success) {
throw new Error(result.error)
}
return configManager.getConfig().cleaner
}, 'config:updateCleaner')
}
)
}

View File

@@ -12,7 +12,9 @@ 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(),
queryBatchSize: z.number().int().min(1).max(100).optional().default(100),
processConcurrency: z.number().int().min(1).max(20).optional().default(1)
// Note: onProgress is a function, not validated via Zod
})

View File

@@ -81,6 +81,10 @@ const DEFAULT_CONFIG: FullConfig = {
enableCrud: false,
defaultManager: ''
},
cleaner: {
queryBatchSize: 100,
processConcurrency: 1
},
orderResolution: {
tableName: '',
productionIdField: '',
@@ -90,6 +94,14 @@ const DEFAULT_CONFIG: FullConfig = {
level: 'info',
auditRetention: 30,
appRetention: 14
},
rustfs: {
enabled: false,
endpoint: '',
accessKey: '',
secretKey: '',
bucket: 'erpauto',
region: 'us-east-1'
}
}

View File

@@ -7,12 +7,48 @@ import { createLogger } from '../logger'
const log = createLogger('CleanerService')
const DEFAULT_QUERY_BATCH_SIZE = 100
const MAX_QUERY_BATCH_SIZE = 100
const DEFAULT_PROCESS_CONCURRENCY = 1
const MAX_PROCESS_CONCURRENCY = 20
interface RetryResult {
retriedOrders: number
successfulRetries: number
updatedDetails: OrderCleanDetail[]
}
interface ProgressState {
completedOrders: number
totalOrders: number
}
interface QueryResultRow {
rowIndex: number
orderNumber: string
}
class AsyncMutex {
private queue: Promise<void> = Promise.resolve()
async runExclusive<T>(task: () => Promise<T>): Promise<T> {
let release!: () => void
const next = new Promise<void>((resolve) => {
release = resolve
})
const previous = this.queue
this.queue = this.queue.then(() => next)
await previous
try {
return await task()
} finally {
release()
}
}
}
/**
* Cleaner Service Options
*/
@@ -31,11 +67,58 @@ export interface ShouldDeleteParams {
deleteSet: Set<string>
}
function clampNumber(
value: number | undefined,
fallback: number,
min: number,
max: number
): number {
if (!Number.isFinite(value)) {
return fallback
}
return Math.min(max, Math.max(min, Math.trunc(value ?? fallback)))
}
export function createBatches<T>(items: T[], batchSize: number): T[][] {
const batches: T[][] = []
for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize))
}
return batches
}
export function getMissingOrders(inputOrders: string[], processedOrders: Set<string>): string[] {
const uniqueInputOrders = Array.from(new Set(inputOrders))
return uniqueInputOrders.filter((order) => !processedOrders.has(order))
}
export async function runWithConcurrency<T, R>(
items: T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length)
const limit = Math.max(1, Math.trunc(concurrency))
let cursor = 0
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (true) {
const current = cursor
cursor += 1
if (current >= items.length) {
return
}
results[current] = await worker(items[current], current)
}
})
await Promise.all(runners)
return results
}
/**
* ERP Cleaner Service
* Deletes specified materials from production orders in ERP system
*
* Reference: playwrite/utils/discrete_material_plan_cleaner.py
*/
export class CleanerService {
private authService: ErpAuthService
@@ -55,27 +138,18 @@ export class CleanerService {
/**
* Determine if a material should be deleted
* Reference: Python lines 284-406
*
* Deletion conditions:
* 1. Material code must be in the delete set
* 2. Row number must NOT be in range 7000-7999
* 3. Pending quantity must be empty
*/
shouldDeleteMaterial(params: ShouldDeleteParams): boolean {
const { rowNumber, pendingQty, materialCode, deleteSet } = params
// Check if material is in delete list
if (!deleteSet.has(materialCode)) {
return false
}
// Check row number range (7000-7999 are protected)
if (rowNumber >= 7000 && rowNumber < 8000) {
return false
}
// Check pending quantity (must be empty)
if (pendingQty && pendingQty.trim() !== '') {
return false
}
@@ -98,10 +172,6 @@ export class CleanerService {
return '未知原因'
}
/**
* Execute the cleaning process
* Reference: Python clean() method lines 456-525
*/
async clean(input: CleanerInput): Promise<CleanerResult> {
const result: CleanerResult = {
ordersProcessed: 0,
@@ -115,131 +185,172 @@ export class CleanerService {
const totalOrders = input.orderNumbers.length
const dryRun = input.dryRun ?? this.dryRun
const queryBatchSize = clampNumber(
input.queryBatchSize,
DEFAULT_QUERY_BATCH_SIZE,
1,
MAX_QUERY_BATCH_SIZE
)
const processConcurrency = clampNumber(
input.processConcurrency,
DEFAULT_PROCESS_CONCURRENCY,
1,
MAX_PROCESS_CONCURRENCY
)
log.info('Starting cleaner', {
totalOrders,
materialCount: input.materialCodes.length,
dryRun
dryRun,
queryBatchSize,
processConcurrency
})
// Create delete set for O(1) lookup
const deleteSet = new Set(input.materialCodes)
let popupPage: Page | null = null
try {
const session = this.authService.getSession()
const navigation = await this.navigateToCleanerPage(session)
popupPage = navigation.popupPage
const { workFrame } = navigation
// Navigate to cleaner page
const { popupPage, workFrame } = await this.navigateToCleanerPage(session)
// Setup query interface
await this.setupQueryInterface(workFrame)
// Process each order
for (let i = 0; i < totalOrders; i++) {
const orderNumber = input.orderNumbers[i]
const orderBatches = createBatches(input.orderNumbers, queryBatchSize)
const popupMutex = new AsyncMutex()
const progressState: ProgressState = {
completedOrders: 0,
totalOrders
}
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
for (let batchIndex = 0; batchIndex < orderBatches.length; batchIndex++) {
const batchOrders = orderBatches[batchIndex]
log.info('Processing cleaner batch', {
batchIndex: batchIndex + 1,
totalBatches: orderBatches.length,
batchSize: batchOrders.length
})
await this.queryOrders(workFrame, batchOrders)
await this.waitForLoading(workFrame)
const queriedRows = await this.collectQueryResultRows(workFrame)
const queriedOrderNumbersInBatch = new Set(queriedRows.map((row) => row.orderNumber))
await runWithConcurrency(queriedRows, processConcurrency, async (row) => {
const { rowIndex, orderNumber } = row
const openedDetailPage = await popupMutex.runExclusive(async () => {
return await this.openDetailPageFromRow(workFrame, popupPage!, rowIndex)
})
let detail: OrderCleanDetail
try {
detail = await this.processDetailPage({
detailPage: openedDetailPage,
deleteSet,
dryRun,
expectedOrderNumber: orderNumber,
progressState,
onProgress: input.onProgress
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
detail = this.createErrorDetail(orderNumber, message)
} finally {
progressState.completedOrders += 1
}
result.details.push(detail)
result.ordersProcessed++
if (detail.errors.length > 0) {
result.errors.push(`Order ${detail.orderNumber}: ${detail.errors.join('; ')}`)
return
}
result.ordersProcessed += 1
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: [],
retryCount: 0,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
})
const missingOrders = getMissingOrders(batchOrders, queriedOrderNumbersInBatch)
for (const missingOrder of missingOrders) {
const missingMessage = '订单未出现在查询结果中'
result.errors.push(`Order ${missingOrder}: ${missingMessage}`)
result.details.push(this.createErrorDetail(missingOrder, missingMessage))
}
}
// Close popup page
await popupPage.close()
log.info('Cleaner completed', {
ordersProcessed: result.ordersProcessed,
materialsDeleted: result.materialsDeleted,
materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length
})
// Retry failed orders
const retryResult = await this.retryFailedOrders({
workFrame,
popupPage,
failedDetails: result.details.filter((d) => d.errors.length > 0),
failedDetails: result.details.filter(
(d) => d.errors.length > 0 && this.isOrderNumber(d.orderNumber)
),
deleteSet,
dryRun,
onProgress: input.onProgress
})
// Merge retry results
result.retriedOrders = retryResult.retriedOrders
result.successfulRetries = retryResult.successfulRetries
// Update details with retry information
retryResult.updatedDetails.forEach((updatedDetail) => {
const index = result.details.findIndex((d) => d.orderNumber === updatedDetail.orderNumber)
if (index !== -1) {
const previousDetail = result.details[index]
if (updatedDetail.retrySuccess && previousDetail.errors.length > 0) {
result.ordersProcessed += 1
result.materialsDeleted += updatedDetail.materialsDeleted
result.materialsSkipped += updatedDetail.materialsSkipped
}
result.details[index] = updatedDetail
}
})
// Clear errors for successfully retried orders
const successfulRetryOrders = new Set(
retryResult.updatedDetails.filter((d) => d.retrySuccess).map((d) => d.orderNumber)
)
result.errors = result.errors.filter(
(err) => !successfulRetryOrders.has(err.split(':')[0].replace('Order ', ''))
)
log.info('Cleaner completed', {
ordersProcessed: result.ordersProcessed,
materialsDeleted: result.materialsDeleted,
materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.error('Cleaner failed', { error: message })
result.errors.push(`Clean failed: ${message}`)
} finally {
if (popupPage) {
try {
await popupPage.close()
} catch {
// Ignore close errors
}
}
}
return result
}
/**
* Navigate to the discrete production order maintenance page
* Reference: Python lines 476-486
*/
async navigateToCleanerPage(
session: ErpSession
): Promise<{ popupPage: Page; workFrame: FrameLocator }> {
const { page, mainFrame } = session
// Click menu icon (Python line 476)
await mainFrame.locator('i').first().click()
// Click discrete production order menu item and expect popup (Python lines 477-479)
const popupPromise = page.waitForEvent('popup')
await mainFrame.getByTitle('离散生产订单维护', { exact: true }).first().click()
const popupPage = await popupPromise
// Get nested frame structure (Python lines 482-486)
const forwardFrameLocator = popupPage.locator('#forwardFrame')
const fFrame = forwardFrameLocator.contentFrame()
@@ -247,91 +358,129 @@ export class CleanerService {
await innerFrameLocator.waitFor({ state: 'visible', timeout: 30000 })
const workFrame = innerFrameLocator.contentFrame()
// Wait for hot-key-head_list to be visible (Python line 484-486)
await workFrame.locator('#hot-key-head_list').waitFor({ state: 'visible', timeout: 30000 })
return { popupPage, workFrame }
}
/**
* Setup query interface
* Reference: Python setup_query_interface() lines 445-454
*/
private async setupQueryInterface(innerFrame: FrameLocator): Promise<void> {
// Click search icon (Python line 447)
await innerFrame.locator('.search-name-wrapper > .iconfont').click()
// Click "订单号查询" menu item (Python line 448)
await innerFrame.getByText('订单号查询').click()
// Click "全部" tab (Python line 449)
await innerFrame.getByRole('tab', { name: '全部' }).click()
// Set limit to 5000 (Python lines 451-454)
const inputEl = innerFrame.locator('#rc_select_0')
await inputEl.fill('5000')
await inputEl.press('Enter')
}
/**
* Process a single order
* Reference: Python process_order() lines 171-443
*/
private async processOrder(params: {
workFrame: FrameLocator
private async queryOrders(workFrame: FrameLocator, orderNumbers: string[]): Promise<void> {
const textbox = workFrame.getByRole('textbox', { name: '生产订单号' })
await textbox.fill(orderNumbers.join(','))
await workFrame.locator('.search-component-searchBtn').click()
}
private async collectQueryResultRows(workFrame: FrameLocator): Promise<QueryResultRow[]> {
const rows = workFrame.locator('tbody tr')
const rowCount = await rows.count()
const result: QueryResultRow[] = []
for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
const row = rows.nth(rowIndex)
const orderNumber = await this.extractOrderNumberFromQueryRow(row)
if (!this.isOrderNumber(orderNumber)) {
continue
}
result.push({ rowIndex, orderNumber })
}
return result
}
private async extractOrderNumberFromQueryRow(row: Locator): Promise<string> {
try {
const cell = row.locator('td[colkey="vbillcode"]')
const codeLink = cell.locator('.code-detail-link').first()
const rawValue = (await codeLink.count()) > 0 ? await codeLink.innerText() : await cell.innerText()
const value = rawValue.trim()
const match = value.match(/SC\d{14}/)
return match ? match[0] : value
} catch {
return ''
}
}
private async openDetailPageFromRow(
workFrame: FrameLocator,
popupPage: Page,
rowIndex: number
): Promise<Page> {
const row = workFrame.locator('tbody tr').nth(rowIndex)
await row.waitFor({ state: 'visible', timeout: 15000 })
const moreButton = row.locator('a.row-more').first()
await moreButton.scrollIntoViewIfNeeded()
const detailPagePromise = popupPage.waitForEvent('popup')
await moreButton.click()
await this.clickMaterialPlanMenu(workFrame)
return await detailPagePromise
}
private async openDetailPageFromCurrentQuery(
workFrame: FrameLocator,
popupPage: Page
orderNumber: string
orderIndex: number
totalOrders: number
): Promise<Page> {
const firstRow = workFrame.locator('tbody tr').first()
await firstRow.waitFor({ state: 'visible', timeout: 10000 })
const moreButton = firstRow.locator('a.row-more').first()
const detailPagePromise = popupPage.waitForEvent('popup')
await moreButton.click()
await this.clickMaterialPlanMenu(workFrame)
return await detailPagePromise
}
private async clickMaterialPlanMenu(workFrame: FrameLocator): Promise<void> {
const candidates = [
workFrame.locator('li:visible, a:visible, span:visible, div:visible').filter({
hasText: /^备料计划$/
}),
workFrame.getByRole('menuitem', { name: '备料计划' }),
workFrame.getByText('备料计划', { exact: true }),
workFrame.getByText('备料计划')
]
for (const candidate of candidates) {
const target = candidate.last()
try {
await target.waitFor({ state: 'visible', timeout: 2000 })
await target.click()
return
} catch {
// Try next locator candidate
}
}
throw new Error('无法定位“备料计划”菜单项(可能菜单结构已变化)')
}
private async processDetailPage(params: {
detailPage: Page
deleteSet: Set<string>
dryRun: boolean
progressState: ProgressState
expectedOrderNumber?: string
onProgress?: (
message: string,
progress?: number,
extra?: Partial<import('../../types/cleaner.types').CleanerProgress>
) => void
}): Promise<OrderCleanDetail> {
const {
workFrame,
popupPage,
orderNumber,
orderIndex,
totalOrders,
deleteSet,
dryRun,
onProgress
} = params
const detail: OrderCleanDetail = {
orderNumber,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [],
skippedMaterials: [],
retryCount: 0,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
}
// 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
const { detailPage, deleteSet, dryRun, progressState, expectedOrderNumber, onProgress } = params
try {
// Navigate nested frames in detail page (Python lines 206-207)
const detailMainFrame = detailPage.locator('#forwardFrame')
const dFrame = await detailMainFrame.contentFrame()
@@ -347,47 +496,58 @@ export class CleanerService {
throw new Error('Failed to access detail inner frame')
}
// Wait for plan code (Python lines 210-213)
await detailInnerFrame
.getByText(/^离散备料计划维护:/)
.waitFor({ state: 'visible', timeout: 30000 })
// Extract detail count (Python lines 215-218)
const sourceOrderNumber = await this.extractSourceOrderNumber(detailInnerFrame)
const orderNumber = sourceOrderNumber || expectedOrderNumber || 'UNKNOWN_ORDER'
const detail: OrderCleanDetail = {
orderNumber,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [],
skippedMaterials: [],
retryCount: 0,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
}
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)
const statusText = await detailInnerFrame.getByText(/^备料状态:.+$/).innerText()
const statusMatch = statusText.replace(/\n/g, '').match(/备料状态:(.+)$/)
const detailStatus = statusMatch ? statusMatch[1].trim() : ''
// Send progress for order start
onProgress?.(
`开始处理订单 ${orderIndex + 1}/${totalOrders}: ${orderNumber}`,
((1 + orderIndex) / (1 + totalOrders)) * 100,
`开始处理订单: ${orderNumber}`,
this.calculateProgress(
progressState.completedOrders,
0,
detailCount,
progressState.totalOrders
),
{
currentOrderIndex: orderIndex + 1,
totalOrders,
currentOrderIndex: progressState.completedOrders + 1,
totalOrders: progressState.totalOrders,
currentMaterialIndex: 0,
totalMaterialsInOrder: detailCount,
currentOrderNumber: orderNumber
}
)
// Process based on status (Python lines 228-441)
if (detailStatus === '审批通过' && detailCount > 0) {
// Click modify button (Python line 235)
await detailInnerFrame.getByRole('button', { name: '修改' }).click()
// Wait for save button (Python lines 238-242)
const saveButtonLocator = detailInnerFrame.getByRole('button', { name: '保存' })
await saveButtonLocator.waitFor({ state: 'visible', timeout: 30000 })
// Expand the form (Python line 245)
await detailInnerFrame.getByText('展开').first().click()
// Get form elements (Python lines 247-253)
const childForm = detailInnerFrame.locator('.card-table-side-box')
const buttonWrapper = childForm.locator('.button-wrapper')
const deleteRowBtn = buttonWrapper.getByRole('button', { name: '删行' })
@@ -397,11 +557,9 @@ export class CleanerService {
let lastRowNumber = ''
let materialIdx = 0
// Process each material row (Python lines 257-423)
while (true) {
materialIdx++
materialIdx += 1
// Wait for row number to stabilize (Python lines 262-265)
const currentRow = await this.getInputValue(childForm, /^行号$/)
const rowNumInt = parseInt(currentRow, 10)
@@ -409,28 +567,29 @@ export class CleanerService {
await this.delay(500)
}
// Get material data (Python lines 267-271)
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
const progress = this.calculateProgress(
progressState.completedOrders,
materialIdx,
detailCount,
progressState.totalOrders
)
onProgress?.(
`订单 ${orderIndex + 1}/${totalOrders} - 物料 ${materialIdx}/${detailCount}: ${materialName}`,
`订单 ${orderNumber} - 物料 ${materialIdx}/${detailCount}: ${materialName}`,
progress,
{
currentOrderIndex: orderIndex + 1,
totalOrders,
currentOrderIndex: progressState.completedOrders + 1,
totalOrders: progressState.totalOrders,
currentMaterialIndex: materialIdx,
totalMaterialsInOrder: detailCount,
currentOrderNumber: orderNumber
}
)
// Check if should delete (Python lines 284-406)
if (deleteSet.has(materialCode)) {
const shouldDelete = this.shouldDeleteMaterial({
rowNumber: rowNumInt,
@@ -440,19 +599,19 @@ export class CleanerService {
})
if (shouldDelete && !dryRun) {
// Delete the material (Python lines 302-340)
const oldRowNumber = currentRow
await deleteRowBtn.click()
// Wait for row number to change (Python lines 306-324)
const deleteSuccess = await this.waitForRowChange(childForm, oldRowNumber, 10000)
if (deleteSuccess) {
detail.materialsDeleted++
detail.materialsDeleted += 1
}
continue
} else if (!shouldDelete) {
detail.materialsSkipped++
}
if (!shouldDelete) {
detail.materialsSkipped += 1
const reason = this.getSkipReason({
rowNumber: rowNumInt,
pendingQty,
@@ -468,7 +627,6 @@ export class CleanerService {
}
}
// Move to next row (Python lines 419-423)
const isNextEnabled = await this.isButtonEnabled(nextBtn)
if (isNextEnabled) {
lastRowNumber = currentRow
@@ -478,27 +636,61 @@ export class CleanerService {
}
}
// Collapse form (Python line 424)
await collapseBtn.click()
// Save changes (Python lines 427-435)
if (!dryRun && detail.materialsDeleted > 0) {
await saveButtonLocator.click()
await saveButtonLocator.waitFor({ state: 'hidden', timeout: 60000 })
}
}
return detail
} finally {
// Close detail page (Python lines 442-443)
await detailPage.close()
}
return detail
}
/**
* Wait for loading overlay to disappear
* Reference: Python lines 192-197
*/
private calculateProgress(
completedOrders: number,
materialIdx: number,
detailCount: number,
totalOrders: number
): number {
const materialRatio = detailCount > 0 ? materialIdx / detailCount : 0
return ((1 + completedOrders + materialRatio) / (1 + totalOrders)) * 100
}
private async extractSourceOrderNumber(frame: FrameLocator): Promise<string> {
try {
const sourceOrder = await frame
.locator('.vsourcebillcode .code-detail-link')
.first()
.innerText()
const match = sourceOrder.match(/SC\d{14}/)
return match ? match[0] : sourceOrder.trim()
} catch {
return ''
}
}
private isOrderNumber(value: string): boolean {
return /^SC\d{14}$/.test(value)
}
private createErrorDetail(orderNumber: string, message: string): OrderCleanDetail {
return {
orderNumber,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [message],
skippedMaterials: [],
retryCount: 0,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
}
}
private async waitForLoading(frame: FrameLocator): Promise<void> {
const loadingLocator = frame
.locator('div')
@@ -513,10 +705,6 @@ export class CleanerService {
}
}
/**
* Get input value by label regex
* Reference: Python _get_input_value() lines 141-148
*/
private async getInputValue(
container: FrameLocator | Locator,
labelRegex: RegExp
@@ -533,10 +721,6 @@ export class CleanerService {
}
}
/**
* Check if button is enabled
* Reference: Python _is_button_enabled() lines 133-139
*/
private async isButtonEnabled(button: Locator): Promise<boolean> {
try {
return await button.isEnabled()
@@ -545,10 +729,6 @@ export class CleanerService {
}
}
/**
* Wait for row number to change after deletion
* Reference: Python lines 306-324
*/
private async waitForRowChange(
childForm: FrameLocator | Locator,
oldRowNumber: string,
@@ -571,17 +751,10 @@ export class CleanerService {
return false
}
/**
* Delay helper
*/
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Retry failed orders from the initial execution
* Maximum 2 retry attempts per failed order
*/
private async retryFailedOrders(params: {
workFrame: FrameLocator
popupPage: Page
@@ -610,7 +783,8 @@ export class CleanerService {
const MAX_RETRIES = 2
for (const failedDetail of failedDetails) {
for (let detailIndex = 0; detailIndex < failedDetails.length; detailIndex++) {
const failedDetail = failedDetails[detailIndex]
const orderNumber = failedDetail.orderNumber
const retryAttempts: import('../../types/cleaner.types').RetryAttempt[] = []
@@ -618,28 +792,25 @@ export class CleanerService {
try {
log.info(`Retrying order ${orderNumber} (attempt ${attempt}/${MAX_RETRIES})`)
// Create a new detail for retry
const retryDetail: OrderCleanDetail = {
orderNumber,
materialsDeleted: 0,
materialsSkipped: 0,
errors: [],
skippedMaterials: [],
retryCount: attempt,
retryAttempts: [],
retriedAt: undefined,
retrySuccess: false
await this.queryOrders(workFrame, [orderNumber])
await this.waitForLoading(workFrame)
const rows = workFrame.locator('tbody tr')
const rowCount = await rows.count()
if (rowCount === 0) {
throw new Error('订单重试查询无结果')
}
// Re-run the order processing
await this.processOrder({
workFrame,
popupPage,
orderNumber,
orderIndex: 0, // Not used for retry
totalOrders: failedDetails.length,
const detailPage = await this.openDetailPageFromCurrentQuery(workFrame, popupPage)
const retryDetail = await this.processDetailPage({
detailPage,
deleteSet,
dryRun,
expectedOrderNumber: orderNumber,
progressState: {
completedOrders: detailIndex,
totalOrders: failedDetails.length
},
onProgress: (message, progress, extra) => {
onProgress?.(
`[重试 ${attempt}/${MAX_RETRIES}] ${message}`,
@@ -649,18 +820,16 @@ export class CleanerService {
}
})
// If we reach here, retry succeeded
result.successfulRetries++
log.info(`Retry succeeded for order ${orderNumber}`)
// Merge the successful retry detail
result.successfulRetries += 1
result.updatedDetails.push({
...retryDetail,
retryCount: attempt,
retriedAt: Date.now(),
retrySuccess: true
retrySuccess: true,
retryAttempts
})
result.retriedOrders++
break // Exit retry loop for this order
result.retriedOrders += 1
break
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
log.warn(`Retry attempt ${attempt} failed for order ${orderNumber}: ${message}`)
@@ -672,8 +841,6 @@ export class CleanerService {
})
if (attempt === MAX_RETRIES) {
// All retries exhausted
log.error(`All retries failed for order ${orderNumber}`)
result.updatedDetails.push({
...failedDetail,
retryCount: MAX_RETRIES,
@@ -681,7 +848,7 @@ export class CleanerService {
retriedAt: Date.now(),
retrySuccess: false
})
result.retriedOrders++
result.retriedOrders += 1
}
}
}

View File

@@ -0,0 +1,6 @@
/**
* RustFS Service Module
*/
export { RustfsService } from './rustfs-service'
export type { UploadResult, DownloadResult, RustfsServiceOptions } from './rustfs-service'

View File

@@ -0,0 +1,376 @@
/**
* RustFS Service
*
* S3-compatible object storage service for persisting reports and files
* Uses AWS SDK for S3 protocol compatibility
*/
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
type PutObjectCommandInput,
type GetObjectCommandInput,
type DeleteObjectCommandInput
} from '@aws-sdk/client-s3'
import { createLogger } from '../logger'
import type { RustfsConfig } from '../../types/config.schema'
import * as fs from 'fs'
import * as path from 'path'
const log = createLogger('RustfsService')
export interface UploadResult {
success: boolean
key: string
etag?: string
error?: string
}
export interface DownloadResult {
success: boolean
content: Buffer
error?: string
}
export interface RustfsServiceOptions {
config: RustfsConfig
}
export class RustfsService {
private client: S3Client
private config: RustfsConfig
constructor(options: RustfsServiceOptions) {
const { config } = options
this.config = config
// Configure S3 client for RustFS
// RustFS is fully compatible with S3 protocol
this.client = new S3Client({
region: config.region || 'us-east-1',
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey
},
forcePathStyle: true // Required for some S3-compatible services
})
log.info('RustFS service initialized', {
endpoint: config.endpoint,
bucket: config.bucket,
region: config.region
})
}
/**
* Upload a file to RustFS
* @param filePath - Local file path to upload
* @param key - Object key (path) in the bucket
* @param contentType - Optional MIME type
*/
async uploadFile(filePath: string, key: string, contentType?: string): Promise<UploadResult> {
try {
// Validate configuration
if (!this.config.enabled) {
return {
success: false,
key,
error: 'RustFS is not enabled in configuration'
}
}
// Check if file exists
if (!fs.existsSync(filePath)) {
return {
success: false,
key,
error: `File not found: ${filePath}`
}
}
// Read file content
const fileContent = await fs.promises.readFile(filePath)
// Determine content type
const mimeType = contentType || this.getMimeType(filePath) || 'application/octet-stream'
log.info('Uploading file to RustFS', {
filePath,
key,
contentType: mimeType,
size: fileContent.length
})
const input: PutObjectCommandInput = {
Bucket: this.config.bucket,
Key: key,
Body: fileContent,
ContentType: mimeType
}
const command = new PutObjectCommand(input)
const response = await this.client.send(command)
log.info('File uploaded successfully', {
key,
etag: response.ETag
})
return {
success: true,
key,
etag: response.ETag
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown upload error'
log.error('Failed to upload file to RustFS', {
filePath,
key,
error: errorMessage
})
return {
success: false,
key,
error: errorMessage
}
}
}
/**
* Upload a string content directly to RustFS
* @param content - String content to upload
* @param key - Object key (path) in the bucket
* @param contentType - Optional MIME type
*/
async uploadString(content: string, key: string, contentType?: string): Promise<UploadResult> {
try {
if (!this.config.enabled) {
return {
success: false,
key,
error: 'RustFS is not enabled in configuration'
}
}
const mimeType = contentType || 'text/plain; charset=utf-8'
log.info('Uploading string content to RustFS', {
key,
contentType: mimeType,
size: content.length
})
const input: PutObjectCommandInput = {
Bucket: this.config.bucket,
Key: key,
Body: Buffer.from(content, 'utf-8'),
ContentType: mimeType
}
const command = new PutObjectCommand(input)
const response = await this.client.send(command)
log.info('String content uploaded successfully', {
key,
etag: response.ETag
})
return {
success: true,
key,
etag: response.ETag
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown upload error'
log.error('Failed to upload string to RustFS', {
key,
error: errorMessage
})
return {
success: false,
key,
error: errorMessage
}
}
}
/**
* Download a file from RustFS
* @param key - Object key (path) in the bucket
*/
async downloadFile(key: string): Promise<DownloadResult> {
try {
if (!this.config.enabled) {
return {
success: false,
content: Buffer.alloc(0),
error: 'RustFS is not enabled in configuration'
}
}
log.info('Downloading file from RustFS', { key })
const input: GetObjectCommandInput = {
Bucket: this.config.bucket,
Key: key
}
const command = new GetObjectCommand(input)
const response = await this.client.send(command)
const chunks: Buffer[] = []
for await (const chunk of response.Body as any) {
chunks.push(Buffer.from(chunk))
}
const content = Buffer.concat(chunks)
log.info('File downloaded successfully', {
key,
size: content.length
})
return {
success: true,
content
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown download error'
log.error('Failed to download file from RustFS', {
key,
error: errorMessage
})
return {
success: false,
content: Buffer.alloc(0),
error: errorMessage
}
}
}
/**
* Delete a file from RustFS
* @param key - Object key (path) in the bucket
*/
async deleteFile(key: string): Promise<{ success: boolean; error?: string }> {
try {
if (!this.config.enabled) {
return {
success: false,
error: 'RustFS is not enabled in configuration'
}
}
log.info('Deleting file from RustFS', { key })
const input: DeleteObjectCommandInput = {
Bucket: this.config.bucket,
Key: key
}
const command = new DeleteObjectCommand(input)
await this.client.send(command)
log.info('File deleted successfully', { key })
return {
success: true
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown delete error'
log.error('Failed to delete file from RustFS', {
key,
error: errorMessage
})
return {
success: false,
error: errorMessage
}
}
}
/**
* Generate a storage key for cleaner reports
* @param reportFileName - Original report file name
* @param username - Username who generated the report
*/
generateReportKey(reportFileName: string, username: string): string {
// Organize reports by user for easy access
// Format: reports/cleaner/{username}/{filename}
return `reports/cleaner/${username}/${reportFileName}`
}
/**
* Get MIME type based on file extension
*/
private getMimeType(filePath: string): string | null {
const ext = path.extname(filePath).toLowerCase()
const mimeTypes: Record<string, string> = {
'.md': 'text/markdown; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xls': 'application/vnd.ms-excel',
'.csv': 'text/csv; charset=utf-8',
'.pdf': 'application/pdf',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif'
}
return mimeTypes[ext] || null
}
/**
* Test connection to RustFS
*/
async testConnection(): Promise<{
success: boolean
message: string
error?: string
}> {
try {
log.info('Testing RustFS connection', {
endpoint: this.config.endpoint,
bucket: this.config.bucket
})
// Try to list objects in the bucket (head bucket operation)
const input = {
Bucket: this.config.bucket,
Prefix: '',
MaxKeys: 1
}
const command = new ListObjectsV2Command(input)
await this.client.send(command)
log.info('RustFS connection test successful')
return {
success: true,
message: '连接成功'
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown connection error'
log.error('RustFS connection test failed', {
error: errorMessage
})
return {
success: false,
message: '连接失败',
error: errorMessage
}
}
}
}

View File

@@ -0,0 +1,215 @@
/**
* RustFS Integration Test Script
*
* Tests RustFS connection and upload functionality
* Usage: tsx src/main/tools/rustfs-test.ts
*
* Note: This test runs in standalone mode without Electron
*/
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
DeleteObjectCommand,
type PutObjectCommandInput
} from '@aws-sdk/client-s3'
import * as path from 'path'
import * as fs from 'fs'
// Simple console logger (standalone mode)
const log = {
info: (msg: string, data?: any) => console.log(`[INFO] ${msg}`, data ? JSON.stringify(data) : ''),
error: (msg: string, data?: any) =>
console.error(`[ERROR] ${msg}`, data ? JSON.stringify(data) : ''),
warn: (msg: string, data?: any) => console.warn(`[WARN] ${msg}`, data ? JSON.stringify(data) : '')
}
// Test configuration
const TEST_CONFIG = {
enabled: true,
endpoint: 'http://192.168.110.114:9000',
accessKey: 'dP4O7ePAzyH8earoXxE9',
secretKey: '2vRPLnsh9Zi1KyBDymUtACyDdLHGfsLvw4MkG3cv',
bucket: 'erpauto',
region: 'us-east-1'
}
function createS3Client(config: typeof TEST_CONFIG) {
return new S3Client({
region: config.region,
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey
},
forcePathStyle: true
})
}
function getMimeType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase()
const mimeTypes: Record<string, string> = {
'.md': 'text/markdown; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.csv': 'text/csv; charset=utf-8'
}
return mimeTypes[ext] || 'application/octet-stream'
}
function generateReportKey(reportFileName: string, username: string): string {
// Organize reports by user for easy access
// Format: reports/cleaner/{username}/{filename}
return `reports/cleaner/${username}/${reportFileName}`
}
async function runTests() {
console.log('='.repeat(50))
console.log('RustFS Integration Test')
console.log('='.repeat(50))
console.log()
const client = createS3Client(TEST_CONFIG)
// Test connection
console.log('1. Testing connection...')
try {
const command = new ListObjectsV2Command({
Bucket: TEST_CONFIG.bucket,
Prefix: '',
MaxKeys: 1
})
await client.send(command)
console.log(' ✓ Connection successful')
} catch (error) {
console.log(` ✗ Connection failed: ${(error as Error).message}`)
return
}
console.log()
// Test upload string
console.log('2. Testing string upload...')
const testContent = `# Test Report
Generated at: ${new Date().toISOString()}
This is a test report to verify RustFS integration.
`
const testKey = `test/reports/test-${Date.now()}.md`
try {
const input: PutObjectCommandInput = {
Bucket: TEST_CONFIG.bucket,
Key: testKey,
Body: Buffer.from(testContent, 'utf-8'),
ContentType: 'text/markdown; charset=utf-8'
}
const command = new PutObjectCommand(input)
const response = await client.send(command)
console.log(' ✓ Upload successful')
console.log(` Key: ${testKey}`)
console.log(` ETag: ${response.ETag}`)
} catch (error) {
console.log(' ✗ Upload failed')
console.log(` Error: ${(error as Error).message}`)
return
}
console.log()
// Test download
console.log('3. Testing download...')
try {
const command = new GetObjectCommand({
Bucket: TEST_CONFIG.bucket,
Key: testKey
})
const response = await client.send(command)
const chunks: Buffer[] = []
for await (const chunk of response.Body as any) {
chunks.push(Buffer.from(chunk))
}
const content = Buffer.concat(chunks)
console.log(' ✓ Download successful')
console.log(` Size: ${content.length} bytes`)
console.log(` Content preview: ${content.toString('utf-8').slice(0, 50)}...`)
} catch (error) {
console.log(' ✗ Download failed')
console.log(` Error: ${(error as Error).message}`)
}
console.log()
// Test file upload (create a temporary file)
console.log('4. Testing file upload...')
const tempFilePath = path.join(process.cwd(), `test-file-${Date.now()}.md`)
fs.writeFileSync(tempFilePath, testContent, 'utf-8')
const fileKey = `test/files/test-file-${Date.now()}.md`
try {
const fileContent = fs.readFileSync(tempFilePath)
const input: PutObjectCommandInput = {
Bucket: TEST_CONFIG.bucket,
Key: fileKey,
Body: fileContent,
ContentType: getMimeType(tempFilePath)
}
const command = new PutObjectCommand(input)
const response = await client.send(command)
console.log(' ✓ File upload successful')
console.log(` Key: ${fileKey}`)
console.log(` ETag: ${response.ETag}`)
} catch (error) {
console.log(' ✗ File upload failed')
console.log(` Error: ${(error as Error).message}`)
}
// Cleanup temp file
try {
fs.unlinkSync(tempFilePath)
console.log(' ✓ Temporary file cleaned up')
} catch (e) {
console.log(` ⚠ Could not clean up temp file: ${(e as Error).message}`)
}
console.log()
// Test report key generation
console.log('5. Testing report key generation...')
const reportKey = generateReportKey('cleaner-report-2026-03-17-10-30-00.md', 'admin')
console.log(` ✓ Generated key: ${reportKey}`)
console.log()
// Test cleanup (delete test files)
console.log('6. Cleaning up test files...')
try {
const deleteCommand = new DeleteObjectCommand({
Bucket: TEST_CONFIG.bucket,
Key: testKey
})
await client.send(deleteCommand)
console.log(' ✓ Test string file deleted')
} catch (error) {
console.log(` ⚠ Could not delete test string file: ${(error as Error).message}`)
}
try {
const deleteCommand = new DeleteObjectCommand({
Bucket: TEST_CONFIG.bucket,
Key: fileKey
})
await client.send(deleteCommand)
console.log(' ✓ Test file deleted')
} catch (error) {
console.log(` ⚠ Could not delete test file: ${(error as Error).message}`)
}
console.log()
console.log('='.repeat(50))
console.log('All tests completed!')
console.log('='.repeat(50))
}
// Run tests
runTests().catch((error) => {
console.error('Test failed with error:', error)
process.exit(1)
})

View File

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

View File

@@ -97,6 +97,15 @@ export const validationConfigSchema = z.object({
defaultManager: z.string().default('')
})
/**
* 清理配置 Schema
*/
export const cleanerConfigSchema = z.object({
queryBatchSize: z.number().int().min(1).max(100).default(100),
processConcurrency: z.number().int().min(1).max(20).default(1)
})
export type CleanerConfig = z.infer<typeof cleanerConfigSchema>
/**
* 订单号解析配置 Schema
*/
@@ -122,6 +131,18 @@ export const loggingConfigSchema = z.object({
appRetention: z.number().int().min(1).max(365).default(14)
})
/**
* RustFS 对象存储配置 Schema
*/
export const rustfsConfigSchema = z.object({
enabled: z.boolean().default(false),
endpoint: z.string().min(1, 'RustFS endpoint is required'),
accessKey: z.string().min(1, 'RustFS access key is required'),
secretKey: z.string().min(1, 'RustFS secret key is required'),
bucket: z.string().min(1, 'RustFS bucket is required'),
region: z.string().default('us-east-1')
})
/**
* 完整应用配置 Schema
*/
@@ -131,8 +152,10 @@ export const fullConfigSchema = z.object({
paths: pathsConfigSchema,
extraction: extractionConfigSchema,
validation: validationConfigSchema,
cleaner: cleanerConfigSchema,
orderResolution: orderResolutionSchema,
logging: loggingConfigSchema
logging: loggingConfigSchema,
rustfs: rustfsConfigSchema.optional()
})
/**
@@ -144,6 +167,7 @@ export type MySqlConfig = z.infer<typeof mysqlConfigSchema>
export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
export type RustfsConfig = z.infer<typeof rustfsConfigSchema>
/**
* 验证并解析配置