merge: sync product archive operators

This commit is contained in:
WuYongJie 2026-08-13 12:02:14 +08:00
commit af97ea7074
19 changed files with 866 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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