Merge remote-tracking branch 'origin/codex/wuyongjie-bom-category-skills-v7' into codex/wuyongjie-promote-bom-optional-20260813
This commit is contained in:
commit
adae1ac0f1
@ -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 '';
|
||||
@ -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("")
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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: '采购' },
|
||||
|
||||
@ -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: '线稿图 | 爆炸图 | 安装图',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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(
|
||||
|
||||
@ -17,6 +17,9 @@ const productArchiveSelect = {
|
||||
categoryL2: true,
|
||||
productName: true,
|
||||
designer: true,
|
||||
opsDirector: true,
|
||||
opsDeptHead: true,
|
||||
opsHead: true,
|
||||
productStatus: true,
|
||||
material: true,
|
||||
color: true,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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) })
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -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: '基本盘',
|
||||
|
||||
@ -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 = '全实木普通开放式置物架'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -101,7 +101,6 @@ export class SupplyChainProductArchiveEnrichmentSyncService {
|
||||
targets.map((target) => target.barcode),
|
||||
),
|
||||
])
|
||||
|
||||
for (const target of targets) {
|
||||
const updates = buildProductArchiveEnrichmentUpdates(target, {
|
||||
suppliers,
|
||||
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user