fix(supply-chain): 运营负责人改为一次性定标冻结(摘除跟随最新销售的每日翻转)

This commit is contained in:
WuYongJie 2026-10-09 09:00:12 +08:00
parent 139aec0e70
commit c3b20aace6
3 changed files with 76 additions and 284 deletions

View File

@ -62,8 +62,6 @@ describe('supply-chain product archive enrichment sync', () => {
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
dwsOpsOwners: [],
spuOpsOwners: [],
intakeSpuOwners: new Map(),
ownerHierarchyByPerson: new Map(),
catalogCosts: [],
@ -88,8 +86,6 @@ describe('supply-chain product archive enrichment sync', () => {
dwsGoodsCosts: [{ skuoid: 'DF-A3BJ006601', goodsCost: '70.18' }],
skumapPics: [{ skuId: 'DF-A3BJ006601', pic: 'https://img.pddpic.com/mms-material-img/df.jpg' }],
skumapFillPics: [],
dwsOpsOwners: [],
spuOpsOwners: [],
intakeSpuOwners: new Map(),
ownerHierarchyByPerson: new Map(),
catalogCosts: [],
@ -163,8 +159,6 @@ describe('supply-chain product archive enrichment sync', () => {
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [{ skuId: 'BAR-001', pic: 'https://img.example.com/bar-001.jpg' }],
dwsOpsOwners: [],
spuOpsOwners: [],
intakeSpuOwners: new Map(),
ownerHierarchyByPerson: new Map(),
catalogCosts: [],
@ -210,7 +204,7 @@ describe('supply-chain product archive enrichment sync', () => {
expect(filledUpdates.some((update) => 'image' in update.patch)).toBe(false)
})
it('falls back to spu-level ops owners when the barcode has no direct dws row', () => {
it('binds the intake spuOwner only when the archive has no stored opsHead', () => {
const lookups = {
clearanceStocks: [],
publicAvailableStocks: [],
@ -221,70 +215,6 @@ describe('supply-chain product archive enrichment sync', () => {
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
dwsOpsOwners: [],
spuOpsOwners: [{ spu: 'DF-A3CJ0024', cater: '虞姬', ider: '李逍遥' }],
intakeSpuOwners: new Map(),
ownerHierarchyByPerson: new Map(),
catalogCosts: [],
warehouseConfigs: [],
}
// Same-spu barcode without its own dws row inherits the spu owners
const fallbackUpdates = buildProductArchiveEnrichmentUpdates(
{
id: 'archive-df-spu',
barcode: 'DF-A3CJ002401',
spu: 'DF-A3CJ0024',
sku: 'DF-A3CJ0024',
supplierName: '',
inOutWarehouse: '',
image: '',
},
lookups,
)
const opsUpdate = fallbackUpdates.find((update) => 'opsHead' in update.patch)
expect(opsUpdate?.patch).toEqual({ opsHead: '虞姬' })
expect(opsUpdate?.source).toEqual(
expect.objectContaining({ sourceRecordId: 'spu:DF-A3CJ0024' }),
)
// Direct dws match still wins over the spu fallback
const directLookups = {
...lookups,
dwsOpsOwners: [{ skuoid: 'DF-A3CJ002401', cater: '比干', ider: '', lastSaleDate: '2026-09-01' }],
}
const directUpdates = buildProductArchiveEnrichmentUpdates(
{
id: 'archive-df-spu',
barcode: 'DF-A3CJ002401',
spu: 'DF-A3CJ0024',
sku: 'DF-A3CJ0024',
supplierName: '',
inOutWarehouse: '',
image: '',
},
directLookups,
)
const directUpdate = directUpdates.find((update) => 'opsHead' in update.patch)
expect(directUpdate?.patch).toEqual({ opsHead: '比干' })
expect(directUpdate?.source).toEqual(
expect.objectContaining({ sourceRecordId: 'DF-A3CJ002401' }),
)
})
it('falls back to the intake process spuOwner when there is no sales history', () => {
const lookups = {
clearanceStocks: [],
publicAvailableStocks: [],
suppliers: [],
stocks: [],
warehouses: [],
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
dwsOpsOwners: [],
spuOpsOwners: [],
intakeSpuOwners: new Map([['BJ0220', '萧何']]),
ownerHierarchyByPerson: new Map([
['萧何', { departmentOwner: '东邪', generalOwner: '無尘' }],
@ -317,6 +247,39 @@ describe('supply-chain product archive enrichment sync', () => {
expect(hierarchyUpdate?.patch).toEqual({ opsDeptHead: '东邪', opsDirector: '無尘' })
})
it('never overwrites a stored opsHead with the intake binding', () => {
const lookups = {
clearanceStocks: [],
publicAvailableStocks: [],
suppliers: [],
stocks: [],
warehouses: [],
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
intakeSpuOwners: new Map([['BJ0220', '萧何']]),
ownerHierarchyByPerson: new Map(),
catalogCosts: [],
warehouseConfigs: [],
}
const updates = buildProductArchiveEnrichmentUpdates(
{
id: 'archive-bound',
barcode: 'BJ022001N100V01',
spu: 'BJ0220',
sku: 'BJ022001',
supplierName: '',
inOutWarehouse: '',
image: '',
opsHead: '灵犀',
},
lookups,
)
expect(updates.some((update) => 'opsHead' in update.patch)).toBe(false)
})
it('normalizes invisible characters so identical names always match', () => {
expect(normalizeIdentity('琼\u200B琚')).toBe(normalizeIdentity('琼琚'))
expect(normalizeIdentity(' 子\uFEFF芩 ')).toBe('子芩')
@ -334,8 +297,6 @@ describe('supply-chain product archive enrichment sync', () => {
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
dwsOpsOwners: [],
spuOpsOwners: [],
intakeSpuOwners: new Map(),
ownerHierarchyByPerson: new Map([
['子芩', { departmentOwner: '子芩', generalOwner: '江陵' }],
@ -365,7 +326,7 @@ describe('supply-chain product archive enrichment sync', () => {
expect(hierarchyUpdate?.source).toEqual(
expect.objectContaining({ sourceRecordId: '子芩' }),
)
// 推不出运营负责人时只补层级,不动 opsHead 本身
// 层级只按档案已存的运营负责人匹配,不再反推 opsHead 本身
expect(updates.some((update) => 'opsHead' in update.patch)).toBe(false)
})
@ -380,8 +341,6 @@ describe('supply-chain product archive enrichment sync', () => {
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
dwsOpsOwners: [],
spuOpsOwners: [],
intakeSpuOwners: new Map(),
ownerHierarchyByPerson: new Map([
['琼琚', { departmentOwner: '半夏', generalOwner: '江陵' }],
@ -410,83 +369,6 @@ describe('supply-chain product archive enrichment sync', () => {
expect(hierarchyUpdate?.patch).toEqual({ opsDeptHead: '半夏', opsDirector: '江陵' })
})
it('takes ops owners from the dws sales table only when dws has a value', () => {
const lookups = {
clearanceStocks: [],
publicAvailableStocks: [],
suppliers: [],
stocks: [],
warehouses: [],
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
dwsOpsOwners: [
{ skuoid: 'BAR-001', cater: '比干', ider: '精卫' },
{ skuoid: 'BAR-002', cater: '香茗', ider: '' },
],
spuOpsOwners: [],
intakeSpuOwners: new Map(),
ownerHierarchyByPerson: new Map(),
catalogCosts: [],
warehouseConfigs: [],
}
const bothUpdates = buildProductArchiveEnrichmentUpdates(
{
id: 'archive-1',
barcode: 'BAR-001',
spu: 'SPU-001',
sku: 'SKU-001',
supplierName: '',
inOutWarehouse: '',
image: '',
},
lookups,
)
const bothPatch = bothUpdates.find((update) => 'opsHead' in update.patch)
expect(bothPatch?.patch).toEqual({ opsHead: '比干' })
expect(bothPatch?.source).toEqual(
expect.objectContaining({
sourceCode: 'DWS_SALES_OPS',
sourceObject: 'dws_sn_sales_full_info_di',
sourceColumns: { opsHead: 'cater' },
}),
)
// Only cater present: opsHead still filled from cater (ider is unused now)
const partialUpdates = buildProductArchiveEnrichmentUpdates(
{
id: 'archive-2',
barcode: 'BAR-002',
spu: 'SPU-002',
sku: 'SKU-002',
supplierName: '',
inOutWarehouse: '',
image: '',
},
lookups,
)
const partialPatch = partialUpdates.find((update) => 'opsHead' in update.patch)
expect(partialPatch?.patch).toEqual({ opsHead: '香茗' })
// Barcode without dws ops data: no ops patch at all (keeps archive value)
const untouchedUpdates = buildProductArchiveEnrichmentUpdates(
{
id: 'archive-3',
barcode: 'BAR-009',
spu: 'SPU-009',
sku: 'SKU-009',
supplierName: '',
inOutWarehouse: '',
image: '',
},
lookups,
)
expect(untouchedUpdates.some((update) => 'opsDeptHead' in update.patch)).toBe(false)
expect(untouchedUpdates.some((update) => 'opsHead' in update.patch)).toBe(false)
})
it('takes dropship (DF) cost from the sales goods_cost source and keeps jst_sku cost for others', () => {
const lookups = {
clearanceStocks: [],
@ -501,8 +383,6 @@ describe('supply-chain product archive enrichment sync', () => {
dwsGoodsCosts: [{ skuoid: 'DF-A3BJ006601', goodsCost: '70.18' }],
skumapPics: [],
skumapFillPics: [],
dwsOpsOwners: [],
spuOpsOwners: [],
intakeSpuOwners: new Map(),
ownerHierarchyByPerson: new Map(),
catalogCosts: [],
@ -718,25 +598,9 @@ describe('supply-chain product archive enrichment sync', () => {
expect.objectContaining({ scanned: 1, changed: 1 }),
)
// 仓库配置拆成两条:负责人照常更新;入库/代发只填空值(产品定厂带回的值不被覆盖)
expect(repository.applyPatch).toHaveBeenCalledTimes(12)
// dws 运营负责人:cater→opsHead(ider 不再使用)
expect(repository.applyPatch).toHaveBeenCalledWith(
expect.objectContaining({
patch: expect.objectContaining({ opsHead: '李逍遥' }),
source: expect.objectContaining({ sourceObject: 'dws_sn_sales_full_info_di' }),
}),
)
// 人员信息表层级覆盖 dws 的部门负责人猜测值,并补齐运营总负责人
expect(repository.applyPatch).toHaveBeenCalledWith(
expect.objectContaining({
patch: expect.objectContaining({ opsDeptHead: '东邪', opsDirector: '無尘' }),
source: expect.objectContaining({
sourceObject: 'SupplyChainManagementOwnerConfig',
sourceColumns: { opsDeptHead: 'departmentOwner', opsDirector: 'generalOwner' },
}),
}),
)
// 仓库配置拆成两条:负责人照常更新;入库/代发只填空值(产品定厂带回的值不被覆盖)。
// 运营负责人(opsHead)2026-10 起为一次性定标冻结口径,enrichment 不再产生这两条。
expect(repository.applyPatch).toHaveBeenCalledTimes(10)
expect(repository.applyPatch).toHaveBeenCalledWith(
expect.objectContaining({
patch: expect.objectContaining({ warehouseHead: '负责人' }),

View File

@ -1,6 +1,5 @@
import {
DWS_SALES_GOODS_COST_ARCHIVE_SOURCE_CODE,
DWS_SALES_OPS_ARCHIVE_SOURCE_CODE,
JST_CLEARANCE_STOCK_ARCHIVE_SOURCE_CODE,
JST_FACTORY_STOCK_ARCHIVE_SOURCE_CODE,
JST_PUBLIC_AVAILABLE_STOCK_ARCHIVE_SOURCE_CODE,
@ -15,10 +14,7 @@ import {
WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE,
} from '@/lib/supply-chain-product-archive-field-source'
import type { ProductArchiveClearanceStockProfile } from '@/server/repositories/supply-chain-product-archive-clearance-stock.repository'
import type {
DwsSalesGoodsCostProfile,
DwsSalesOpsOwnerProfile,
} from '@/server/repositories/dws-sales-goods-cost.repository'
import type { DwsSalesGoodsCostProfile } from '@/server/repositories/dws-sales-goods-cost.repository'
import type { JstSkumapPicProfile } from '@/server/repositories/jst-skumap-pic.repository'
import type { JstInventoryPublicAvailableProfile } from '@/server/repositories/jst-inventory-public-available.repository'
import type { JstSkuPriceProfile } from '@/server/repositories/jst-sku-price.repository'
@ -56,8 +52,6 @@ export type ProductArchiveEnrichmentLookups = {
dwsGoodsCosts: readonly DwsSalesGoodsCostProfile[]
skumapPics: readonly JstSkumapPicProfile[]
skumapFillPics: readonly JstSkumapPicProfile[]
dwsOpsOwners: readonly DwsSalesOpsOwnerProfile[]
spuOpsOwners: readonly { spu: string; cater: string; ider: string }[]
intakeSpuOwners: ReadonlyMap<string, string>
ownerHierarchyByPerson: ReadonlyMap<
string,
@ -185,55 +179,37 @@ export function buildProductArchiveEnrichmentUpdates(
},
]
: []
// 运营负责人(opsHead)取数口径(2026-09-22 用户确认):
// ① dws_sn_sales_full_info_di.cater——有销售历史的条码取最新销售行的 cater;
// 条码无直连行时回退同 SPU 最新销售条码的 cater(SPU 级兜底);
// ② 无任何销售数据时回退新品流程(IntakeNonIntegratedProductProfile.spuOwner,SPU 级)。
// dws 的 ider 列不再使用;运营部门负责人/总负责人不来自 dws(见下方人员信息表层)。
const dwsOpsOwner = lookups.dwsOpsOwners.find(
(profile) => normalizeIdentity(profile.skuoid) === normalizeIdentity(target.barcode),
)
const spuOpsOwner = !dwsOpsOwner?.cater
? lookups.spuOpsOwners.find(
(profile) => normalizeIdentity(profile.spu) === normalizeIdentity(target.spu),
)
: undefined
const intakeSpuOwner = !dwsOpsOwner?.cater && !spuOpsOwner?.cater
? lookups.intakeSpuOwners.get(normalizeIdentity(target.spu))
: undefined
const effectiveOpsHead = dwsOpsOwner?.cater || spuOpsOwner?.cater || intakeSpuOwner || ''
const opsOwnerUpdates: ProductArchiveEnrichmentUpdate[] =
effectiveOpsHead
? [
{
patch: { opsHead: effectiveOpsHead },
source: intakeSpuOwner
? source(
INTAKE_PROFILE_ARCHIVE_SOURCE_CODE,
PRODUCT_DEVELOPMENT_SOURCE_SYSTEM,
PUBLIC_SOURCE_SCHEMA,
'IntakeNonIntegratedProductProfile',
normalizeIdentity(target.spu),
{ opsHead: 'spuOwner' },
)
: source(
DWS_SALES_OPS_ARCHIVE_SOURCE_CODE,
JST_SOURCE_SYSTEM,
JST_SOURCE_SCHEMA,
'dws_sn_sales_full_info_di',
dwsOpsOwner?.skuoid || `spu:${normalizeIdentity(target.spu)}`,
{ opsHead: 'cater' },
),
},
]
: []
// 运营负责人(opsHead)2026-10 起改为一次性定标冻结:定标只允许发生在
// ① 新品绑定(无销售档案按新品流程 spuOwner 打底,见下方兜底);
// ② 一次性定标脚本(按 SPU 累计业绩最多的运营,见 scripts/fix-archive-ops-head-once.ts);
// ③ 人工在页面修改。
// enrichment 同步不再跟随 dws 最新销售翻单;部门负责人/总负责人仍按人员信息
// 匹配档案当前运营负责人,保持层级一致。
const storedOpsHead = (target.opsHead ?? '').trim()
const intakeSpuOwner = storedOpsHead
? undefined
: lookups.intakeSpuOwners.get(normalizeIdentity(target.spu))
const intakeOpsHeadUpdates: ProductArchiveEnrichmentUpdate[] = intakeSpuOwner
? [
{
patch: { opsHead: intakeSpuOwner },
source: source(
INTAKE_PROFILE_ARCHIVE_SOURCE_CODE,
PRODUCT_DEVELOPMENT_SOURCE_SYSTEM,
PUBLIC_SOURCE_SCHEMA,
'IntakeNonIntegratedProductProfile',
normalizeIdentity(target.spu),
{ opsHead: 'spuOwner' },
),
},
]
: []
// 部门负责人/总负责人层级只来自人员信息表(SupplyChainManagementOwnerConfig):
// 按运营负责人花名匹配人员,取其 departmentOwner/generalOwner;未匹配则不动这两个字段。
// 匹配基准优先本轮推出的运营负责人(dws 销售/新品流程);推不出时回退档案已存的
// 运营负责人——只要人员信息里能匹配到人,层级字段就不允许留空(不动运营负责人本身)。
const ownerConfigBasis = effectiveOpsHead || (target.opsHead ?? '').trim()
const ownerConfig = ownerConfigBasis
? lookups.ownerHierarchyByPerson.get(normalizeIdentity(ownerConfigBasis))
// 按档案当前运营负责人花名匹配人员(含本轮新品绑定的新负责人),取其
// departmentOwner/generalOwner;未匹配则不动这两个字段。
const hierarchyBasis = storedOpsHead || intakeSpuOwner || ''
const ownerConfig = hierarchyBasis
? lookups.ownerHierarchyByPerson.get(normalizeIdentity(hierarchyBasis))
: undefined
const ownerConfigUpdates: ProductArchiveEnrichmentUpdate[] =
ownerConfig && (ownerConfig.departmentOwner || ownerConfig.generalOwner)
@ -248,7 +224,7 @@ export function buildProductArchiveEnrichmentUpdates(
CONFIG_SOURCE_SYSTEM,
PUBLIC_SOURCE_SCHEMA,
'SupplyChainManagementOwnerConfig',
ownerConfigBasis,
hierarchyBasis,
{ opsDeptHead: 'departmentOwner', opsDirector: 'generalOwner' },
),
},
@ -273,7 +249,7 @@ export function buildProductArchiveEnrichmentUpdates(
]
: []),
...skumapFillUpdates,
...opsOwnerUpdates,
...intakeOpsHeadUpdates,
...ownerConfigUpdates,
{
patch: {

View File

@ -65,7 +65,7 @@ type EnrichmentDependencies = {
>
dwsGoodsCostRepository: Pick<
DwsSalesGoodsCostRepository,
'listDfLatestGoodsCost' | 'listLatestOpsOwners' | 'close'
'listDfLatestGoodsCost' | 'close'
>
skumapPicRepository: Pick<
JstSkumapPicRepository,
@ -95,45 +95,6 @@ function unique<T>(values: readonly T[]) {
return Array.from(new Set(values))
}
export type SpuOpsOwnerProfile = {
spu: string
cater: string
ider: string
}
// Collapse per-barcode dws ops owners into per-spu owners: for each spu keep
// the latest-selling non-empty cater and ider (fields may come from different
// barcodes). Barcodes without a direct dws row inherit these in the mapper.
export function buildSpuOpsOwners(
dwsOpsOwners: ReadonlyArray<{ skuoid: string; cater: string; ider: string; lastSaleDate: string }>,
spuByBarcode: ReadonlyMap<string, string>,
): SpuOpsOwnerProfile[] {
const bySpu = new Map<string, { cater: string; caterDate: string; ider: string; iderDate: string }>()
for (const owner of dwsOpsOwners) {
const skuoid = owner.skuoid?.trim()?.toUpperCase()
if (!skuoid) continue
const spu = spuByBarcode.get(skuoid)
if (!spu) continue
const bucket = bySpu.get(spu) || { cater: '', caterDate: '', ider: '', iderDate: '' }
const cater = owner.cater?.trim() ?? ''
const ider = owner.ider?.trim() ?? ''
if (cater && (!bucket.caterDate || owner.lastSaleDate >= bucket.caterDate)) {
bucket.cater = cater
bucket.caterDate = owner.lastSaleDate
}
if (ider && (!bucket.iderDate || owner.lastSaleDate >= bucket.iderDate)) {
bucket.ider = ider
bucket.iderDate = owner.lastSaleDate
}
bySpu.set(spu, bucket)
}
return [...bySpu.entries()].map(([spu, bucket]) => ({
spu,
cater: bucket.cater,
ider: bucket.ider,
}))
}
export class SupplyChainProductArchiveEnrichmentSyncService {
constructor(
private readonly dependencies: EnrichmentDependencies = {
@ -167,16 +128,9 @@ export class SupplyChainProductArchiveEnrichmentSyncService {
const dwsGoodsCosts = await this.dependencies.dwsGoodsCostRepository.listDfLatestGoodsCost()
// One shot: latest platform picture for every DF skuoid.
const skumapPics = await this.dependencies.skumapPicRepository.listDfLatestSkuPics()
// One shot: latest ops owners (cater/ider) for every barcode with sales.
const dwsOpsOwners = await this.dependencies.dwsGoodsCostRepository.listLatestOpsOwners()
// One shot: spu-level ops owners — barcodes without a direct dws match
// inherit the owners of the latest-selling barcode under the same spu
// ("只要能匹配到的都放上去" — no empty ops fields when the spu sold).
const barcodeSpuPairs = await this.dependencies.repository.listBarcodeSpuPairs()
const spuByBarcode = new Map(
barcodeSpuPairs.map((pair) => [pair.barcode.trim().toUpperCase(), pair.spu.trim().toUpperCase()]),
)
const spuOpsOwners = buildSpuOpsOwners(dwsOpsOwners, spuByBarcode)
// 运营负责人(opsHead)自 2026-10 起为一次性定标冻结口径,enrichment 不再
// 从 dws 销售表取 cater 翻单;这里只加载新品流程负责人(给无销售的档案
// 打底绑定)与人员信息层级(按档案当前运营负责人匹配部门/总负责人)。
// One shot: 新品流程(非集成产品)每个 SPU 的负责人(spuOwner),给无销售
// 数据的档案行兜底运营负责人。
const intakeSpuOwners = new Map(
@ -186,7 +140,7 @@ export class SupplyChainProductArchiveEnrichmentSyncService {
]),
)
// One shot: 人员信息表的「人员 → 部门负责人/总负责人」层级。运营负责人花名
// 匹配到人员后,部门负责人/总负责人以这张表为准(覆盖 dws 的同名猜测值)。
// 匹配到人员后,部门负责人/总负责人以这张表为准。
// 键与档案侧统一走 normalizeIdentity(含不可见字符清洗),避免同一个人因
// 源数据混入零宽字符而永远匹配不上。
const ownerHierarchyByPerson = new Map(
@ -250,8 +204,6 @@ export class SupplyChainProductArchiveEnrichmentSyncService {
dwsGoodsCosts,
skumapPics,
skumapFillPics,
dwsOpsOwners,
spuOpsOwners,
intakeSpuOwners,
ownerHierarchyByPerson,
catalogCosts,