运营部门负责人/运营负责人接入聚水潭销售宽表cater/ider

- dws repository 新增 listLatestOpsOwners:rpt.dws_sn_sales_full_info_di 按 skuoid 取 stat_date 最新一条的 cater/ider,全条码范围
- enrichment 链路新增 ops owners patch:来源码 DWS_SALES_OPS 可追溯;字段级有值才覆盖(dws 无值保留档案原值)
- opsDirector(运营总负责人)不接
- 本地全量验证:抽样 10 条与上游 100% 一致;3001 API 验收通过
This commit is contained in:
WuYongJie 2026-09-12 11:12:50 +08:00
parent 1b3f210e0f
commit 904b80d4c9
5 changed files with 155 additions and 2 deletions

View File

@ -10,6 +10,7 @@ export const JST_STOCK_MONITOR_ARCHIVE_SOURCE_CODE = 'JST_STOCK_MONITOR'
export const JST_CLEARANCE_STOCK_ARCHIVE_SOURCE_CODE = 'JST_CLEARANCE_STOCK'
export const JST_SKUMAP_PIC_ARCHIVE_SOURCE_CODE = 'JST_SKUMAP_PIC'
export const DWS_SALES_GOODS_COST_ARCHIVE_SOURCE_CODE = 'DWS_SALES_GOODS_COST'
export const DWS_SALES_OPS_ARCHIVE_SOURCE_CODE = 'DWS_SALES_OPS'
export const JST_PUBLIC_AVAILABLE_STOCK_ARCHIVE_SOURCE_CODE = 'JST_PUBLIC_AVAILABLE_STOCK'
export const JST_WAREHOUSE_ARCHIVE_SOURCE_CODE = 'JST_WAREHOUSE_INFO'
export const JST_SKU_PRICE_ARCHIVE_SOURCE_CODE = 'JST_SKU_PRICE'

View File

@ -5,8 +5,15 @@ export type DwsSalesGoodsCostProfile = {
goodsCost: string
}
export type DwsSalesOpsOwnerProfile = {
skuoid: string
cater: string
ider: string
}
export interface DwsSalesGoodsCostRepository {
listDfLatestGoodsCost(): Promise<DwsSalesGoodsCostProfile[]>
listLatestOpsOwners(): Promise<DwsSalesOpsOwnerProfile[]>
close(): Promise<void>
}
@ -87,6 +94,36 @@ class DwsSalesGoodsCostRepositoryClass implements DwsSalesGoodsCostRepository {
return result.recordset
}
// Latest non-empty ops owners per skuoid (all barcodes, not only DF).
// One row per skuoid from the newest stat_date; cater/ider are returned
// as-is and the mapper decides per field whether to overwrite.
async listLatestOpsOwners() {
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<DwsSalesOpsOwnerProfile>(`
WITH ranked AS (
SELECT
UPPER(LTRIM(RTRIM(CAST(skuoid AS varchar(200))))) AS skuoid,
LTRIM(RTRIM(CAST(cater AS varchar(100)))) AS cater,
LTRIM(RTRIM(CAST(ider AS varchar(100)))) AS ider,
ROW_NUMBER() OVER (
PARTITION BY UPPER(LTRIM(RTRIM(CAST(skuoid AS varchar(200)))))
ORDER BY stat_date DESC
) AS rn
FROM [${schema}].[dws_sn_sales_full_info_di]
WHERE (cater IS NOT NULL AND LTRIM(RTRIM(CAST(cater AS varchar(100)))) <> '')
OR (ider IS NOT NULL AND LTRIM(RTRIM(CAST(ider AS varchar(100)))) <> '')
)
SELECT skuoid, cater, ider
FROM ranked
WHERE rn = 1
`)
return result.recordset
}
async close() {
if (!this.pool) return
await this.pool.close()

View File

@ -57,6 +57,7 @@ describe('supply-chain product archive enrichment sync', () => {
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
dwsOpsOwners: [],
catalogCosts: [],
warehouseConfigs: [],
},
@ -78,6 +79,7 @@ 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' }],
dwsOpsOwners: [],
catalogCosts: [],
warehouseConfigs: [],
}
@ -135,6 +137,79 @@ describe('supply-chain product archive enrichment sync', () => {
expect(barUpdates.some((update) => 'image' in update.patch)).toBe(false)
})
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: [],
dwsOpsOwners: [
{ skuoid: 'BAR-001', cater: '比干', ider: '精卫' },
{ skuoid: 'BAR-002', cater: '香茗', ider: '' },
],
catalogCosts: [],
warehouseConfigs: [],
}
const bothUpdates = buildProductArchiveEnrichmentUpdates(
{
id: 'archive-1',
barcode: 'BAR-001',
spu: 'SPU-001',
sku: 'SKU-001',
supplierName: '',
inOutWarehouse: '',
},
lookups,
)
const bothPatch = bothUpdates.find(
(update) => 'opsDeptHead' in update.patch || 'opsHead' in update.patch,
)
expect(bothPatch?.patch).toEqual({ opsDeptHead: '比干', opsHead: '精卫' })
expect(bothPatch?.source).toEqual(
expect.objectContaining({
sourceCode: 'DWS_SALES_OPS',
sourceObject: 'dws_sn_sales_full_info_di',
sourceColumns: { opsDeptHead: 'cater', opsHead: 'ider' },
}),
)
// Only cater present: patch covers opsDeptHead only (field-level overwrite)
const partialUpdates = buildProductArchiveEnrichmentUpdates(
{
id: 'archive-2',
barcode: 'BAR-002',
spu: 'SPU-002',
sku: 'SKU-002',
supplierName: '',
inOutWarehouse: '',
},
lookups,
)
const partialPatch = partialUpdates.find((update) => 'opsDeptHead' in update.patch)
expect(partialPatch?.patch).toEqual({ opsDeptHead: '香茗' })
expect(partialUpdates.some((update) => 'opsHead' in update.patch)).toBe(false)
// 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: '',
},
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: [],
@ -148,6 +223,7 @@ describe('supply-chain product archive enrichment sync', () => {
],
dwsGoodsCosts: [{ skuoid: 'DF-A3BJ006601', goodsCost: '70.18' }],
skumapPics: [],
dwsOpsOwners: [],
catalogCosts: [],
warehouseConfigs: [],
}
@ -304,6 +380,7 @@ describe('supply-chain product archive enrichment sync', () => {
clearanceStockRepository,
dwsGoodsCostRepository: {
listDfLatestGoodsCost: jest.fn().mockResolvedValue([]),
listLatestOpsOwners: jest.fn().mockResolvedValue([]),
close: jest.fn().mockResolvedValue(undefined),
},
skumapPicRepository: {

View File

@ -1,5 +1,6 @@
import {
DWS_SALES_GOODS_COST_ARCHIVE_SOURCE_CODE,
DWS_SALES_OPS_ARCHIVE_SOURCE_CODE,
JST_CLEARANCE_STOCK_ARCHIVE_SOURCE_CODE,
JST_PUBLIC_AVAILABLE_STOCK_ARCHIVE_SOURCE_CODE,
JST_SKUMAP_PIC_ARCHIVE_SOURCE_CODE,
@ -11,7 +12,10 @@ 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 } from '@/server/repositories/dws-sales-goods-cost.repository'
import type {
DwsSalesGoodsCostProfile,
DwsSalesOpsOwnerProfile,
} 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'
@ -48,6 +52,7 @@ export type ProductArchiveEnrichmentLookups = {
prices: readonly JstSkuPriceProfile[]
dwsGoodsCosts: readonly DwsSalesGoodsCostProfile[]
skumapPics: readonly JstSkumapPicProfile[]
dwsOpsOwners: readonly DwsSalesOpsOwnerProfile[]
catalogCosts: readonly ProductCatalogCostPriceProfile[]
warehouseConfigs: readonly ProductArchiveWarehouseConfigRecord[]
}
@ -142,6 +147,32 @@ export function buildProductArchiveEnrichmentUpdates(
(profile) => normalizeIdentity(profile.skuId) === normalizeIdentity(target.barcode),
)
: undefined
// Ops owners come from the sales detail table for every barcode with sales
// history; per agreed contract, empty dws values keep the current archive
// value untouched (field-level overwrite only when dws has a name).
const dwsOpsOwner = lookups.dwsOpsOwners.find(
(profile) => normalizeIdentity(profile.skuoid) === normalizeIdentity(target.barcode),
)
const opsOwnerPatch = {
...(dwsOpsOwner?.cater ? { opsDeptHead: dwsOpsOwner.cater } : {}),
...(dwsOpsOwner?.ider ? { opsHead: dwsOpsOwner.ider } : {}),
}
const opsOwnerUpdates: ProductArchiveEnrichmentUpdate[] =
Object.keys(opsOwnerPatch).length > 0
? [
{
patch: opsOwnerPatch,
source: source(
DWS_SALES_OPS_ARCHIVE_SOURCE_CODE,
JST_SOURCE_SYSTEM,
JST_SOURCE_SCHEMA,
'dws_sn_sales_full_info_di',
dwsOpsOwner?.skuoid || target.barcode,
{ opsDeptHead: 'cater', opsHead: 'ider' },
),
},
]
: []
return [
...(isDropshipSku
? [
@ -160,6 +191,7 @@ export function buildProductArchiveEnrichmentUpdates(
},
]
: []),
...opsOwnerUpdates,
{
patch: {
stockClearance: clearanceStock?.remainingStock ?? '',

View File

@ -56,7 +56,10 @@ type EnrichmentDependencies = {
ProductArchiveClearanceStockRepository,
'listByBarcodes' | 'close'
>
dwsGoodsCostRepository: Pick<DwsSalesGoodsCostRepository, 'listDfLatestGoodsCost' | 'close'>
dwsGoodsCostRepository: Pick<
DwsSalesGoodsCostRepository,
'listDfLatestGoodsCost' | 'listLatestOpsOwners' | 'close'
>
skumapPicRepository: Pick<JstSkumapPicRepository, 'listDfLatestSkuPics' | 'close'>
publicAvailableStockRepository: Pick<
JstInventoryPublicAvailableRepository,
@ -110,6 +113,8 @@ 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()
try {
while (true) {
const targets = await this.dependencies.repository.listTargets({
@ -159,6 +164,7 @@ export class SupplyChainProductArchiveEnrichmentSyncService {
prices,
dwsGoodsCosts,
skumapPics,
dwsOpsOwners,
catalogCosts,
warehouseConfigs,
})