fix(supply-chain): 产品全量档案 DF 条码成本价改为统一取 jst_sku.cost_price

- enrichment 价格补丁不再区分 DF/非 DF:purchasePrice 与 costPrice 均取 rpt.jst_sku(purchase_price / cost_price)
- 删除 dws_sn_sales_full_info_di.goods_cost 取价链路(dws-sales-goods-cost.repository.ts 整删,DWS_SALES_GOODS_COST 来源常量移除)
- 单测与《供应链产品全量档案字段来源对照表》同步更新

预期效果(部署后下一轮同步):约 1874 条 DF 成本价由空变有值、约 230 条由 dws 金额口径变为 jst 单价口径、约 4516 条数值不变、0 条丢值。
This commit is contained in:
WuYongJie 2026-10-16 16:26:43 +08:00
parent 4bd48d8bc0
commit 77c91bd689
6 changed files with 33 additions and 248 deletions

View File

@ -1,6 +1,6 @@
# 供应链产品全量档案字段来源对照表
更新时间:2026-08-10
更新时间:2026-10-16
## 1. 合并与追踪规则
@ -70,7 +70,7 @@
| `sales7d` | 7日日销 | | | `rpt.pm_stockMonitor.sevenAvgSale` | 按条码匹配最新记录 |
| `sales15d` | 15日日销 | | | `rpt.pm_stockMonitor.fifteenAvgSale` | 按条码匹配最新记录 |
| `purchasePrice` | 采购价 | `rpt.jst_sku.purchase_price` | | | 按档案条码精确匹配 `jst_sku.sku_id`;匹配不到时留空 |
| `costPrice` | 成本价(聚水潭) | `rpt.jst_sku.cost_price` | | | 按档案条码精确匹配 `jst_sku.sku_id`;匹配不到时留空 |
| `costPrice` | 成本价(聚水潭) | `rpt.jst_sku.cost_price` | | | 按档案条码精确匹配 `jst_sku.sku_id`;匹配不到时留空。DF 条码自 2026-10-16 起同样取本来源(原 `rpt.dws_sn_sales_full_info_di.goods_cost` 为金额口径,已停用) |
| `costPriceSecondary` | 成本价(新品) | | `external.product_catalog_wide_v1.cost_price` | | 按档案条码精确匹配 `barcode_business_key`,只读取 `catalog_source = INTAKE_READY` 的新品行;匹配不到或来源为空时留空 |
## 3. JST_SKU 第一批字段覆盖率

View File

@ -13,7 +13,6 @@ export const JST_FACTORY_STOCK_ARCHIVE_SOURCE_CODE = 'JST_FACTORY_STOCK'
export const SUPPLY_CHAIN_OWNER_CONFIG_ARCHIVE_SOURCE_CODE = 'SUPPLY_CHAIN_OWNER_CONFIG'
export const INTAKE_PROFILE_ARCHIVE_SOURCE_CODE = 'INTAKE_NON_INTEGRATED_PROFILE'
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'

View File

@ -1,139 +0,0 @@
import sql from 'mssql'
export type DwsSalesGoodsCostProfile = {
skuoid: string
goodsCost: string
}
export type DwsSalesOpsOwnerProfile = {
skuoid: string
cater: string
ider: string
lastSaleDate: string
}
export interface DwsSalesGoodsCostRepository {
listDfLatestGoodsCost(): Promise<DwsSalesGoodsCostProfile[]>
listLatestOpsOwners(): Promise<DwsSalesOpsOwnerProfile[]>
close(): Promise<void>
}
function requiredEnv(name: string) {
const value = process.env[name]?.trim()
if (!value) throw new Error(`Missing required environment variable: ${name}`)
return value
}
function booleanEnv(name: string, fallback: boolean) {
const value = process.env[name]?.trim().toLowerCase()
if (!value) return fallback
return value === 'true' || value === '1'
}
function safeIdentifier(value: string, label: string) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
throw new Error(`${label} is not a valid SQL Server identifier`)
}
return value
}
class DwsSalesGoodsCostRepositoryClass implements DwsSalesGoodsCostRepository {
private pool: sql.ConnectionPool | null = null
private async getPool() {
if (this.pool?.connected) return this.pool
this.pool = await new sql.ConnectionPool({
server: requiredEnv('JST_REPORT_DB_HOST'),
port: Number(process.env.JST_REPORT_DB_PORT?.trim() || '1433'),
database: requiredEnv('JST_REPORT_DB_NAME'),
user: requiredEnv('JST_REPORT_DB_USER'),
password: requiredEnv('JST_REPORT_DB_PASSWORD'),
options: {
encrypt: booleanEnv('JST_REPORT_DB_ENCRYPT', true),
trustServerCertificate: booleanEnv(
'JST_REPORT_DB_TRUST_SERVER_CERTIFICATE',
true,
),
},
pool: {
min: 0,
max: 4,
idleTimeoutMillis: 30_000,
},
requestTimeout: 120_000,
connectionTimeout: 20_000,
}).connect()
return this.pool
}
// Latest non-zero sales goods_cost per DF (dropship) skuoid. One row per
// skuoid, keyed the same way the archive barcode is stored (DF-xxx).
async listDfLatestGoodsCost() {
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<DwsSalesGoodsCostProfile>(`
WITH ranked AS (
SELECT
UPPER(LTRIM(RTRIM(CAST(skuoid AS varchar(200))))) AS skuoid,
LTRIM(RTRIM(CAST(goods_cost AS varchar(50)))) AS goodsCost,
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 UPPER(LTRIM(RTRIM(CAST(skuoid AS varchar(200))))) LIKE 'DF%'
AND goods_cost IS NOT NULL AND goods_cost <> 0
)
SELECT skuoid, goodsCost
FROM ranked
WHERE rn = 1
`)
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,
CAST(MAX(stat_date) AS date) AS last_sale_date,
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 skuoid IS NOT NULL
AND LTRIM(RTRIM(CAST(skuoid AS varchar(200)))) <> ''
AND ((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)))) <> ''))
GROUP BY UPPER(LTRIM(RTRIM(CAST(skuoid AS varchar(200))))), LTRIM(RTRIM(CAST(cater AS varchar(100)))), LTRIM(RTRIM(CAST(ider AS varchar(100)))), stat_date
)
SELECT skuoid, cater, ider, CONVERT(varchar(10), last_sale_date, 23) AS lastSaleDate
FROM ranked
WHERE rn = 1
`)
return result.recordset
}
async close() {
if (!this.pool) return
await this.pool.close()
this.pool = null
}
}
export const dwsSalesGoodsCostRepository = new DwsSalesGoodsCostRepositoryClass()

View File

@ -23,9 +23,6 @@ jest.mock('@/server/repositories/supply-chain-product-archive-enrichment-sync.re
jest.mock('@/server/repositories/supply-chain-product-archive-clearance-stock.repository', () => ({
productArchiveClearanceStockRepository: {},
}))
jest.mock('@/server/repositories/dws-sales-goods-cost.repository', () => ({
dwsSalesGoodsCostRepository: {},
}))
jest.mock('@/server/repositories/jst-skumap-pic.repository', () => ({
jstSkumapPicRepository: {},
}))
@ -59,7 +56,6 @@ describe('supply-chain product archive enrichment sync', () => {
stocks: [],
warehouses: [],
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
intakeSpuOwners: new Map(),
@ -84,7 +80,6 @@ describe('supply-chain product archive enrichment sync', () => {
{ barcode: 'DF-A3BJ006601', purchasePrice: '', costPrice: '' },
{ barcode: 'BAR-001', purchasePrice: '90.00', costPrice: '76.20' },
],
dwsGoodsCosts: [{ skuoid: 'DF-A3BJ006601', goodsCost: '70.18' }],
skumapPics: [{ skuId: 'DF-A3BJ006601', pic: 'https://img.pddpic.com/mms-material-img/df.jpg' }],
skumapFillPics: [],
intakeSpuOwners: new Map(),
@ -158,7 +153,6 @@ describe('supply-chain product archive enrichment sync', () => {
stocks: [],
warehouses: [],
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [{ skuId: 'BAR-001', pic: 'https://img.example.com/bar-001.jpg' }],
intakeSpuOwners: new Map(),
@ -215,7 +209,6 @@ describe('supply-chain product archive enrichment sync', () => {
stocks: [],
warehouses: [],
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
intakeSpuOwners: new Map([['BJ0220', '萧何']]),
@ -259,7 +252,6 @@ describe('supply-chain product archive enrichment sync', () => {
stocks: [],
warehouses: [],
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
intakeSpuOwners: new Map([['BJ0220', '萧何']]),
@ -294,7 +286,6 @@ describe('supply-chain product archive enrichment sync', () => {
stocks: [],
warehouses: [],
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
intakeSpuOwners: new Map(),
@ -358,7 +349,6 @@ describe('supply-chain product archive enrichment sync', () => {
stocks: [],
warehouses: [],
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
intakeSpuOwners: new Map(),
@ -403,7 +393,6 @@ describe('supply-chain product archive enrichment sync', () => {
stocks: [],
warehouses: [],
prices: [],
dwsGoodsCosts: [],
skumapPics: [],
skumapFillPics: [],
intakeSpuOwners: new Map(),
@ -435,7 +424,7 @@ describe('supply-chain product archive enrichment sync', () => {
expect(hierarchyUpdate?.patch).toEqual({ opsDeptHead: '半夏', opsDirector: '江陵' })
})
it('takes dropship (DF) cost from the sales goods_cost source and keeps jst_sku cost for others', () => {
it('takes cost from jst_sku.cost_price for dropship (DF) and normal barcodes alike', () => {
const lookups = {
clearanceStocks: [],
publicAvailableStocks: [],
@ -443,10 +432,9 @@ describe('supply-chain product archive enrichment sync', () => {
stocks: [],
warehouses: [],
prices: [
{ barcode: 'DF-A3BJ006601', purchasePrice: '', costPrice: '' },
{ barcode: 'DF-A3BJ006601', purchasePrice: '80.00', costPrice: '70.18' },
{ barcode: 'BAR-001', purchasePrice: '90.00', costPrice: '76.20' },
],
dwsGoodsCosts: [{ skuoid: 'DF-A3BJ006601', goodsCost: '70.18' }],
skumapPics: [],
skumapFillPics: [],
intakeSpuOwners: new Map(),
@ -468,21 +456,18 @@ describe('supply-chain product archive enrichment sync', () => {
},
lookups,
)
const dfPriceUpdate = dfUpdates.find((update) => 'purchasePrice' in update.patch)
const dfCostUpdate = dfUpdates.find((update) => 'costPrice' in update.patch)
expect(dfPriceUpdate?.patch).toEqual({ purchasePrice: '' })
expect(dfPriceUpdate?.source.sourceCode).toBe('JST_SKU_PRICE')
expect(dfCostUpdate?.patch).toEqual({ costPrice: '70.18' })
expect(dfCostUpdate?.source).toEqual(
const dfPriceUpdate = dfUpdates.find((update) => 'costPrice' in update.patch)
expect(dfPriceUpdate?.patch).toEqual({ purchasePrice: '80.00', costPrice: '70.18' })
expect(dfPriceUpdate?.source).toEqual(
expect.objectContaining({
sourceCode: 'DWS_SALES_GOODS_COST',
sourceObject: 'dws_sn_sales_full_info_di',
sourceColumns: { costPrice: 'goods_cost' },
sourceCode: 'JST_SKU_PRICE',
sourceObject: 'jst_sku',
sourceColumns: { purchasePrice: 'purchase_price', costPrice: 'cost_price' },
}),
)
// without sales cost the DF cost is cleared instead of falling back to jst_sku
const dfWithoutSales = buildProductArchiveEnrichmentUpdates(
// DF without a jst_sku price row clears both price fields (no dws fallback anymore)
const dfWithoutJst = buildProductArchiveEnrichmentUpdates(
{
id: 'archive-df-2',
barcode: 'DF-A3BJ006602',
@ -492,11 +477,11 @@ describe('supply-chain product archive enrichment sync', () => {
inOutWarehouse: '',
image: '',
},
{ ...lookups, dwsGoodsCosts: [] },
lookups,
)
const clearedCost = dfWithoutSales.find((update) => 'costPrice' in update.patch)
expect(clearedCost?.patch).toEqual({ costPrice: '' })
expect(clearedCost?.source.sourceCode).toBe('DWS_SALES_GOODS_COST')
const clearedCost = dfWithoutJst.find((update) => 'costPrice' in update.patch)
expect(clearedCost?.patch).toEqual({ purchasePrice: '', costPrice: '' })
expect(clearedCost?.source.sourceCode).toBe('JST_SKU_PRICE')
// non-DF keeps the jst_sku price source untouched
const barUpdates = buildProductArchiveEnrichmentUpdates(
@ -514,7 +499,7 @@ describe('supply-chain product archive enrichment sync', () => {
const barPriceUpdate = barUpdates.find((update) => 'costPrice' in update.patch)
expect(barPriceUpdate?.patch).toEqual({ purchasePrice: '90.00', costPrice: '76.20' })
expect(barPriceUpdate?.source.sourceCode).toBe('JST_SKU_PRICE')
expect(barUpdates.some((update) => update.source.sourceCode === 'DWS_SALES_GOODS_COST')).toBe(false)
expect(barUpdates.every((update) => update.source.sourceCode !== 'DWS_SALES_GOODS_COST')).toBe(true)
})
it('persists every external group and logs only later value changes', async () => {
@ -623,16 +608,6 @@ describe('supply-chain product archive enrichment sync', () => {
}
const service = new SupplyChainProductArchiveEnrichmentSyncService({
clearanceStockRepository,
dwsGoodsCostRepository: {
listDfLatestGoodsCost: jest.fn().mockResolvedValue([]),
listLatestOpsOwners: jest.fn().mockResolvedValue([{
skuoid: 'BAR-001',
cater: '李逍遥',
ider: '丹参',
lastSaleDate: '2026-09-01',
}]),
close: jest.fn().mockResolvedValue(undefined),
},
skumapPicRepository: {
listDfLatestSkuPics: jest.fn().mockResolvedValue([]),
listLatestSkuPicsForBarcodes: jest.fn().mockResolvedValue([]),

View File

@ -1,5 +1,4 @@
import {
DWS_SALES_GOODS_COST_ARCHIVE_SOURCE_CODE,
JST_CLEARANCE_STOCK_ARCHIVE_SOURCE_CODE,
JST_FACTORY_STOCK_ARCHIVE_SOURCE_CODE,
JST_PUBLIC_AVAILABLE_STOCK_ARCHIVE_SOURCE_CODE,
@ -14,7 +13,6 @@ 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 { 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'
@ -49,7 +47,6 @@ export type ProductArchiveEnrichmentLookups = {
stocks: readonly JstStockMonitorProfile[]
warehouses: readonly JstWarehouseProfile[]
prices: readonly JstSkuPriceProfile[]
dwsGoodsCosts: readonly DwsSalesGoodsCostProfile[]
skumapPics: readonly JstSkumapPicProfile[]
skumapFillPics: readonly JstSkumapPicProfile[]
intakeSpuOwners: ReadonlyMap<string, string>
@ -145,12 +142,9 @@ export function buildProductArchiveEnrichmentUpdates(
const publicAvailableStock = lookups.publicAvailableStocks.find(
(profile) => normalizeIdentity(profile.barcode) === normalizeIdentity(target.barcode),
)
// Dropship (DF-prefixed) SKUs take their cost from the sales detail table's
// goods_cost instead of jst_sku, per the agreed data contract.
// 成本价自 2026-10-16 起 DF 条码与普通条码统一取 jst_sku.cost_price
// (原 dws_sn_sales_full_info_di.goods_cost 为金额口径,停用)。
const isDropshipSku = normalizeIdentity(target.barcode).startsWith('DF')
const dwsGoodsCost = lookups.dwsGoodsCosts.find(
(profile) => normalizeIdentity(profile.skuoid) === normalizeIdentity(target.barcode),
)
// Dropship SKUs take their picture from the skumap platform tables as well.
const skumapPic = isDropshipSku
? lookups.skumapPics.find(
@ -403,51 +397,20 @@ export function buildProductArchiveEnrichmentUpdates(
),
},
] : []),
...(isDropshipSku
? [
{
patch: {
purchasePrice: price?.purchasePrice ?? '',
},
source: source(
JST_SKU_PRICE_ARCHIVE_SOURCE_CODE,
JST_SOURCE_SYSTEM,
JST_SOURCE_SCHEMA,
'jst_sku',
price?.barcode || target.barcode,
{ purchasePrice: 'purchase_price' },
),
},
{
patch: {
costPrice: dwsGoodsCost?.goodsCost ?? '',
},
source: source(
DWS_SALES_GOODS_COST_ARCHIVE_SOURCE_CODE,
JST_SOURCE_SYSTEM,
JST_SOURCE_SCHEMA,
'dws_sn_sales_full_info_di',
dwsGoodsCost?.skuoid || target.barcode,
{ costPrice: 'goods_cost' },
),
},
]
: [
{
patch: {
purchasePrice: price?.purchasePrice ?? '',
costPrice: price?.costPrice ?? '',
},
source: source(
JST_SKU_PRICE_ARCHIVE_SOURCE_CODE,
JST_SOURCE_SYSTEM,
JST_SOURCE_SCHEMA,
'jst_sku',
price?.barcode || target.barcode,
{ purchasePrice: 'purchase_price', costPrice: 'cost_price' },
),
},
]),
{
patch: {
purchasePrice: price?.purchasePrice ?? '',
costPrice: price?.costPrice ?? '',
},
source: source(
JST_SKU_PRICE_ARCHIVE_SOURCE_CODE,
JST_SOURCE_SYSTEM,
JST_SOURCE_SCHEMA,
'jst_sku',
price?.barcode || target.barcode,
{ purchasePrice: 'purchase_price', costPrice: 'cost_price' },
),
},
{
patch: { costPriceSecondary: catalogCost?.costPrice ?? '' },
source: source(

View File

@ -2,10 +2,6 @@ import {
productArchiveClearanceStockRepository,
type ProductArchiveClearanceStockRepository,
} from '@/server/repositories/supply-chain-product-archive-clearance-stock.repository'
import {
dwsSalesGoodsCostRepository,
type DwsSalesGoodsCostRepository,
} from '@/server/repositories/dws-sales-goods-cost.repository'
import {
jstSkumapPicRepository,
type JstSkumapPicRepository,
@ -63,10 +59,6 @@ type EnrichmentDependencies = {
ProductArchiveClearanceStockRepository,
'listByBarcodes' | 'close'
>
dwsGoodsCostRepository: Pick<
DwsSalesGoodsCostRepository,
'listDfLatestGoodsCost' | 'close'
>
skumapPicRepository: Pick<
JstSkumapPicRepository,
'listDfLatestSkuPics' | 'listLatestSkuPicsForBarcodes' | 'close'
@ -99,7 +91,6 @@ export class SupplyChainProductArchiveEnrichmentSyncService {
constructor(
private readonly dependencies: EnrichmentDependencies = {
clearanceStockRepository: productArchiveClearanceStockRepository,
dwsGoodsCostRepository: dwsSalesGoodsCostRepository,
skumapPicRepository: jstSkumapPicRepository,
publicAvailableStockRepository: jstInventoryPublicAvailableRepository,
repository: supplyChainProductArchiveEnrichmentSyncRepository,
@ -124,8 +115,6 @@ export class SupplyChainProductArchiveEnrichmentSyncService {
lastId: '',
}
const warehouseConfigs = await this.dependencies.configRepository.listActive()
// One shot: latest non-zero sales goods_cost for every DF skuoid.
const dwsGoodsCosts = await this.dependencies.dwsGoodsCostRepository.listDfLatestGoodsCost()
// One shot: latest platform picture for every DF skuoid.
const skumapPics = await this.dependencies.skumapPicRepository.listDfLatestSkuPics()
// 运营负责人(opsHead)自 2026-10 起为一次性定标冻结口径,enrichment 不再
@ -209,7 +198,6 @@ export class SupplyChainProductArchiveEnrichmentSyncService {
stocks,
warehouses,
prices,
dwsGoodsCosts,
skumapPics,
skumapFillPics,
intakeSpuOwners,
@ -256,7 +244,6 @@ export class SupplyChainProductArchiveEnrichmentSyncService {
} finally {
await Promise.all([
this.dependencies.clearanceStockRepository.close(),
this.dependencies.dwsGoodsCostRepository.close(),
this.dependencies.skumapPicRepository.close(),
this.dependencies.publicAvailableStockRepository.close(),
this.dependencies.supplierRepository.close(),