360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
/**
|
||
* Order Number Resolver Service
|
||
*
|
||
* Automatically recognizes productionID and 生产订单号 (production order number),
|
||
* and converts them via database lookup.
|
||
*
|
||
* - productionID format: 2 digits + 1 letter + serial number (e.g., "22A1", "22A1234")
|
||
* - 生产订单号 format: SC + 14 digits (e.g., "SC70202602120085")
|
||
*
|
||
* Database table and field names are loaded from config.yaml
|
||
*/
|
||
|
||
import type { IDatabaseService } from '../database'
|
||
import { ConfigManager } from '../config/config-manager'
|
||
import { createLogger } from '../logger'
|
||
import type {
|
||
OrderMapping,
|
||
OrderNumberType,
|
||
ResolutionStats
|
||
} from '../../types/order-resolver.types'
|
||
|
||
const log = createLogger('OrderResolver')
|
||
|
||
/**
|
||
* ProductionID pattern: 2 digits + 1 letter + 1-6 digits
|
||
* Examples: 22A1, 22A123, 26B10617
|
||
*/
|
||
const PRODUCTION_ID_PATTERN = /^\d{2}[A-Z]\d{1,6}$/i
|
||
|
||
/**
|
||
* Production order number pattern: SC + 14 digits
|
||
*/
|
||
const ORDER_NUMBER_PATTERN = /^SC\d{14}$/i
|
||
|
||
/**
|
||
* Database table and field names
|
||
* Loaded from config.yaml via ConfigManager
|
||
*/
|
||
export function getDbConfig() {
|
||
const configManager = ConfigManager.getInstance()
|
||
const config = configManager.getConfig()
|
||
return {
|
||
TABLE_NAME: config.orderResolution.tableName || 'productionContractData_26 年压力表合同数据',
|
||
FIELD_PRODUCTION_ID: config.orderResolution.productionIdField || '总排号',
|
||
FIELD_ORDER_NUMBER: config.orderResolution.orderNumberField || '生产订单号'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Order Number Resolver Service
|
||
*/
|
||
export class OrderNumberResolver {
|
||
private dbService: IDatabaseService
|
||
|
||
constructor(dbService: IDatabaseService) {
|
||
this.dbService = dbService
|
||
}
|
||
|
||
/**
|
||
* Get table name based on database type
|
||
* Converts MySQL schema_tablename format to SQL Server [schema].[tablename] format
|
||
* e.g., productionContractData_26年压力表合同数据 -> [productionContractData].[26年压力表合同数据]
|
||
* dbo_MaterialsToBeDeleted -> [dbo].[MaterialsToBeDeleted]
|
||
*/
|
||
private getTableName(tableName: string): string {
|
||
if (this.dbService.type === 'sqlserver') {
|
||
// Find the FIRST underscore to split schema and table name
|
||
// This handles patterns like: schema_tablename
|
||
const firstUnderscoreIndex = tableName.indexOf('_')
|
||
if (firstUnderscoreIndex > 0) {
|
||
const schema = tableName.substring(0, firstUnderscoreIndex)
|
||
const actualTableName = tableName.substring(firstUnderscoreIndex + 1)
|
||
return `[${schema}].[${actualTableName}]`
|
||
}
|
||
// If no underscore found, default to dbo schema
|
||
return `[dbo].[${tableName}]`
|
||
}
|
||
return tableName
|
||
}
|
||
|
||
/**
|
||
* Check if input matches productionID pattern
|
||
*/
|
||
isProductionId(input: string): boolean {
|
||
return PRODUCTION_ID_PATTERN.test(input)
|
||
}
|
||
|
||
/**
|
||
* Check if input matches order number pattern
|
||
*/
|
||
isOrderNumber(input: string): boolean {
|
||
return ORDER_NUMBER_PATTERN.test(input)
|
||
}
|
||
|
||
/**
|
||
* Map productionID to order number via database lookup
|
||
*/
|
||
async mapProductionIdToOrderNumber(productionId: string): Promise<string | null> {
|
||
try {
|
||
const dbConfig = getDbConfig()
|
||
const tableName = this.getTableName(dbConfig.TABLE_NAME)
|
||
|
||
let sql: string
|
||
let params: any[]
|
||
|
||
if (this.dbService.type === 'sqlserver') {
|
||
// 使用 COLLATE 指定不区分大小写的排序规则
|
||
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]
|
||
} else {
|
||
// MySQL 默认不区分大小写,但显式使用 UPPER 确保一致性
|
||
sql = `SELECT \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) = UPPER(?) LIMIT 1`
|
||
params = [productionId]
|
||
}
|
||
|
||
const result = await this.dbService.query(sql, params)
|
||
|
||
if (result.rows.length > 0) {
|
||
const orderNumber = result.rows[0][Object.keys(result.rows[0])[0]] as string
|
||
return orderNumber || null
|
||
}
|
||
|
||
return null
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : '未知数据库错误'
|
||
log.error('Failed to map productionID to order number', {
|
||
productionId,
|
||
error: message
|
||
})
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Map multiple productionIds to order numbers
|
||
*/
|
||
async mapProductionIdsToOrderNumbers(productionIds: string[]): Promise<Map<string, string>> {
|
||
try {
|
||
const dbConfig = getDbConfig()
|
||
const tableName = this.getTableName(dbConfig.TABLE_NAME)
|
||
|
||
if (productionIds.length === 0) {
|
||
return new Map()
|
||
}
|
||
|
||
// P1: Deduplicate input productionIds to avoid redundant queries
|
||
const uniqueProductionIds = [...new Set(productionIds)]
|
||
|
||
// Use parameterized query to prevent SQL injection
|
||
const placeholders = uniqueProductionIds.map((_, i) => `@p${i}`).join(', ')
|
||
const params = uniqueProductionIds
|
||
|
||
let sql: string
|
||
if (this.dbService.type === 'sqlserver') {
|
||
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
||
// 使用 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})`
|
||
} else {
|
||
const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ')
|
||
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
||
// MySQL: 使用 UPPER 确保不区分大小写
|
||
sql = `SELECT DISTINCT \`${dbConfig.FIELD_PRODUCTION_ID}\`, \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) IN (${idPlaceholders})`
|
||
}
|
||
|
||
const result = await this.dbService.query(sql, params)
|
||
|
||
const mappings = new Map<string, string>()
|
||
for (const row of result.rows) {
|
||
const keys = Object.keys(row)
|
||
const prodId = row[keys[0]] as string
|
||
const orderNum = row[keys[1]] as string
|
||
if (prodId && orderNum) {
|
||
mappings.set(prodId, orderNum)
|
||
}
|
||
}
|
||
|
||
return mappings
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : '未知数据库错误'
|
||
log.error('Failed to map productionIds to order numbers', {
|
||
error: message
|
||
})
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Resolve order numbers from mixed input
|
||
*
|
||
* Optimized for batch processing with deduplication:
|
||
* - Multiple productionIDs mapping to the same order number are treated as valid (not errors)
|
||
* - Returns all mappings with duplicate tracking
|
||
*/
|
||
async resolve(inputs: string[]): Promise<OrderMapping[]> {
|
||
// P1: Deduplicate inputs at the input layer to avoid redundant queries
|
||
const uniqueInputs = [...new Set(inputs)]
|
||
|
||
// Separate productionIds and order numbers
|
||
const productionIds: string[] = []
|
||
const orderNumbers: string[] = []
|
||
|
||
for (const input of uniqueInputs) {
|
||
if (this.isOrderNumber(input)) {
|
||
orderNumbers.push(input)
|
||
} else if (this.isProductionId(input)) {
|
||
productionIds.push(input)
|
||
}
|
||
}
|
||
|
||
// Batch query productionId to order number mappings
|
||
// 使用小写 key 存储映射,以支持忽略大小写查找
|
||
const mappings = new Map<string, string>()
|
||
if (productionIds.length > 0) {
|
||
const batchMappings = await this.mapProductionIdsToOrderNumbers(productionIds)
|
||
batchMappings.forEach((orderNum, prodId) => {
|
||
mappings.set(prodId.toLowerCase(), orderNum)
|
||
})
|
||
}
|
||
|
||
// Build results while preserving original input order
|
||
// Note: Multiple productionIDs mapping to the same order number is VALID (not an error)
|
||
const results: OrderMapping[] = []
|
||
|
||
for (const input of inputs) {
|
||
// Skip if this exact input was already processed
|
||
const alreadyProcessed = results.some((r) => r.input === input)
|
||
if (alreadyProcessed) {
|
||
continue
|
||
}
|
||
|
||
const mapping: OrderMapping = { input, resolved: false }
|
||
|
||
if (this.isOrderNumber(input)) {
|
||
// Already an order number
|
||
mapping.orderNumber = input
|
||
mapping.resolved = true
|
||
} else if (this.isProductionId(input)) {
|
||
// Is a productionID, lookup from batch mappings (使用小写查找以忽略大小写)
|
||
mapping.productionId = input
|
||
const orderNumber = mappings.get(input.toLowerCase())
|
||
if (orderNumber) {
|
||
mapping.orderNumber = orderNumber
|
||
mapping.resolved = true
|
||
} else {
|
||
mapping.error = '未在数据库中找到对应的订单号'
|
||
}
|
||
} else {
|
||
mapping.error = '格式不识别:既不是有效的生产订单号也不是总排号格式'
|
||
}
|
||
|
||
results.push(mapping)
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
/**
|
||
* Get valid order numbers from mappings
|
||
* P2: Returns deduplicated order numbers
|
||
*/
|
||
getValidOrderNumbers(mappings: OrderMapping[]): string[] {
|
||
const validNumbers = mappings
|
||
.filter((m) => m.resolved && m.orderNumber)
|
||
.map((m) => m.orderNumber!)
|
||
// P2: Deduplicate before returning
|
||
return [...new Set(validNumbers)]
|
||
}
|
||
|
||
/**
|
||
* Get warnings from failed mappings
|
||
*/
|
||
getWarnings(mappings: OrderMapping[]): string[] {
|
||
return mappings.filter((m) => !m.resolved && m.error).map((m) => `${m.input}: ${m.error}`)
|
||
}
|
||
|
||
/**
|
||
* Recognize the type of input
|
||
*/
|
||
recognizeType(input: string): OrderNumberType {
|
||
if (this.isOrderNumber(input)) return 'orderNumber'
|
||
if (this.isProductionId(input)) return 'productionId'
|
||
return 'unknown'
|
||
}
|
||
|
||
/**
|
||
* Get resolution statistics
|
||
*/
|
||
getStats(mappings: OrderMapping[]): ResolutionStats {
|
||
const stats: ResolutionStats = {
|
||
totalInputs: mappings.length,
|
||
validOrderNumbers: 0,
|
||
validProductionIds: 0,
|
||
resolvedCount: 0,
|
||
failedCount: 0,
|
||
unknownFormat: 0
|
||
}
|
||
|
||
for (const mapping of mappings) {
|
||
if (mapping.resolved) {
|
||
stats.resolvedCount++
|
||
if (mapping.orderNumber && !mapping.productionId) {
|
||
stats.validOrderNumbers++
|
||
} else if (mapping.productionId) {
|
||
stats.validProductionIds++
|
||
}
|
||
} else {
|
||
stats.failedCount++
|
||
if (!mapping.productionId && !mapping.orderNumber) {
|
||
stats.unknownFormat++
|
||
}
|
||
}
|
||
}
|
||
|
||
return stats
|
||
}
|
||
|
||
/**
|
||
* Get deduplication summary for logging
|
||
* Returns a human-readable report showing:
|
||
* - Input count
|
||
* - Unique order numbers count
|
||
* - Mapping details (which productionIDs map to which order numbers)
|
||
*/
|
||
getDeduplicationReport(mappings: OrderMapping[]): {
|
||
inputCount: number
|
||
uniqueOrderNumbersCount: number
|
||
orderNumberGroups: Map<string, string[]>
|
||
summary: string
|
||
} {
|
||
// Group productionIDs by their resolved order number
|
||
const orderNumberGroups = new Map<string, string[]>()
|
||
|
||
for (const mapping of mappings) {
|
||
if (mapping.resolved && mapping.orderNumber) {
|
||
const existing = orderNumberGroups.get(mapping.orderNumber) || []
|
||
existing.push(mapping.input)
|
||
orderNumberGroups.set(mapping.orderNumber, existing)
|
||
}
|
||
}
|
||
|
||
const inputCount = mappings.length
|
||
const uniqueOrderNumbersCount = orderNumberGroups.size
|
||
|
||
// Build summary string
|
||
let summary = `输入 ${inputCount} 个总排号 → 解析为 ${uniqueOrderNumbersCount} 个唯一订单号`
|
||
|
||
if (inputCount > uniqueOrderNumbersCount) {
|
||
const duplicateCount = inputCount - uniqueOrderNumbersCount
|
||
summary += `(${duplicateCount} 个重复已合并)`
|
||
}
|
||
|
||
return {
|
||
inputCount,
|
||
uniqueOrderNumbersCount,
|
||
orderNumberGroups,
|
||
summary
|
||
}
|
||
}
|
||
}
|