diff --git a/furniture-3d-system/src/lib/supply-chain-product-archive-values.ts b/furniture-3d-system/src/lib/supply-chain-product-archive-values.ts index 3ae3c02b..f5a79a1c 100644 --- a/furniture-3d-system/src/lib/supply-chain-product-archive-values.ts +++ b/furniture-3d-system/src/lib/supply-chain-product-archive-values.ts @@ -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 '' +} diff --git a/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-warehouse-config-fill.test.ts b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-warehouse-config-fill.test.ts new file mode 100644 index 00000000..f3878bba --- /dev/null +++ b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-warehouse-config-fill.test.ts @@ -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() + }) +}) diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sync.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sync.repository.ts index aa4b13fe..009597ef 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sync.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sync.repository.ts @@ -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 diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts index 4919a56b..7e3c1af4 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-enrichment-sync.service.test.ts @@ -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' }), diff --git a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sync.service.test.ts b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sync.service.test.ts index 39461de3..c05625a0 100644 --- a/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sync.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/supply-chain-product-archive-sync.service.test.ts @@ -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 = { diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts index 67bc4f79..523e2ff1 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-enrichment-sync.mapper.ts @@ -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 ? [ { diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-sync.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-sync.service.ts index bdc95c03..67beab87 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-sync.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-sync.service.ts @@ -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), diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts index 55f405a8..b35a3c7d 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive.service.ts @@ -36,7 +36,7 @@ export function resolveProductArchiveWarehouseFields(input: { ), warehouseHead: configured?.warehouseHead ?? input.currentWarehouseHead, storageType: configured?.storageType - ? configured.storageType === 'INBOUND' ? '入库' : '代发' + ? configured.storageType === 'INBOUND' ? '入仓' : '代发' : input.currentStorageType, } }