merge: sync product archive operators
This commit is contained in:
commit
af97ea7074
@ -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: '基本盘',
|
||||
|
||||
@ -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