Merge remote-tracking branch 'origin/codex/wuyongjie-bom-category-skills-v7' into codex/wuyongjie-promote-bom-optional-20260813

This commit is contained in:
WuYongJie 2026-08-13 12:11:10 +08:00
commit adae1ac0f1
28 changed files with 1188 additions and 45 deletions

View File

@ -0,0 +1,4 @@
ALTER TABLE "SupplyChainProductArchive"
ADD COLUMN "opsDirector" TEXT NOT NULL DEFAULT '',
ADD COLUMN "opsDeptHead" TEXT NOT NULL DEFAULT '',
ADD COLUMN "opsHead" TEXT NOT NULL DEFAULT '';

View File

@ -537,6 +537,9 @@ model SupplyChainProductArchive {
replaceCount String @default("")
region String @default("")
supplierName String @default("")
opsDirector String @default("")
opsDeptHead String @default("")
opsHead String @default("")
purchaseOwner String @default("")
supplierType String @default("")
supplierStatus String @default("")

View File

@ -67,6 +67,11 @@ async function main() {
}
const syncJstBase = process.argv.includes('--sync-jst-base')
const operatorOnly = process.argv.includes('--operator-only')
const opsDirectorOnly = process.argv.includes('--ops-director-only')
if (operatorOnly && opsDirectorOnly) {
throw new Error('--operator-only and --ops-director-only cannot be used together')
}
const jstBatchSize = Number(readArgument('jst-batch-size') || '500')
if (
!Number.isSafeInteger(jstBatchSize) ||
@ -87,10 +92,14 @@ async function main() {
const [
{ prisma },
{ supplyChainProductArchiveEnrichmentSyncService },
{ supplyChainProductArchiveOperatorInitialSyncService },
{ supplyChainProductArchiveOpsDirectorSyncService },
{ jstSkuProductArchiveFullSyncService },
] = await Promise.all([
import('../src/lib/prisma'),
import('../src/server/services/supply-chain-product-archive-enrichment-sync.service'),
import('../src/server/services/supply-chain-product-archive-operator-initial-sync.service'),
import('../src/server/services/supply-chain-product-archive-ops-director-sync.service'),
syncJstBase
? import('../src/server/services/jst-sku-product-archive-full-sync.service')
: Promise.resolve({ jstSkuProductArchiveFullSyncService: null }),
@ -103,10 +112,27 @@ async function main() {
concurrency: jstConcurrency,
})
: null
const enrichmentResult = await supplyChainProductArchiveEnrichmentSyncService.syncAll({
batchSize: batchSizeValue,
})
console.log(JSON.stringify({ baseResult, enrichmentResult }, null, 2))
const operatorInitialResult = operatorOnly
? await supplyChainProductArchiveOperatorInitialSyncService.syncAll({
batchSize: batchSizeValue,
})
: null
const opsDirectorResult = opsDirectorOnly
? await supplyChainProductArchiveOpsDirectorSyncService.syncAll({
batchSize: batchSizeValue,
})
: null
const enrichmentResult = operatorOnly || opsDirectorOnly
? null
: await supplyChainProductArchiveEnrichmentSyncService.syncAll({
batchSize: batchSizeValue,
})
console.log(JSON.stringify({
baseResult,
operatorInitialResult,
opsDirectorResult,
enrichmentResult,
}, null, 2))
} finally {
await prisma.$disconnect()
}

View File

@ -30,6 +30,9 @@ export const PRODUCT_ARCHIVE_CHANGE_FIELDS = [
{ key: 'replaceCount', label: '替换款数量' },
{ key: 'region', label: '区域' },
{ key: 'supplierName', label: '供应商' },
{ key: 'opsDirector', label: '运营总负责人' },
{ key: 'opsDeptHead', label: '运营部门负责人' },
{ key: 'opsHead', label: '运营负责人' },
{ key: 'supplierType', label: '供应商类型' },
{ key: 'supplierStatus', label: '供应商状态' },
{ key: 'purchaseOwner', label: '采购' },

View File

@ -11,6 +11,8 @@ export const JST_WAREHOUSE_ARCHIVE_SOURCE_CODE = 'JST_WAREHOUSE_INFO'
export const JST_SKU_PRICE_ARCHIVE_SOURCE_CODE = 'JST_SKU_PRICE'
export const PRODUCT_CATALOG_COST_ARCHIVE_SOURCE_CODE = 'PRODUCT_CATALOG_COST_PRICE'
export const WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE = 'WAREHOUSE_CONFIG'
export const JST_SKU_OPERATOR_ARCHIVE_SOURCE_CODE = 'JST_SKU_INFO_MANAGE'
export const SYSTEM_USER_DEPARTMENT_ARCHIVE_SOURCE_CODE = 'SYSTEM_USER_DEPARTMENT'
export type ProductArchiveFieldSource = {
sourceCode: string
@ -78,6 +80,12 @@ export const PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS = {
minimumOrderQuantity: 'archive_minimum_order_quantity',
} as const
export const JST_SKU_OPERATOR_ARCHIVE_FIELD_COLUMNS = {
opsDeptHead: 'operation_dept_manager',
opsHead: 'sku_manager',
designer: 'designer',
} as const
export const DRAWING_MEDIA_ARCHIVE_FIELD_COLUMNS = {
installDocId: '图纸ID',
hasDrawing: '线稿图 | 爆炸图 | 安装图',

View File

@ -12,6 +12,9 @@ export type SupplyChainProductArchiveRow = {
barcode: string
productName: string
designer: string
opsDirector: string
opsDeptHead: string
opsHead: string
productStatus: string
material: string
color: string

View File

@ -0,0 +1,101 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
$transaction: jest.fn(),
},
}))
import { prisma } from '@/lib/prisma'
import { SupplyChainProductArchiveEnrichmentSyncRepositoryClass } from '../supply-chain-product-archive-enrichment-sync.repository'
const mockPrisma = prisma as unknown as { $transaction: jest.Mock }
function createTransaction(input: {
existingSource?: { id: string } | null
designer?: string
opsDeptHead?: string
opsHead?: string
}) {
return {
supplyChainProductArchive: {
findUniqueOrThrow: jest.fn().mockResolvedValue({
id: 'archive-1',
barcode: 'BAR-001',
spu: 'SPU-001',
fieldSourceMap: {},
designer: input.designer ?? '',
opsDirector: '',
opsDeptHead: input.opsDeptHead ?? '',
opsHead: input.opsHead ?? '',
}),
update: jest.fn().mockResolvedValue({}),
},
supplyChainProductArchiveSource: {
findUnique: jest.fn().mockResolvedValue(input.existingSource ?? null),
upsert: jest.fn().mockResolvedValue({}),
},
}
}
const initialSource = {
sourceCode: 'JST_SKU_INFO_MANAGE',
sourceSystem: '聚水潭 JSReportReplica',
sourceSchema: 'rpt',
sourceObject: 'js_sku_info_manage',
sourceRecordId: 'BAR-001',
sourceColumns: {
designer: 'designer',
opsDeptHead: 'operation_dept_manager',
opsHead: 'sku_manager',
},
fillEmptyOnly: true,
} as const
describe('product archive initial operator source', () => {
beforeEach(() => jest.clearAllMocks())
it('fills only empty owner fields and preserves an existing designer', async () => {
const tx = createTransaction({ designer: '现有设计师' })
mockPrisma.$transaction.mockImplementation(async (callback) => callback(tx))
const repository = new SupplyChainProductArchiveEnrichmentSyncRepositoryClass()
const result = await repository.applyPatch({
archiveId: 'archive-1',
patch: {
designer: '来源设计师',
opsDeptHead: '运营部门负责人',
opsHead: '运营负责人',
},
source: initialSource,
})
expect(result.changes.map((change) => change.field)).toEqual([
'opsDeptHead',
'opsHead',
])
expect(tx.supplyChainProductArchive.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'archive-1' },
data: expect.objectContaining({
opsDeptHead: '运营部门负责人',
opsHead: '运营负责人',
}),
}),
)
expect(tx.supplyChainProductArchive.update.mock.calls[0][0].data).not.toHaveProperty('designer')
})
it('never reapplies the initial source after that barcode was initialized', async () => {
const tx = createTransaction({ existingSource: { id: 'source-1' } })
mockPrisma.$transaction.mockImplementation(async (callback) => callback(tx))
const repository = new SupplyChainProductArchiveEnrichmentSyncRepositoryClass()
await expect(repository.applyPatch({
archiveId: 'archive-1',
patch: { opsHead: '后续变化的负责人' },
source: initialSource,
})).resolves.toEqual({ changes: [], sourceInitialized: false })
expect(tx.supplyChainProductArchive.update).not.toHaveBeenCalled()
expect(tx.supplyChainProductArchiveSource.upsert).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,116 @@
import sql from 'mssql'
export type JstSkuOperatorProfile = {
barcode: string
sku: string
skuManager: string
operationDeptManager: string
designer: string
updatedAt: Date | null
}
export interface JstSkuOperatorRepository {
listByKeys(keys: readonly { barcode: string; sku: string }[]): Promise<JstSkuOperatorProfile[]>
close(): Promise<void>
}
function requiredEnv(name: string) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`Missing required environment variable: ${name}`)
return value
}
function booleanEnv(name: string, fallback: boolean) {
const value = process.env[name]?.trim().toLowerCase()
if (!value) return fallback
return value === 'true' || value === '1'
}
function safeIdentifier(value: string, label: string) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
throw new Error(`${label} is not a valid SQL Server identifier`)
}
return value
}
function normalizedKeys(keys: readonly { barcode: string; sku: string }[]) {
const barcodes = Array.from(
new Set(keys.map((key) => key.barcode.trim().toUpperCase()).filter(Boolean)),
).slice(0, 200)
const skus = Array.from(
new Set(keys.map((key) => key.sku.trim().toUpperCase()).filter(Boolean)),
).slice(0, 200)
return { barcodes, skus }
}
class JstSkuOperatorRepositoryClass implements JstSkuOperatorRepository {
private pool: sql.ConnectionPool | null = null
private async getPool() {
if (this.pool?.connected) return this.pool
this.pool = await new sql.ConnectionPool({
server: requiredEnv('JST_REPORT_DB_HOST'),
port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'),
database: requiredEnv('JST_REPORT_DB_NAME'),
user: requiredEnv('JST_REPORT_DB_USER'),
password: requiredEnv('JST_REPORT_DB_PASSWORD'),
options: {
encrypt: booleanEnv('JST_REPORT_DB_ENCRYPT', true),
trustServerCertificate: booleanEnv('JST_REPORT_DB_TRUST_SERVER_CERTIFICATE', true),
},
pool: { min: 0, max: 4, idleTimeoutMillis: 30_000 },
requestTimeout: 30_000,
connectionTimeout: 15_000,
}).connect()
return this.pool
}
async listByKeys(keys: readonly { barcode: string; sku: string }[]) {
const normalized = normalizedKeys(keys)
if (normalized.barcodes.length === 0 && normalized.skus.length === 0) return []
const schema = safeIdentifier(
process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
'JST_REPORT_DB_SCHEMA',
)
const pool = await this.getPool()
const request = pool.request()
const barcodeParameters = normalized.barcodes.map((barcode, index) => {
const parameter = `barcode${index}`
request.input(parameter, sql.NVarChar(120), barcode)
return `@${parameter}`
})
const skuParameters = normalized.skus.map((sku, index) => {
const parameter = `sku${index}`
request.input(parameter, sql.NVarChar(120), sku)
return `@${parameter}`
})
const conditions = [
barcodeParameters.length
? `UPPER(LTRIM(RTRIM(CAST(sku_id AS nvarchar(120))))) IN (${barcodeParameters.join(', ')})`
: '',
skuParameters.length
? `UPPER(LTRIM(RTRIM(CAST(sku AS nvarchar(120))))) IN (${skuParameters.join(', ')})`
: '',
].filter(Boolean)
const result = await request.query<JstSkuOperatorProfile>(`
SELECT
UPPER(LTRIM(RTRIM(CAST(sku_id AS nvarchar(120))))) AS barcode,
UPPER(LTRIM(RTRIM(CAST(sku AS nvarchar(120))))) AS sku,
LTRIM(RTRIM(COALESCE(CAST(sku_manager AS nvarchar(120)), N''))) AS skuManager,
LTRIM(RTRIM(COALESCE(CAST(operation_dept_manager AS nvarchar(120)), N''))) AS operationDeptManager,
LTRIM(RTRIM(COALESCE(CAST(designer AS nvarchar(120)), N''))) AS designer,
update_time AS updatedAt
FROM [${schema}].[js_sku_info_manage]
WHERE ${conditions.join(' OR ')}
`)
return result.recordset
}
async close() {
if (!this.pool) return
await this.pool.close()
this.pool = null
}
}
export const jstSkuOperatorRepository = new JstSkuOperatorRepositoryClass()

View File

@ -0,0 +1,35 @@
import { prisma } from '@/lib/prisma'
export type ProductArchiveOperatorDirectoryRecord = {
name: string
departmentFullPath: string | null
}
export interface ProductArchiveOperatorDirectoryRepository {
listByNames(names: readonly string[]): Promise<ProductArchiveOperatorDirectoryRecord[]>
}
export class ProductArchiveOperatorDirectoryRepositoryClass
implements ProductArchiveOperatorDirectoryRepository
{
async listByNames(names: readonly string[]) {
const uniqueNames = [...new Set(names.map((name) => name.trim()).filter(Boolean))]
if (uniqueNames.length === 0) return []
const users = await prisma.user.findMany({
where: { name: { in: uniqueNames } },
select: {
name: true,
department: { select: { fullPath: true } },
},
})
return users.map((user) => ({
name: user.name,
departmentFullPath: user.department?.fullPath ?? null,
}))
}
}
export const productArchiveOperatorDirectoryRepository =
new ProductArchiveOperatorDirectoryRepositoryClass()

View File

@ -12,6 +12,10 @@ import {
} from '@/lib/supply-chain-product-archive-field-source'
export const PRODUCT_ARCHIVE_ENRICHMENT_FIELDS = [
'designer',
'opsDirector',
'opsDeptHead',
'opsHead',
'region',
'supplierType',
'supplierStatus',
@ -49,6 +53,7 @@ export type ProductArchiveEnrichmentSource = {
sourceRecordId: string
sourceColumns: Partial<Record<ProductArchiveEnrichmentField, string>>
sourceRecordedAt?: Date | null
fillEmptyOnly?: boolean
}
const syncTargetSelect = {
@ -57,6 +62,10 @@ const syncTargetSelect = {
spu: true,
sku: true,
supplierName: true,
designer: true,
opsDirector: true,
opsDeptHead: true,
opsHead: true,
inOutWarehouse: true,
} satisfies Prisma.SupplyChainProductArchiveSelect
@ -68,6 +77,10 @@ const currentArchiveSelect = {
barcode: true,
spu: true,
fieldSourceMap: true,
designer: true,
opsDirector: true,
opsDeptHead: true,
opsHead: true,
region: true,
supplierType: true,
supplierStatus: true,
@ -157,7 +170,15 @@ class SupplyChainProductArchiveEnrichmentSyncRepositoryClass
where: { id: input.archiveId },
select: currentArchiveSelect,
})
const changes = changedValues(previous, input.patch)
const patch = input.source.fillEmptyOnly
? Object.fromEntries(
Object.entries(input.patch).filter(([field]) => {
const current = (previous as unknown as Record<string, unknown>)[field]
return comparable(current) === ''
}),
) as ProductArchiveEnrichmentPatch
: input.patch
const changes = changedValues(previous, patch)
const sourceRecordId = previous.barcode
const fieldSourceRecordId = `${input.source.sourceRecordId}:${previous.barcode}`
const syncedAt = new Date()
@ -171,11 +192,15 @@ class SupplyChainProductArchiveEnrichmentSyncRepositoryClass
select: { id: true },
})
if (input.source.fillEmptyOnly && existingSource) {
return { changes: [], sourceInitialized: false }
}
if (changes.length > 0) {
const changedPatch = Object.fromEntries(
changes.map((change) => [
change.field,
input.patch[change.field as ProductArchiveEnrichmentField] ?? '',
patch[change.field as ProductArchiveEnrichmentField] ?? '',
]),
) as ProductArchiveEnrichmentPatch
const sourceFields = Object.fromEntries(

View File

@ -17,6 +17,9 @@ const productArchiveSelect = {
categoryL2: true,
productName: true,
designer: true,
opsDirector: true,
opsDeptHead: true,
opsHead: true,
productStatus: true,
material: true,
color: true,

View File

@ -0,0 +1,102 @@
import {
buildProductArchiveEnrichmentUpdates,
buildProductArchiveOperatorInitialUpdate,
} from '../supply-chain-product-archive-enrichment-sync.mapper'
const emptyLookups = {
suppliers: [],
stocks: [],
warehouses: [],
prices: [],
catalogCosts: [],
warehouseConfigs: [],
}
describe('product archive operator source mapping', () => {
it('matches the source by exact barcode before SKU and marks it as initial-only', () => {
const update = buildProductArchiveOperatorInitialUpdate(
{
id: 'archive-1',
barcode: 'BAR-001',
spu: 'SPU-001',
sku: 'SKU-001',
supplierName: '',
inOutWarehouse: '',
},
[
{
barcode: 'BAR-OTHER',
sku: 'SKU-001',
skuManager: '错误负责人',
operationDeptManager: '错误部门负责人',
designer: '',
updatedAt: null,
},
{
barcode: 'BAR-001',
sku: 'SKU-001',
skuManager: '王也',
operationDeptManager: '子龙',
designer: '财神',
updatedAt: new Date('2026-07-30T08:01:00.000Z'),
},
],
)
expect(update).toEqual(expect.objectContaining({
patch: {
opsDeptHead: '子龙',
opsHead: '王也',
designer: '财神',
},
source: expect.objectContaining({
sourceObject: 'js_sku_info_manage',
fillEmptyOnly: true,
}),
}))
})
it('uses SKU only when that SKU has one unambiguous source row', () => {
const target = {
id: 'archive-2',
barcode: 'BAR-NOT-MATCHED',
spu: 'SPU-002',
sku: 'SKU-002',
supplierName: '',
inOutWarehouse: '',
}
const unique = buildProductArchiveOperatorInitialUpdate(target, [{
barcode: 'SOURCE-BAR',
sku: 'SKU-002',
skuManager: '唯一负责人',
operationDeptManager: '唯一部门负责人',
designer: '唯一设计师',
updatedAt: null,
}])
const ambiguous = buildProductArchiveOperatorInitialUpdate(target, [
{
barcode: 'SOURCE-BAR-1',
sku: 'SKU-002',
skuManager: '负责人一',
operationDeptManager: '',
designer: '',
updatedAt: null,
},
{
barcode: 'SOURCE-BAR-2',
sku: 'SKU-002',
skuManager: '负责人二',
operationDeptManager: '',
designer: '',
updatedAt: null,
},
])
expect(unique?.patch).toEqual({
opsDeptHead: '唯一部门负责人',
opsHead: '唯一负责人',
designer: '唯一设计师',
})
expect(ambiguous).toBeNull()
})
})

View File

@ -0,0 +1,100 @@
jest.mock('../supply-chain-product-archive-change-log.service', () => ({
recordProductArchiveFieldChanges: jest.fn().mockResolvedValue(undefined),
}))
import {
resolveOpsDirectorByDepartmentPath,
SupplyChainProductArchiveOpsDirectorSyncService,
} from '../supply-chain-product-archive-ops-director-sync.service'
describe('resolveOpsDirectorByDepartmentPath', () => {
it.each([
['江苏简尚品牌管理有限公司 > 运营中心A > 事业一部', '比干'],
['江苏简尚品牌管理有限公司 > 运营中心A > 事业二部 > 一组', '無尘'],
['江苏简尚品牌管理有限公司 > 运营中心A > 事业三部', '子龙'],
['江苏简尚品牌管理有限公司 > 运营中心A > 事业四部', '半夏'],
['江苏简尚品牌管理有限公司 > 运营中心A-事业四部', '半夏'],
['江苏简尚品牌管理有限公司 > 运营中心B > 运营一部', '苏适'],
['江苏简尚品牌管理有限公司 > 运营中心C', '花城'],
['江苏简尚品牌管理有限公司 > 运营中心G > 运营组', '江陵'],
['江苏简尚品牌管理有限公司 > 运营中心A', ''],
['江苏简尚品牌管理有限公司 > 供应链中心', ''],
[null, ''],
])('maps %s to %s', (departmentFullPath, expected) => {
expect(resolveOpsDirectorByDepartmentPath(departmentFullPath)).toBe(expected)
})
})
describe('SupplyChainProductArchiveOpsDirectorSyncService', () => {
it('derives only opsDirector from the operator account department', async () => {
const targets = [
{
id: 'archive-1',
barcode: 'BAR-001',
spu: 'SPU-001',
sku: 'SKU-001',
supplierName: '',
designer: '设计师甲',
opsDirector: '',
opsDeptHead: '部门负责人甲',
opsHead: '五竹',
inOutWarehouse: '',
},
{
id: 'archive-2',
barcode: 'BAR-002',
spu: 'SPU-002',
sku: 'SKU-002',
supplierName: '',
designer: '设计师乙',
opsDirector: '旧值',
opsDeptHead: '部门负责人乙',
opsHead: '无账号人员',
inOutWarehouse: '',
},
]
const repository = {
listTargets: jest
.fn()
.mockResolvedValueOnce(targets)
.mockResolvedValueOnce([]),
applyPatch: jest.fn().mockResolvedValue({
changes: [],
sourceInitialized: true,
}),
}
const directoryRepository = {
listByNames: jest.fn().mockResolvedValue([{
name: '五竹',
departmentFullPath: '江苏简尚品牌管理有限公司 > 运营中心A > 事业四部',
}]),
}
const service = new SupplyChainProductArchiveOpsDirectorSyncService({
repository,
directoryRepository,
})
await expect(service.syncAll()).resolves.toMatchObject({
scanned: 2,
accountMatched: 1,
resolved: 1,
})
expect(repository.applyPatch).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
archiveId: 'archive-1',
patch: { opsDirector: '半夏' },
}),
)
expect(repository.applyPatch).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
archiveId: 'archive-2',
patch: { opsDirector: '' },
}),
)
for (const call of repository.applyPatch.mock.calls) {
expect(call[0].patch).toEqual({ opsDirector: expect.any(String) })
}
})
})

View File

@ -51,6 +51,9 @@ describe('supply-chain product archive field mapping', () => {
categoryL1: '桌类',
categoryL2: '住宅家具 - 电脑桌',
designer: '',
opsDirector: '花城',
opsDeptHead: '子龙',
opsHead: '王也',
weight: new Prisma.Decimal('27'),
length: new Prisma.Decimal('128'),
width: new Prisma.Decimal('68'),
@ -98,6 +101,9 @@ describe('supply-chain product archive field mapping', () => {
categoryL2: '住宅家具 - 电脑桌',
productStatus: '销售',
productName: '电脑桌 120/60/75',
opsDirector: '花城',
opsDeptHead: '子龙',
opsHead: '王也',
spec: '120/60/75',
weight: '27',
brand: '基本盘',

View File

@ -140,6 +140,7 @@ describe('BOM v7 移动藤编双层置物架生产形态回归', () => {
expect(slotsByKey.get('surface-upper-tray')?.evidenceFactIds).toEqual(
expect.arrayContaining(['IMG_0002_fact_001', 'IMG_0002_fact_002']),
)
expect(slotsByKey.get('surface-upper-tray')?.evidenceFactIds).toHaveLength(20)
expect(slotsByKey.get('frame-lower-basket')).toMatchObject({
required: true,
category: 'ACCESSORY',
@ -404,6 +405,42 @@ describe('BOM v7 移动藤编双层置物架生产形态回归', () => {
}
})
it('同一事实同时描述长短两轴时拒绝猜测单个数值', async () => {
const snapshot = buildProductionMobileRattanStorageRack2Snapshot()
const factSnapshot = buildProductionMobileRattanStorageRack2FactSnapshot()
const trayImage = factSnapshot.imageFacts.find((image) => image.imageId === 'IMG_0002')!
trayImage.facts = trayImage.facts.filter((fact) => (
!['IMG_0002_fact_001', 'IMG_0002_fact_002'].includes(fact.factId)
))
trayImage.facts.push({
factId: 'IMG_0002_fact_combined_axes',
kind: 'DIMENSION',
subject: '上层台面',
value: '图片标注56cm,另标31cm',
note: '较长方向56cm,较短方向31cm',
relatedSkuIds: [],
basis: 'SOURCE_TEXT',
confidence: 0.99,
})
jest.spyOn(global, 'fetch').mockRejectedValue(new Error('offline fixture'))
const { result } = await buildStructuredBomCostingAnalysis({
config,
sourceUrl: snapshot.detailUrl,
snapshot,
factSnapshot,
requestedCategorySkillId: 'STORAGE_RACK',
})
for (const sku of result.skus) {
const tray = sku.bomLines.find((line) => line.externalLineId === 'surface-upper-tray')!
expect(tray).toMatchObject({
lengthMm: undefined,
widthMm: undefined,
})
}
})
it('只有可信商品级“双层置物”事实的普通木架仍使用层板、木立柱和固定脚', () => {
const snapshot = buildProductionMobileRattanStorageRack2Snapshot()
snapshot.title = '全实木普通开放式置物架'

View File

@ -218,7 +218,7 @@ const supportedV3AnalysisResult = {
schemaVersion: 'competitor-analysis/v3' as const,
structureBlueprint: {
schemaVersion: 'structure-blueprint/v2' as const,
knowledgeVersion: 'structure-priors/2026-08-13-v7',
knowledgeVersion: 'structure-priors/2026-08-13-v8',
supportStatus: 'SUPPORTED' as const,
categorySkill: {
selectedSkillId: 'TABLE',

View File

@ -276,7 +276,7 @@ async function runStructurePipeline(orderedFixture: readonly ProductionWardrobe2
}
const checkpoint = readBomCostingStructureCheckpoint({
kind: 'BOM_COSTING_STRUCTURE_CHECKPOINT',
version: 11,
version: 12,
sourceSkuIds: snapshot.skus.map((sku) => sku.externalSkuId),
blueprint,
completedGroupKeys: [],

View File

@ -222,7 +222,7 @@ describe('BOM v7 结构蓝图与 SKU 展开', () => {
const group = blueprint.structureGroups[0]!
const checkpointValue = {
kind: 'BOM_COSTING_STRUCTURE_CHECKPOINT',
version: 11,
version: 12,
sourceSkuIds: ['sku-1'],
blueprint,
completedGroupKeys: [group.structureGroupKey],
@ -246,7 +246,7 @@ describe('BOM v7 结构蓝图与 SKU 展开', () => {
}
expect(readBomCostingStructureCheckpoint({
...checkpointValue,
version: 10,
version: 11,
})).toBeNull()
expect(readBomCostingStructureCheckpoint({
...checkpointValue,
@ -307,7 +307,7 @@ describe('BOM v7 结构蓝图与 SKU 展开', () => {
)
const checkpoint = readBomCostingStructureCheckpoint({
kind: 'BOM_COSTING_STRUCTURE_CHECKPOINT',
version: 11,
version: 12,
sourceSkuIds: snapshot.skus.map((sku) => sku.externalSkuId),
blueprint,
completedGroupKeys: blueprint.structureGroups.map((group) => group.structureGroupKey),

View File

@ -140,9 +140,9 @@ const instantiationPayloadSchema = z.object({
const BOM_COSTING_STRUCTURE_CHECKPOINT_KIND =
'BOM_COSTING_STRUCTURE_CHECKPOINT' as const
// Version 11 invalidates instantiated lines created before the storage-trolley
// and split-table component/geometry contracts were made explicit.
const BOM_COSTING_STRUCTURE_CHECKPOINT_VERSION = 11 as const
// Version 12 invalidates instantiated lines created before trusted slot
// dimensions were preserved and axis-labelled evidence was parsed safely.
const BOM_COSTING_STRUCTURE_CHECKPOINT_VERSION = 12 as const
const bomCostingStructureCheckpointSchema = z.object({
kind: z.literal(BOM_COSTING_STRUCTURE_CHECKPOINT_KIND),
@ -1159,18 +1159,27 @@ function resolveTrustedSlotDimensionMm(input: {
slot: BomCostingStructureBlueprintV2['structureGroups'][number]['componentSlots'][number]
sku: MarketSkuSnapshot
factSnapshot: BomCostingSourceFactSnapshot
subjectPattern: RegExp
axis: 'LONG' | 'SHORT'
}) {
const allowedIds = new Set(input.slot.evidenceFactIds)
const values = listBomCostingFactRecords(input.factSnapshot)
.filter((record) => (
allowedIds.has(record.factId) &&
record.fact.kind === 'DIMENSION' &&
record.fact.basis !== 'VISUAL_ESTIMATE' &&
record.fact.confidence >= 0.75 &&
factAppliesToSku(record, input.sku.externalSkuId) &&
input.subjectPattern.test(record.fact.subject)
))
.filter((record) => {
if (
!allowedIds.has(record.factId) ||
record.fact.kind !== 'DIMENSION' ||
record.fact.basis === 'VISUAL_ESTIMATE' ||
record.fact.confidence < 0.75 ||
!factAppliesToSku(record, input.sku.externalSkuId)
) return false
if (!/(?:托盘|台面)/.test(record.fact.subject)) return false
const axisContext = `${record.fact.subject} ${record.fact.note ?? ''}`
const longMatch = /(?:长度|较长方向)/.test(axisContext)
const shortMatch = /(?:宽度|深度|较短方向)/.test(axisContext)
// One record may prove one axis only. Combined long/short prose is
// ambiguous because factMillimeters intentionally reads one value.
if (longMatch === shortMatch) return false
return input.axis === 'LONG' ? longMatch : shortMatch
})
.map(factMillimeters)
.filter((value): value is number => Boolean(value))
const uniqueValues = Array.from(new Set(values))
@ -1261,13 +1270,13 @@ function deterministicBoardGeometry(input: {
slot: input.slot,
sku: input.sku,
factSnapshot: input.factSnapshot,
subjectPattern: /(?:托盘|台面).*(?:长度|长)|(?:长度|长).*(?:托盘|台面)/,
axis: 'LONG',
}),
widthMm: resolveTrustedSlotDimensionMm({
slot: input.slot,
sku: input.sku,
factSnapshot: input.factSnapshot,
subjectPattern: /(?:托盘|台面).*(?:宽度|宽|深度|深)|(?:宽度|宽|深度|深).*(?:托盘|台面)/,
axis: 'SHORT',
}),
}
case 'module-horizontal-panel': return {

View File

@ -335,6 +335,164 @@ describe('BOM 通用结构知识与确定性蓝图', () => {
expect(drawerSide?.confidence).toBeLessThan(0.55)
})
it('拥挤证据中同时保留可信尺寸、材质、数量和结构事实,并保持证据有界去重', () => {
const snapshot = buildSnapshot([
sku('rack', '移动双层置物推车'),
], '橡木藤编移动双层置物推车')
const noisyFacts = Array.from({ length: 24 }, (_, index) => ({
factId: `upper-tray-noise-${String(index + 1).padStart(2, '0')}`,
kind: 'PART' as const,
subject: '上层台面',
value: `高置信描述 ${index + 1}`,
relatedSkuIds: ['rack'],
basis: 'SOURCE_TEXT' as const,
confidence: 0.99,
}))
const result = buildDeterministicStructureBlueprint({
snapshot,
requestedCategorySkillId: 'STORAGE_RACK',
factSnapshot: {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: [{
imageId: 'crowded-upper-tray-facts',
sourceImageUrl: 'https://example.com/crowded-upper-tray.jpg',
kind: 'DETAIL',
label: '上层台面详情',
externalSkuIds: ['rack'],
observedTexts: [],
facts: [
...noisyFacts,
// A repeated record must not consume another position in the
// bounded evidence selection.
{ ...noisyFacts[0]! },
...Array.from({ length: 22 }, (_, index) => ({
factId: `upper-tray-extra-dimension-${index + 1}`,
kind: 'DIMENSION' as const,
subject: '上层台面',
value: `${40 + index}cm`,
note: index % 2 === 0 ? '较长方向' : '较短方向',
relatedSkuIds: ['rack'],
basis: 'SOURCE_TEXT' as const,
confidence: 0.8,
})),
{
factId: 'upper-tray-length',
kind: 'DIMENSION',
subject: '上层台面',
value: '图片标注56cm',
note: '较长方向',
relatedSkuIds: ['rack'],
basis: 'SOURCE_TEXT',
confidence: 0.91,
},
{
factId: 'upper-tray-width',
kind: 'DIMENSION',
subject: '上层台面',
value: '图片标注31cm',
note: '较短方向',
relatedSkuIds: ['rack'],
basis: 'SOURCE_TEXT',
confidence: 0.9,
},
{
factId: 'upper-tray-material',
kind: 'MATERIAL',
subject: '上层台面材质',
value: '橡木',
relatedSkuIds: ['rack'],
basis: 'SOURCE_TEXT',
confidence: 0.89,
},
{
factId: 'upper-tray-quantity',
kind: 'PART',
subject: '上层台面数量',
value: '1块',
relatedSkuIds: ['rack'],
basis: 'SOURCE_TEXT',
confidence: 0.88,
},
{
factId: 'upper-tray-structure',
kind: 'STRUCTURE',
subject: '上层台面结构',
value: '由金属框架支承',
relatedSkuIds: ['rack'],
basis: 'IMAGE',
confidence: 0.87,
},
],
}],
failedImages: [],
extractedAt: new Date().toISOString(),
},
})
const upperTray = result.structureGroups[0]!.componentSlots.find(
(slot) => slot.componentKey === 'surface-upper-tray',
)!
expect(upperTray.evidenceFactIds).toHaveLength(20)
expect(new Set(upperTray.evidenceFactIds).size).toBe(20)
expect(upperTray.evidenceFactIds).toEqual(expect.arrayContaining([
'upper-tray-length',
'upper-tray-width',
'upper-tray-material',
'upper-tray-quantity',
'upper-tray-structure',
]))
expect(upperTray.quantityPolicy.skuSpecificFactIds).toContain('upper-tray-quantity')
})
it('证据摘要被高置信尺寸占满时,完整事实仍可激活可选结构与数量合同', () => {
const snapshot = buildSnapshot([sku('rack', '移动置物推车')], '移动置物推车')
const result = buildDeterministicStructureBlueprint({
snapshot,
requestedCategorySkillId: 'STORAGE_RACK',
factSnapshot: {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,
imageFacts: [{
imageId: 'crowded-caster-facts',
sourceImageUrl: 'https://example.com/crowded-caster.jpg',
kind: 'SKU',
label: 'SKU 图',
externalSkuIds: ['rack'],
observedTexts: [],
facts: [
...Array.from({ length: 24 }, (_, index) => ({
factId: `caster-dimension-${index + 1}`,
kind: 'DIMENSION' as const,
subject: '脚轮尺寸',
value: `${50 + index}mm`,
relatedSkuIds: ['rack'],
basis: 'SOURCE_TEXT' as const,
confidence: 0.99,
})),
{
factId: 'caster-structure',
kind: 'HARDWARE',
subject: '四个脚轮',
value: '底部配置4个万向轮',
relatedSkuIds: ['rack'],
basis: 'IMAGE',
confidence: 0.8,
},
],
}],
failedImages: [],
extractedAt: new Date().toISOString(),
},
})
const caster = result.structureGroups[0]!.componentSlots.find(
(slot) => slot.componentKey === 'motion-caster',
)!
expect(caster.required).toBe(true)
expect(caster.quantityPolicy.skuSpecificFactIds).toContain('caster-structure')
expect(caster.evidenceFactIds).toHaveLength(20)
expect(caster.evidenceFactIds).toContain('caster-structure')
})
it('未知品类和双候选歧义都保存 v2 草稿,但不生成可计价结构组', () => {
const factSnapshot = {
schemaVersion: BOM_COSTING_FACT_SCHEMA_VERSION,

View File

@ -26,7 +26,7 @@ import {
} from './category-skills'
export const BOM_COSTING_STRUCTURE_KNOWLEDGE_VERSION =
'structure-priors/2026-08-13-v7' as const
'structure-priors/2026-08-13-v8' as const
export type BomCostingStructureSlotPrior = BomCostingCategoryComponentDefinition
@ -56,13 +56,75 @@ function factTextForSlot(record: ReturnType<typeof listBomCostingFactRecords>[nu
return normalize(`${record.fact.subject} ${record.fact.value} ${record.fact.note ?? ''}`)
}
function isTrustedDirectDimension(match: SlotFactMatch) {
return match.record.fact.kind === 'DIMENSION' &&
match.record.fact.basis !== 'VISUAL_ESTIMATE' &&
match.record.fact.confidence >= 0.75
}
function isScopedActivationFact(
match: SlotFactMatch,
skuIds: readonly string[],
) {
const { record } = match
if (
['SKU_DIFFERENCE', 'MATERIAL'].includes(record.fact.kind) ||
record.fact.basis === 'VISUAL_ESTIMATE' ||
record.fact.confidence < 0.55
) return false
const skuIdSet = new Set(skuIds)
const scopedSkuIds = getBomCostingFactScopedSkuIds(record)
return scopedSkuIds.length > 0 && scopedSkuIds.every((skuId) => skuIdSet.has(skuId))
}
function selectBoundedSlotFacts(
matches: readonly SlotFactMatch[],
skuIds: readonly string[],
limit = 20,
) {
const selected: SlotFactMatch[] = []
const selectedFactIds = new Set<string>()
const append = (match: SlotFactMatch) => {
if (selected.length >= limit || selectedFactIds.has(match.record.factId)) return
selected.push(match)
selectedFactIds.add(match.record.factId)
}
// Keep a bounded evidence summary, but reserve entries by business purpose.
// A large batch of dimensions must not hide explicit material, quantity or
// topology evidence, because those feed independent downstream contracts.
matches.filter(isTrustedDirectDimension)
.slice(0, Math.min(8, Math.floor(limit / 2)))
.forEach(append)
matches.filter((match) => match.record.fact.kind === 'MATERIAL')
.slice(0, 4)
.forEach(append)
matches.filter((match) => isQuantityFact(match, skuIds))
.slice(0, 4)
.forEach(append)
matches.filter((match) => (
['STRUCTURE', 'HARDWARE', 'MECHANISM'].includes(match.record.fact.kind) &&
isScopedActivationFact(match, skuIds)
)).slice(0, 4).forEach(append)
matches.filter((match) => isScopedActivationFact(match, skuIds))
.slice(0, 4)
.forEach(append)
matches.filter((match) => (
!['DIMENSION', 'MATERIAL'].includes(match.record.fact.kind) &&
match.record.fact.basis !== 'VISUAL_ESTIMATE' &&
match.record.fact.confidence >= 0.55
)).slice(0, 4).forEach(append)
matches.forEach(append)
return selected
}
function findSlotFacts(
slot: BomCostingCategoryComponentDefinition,
facts: BomCostingSourceFactSnapshot,
skuIds: readonly string[],
) {
const skuIdSet = new Set(skuIds)
return listBomCostingFactRecords(facts)
const matches = listBomCostingFactRecords(facts)
.flatMap((record): SlotFactMatch[] => {
if (!bomCostingFactAppliesToAnySku(record, skuIds)) return []
const bindingKeywords = record.fact.kind === 'MATERIAL'
@ -84,7 +146,7 @@ function findSlotFacts(
right.scopeRank - left.scopeRank ||
right.record.fact.confidence - left.record.fact.confidence
))
.slice(0, 20)
return matches
}
function isQuantityFact(match: SlotFactMatch, skuIds: readonly string[]) {
@ -189,18 +251,21 @@ function buildComponentSlot(input: {
skuIds: readonly string[]
draft?: boolean
}) {
const facts = input.factSnapshot
const allFacts = input.factSnapshot
? findSlotFacts(input.component, input.factSnapshot, input.skuIds)
: []
const facts = selectBoundedSlotFacts(allFacts, input.skuIds)
const evidenceFactIds = Array.from(new Set(facts.map(({ record }) => record.factId)))
const skuSpecificFactIds = Array.from(new Set(
facts.filter((match) => isQuantityFact(match, input.skuIds)).map(({ record }) => record.factId),
))
allFacts
.filter((match) => isQuantityFact(match, input.skuIds))
.map(({ record }) => record.factId),
)).slice(0, 100)
const triggered = componentTriggeredByStructure(
input.component,
input.structureDifferenceKeys,
)
const supportingEvidence = facts.some(({ record }) => (
const supportingEvidence = allFacts.some(({ record }) => (
record.fact.basis !== 'VISUAL_ESTIMATE' && record.fact.confidence >= 0.55
))
// Product-level detail images often summarize optional variants (drawers,
@ -209,7 +274,7 @@ function buildComponentSlot(input: {
// optional topology for all sibling groups. Activation evidence therefore
// has to be explicitly scoped and collectively cover the whole group.
const scopedActivationEvidence = hasGroupScopedActivationEvidence(
facts,
allFacts,
input.skuIds,
)
const activationVetoed = isBomCostingComponentActivationVetoed({
@ -220,8 +285,8 @@ function buildComponentSlot(input: {
const required = input.definitionRequired || triggered || (
!activationVetoed && scopedActivationEvidence
)
const confidence = facts.length > 0
? Math.min(0.85, Math.max(...facts.map(({ record }) => record.fact.confidence)))
const confidence = allFacts.length > 0
? Math.min(0.85, Math.max(...allFacts.map(({ record }) => record.fact.confidence)))
: required ? 0.4 : 0.3
const baseQuantity = stableQuantity(input.component, input.structureDifferenceKeys)
return {

View File

@ -935,6 +935,10 @@ describe('BomCostingService', () => {
})
it.each([
{
label: '紧邻旧 v7',
knowledgeVersion: 'structure-priors/2026-08-13-v7',
},
{
label: '旧 v6',
knowledgeVersion: 'structure-priors/2026-08-13-v6',

View File

@ -18,6 +18,19 @@ export const PRODUCTION_MOBILE_RATTAN_STORAGE_RACK_2_SKUS = [
},
] as const
const HIGH_CONFIDENCE_UPPER_TRAY_NOISE_FACTS = Array.from(
{ length: 21 },
(_, index) => ({
factId: `IMG_0002_noise_${String(index + 1).padStart(2, '0')}`,
kind: 'PART' as const,
subject: '上层托盘结构说明',
value: `上层托盘生产说明 ${index + 1}`,
relatedSkuIds: [],
basis: 'SOURCE_TEXT' as const,
confidence: 0.99,
}),
)
export function buildProductionMobileRattanStorageRack2Snapshot(): MarketDetailSnapshot {
return {
itemId: 'production-mobile-rattan-storage-rack-2',
@ -154,13 +167,13 @@ export function buildProductionMobileRattanStorageRack2FactSnapshot(): BomCostin
externalSkuIds: [],
observedTexts: ['上层台面 56cm', '31cm'],
facts: [
...HIGH_CONFIDENCE_UPPER_TRAY_NOISE_FACTS,
{
factId: 'IMG_0002_fact_001',
kind: 'DIMENSION',
subject: '上层台面长度',
value: '56cm',
numericValue: 56,
unit: 'cm',
subject: '上层台面',
value: '图片标注56cm',
note: '较长方向',
relatedSkuIds: [],
basis: 'SOURCE_TEXT',
confidence: 0.91,
@ -168,10 +181,9 @@ export function buildProductionMobileRattanStorageRack2FactSnapshot(): BomCostin
{
factId: 'IMG_0002_fact_002',
kind: 'DIMENSION',
subject: '上层台面宽度',
value: '31cm',
numericValue: 31,
unit: 'cm',
subject: '上层台面',
value: '图片标注31cm',
note: '较短方向',
relatedSkuIds: [],
basis: 'SOURCE_TEXT',
confidence: 0.91,

View File

@ -1,11 +1,13 @@
import {
JST_SKU_PRICE_ARCHIVE_SOURCE_CODE,
JST_SKU_OPERATOR_ARCHIVE_SOURCE_CODE,
JST_STOCK_MONITOR_ARCHIVE_SOURCE_CODE,
JST_SUPPLIER_ARCHIVE_SOURCE_CODE,
JST_WAREHOUSE_ARCHIVE_SOURCE_CODE,
PRODUCT_CATALOG_COST_ARCHIVE_SOURCE_CODE,
WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE,
} from '@/lib/supply-chain-product-archive-field-source'
import type { JstSkuOperatorProfile } from '@/server/repositories/jst-sku-operator.repository'
import type { JstSkuPriceProfile } from '@/server/repositories/jst-sku-price.repository'
import type { JstStockMonitorProfile } from '@/server/repositories/jst-stock-monitor.repository'
import type { JstSupplierProfile } from '@/server/repositories/jst-supplier-profile.repository'
@ -78,6 +80,7 @@ function source(
sourceObject: string,
sourceRecordId: string,
sourceColumns: ProductArchiveEnrichmentSource['sourceColumns'],
options: Pick<ProductArchiveEnrichmentSource, 'sourceRecordedAt' | 'fillEmptyOnly'> = {},
): ProductArchiveEnrichmentSource {
return {
sourceCode,
@ -86,9 +89,57 @@ function source(
sourceObject,
sourceRecordId,
sourceColumns,
...options,
}
}
function findOperatorProfile(
target: ProductArchiveEnrichmentTarget,
profiles: readonly JstSkuOperatorProfile[],
) {
const barcode = normalizeIdentity(target.barcode)
const exact = barcode
? profiles.find((profile) => normalizeIdentity(profile.barcode) === barcode)
: undefined
if (exact) return exact
const sku = normalizeIdentity(target.sku)
if (!sku) return undefined
const candidates = profiles.filter(
(profile) => normalizeIdentity(profile.sku) === sku,
)
return candidates.length === 1 ? candidates[0] : undefined
}
export function buildProductArchiveOperatorInitialUpdate(
target: ProductArchiveEnrichmentTarget,
operators: readonly JstSkuOperatorProfile[],
): ProductArchiveEnrichmentUpdate | null {
const operator = findOperatorProfile(target, operators)
return operator
? {
patch: {
opsDeptHead: operator.operationDeptManager,
opsHead: operator.skuManager,
designer: operator.designer,
},
source: source(
JST_SKU_OPERATOR_ARCHIVE_SOURCE_CODE,
JST_SOURCE_SYSTEM,
JST_SOURCE_SCHEMA,
'js_sku_info_manage',
operator.barcode || operator.sku,
{
opsDeptHead: 'operation_dept_manager',
opsHead: 'sku_manager',
designer: 'designer',
},
{ sourceRecordedAt: operator.updatedAt, fillEmptyOnly: true },
),
}
: null
}
export function buildProductArchiveEnrichmentUpdates(
target: ProductArchiveEnrichmentTarget,
lookups: ProductArchiveEnrichmentLookups,
@ -109,7 +160,6 @@ export function buildProductArchiveEnrichmentUpdates(
const catalogCost = lookups.catalogCosts.find(
(profile) => normalizeIdentity(profile.barcode) === normalizeIdentity(target.barcode),
)
return [
{
patch: {

View File

@ -101,7 +101,6 @@ export class SupplyChainProductArchiveEnrichmentSyncService {
targets.map((target) => target.barcode),
),
])
for (const target of targets) {
const updates = buildProductArchiveEnrichmentUpdates(target, {
suppliers,

View File

@ -0,0 +1,102 @@
import {
jstSkuOperatorRepository,
type JstSkuOperatorRepository,
} from '@/server/repositories/jst-sku-operator.repository'
import {
supplyChainProductArchiveEnrichmentSyncRepository,
type SupplyChainProductArchiveEnrichmentSyncRepository,
} from '@/server/repositories/supply-chain-product-archive-enrichment-sync.repository'
import { recordProductArchiveFieldChanges } from './supply-chain-product-archive-change-log.service'
import { buildProductArchiveOperatorInitialUpdate } from './supply-chain-product-archive-enrichment-sync.mapper'
export type ProductArchiveOperatorInitialSyncResult = {
batches: number
scanned: number
matched: number
changed: number
unchanged: number
lastId: string
}
type OperatorInitialSyncDependencies = {
repository: SupplyChainProductArchiveEnrichmentSyncRepository
operatorRepository: Pick<JstSkuOperatorRepository, 'listByKeys' | 'close'>
}
export class SupplyChainProductArchiveOperatorInitialSyncService {
constructor(
private readonly dependencies: OperatorInitialSyncDependencies = {
repository: supplyChainProductArchiveEnrichmentSyncRepository,
operatorRepository: jstSkuOperatorRepository,
},
) {}
async syncAll(input: { batchSize?: number } = {}) {
const batchSize = Math.min(200, Math.max(1, Math.trunc(input.batchSize ?? 200)))
const result: ProductArchiveOperatorInitialSyncResult = {
batches: 0,
scanned: 0,
matched: 0,
changed: 0,
unchanged: 0,
lastId: '',
}
try {
while (true) {
const targets = await this.dependencies.repository.listTargets({
afterId: result.lastId || undefined,
limit: batchSize,
})
if (targets.length === 0) break
const operators = await this.dependencies.operatorRepository.listByKeys(
targets.map((target) => ({ barcode: target.barcode, sku: target.sku })),
)
for (const target of targets) {
const update = buildProductArchiveOperatorInitialUpdate(target, operators)
if (!update) {
result.unchanged += 1
continue
}
result.matched += 1
const applied = await this.dependencies.repository.applyPatch({
archiveId: target.id,
patch: update.patch,
source: update.source,
})
if (applied.changes.length === 0) {
result.unchanged += 1
continue
}
result.changed += applied.changes.length
if (applied.sourceInitialized) continue
await recordProductArchiveFieldChanges({
barcode: target.barcode,
actor: {
source: 'SYSTEM',
actorName: `${update.source.sourceSystem} / ${update.source.sourceSchema} / ${update.source.sourceObject}`,
},
source: {
sourceSystem: update.source.sourceSystem,
sourceSchema: update.source.sourceSchema,
sourceObject: update.source.sourceObject,
},
changes: applied.changes,
})
}
result.batches += 1
result.scanned += targets.length
result.lastId = targets[targets.length - 1]?.id ?? result.lastId
if (targets.length < batchSize) break
}
return result
} finally {
await this.dependencies.operatorRepository.close()
}
}
}
export const supplyChainProductArchiveOperatorInitialSyncService =
new SupplyChainProductArchiveOperatorInitialSyncService()

View File

@ -0,0 +1,169 @@
import { SYSTEM_USER_DEPARTMENT_ARCHIVE_SOURCE_CODE } from '@/lib/supply-chain-product-archive-field-source'
import {
productArchiveOperatorDirectoryRepository,
type ProductArchiveOperatorDirectoryRepository,
} from '@/server/repositories/product-archive-operator-directory.repository'
import {
supplyChainProductArchiveEnrichmentSyncRepository,
type SupplyChainProductArchiveEnrichmentSyncRepository,
} from '@/server/repositories/supply-chain-product-archive-enrichment-sync.repository'
import { recordProductArchiveFieldChanges } from './supply-chain-product-archive-change-log.service'
const OPS_DIRECTOR_BY_CENTER = {
B: '苏适',
C: '花城',
G: '江陵',
} as const
const OPS_DIRECTOR_BY_DIVISION = {
事业一部: '比干',
事业二部: '無尘',
事业三部: '子龙',
事业四部: '半夏',
} as const
const SOURCE = {
sourceCode: SYSTEM_USER_DEPARTMENT_ARCHIVE_SOURCE_CODE,
sourceSystem: '家具系统账号目录',
sourceSchema: 'public',
sourceObject: 'User + Department',
sourceColumns: { opsDirector: 'User.name -> Department.fullPath' },
} as const
function normalizeName(value: string | null | undefined) {
return value?.trim().toLocaleLowerCase('zh-CN') ?? ''
}
function normalizePathPart(value: string) {
return value.replace(/\s+/g, '').trim()
}
export function resolveOpsDirectorByDepartmentPath(
departmentFullPath: string | null | undefined,
) {
const parts = (departmentFullPath ?? '')
.split(/\s*(?:>|>|\/)\s*/)
.map(normalizePathPart)
.filter(Boolean)
const centerAIndex = parts.findIndex((part) => part === '运营中心A')
if (centerAIndex >= 0) {
const division = parts
.slice(centerAIndex + 1)
.find((part): part is keyof typeof OPS_DIRECTOR_BY_DIVISION => (
part in OPS_DIRECTOR_BY_DIVISION
))
return division ? OPS_DIRECTOR_BY_DIVISION[division] : ''
}
const compactPath = parts.join('>')
const combinedDivision = Object.keys(OPS_DIRECTOR_BY_DIVISION).find((division) => (
compactPath.includes(`运营中心A-${division}`)
)) as keyof typeof OPS_DIRECTOR_BY_DIVISION | undefined
if (combinedDivision) return OPS_DIRECTOR_BY_DIVISION[combinedDivision]
const center = (['B', 'C', 'G'] as const).find((code) => (
parts.includes(`运营中心${code}`)
))
return center ? OPS_DIRECTOR_BY_CENTER[center] : ''
}
export type ProductArchiveOpsDirectorSyncResult = {
batches: number
scanned: number
accountMatched: number
resolved: number
changed: number
unchanged: number
lastId: string
}
type OpsDirectorSyncDependencies = {
repository: SupplyChainProductArchiveEnrichmentSyncRepository
directoryRepository: ProductArchiveOperatorDirectoryRepository
}
export class SupplyChainProductArchiveOpsDirectorSyncService {
constructor(
private readonly dependencies: OpsDirectorSyncDependencies = {
repository: supplyChainProductArchiveEnrichmentSyncRepository,
directoryRepository: productArchiveOperatorDirectoryRepository,
},
) {}
async syncAll(input: { batchSize?: number } = {}) {
const batchSize = Math.min(200, Math.max(1, Math.trunc(input.batchSize ?? 200)))
const result: ProductArchiveOpsDirectorSyncResult = {
batches: 0,
scanned: 0,
accountMatched: 0,
resolved: 0,
changed: 0,
unchanged: 0,
lastId: '',
}
while (true) {
const targets = await this.dependencies.repository.listTargets({
afterId: result.lastId || undefined,
limit: batchSize,
})
if (targets.length === 0) break
const directoryRecords = await this.dependencies.directoryRepository.listByNames(
targets.map((target) => target.opsHead),
)
const directoryByName = new Map(
directoryRecords.map((record) => [normalizeName(record.name), record]),
)
for (const target of targets) {
const directoryRecord = directoryByName.get(normalizeName(target.opsHead))
const opsDirector = resolveOpsDirectorByDepartmentPath(
directoryRecord?.departmentFullPath,
)
if (directoryRecord) result.accountMatched += 1
if (opsDirector) result.resolved += 1
const applied = await this.dependencies.repository.applyPatch({
archiveId: target.id,
patch: { opsDirector },
source: {
...SOURCE,
sourceRecordId: directoryRecord?.name || target.opsHead || target.barcode,
},
})
if (applied.changes.length === 0) {
result.unchanged += 1
continue
}
result.changed += applied.changes.length
if (applied.sourceInitialized) continue
await recordProductArchiveFieldChanges({
barcode: target.barcode,
actor: {
source: 'SYSTEM',
actorName: `${SOURCE.sourceSystem} / ${SOURCE.sourceSchema} / ${SOURCE.sourceObject}`,
},
source: {
sourceSystem: SOURCE.sourceSystem,
sourceSchema: SOURCE.sourceSchema,
sourceObject: SOURCE.sourceObject,
},
changes: applied.changes,
})
}
result.batches += 1
result.scanned += targets.length
result.lastId = targets[targets.length - 1]?.id ?? result.lastId
if (targets.length < batchSize) break
}
return result
}
}
export const supplyChainProductArchiveOpsDirectorSyncService =
new SupplyChainProductArchiveOpsDirectorSyncService()

View File

@ -45,6 +45,9 @@ export function mapProductArchiveRow({
barcode: record.barcode,
productName: record.productName,
designer: record.designer,
opsDirector: record.opsDirector,
opsDeptHead: record.opsDeptHead,
opsHead: record.opsHead,
productStatus: record.productStatus,
material: record.material,
color: record.color,