feat: implement material validation UI in CleanerPage
New Features: - Material validation from database (full table or filtered by ProductionID) - Checkbox selection for materials to delete with auto-select for marked items - Manager-based filtering (Admin only) - Two-phase deletion: confirm (mark in DB) + execute (delete from ERP) - Export results to CSV - Hide/show checked items feature (User mode) - Share Production IDs between ExtractorPage and CleanerPage - Persist page state using sessionStorage Bug Fixes: - Fix MySQL query rowCount for INSERT/UPDATE/DELETE operations - Add AUTO_INCREMENT to MaterialsToBeDeleted.ID (preserved 43 records) - Add UNIQUE constraint on MaterialsToBeDeleted.MaterialCode Files Added: - src/main/ipc/validation-handler.ts - src/main/types/validation.types.ts - src/main/services/database/materials-to-be-deleted-dao.ts - src/main/services/database/discrete-material-plan-dao.ts Files Modified: - src/renderer/src/pages/CleanerPage.tsx (complete rewrite) - src/renderer/src/pages/ExtractorPage.tsx - src/renderer/src/App.tsx (add navigation tabs) - src/main/services/database/mysql.ts - src/preload/index.ts + index.d.ts
This commit is contained in:
@@ -9,6 +9,7 @@ import { registerCleanerHandlers } from './cleaner-handler'
|
|||||||
import { registerDatabaseHandlers } from './database-handler'
|
import { registerDatabaseHandlers } from './database-handler'
|
||||||
import { registerResolverHandlers } from './resolver-handler'
|
import { registerResolverHandlers } from './resolver-handler'
|
||||||
import { registerAuthHandlers } from './auth-handler'
|
import { registerAuthHandlers } from './auth-handler'
|
||||||
|
import { registerValidationHandlers } from './validation-handler'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all IPC handlers
|
* Register all IPC handlers
|
||||||
@@ -20,4 +21,5 @@ export function registerIpcHandlers(): void {
|
|||||||
registerDatabaseHandlers()
|
registerDatabaseHandlers()
|
||||||
registerResolverHandlers()
|
registerResolverHandlers()
|
||||||
registerAuthHandlers()
|
registerAuthHandlers()
|
||||||
|
registerValidationHandlers()
|
||||||
}
|
}
|
||||||
|
|||||||
618
src/main/ipc/validation-handler.ts
Normal file
618
src/main/ipc/validation-handler.ts
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
/**
|
||||||
|
* IPC handlers for material validation operations
|
||||||
|
*
|
||||||
|
* Provides endpoints for:
|
||||||
|
* - Running material validation from database
|
||||||
|
* - Getting/setting materials to be deleted
|
||||||
|
* - Manager-based filtering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { MySqlService } from '../services/database/mysql'
|
||||||
|
import { MaterialsToBeDeletedDAO } from '../services/database/materials-to-be-deleted-dao'
|
||||||
|
import { DiscreteMaterialPlanDAO } from '../services/database/discrete-material-plan-dao'
|
||||||
|
import type {
|
||||||
|
ValidationRequest,
|
||||||
|
ValidationResponse,
|
||||||
|
MaterialUpsertBatchRequest,
|
||||||
|
MaterialDeleteRequest,
|
||||||
|
MaterialOperationResponse,
|
||||||
|
ValidationResult,
|
||||||
|
MaterialRecordSummary
|
||||||
|
} from '../types/validation.types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared state for Production IDs from extractor page
|
||||||
|
* This is a simple in-memory store for sharing Production IDs between pages
|
||||||
|
*/
|
||||||
|
const sharedProductionIds = new Set<string>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set shared Production IDs
|
||||||
|
*/
|
||||||
|
export function setSharedProductionIds(ids: string[]): void {
|
||||||
|
ids.forEach(id => sharedProductionIds.add(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shared Production IDs
|
||||||
|
*/
|
||||||
|
export function getSharedProductionIds(): string[] {
|
||||||
|
return [...sharedProductionIds]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear shared Production IDs
|
||||||
|
*/
|
||||||
|
export function clearSharedProductionIds(): void {
|
||||||
|
sharedProductionIds.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MySQL service for validation operations
|
||||||
|
*/
|
||||||
|
async function getValidationMySqlService(): Promise<MySqlService> {
|
||||||
|
const mysqlService = new MySqlService({
|
||||||
|
host: process.env.DB_MYSQL_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
||||||
|
user: process.env.DB_USERNAME || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
await mysqlService.connect()
|
||||||
|
return mysqlService
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read Production IDs from file
|
||||||
|
*/
|
||||||
|
function readProductionIds(filePath: string): string[] {
|
||||||
|
const fs = require('fs')
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
return content
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify input type (production ID or order number)
|
||||||
|
*/
|
||||||
|
function identifyInputType(input: string): 'production_id' | 'order_number' | 'unknown' {
|
||||||
|
// Order number: SC + 14 digits
|
||||||
|
if (/^SC\d{14}$/.test(input)) {
|
||||||
|
return 'order_number'
|
||||||
|
}
|
||||||
|
// Production ID: 2 digits + 1 letter + 1-6 digits
|
||||||
|
if (/^\d{2}[A-Za-z]\d{1,6}$/.test(input)) {
|
||||||
|
return 'production_id'
|
||||||
|
}
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get source numbers from inputs
|
||||||
|
*/
|
||||||
|
async function getSourceNumbersFromInputs(
|
||||||
|
inputs: string[],
|
||||||
|
mysqlService: MySqlService
|
||||||
|
): Promise<string[]> {
|
||||||
|
const productionIds: string[] = []
|
||||||
|
const orderNumbers: string[] = []
|
||||||
|
|
||||||
|
for (const item of inputs) {
|
||||||
|
const type = identifyInputType(item)
|
||||||
|
if (type === 'order_number') {
|
||||||
|
orderNumbers.push(item)
|
||||||
|
} else if (type === 'production_id') {
|
||||||
|
productionIds.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query production contract data for production IDs
|
||||||
|
// Table name in MySQL: productionContractData_26年压力表合同数据
|
||||||
|
// Column name: 生产订单号 (SourceNumber)
|
||||||
|
if (productionIds.length > 0) {
|
||||||
|
const placeholders = productionIds.map(() => '?').join(',')
|
||||||
|
const contractSql = `
|
||||||
|
SELECT DISTINCT 生产订单号
|
||||||
|
FROM productionContractData_26年压力表合同数据
|
||||||
|
WHERE 总排号 IN (${placeholders})
|
||||||
|
`
|
||||||
|
const contractResult = await mysqlService.query(contractSql, productionIds)
|
||||||
|
const dbOrderNumbers = contractResult.rows.map(
|
||||||
|
row => row.生产订单号 as string
|
||||||
|
)
|
||||||
|
orderNumbers.push(...dbOrderNumbers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
return [...new Set(orderNumbers)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register IPC handlers for validation operations
|
||||||
|
*/
|
||||||
|
export function registerValidationHandlers(): void {
|
||||||
|
// ==================== VALIDATION ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run material validation from database
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'validation:validate',
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
request: ValidationRequest
|
||||||
|
): Promise<ValidationResponse> => {
|
||||||
|
let mysqlService: MySqlService | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[Validation] Starting validation:', request)
|
||||||
|
|
||||||
|
// Connect to MySQL
|
||||||
|
mysqlService = await getValidationMySqlService()
|
||||||
|
|
||||||
|
let sourceNumbers: string[] | null = null
|
||||||
|
|
||||||
|
// Get source numbers based on mode
|
||||||
|
if (request.mode === 'database_filtered') {
|
||||||
|
if (request.useSharedProductionIds) {
|
||||||
|
// Use shared Production IDs from extractor page
|
||||||
|
const sharedIds = getSharedProductionIds()
|
||||||
|
console.log(`[Validation] Using ${sharedIds.length} shared Production IDs`)
|
||||||
|
|
||||||
|
if (sharedIds.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '没有可用的共享 Production ID。请在数据提取页面输入 Production ID。',
|
||||||
|
stats: {
|
||||||
|
totalRecords: 0,
|
||||||
|
matchedCount: 0,
|
||||||
|
markedCount: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceNumbers = await getSourceNumbersFromInputs(sharedIds, mysqlService)
|
||||||
|
console.log(
|
||||||
|
`[Validation] Got ${sourceNumbers.length} source numbers from shared Production IDs`
|
||||||
|
)
|
||||||
|
} else if (request.productionIdFile) {
|
||||||
|
// Read from file
|
||||||
|
const inputs = readProductionIds(request.productionIdFile)
|
||||||
|
console.log(
|
||||||
|
`[Validation] Read ${inputs.length} inputs from file`
|
||||||
|
)
|
||||||
|
sourceNumbers = await getSourceNumbersFromInputs(inputs, mysqlService)
|
||||||
|
console.log(
|
||||||
|
`[Validation] Got ${sourceNumbers.length} source numbers`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get material records from DiscreteMaterialPlanData
|
||||||
|
const materialDao = new DiscreteMaterialPlanDAO()
|
||||||
|
// Inject the mysqlService instance
|
||||||
|
;(materialDao as any).mysqlService = mysqlService
|
||||||
|
|
||||||
|
let materialRecords: any[] = []
|
||||||
|
|
||||||
|
if (request.mode === 'database_full') {
|
||||||
|
// Full table query with deduplication by MaterialCode
|
||||||
|
materialRecords = await materialDao.queryAllDistinctByMaterialCode()
|
||||||
|
} else if (sourceNumbers && sourceNumbers.length > 0) {
|
||||||
|
// Filtered query by source numbers
|
||||||
|
materialRecords = await materialDao.queryBySourceNumbersDistinct(
|
||||||
|
sourceNumbers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (materialRecords.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No material records found',
|
||||||
|
stats: {
|
||||||
|
totalRecords: 0,
|
||||||
|
matchedCount: 0,
|
||||||
|
markedCount: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get type keywords from MaterialsTypeToBeDeleted
|
||||||
|
const typeKeywordSql = `
|
||||||
|
SELECT MaterialName, ManagerName
|
||||||
|
FROM dbo_MaterialsTypeToBeDeleted
|
||||||
|
WHERE MaterialName IS NOT NULL
|
||||||
|
`
|
||||||
|
const typeKeywordResult = await mysqlService.query(typeKeywordSql)
|
||||||
|
const typeKeywords = typeKeywordResult.rows.map(row => ({
|
||||||
|
materialName: row.MaterialName as string,
|
||||||
|
managerName: row.ManagerName as string
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Get marked material codes from MaterialsToBeDeleted
|
||||||
|
const markedSql = `
|
||||||
|
SELECT MaterialCode, ManagerName
|
||||||
|
FROM dbo_MaterialsToBeDeleted
|
||||||
|
WHERE MaterialCode IS NOT NULL AND ManagerName IS NOT NULL
|
||||||
|
`
|
||||||
|
const markedResult = await mysqlService.query(markedSql)
|
||||||
|
const markedCodesDict = new Map<string, string>()
|
||||||
|
for (const row of markedResult.rows) {
|
||||||
|
markedCodesDict.set(
|
||||||
|
row.MaterialCode as string,
|
||||||
|
row.ManagerName as string
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match materials
|
||||||
|
const results: ValidationResult[] = []
|
||||||
|
for (const record of materialRecords) {
|
||||||
|
const materialName = (record.MaterialName as string) || ''
|
||||||
|
const materialCode = (record.MaterialCode as string) || ''
|
||||||
|
const specification = (record.Specification as string) || ''
|
||||||
|
const model = (record.Model as string) || ''
|
||||||
|
|
||||||
|
// Priority 1: Check MaterialsToBeDeleted (MaterialCode exact match)
|
||||||
|
let managerName = markedCodesDict.get(materialCode) || null
|
||||||
|
const isMarkedForDeletion = managerName !== null
|
||||||
|
let matchedTypeKeyword: string | undefined = undefined
|
||||||
|
|
||||||
|
// Priority 2: Match with MaterialsTypeToBeDeleted (MaterialName contains)
|
||||||
|
if (!managerName) {
|
||||||
|
for (const typeKeyword of typeKeywords) {
|
||||||
|
if (
|
||||||
|
typeKeyword.materialName &&
|
||||||
|
typeKeyword.materialName.includes(materialName)
|
||||||
|
) {
|
||||||
|
matchedTypeKeyword = typeKeyword.materialName
|
||||||
|
managerName = typeKeyword.managerName
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
materialName,
|
||||||
|
materialCode,
|
||||||
|
specification,
|
||||||
|
model,
|
||||||
|
managerName: managerName || '',
|
||||||
|
isMarkedForDeletion,
|
||||||
|
matchedTypeKeyword
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const markedCount = results.filter(r => r.isMarkedForDeletion).length
|
||||||
|
const matchedCount = results.filter(r => r.managerName).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
results,
|
||||||
|
stats: {
|
||||||
|
totalRecords: results.length,
|
||||||
|
matchedCount,
|
||||||
|
markedCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
console.error('[Validation] Validation error:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Validation failed: ${message}`
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mysqlService) {
|
||||||
|
try {
|
||||||
|
await mysqlService.disconnect()
|
||||||
|
} catch (closeError) {
|
||||||
|
console.warn('[Validation] Error disconnecting MySQL:', closeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== MATERIAL OPERATIONS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert batch materials to MaterialsToBeDeleted
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'materials:upsertBatch',
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
request: MaterialUpsertBatchRequest
|
||||||
|
): Promise<MaterialOperationResponse> => {
|
||||||
|
let mysqlService: MySqlService | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
mysqlService = await getValidationMySqlService()
|
||||||
|
const dao = new MaterialsToBeDeletedDAO()
|
||||||
|
;(dao as any).mysqlService = mysqlService
|
||||||
|
|
||||||
|
const stats = await dao.upsertBatch(request.materials)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
console.error('[Materials] Upsert batch error:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Upsert failed: ${message}`
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mysqlService) {
|
||||||
|
try {
|
||||||
|
await mysqlService.disconnect()
|
||||||
|
} catch (closeError) {
|
||||||
|
console.warn('[Materials] Error disconnecting MySQL:', closeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete materials by material codes
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'materials:delete',
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
request: MaterialDeleteRequest
|
||||||
|
): Promise<MaterialOperationResponse> => {
|
||||||
|
let mysqlService: MySqlService | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
mysqlService = await getValidationMySqlService()
|
||||||
|
const dao = new MaterialsToBeDeletedDAO()
|
||||||
|
;(dao as any).mysqlService = mysqlService
|
||||||
|
|
||||||
|
const count = await dao.deleteByMaterialCodes(request.materialCodes)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
count
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
console.error('[Materials] Delete error:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Delete failed: ${message}`
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mysqlService) {
|
||||||
|
try {
|
||||||
|
await mysqlService.disconnect()
|
||||||
|
} catch (closeError) {
|
||||||
|
console.warn('[Materials] Error disconnecting MySQL:', closeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unique manager names
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'materials:getManagers',
|
||||||
|
async (_event): Promise<{ managers: string[] }> => {
|
||||||
|
let mysqlService: MySqlService | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
mysqlService = await getValidationMySqlService()
|
||||||
|
const dao = new MaterialsToBeDeletedDAO()
|
||||||
|
;(dao as any).mysqlService = mysqlService
|
||||||
|
|
||||||
|
const managers = await dao.getManagers()
|
||||||
|
return { managers }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Materials] Get managers error:', error)
|
||||||
|
return { managers: [] }
|
||||||
|
} finally {
|
||||||
|
if (mysqlService) {
|
||||||
|
try {
|
||||||
|
await mysqlService.disconnect()
|
||||||
|
} catch (closeError) {
|
||||||
|
console.warn('[Materials] Error disconnecting MySQL:', closeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get materials by manager
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'materials:getByManager',
|
||||||
|
async (
|
||||||
|
_event,
|
||||||
|
managerName: string
|
||||||
|
): Promise<{ materials: MaterialRecordSummary[] }> => {
|
||||||
|
let mysqlService: MySqlService | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
mysqlService = await getValidationMySqlService()
|
||||||
|
const dao = new MaterialsToBeDeletedDAO()
|
||||||
|
;(dao as any).mysqlService = mysqlService
|
||||||
|
|
||||||
|
const materials = await dao.getMaterialsByManager(managerName)
|
||||||
|
|
||||||
|
// Get material codes set for quick lookup
|
||||||
|
const markedCodes = await dao.getAllMaterialCodes()
|
||||||
|
|
||||||
|
// Enrich with material details from DiscreteMaterialPlanData
|
||||||
|
const enrichedMaterials: MaterialRecordSummary[] = []
|
||||||
|
|
||||||
|
for (const mat of materials) {
|
||||||
|
const detailSql = `
|
||||||
|
SELECT MaterialName, Specification, Model
|
||||||
|
FROM dbo_DiscreteMaterialPlanData
|
||||||
|
WHERE MaterialCode = ?
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
const detailResult = await mysqlService.query(detailSql, [
|
||||||
|
mat.materialCode
|
||||||
|
])
|
||||||
|
|
||||||
|
enrichedMaterials.push({
|
||||||
|
materialCode: mat.materialCode,
|
||||||
|
materialName:
|
||||||
|
detailResult.rows.length > 0
|
||||||
|
? (detailResult.rows[0].MaterialName as string)
|
||||||
|
: '',
|
||||||
|
specification:
|
||||||
|
detailResult.rows.length > 0
|
||||||
|
? (detailResult.rows[0].Specification as string)
|
||||||
|
: '',
|
||||||
|
model:
|
||||||
|
detailResult.rows.length > 0
|
||||||
|
? (detailResult.rows[0].Model as string)
|
||||||
|
: '',
|
||||||
|
managerName: mat.managerName,
|
||||||
|
isMarked: markedCodes.has(mat.materialCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { materials: enrichedMaterials }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Materials] Get by manager error:', error)
|
||||||
|
return { materials: [] }
|
||||||
|
} finally {
|
||||||
|
if (mysqlService) {
|
||||||
|
try {
|
||||||
|
await mysqlService.disconnect()
|
||||||
|
} catch (closeError) {
|
||||||
|
console.warn('[Materials] Error disconnecting MySQL:', closeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all material records
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'materials:getAll',
|
||||||
|
async (_event): Promise<{ materials: MaterialRecordSummary[] }> => {
|
||||||
|
let mysqlService: MySqlService | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
mysqlService = await getValidationMySqlService()
|
||||||
|
const dao = new MaterialsToBeDeletedDAO()
|
||||||
|
;(dao as any).mysqlService = mysqlService
|
||||||
|
|
||||||
|
const materials = await dao.getAllRecords()
|
||||||
|
const markedCodes = await dao.getAllMaterialCodes()
|
||||||
|
|
||||||
|
const enrichedMaterials: MaterialRecordSummary[] = []
|
||||||
|
|
||||||
|
for (const mat of materials) {
|
||||||
|
const detailSql = `
|
||||||
|
SELECT MaterialName, Specification, Model
|
||||||
|
FROM dbo_DiscreteMaterialPlanData
|
||||||
|
WHERE MaterialCode = ?
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
const detailResult = await mysqlService.query(detailSql, [
|
||||||
|
mat.materialCode
|
||||||
|
])
|
||||||
|
|
||||||
|
enrichedMaterials.push({
|
||||||
|
materialCode: mat.materialCode,
|
||||||
|
materialName:
|
||||||
|
detailResult.rows.length > 0
|
||||||
|
? (detailResult.rows[0].MaterialName as string)
|
||||||
|
: '',
|
||||||
|
specification:
|
||||||
|
detailResult.rows.length > 0
|
||||||
|
? (detailResult.rows[0].Specification as string)
|
||||||
|
: '',
|
||||||
|
model:
|
||||||
|
detailResult.rows.length > 0
|
||||||
|
? (detailResult.rows[0].Model as string)
|
||||||
|
: '',
|
||||||
|
managerName: mat.managerName,
|
||||||
|
isMarked: markedCodes.has(mat.materialCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { materials: enrichedMaterials }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Materials] Get all error:', error)
|
||||||
|
return { materials: [] }
|
||||||
|
} finally {
|
||||||
|
if (mysqlService) {
|
||||||
|
try {
|
||||||
|
await mysqlService.disconnect()
|
||||||
|
} catch (closeError) {
|
||||||
|
console.warn('[Materials] Error disconnecting MySQL:', closeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'materials:getStatistics',
|
||||||
|
async (_event): Promise<{ stats: any }> => {
|
||||||
|
let mysqlService: MySqlService | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
mysqlService = await getValidationMySqlService()
|
||||||
|
const dao = new MaterialsToBeDeletedDAO()
|
||||||
|
;(dao as any).mysqlService = mysqlService
|
||||||
|
|
||||||
|
const stats = await dao.getStatistics()
|
||||||
|
return { stats }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Materials] Get statistics error:', error)
|
||||||
|
return { stats: null }
|
||||||
|
} finally {
|
||||||
|
if (mysqlService) {
|
||||||
|
try {
|
||||||
|
await mysqlService.disconnect()
|
||||||
|
} catch (closeError) {
|
||||||
|
console.warn('[Materials] Error disconnecting MySQL:', closeError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set shared Production IDs from extractor page
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'validation:setSharedProductionIds',
|
||||||
|
async (_event, productionIds: string[]): Promise<void> => {
|
||||||
|
console.log(`[Validation] Received ${productionIds.length} shared Production IDs`)
|
||||||
|
setSharedProductionIds(productionIds)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get shared Production IDs
|
||||||
|
*/
|
||||||
|
ipcMain.handle(
|
||||||
|
'validation:getSharedProductionIds',
|
||||||
|
async (): Promise<{ productionIds: string[] }> => {
|
||||||
|
return { productionIds: getSharedProductionIds() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
460
src/main/services/database/discrete-material-plan-dao.ts
Normal file
460
src/main/services/database/discrete-material-plan-dao.ts
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
/**
|
||||||
|
* Data Access Object for DiscreteMaterialPlanData table
|
||||||
|
*
|
||||||
|
* Mirrors the Python DiscreteMaterialPlanDAO functionality:
|
||||||
|
* - Query operations for discrete material plan data
|
||||||
|
* - Support for querying by PlanNumber, SourceNumber
|
||||||
|
* - Deduplication by MaterialCode
|
||||||
|
* - Statistics gathering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MySqlService } from './mysql'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material plan record interface
|
||||||
|
*/
|
||||||
|
export interface MaterialPlanRecord {
|
||||||
|
id?: number
|
||||||
|
factory: string
|
||||||
|
materialStatus: string
|
||||||
|
planNumber: string
|
||||||
|
sourceNumber: string
|
||||||
|
materialType: string
|
||||||
|
productCode: string
|
||||||
|
productName: string
|
||||||
|
productUnit: string
|
||||||
|
productPlanQuantity: number
|
||||||
|
useDepartment: string
|
||||||
|
remark: string
|
||||||
|
creator: string
|
||||||
|
createDate: Date
|
||||||
|
approver: string
|
||||||
|
approveDate: Date
|
||||||
|
sequenceNumber: number
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
specification: string
|
||||||
|
model: string
|
||||||
|
drawingNumber: string
|
||||||
|
materialQuality: string
|
||||||
|
planQuantity: number
|
||||||
|
unit: string
|
||||||
|
requiredDate: Date
|
||||||
|
warehouse: string
|
||||||
|
unitUsage: number
|
||||||
|
cumulativeOutputQuantity: number
|
||||||
|
bomVersion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for DiscreteMaterialPlanData table
|
||||||
|
*/
|
||||||
|
export const DISCRETE_MATERIAL_PLAN_CONFIG = {
|
||||||
|
TABLE_NAME_MYSQL: 'dbo_DiscreteMaterialPlanData',
|
||||||
|
COLUMNS: {
|
||||||
|
ID: 'ID',
|
||||||
|
FACTORY: 'Factory',
|
||||||
|
MATERIAL_STATUS: 'MaterialStatus',
|
||||||
|
PLAN_NUMBER: 'PlanNumber',
|
||||||
|
SOURCE_NUMBER: 'SourceNumber',
|
||||||
|
MATERIAL_TYPE: 'MaterialType',
|
||||||
|
PRODUCT_CODE: 'ProductCode',
|
||||||
|
PRODUCT_NAME: 'ProductName',
|
||||||
|
PRODUCT_UNIT: 'ProductUnit',
|
||||||
|
PRODUCT_PLAN_QUANTITY: 'ProductPlanQuantity',
|
||||||
|
USE_DEPARTMENT: 'UseDepartment',
|
||||||
|
REMARK: 'Remark',
|
||||||
|
CREATOR: 'Creator',
|
||||||
|
CREATE_DATE: 'CreateDate',
|
||||||
|
APPROVER: 'Approver',
|
||||||
|
APPROVE_DATE: 'ApproveDate',
|
||||||
|
SEQUENCE_NUMBER: 'SequenceNumber',
|
||||||
|
MATERIAL_CODE: 'MaterialCode',
|
||||||
|
MATERIAL_NAME: 'MaterialName',
|
||||||
|
SPECIFICATION: 'Specification',
|
||||||
|
MODEL: 'Model',
|
||||||
|
DRAWING_NUMBER: 'DrawingNumber',
|
||||||
|
MATERIAL_QUALITY: 'MaterialQuality',
|
||||||
|
PLAN_QUANTITY: 'PlanQuantity',
|
||||||
|
UNIT: 'Unit',
|
||||||
|
REQUIRED_DATE: 'RequiredDate',
|
||||||
|
WAREHOUSE: 'Warehouse',
|
||||||
|
UNIT_USAGE: 'UnitUsage',
|
||||||
|
CUMULATIVE_OUTPUT_QUANTITY: 'CumulativeOutputQuantity',
|
||||||
|
BOM_VERSION: 'BOMVersion'
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DiscreteMaterialPlanDAO Class
|
||||||
|
*/
|
||||||
|
export class DiscreteMaterialPlanDAO {
|
||||||
|
private mysqlService: MySqlService | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MySQL service instance
|
||||||
|
*/
|
||||||
|
private async getMySqlService(): Promise<MySqlService> {
|
||||||
|
if (this.mysqlService && this.mysqlService.isConnected()) {
|
||||||
|
return this.mysqlService
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mysqlService = new MySqlService({
|
||||||
|
host: process.env.DB_MYSQL_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
||||||
|
user: process.env.DB_USERNAME || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.mysqlService.connect()
|
||||||
|
return this.mysqlService
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== QUERY ALL ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query all records from DiscreteMaterialPlanData table
|
||||||
|
* @returns List of all records
|
||||||
|
*/
|
||||||
|
async queryAll(): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `SELECT * FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql)
|
||||||
|
return result.rows
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DiscreteMaterialPlanDAO] Query all error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query all records with deduplication by MaterialCode
|
||||||
|
* Strategy: Keep first record for each MaterialCode
|
||||||
|
* Order: CreateDate ASC, SequenceNumber ASC
|
||||||
|
* @returns List of deduplicated records
|
||||||
|
*/
|
||||||
|
async queryAllDistinctByMaterialCode(): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
WITH RankedRecords AS (
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY MaterialCode
|
||||||
|
ORDER BY CreateDate ASC, SequenceNumber ASC
|
||||||
|
) AS rn
|
||||||
|
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE MaterialCode IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
Factory, MaterialStatus, PlanNumber, SourceNumber, MaterialType,
|
||||||
|
ProductCode, ProductName, ProductUnit, ProductPlanQuantity,
|
||||||
|
UseDepartment, Remark, Creator, CreateDate, Approver, ApproveDate,
|
||||||
|
SequenceNumber, MaterialCode, MaterialName, Specification, Model,
|
||||||
|
DrawingNumber, MaterialQuality, PlanQuantity, Unit, RequiredDate,
|
||||||
|
Warehouse, UnitUsage, CumulativeOutputQuantity, BOMVersion
|
||||||
|
FROM RankedRecords
|
||||||
|
WHERE rn = 1
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql)
|
||||||
|
return result.rows
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DiscreteMaterialPlanDAO] Query all distinct by material code error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== QUERY BY SOURCE NUMBER ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query records by SourceNumber list
|
||||||
|
* @param sourceNumbers - List of SourceNumber values
|
||||||
|
* @returns List of records
|
||||||
|
*/
|
||||||
|
async queryBySourceNumbers(sourceNumbers: string[]): Promise<any[]> {
|
||||||
|
if (!sourceNumbers || sourceNumbers.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
const batchSize = 2000
|
||||||
|
const allResults: any[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||||
|
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||||
|
const placeholders = batch.map(() => '?').join(',')
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT *
|
||||||
|
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE SourceNumber IN (${placeholders})
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, batch)
|
||||||
|
allResults.push(...result.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allResults
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DiscreteMaterialPlanDAO] Query by source numbers error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query records by SourceNumber with deduplication by MaterialCode
|
||||||
|
* Strategy: Keep first record for each MaterialCode
|
||||||
|
* Order: CreateDate ASC, SequenceNumber ASC
|
||||||
|
* @param sourceNumbers - List of SourceNumber values
|
||||||
|
* @returns List of deduplicated records
|
||||||
|
*/
|
||||||
|
async queryBySourceNumbersDistinct(sourceNumbers: string[]): Promise<any[]> {
|
||||||
|
if (!sourceNumbers || sourceNumbers.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
const batchSize = 2000
|
||||||
|
const allResults: any[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||||
|
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||||
|
const placeholders = batch.map(() => '?').join(',')
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
WITH RankedRecords AS (
|
||||||
|
SELECT
|
||||||
|
*,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY MaterialCode
|
||||||
|
ORDER BY CreateDate ASC, SequenceNumber ASC
|
||||||
|
) AS rn
|
||||||
|
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE SourceNumber IN (${placeholders})
|
||||||
|
AND MaterialCode IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
Factory, MaterialStatus, PlanNumber, SourceNumber, MaterialType,
|
||||||
|
ProductCode, ProductName, ProductUnit, ProductPlanQuantity,
|
||||||
|
UseDepartment, Remark, Creator, CreateDate, Approver, ApproveDate,
|
||||||
|
SequenceNumber, MaterialCode, MaterialName, Specification, Model,
|
||||||
|
DrawingNumber, MaterialQuality, PlanQuantity, Unit, RequiredDate,
|
||||||
|
Warehouse, UnitUsage, CumulativeOutputQuantity, BOMVersion
|
||||||
|
FROM RankedRecords
|
||||||
|
WHERE rn = 1
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, batch)
|
||||||
|
allResults.push(...result.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allResults
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DiscreteMaterialPlanDAO] Query by source numbers distinct error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query by production order number (SourceNumber)
|
||||||
|
* @param sourceNumber - SourceNumber value
|
||||||
|
* @returns List of records
|
||||||
|
*/
|
||||||
|
async queryBySourceNumber(sourceNumber: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT *
|
||||||
|
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE SourceNumber = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, [sourceNumber])
|
||||||
|
return result.rows
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DiscreteMaterialPlanDAO] Query by source number error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== QUERY BY PLAN NUMBER ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query by plan number
|
||||||
|
* @param planNumber - PlanNumber value
|
||||||
|
* @returns List of records
|
||||||
|
*/
|
||||||
|
async queryByPlanNumber(planNumber: string): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT *
|
||||||
|
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE PlanNumber = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, [planNumber])
|
||||||
|
return result.rows
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DiscreteMaterialPlanDAO] Query by plan number error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query by multiple plan numbers
|
||||||
|
* @param planNumbers - List of PlanNumber values
|
||||||
|
* @returns List of records
|
||||||
|
*/
|
||||||
|
async queryByPlanNumbers(planNumbers: string[]): Promise<any[]> {
|
||||||
|
if (!planNumbers || planNumbers.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
const placeholders = planNumbers.map(() => '?').join(',')
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT *
|
||||||
|
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE PlanNumber IN (${placeholders})
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, planNumbers)
|
||||||
|
return result.rows
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DiscreteMaterialPlanDAO] Query by plan numbers error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UTILITY METHODS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count all records
|
||||||
|
* @returns Total number of records
|
||||||
|
*/
|
||||||
|
async countAll(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `SELECT COUNT(*) as count FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql)
|
||||||
|
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DiscreteMaterialPlanDAO] Count all error:', error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count records by plan number
|
||||||
|
* @param planNumber - PlanNumber value
|
||||||
|
* @returns Number of records
|
||||||
|
*/
|
||||||
|
async countByPlanNumber(planNumber: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE PlanNumber = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, [planNumber])
|
||||||
|
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DiscreteMaterialPlanDAO] Count by plan number error:', error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unique material names
|
||||||
|
* @param sourceNumbers - Optional list of SourceNumber values to filter
|
||||||
|
* @returns List of unique material names
|
||||||
|
*/
|
||||||
|
async getUniqueMaterialNames(sourceNumbers?: string[]): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
let sql: string
|
||||||
|
let params: any[] = []
|
||||||
|
|
||||||
|
if (sourceNumbers && sourceNumbers.length > 0) {
|
||||||
|
const placeholders = sourceNumbers.map(() => '?').join(',')
|
||||||
|
sql = `
|
||||||
|
SELECT DISTINCT MaterialName
|
||||||
|
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE SourceNumber IN (${placeholders})
|
||||||
|
AND MaterialName IS NOT NULL
|
||||||
|
`
|
||||||
|
params = sourceNumbers
|
||||||
|
} else {
|
||||||
|
sql = `
|
||||||
|
SELECT DISTINCT MaterialName
|
||||||
|
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE MaterialName IS NOT NULL
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, params)
|
||||||
|
return result.rows
|
||||||
|
.map(row => row.MaterialName as string)
|
||||||
|
.filter(Boolean)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DiscreteMaterialPlanDAO] Get unique material names error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics
|
||||||
|
* @returns Statistics object
|
||||||
|
*/
|
||||||
|
async getStatistics(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as totalRecords,
|
||||||
|
COUNT(DISTINCT PlanNumber) as uniquePlans,
|
||||||
|
COUNT(DISTINCT SourceNumber) as uniqueOrders,
|
||||||
|
MIN(CreateDate) as earliestRecord,
|
||||||
|
MAX(CreateDate) as latestRecord
|
||||||
|
FROM ${DISCRETE_MATERIAL_PLAN_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql)
|
||||||
|
return result.rows.length > 0 ? result.rows[0] : {}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DiscreteMaterialPlanDAO] Get statistics error:', error)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from database
|
||||||
|
*/
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.mysqlService) {
|
||||||
|
await this.mysqlService.disconnect()
|
||||||
|
this.mysqlService = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
516
src/main/services/database/materials-to-be-deleted-dao.ts
Normal file
516
src/main/services/database/materials-to-be-deleted-dao.ts
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
/**
|
||||||
|
* Data Access Object for MaterialsToBeDeleted table
|
||||||
|
*
|
||||||
|
* Mirrors the Python MaterialsToBeDeletedDAO functionality:
|
||||||
|
* - CRUD operations for materials identified by MaterialCode
|
||||||
|
* - Batch upsert operations
|
||||||
|
* - Manager-based filtering and queries
|
||||||
|
* - Statistics gathering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MySqlService } from './mysql'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material record interface
|
||||||
|
*/
|
||||||
|
export interface MaterialRecord {
|
||||||
|
id?: number
|
||||||
|
materialCode: string
|
||||||
|
managerName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert statistics
|
||||||
|
*/
|
||||||
|
export interface UpsertStats {
|
||||||
|
total: number
|
||||||
|
success: number
|
||||||
|
failed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material statistics
|
||||||
|
*/
|
||||||
|
export interface MaterialStats {
|
||||||
|
totalMaterials: number
|
||||||
|
uniqueManagers: number
|
||||||
|
materialsPerManager: Record<string, number>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for MaterialsToBeDeleted table
|
||||||
|
*/
|
||||||
|
export const MATERIALS_TO_BE_DELETED_CONFIG = {
|
||||||
|
TABLE_NAME_MYSQL: 'dbo_MaterialsToBeDeleted',
|
||||||
|
COLUMNS: {
|
||||||
|
ID: 'ID',
|
||||||
|
MATERIAL_CODE: 'MaterialCode',
|
||||||
|
MANAGER_NAME: 'ManagerName'
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MaterialsToBeDeleted DAO Class
|
||||||
|
*/
|
||||||
|
export class MaterialsToBeDeletedDAO {
|
||||||
|
private mysqlService: MySqlService | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MySQL service instance
|
||||||
|
*/
|
||||||
|
private async getMySqlService(): Promise<MySqlService> {
|
||||||
|
if (this.mysqlService && this.mysqlService.isConnected()) {
|
||||||
|
return this.mysqlService
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mysqlService = new MySqlService({
|
||||||
|
host: process.env.DB_MYSQL_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
||||||
|
user: process.env.DB_USERNAME || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.mysqlService.connect()
|
||||||
|
return this.mysqlService
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UPSERT (MERGE) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or update a single material record
|
||||||
|
* @param materialCode - Material code (exact match key)
|
||||||
|
* @param managerName - Manager name
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async upsertMaterial(materialCode: string, managerName: string): Promise<boolean> {
|
||||||
|
if (!materialCode || !materialCode.trim()) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] MaterialCode cannot be empty')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL} (MaterialCode, ManagerName)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
||||||
|
`
|
||||||
|
|
||||||
|
await mysqlService.query(sql, [materialCode.trim(), managerName?.trim() || null])
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Upsert material error:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or update multiple material records in batch
|
||||||
|
* @param materials - List of materials with materialCode and managerName
|
||||||
|
* @returns Statistics object
|
||||||
|
*/
|
||||||
|
async upsertBatch(materials: { materialCode: string; managerName: string }[]): Promise<UpsertStats> {
|
||||||
|
if (!materials || materials.length === 0) {
|
||||||
|
return { total: 0, success: 0, failed: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats: UpsertStats = {
|
||||||
|
total: materials.length,
|
||||||
|
success: 0,
|
||||||
|
failed: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
for (const material of materials) {
|
||||||
|
const materialCode = material.materialCode?.trim()
|
||||||
|
const managerName = material.managerName?.trim() || ''
|
||||||
|
|
||||||
|
if (!materialCode) {
|
||||||
|
stats.failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL} (MaterialCode, ManagerName)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE ManagerName = VALUES(ManagerName)
|
||||||
|
`
|
||||||
|
|
||||||
|
await mysqlService.query(sql, [materialCode, managerName || null])
|
||||||
|
stats.success++
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Error upserting material:', materialCode, error)
|
||||||
|
stats.failed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Batch upsert error:', error)
|
||||||
|
stats.failed = stats.total - stats.success
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== READ ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all material codes as a set
|
||||||
|
* @returns Set of material codes
|
||||||
|
*/
|
||||||
|
async getAllMaterialCodes(): Promise<Set<string>> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT MaterialCode
|
||||||
|
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE MaterialCode IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql)
|
||||||
|
return new Set(result.rows.map(row => row.MaterialCode as string).filter(Boolean))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Get all material codes error:', error)
|
||||||
|
return new Set()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all material records
|
||||||
|
* @returns List of all material records
|
||||||
|
*/
|
||||||
|
async getAllRecords(): Promise<MaterialRecord[]> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT ID, MaterialCode, ManagerName
|
||||||
|
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE MaterialCode IS NOT NULL
|
||||||
|
ORDER BY ManagerName, MaterialCode
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql)
|
||||||
|
return result.rows.map(row => ({
|
||||||
|
id: row.ID as number,
|
||||||
|
materialCode: row.MaterialCode as string,
|
||||||
|
managerName: row.ManagerName as string
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Get all records error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all materials for a specific manager
|
||||||
|
* @param managerName - Manager name
|
||||||
|
* @returns List of materials for the manager
|
||||||
|
*/
|
||||||
|
async getMaterialsByManager(managerName: string): Promise<MaterialRecord[]> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT ID, MaterialCode, ManagerName
|
||||||
|
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE ManagerName = ? AND MaterialCode IS NOT NULL
|
||||||
|
ORDER BY MaterialCode
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, [managerName])
|
||||||
|
return result.rows.map(row => ({
|
||||||
|
id: row.ID as number,
|
||||||
|
materialCode: row.MaterialCode as string,
|
||||||
|
managerName: row.ManagerName as string
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Get materials by manager error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of unique manager names
|
||||||
|
* @returns List of unique manager names
|
||||||
|
*/
|
||||||
|
async getManagers(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT DISTINCT ManagerName
|
||||||
|
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE ManagerName IS NOT NULL
|
||||||
|
ORDER BY ManagerName
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql)
|
||||||
|
return result.rows.map(row => row.ManagerName as string).filter(Boolean)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Get managers error:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific record by material code
|
||||||
|
* @param materialCode - Material code
|
||||||
|
* @returns Material record or null
|
||||||
|
*/
|
||||||
|
async getRecordByMaterialCode(materialCode: string): Promise<MaterialRecord | null> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT ID, MaterialCode, ManagerName
|
||||||
|
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE MaterialCode = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, [materialCode.trim()])
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = result.rows[0]
|
||||||
|
return {
|
||||||
|
id: row.ID as number,
|
||||||
|
materialCode: row.MaterialCode as string,
|
||||||
|
managerName: row.ManagerName as string
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Get record by material code error:', error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DELETE ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a specific material by material code
|
||||||
|
* @param materialCode - Material code
|
||||||
|
* @returns True if successful
|
||||||
|
*/
|
||||||
|
async deleteByMaterialCode(materialCode: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
DELETE FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE MaterialCode = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, [materialCode.trim()])
|
||||||
|
return result.rowCount > 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Delete by material code error:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all materials for a specific manager
|
||||||
|
* @param managerName - Manager name
|
||||||
|
* @returns Number of records deleted
|
||||||
|
*/
|
||||||
|
async deleteByManager(managerName: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
DELETE FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE ManagerName = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, [managerName])
|
||||||
|
return result.rowCount
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Delete by manager error:', error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all material records
|
||||||
|
* @returns Number of records deleted
|
||||||
|
*/
|
||||||
|
async deleteAllMaterials(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `DELETE FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql)
|
||||||
|
return result.rowCount
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Delete all materials error:', error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete multiple materials by material codes
|
||||||
|
* @param materialCodes - List of material codes to delete
|
||||||
|
* @returns Number of records deleted
|
||||||
|
*/
|
||||||
|
async deleteByMaterialCodes(materialCodes: string[]): Promise<number> {
|
||||||
|
if (!materialCodes || materialCodes.length === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalDeleted = 0
|
||||||
|
const batchSize = 1000
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
for (let i = 0; i < materialCodes.length; i += batchSize) {
|
||||||
|
const batch = materialCodes.slice(i, i + batchSize)
|
||||||
|
const placeholders = batch.map(() => '?').join(',')
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
DELETE FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE MaterialCode IN (${placeholders})
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, batch)
|
||||||
|
totalDeleted += result.rowCount
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Delete by material codes error:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalDeleted
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== UTILITIES ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a material exists
|
||||||
|
* @param materialCode - Material code
|
||||||
|
* @returns True if material exists
|
||||||
|
*/
|
||||||
|
async materialExists(materialCode: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE MaterialCode = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, [materialCode.trim()])
|
||||||
|
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Material exists error:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count all material records
|
||||||
|
* @returns Total number of records
|
||||||
|
*/
|
||||||
|
async countAll(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `SELECT COUNT(*) as count FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql)
|
||||||
|
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Count all error:', error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count materials for a specific manager
|
||||||
|
* @param managerName - Manager name
|
||||||
|
* @returns Number of materials for the manager
|
||||||
|
*/
|
||||||
|
async countByManager(managerName: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE ManagerName = ?
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await mysqlService.query(sql, [managerName])
|
||||||
|
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Count by manager error:', error)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comprehensive statistics
|
||||||
|
* @returns Statistics object
|
||||||
|
*/
|
||||||
|
async getStatistics(): Promise<MaterialStats> {
|
||||||
|
try {
|
||||||
|
const mysqlService = await this.getMySqlService()
|
||||||
|
|
||||||
|
// Get total and unique managers
|
||||||
|
const statsSql = `
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as totalMaterials,
|
||||||
|
COUNT(DISTINCT ManagerName) as uniqueManagers
|
||||||
|
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE MaterialCode IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
const statsResult = await mysqlService.query(statsSql)
|
||||||
|
const stats = statsResult.rows[0] || {}
|
||||||
|
|
||||||
|
// Get materials per manager
|
||||||
|
const managerSql = `
|
||||||
|
SELECT ManagerName, COUNT(*) as count
|
||||||
|
FROM ${MATERIALS_TO_BE_DELETED_CONFIG.TABLE_NAME_MYSQL}
|
||||||
|
WHERE ManagerName IS NOT NULL
|
||||||
|
GROUP BY ManagerName
|
||||||
|
ORDER BY count DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
const managerResult = await mysqlService.query(managerSql)
|
||||||
|
const materialsPerManager = managerResult.rows.map(row => ({
|
||||||
|
[row.ManagerName as string]: row.count as number
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalMaterials: (stats.totalMaterials as number) || 0,
|
||||||
|
uniqueManagers: (stats.uniqueManagers as number) || 0,
|
||||||
|
materialsPerManager
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MaterialsToBeDeletedDAO] Get statistics error:', error)
|
||||||
|
return {
|
||||||
|
totalMaterials: 0,
|
||||||
|
uniqueManagers: 0,
|
||||||
|
materialsPerManager: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from database
|
||||||
|
*/
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (this.mysqlService) {
|
||||||
|
await this.mysqlService.disconnect()
|
||||||
|
this.mysqlService = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,17 +78,27 @@ export class MySqlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows, fields] = await this.connection.execute(sql, params)
|
const [result, fields] = await this.connection.execute(sql, params)
|
||||||
|
|
||||||
// Convert rows to plain objects and extract column names
|
// Convert to plain objects and extract column names
|
||||||
const columns = fields.map((field) => field.name)
|
const columns = Array.isArray(fields) ? fields.map((field) => field.name) : []
|
||||||
const rowCount = Array.isArray(rows) ? rows.length : 0
|
|
||||||
|
|
||||||
// Type assertion for rows - mysql2 returns different types based on query
|
// Handle different result types
|
||||||
const typedRows = Array.isArray(rows) ? (rows as Record<string, unknown>[]) : []
|
let rows: Record<string, unknown>[] = []
|
||||||
|
let rowCount = 0
|
||||||
|
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
// SELECT query - result is an array of rows
|
||||||
|
rows = result as Record<string, unknown>[]
|
||||||
|
rowCount = rows.length
|
||||||
|
} else if (typeof result === 'object' && result !== null) {
|
||||||
|
// INSERT/UPDATE/DELETE query - result is OkPacket
|
||||||
|
const okPacket = result as any
|
||||||
|
rowCount = okPacket.affectedRows || okPacket.changedRows || 0
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: typedRows,
|
rows,
|
||||||
columns,
|
columns,
|
||||||
rowCount
|
rowCount
|
||||||
}
|
}
|
||||||
|
|||||||
105
src/main/types/validation.types.ts
Normal file
105
src/main/types/validation.types.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Validation IPC API type definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material validation result
|
||||||
|
*/
|
||||||
|
export interface ValidationResult {
|
||||||
|
materialName: string
|
||||||
|
materialCode: string
|
||||||
|
specification: string
|
||||||
|
model: string
|
||||||
|
managerName: string
|
||||||
|
isMarkedForDeletion: boolean
|
||||||
|
matchedTypeKeyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material validation request
|
||||||
|
*/
|
||||||
|
export interface ValidationRequest {
|
||||||
|
/** Validation mode: 'database_full' or 'database_filtered' */
|
||||||
|
mode: 'database_full' | 'database_filtered'
|
||||||
|
/** Production ID file path (for database_filtered mode) */
|
||||||
|
productionIdFile?: string
|
||||||
|
/** Use shared Production IDs from extractor page */
|
||||||
|
useSharedProductionIds?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation response
|
||||||
|
*/
|
||||||
|
export interface ValidationResponse {
|
||||||
|
success: boolean
|
||||||
|
results?: ValidationResult[]
|
||||||
|
error?: string
|
||||||
|
stats?: {
|
||||||
|
totalRecords: number
|
||||||
|
matchedCount: number
|
||||||
|
markedCount: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material upsert request
|
||||||
|
*/
|
||||||
|
export interface MaterialUpsertRequest {
|
||||||
|
materialCode: string
|
||||||
|
managerName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material upsert batch request
|
||||||
|
*/
|
||||||
|
export interface MaterialUpsertBatchRequest {
|
||||||
|
materials: { materialCode: string; managerName: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material delete request
|
||||||
|
*/
|
||||||
|
export interface MaterialDeleteRequest {
|
||||||
|
materialCodes: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material query by manager request
|
||||||
|
*/
|
||||||
|
export interface MaterialQueryByManagerRequest {
|
||||||
|
managerName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material operations response
|
||||||
|
*/
|
||||||
|
export interface MaterialOperationResponse {
|
||||||
|
success: boolean
|
||||||
|
count?: number
|
||||||
|
stats?: {
|
||||||
|
total: number
|
||||||
|
success: number
|
||||||
|
failed: number
|
||||||
|
}
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manager filter response
|
||||||
|
*/
|
||||||
|
export interface ManagerFilterResponse {
|
||||||
|
managers: string[]
|
||||||
|
materials?: MaterialRecordSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Material record summary
|
||||||
|
*/
|
||||||
|
export interface MaterialRecordSummary {
|
||||||
|
materialCode: string
|
||||||
|
materialName: string
|
||||||
|
specification: string
|
||||||
|
model: string
|
||||||
|
managerName: string
|
||||||
|
isMarked: boolean
|
||||||
|
}
|
||||||
64
src/preload/index.d.ts
vendored
64
src/preload/index.d.ts
vendored
@@ -8,6 +8,7 @@ import type {
|
|||||||
UserSelectionResponse,
|
UserSelectionResponse,
|
||||||
CurrentUserResponse
|
CurrentUserResponse
|
||||||
} from '../main/ipc/auth-handler'
|
} from '../main/ipc/auth-handler'
|
||||||
|
import type { ValidationRequest, ValidationResponse } from '../main/types/validation.types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Order number resolver API
|
* Order number resolver API
|
||||||
@@ -69,6 +70,67 @@ export interface AuthAPI {
|
|||||||
isAdmin: () => Promise<boolean>
|
isAdmin: () => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation API
|
||||||
|
*/
|
||||||
|
export interface ValidationAPI {
|
||||||
|
/**
|
||||||
|
* Run material validation
|
||||||
|
* @param request - Validation request
|
||||||
|
*/
|
||||||
|
validate: (request: ValidationRequest) => Promise<ValidationResponse>
|
||||||
|
/**
|
||||||
|
* Set shared Production IDs from extractor page
|
||||||
|
* @param productionIds - List of Production IDs
|
||||||
|
*/
|
||||||
|
setSharedProductionIds: (productionIds: string[]) => Promise<void>
|
||||||
|
/**
|
||||||
|
* Get shared Production IDs
|
||||||
|
*/
|
||||||
|
getSharedProductionIds: () => Promise<{ productionIds: string[] }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materials API
|
||||||
|
*/
|
||||||
|
export interface MaterialsAPI {
|
||||||
|
/**
|
||||||
|
* Upsert batch materials to MaterialsToBeDeleted
|
||||||
|
* @param materials - List of materials with materialCode and managerName
|
||||||
|
*/
|
||||||
|
upsertBatch: (materials: { materialCode: string; managerName: string }[]) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
stats?: { total: number; success: number; failed: number }
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
/**
|
||||||
|
* Delete materials by material codes
|
||||||
|
* @param materialCodes - List of material codes to delete
|
||||||
|
*/
|
||||||
|
delete: (materialCodes: string[]) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
count?: number
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
|
/**
|
||||||
|
* Get unique manager names
|
||||||
|
*/
|
||||||
|
getManagers: () => Promise<{ managers: string[] }>
|
||||||
|
/**
|
||||||
|
* Get materials by manager
|
||||||
|
* @param managerName - Manager name
|
||||||
|
*/
|
||||||
|
getByManager: (managerName: string) => Promise<{ materials: unknown[] }>
|
||||||
|
/**
|
||||||
|
* Get all material records
|
||||||
|
*/
|
||||||
|
getAll: () => Promise<{ materials: unknown[] }>
|
||||||
|
/**
|
||||||
|
* Get statistics
|
||||||
|
*/
|
||||||
|
getStatistics: () => Promise<{ stats: unknown }>
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
electron: {
|
electron: {
|
||||||
@@ -93,6 +155,8 @@ declare global {
|
|||||||
database: DatabaseAPI
|
database: DatabaseAPI
|
||||||
resolver: ResolverAPI
|
resolver: ResolverAPI
|
||||||
auth: AuthAPI
|
auth: AuthAPI
|
||||||
|
validation: ValidationAPI
|
||||||
|
materials: MaterialsAPI
|
||||||
}
|
}
|
||||||
api: unknown
|
api: unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { CleanerInput } from '../main/types/cleaner.types'
|
|||||||
import type { ResolverInput } from '../main/ipc/resolver-handler'
|
import type { ResolverInput } from '../main/ipc/resolver-handler'
|
||||||
import type { LoginRequest } from '../main/ipc/auth-handler'
|
import type { LoginRequest } from '../main/ipc/auth-handler'
|
||||||
import type { UserInfo } from '../main/types/user.types'
|
import type { UserInfo } from '../main/types/user.types'
|
||||||
|
import type { ValidationRequest } from '../main/types/validation.types'
|
||||||
|
|
||||||
// Custom APIs for renderer
|
// Custom APIs for renderer
|
||||||
const api = {
|
const api = {
|
||||||
@@ -61,6 +62,28 @@ const api = {
|
|||||||
isSqlServerConnected: () => ipcRenderer.invoke('database:sqlserver:isConnected'),
|
isSqlServerConnected: () => ipcRenderer.invoke('database:sqlserver:isConnected'),
|
||||||
querySqlServer: (sql: string, params?: Record<string, unknown>) =>
|
querySqlServer: (sql: string, params?: Record<string, unknown>) =>
|
||||||
ipcRenderer.invoke('database:sqlserver:query', sql, params)
|
ipcRenderer.invoke('database:sqlserver:query', sql, params)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Validation service
|
||||||
|
validation: {
|
||||||
|
validate: (request: ValidationRequest) => ipcRenderer.invoke('validation:validate', request),
|
||||||
|
setSharedProductionIds: (productionIds: string[]) =>
|
||||||
|
ipcRenderer.invoke('validation:setSharedProductionIds', productionIds),
|
||||||
|
getSharedProductionIds: () =>
|
||||||
|
ipcRenderer.invoke('validation:getSharedProductionIds')
|
||||||
|
},
|
||||||
|
|
||||||
|
// Materials service
|
||||||
|
materials: {
|
||||||
|
upsertBatch: (materials: { materialCode: string; managerName: string }[]) =>
|
||||||
|
ipcRenderer.invoke('materials:upsertBatch', { materials }),
|
||||||
|
delete: (materialCodes: string[]) =>
|
||||||
|
ipcRenderer.invoke('materials:delete', { materialCodes }),
|
||||||
|
getManagers: () => ipcRenderer.invoke('materials:getManagers'),
|
||||||
|
getByManager: (managerName: string) =>
|
||||||
|
ipcRenderer.invoke('materials:getByManager', managerName),
|
||||||
|
getAll: () => ipcRenderer.invoke('materials:getAll'),
|
||||||
|
getStatistics: () => ipcRenderer.invoke('materials:getStatistics')
|
||||||
}
|
}
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ function App(): React.JSX.Element {
|
|||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
backgroundColor: '#f5f7fa'
|
backgroundColor: '#f5f7fa'
|
||||||
}}>
|
}}>
|
||||||
{/* Header with user info and logout */}
|
{/* Header with user info, navigation and logout */}
|
||||||
<header style={{
|
<header style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -300,13 +300,61 @@ function App(): React.JSX.Element {
|
|||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||||
marginBottom: '16px'
|
marginBottom: '16px'
|
||||||
}}>
|
}}>
|
||||||
<div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
color: '#666'
|
color: '#666'
|
||||||
}}>
|
}}>
|
||||||
欢迎,{currentUser?.username} ({currentUser?.userType})
|
欢迎,{currentUser?.username} ({currentUser?.userType})
|
||||||
</span>
|
</span>
|
||||||
|
{/* Navigation tabs */}
|
||||||
|
<nav style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage('home')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: currentPage === 'home' ? '#1890ff' : '#f5f5f5',
|
||||||
|
color: currentPage === 'home' ? '#fff' : '#666',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
主页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage('extractor')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: currentPage === 'extractor' ? '#1890ff' : '#f5f5f5',
|
||||||
|
color: currentPage === 'extractor' ? '#fff' : '#666',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
数据提取
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage('cleaner')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: currentPage === 'cleaner' ? '#1890ff' : '#f5f5f5',
|
||||||
|
color: currentPage === 'cleaner' ? '#fff' : '#666',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.3s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
物料清理
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{shouldShowLogout && (
|
{shouldShowLogout && (
|
||||||
@@ -330,34 +378,9 @@ function App(): React.JSX.Element {
|
|||||||
{currentPage === 'home' && (
|
{currentPage === 'home' && (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontSize: '24px', color: '#333', marginBottom: '24px' }}>主页面</h1>
|
<h1 style={{ fontSize: '24px', color: '#333', marginBottom: '24px' }}>主页面</h1>
|
||||||
<div style={{
|
<p style={{ color: '#666', fontSize: '14px' }}>
|
||||||
display: 'grid',
|
请使用顶部导航栏切换到 数据提取 或 物料清理 页面
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
</p>
|
||||||
gap: '16px'
|
|
||||||
}}>
|
|
||||||
<div onClick={() => setCurrentPage('extractor')} style={{
|
|
||||||
padding: '24px',
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<h3 style={{ margin: '0 0 8px 0', color: '#1890ff' }}>数据提取</h3>
|
|
||||||
<p style={{ margin: 0, color: '#666', fontSize: '14px' }}>提取 ERP 数据</p>
|
|
||||||
</div>
|
|
||||||
<div onClick={() => setCurrentPage('cleaner')} style={{
|
|
||||||
padding: '24px',
|
|
||||||
backgroundColor: '#fff',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
|
||||||
cursor: 'pointer',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<h3 style={{ margin: '0 0 8px 0', color: '#52c41a' }}>物料清理</h3>
|
|
||||||
<p style={{ margin: 0, color: '#666', fontSize: '14px' }}>清理 ERP 物料</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,399 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState, useCallback, useEffect } from 'react'
|
||||||
import { OrderNumberInput } from '../components/OrderNumberInput'
|
|
||||||
import { MaterialCodeInput } from '../components/MaterialCodeInput'
|
|
||||||
|
|
||||||
interface CleanerResult {
|
/**
|
||||||
ordersProcessed: number
|
* Material validation result interface
|
||||||
materialsDeleted: number
|
*/
|
||||||
materialsSkipped: number
|
interface ValidationResult {
|
||||||
errors: string[]
|
materialName: string
|
||||||
details: Array<{
|
materialCode: string
|
||||||
orderNumber: string
|
specification: string
|
||||||
materialsDeleted: number
|
model: string
|
||||||
materialsSkipped: number
|
managerName: string
|
||||||
errors: string[]
|
isMarkedForDeletion: boolean
|
||||||
}>
|
matchedTypeKeyword?: string
|
||||||
}
|
|
||||||
|
|
||||||
interface CleanerProgress {
|
|
||||||
message: string
|
|
||||||
progress?: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CleanerPage - Main page for ERP material cleaning
|
* CleanerPage with material validation functionality
|
||||||
|
*
|
||||||
|
* This component provides the same functionality as the Python MaterialValidationTab:
|
||||||
|
* - Material validation from database (full table or filtered by ProductionID)
|
||||||
|
* - Checkbox selection for materials to delete
|
||||||
|
* - Manager-based filtering (Admin only)
|
||||||
|
* - Two-phase deletion: confirm (mark in DB) + execute (delete from ERP)
|
||||||
*/
|
*/
|
||||||
export const CleanerPage: React.FC = () => {
|
export const CleanerPage: React.FC = () => {
|
||||||
const [orderNumbers, setOrderNumbers] = useState('')
|
// State with sessionStorage persistence
|
||||||
const [materialCodes, setMaterialCodes] = useState('')
|
const [orderNumbers, setOrderNumbers] = useState(() => {
|
||||||
const [dryRun, setDryRun] = useState(true)
|
return sessionStorage.getItem('cleaner_orderNumbers') || ''
|
||||||
|
})
|
||||||
|
const [materialCodes, setMaterialCodes] = useState(() => {
|
||||||
|
return sessionStorage.getItem('cleaner_materialCodes') || ''
|
||||||
|
})
|
||||||
|
const [dryRun, setDryRun] = useState(() => {
|
||||||
|
const saved = sessionStorage.getItem('cleaner_dryRun')
|
||||||
|
return saved ? saved === 'true' : true
|
||||||
|
})
|
||||||
|
const [validationMode, setValidationMode] = useState<'database_full' | 'database_filtered'>(() => {
|
||||||
|
const saved = sessionStorage.getItem('cleaner_validationMode') as 'database_full' | 'database_filtered'
|
||||||
|
return saved || 'database_filtered'
|
||||||
|
})
|
||||||
|
const [useSharedProductionIds, setUseSharedProductionIds] = useState(() => {
|
||||||
|
const saved = sessionStorage.getItem('cleaner_useSharedProductionIds')
|
||||||
|
return saved ? saved === 'true' : true
|
||||||
|
})
|
||||||
|
|
||||||
const [isRunning, setIsRunning] = useState(false)
|
const [isRunning, setIsRunning] = useState(false)
|
||||||
const [progress, setProgress] = useState<CleanerProgress | null>(null)
|
const [progress, setProgress] = useState<{ message: string; progress?: number } | null>(null)
|
||||||
const [result, setResult] = useState<CleanerResult | null>(null)
|
const [result, setResult] = useState<{
|
||||||
|
ordersProcessed: number
|
||||||
|
materialsDeleted: number
|
||||||
|
materialsSkipped: number
|
||||||
|
errors: string[]
|
||||||
|
details: Array<{
|
||||||
|
orderNumber: string
|
||||||
|
materialsDeleted: number
|
||||||
|
materialsSkipped: number
|
||||||
|
errors: string[]
|
||||||
|
}>
|
||||||
|
} | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
const handleClean = async () => {
|
// Validation state
|
||||||
|
const [validationResults, setValidationResults] = useState<ValidationResult[]>([])
|
||||||
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set())
|
||||||
|
const [managers, setManagers] = useState<string[]>([])
|
||||||
|
const [selectedManagers, setSelectedManagers] = useState<Set<string>>(new Set())
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false)
|
||||||
|
const [currentUsername, setCurrentUsername] = useState<string>('')
|
||||||
|
const [isValidationRunning, setIsValidationRunning] = useState(false)
|
||||||
|
const [validationStats, setValidationStats] = useState<{
|
||||||
|
totalRecords: number
|
||||||
|
matchedCount: number
|
||||||
|
markedCount: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
// Hidden items state (for user mode hide checked feature)
|
||||||
|
const [hiddenItems, setHiddenItems] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Filter validation results by selected managers and hidden items
|
||||||
|
const filteredResults = React.useMemo(() => {
|
||||||
|
let results = validationResults
|
||||||
|
if (!isAdmin && currentUsername) {
|
||||||
|
// Non-admin users only see their own data
|
||||||
|
results = results.filter(r => r.managerName === currentUsername || !r.managerName)
|
||||||
|
} else if (managers.length > 0 && selectedManagers.size > 0) {
|
||||||
|
// Admin users see selected managers' data
|
||||||
|
results = results.filter(
|
||||||
|
r => selectedManagers.has(r.managerName) || !r.managerName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Filter out hidden items
|
||||||
|
results = results.filter(r => !hiddenItems.has(r.materialCode))
|
||||||
|
return results
|
||||||
|
}, [validationResults, isAdmin, currentUsername, managers, selectedManagers, hiddenItems])
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('cleaner_orderNumbers', orderNumbers)
|
||||||
|
}, [orderNumbers])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('cleaner_materialCodes', materialCodes)
|
||||||
|
}, [materialCodes])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('cleaner_dryRun', dryRun.toString())
|
||||||
|
}, [dryRun])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('cleaner_validationMode', validationMode)
|
||||||
|
}, [validationMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('cleaner_useSharedProductionIds', useSharedProductionIds.toString())
|
||||||
|
}, [useSharedProductionIds])
|
||||||
|
|
||||||
|
// Shared Production IDs state
|
||||||
|
const [sharedProductionIdsCount, setSharedProductionIdsCount] = useState(0)
|
||||||
|
|
||||||
|
// Check admin status and get shared Production IDs on mount
|
||||||
|
React.useEffect(() => {
|
||||||
|
const initializePage = async () => {
|
||||||
|
const admin = await window.electron.auth.isAdmin()
|
||||||
|
const user = await window.electron.auth.getCurrentUser()
|
||||||
|
setIsAdmin(admin)
|
||||||
|
if (user) {
|
||||||
|
setCurrentUsername(user.username)
|
||||||
|
if (!admin) {
|
||||||
|
// Non-admin users can only see their own data
|
||||||
|
setSelectedManagers(new Set([user.username]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get shared Production IDs
|
||||||
|
try {
|
||||||
|
const result = await window.electron.validation.getSharedProductionIds()
|
||||||
|
setSharedProductionIdsCount(result.productionIds.length)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to get shared Production IDs:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initializePage()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load managers for filter (Admin only)
|
||||||
|
const loadManagers = useCallback(async () => {
|
||||||
|
if (!isAdmin) return
|
||||||
|
try {
|
||||||
|
const response = await window.electron.materials.getManagers()
|
||||||
|
setManagers(response.managers)
|
||||||
|
// Select all managers by default
|
||||||
|
setSelectedManagers(new Set(response.managers))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load managers:', err)
|
||||||
|
}
|
||||||
|
}, [isAdmin])
|
||||||
|
|
||||||
|
// Handle validation
|
||||||
|
const handleValidation = useCallback(async () => {
|
||||||
|
setIsValidationRunning(true)
|
||||||
|
setError(null)
|
||||||
|
setValidationResults([])
|
||||||
|
setSelectedItems(new Set())
|
||||||
|
setValidationStats(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await window.electron.validation.validate({
|
||||||
|
mode: validationMode,
|
||||||
|
useSharedProductionIds: validationMode === 'database_filtered' ? useSharedProductionIds : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success && response.results) {
|
||||||
|
setValidationResults(response.results)
|
||||||
|
setValidationStats(response.stats || null)
|
||||||
|
|
||||||
|
// Auto-select items that are already marked for deletion
|
||||||
|
const markedCodes = new Set(
|
||||||
|
response.results
|
||||||
|
.filter(r => r.isMarkedForDeletion)
|
||||||
|
.map(r => r.materialCode)
|
||||||
|
)
|
||||||
|
setSelectedItems(markedCodes)
|
||||||
|
|
||||||
|
// Auto-select managers for admin users
|
||||||
|
if (isAdmin) {
|
||||||
|
const uniqueManagers = new Set(
|
||||||
|
response.results
|
||||||
|
.map(r => r.managerName)
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
setManagers([...uniqueManagers])
|
||||||
|
setSelectedManagers(uniqueManagers)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(response.error || '校验失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '校验过程中发生未知错误')
|
||||||
|
} finally {
|
||||||
|
setIsValidationRunning(false)
|
||||||
|
}
|
||||||
|
}, [validationMode, useSharedProductionIds, isAdmin])
|
||||||
|
|
||||||
|
// Handle checkbox toggle
|
||||||
|
const handleCheckboxToggle = useCallback((materialCode: string) => {
|
||||||
|
setSelectedItems(prev => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
if (newSet.has(materialCode)) {
|
||||||
|
newSet.delete(materialCode)
|
||||||
|
} else {
|
||||||
|
newSet.add(materialCode)
|
||||||
|
}
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle select all
|
||||||
|
const handleSelectAll = useCallback(() => {
|
||||||
|
setSelectedItems(new Set(filteredResults.map(r => r.materialCode)))
|
||||||
|
}, [filteredResults])
|
||||||
|
|
||||||
|
// Handle deselect all
|
||||||
|
const handleDeselectAll = useCallback(() => {
|
||||||
|
setSelectedItems(new Set())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Handle confirm deletion (mark materials in database)
|
||||||
|
const handleConfirmDeletion = useCallback(async () => {
|
||||||
|
if (validationResults.length === 0) {
|
||||||
|
alert('没有可处理的数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all data
|
||||||
|
const materialsToUpsert: { materialCode: string; managerName: string }[] = []
|
||||||
|
const materialsToDelete: string[] = []
|
||||||
|
const missingManager: string[] = []
|
||||||
|
|
||||||
|
for (const result of validationResults) {
|
||||||
|
// Skip empty material codes
|
||||||
|
if (!result.materialCode || !result.materialCode.trim()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const materialCode = result.materialCode.trim()
|
||||||
|
const isChecked = selectedItems.has(materialCode)
|
||||||
|
|
||||||
|
if (isChecked) {
|
||||||
|
// Checked: needs to be upserted
|
||||||
|
if (!result.managerName || !result.managerName.trim()) {
|
||||||
|
missingManager.push(materialCode)
|
||||||
|
} else {
|
||||||
|
materialsToUpsert.push({
|
||||||
|
materialCode,
|
||||||
|
managerName: result.managerName.trim()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unchecked: needs to be deleted
|
||||||
|
materialsToDelete.push(materialCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate: checked items must have manager names
|
||||||
|
if (missingManager.length > 0) {
|
||||||
|
const msg = `以下已勾选的记录缺少负责人信息,无法保存:\n\n` +
|
||||||
|
missingManager.slice(0, 10).join('\n') +
|
||||||
|
(missingManager.length > 10 ? `\n... 共 ${missingManager.length} 条` : '')
|
||||||
|
alert(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's anything to do
|
||||||
|
if (materialsToUpsert.length === 0 && materialsToDelete.length === 0) {
|
||||||
|
alert('没有需要处理的记录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build confirmation message
|
||||||
|
const confirmParts: string[] = []
|
||||||
|
if (materialsToUpsert.length > 0) {
|
||||||
|
confirmParts.push(`写入/更新 ${materialsToUpsert.length} 条记录`)
|
||||||
|
}
|
||||||
|
if (materialsToDelete.length > 0) {
|
||||||
|
confirmParts.push(`删除 ${materialsToDelete.length} 条记录`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = window.confirm(`确认以下操作吗?\n\n${confirmParts.join('\n')}`)
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the operation
|
||||||
|
try {
|
||||||
|
setIsRunning(true)
|
||||||
|
|
||||||
|
// Upsert selected materials
|
||||||
|
let upsertSuccess = 0
|
||||||
|
let upsertFailed = 0
|
||||||
|
if (materialsToUpsert.length > 0) {
|
||||||
|
const upsertResult = await window.electron.materials.upsertBatch(materialsToUpsert)
|
||||||
|
if (upsertResult.success) {
|
||||||
|
upsertSuccess = upsertResult.stats?.success || 0
|
||||||
|
upsertFailed = upsertResult.stats?.failed || 0
|
||||||
|
} else {
|
||||||
|
throw new Error(upsertResult.error || '写入物料失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete unselected materials
|
||||||
|
let deleteCount = 0
|
||||||
|
if (materialsToDelete.length > 0) {
|
||||||
|
const deleteResult = await window.electron.materials.delete(materialsToDelete)
|
||||||
|
if (deleteResult.success) {
|
||||||
|
deleteCount = deleteResult.count || 0
|
||||||
|
} else {
|
||||||
|
throw new Error(deleteResult.error || '删除物料失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show result message
|
||||||
|
const msgParts: string[] = []
|
||||||
|
if (upsertSuccess > 0) {
|
||||||
|
msgParts.push(`写入/更新成功:${upsertSuccess} 条`)
|
||||||
|
}
|
||||||
|
if (upsertFailed > 0) {
|
||||||
|
msgParts.push(`写入/更新失败:${upsertFailed} 条`)
|
||||||
|
}
|
||||||
|
if (deleteCount > 0) {
|
||||||
|
msgParts.push(`删除成功:${deleteCount} 条`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = `操作完成!\n\n${msgParts.join('\n')}`
|
||||||
|
|
||||||
|
if (upsertFailed > 0) {
|
||||||
|
alert(`完成(部分失败)\n\n${msg}`)
|
||||||
|
} else {
|
||||||
|
alert(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload managers to reflect changes
|
||||||
|
await loadManagers()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '操作失败')
|
||||||
|
} finally {
|
||||||
|
setIsRunning(false)
|
||||||
|
}
|
||||||
|
}, [selectedItems, validationResults, loadManagers])
|
||||||
|
|
||||||
|
// Handle export results
|
||||||
|
const handleExportResults = useCallback(async () => {
|
||||||
|
if (validationResults.length === 0) {
|
||||||
|
setError('没有数据可导出')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Prepare data for export
|
||||||
|
const data = validationResults.map(result => ({
|
||||||
|
'选择': selectedItems.has(result.materialCode) ? '是' : '否',
|
||||||
|
'材料名称': result.materialName,
|
||||||
|
'材料代码': result.materialCode,
|
||||||
|
'规格': result.specification,
|
||||||
|
'型号': result.model,
|
||||||
|
'负责人': result.managerName || ''
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Create CSV content
|
||||||
|
const headers = ['选择', '材料名称', '材料代码', '规格', '型号', '负责人']
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...data.map(row =>
|
||||||
|
headers.map(header => {
|
||||||
|
const value = row[header as keyof typeof row]
|
||||||
|
// Escape quotes and wrap in quotes if contains comma
|
||||||
|
const escaped = String(value).replace(/"/g, '""')
|
||||||
|
return escaped.includes(',') ? `"${escaped}"` : escaped
|
||||||
|
}).join(',')
|
||||||
|
)
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
// Create blob and download
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const link = document.createElement('a')
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
link.setAttribute('href', url)
|
||||||
|
link.setAttribute('download', `物料状态校验结果_${new Date().getTime()}.csv`)
|
||||||
|
link.style.visibility = 'hidden'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '导出失败')
|
||||||
|
}
|
||||||
|
}, [validationResults, selectedItems])
|
||||||
|
|
||||||
|
// Handle execute deletion
|
||||||
|
const handleExecuteDeletion = useCallback(async () => {
|
||||||
if (!orderNumbers.trim()) {
|
if (!orderNumbers.trim()) {
|
||||||
setError('请输入至少一个订单号')
|
setError('请输入至少一个订单号')
|
||||||
return
|
return
|
||||||
@@ -42,6 +403,11 @@ export const CleanerPage: React.FC = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
const confirmed = window.confirm('警告:正式执行将删除 ERP 系统中的物料数据,是否继续?')
|
||||||
|
if (!confirmed) return
|
||||||
|
}
|
||||||
|
|
||||||
setIsRunning(true)
|
setIsRunning(true)
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
setResult(null)
|
setResult(null)
|
||||||
@@ -50,15 +416,14 @@ export const CleanerPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const orderNumberList = orderNumbers
|
const orderNumberList = orderNumbers
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => line.trim())
|
.map(line => line.trim())
|
||||||
.filter((line) => line.length > 0)
|
.filter(line => line.length > 0)
|
||||||
|
|
||||||
const materialCodeList = materialCodes
|
const materialCodeList = materialCodes
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map((line) => line.trim())
|
.map(line => line.trim())
|
||||||
.filter((line) => line.length > 0)
|
.filter(line => line.length > 0)
|
||||||
|
|
||||||
// Call cleaner API through electron
|
|
||||||
const response = await window.electron.cleaner.runCleaner({
|
const response = await window.electron.cleaner.runCleaner({
|
||||||
orderNumbers: orderNumberList,
|
orderNumbers: orderNumberList,
|
||||||
materialCodes: materialCodeList,
|
materialCodes: materialCodeList,
|
||||||
@@ -76,37 +441,273 @@ export const CleanerPage: React.FC = () => {
|
|||||||
setIsRunning(false)
|
setIsRunning(false)
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
}
|
}
|
||||||
}
|
}, [orderNumbers, materialCodes, dryRun])
|
||||||
|
|
||||||
const handleReset = () => {
|
// Handle clean (alias for execute deletion)
|
||||||
|
const handleClean = useCallback(handleExecuteDeletion, [handleExecuteDeletion])
|
||||||
|
|
||||||
|
// Handle reset
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
setOrderNumbers('')
|
setOrderNumbers('')
|
||||||
setMaterialCodes('')
|
setMaterialCodes('')
|
||||||
setDryRun(true)
|
setDryRun(true)
|
||||||
setResult(null)
|
setResult(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
|
// Handle hide checked items (for user mode)
|
||||||
|
const handleHideChecked = useCallback(() => {
|
||||||
|
const checkedCodes = filteredResults
|
||||||
|
.filter(r => selectedItems.has(r.materialCode))
|
||||||
|
.map(r => r.materialCode)
|
||||||
|
|
||||||
|
if (checkedCodes.length === 0) {
|
||||||
|
setError('没有已勾选的物料')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setHiddenItems(prev => new Set([...prev, ...checkedCodes]))
|
||||||
|
}, [filteredResults, selectedItems])
|
||||||
|
|
||||||
|
// Handle show all items
|
||||||
|
const handleShowAll = useCallback(() => {
|
||||||
|
setHiddenItems(new Set())
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cleaner-page">
|
<div className="cleaner-page">
|
||||||
<h1 className="page-title">ERP 物料清理</h1>
|
<h1 className="page-title">ERP 物料清理</h1>
|
||||||
|
|
||||||
<div className="cleaner-content">
|
<div className="cleaner-content">
|
||||||
{/* Input Section */}
|
{/* Validation Section (Admin only for mode selection) */}
|
||||||
<div className="input-section">
|
<div className="validation-section">
|
||||||
<OrderNumberInput
|
<h2>物料校验</h2>
|
||||||
value={orderNumbers}
|
|
||||||
onChange={setOrderNumbers}
|
|
||||||
label="订单号列表"
|
|
||||||
placeholder="请输入订单号,每行一个"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MaterialCodeInput
|
{isAdmin && (
|
||||||
value={materialCodes}
|
<div className="validation-mode-selector">
|
||||||
onChange={setMaterialCodes}
|
<label>
|
||||||
label="物料代码列表"
|
<input
|
||||||
placeholder="请输入物料代码,每行一个"
|
type="radio"
|
||||||
/>
|
checked={validationMode === 'database_full'}
|
||||||
|
onChange={() => setValidationMode('database_full')}
|
||||||
|
disabled={isValidationRunning}
|
||||||
|
/>
|
||||||
|
数据库 - 全表校验
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
checked={validationMode === 'database_filtered'}
|
||||||
|
onChange={() => setValidationMode('database_filtered')}
|
||||||
|
disabled={isValidationRunning}
|
||||||
|
/>
|
||||||
|
数据库 - ProductionID 过滤
|
||||||
|
</label>
|
||||||
|
{validationMode === 'database_filtered' && (
|
||||||
|
<div style={{ marginTop: '8px', marginLeft: '24px' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useSharedProductionIds}
|
||||||
|
onChange={(e) => setUseSharedProductionIds(e.target.checked)}
|
||||||
|
disabled={isValidationRunning}
|
||||||
|
/>
|
||||||
|
使用数据提取页面的 Production ID
|
||||||
|
</label>
|
||||||
|
{useSharedProductionIds && (
|
||||||
|
<p style={{ margin: '4px 0 0 0', fontSize: '12px', color: sharedProductionIdsCount > 0 ? '#52c41a' : '#fa8c16' }}>
|
||||||
|
{sharedProductionIdsCount > 0
|
||||||
|
? `当前有 ${sharedProductionIdsCount} 个共享的 Production ID`
|
||||||
|
: '暂无共享的 Production ID,请先在数据提取页面输入'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAdmin && (
|
||||||
|
<p style={{ fontSize: '14px', color: '#666', marginBottom: '16px' }}>
|
||||||
|
当前模式:数据库 - ProductionID 过滤(使用数据提取页面的数据)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="button-group">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleValidation}
|
||||||
|
disabled={isValidationRunning}
|
||||||
|
>
|
||||||
|
{isValidationRunning ? '校验中...' : '开始校验'}
|
||||||
|
</button>
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
disabled={filteredResults.length === 0}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleDeselectAll}
|
||||||
|
disabled={selectedItems.size === 0}
|
||||||
|
>
|
||||||
|
取消全选
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-warning"
|
||||||
|
onClick={handleConfirmDeletion}
|
||||||
|
disabled={selectedItems.size === 0 || isRunning}
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validationStats && (
|
||||||
|
<div className="validation-stats">
|
||||||
|
<span>总记录数:{validationStats.totalRecords}</span>
|
||||||
|
<span>匹配到负责人:{validationStats.matchedCount}</span>
|
||||||
|
<span>已标记删除:{validationStats.markedCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Additional action buttons */}
|
||||||
|
<div className="button-group" style={{ marginTop: '12px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleExportResults}
|
||||||
|
disabled={validationResults.length === 0}
|
||||||
|
>
|
||||||
|
导出结果
|
||||||
|
</button>
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleExecuteDeletion}
|
||||||
|
disabled={!orderNumbers.trim() || !materialCodes.trim() || isRunning}
|
||||||
|
>
|
||||||
|
{isRunning ? '清理中...' : '执行删除'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manager Filter (Admin only) */}
|
||||||
|
{isAdmin && managers.length > 0 && (
|
||||||
|
<div className="manager-filter-section">
|
||||||
|
<h3>筛选(按负责人)</h3>
|
||||||
|
<div className="manager-checkboxes">
|
||||||
|
{managers.map(manager => (
|
||||||
|
<label key={manager}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedManagers.has(manager)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedManagers(prev => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
if (e.target.checked) {
|
||||||
|
newSet.add(manager)
|
||||||
|
} else {
|
||||||
|
newSet.delete(manager)
|
||||||
|
}
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{manager}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validation Results Table */}
|
||||||
|
{filteredResults.length > 0 && (
|
||||||
|
<div className="validation-results-section">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
||||||
|
<h2 style={{ margin: 0 }}>校验结果</h2>
|
||||||
|
{/* User mode buttons: Hide Checked / Show All */}
|
||||||
|
{!isAdmin && (
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleHideChecked}
|
||||||
|
disabled={selectedItems.size === 0}
|
||||||
|
>
|
||||||
|
隐藏勾选
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleShowAll}
|
||||||
|
disabled={hiddenItems.size === 0}
|
||||||
|
>
|
||||||
|
显示全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<table className="validation-results-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>选择</th>
|
||||||
|
<th>材料名称</th>
|
||||||
|
<th>材料代码</th>
|
||||||
|
<th>规格</th>
|
||||||
|
<th>型号</th>
|
||||||
|
<th>负责人</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredResults.map(result => (
|
||||||
|
<tr key={result.materialCode}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedItems.has(result.materialCode)}
|
||||||
|
onChange={() => handleCheckboxToggle(result.materialCode)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{result.materialName}</td>
|
||||||
|
<td>{result.materialCode}</td>
|
||||||
|
<td>{result.specification}</td>
|
||||||
|
<td>{result.model}</td>
|
||||||
|
<td>{result.managerName || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Simple Input Section */}
|
||||||
|
<div className="input-section">
|
||||||
|
<h2>订单号和物料代码输入</h2>
|
||||||
|
<div className="input-row">
|
||||||
|
<div className="input-group">
|
||||||
|
<label>订单号列表</label>
|
||||||
|
<textarea
|
||||||
|
value={orderNumbers}
|
||||||
|
onChange={(e) => setOrderNumbers(e.target.value)}
|
||||||
|
placeholder="请输入订单号,每行一个"
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="input-group">
|
||||||
|
<label>物料代码列表</label>
|
||||||
|
<textarea
|
||||||
|
value={materialCodes}
|
||||||
|
onChange={(e) => setMaterialCodes(e.target.value)}
|
||||||
|
placeholder="请输入物料代码,每行一个"
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="dry-run-option">
|
<div className="dry-run-option">
|
||||||
<label>
|
<label>
|
||||||
@@ -150,7 +751,13 @@ export const CleanerPage: React.FC = () => {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="error-section">
|
<div className="error-section">
|
||||||
<h3>错误</h3>
|
<h3>错误</h3>
|
||||||
<p>{error}</p>
|
<div className="error-message" title="双击可选中复制" onDoubleClick={(e) => {
|
||||||
|
const text = e.currentTarget.textContent
|
||||||
|
navigator.clipboard.writeText(text || '')
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
<p className="error-hint">💡 提示:双击错误信息可选中复制</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -226,7 +833,7 @@ export const CleanerPage: React.FC = () => {
|
|||||||
<style>{`
|
<style>{`
|
||||||
.cleaner-page {
|
.cleaner-page {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
max-width: 800px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
.page-title {
|
.page-title {
|
||||||
@@ -241,16 +848,23 @@ export const CleanerPage: React.FC = () => {
|
|||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
.input-section {
|
.validation-section, .manager-filter-section, .input-section {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
}
|
}
|
||||||
.dry-run-option {
|
.validation-section h2, .manager-filter-section h3, .input-section h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #333;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
padding: 12px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
}
|
||||||
.dry-run-option label {
|
.validation-mode-selector {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.validation-mode-selector label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -258,17 +872,10 @@ export const CleanerPage: React.FC = () => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
.dry-run-option input[type="checkbox"] {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.dry-run-option input:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.button-group {
|
.button-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
padding: 10px 24px;
|
padding: 10px 24px;
|
||||||
@@ -297,6 +904,113 @@ export const CleanerPage: React.FC = () => {
|
|||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: #e8e8e8;
|
background: #e8e8e8;
|
||||||
}
|
}
|
||||||
|
.btn-warning {
|
||||||
|
background: #fa8c16;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-warning:hover:not(:disabled) {
|
||||||
|
background: #ffc069;
|
||||||
|
}
|
||||||
|
.validation-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f6ffed;
|
||||||
|
border: 1px solid #b7eb8f;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
.validation-stats span {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.manager-filter-section {
|
||||||
|
background: #fafafa;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.manager-checkboxes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.manager-checkboxes label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
background: #fff;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
}
|
||||||
|
.validation-results-section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.validation-results-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.validation-results-table th, .validation-results-table td {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
text-align: left;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.validation-results-table th {
|
||||||
|
background: #fafafa;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.validation-results-table tr:nth-child(even) {
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.validation-results-table tr:hover {
|
||||||
|
background: #e6f7ff;
|
||||||
|
}
|
||||||
|
.input-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.input-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.input-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.input-group textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #52c41a;
|
||||||
|
box-shadow: 0 0 0 2px rgba(82, 196, 26, 0.2);
|
||||||
|
}
|
||||||
|
.dry-run-option {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.dry-run-option label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
.progress-section {
|
.progress-section {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -332,9 +1046,27 @@ export const CleanerPage: React.FC = () => {
|
|||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
.error-section p {
|
.error-message {
|
||||||
color: #666;
|
background: #fff;
|
||||||
margin: 0;
|
padding: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
border: 1px solid #ffccc7;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
cursor: text;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.error-message:hover {
|
||||||
|
background: #fffbfc;
|
||||||
|
}
|
||||||
|
.error-hint {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 8px 0 0 0;
|
||||||
}
|
}
|
||||||
.result-section {
|
.result-section {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
@@ -434,6 +1166,11 @@ export const CleanerPage: React.FC = () => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #fa8c16;
|
color: #fa8c16;
|
||||||
}
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.input-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { OrderNumberInput } from '../components/OrderNumberInput'
|
import { OrderNumberInput } from '../components/OrderNumberInput'
|
||||||
|
|
||||||
// Extractor result type (matches the type from main process)
|
// Extractor result type (matches the type from main process)
|
||||||
@@ -18,12 +18,60 @@ interface ExtractorProgress {
|
|||||||
* ExtractorPage - Main page for ERP data extraction
|
* ExtractorPage - Main page for ERP data extraction
|
||||||
*/
|
*/
|
||||||
export const ExtractorPage: React.FC = () => {
|
export const ExtractorPage: React.FC = () => {
|
||||||
const [orderNumbers, setOrderNumbers] = useState('')
|
const [orderNumbers, setOrderNumbers] = useState(() => {
|
||||||
const [batchSize, setBatchSize] = useState(100)
|
// Restore from sessionStorage on mount
|
||||||
|
return sessionStorage.getItem('extractor_orderNumbers') || ''
|
||||||
|
})
|
||||||
|
const [batchSize, setBatchSize] = useState(() => {
|
||||||
|
const saved = sessionStorage.getItem('extractor_batchSize')
|
||||||
|
return saved ? parseInt(saved, 10) : 100
|
||||||
|
})
|
||||||
const [isRunning, setIsRunning] = useState(false)
|
const [isRunning, setIsRunning] = useState(false)
|
||||||
const [progress, setProgress] = useState<ExtractorProgress | null>(null)
|
const [progress, setProgress] = useState<ExtractorProgress | null>(null)
|
||||||
const [result, setResult] = useState<ExtractorResult | null>(null)
|
const [result, setResult] = useState<ExtractorResult | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'saved' | 'unsaved' | 'saving'>('unsaved')
|
||||||
|
|
||||||
|
// Save to sessionStorage when orderNumbers changes
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('extractor_orderNumbers', orderNumbers)
|
||||||
|
// Update shared Production IDs when orderNumbers changes
|
||||||
|
if (orderNumbers.trim()) {
|
||||||
|
const orderNumberList = orderNumbers
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line.length > 0)
|
||||||
|
window.electron.validation.setSharedProductionIds(orderNumberList)
|
||||||
|
}
|
||||||
|
}, [orderNumbers])
|
||||||
|
|
||||||
|
// Save to sessionStorage when batchSize changes
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('extractor_batchSize', batchSize.toString())
|
||||||
|
}, [batchSize])
|
||||||
|
|
||||||
|
const saveSharedProductionIds = async () => {
|
||||||
|
if (!orderNumbers.trim()) {
|
||||||
|
setError('请输入至少一个订单号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveStatus('saving')
|
||||||
|
try {
|
||||||
|
const orderNumberList = orderNumbers
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
|
||||||
|
await window.electron.validation.setSharedProductionIds(orderNumberList)
|
||||||
|
console.log(`[Extractor] Stored ${orderNumberList.length} Production IDs for sharing`)
|
||||||
|
setSaveStatus('saved')
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '保存失败')
|
||||||
|
setSaveStatus('unsaved')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleExtract = async () => {
|
const handleExtract = async () => {
|
||||||
if (!orderNumbers.trim()) {
|
if (!orderNumbers.trim()) {
|
||||||
@@ -42,6 +90,10 @@ export const ExtractorPage: React.FC = () => {
|
|||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter((line) => line.length > 0)
|
.filter((line) => line.length > 0)
|
||||||
|
|
||||||
|
// Store Production IDs for sharing with cleaner page (before extraction starts)
|
||||||
|
await window.electron.validation.setSharedProductionIds(orderNumberList)
|
||||||
|
console.log(`[Extractor] Stored ${orderNumberList.length} Production IDs for sharing`)
|
||||||
|
|
||||||
// Call extractor API through electron
|
// Call extractor API through electron
|
||||||
const response = await window.electron.extractor.runExtractor({
|
const response = await window.electron.extractor.runExtractor({
|
||||||
orderNumbers: orderNumberList,
|
orderNumbers: orderNumberList,
|
||||||
@@ -104,6 +156,14 @@ export const ExtractorPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{isRunning ? '提取中...' : '开始提取'}
|
{isRunning ? '提取中...' : '开始提取'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={saveSharedProductionIds}
|
||||||
|
disabled={isRunning || !orderNumbers.trim() || saveStatus === 'saved'}
|
||||||
|
title="保存订单号以便在清理页面使用"
|
||||||
|
>
|
||||||
|
{saveStatus === 'saving' ? '保存中...' : saveStatus === 'saved' ? '已保存' : '保存为共享 ID'}
|
||||||
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={handleReset} disabled={isRunning}>
|
<button className="btn btn-secondary" onClick={handleReset} disabled={isRunning}>
|
||||||
重置
|
重置
|
||||||
</button>
|
</button>
|
||||||
@@ -254,6 +314,10 @@ export const ExtractorPage: React.FC = () => {
|
|||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background: #e8e8e8;
|
background: #e8e8e8;
|
||||||
}
|
}
|
||||||
|
.btn-secondary[data-saved="true"] {
|
||||||
|
background: #52c41a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
.progress-section {
|
.progress-section {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|||||||
Reference in New Issue
Block a user