diff --git a/furniture-3d-system/package.json b/furniture-3d-system/package.json index 3f4d6297..01321b5a 100644 --- a/furniture-3d-system/package.json +++ b/furniture-3d-system/package.json @@ -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", diff --git a/furniture-3d-system/scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts b/furniture-3d-system/scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts new file mode 100644 index 00000000..f3beac21 --- /dev/null +++ b/furniture-3d-system/scripts/backfill-product-archive-opshead-from-jst-sku-info-manage.ts @@ -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 +}) diff --git a/furniture-3d-system/src/server/repositories/jst-sku-info-manage-designer.repository.ts b/furniture-3d-system/src/server/repositories/jst-sku-info-manage-designer.repository.ts index d7fdfe7a..87f1af30 100644 --- a/furniture-3d-system/src/server/repositories/jst-sku-info-manage-designer.repository.ts +++ b/furniture-3d-system/src/server/repositories/jst-sku-info-manage-designer.repository.ts @@ -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 + listSkuManagerRows(): Promise close(): Promise } @@ -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(` + 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() diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-opshead-fill.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-opshead-fill.service.test.ts new file mode 100644 index 00000000..54c3f441 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-opshead-fill.service.test.ts @@ -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() + }) +}) diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-opshead-fill.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-opshead-fill.service.ts new file mode 100644 index 00000000..52f37e2b --- /dev/null +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-opshead-fill.service.ts @@ -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 + hierarchyRepository: Pick +} + +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 { + 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() + for (const row of sourceRows) { + const barcode = row.barcode?.trim().toUpperCase() ?? '' + if (barcode && !byBarcode.has(barcode)) byBarcode.set(barcode, row) + } + + const matchedBarcodes = new Set() + // 换了负责人(以及已与来源一致的)匹配行: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()