feat(supply-chain): 全量档案运营负责人切换 js_sku_info_manage.sku_manager 数据源
This commit is contained in:
parent
bfe3073aa1
commit
89fbe9231b
@ -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",
|
||||
|
||||
@ -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
|
||||
})
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
Loading…
x
Reference in New Issue
Block a user