Files
BIPMaterialManager/src/main/services/erp/order-resolver.ts
2026-03-21 09:17:58 +08:00

360 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
}
}
}