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:
parent
a310362839
commit
ca6e22ac66
@ -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 ''
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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' }),
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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
|
||||
? [
|
||||
{
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -36,7 +36,7 @@ export function resolveProductArchiveWarehouseFields(input: {
|
||||
),
|
||||
warehouseHead: configured?.warehouseHead ?? input.currentWarehouseHead,
|
||||
storageType: configured?.storageType
|
||||
? configured.storageType === 'INBOUND' ? '入库' : '代发'
|
||||
? configured.storageType === 'INBOUND' ? '入仓' : '代发'
|
||||
: input.currentStorageType,
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user