fix(db): complete PostgreSQL integration in validation and cleaner services

OrderNumberResolver, validation, and cleaner services had incomplete
PostgreSQL support - they only handled SQL Server and MySQL, causing
PostgreSQL to fall through to MySQL code paths with invalid syntax
(backticks, ? placeholders) and missing schema.table name splitting.

Changes:
- Add PostgreSQL SQL generation ($N params, double-quoted identifiers)
  in OrderNumberResolver, validation-application-service,
  production-input-service, and validation-database
- Add PostgreSQL to database factory functions in validation-database
  and cleaner-application-service
- Add UPPER, LOWER, and 40+ common SQL functions to SQL_KEYWORDS to
  prevent prepareSql() from quoting them as identifiers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-04-05 13:46:37 +08:00
parent 7601b5f176
commit f51cae0f6f
6 changed files with 164 additions and 20 deletions

View File

@@ -1,11 +1,11 @@
import type { WebContents } from 'electron' import type { WebContents } from 'electron'
import type { MySqlService } from '../database/mysql' import type { IDatabaseService } from '../../types/database.types'
import type { SqlServerService } from '../database/sql-server'
import { ErpAuthService } from '../erp/erp-auth' import { ErpAuthService } from '../erp/erp-auth'
import { CleanerService } from '../erp/cleaner' import { CleanerService } from '../erp/cleaner'
import { OrderNumberResolver } from '../erp/order-resolver' import { OrderNumberResolver } from '../erp/order-resolver'
import { MySqlService as MySqlServiceImpl } from '../database/mysql' import { MySqlService as MySqlServiceImpl } from '../database/mysql'
import { SqlServerService as SqlServerServiceImpl } from '../database/sql-server' import { SqlServerService as SqlServerServiceImpl } from '../database/sql-server'
import { PostgreSqlService as PostgreSqlServiceImpl } from '../database/postgresql'
import { ConfigManager } from '../config/config-manager' import { ConfigManager } from '../config/config-manager'
import { ResultExporter } from '../excel/result-exporter' import { ResultExporter } from '../excel/result-exporter'
import { CleanerReportGenerator } from '../report/cleaner-report-generator' import { CleanerReportGenerator } from '../report/cleaner-report-generator'
@@ -26,13 +26,11 @@ import type {
const log = createLogger('CleanerApplicationService') const log = createLogger('CleanerApplicationService')
type DatabaseService = MySqlService | SqlServerService
export class CleanerApplicationService { export class CleanerApplicationService {
async runCleaner(eventSender: WebContents, input: CleanerInput): Promise<CleanerResult> { async runCleaner(eventSender: WebContents, input: CleanerInput): Promise<CleanerResult> {
const startTime = Date.now() const startTime = Date.now()
let authService: ErpAuthService | null = null let authService: ErpAuthService | null = null
let dbService: DatabaseService | null = null let dbService: IDatabaseService | null = null
try { try {
log.info('Fetching ERP configuration from database...') log.info('Fetching ERP configuration from database...')
@@ -46,7 +44,7 @@ export class CleanerApplicationService {
const configManager = ConfigManager.getInstance() const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType() const dbType = configManager.getDatabaseType()
log.info( log.info(
`Connecting to ${dbType === 'sqlserver' ? 'SQL Server' : 'MySQL'} for order resolution...` `Connecting to ${dbType === 'sqlserver' ? 'SQL Server' : dbType === 'postgresql' ? 'PostgreSQL' : 'MySQL'} for order resolution...`
) )
try { try {
@@ -201,7 +199,7 @@ export class CleanerApplicationService {
} }
} }
private async getDatabaseService(): Promise<DatabaseService> { private async getDatabaseService(): Promise<IDatabaseService> {
const configManager = ConfigManager.getInstance() const configManager = ConfigManager.getInstance()
const config = configManager.getConfig() const config = configManager.getConfig()
const dbType = configManager.getDatabaseType() const dbType = configManager.getDatabaseType()
@@ -223,6 +221,19 @@ export class CleanerApplicationService {
return sqlServerService return sqlServerService
} }
if (dbType === 'postgresql') {
const dbConfig = config.database.postgresql
const pgService = new PostgreSqlServiceImpl({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
await pgService.connect()
return pgService
}
const dbConfig = config.database.mysql const dbConfig = config.database.mysql
const mysqlService = new MySqlServiceImpl({ const mysqlService = new MySqlServiceImpl({
host: dbConfig.host, host: dbConfig.host,

View File

@@ -238,6 +238,53 @@ const SQL_KEYWORDS = new Set([
'FIRST', 'FIRST',
'LAST', 'LAST',
// ==================== Scalar & String Functions ====================
'UPPER',
'LOWER',
'TRIM',
'LTRIM',
'RTRIM',
'BTRIM',
'SUBSTRING',
'CONCAT',
'LENGTH',
'CHAR_LENGTH',
'CHARACTER_LENGTH',
'REPLACE',
'POSITION',
'OVERLAY',
'LPAD',
'RPAD',
'REPEAT',
'REVERSE',
'SPLIT_PART',
'INITCAP',
'NORMALIZE',
'CHR',
'ASCII',
'FORMAT',
// ==================== Numeric Functions ====================
'ABS',
'CEIL',
'CEILING',
'FLOOR',
'ROUND',
'POWER',
'SQRT',
'MOD',
'SIGN',
'TRUNC',
// ==================== Date/Time Functions ====================
'EXTRACT',
'DATE_TRUNC',
'TO_CHAR',
'TO_DATE',
'TO_TIMESTAMP',
'TO_NUMBER',
'AGE',
// ==================== Pattern Matching ==================== // ==================== Pattern Matching ====================
'BETWEEN', 'BETWEEN',
'LIKE', 'LIKE',

View File

@@ -58,22 +58,33 @@ export class OrderNumberResolver {
/** /**
* Get table name based on database type * Get table name based on database type
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format * Converts schema_tablename format to database-specific quoting:
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据] * - SQL Server: [schema].[tablename]
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted] * - PostgreSQL: "schema"."tablename"
* - MySQL: schema_tablename (as-is)
* e.g., productionContractData_26年压力表合同数据 ->
* SQL Server: [productionContractData].[26年压力表合同数据]
* PostgreSQL: "productionContractData"."26年压力表合同数据"
* MySQL: productionContractData_26年压力表合同数据
*/ */
private getTableName(tableName: string): string { private getTableName(tableName: string): string {
if (this.dbService.type === 'sqlserver') { if (this.dbService.type === 'sqlserver' || this.dbService.type === 'postgresql') {
// Find the FIRST underscore to split schema and table name // Find the FIRST underscore to split schema and table name
// This handles patterns like: schema_tablename // This handles patterns like: schema_tablename
const firstUnderscoreIndex = tableName.indexOf('_') const firstUnderscoreIndex = tableName.indexOf('_')
if (firstUnderscoreIndex > 0) { if (firstUnderscoreIndex > 0) {
const schema = tableName.substring(0, firstUnderscoreIndex) const schema = tableName.substring(0, firstUnderscoreIndex)
const actualTableName = tableName.substring(firstUnderscoreIndex + 1) const actualTableName = tableName.substring(firstUnderscoreIndex + 1)
return `[${schema}].[${actualTableName}]` if (this.dbService.type === 'sqlserver') {
return `[${schema}].[${actualTableName}]`
}
return `"${schema}"."${actualTableName}"`
} }
// If no underscore found, default to dbo schema // If no underscore found, default schema
return `[dbo].[${tableName}]` if (this.dbService.type === 'sqlserver') {
return `[dbo].[${tableName}]`
}
return `"public"."${tableName}"`
} }
return tableName return tableName
} }
@@ -107,6 +118,11 @@ export class OrderNumberResolver {
// 使用 COLLATE 指定不区分大小写的排序规则 // 使用 COLLATE 指定不区分大小写的排序规则
sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS = @p0` sql = `SELECT TOP 1 [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS = @p0`
params = [productionId] params = [productionId]
} else if (this.dbService.type === 'postgresql') {
// PostgreSQL: 使用双引号保护中文标识符UPPER 实现不区分大小写
// prepareSql() 会保留已双引号包裹的标识符
sql = `SELECT "${dbConfig.FIELD_ORDER_NUMBER}" FROM "${tableName}" WHERE UPPER("${dbConfig.FIELD_PRODUCTION_ID}") = UPPER($1) LIMIT 1`
params = [productionId]
} else { } else {
// MySQL 默认不区分大小写,但显式使用 UPPER 确保一致性 // MySQL 默认不区分大小写,但显式使用 UPPER 确保一致性
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) = UPPER(?) LIMIT 1` sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) = UPPER(?) LIMIT 1`
@@ -155,6 +171,10 @@ export class OrderNumberResolver {
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships // P0: Use DISTINCT to prevent duplicates from one-to-many relationships
// 使用 COLLATE 指定不区分大小写的排序规则 // 使用 COLLATE 指定不区分大小写的排序规则
sql = `SELECT DISTINCT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS IN (${placeholders})` sql = `SELECT DISTINCT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS IN (${placeholders})`
} else if (this.dbService.type === 'postgresql') {
// PostgreSQL: 使用双引号保护中文标识符UPPER 实现不区分大小写
const pgPlaceholders = uniqueProductionIds.map((_, i) => `UPPER($${i + 1})`).join(', ')
sql = `SELECT DISTINCT "${dbConfig.FIELD_PRODUCTION_ID}", "${dbConfig.FIELD_ORDER_NUMBER}" FROM "${tableName}" WHERE UPPER("${dbConfig.FIELD_PRODUCTION_ID}") IN (${pgPlaceholders})`
} else { } else {
const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ') const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ')
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships // P0: Use DISTINCT to prevent duplicates from one-to-many relationships

View File

@@ -29,7 +29,7 @@ export async function getSourceNumbersFromInputs(
const productionIds: string[] = [] const productionIds: string[] = []
const orderNumbers: string[] = [] const orderNumbers: string[] = []
const configManager = ConfigManager.getInstance() const configManager = ConfigManager.getInstance()
const isSqlServer = configManager.getDatabaseType() === 'sqlserver' const dbType = configManager.getDatabaseType()
for (const item of inputs) { for (const item of inputs) {
const type = identifyInputType(item) const type = identifyInputType(item)
@@ -44,7 +44,7 @@ export async function getSourceNumbersFromInputs(
const contractTableName = getValidationTableName('productionContractData_26年压力表合同数据') const contractTableName = getValidationTableName('productionContractData_26年压力表合同数据')
const batchSize = 2000 const batchSize = 2000
if (isSqlServer) { if (dbType === 'sqlserver') {
const sql = await import('mssql') const sql = await import('mssql')
const allOrderNumbers: string[] = [] const allOrderNumbers: string[] = []
@@ -71,6 +71,22 @@ export async function getSourceNumbersFromInputs(
) )
} }
orderNumbers.push(...allOrderNumbers)
} else if (dbType === 'postgresql') {
const allOrderNumbers: string[] = []
for (let i = 0; i < productionIds.length; i += batchSize) {
const batch = productionIds.slice(i, i + batchSize)
const placeholders = batch.map((_, idx) => `$${idx + 1}`).join(',')
const contractSql = `
SELECT DISTINCT "生产订单号"
FROM ${contractTableName}
WHERE "总排号" IN (${placeholders})
`
const contractResult = await dbService.query(contractSql, batch)
allOrderNumbers.push(...contractResult.rows.map((row) => row. as string))
}
orderNumbers.push(...allOrderNumbers) orderNumbers.push(...allOrderNumbers)
} else { } else {
const allOrderNumbers: string[] = [] const allOrderNumbers: string[] = []

View File

@@ -463,6 +463,18 @@ export class ValidationApplicationService {
) )
} }
if (dbService.type === 'postgresql') {
return dbService.query(
`
SELECT "MaterialName", "Specification", "Model"
FROM ${detailTableName}
WHERE "MaterialCode" = $1
LIMIT 1
`,
[materialCode]
)
}
return dbService.query( return dbService.query(
` `
SELECT MaterialName, Specification, Model SELECT MaterialName, Specification, Model
@@ -521,6 +533,24 @@ export class ValidationApplicationService {
return materialCodes return materialCodes
} }
if (dbService.type === 'postgresql') {
const result = await dbService.query(
`
SELECT "MaterialCode"
FROM ${markedTableName}
WHERE "ManagerName" = $1 AND "MaterialCode" IS NOT NULL
`,
[username]
)
const materialCodes = result.rows.map((row) => row.MaterialCode as string).filter(Boolean)
log.info(`Regular user: got ${materialCodes.length} materials`, {
userId: username,
isAdmin: false,
materialCount: materialCodes.length
})
return materialCodes
}
const result = await dbService.query( const result = await dbService.query(
` `
SELECT MaterialCode SELECT MaterialCode

View File

@@ -1,8 +1,9 @@
import { ConfigManager } from '../config/config-manager' import { ConfigManager } from '../config/config-manager'
import { MySqlService } from '../database/mysql' import { MySqlService } from '../database/mysql'
import { SqlServerService } from '../database/sql-server' import { SqlServerService } from '../database/sql-server'
import { PostgreSqlService } from '../database/postgresql'
export type ValidationDatabaseService = MySqlService | SqlServerService export type ValidationDatabaseService = MySqlService | SqlServerService | PostgreSqlService
export async function createValidationDatabaseService(): Promise<ValidationDatabaseService> { export async function createValidationDatabaseService(): Promise<ValidationDatabaseService> {
const configManager = ConfigManager.getInstance() const configManager = ConfigManager.getInstance()
@@ -26,6 +27,19 @@ export async function createValidationDatabaseService(): Promise<ValidationDatab
return sqlServerService return sqlServerService
} }
if (dbType === 'postgresql') {
const dbConfig = config.database.postgresql
const pgService = new PostgreSqlService({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database
})
await pgService.connect()
return pgService
}
const dbConfig = config.database.mysql const dbConfig = config.database.mysql
const mysqlService = new MySqlService({ const mysqlService = new MySqlService({
host: dbConfig.host, host: dbConfig.host,
@@ -42,14 +56,20 @@ export function getValidationTableName(mysqlTableName: string): string {
const configManager = ConfigManager.getInstance() const configManager = ConfigManager.getInstance()
const dbType = configManager.getDatabaseType() const dbType = configManager.getDatabaseType()
if (dbType === 'sqlserver') { if (dbType === 'sqlserver' || dbType === 'postgresql') {
const firstUnderscoreIndex = mysqlTableName.indexOf('_') const firstUnderscoreIndex = mysqlTableName.indexOf('_')
if (firstUnderscoreIndex > 0) { if (firstUnderscoreIndex > 0) {
const schema = mysqlTableName.substring(0, firstUnderscoreIndex) const schema = mysqlTableName.substring(0, firstUnderscoreIndex)
const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1) const tableName = mysqlTableName.substring(firstUnderscoreIndex + 1)
return `[${schema}].[${tableName}]` if (dbType === 'sqlserver') {
return `[${schema}].[${tableName}]`
}
return `"${schema}"."${tableName}"`
} }
return `[dbo].[${mysqlTableName}]` if (dbType === 'sqlserver') {
return `[dbo].[${mysqlTableName}]`
}
return `"public"."${mysqlTableName}"`
} }
return mysqlTableName return mysqlTableName