feat(product-archive): 定厂「发货方式」同步进全量档案的入库/代发列

全量档案的「入库/代发」列(storageType)此前 73,278 行 100% 为空:产品定厂时
采购填的「发货方式」(入仓/代发)从来没有回流到档案表,列上没有任何来源映射。

规则(按用户口径):全量的入库/代发只有 代发/入仓 两个值——
- 新品(产品开发)定厂带了发货方式的,直接带进档案
- 没填的,等仓库配置同步兜底填空
- 无法识别的值(如 海运)不写,保持空

实现要点:
- sync.repository:产品开发来源的 SELECT/类型加 shipping_method;
  buildArchiveUpdate 白名单补 storageType(此前该列不在白名单里,值写不进档案表)
- sync.service:宽表 shipping_method 经 storageTypeFromShippingMethodLabel
  映射(入仓/入库→INBOUND、代发→DROPSHIP、无法识别→空串不动),
  仅非空时写入;fieldSourceMap 记 storageType ← shipping_method
- enrichment mapper:仓库配置来源拆成两条——warehouseHead 照常无条件更新;
  storageType 改 fillEmptyOnly(只填空),定厂带回的值不被仓库配置覆盖
- lib/values:新增 storageTypeFromShippingMethodLabel(入仓/入库→INBOUND、
  代发→DROPSHIP,兼容「入库」写法)
- 页面显示「入库」→「入仓」(与定厂填值一致)
- 测试:sync.service 加 3 用例;新增 warehouse-config-fill 套件 3 用例
  (已有值不覆盖 / 为空才填 / 初始化过不再重复应用);
  enrichment.service 计数断言 8→9 并补仓库配置两条的断言

验证:type-check EXIT=0;相关套件 sync 11 / enrichment 9 / warehouse-config-fill 3
全过;真库端到端——跑一次 XG0012 的产品开发同步,档案 storageType 从空变 INBOUND、
来源记 PRODUCT_DEVELOPMENT/shipping_method,再跑仓库配置 enrichment 后仍为 INBOUND
(不被覆盖);3001 真机按「入仓」筛选出 1 行、页面列显示「入仓」。
This commit is contained in:
WuYongJie 2026-09-17 14:55:11 +08:00
parent a310362839
commit ca6e22ac66
8 changed files with 220 additions and 18 deletions

View File

@ -10,7 +10,19 @@ export const PRODUCT_ARCHIVE_STORAGE_TYPES = ['INBOUND', 'DROPSHIP'] as const
export type ProductArchiveStorageType = (typeof PRODUCT_ARCHIVE_STORAGE_TYPES)[number]
export function storageTypeLabel(value: string | null | undefined) {
if (value === 'INBOUND') return '入库'
if (value === 'INBOUND') return '入仓'
if (value === 'DROPSHIP') return '代发'
return ''
}
/**
* 产品定厂「发货方式」(`入仓` / `代发`)→ 档案「入库/代发」库值。
* 无法识别的返回空串,调用方应保持原值不动(不要清空)。
* 兼容「入库」写法,避免历史数据里出现这种填法时对不上。
*/
export function storageTypeFromShippingMethodLabel(value: string | null | undefined) {
const keyword = String(value ?? '').trim()
if (keyword === '入仓' || keyword === '入库') return 'INBOUND'
if (keyword === '代发') return 'DROPSHIP'
return ''
}

View File

@ -0,0 +1,106 @@
/**
* 仓库配置来源的「入库/代发」只填空值,不覆盖产品定厂带回的值
* (产品定厂的「发货方式」由产品开发同步直接带入,仓库配置只是兜底)
*/
jest.mock('@/lib/prisma', () => ({
prisma: { $transaction: jest.fn() },
}))
import { prisma } from '@/lib/prisma'
import { SupplyChainProductArchiveEnrichmentSyncRepositoryClass } from '../supply-chain-product-archive-enrichment-sync.repository'
const mockPrisma = prisma as unknown as { $transaction: jest.Mock }
const WAREHOUSE_CONFIG_SOURCE = {
sourceCode: 'WAREHOUSE_CONFIG',
sourceSystem: '供应链中心',
sourceSchema: 'public',
sourceObject: 'SupplyChainProductArchiveWarehouseConfig',
sourceRecordId: 'config-1',
sourceColumns: {
warehouseHead: 'warehouseHead',
storageType: 'storageType',
},
fillEmptyOnly: true,
} as const
function createTransaction(input: { storageType?: string; existingSource?: { id: string } | null }) {
return {
supplyChainProductArchive: {
findUniqueOrThrow: jest.fn().mockResolvedValue({
id: 'archive-1',
barcode: 'BAR-001',
spu: 'SPU-001',
fieldSourceMap: {},
storageType: input.storageType ?? '',
warehouseHead: '',
}),
update: jest.fn().mockResolvedValue({}),
},
supplyChainProductArchiveSource: {
findUnique: jest.fn().mockResolvedValue(input.existingSource ?? null),
upsert: jest.fn().mockResolvedValue({}),
},
}
}
const repository = new SupplyChainProductArchiveEnrichmentSyncRepositoryClass()
describe('仓库配置来源的入库/代发只填空值', () => {
beforeEach(() => jest.clearAllMocks())
it('档案已有值(产品定厂带回)时不覆盖,仓库负责人照常更新', async () => {
const tx = createTransaction({ storageType: 'INBOUND' })
mockPrisma.$transaction.mockImplementation(async (callback) => callback(tx))
const repository = new SupplyChainProductArchiveEnrichmentSyncRepositoryClass()
const result = await repository.applyPatch({
archiveId: 'archive-1',
patch: { warehouseHead: '新负责人', storageType: 'DROPSHIP' },
source: WAREHOUSE_CONFIG_SOURCE,
})
expect(result.changes.map((change) => change.field)).toEqual(['warehouseHead'])
expect(tx.supplyChainProductArchive.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'archive-1' },
data: expect.objectContaining({ warehouseHead: '新负责人' }),
}),
)
expect(tx.supplyChainProductArchive.update.mock.calls[0][0].data).not.toHaveProperty('storageType')
})
it('档案为空时才填入仓库配置的入库/代发', async () => {
const tx = createTransaction({ storageType: '' })
mockPrisma.$transaction.mockImplementation(async (callback) => callback(tx))
const repository = new SupplyChainProductArchiveEnrichmentSyncRepositoryClass()
const result = await repository.applyPatch({
archiveId: 'archive-1',
patch: { warehouseHead: '新负责人', storageType: 'DROPSHIP' },
source: WAREHOUSE_CONFIG_SOURCE,
})
expect(result.changes.map((change) => change.field)).toEqual(['warehouseHead', 'storageType'])
expect(tx.supplyChainProductArchive.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'archive-1' },
data: expect.objectContaining({ warehouseHead: '新负责人', storageType: 'DROPSHIP' }),
}),
)
})
it('该来源已经初始化过的条码不再重复应用(fillEmptyOnly 的早返回)', async () => {
const tx = createTransaction({ storageType: '' , existingSource: { id: 'source-1' } })
mockPrisma.$transaction.mockImplementation(async (callback) => callback(tx))
const repository = new SupplyChainProductArchiveEnrichmentSyncRepositoryClass()
await expect(repository.applyPatch({
archiveId: 'archive-1',
patch: { warehouseHead: '再改', storageType: 'DROPSHIP' },
source: WAREHOUSE_CONFIG_SOURCE,
})).resolves.toEqual({ changes: [], sourceInitialized: false })
expect(tx.supplyChainProductArchive.update).not.toHaveBeenCalled()
})
})

View File

@ -51,6 +51,7 @@ export type ProductCatalogWideArchiveRecord = {
package_set_count: string | number | Prisma.Decimal | null
purchase_order_method: string | null
factory_warehouse: string | null
shipping_method: string | null
sku_image_url: string | null
updated_at: Date | null
}
@ -185,6 +186,7 @@ function buildArchiveUpdate(
brand: archive.brand,
orderType: archive.orderType,
inOutWarehouse: archive.inOutWarehouse,
storageType: archive.storageType,
isSeasonal: archive.isSeasonal,
replaceCount: archive.replaceCount,
supplierName: archive.supplierName,
@ -269,6 +271,7 @@ class SupplyChainProductArchiveSyncRepositoryClass
package_set_count,
purchase_order_method,
factory_warehouse,
shipping_method,
CASE
WHEN NULLIF(btrim(COALESCE(archive_image, '')), '') IS NOT NULL THEN image_url
ELSE NULL

View File

@ -552,7 +552,23 @@ describe('supply-chain product archive enrichment sync', () => {
expect.objectContaining({ scanned: 1, changed: 1 }),
)
expect(repository.applyPatch).toHaveBeenCalledTimes(8)
// 仓库配置拆成两条:负责人照常更新;入库/代发只填空值(产品定厂带回的值不被覆盖)
expect(repository.applyPatch).toHaveBeenCalledTimes(9)
expect(repository.applyPatch).toHaveBeenCalledWith(
expect.objectContaining({
patch: expect.objectContaining({ warehouseHead: '负责人' }),
source: expect.objectContaining({ sourceObject: 'SupplyChainProductArchiveWarehouseConfig' }),
}),
)
expect(repository.applyPatch).toHaveBeenCalledWith(
expect.objectContaining({
patch: expect.objectContaining({ storageType: 'INBOUND' }),
source: expect.objectContaining({
sourceObject: 'SupplyChainProductArchiveWarehouseConfig',
fillEmptyOnly: true,
}),
}),
)
expect(repository.applyPatch).toHaveBeenCalledWith(
expect.objectContaining({
patch: expect.objectContaining({ stockJianshang: '60' }),

View File

@ -49,6 +49,7 @@ function createSourceRecord() {
package_set_count: '1',
purchase_order_method: '非订单式',
factory_warehouse: '成品仓',
shipping_method: '',
sku_image_url: 'https://cp.jiansun.vip/api/storage/object?key=sku-1.jpeg',
updated_at: new Date('2026-07-17T08:00:00.000Z'),
}
@ -129,6 +130,51 @@ describe('supply-chain product archive wide-catalog sync', () => {
)
})
it('maps the factory shipping method into the archive storage type', () => {
for (const [shippingMethod, expected] of [
['入仓', 'INBOUND'],
['入库', 'INBOUND'],
['代发', 'DROPSHIP'],
] as const) {
const mapped = mapProductCatalogWideArchiveSource({
...createSourceRecord(),
shipping_method: shippingMethod,
})
expect(mapped?.archive).toEqual(
expect.objectContaining({ storageType: expected }),
)
const fieldSourceMap = mapped?.archive.fieldSourceMap as Record<
string,
{ sourceCode: string; sourceColumn: string }
>
expect(fieldSourceMap.storageType).toEqual(
expect.objectContaining({
sourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE,
sourceColumn: 'shipping_method',
}),
)
}
})
it('keeps the storage type untouched when the factory row has no shipping method', () => {
const mapped = mapProductCatalogWideArchiveSource({
...createSourceRecord(),
shipping_method: null,
})
expect(mapped?.archive).not.toHaveProperty('storageType')
})
it('ignores unrecognized shipping method values instead of writing garbage', () => {
const mapped = mapProductCatalogWideArchiveSource({
...createSourceRecord(),
shipping_method: '海运',
})
expect(mapped?.archive).not.toHaveProperty('storageType')
})
it('upserts eligible rows and removes stale product sources', async () => {
const valid = createSourceRecord()
const missingBarcode = {

View File

@ -324,20 +324,33 @@ export function buildProductArchiveEnrichmentUpdates(
{ warehouseType: 'remark2' },
),
},
...(warehouseConfig ? [{
patch: {
warehouseHead: warehouseConfig.warehouseHead,
storageType: warehouseConfig.storageType,
...(warehouseConfig ? [
{
patch: { warehouseHead: warehouseConfig.warehouseHead },
source: source(
WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE,
CONFIG_SOURCE_SYSTEM,
PUBLIC_SOURCE_SCHEMA,
'SupplyChainProductArchiveWarehouseConfig',
warehouseConfig.id,
{ warehouseHead: 'warehouseHead' },
),
},
source: source(
WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE,
CONFIG_SOURCE_SYSTEM,
PUBLIC_SOURCE_SCHEMA,
'SupplyChainProductArchiveWarehouseConfig',
warehouseConfig.id,
{ warehouseHead: 'warehouseHead', storageType: 'storageType' },
),
}] : []),
{
// 入库/代发只填空值:产品定厂的「发货方式」是采购在定厂时的明确决策,
// 档案里已有值(定厂带回)不能被仓库配置覆盖;没填过的才由仓库配置兜底。
patch: { storageType: warehouseConfig.storageType },
source: source(
WAREHOUSE_CONFIG_ARCHIVE_SOURCE_CODE,
CONFIG_SOURCE_SYSTEM,
PUBLIC_SOURCE_SCHEMA,
'SupplyChainProductArchiveWarehouseConfig',
warehouseConfig.id,
{ storageType: 'storageType' },
{ fillEmptyOnly: true },
),
},
] : []),
...(isDropshipSku
? [
{

View File

@ -5,7 +5,7 @@ import {
toProductArchiveFieldSourceJson,
} from '@/lib/supply-chain-product-archive-field-source'
import { calculateProductArchiveVolumeDecimal } from '@/lib/supply-chain-product-archive-volume'
import { normalizeProductArchiveOrderType } from '@/lib/supply-chain-product-archive-values'
import { normalizeProductArchiveOrderType, storageTypeFromShippingMethodLabel } from '@/lib/supply-chain-product-archive-values'
import {
PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE,
supplyChainProductArchiveSyncRepository,
@ -120,8 +120,13 @@ export function mapProductCatalogWideArchiveSource(
const height = numericText(record.package_height_cm)
const skuImage = text(record.archive_image)
const skuImageUrl = skuImage ? text(record.sku_image_url) || skuImage : ''
// 产品定厂「发货方式」(入仓/代发)→ 档案「入库/代发」库值;定厂没填就不写,交给仓库配置兜底
const shippingStorageType = storageTypeFromShippingMethodLabel(record.shipping_method)
const fieldColumns = shippingStorageType
? { ...PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS, storageType: 'shipping_method' }
: PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS
const fieldSourceMap = createProductArchiveFieldSourceMap({
fields: PRODUCT_DEVELOPMENT_ARCHIVE_FIELD_COLUMNS,
fields: fieldColumns,
sourceCode: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE_CODE,
sourceSystem: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE.sourceSystem,
sourceSchema: PRODUCT_DEVELOPMENT_ARCHIVE_SOURCE.sourceSchema,
@ -159,6 +164,7 @@ export function mapProductCatalogWideArchiveSource(
inOutWarehouse: text(record.factory_warehouse),
isSeasonal: '',
replaceCount: '',
...(shippingStorageType ? { storageType: shippingStorageType } : {}),
supplierName: text(record.archive_supplier_name),
purchaseOwner: text(record.archive_purchase_owner),
minimumOrderQuantity: text(record.archive_minimum_order_quantity),

View File

@ -36,7 +36,7 @@ export function resolveProductArchiveWarehouseFields(input: {
),
warehouseHead: configured?.warehouseHead ?? input.currentWarehouseHead,
storageType: configured?.storageType
? configured.storageType === 'INBOUND' ? '入库' : '代发'
? configured.storageType === 'INBOUND' ? '入仓' : '代发'
: input.currentStorageType,
}
}