feat(supply-chain): 全量档案运营负责人切换 js_sku_info_manage.sku_manager 数据源

This commit is contained in:
WuYongJie 2026-10-14 14:27:02 +08:00
parent bfe3073aa1
commit 89fbe9231b
5 changed files with 588 additions and 0 deletions

View File

@ -21,6 +21,10 @@
"sync:supply-chain-product-archive-enrichment": "tsx scripts/sync-supply-chain-product-archive-enrichment.ts",
"backfill:product-archive-designer": "tsx scripts/backfill-product-archive-designer-from-jst-sku-info-manage.ts",
"backfill:product-archive-designer:apply": "tsx scripts/backfill-product-archive-designer-from-jst-sku-info-manage.ts --apply",
"backfill:product-archive-opshead": "tsx scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts",
"backfill:product-archive-opshead:apply": "tsx scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts --apply",
"backfill:product-archive-opshead:regular": "tsx scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts --mode=regular",
"backfill:product-archive-opshead:regular:apply": "tsx scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts --mode=regular --apply",
"backfill:product-archive-production-type": "tsx scripts/backfill-product-archive-production-type.ts",
"backfill:product-archive-production-type:apply": "tsx scripts/backfill-product-archive-production-type.ts --apply",
"sync:supply-chain-product-archive-sales-performance": "tsx scripts/sync-supply-chain-product-archive-sales-performance.ts",

View File

@ -0,0 +1,105 @@
import { loadEnvConfig } from '@next/env'
loadEnvConfig(process.cwd())
function readArgument(name: string) {
const prefix = `--${name}=`
return process.argv.slice(2).find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? ''
}
function readRequired(name: string) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`${name} is required.`)
return value
}
function databaseIdentity(url: string) {
const parsed = new URL(url)
return [
parsed.protocol.replace(/:$/, ''),
decodeURIComponent(parsed.username || ''),
parsed.hostname,
parsed.port || '',
parsed.pathname.replace(/^\//, ''),
parsed.searchParams.get('schema') ?? '',
].join('|')
}
function sourceDatabaseIdentity() {
return [
'sqlserver',
readRequired('JST_REPORT_DB_USER'),
readRequired('JST_REPORT_DB_HOST'),
process.env.JST_REPORT_DB_PORT?.trim() || '1433',
readRequired('JST_REPORT_DB_NAME'),
process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
].join('|')
}
/**
* 产品全量档案「运营负责人」按 rpt.js_sku_info_manage.sku_manager 刷新。
*
* 默认 dry-run,只统计会改多少行;必须显式带 --apply 才写库。
* 模式(--mode=):
* - replace-all(默认):一次性存量迁移——sku_manager 有值即覆盖档案值,
* 不区分原值来源(含钉钉审批/新品流程/手动上传来源的行),值相同的不动;
* - regular:每日常规——只覆盖「空值 / 旧 DWS_SALES_OPS / 本来源自己」的行,
* 钉钉审批、新品流程、手动上传来源的行一律保护不动。
* 传 --expected-database-identity / --expected-source-database-identity 时,
* 会校验目标库与来源库身份,供计划任务无人值守调用。
*/
async function main() {
const apply = process.argv.includes('--apply')
const modeArgument = readArgument('mode')
if (modeArgument && modeArgument !== 'replace-all' && modeArgument !== 'regular') {
throw new Error('--mode must be replace-all or regular.')
}
const mode = (modeArgument || 'replace-all') as 'replace-all' | 'regular'
const batchSizeValue = Number(readArgument('batch-size') || '200')
if (!Number.isSafeInteger(batchSizeValue) || batchSizeValue < 1 || batchSizeValue > 200) {
throw new Error('--batch-size must be an integer between 1 and 200.')
}
const expectedDatabaseIdentity = readArgument('expected-database-identity')
if (expectedDatabaseIdentity && expectedDatabaseIdentity !== databaseIdentity(readRequired('DATABASE_URL'))) {
throw new Error('expected database identity mismatch')
}
const expectedSourceIdentity = readArgument('expected-source-database-identity')
if (expectedSourceIdentity && expectedSourceIdentity !== sourceDatabaseIdentity()) {
throw new Error('expected source identity mismatch')
}
const [
{ prisma },
{ supplyChainProductArchiveOpsHeadFillService },
] = await Promise.all([
import('../src/lib/prisma'),
import('../src/server/services/supply-chain-product-archive-opshead-fill.service'),
])
try {
console.log(apply ? 'mode=apply' : 'mode=dry-run', `scope=${mode}`)
const result = await supplyChainProductArchiveOpsHeadFillService.syncAll({
apply,
mode,
batchSize: batchSizeValue,
})
console.log(JSON.stringify(result, null, 2))
} finally {
await prisma.$disconnect()
}
}
async function notifySyncFailure(error: unknown) {
try {
const { ARCHIVE_SYNC_FAILURE_TASKS, notifyArchiveSyncFailureSafely } = await import('../src/server/services/product-archive-sync-failure-notification.service')
await notifyArchiveSyncFailureSafely(ARCHIVE_SYNC_FAILURE_TASKS.designerBackfill, error)
} catch {
// 通知链路异常不影响同步任务的退出码
}
}
void main().catch(async (error) => {
await notifySyncFailure(error)
console.error(error instanceof Error ? (error.stack || error.message) : String(error))
process.exitCode = 1
})

View File

@ -8,8 +8,15 @@ export type JstSkuInfoManageDesignerProfile = {
updatedAt: Date | null
}
export type JstSkuInfoManageManagerProfile = {
barcode: string
skuManager: string
updatedAt: Date | null
}
export interface JstSkuInfoManageDesignerRepository {
listDesignerRows(): Promise<JstSkuInfoManageDesignerProfile[]>
listSkuManagerRows(): Promise<JstSkuInfoManageManagerProfile[]>
close(): Promise<void>
}
@ -78,6 +85,25 @@ class JstSkuInfoManageDesignerRepositoryClass
return result.recordset
}
// 只取 sku_manager 有值的行:产品全量档案「运营负责人」的常规来源固定为这部分数据。
async listSkuManagerRows() {
const schema = safeIdentifier(
process.env.JST_REPORT_DB_SCHEMA?.trim() || 'rpt',
'JST_REPORT_DB_SCHEMA',
)
const pool = await this.getPool()
const result = await pool.request().query<JstSkuInfoManageManagerProfile>(`
SELECT
UPPER(LTRIM(RTRIM(CAST(sku_id AS nvarchar(200))))) AS barcode,
LTRIM(RTRIM(CAST(sku_manager AS nvarchar(200)))) AS skuManager,
update_time AS updatedAt
FROM [${schema}].[js_sku_info_manage]
WHERE sku_manager IS NOT NULL
AND LTRIM(RTRIM(CAST(sku_manager AS nvarchar(200)))) <> ''
`)
return result.recordset
}
async close() {
if (!this.pool) return
await this.pool.close()

View File

@ -0,0 +1,185 @@
jest.mock('@/lib/prisma', () => ({ prisma: {} }))
jest.mock('@/server/repositories/jst-sku-info-manage-designer.repository', () => ({
jstSkuInfoManageDesignerRepository: { listSkuManagerRows: jest.fn(), close: jest.fn() },
}))
jest.mock('@/server/repositories/supply-chain-product-archive-enrichment-sync.repository', () => ({
supplyChainProductArchiveEnrichmentSyncRepository: { listTargets: jest.fn(), applyPatch: jest.fn() },
}))
jest.mock('../supply-chain-product-archive-change-log.service', () => ({
recordProductArchiveFieldChanges: jest.fn().mockResolvedValue(undefined),
}))
import {
decideProductArchiveOpsHeadFill,
SupplyChainProductArchiveOpsHeadFillService,
} from '../supply-chain-product-archive-opshead-fill.service'
import { jstSkuInfoManageDesignerRepository } from '@/server/repositories/jst-sku-info-manage-designer.repository'
import { supplyChainProductArchiveEnrichmentSyncRepository } from '@/server/repositories/supply-chain-product-archive-enrichment-sync.repository'
describe('decideProductArchiveOpsHeadFill', () => {
it('skips when the source manager is empty', () => {
expect(
decideProductArchiveOpsHeadFill({ mode: 'replace-all', currentOpsHead: '五竹', sourceManager: ' ' }),
).toEqual({ kind: 'skip' })
})
it('replace-all overrides any stored source when values differ', () => {
expect(
decideProductArchiveOpsHeadFill({
mode: 'replace-all',
currentOpsHead: '子龙',
sourceManager: '萧何',
storedOpsHeadSource: { sourceCode: 'DING_APPROVED_OWNER_WORKFLOW', sourceUpdatedAt: '2026-10-01T00:00:00.000Z' },
}),
).toEqual({ kind: 'replace', opsHead: '萧何' })
})
it('replace-all keeps equal values untouched (no source churn)', () => {
expect(
decideProductArchiveOpsHeadFill({
mode: 'replace-all',
currentOpsHead: '萧何',
sourceManager: '萧何',
storedOpsHeadSource: { sourceCode: 'DWS_SALES_OPS', sourceUpdatedAt: '2026-10-07T00:00:00.000Z' },
}),
).toEqual({ kind: 'unchanged' })
})
it('regular replaces empty, legacy dws and self-sourced rows', () => {
for (const sourceCode of ['', 'DWS_SALES_OPS', 'JST_SKU_INFO_MANAGE']) {
expect(
decideProductArchiveOpsHeadFill({
mode: 'regular',
currentOpsHead: '子龙',
sourceManager: '萧何',
storedOpsHeadSource: sourceCode ? { sourceCode, sourceUpdatedAt: '2026-10-07T00:00:00.000Z' } : null,
}),
).toEqual({ kind: 'replace', opsHead: '萧何' })
}
})
it('regular protects approval, intake and manual-import rows', () => {
for (const sourceCode of ['DING_APPROVED_OWNER_WORKFLOW', 'INTAKE_NON_INTEGRATED_PROFILE', 'MANUAL_IMPORT']) {
expect(
decideProductArchiveOpsHeadFill({
mode: 'regular',
currentOpsHead: '子龙',
sourceManager: '萧何',
storedOpsHeadSource: { sourceCode, sourceUpdatedAt: '2026-10-07T00:00:00.000Z' },
}),
).toEqual({ kind: 'skip' })
}
})
it('regular keeps equal values unchanged', () => {
expect(
decideProductArchiveOpsHeadFill({
mode: 'regular',
currentOpsHead: '萧何',
sourceManager: '萧何',
storedOpsHeadSource: { sourceCode: 'JST_SKU_INFO_MANAGE', sourceUpdatedAt: '2026-10-07T00:00:00.000Z' },
}),
).toEqual({ kind: 'unchanged' })
})
})
function createDeps(input: {
sourceRows: Array<{ barcode: string; skuManager: string; updatedAt: Date | null }>
targets: Array<{
id: string
barcode: string
opsHead: string | null
fieldSourceMap: unknown
}>
}) {
;(jstSkuInfoManageDesignerRepository as { listSkuManagerRows: jest.Mock }).listSkuManagerRows
.mockReset()
.mockResolvedValueOnce(input.sourceRows)
.mockResolvedValue([])
;(supplyChainProductArchiveEnrichmentSyncRepository as { listTargets: jest.Mock }).listTargets
.mockReset()
.mockImplementationOnce(async () => input.targets)
.mockImplementation(async () => [])
;(supplyChainProductArchiveEnrichmentSyncRepository as { applyPatch: jest.Mock }).applyPatch
.mockReset()
.mockImplementation(async () => ({
changes: [{ field: 'opsHead', fieldLabel: '运营负责人', previousValue: '', nextValue: '萧何' }],
sourceInitialized: false,
}))
}
describe('SupplyChainProductArchiveOpsHeadFillService', () => {
it('replace-all writes matched managers and records the jst source', async () => {
createDeps({
sourceRows: [
{ barcode: 'BJ021801N3370V01', skuManager: '萧何', updatedAt: new Date('2026-09-10T08:00:00Z') },
{ barcode: 'NOARCHIVE01', skuManager: '某人', updatedAt: null },
],
targets: [
{
id: 'a1',
barcode: 'BJ021801N3370V01',
opsHead: '子龙',
fieldSourceMap: { opsHead: { sourceCode: 'DING_APPROVED_OWNER_WORKFLOW', sourceColumn: 'x', sourceUpdatedAt: '2026-10-01T00:00:00.000Z' } },
},
{
id: 'a2',
barcode: 'UNMATCHED01',
opsHead: '五竹',
fieldSourceMap: { opsHead: { sourceCode: 'DWS_SALES_OPS', sourceColumn: 'cater', sourceUpdatedAt: '2026-10-07T00:00:00.000Z' } },
},
],
})
const service = new SupplyChainProductArchiveOpsHeadFillService({
archiveRepository: supplyChainProductArchiveEnrichmentSyncRepository,
sourceRepository: jstSkuInfoManageDesignerRepository,
})
const result = await service.syncAll({ apply: true, mode: 'replace-all' })
expect(result).toMatchObject({
apply: true,
mode: 'replace-all',
sourceRows: 2,
scanned: 2,
matched: 1,
replaced: 1,
unmatched: 1,
})
expect(supplyChainProductArchiveEnrichmentSyncRepository.applyPatch).toHaveBeenCalledWith(
expect.objectContaining({
archiveId: 'a1',
patch: { opsHead: '萧何' },
source: expect.objectContaining({
sourceCode: 'JST_SKU_INFO_MANAGE',
sourceColumns: { opsHead: 'sku_manager' },
}),
}),
)
})
it('regular mode protects approval rows end to end', async () => {
createDeps({
sourceRows: [{ barcode: 'BJ021801N3370V01', skuManager: '萧何', updatedAt: null }],
targets: [
{
id: 'a1',
barcode: 'BJ021801N3370V01',
opsHead: '子龙',
fieldSourceMap: { opsHead: { sourceCode: 'DING_APPROVED_OWNER_WORKFLOW', sourceColumn: 'x', sourceUpdatedAt: '2026-10-01T00:00:00.000Z' } },
},
],
})
const service = new SupplyChainProductArchiveOpsHeadFillService({
archiveRepository: supplyChainProductArchiveEnrichmentSyncRepository,
sourceRepository: jstSkuInfoManageDesignerRepository,
})
const result = await service.syncAll({ apply: true, mode: 'regular' })
expect(result.protectedSkipped).toBe(1)
expect(result.replaced).toBe(0)
expect(supplyChainProductArchiveEnrichmentSyncRepository.applyPatch).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,268 @@
import {
DWS_SALES_OPS_ARCHIVE_SOURCE_CODE,
JST_SKU_INFO_MANAGE_ARCHIVE_SOURCE_CODE,
parseProductArchiveFieldSourceMap,
} from '@/lib/supply-chain-product-archive-field-source'
import { normalizeOwnerName } from '@/lib/supply-chain-owner-employment'
import {
supplyChainProductArchiveEnrichmentSyncRepository,
type ProductArchiveEnrichmentPatch,
type ProductArchiveEnrichmentSource,
type SupplyChainProductArchiveEnrichmentSyncRepository,
} from '@/server/repositories/supply-chain-product-archive-enrichment-sync.repository'
import {
jstSkuInfoManageDesignerRepository,
type JstSkuInfoManageManagerProfile,
type JstSkuInfoManageDesignerRepository,
} from '@/server/repositories/jst-sku-info-manage-designer.repository'
import {
productArchiveOwnerImportRepository,
type ProductArchiveOwnerImportRepository,
} from '@/server/repositories/supply-chain-product-archive-owner-import.repository'
import { clearOwnerEmploymentValueCache } from '@/server/repositories/supply-chain-product-archive-filters'
import { supplyChainManagementOwnerConfigRepository } from '@/server/repositories/supply-chain-management-owner-config.repository'
import { recordProductArchiveFieldChanges } from './supply-chain-product-archive-change-log.service'
const JST_SOURCE_SYSTEM = '聚水潭 JSReportReplica'
const JST_SOURCE_SCHEMA = 'rpt'
const JST_OPSHEAD_SOURCE_OBJECT = 'js_sku_info_manage'
/** 日常模式下允许本任务覆盖的档案值来源(自己领地);审批/新品/手动上传一律保护。 */
const REGULAR_REPLACEABLE_SOURCES = new Set([
'',
DWS_SALES_OPS_ARCHIVE_SOURCE_CODE,
JST_SKU_INFO_MANAGE_ARCHIVE_SOURCE_CODE,
])
export type ProductArchiveOpsHeadFillDecision =
| { kind: 'skip' }
| { kind: 'unchanged' }
| { kind: 'replace'; opsHead: string }
/**
* 运营负责人按 js_sku_info_manage.sku_manager 刷新的决策:
* - replace-all(一次性存量迁移,用户 2026-10-14 拍板):sku_manager 有值即覆盖档案值
* 并统一溯源到 JST_SKU_INFO_MANAGE(值相同也刷溯源,口径归一);
* - regular(每日常规):只覆盖「空值/旧 DWS_SALES_OPS/本来源自己」的行,
* 钉钉审批、新品流程、手动上传来源的行不动,防止打回更高优先级写入的值。
*/
export function decideProductArchiveOpsHeadFill(input: {
mode: 'replace-all' | 'regular'
currentOpsHead: string | null | undefined
sourceManager: string | null | undefined
storedOpsHeadSource?: { sourceCode: string; sourceUpdatedAt: string } | null
}): ProductArchiveOpsHeadFillDecision {
const sourceManager = input.sourceManager?.trim() ?? ''
if (!sourceManager) return { kind: 'skip' }
const currentOpsHead = input.currentOpsHead?.trim() ?? ''
if (input.mode === 'replace-all') {
// 值已相同的行连溯源都不动:applyPatch 只在值变化时才写 fieldSourceMap,
// 强行刷溯源只会空转;值对齐即达成「表格里就是 sku_manager」的目标。
if (currentOpsHead === sourceManager) return { kind: 'unchanged' }
return { kind: 'replace', opsHead: sourceManager }
}
const storedSourceCode = input.storedOpsHeadSource?.sourceCode?.trim() ?? ''
if (!REGULAR_REPLACEABLE_SOURCES.has(storedSourceCode)) return { kind: 'skip' }
if (currentOpsHead === sourceManager) return { kind: 'unchanged' }
return { kind: 'replace', opsHead: sourceManager }
}
export type ProductArchiveOpsHeadFillResult = {
apply: boolean
mode: 'replace-all' | 'regular'
sourceRows: number
scanned: number
matched: number
replaced: number
unchanged: number
protectedSkipped: number
unmatched: number
hierarchyRefreshed: number
lastId: string
}
type OpsHeadFillDependencies = {
archiveRepository: Pick<
SupplyChainProductArchiveEnrichmentSyncRepository,
'listTargets' | 'applyPatch'
>
sourceRepository: Pick<
JstSkuInfoManageDesignerRepository,
'listSkuManagerRows' | 'close'
>
ownerConfigRepository: Pick<typeof supplyChainManagementOwnerConfigRepository, 'listOwnerHierarchy'>
hierarchyRepository: Pick<ProductArchiveOwnerImportRepository, 'applyHierarchyUpdate'>
}
export class SupplyChainProductArchiveOpsHeadFillService {
constructor(
private readonly dependencies: OpsHeadFillDependencies = {
archiveRepository: supplyChainProductArchiveEnrichmentSyncRepository,
sourceRepository: jstSkuInfoManageDesignerRepository,
ownerConfigRepository: supplyChainManagementOwnerConfigRepository,
hierarchyRepository: productArchiveOwnerImportRepository,
},
) {}
async syncAll(input: {
apply: boolean
mode?: 'replace-all' | 'regular'
batchSize?: number
}): Promise<ProductArchiveOpsHeadFillResult> {
const apply = input.apply === true
const mode = input.mode === 'regular' ? 'regular' : 'replace-all'
const batchSize = Math.min(200, Math.max(1, Math.trunc(input.batchSize ?? 200)))
const result: ProductArchiveOpsHeadFillResult = {
apply,
mode,
sourceRows: 0,
scanned: 0,
matched: 0,
replaced: 0,
unchanged: 0,
protectedSkipped: 0,
unmatched: 0,
hierarchyRefreshed: 0,
lastId: '',
}
try {
const sourceRows = await this.dependencies.sourceRepository.listSkuManagerRows()
result.sourceRows = sourceRows.length
const byBarcode = new Map<string, JstSkuInfoManageManagerProfile>()
for (const row of sourceRows) {
const barcode = row.barcode?.trim().toUpperCase() ?? ''
if (barcode && !byBarcode.has(barcode)) byBarcode.set(barcode, row)
}
const matchedBarcodes = new Set<string>()
// 换了负责人(以及已与来源一致的)匹配行:apply 模式下按人员信息重算部门/总负责人,
// 不等第二天 08:10 enrichment——层级与负责人同批一致(幂等,值没变不写库)。
const hierarchyTargets: Array<{ archiveId: string; opsHead: string }> = []
while (true) {
const targets = await this.dependencies.archiveRepository.listTargets({
afterId: result.lastId || undefined,
limit: batchSize,
})
if (targets.length === 0) break
for (const target of targets) {
result.scanned += 1
const barcode = target.barcode?.trim().toUpperCase() ?? ''
const profile = barcode ? byBarcode.get(barcode) : undefined
if (!profile?.skuManager?.trim()) continue
matchedBarcodes.add(barcode)
result.matched += 1
const currentOpsHead = target.opsHead?.trim() ?? ''
const storedSource = parseProductArchiveFieldSourceMap(target.fieldSourceMap).opsHead
const decision = decideProductArchiveOpsHeadFill({
mode,
currentOpsHead: target.opsHead,
sourceManager: profile.skuManager,
storedOpsHeadSource: storedSource,
})
if (decision.kind === 'skip') {
result.protectedSkipped += 1
continue
}
if (decision.kind === 'unchanged') {
result.unchanged += 1
if (apply) hierarchyTargets.push({ archiveId: target.id, opsHead: currentOpsHead })
continue
}
if (!apply) {
result.replaced += 1
continue
}
hierarchyTargets.push({ archiveId: target.id, opsHead: decision.opsHead })
const source: ProductArchiveEnrichmentSource = {
sourceCode: JST_SKU_INFO_MANAGE_ARCHIVE_SOURCE_CODE,
sourceSystem: JST_SOURCE_SYSTEM,
sourceSchema: JST_SOURCE_SCHEMA,
sourceObject: JST_OPSHEAD_SOURCE_OBJECT,
sourceRecordId: profile.barcode,
sourceColumns: { opsHead: 'sku_manager' },
sourceRecordedAt: profile.updatedAt,
}
const patch: ProductArchiveEnrichmentPatch = { opsHead: decision.opsHead }
const applied = await this.dependencies.archiveRepository.applyPatch({
archiveId: target.id,
patch,
source,
})
if (applied.changes.length === 0) {
result.unchanged += 1
continue
}
result.replaced += 1
if (applied.sourceInitialized) continue
await recordProductArchiveFieldChanges({
barcode: target.barcode,
actor: {
source: 'SYSTEM',
actorName: `${JST_SOURCE_SYSTEM} / ${JST_SOURCE_SCHEMA} / ${JST_OPSHEAD_SOURCE_OBJECT}`,
},
source: {
sourceSystem: JST_SOURCE_SYSTEM,
sourceSchema: JST_SOURCE_SCHEMA,
sourceObject: JST_OPSHEAD_SOURCE_OBJECT,
sourceRecordedAt: profile.updatedAt,
},
changes: applied.changes,
})
}
result.lastId = targets[targets.length - 1]?.id ?? result.lastId
if (targets.length < batchSize) break
}
result.unmatched = byBarcode.size - matchedBarcodes.size
if (apply && hierarchyTargets.length > 0) {
result.hierarchyRefreshed = await this.refreshHierarchy(hierarchyTargets)
clearOwnerEmploymentValueCache()
}
return result
} finally {
await this.dependencies.sourceRepository.close()
}
}
/** 按人员信息的「人员 → 部门负责人/总负责人」层级重算(幂等;失败不回滚负责人替换)。 */
private async refreshHierarchy(targets: Array<{ archiveId: string; opsHead: string }>) {
try {
const hierarchy = await this.dependencies.ownerConfigRepository.listOwnerHierarchy()
const hierarchyByPerson = new Map(
hierarchy.map((entry) => [normalizeOwnerName(entry.operationOwner), entry]),
)
let refreshed = 0
for (const target of targets) {
const entry = hierarchyByPerson.get(normalizeOwnerName(target.opsHead))
if (!entry || (!entry.departmentOwner && !entry.generalOwner)) continue
const applied = await this.dependencies.hierarchyRepository.applyHierarchyUpdate({
archiveId: target.archiveId,
...(entry.departmentOwner ? { opsDeptHead: entry.departmentOwner } : {}),
...(entry.generalOwner ? { opsDirector: entry.generalOwner } : {}),
occurredAt: new Date(),
actor: {
source: 'SYSTEM',
actorName: '供应链中心 / public / SupplyChainManagementOwnerConfig',
},
})
if (applied.length > 0) refreshed += 1
}
return refreshed
} catch (error) {
// 层级刷新失败不影响已完成的负责人替换;每日 enrichment 会自然补齐
console.warn('[product-archive-opshead-fill] hierarchy refresh failed', error)
return 0
}
}
}
export const supplyChainProductArchiveOpsHeadFillService =
new SupplyChainProductArchiveOpsHeadFillService()