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:
Misaka
2026-03-01 19:15:38 +08:00
parent fad08796f7
commit 0f1e0a8908
11 changed files with 2729 additions and 107 deletions

View File

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

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

View 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
}
}
}

View 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
}
}
}

View File

@@ -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
} }

View 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
}

View File

@@ -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
} }

View File

@@ -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

View File

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

View File

@@ -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>
) )

View File

@@ -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;