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 { registerResolverHandlers } from './resolver-handler'
import { registerAuthHandlers } from './auth-handler'
import { registerValidationHandlers } from './validation-handler'
/**
* Register all IPC handlers
@@ -20,4 +21,5 @@ export function registerIpcHandlers(): void {
registerDatabaseHandlers()
registerResolverHandlers()
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 {
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
const columns = fields.map((field) => field.name)
const rowCount = Array.isArray(rows) ? rows.length : 0
// Convert to plain objects and extract column names
const columns = Array.isArray(fields) ? fields.map((field) => field.name) : []
// Type assertion for rows - mysql2 returns different types based on query
const typedRows = Array.isArray(rows) ? (rows as Record<string, unknown>[]) : []
// Handle different result types
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 {
rows: typedRows,
rows,
columns,
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
}