From d563513875ca2558b8b566b01248a8e583395de0 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 9 Sep 2026 11:43:21 +0800 Subject: [PATCH 001/184] feat(product-development-todos): drop early-stage reminders and read structured factory rows --- .../product-development-todo.repository.ts | 54 +++++++ ...velopment-todo-preparation.service.test.ts | 152 ++++++++++++++++++ .../product-development-todo-rules.test.ts | 17 +- .../product-development-todo.service.test.ts | 11 +- .../intake-non-integrated-product.service.ts | 131 +-------------- .../services/intake-structured-row-overlay.ts | 131 +++++++++++++++ ...ct-development-todo-preparation.service.ts | 19 ++- .../product-development-todo-rules.ts | 41 ----- 8 files changed, 375 insertions(+), 181 deletions(-) create mode 100644 furniture-3d-system/src/server/services/__tests__/product-development-todo-preparation.service.test.ts create mode 100644 furniture-3d-system/src/server/services/intake-structured-row-overlay.ts diff --git a/furniture-3d-system/src/server/repositories/product-development-todo.repository.ts b/furniture-3d-system/src/server/repositories/product-development-todo.repository.ts index 98d427ea..e6ecf7b5 100644 --- a/furniture-3d-system/src/server/repositories/product-development-todo.repository.ts +++ b/furniture-3d-system/src/server/repositories/product-development-todo.repository.ts @@ -30,6 +30,32 @@ export type ProductDevelopmentTodoSourceRecord = { pricingRows: Array<{ costPrice: Prisma.Decimal | null }> + skuItems: Array<{ + skuItemId: string + sortOrder: number + model: string + skuCode: string + setCount: string + barcode: string + specName: string + size: string + color: string + material: string + installMethod: string + executeStandard: string + manufacturer: string + manufacturerCode: string + factoryWarehouse: string + shippingMethod: string + purchaseOrderMethod: string + minimumOrderQuantity: string + taxIncludedUnitPrice: string + leadTime: string + sampleLengthCm: Prisma.Decimal | null + sampleWidthCm: Prisma.Decimal | null + sampleHeightCm: Prisma.Decimal | null + sampleWeightKg: Prisma.Decimal | null + }> } class ProductDevelopmentTodoRepository { @@ -61,6 +87,34 @@ class ProductDevelopmentTodoRepository { costPrice: true, }, }, + skuItems: { + select: { + skuItemId: true, + sortOrder: true, + model: true, + skuCode: true, + setCount: true, + barcode: true, + specName: true, + size: true, + color: true, + material: true, + installMethod: true, + executeStandard: true, + manufacturer: true, + manufacturerCode: true, + factoryWarehouse: true, + shippingMethod: true, + purchaseOrderMethod: true, + minimumOrderQuantity: true, + taxIncludedUnitPrice: true, + leadTime: true, + sampleLengthCm: true, + sampleWidthCm: true, + sampleHeightCm: true, + sampleWeightKg: true, + }, + }, }, orderBy: [ { createdAt: 'asc' }, diff --git a/furniture-3d-system/src/server/services/__tests__/product-development-todo-preparation.service.test.ts b/furniture-3d-system/src/server/services/__tests__/product-development-todo-preparation.service.test.ts new file mode 100644 index 00000000..7efff269 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/product-development-todo-preparation.service.test.ts @@ -0,0 +1,152 @@ +/** @jest-environment node */ + +const mockFindEligibleProducts = jest.fn() +const mockFindUsersByNames = jest.fn() +const mockFindAttemptKeys = jest.fn() + +jest.mock('@/server/repositories/product-development-todo.repository', () => ({ + productDevelopmentTodoRepository: { + findEligibleProducts: (...args: unknown[]) => mockFindEligibleProducts(...args), + findUsersByNames: (...args: unknown[]) => mockFindUsersByNames(...args), + findAttemptKeys: (...args: unknown[]) => mockFindAttemptKeys(...args), + createAttempts: jest.fn(), + updateAttempts: jest.fn(), + }, +})) + +import { prepareProductDevelopmentTodos } from '../product-development-todo-preparation.service' + +const now = new Date('2026-09-08T02:00:00.000Z') + +const legacyFactoryRow = (skuId: string, skuCode: string) => ({ + skuId, + confirmed: true, + model: 'YMJ0146K01', + skuCode, + setCount: '1', + barcode: `${skuCode}N3057V01`, + specName: '款式一 φ380/1770 原木色', + material: '榉木', + installMethod: '拆装', + supplierName: '', + supplierCode: '', + warehouse: '工厂-山东春霖代发仓', + shippingMethod: '代发', + minimumOrderQuantity: '60', + leadTime: '2026年06月30日', + productionType: 'ODM', + installationService: '送货', + supplierCategory: '主力供应商', + purchaseOrderMethod: '非订单式', + executeStandard: 'GB/T3324-2024', +}) + +const structuredSkuItem = (skuItemId: string, skuCode: string) => ({ + skuItemId, + sortOrder: 1, + model: 'YMJ0146K01', + skuCode, + setCount: '1', + barcode: `${skuCode}N3057V01`, + specName: '款式一 φ380/1770 原木色', + size: 'φ380/1770', + color: '原木色', + material: '榉木', + installMethod: '拆装', + executeStandard: 'GB/T3324-2024', + manufacturer: '春霖木制品', + manufacturerCode: '', + factoryWarehouse: '工厂-山东春霖代发仓', + shippingMethod: '代发', + purchaseOrderMethod: '非订单式', + minimumOrderQuantity: '60', + taxIncludedUnitPrice: '', + leadTime: '2026年06月30日', + sampleLengthCm: null, + sampleWidthCm: null, + sampleHeightCm: null, + sampleWeightKg: null, +}) + +describe('prepareProductDevelopmentTodos with structured sku overlay', () => { + beforeEach(() => { + jest.clearAllMocks() + mockFindUsersByNames.mockResolvedValue([{ + id: 'user-1', + name: '徐霞客', + email: 'xuxiake@example.com', + status: 'ACTIVE', + businessRoles: ['MANAGEMENT'], + dingtalkUserId: 'ding-user-1', + }]) + mockFindAttemptKeys.mockResolvedValue([]) + }) + + it('evaluates factory completion against structured sku rows even when legacy rows miss supplierName', async () => { + mockFindEligibleProducts.mockResolvedValue([{ + id: 'product-1', + spu: 'YMJ0146', + designer: '徐霞客', + purchaser: '徐霞客', + createdAt: new Date('2026-06-01T00:00:00.000Z'), + payload: { + projectStatus: 'draft', + documentWorkspaceConfirmations: { 基础信息: true, 产品定厂: true }, + documentWorkspaceApprovedAt: { + 基础信息: '2026-06-12T03:03:53.932Z', + 产品定厂: '2026-06-15T08:31:43.998Z', + }, + factoryRows: [ + legacyFactoryRow('sku-1', 'YMJ014601'), + legacyFactoryRow('sku-2', 'YMJ014602'), + legacyFactoryRow('sku-3', 'YMJ014603'), + legacyFactoryRow('sku-4', 'YMJ014604'), + ], + sampleMeasurementRows: [], + }, + profile: { purchaseOwner: '徐霞客', structureDesigner: '徐霞客' }, + pricingRows: [], + skuItems: [ + structuredSkuItem('sku-1', 'YMJ014601'), + structuredSkuItem('sku-2', 'YMJ014602'), + structuredSkuItem('sku-3', 'YMJ014603'), + structuredSkuItem('sku-4', 'YMJ014604'), + ], + }]) + + const { candidates } = await prepareProductDevelopmentTodos({ now }) + + expect(candidates.map((item) => item.ruleId)).not.toContain('product_factory_unfinished') + expect(candidates.map((item) => item.ruleId)).toContain('sample_measurement_unfinished') + }) + + it('still reports an unfinished factory node when structured rows miss required fields', async () => { + mockFindEligibleProducts.mockResolvedValue([{ + id: 'product-2', + spu: 'YMJ0147', + designer: '徐霞客', + purchaser: '徐霞客', + createdAt: new Date('2026-06-01T00:00:00.000Z'), + payload: { + projectStatus: 'approved', + documentWorkspaceConfirmations: { 基础信息: true, 产品定厂: true }, + documentWorkspaceApprovedAt: { + 基础信息: '2026-06-12T03:03:53.932Z', + 产品定厂: '2026-06-15T08:31:43.998Z', + }, + factoryRows: [legacyFactoryRow('sku-1', 'YMJ014701')], + sampleMeasurementRows: [], + }, + profile: { purchaseOwner: '徐霞客', structureDesigner: '徐霞客' }, + pricingRows: [], + skuItems: [{ + ...structuredSkuItem('sku-1', 'YMJ014701'), + manufacturer: '', + }], + }]) + + const { candidates } = await prepareProductDevelopmentTodos({ now }) + + expect(candidates.map((item) => item.ruleId)).toContain('product_factory_unfinished') + }) +}) diff --git a/furniture-3d-system/src/server/services/__tests__/product-development-todo-rules.test.ts b/furniture-3d-system/src/server/services/__tests__/product-development-todo-rules.test.ts index 239bea6d..377cd65c 100644 --- a/furniture-3d-system/src/server/services/__tests__/product-development-todo-rules.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/product-development-todo-rules.test.ts @@ -80,25 +80,24 @@ function completeSampleMeasurementRow() { } describe('evaluateProductDevelopmentTodos', () => { - it('keeps an earlier missing node and also activates a later node whose predecessor completed', () => { + it('no longer emits reminders for opening reference, selling point or product information', () => { const input = product({ ...approval(['设计卖点']), }) - expect(evaluateProductDevelopmentTodos(input, now).map((item) => item.ruleId)).toEqual([ - 'opening_reference_unconfirmed', - 'product_information_unfinished', - ]) + expect(evaluateProductDevelopmentTodos(input, now)).toEqual([]) }) it('requires a valid timestamp as well as the confirmation boolean', () => { + const base = approval(['基础信息']) const input = product({ - confirmations: { 开品参考: true }, - approvedAt: { 开品参考: 'not-a-date' }, + confirmations: { ...base.confirmations, 产品定厂: true }, + approvedAt: { ...base.approvedAt, 产品定厂: 'not-a-date' }, + factoryRows: [completeFactoryRow()], }) - expect(evaluateProductDevelopmentTodos(input, now)[0]?.ruleId) - .toBe('opening_reference_unconfirmed') + expect(evaluateProductDevelopmentTodos(input, now).map((item) => item.ruleId)) + .toContain('product_factory_unfinished') }) it('waits seven full days before activating the basic information reminder', () => { diff --git a/furniture-3d-system/src/server/services/__tests__/product-development-todo.service.test.ts b/furniture-3d-system/src/server/services/__tests__/product-development-todo.service.test.ts index 667f5afe..a78ec1ed 100644 --- a/furniture-3d-system/src/server/services/__tests__/product-development-todo.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/product-development-todo.service.test.ts @@ -47,6 +47,7 @@ function record(payload: Record) { structureDesigner: '徐霞客', }, pricingRows: [], + skuItems: [], } } @@ -71,7 +72,7 @@ describe('ProductDevelopmentTodoService', () => { mockRecordUndeliverable.mockResolvedValue({ status: 'failed' }) }) - it('aggregates abnormal earlier and active later nodes into one recipient message', async () => { + it('aggregates the sampling handoff and basic information reminder into one recipient message', async () => { mockFindEligibleProducts.mockResolvedValue([ record({ projectStatus: 'approved', @@ -79,8 +80,8 @@ describe('ProductDevelopmentTodoService', () => { designer: '徐霞客', structureDesigner: '徐霞客', }, - documentWorkspaceConfirmations: { 设计卖点: true }, - documentWorkspaceApprovedAt: { 设计卖点: '2026-08-26T02:00:00.000Z' }, + documentWorkspaceConfirmations: { 产品模型: true }, + documentWorkspaceApprovedAt: { 产品模型: '2026-08-19T02:00:00.000Z' }, }), ]) @@ -93,8 +94,8 @@ describe('ProductDevelopmentTodoService', () => { deliverable: true, }) expect(result.groups[0]?.content.split('\n\n')).toEqual([ - expect.stringMatching(/^XJ0100 · 开品参考未确认 · 已用/), - expect.stringMatching(/^XJ0100 · 产品资料未完成 · 已用/), + expect.stringMatching(/^XJ0100 · 基础信息未确认 · 已用/), + expect.stringMatching(/^XJ0100 · 产品资料已完成,需要打样 · 已用/), ]) expect(result.groups[0]?.content).not.toMatch(/查看|https?:\/\/|\[[^\]]*\]/) expect(result.groups[0]?.content).not.toContain('徐霞客') diff --git a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts index b48b60a4..d16e5830 100644 --- a/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts +++ b/furniture-3d-system/src/server/services/intake-non-integrated-product.service.ts @@ -2656,27 +2656,13 @@ type StructuredHydrationSource = { productLimitRows?: Array> | null } -function readStructuredText(value: unknown) { - return typeof value === 'string' ? value.trim() : '' -} - -function readStructuredNumber(value: unknown, fallback: number) { - return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : fallback -} - -function readStructuredDecimalText(value: unknown) { - if (typeof value === 'number') { - return Number.isFinite(value) ? String(value) : '' - } - if (typeof value === 'string') { - return value.trim() - } - if (value && typeof value === 'object' && 'toString' in value) { - const text = value.toString() - return typeof text === 'string' ? text.trim() : '' - } - return '' -} +import { + readStructuredText, + readStructuredNumber, + readStructuredDecimalText, + buildStructuredFactoryRowOverlay, + buildStructuredSampleMeasurementRowOverlay, +} from '@/server/services/intake-structured-row-overlay' function parseStructuredDate(value: unknown): Date | null { if (value instanceof Date) { @@ -2805,109 +2791,6 @@ function buildStructuredSkuItemOverlay( }) } -function buildStructuredFactoryRowOverlay( - factoryRows: NonIntegratedFactoryRow[], - skuRows: Array>, -): NonIntegratedFactoryRow[] { - if (skuRows.length === 0) return factoryRows - - const legacyBySkuItemId = new Map(factoryRows.map((row) => [row.skuId, row])) - - return skuRows - .slice() - .sort( - (left, right) => - readStructuredNumber(left.sortOrder, 0) - readStructuredNumber(right.sortOrder, 0), - ) - .map((row, index) => { - const skuItemId = readStructuredText(row.skuItemId) || `sku-${index + 1}` - const legacy = legacyBySkuItemId.get(skuItemId) - - return { - skuId: skuItemId, - confirmed: legacy?.confirmed ?? false, - model: readStructuredText(row.model), - skuCode: readStructuredText(row.skuCode), - setCount: Object.prototype.hasOwnProperty.call(row, 'setCount') - ? readStructuredText(row.setCount) || '1' - : legacy?.setCount || '1', - barcode: readStructuredText(row.barcode), - specName: readStructuredText(row.specName), - size: readStructuredText(row.size), - color: readStructuredText(row.color), - material: readStructuredText(row.material), - installMethod: readStructuredText(row.installMethod), - supplierName: readStructuredText(row.manufacturer), - supplierCode: readStructuredText(row.manufacturerCode), - warehouse: readStructuredText(row.factoryWarehouse), - shippingMethod: readStructuredText(row.shippingMethod), - minimumOrderQuantity: readStructuredText(row.minimumOrderQuantity), - taxIncludedUnitPrice: readStructuredText(row.taxIncludedUnitPrice), - leadTime: readStructuredText(row.leadTime), - assemblyMode: legacy?.assemblyMode || '', - productionType: legacy?.productionType || '', - installationService: legacy?.installationService || '', - supplierCategory: legacy?.supplierCategory || '', - purchaseOrderMethod: Object.prototype.hasOwnProperty.call( - row, - 'purchaseOrderMethod', - ) - ? readStructuredText(row.purchaseOrderMethod) - : legacy?.purchaseOrderMethod || '', - executeStandard: readStructuredText(row.executeStandard), - } - }) -} - -function buildStructuredSampleMeasurementRowOverlay( - measurementRows: NonIntegratedSampleMeasurementRow[], - skuRows: Array>, -) { - const structuredBySkuItemId = new Map( - skuRows.map((row) => [readStructuredText(row.skuItemId), row]), - ) - const existingSkuItemIds = new Set(measurementRows.map((row) => row.skuId)) - - const overlay = measurementRows.map((measurement) => { - const structured = structuredBySkuItemId.get(measurement.skuId) - if (!structured) return measurement - - return { - skuId: measurement.skuId, - lengthCm: Object.prototype.hasOwnProperty.call(structured, 'sampleLengthCm') - ? readStructuredDecimalText(structured.sampleLengthCm) - : measurement.lengthCm, - widthCm: Object.prototype.hasOwnProperty.call(structured, 'sampleWidthCm') - ? readStructuredDecimalText(structured.sampleWidthCm) - : measurement.widthCm, - heightCm: Object.prototype.hasOwnProperty.call(structured, 'sampleHeightCm') - ? readStructuredDecimalText(structured.sampleHeightCm) - : measurement.heightCm, - weightKg: Object.prototype.hasOwnProperty.call(structured, 'sampleWeightKg') - ? readStructuredDecimalText(structured.sampleWeightKg) - : measurement.weightKg, - } - }) - - skuRows.forEach((structured) => { - const skuItemId = readStructuredText(structured.skuItemId) - if (!skuItemId || existingSkuItemIds.has(skuItemId)) return - - const measurement = { - skuId: skuItemId, - lengthCm: readStructuredDecimalText(structured.sampleLengthCm), - widthCm: readStructuredDecimalText(structured.sampleWidthCm), - heightCm: readStructuredDecimalText(structured.sampleHeightCm), - weightKg: readStructuredDecimalText(structured.sampleWeightKg), - } - if (Object.values(measurement).slice(1).some(Boolean)) { - overlay.push(measurement) - } - }) - - return overlay -} - function normalizeStructuredPricingStatus(value: unknown) { return value === 'pending' || value === 'pushed' || diff --git a/furniture-3d-system/src/server/services/intake-structured-row-overlay.ts b/furniture-3d-system/src/server/services/intake-structured-row-overlay.ts new file mode 100644 index 00000000..275ff219 --- /dev/null +++ b/furniture-3d-system/src/server/services/intake-structured-row-overlay.ts @@ -0,0 +1,131 @@ +import type { + NonIntegratedFactoryRow, + NonIntegratedSampleMeasurementRow, +} from '@/app/intake/non-integrated/nonIntegratedStorage' + +// 结构化 SKU 行(IntakeNonIntegratedSkuItem)与旧版 payload 行的叠加视图。 +// 定厂工作区、待办提醒等消费方必须使用同一份叠加结果,避免页面与后台判定数据源不一致。 +export function readStructuredText(value: unknown) { + return typeof value === 'string' ? value.trim() : '' +} + +export function readStructuredNumber(value: unknown, fallback: number) { + return typeof value === 'number' && Number.isFinite(value) ? Math.trunc(value) : fallback +} + +export function readStructuredDecimalText(value: unknown) { + if (typeof value === 'number') { + return Number.isFinite(value) ? String(value) : '' + } + if (typeof value === 'string') { + return value.trim() + } + if (value && typeof value === 'object' && 'toString' in value) { + const text = value.toString() + return typeof text === 'string' ? text.trim() : '' + } + return '' +} + +export function buildStructuredFactoryRowOverlay( + factoryRows: NonIntegratedFactoryRow[], + skuRows: Array>, +): NonIntegratedFactoryRow[] { + if (skuRows.length === 0) return factoryRows + + const legacyBySkuItemId = new Map(factoryRows.map((row) => [row.skuId, row])) + + return skuRows + .slice() + .sort( + (left, right) => + readStructuredNumber(left.sortOrder, 0) - readStructuredNumber(right.sortOrder, 0), + ) + .map((row, index) => { + const skuItemId = readStructuredText(row.skuItemId) || `sku-${index + 1}` + const legacy = legacyBySkuItemId.get(skuItemId) + + return { + skuId: skuItemId, + confirmed: legacy?.confirmed ?? false, + model: readStructuredText(row.model), + skuCode: readStructuredText(row.skuCode), + setCount: Object.prototype.hasOwnProperty.call(row, 'setCount') + ? readStructuredText(row.setCount) || '1' + : legacy?.setCount || '1', + barcode: readStructuredText(row.barcode), + specName: readStructuredText(row.specName), + size: readStructuredText(row.size), + color: readStructuredText(row.color), + material: readStructuredText(row.material), + installMethod: readStructuredText(row.installMethod), + supplierName: readStructuredText(row.manufacturer), + supplierCode: readStructuredText(row.manufacturerCode), + warehouse: readStructuredText(row.factoryWarehouse), + shippingMethod: readStructuredText(row.shippingMethod), + minimumOrderQuantity: readStructuredText(row.minimumOrderQuantity), + taxIncludedUnitPrice: readStructuredText(row.taxIncludedUnitPrice), + leadTime: readStructuredText(row.leadTime), + assemblyMode: legacy?.assemblyMode || '', + productionType: legacy?.productionType || '', + installationService: legacy?.installationService || '', + supplierCategory: legacy?.supplierCategory || '', + purchaseOrderMethod: Object.prototype.hasOwnProperty.call( + row, + 'purchaseOrderMethod', + ) + ? readStructuredText(row.purchaseOrderMethod) + : legacy?.purchaseOrderMethod || '', + executeStandard: readStructuredText(row.executeStandard), + } + }) +} + +export function buildStructuredSampleMeasurementRowOverlay( + measurementRows: NonIntegratedSampleMeasurementRow[], + skuRows: Array>, +) { + const structuredBySkuItemId = new Map( + skuRows.map((row) => [readStructuredText(row.skuItemId), row]), + ) + const existingSkuItemIds = new Set(measurementRows.map((row) => row.skuId)) + + const overlay = measurementRows.map((measurement) => { + const structured = structuredBySkuItemId.get(measurement.skuId) + if (!structured) return measurement + + return { + skuId: measurement.skuId, + lengthCm: Object.prototype.hasOwnProperty.call(structured, 'sampleLengthCm') + ? readStructuredDecimalText(structured.sampleLengthCm) + : measurement.lengthCm, + widthCm: Object.prototype.hasOwnProperty.call(structured, 'sampleWidthCm') + ? readStructuredDecimalText(structured.sampleWidthCm) + : measurement.widthCm, + heightCm: Object.prototype.hasOwnProperty.call(structured, 'sampleHeightCm') + ? readStructuredDecimalText(structured.sampleHeightCm) + : measurement.heightCm, + weightKg: Object.prototype.hasOwnProperty.call(structured, 'sampleWeightKg') + ? readStructuredDecimalText(structured.sampleWeightKg) + : measurement.weightKg, + } + }) + + skuRows.forEach((structured) => { + const skuItemId = readStructuredText(structured.skuItemId) + if (!skuItemId || existingSkuItemIds.has(skuItemId)) return + + const measurement = { + skuId: skuItemId, + lengthCm: readStructuredDecimalText(structured.sampleLengthCm), + widthCm: readStructuredDecimalText(structured.sampleWidthCm), + heightCm: readStructuredDecimalText(structured.sampleHeightCm), + weightKg: readStructuredDecimalText(structured.sampleWeightKg), + } + if (Object.values(measurement).slice(1).some(Boolean)) { + overlay.push(measurement) + } + }) + + return overlay +} diff --git a/furniture-3d-system/src/server/services/product-development-todo-preparation.service.ts b/furniture-3d-system/src/server/services/product-development-todo-preparation.service.ts index c0e59b75..7bbac76e 100644 --- a/furniture-3d-system/src/server/services/product-development-todo-preparation.service.ts +++ b/furniture-3d-system/src/server/services/product-development-todo-preparation.service.ts @@ -7,11 +7,19 @@ import { type ProductDevelopmentTodoSourceRecord, type ProductDevelopmentTodoUser, } from '@/server/repositories/product-development-todo.repository' +import { + buildStructuredFactoryRowOverlay, + buildStructuredSampleMeasurementRowOverlay, +} from '@/server/services/intake-structured-row-overlay' import { evaluateProductDevelopmentTodos, formatProductDevelopmentTodoElapsed, getBeijingDateKey, } from '@/server/services/product-development-todo-rules' +import type { + NonIntegratedFactoryRow, + NonIntegratedSampleMeasurementRow, +} from '@/app/intake/non-integrated/nonIntegratedStorage' import type { ProductDevelopmentTodoCandidate, ProductDevelopmentTodoProduct, @@ -60,6 +68,7 @@ function toRuleProduct(record: ProductDevelopmentTodoSourceRecord): ProductDevel const structuredPricingRows = record.pricingRows.map((row) => ({ costPrice: row.costPrice?.toString() ?? '', })) + const structuredSkuRows = asArray(record.skuItems) return { id: record.id, @@ -78,8 +87,14 @@ function toRuleProduct(record: ProductDevelopmentTodoSourceRecord): ProductDevel }, confirmations: asRecord(payload.documentWorkspaceConfirmations), approvedAt: asRecord(payload.documentWorkspaceApprovedAt), - factoryRows: asArray(payload.factoryRows), - sampleMeasurementRows: asArray(payload.sampleMeasurementRows), + factoryRows: buildStructuredFactoryRowOverlay( + asArray(payload.factoryRows) as NonIntegratedFactoryRow[], + structuredSkuRows, + ), + sampleMeasurementRows: buildStructuredSampleMeasurementRowOverlay( + asArray(payload.sampleMeasurementRows) as NonIntegratedSampleMeasurementRow[], + structuredSkuRows, + ), pricingRows: structuredPricingRows.length > 0 ? structuredPricingRows : asArray(payload.pricingRows), diff --git a/furniture-3d-system/src/server/services/product-development-todo-rules.ts b/furniture-3d-system/src/server/services/product-development-todo-rules.ts index b6d8feff..be049a42 100644 --- a/furniture-3d-system/src/server/services/product-development-todo-rules.ts +++ b/furniture-3d-system/src/server/services/product-development-todo-rules.ts @@ -161,52 +161,11 @@ export function evaluateProductDevelopmentTodos( if (product.projectStatus === 'rejected') return [] const items: ProductDevelopmentTodoCandidate[] = [] - const openingApprovedAt = approvedAt(product, '开品参考') - const sellingPointApprovedAt = approvedAt(product, '设计卖点') const productInformationApprovedAt = approvedAt(product, '产品模型') const basicInformationApprovedAt = approvedAt(product, '基础信息') const productFactoryApprovedAt = approvedAt(product, '产品定厂') const sampleMeasurementApprovedAt = approvedAt(product, '样包测量') - if (!nodeApproved(product, '开品参考')) { - items.push(candidate({ - product, - ruleId: 'opening_reference_unconfirmed', - nodeLabel: '开品参考未确认', - workspace: 'opening-reference', - recipientRole: 'appearanceDesigner', - recipientNames: splitNames(product.owners.appearanceDesigner), - activatedAt: product.createdAt, - deliveryMode: 'daily', - })) - } - - if (nodeApproved(product, '开品参考') && !nodeApproved(product, '设计卖点')) { - items.push(candidate({ - product, - ruleId: 'selling_point_unconfirmed', - nodeLabel: '设计卖点未确认', - workspace: 'design-selling-point', - recipientRole: 'appearanceDesigner', - recipientNames: splitNames(product.owners.appearanceDesigner), - activatedAt: openingApprovedAt!, - deliveryMode: 'daily', - })) - } - - if (nodeApproved(product, '设计卖点') && !nodeApproved(product, '产品模型')) { - items.push(candidate({ - product, - ruleId: 'product_information_unfinished', - nodeLabel: '产品资料未完成', - workspace: 'product-data', - recipientRole: 'structureDesigner', - recipientNames: splitNames(product.owners.structureDesigner), - activatedAt: sellingPointApprovedAt!, - deliveryMode: 'daily', - })) - } - if (nodeApproved(product, '产品模型') && !nodeApproved(product, '基础信息')) { const recipient = purchaserRecipientConfig(product) items.push(candidate({ From f0cc716dd0dd8f99b68ab2b6348b1dbf1e8b4f52 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 9 Sep 2026 12:11:32 +0800 Subject: [PATCH 002/184] fix(intake): map reorganized planning department to PLANNING role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 钉钉组织调整后商品企划部更名为企划部并挂到公司根节点。部门到业务角色推断、非集成产品权限、节点权限主体与商品企划候选人筛选统一识别 企划部 及其下级在职人员;保留旧 品牌中心>商品企划部 口径兼容存量数据;在职/外部过滤逻辑不变。 --- .../src/lib/business-role-inference.test.ts | 13 ++ .../src/lib/business-role-inference.ts | 9 ++ .../intake-node-permission-settings.test.ts | 22 ++- .../lib/intake-node-permission-settings.ts | 12 +- .../lib/non-integrated-permissions.test.ts | 23 ++++ .../src/lib/non-integrated-permissions.ts | 10 +- .../intake-owner-options.service.test.ts | 125 ++++++++++++++++++ .../services/intake-owner-options.service.ts | 9 +- 8 files changed, 216 insertions(+), 7 deletions(-) create mode 100644 furniture-3d-system/src/server/services/__tests__/intake-owner-options.service.test.ts diff --git a/furniture-3d-system/src/lib/business-role-inference.test.ts b/furniture-3d-system/src/lib/business-role-inference.test.ts index ee2724c8..0c4e1e34 100644 --- a/furniture-3d-system/src/lib/business-role-inference.test.ts +++ b/furniture-3d-system/src/lib/business-role-inference.test.ts @@ -109,6 +109,19 @@ describe('business-role-inference', () => { ).toEqual(['PLANNING']) }) + it('maps the reorganized standalone planning department and its children to planning', () => { + expect( + inferBusinessRolesFromDepartmentPath('江苏简尚品牌管理有限公司 > 企划部') + .businessRoles, + ).toEqual(['PLANNING']) + + expect( + inferBusinessRolesFromDepartmentPath( + '江苏简尚品牌管理有限公司 > 企划部 > 桌类企划', + ).businessRoles, + ).toEqual(['PLANNING']) + }) + it('uses general user as the low-permission fallback for unmatched internal departments', () => { const result = inferBusinessRolesFromDepartmentPath( '江苏简尚品牌管理有限公司 > 法务中心 > 合同组', diff --git a/furniture-3d-system/src/lib/business-role-inference.ts b/furniture-3d-system/src/lib/business-role-inference.ts index 408e26f5..a8700f43 100644 --- a/furniture-3d-system/src/lib/business-role-inference.ts +++ b/furniture-3d-system/src/lib/business-role-inference.ts @@ -120,6 +120,15 @@ export function inferBusinessRolesFromDepartmentPath( } } + if (root === '企划部') { + return { + businessRoles: ['PLANNING'], + excluded: false, + shouldWarnWhenEmpty: true, + reason: '企划部(原品牌中心商品企划部)默认归企划', + } + } + if (isNonInternalDepartmentRoot(root)) { return { businessRoles: [], diff --git a/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts b/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts index 8026866b..232f8281 100644 --- a/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts +++ b/furniture-3d-system/src/lib/intake-node-permission-settings.test.ts @@ -39,6 +39,26 @@ describe('intake node permission settings', () => { }), ).toEqual(['planning']) + expect( + resolveIntakeNodePermissionSubjectKeys({ + role: 'USER', + businessRoles: [], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 企划部 > 桌类', + ], + }), + ).toEqual(['planning']) + + expect( + resolveIntakeNodePermissionSubjectKeys({ + role: 'USER', + businessRoles: [], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 企划部', + ], + }), + ).toEqual(['planning']) + expect( resolveIntakeNodePermissionSubjectKeys({ role: 'USER', @@ -310,7 +330,7 @@ describe('intake node permission settings', () => { ).toMatchObject({ defaultState: 'readonly', view: true, - download: false, + download: true, edit: false, }) diff --git a/furniture-3d-system/src/lib/intake-node-permission-settings.ts b/furniture-3d-system/src/lib/intake-node-permission-settings.ts index 3ac3a2b9..5d906397 100644 --- a/furniture-3d-system/src/lib/intake-node-permission-settings.ts +++ b/furniture-3d-system/src/lib/intake-node-permission-settings.ts @@ -315,7 +315,7 @@ export const CORE_NODE_PERMISSION_SUBJECTS: IntakeNodePermissionSubject[] = [ { key: "planning", name: "商品企划", - department: "品牌中心-商品企划部", + department: "企划部(原品牌中心-商品企划部)", }, { key: "space", @@ -869,7 +869,10 @@ function isProductResearchDepartmentPath(path: string) { } function isBrandPlanningDepartmentPath(path: string) { - return hasDepartmentPathSegment(path, "品牌中心") && /商品企划部/.test(path); + return ( + (hasDepartmentPathSegment(path, "品牌中心") && /商品企划部/.test(path)) || + hasDepartmentPathSegment(path, "企划部") + ); } function isSpaceVisualDepartmentPath(path: string) { @@ -877,7 +880,10 @@ function isSpaceVisualDepartmentPath(path: string) { } function isBrandCenterDepartmentPath(path: string) { - return hasDepartmentPathSegment(path, "品牌中心"); + return ( + hasDepartmentPathSegment(path, "品牌中心") || + hasDepartmentPathSegment(path, "企划部") + ); } function isPricingGroupDepartmentPath(path: string) { diff --git a/furniture-3d-system/src/lib/non-integrated-permissions.test.ts b/furniture-3d-system/src/lib/non-integrated-permissions.test.ts index 5f6941e4..173168ab 100644 --- a/furniture-3d-system/src/lib/non-integrated-permissions.test.ts +++ b/furniture-3d-system/src/lib/non-integrated-permissions.test.ts @@ -169,6 +169,29 @@ describe('non-integrated intake permissions', () => { expect(canEditAnyNonIntegratedWorkspace(baseProduct, brandCampaignUser)).toBe(true) }) + it('lets reorganized planning department owners keep their planning permissions', () => { + const planningOwner = { + id: 'planning-reorg-id', + name: '企划A', + role: 'USER', + businessRoles: [], + departmentPaths: [ + '江苏简尚品牌管理有限公司 > 企划部 > 一线品牌', + ], + } + const otherPlanner = { + ...planningOwner, + name: '企划C', + } + + expect(canEditNonIntegratedWorkspace(baseProduct, planningOwner, '开品参考')).toBe(true) + expect(canEditNonIntegratedWorkspace(baseProduct, planningOwner, '产品限价')).toBe(true) + expect(canEditNonIntegratedWorkspace(baseProduct, planningOwner, '成品寄样')).toBe(true) + expect(canDownloadNonIntegratedWorkspace(baseProduct, planningOwner, '产品模型')).toBe(true) + expect(canEditNonIntegratedWorkspace(baseProduct, otherPlanner, '开品参考')).toBe(false) + expect(canDownloadNonIntegratedWorkspace(baseProduct, otherPlanner, '产品模型')).toBe(false) + }) + it('uses brand planning owner for brand-center download and edit checks', () => { const brandPlanner = { id: 'brand-planning-id', diff --git a/furniture-3d-system/src/lib/non-integrated-permissions.ts b/furniture-3d-system/src/lib/non-integrated-permissions.ts index f6c3debf..ef12d2cd 100644 --- a/furniture-3d-system/src/lib/non-integrated-permissions.ts +++ b/furniture-3d-system/src/lib/non-integrated-permissions.ts @@ -199,11 +199,17 @@ function isProductResearchDepartmentPath(path: string) { } function isBrandPlanningDepartmentPath(path: string) { - return hasDepartmentPathSegment(path, '品牌中心') && /商品企划部/.test(path) + return ( + (hasDepartmentPathSegment(path, '品牌中心') && /商品企划部/.test(path)) || + hasDepartmentPathSegment(path, '企划部') + ) } function isBrandCenterDepartmentPath(path: string) { - return hasDepartmentPathSegment(path, '品牌中心') + return ( + hasDepartmentPathSegment(path, '品牌中心') || + hasDepartmentPathSegment(path, '企划部') + ) } function isSpaceVisualDepartmentPath(path: string) { diff --git a/furniture-3d-system/src/server/services/__tests__/intake-owner-options.service.test.ts b/furniture-3d-system/src/server/services/__tests__/intake-owner-options.service.test.ts new file mode 100644 index 00000000..854b7eee --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/intake-owner-options.service.test.ts @@ -0,0 +1,125 @@ +import { prisma } from '@/lib/prisma' +import { listIntakeOwnerOptionNamesByDepartmentScope } from '@/server/services/intake-owner-options.service' + +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { + findMany: jest.fn(), + }, + }, +})) + +jest.mock('@/lib/non-integrated-external-entry-permissions', () => ({ + isExternalIntakeUser: jest.fn(() => false), +})) + +jest.mock('@/lib/non-integrated-external-owner-fields', () => ({ + getExternalIntakeSelfNameOptions: jest.fn(() => []), +})) + +const mockedFindMany = prisma.user.findMany as jest.Mock + +describe('intake-owner-options.service', () => { + beforeEach(() => { + mockedFindMany.mockReset() + }) + + it('lists merchandise planning candidates from the reorganized planning department', async () => { + mockedFindMany.mockResolvedValue([ + { + name: '企划A', + businessRoles: [], + department: { + name: '企划部', + fullPath: '江苏简尚品牌管理有限公司 > 企划部', + }, + team: null, + }, + { + name: '企划B', + businessRoles: [], + department: { + name: '桌类企划', + fullPath: '江苏简尚品牌管理有限公司 > 企划部 > 桌类企划', + }, + team: null, + }, + { + name: '企划C', + businessRoles: ['PLANNING'], + department: { + name: '企划部', + fullPath: '江苏简尚品牌管理有限公司 > 企划部', + }, + team: null, + }, + { + name: '策划A', + businessRoles: [], + department: { + name: '策划部', + fullPath: '江苏简尚品牌管理有限公司 > 品牌中心 > 朴美事业部 > 策划部', + }, + team: null, + }, + { + name: '设计A', + businessRoles: [], + department: { + name: '产品研发部', + fullPath: '江苏简尚品牌管理有限公司 > 产品研发部', + }, + team: null, + }, + ]) + + await expect( + listIntakeOwnerOptionNamesByDepartmentScope('merchandisePlanning'), + ).resolves.toEqual(['企划A', '企划B', '企划C']) + }) + + it('keeps legacy brand-center product planning paths as merchandise planning candidates', async () => { + mockedFindMany.mockResolvedValue([ + { + name: '企划旧', + businessRoles: [], + department: { + name: '商品企划部', + fullPath: '江苏简尚品牌管理有限公司 > 品牌中心 > 商品企划部', + }, + team: null, + }, + ]) + + await expect( + listIntakeOwnerOptionNamesByDepartmentScope('merchandisePlanning'), + ).resolves.toEqual(['企划旧']) + }) + + it('does not treat brand planning or lookalike departments as merchandise planning', async () => { + mockedFindMany.mockResolvedValue([ + { + name: '策划A', + businessRoles: [], + department: { + name: '策划部', + fullPath: '江苏简尚品牌管理有限公司 > 品牌中心 > 朴美事业部 > 策划部', + }, + team: null, + }, + { + name: '市场企划A', + businessRoles: [], + department: { + name: '市场企划部', + fullPath: '江苏简尚品牌管理有限公司 > 运营中心A > 市场企划部', + }, + team: null, + }, + ]) + + await expect( + listIntakeOwnerOptionNamesByDepartmentScope('merchandisePlanning'), + ).resolves.toEqual([]) + }) +}) diff --git a/furniture-3d-system/src/server/services/intake-owner-options.service.ts b/furniture-3d-system/src/server/services/intake-owner-options.service.ts index ff2371c4..b6d8b581 100644 --- a/furniture-3d-system/src/server/services/intake-owner-options.service.ts +++ b/furniture-3d-system/src/server/services/intake-owner-options.service.ts @@ -28,6 +28,10 @@ function normalizeDepartmentText(value: string | null | undefined) { return value?.replace(/\s+/g, "") ?? ""; } +function hasDepartmentSegment(text: string, segment: string) { + return text.split(">").some((part) => part.trim() === segment); +} + function departmentTextMatchesScope( values: Array, scope: IntakeOwnerDepartmentScope, @@ -45,7 +49,10 @@ function departmentTextMatchesScope( } if (scope === "merchandisePlanning") { - return text.includes("品牌中心") && text.includes("商品企划部"); + return ( + (text.includes("品牌中心") && text.includes("商品企划部")) || + hasDepartmentSegment(text, "企划部") + ); } if (scope === "spaceVisual") { From cc1e15582e6b935803660cdd732291c6190a16c9 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 9 Sep 2026 13:35:35 +0800 Subject: [PATCH 003/184] fix(product-archive): fall back signed qnycdn material image urls to reachable s3 links the material library api now returns signed preview urls on qnycdn.jiansun.vip/prod whose objects 404 on the cdn, so every resolved archive material image failed to render; rewrite those urls to the same path on s3.jiansun.vip/gongxiangpan (verified reachable) and drop the signature at every exit of the material image service (barcode lookup, sku zip index, white background fallback, resolved cache reads) so matched sku/barcode images display again. --- ...uct-archive-material-image.service.test.ts | 56 +++++++++++++++++++ .../product-archive-material-image.service.ts | 42 ++++++++++++-- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/furniture-3d-system/src/server/services/__tests__/product-archive-material-image.service.test.ts b/furniture-3d-system/src/server/services/__tests__/product-archive-material-image.service.test.ts index 917126bd..6d51e704 100644 --- a/furniture-3d-system/src/server/services/__tests__/product-archive-material-image.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/product-archive-material-image.service.test.ts @@ -414,4 +414,60 @@ describe('product archive material image service', () => { expect(fetchImpl).toHaveBeenCalledTimes(2) expect(String(jest.mocked(fetchImpl).mock.calls[1][0])).not.toContain('resourceType') }) + + it('rewrites signed qnycdn preview urls to reachable s3 direct links', async () => { + const fetchImpl = jest.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input)) + if (url.pathname.endsWith('/product-barcode-images')) { + return jsonResponse(200, { + data: { + items: [{ + previewUrl: 'https://qnycdn.jiansun.vip/prod/%E7%94%B1%E5%A3%B9%E7%82%B9AI/public-display/task-detail/material-1/sku/client-1-0-69002-preview.webp?e=1788930528&token=abc', + isLatest: true, + }], + }, + }) + } + return jsonResponse(404, { error: { code: 'TASK_DETAIL_NOT_FOUND' } }) + }) as unknown as typeof fetch + const service = new ProductArchiveMaterialImageService(fetchImpl, config, Date.now, createImageCacheRepository()) + + await expect(service.resolve({ spu: 'YMJ0135', barcode: '69002', sku: 'CJ003107' })).resolves.toEqual({ + previewUrl: 'https://s3.jiansun.vip/gongxiangpan/%E7%94%B1%E5%A3%B9%E7%82%B9AI/public-display/task-detail/material-1/sku/client-1-0-69002-preview.webp', + source: 'barcode', + viewKey: 'latest', + viewLabel: '条码 SKU 图', + }) + + const cacheRepository = createImageCacheRepository() + await cacheRepository.saveResolved({ + archiveId: 'archive-cdn-cache', + barcode: 'BARCODE-CDN-CACHE', + spu: 'SPU-CDN-CACHE', + sku: 'SKU-CDN-CACHE', + image: { + previewUrl: 'https://qnycdn.jiansun.vip/prod/material-library/cached-preview.webp?e=1788930528&token=abc', + source: 'sku', + viewKey: 'sku-zip', + viewLabel: '素材库 SKU 图', + }, + }) + const cachedService = new ProductArchiveMaterialImageService( + jest.fn() as unknown as typeof fetch, + config, + Date.now, + cacheRepository, + ) + await expect(cachedService.resolve({ + archiveId: 'archive-cdn-cache', + barcode: 'BARCODE-CDN-CACHE', + spu: 'SPU-CDN-CACHE', + sku: 'SKU-CDN-CACHE', + })).resolves.toEqual({ + previewUrl: 'https://s3.jiansun.vip/gongxiangpan/material-library/cached-preview.webp', + source: 'sku', + viewKey: 'sku-zip', + viewLabel: '素材库 SKU 图', + }) + }) }) diff --git a/furniture-3d-system/src/server/services/product-archive-material-image.service.ts b/furniture-3d-system/src/server/services/product-archive-material-image.service.ts index dec22d49..bf0cf024 100644 --- a/furniture-3d-system/src/server/services/product-archive-material-image.service.ts +++ b/furniture-3d-system/src/server/services/product-archive-material-image.service.ts @@ -95,6 +95,35 @@ function isLegacyMaterialPreview(url: string) { return parsed.hostname === 'sc.jiansun.vip' && parsed.pathname.startsWith('/uploads/') } +// 素材库新七牛 CDN(qnycdn)上的对象存在缺失,签名地址会 404; +// 同路径在 s3 直链域上可公开访问,统一回退,保证匹配到即显示。 +const MATERIAL_LIBRARY_SIGNED_CDN_HOST = 'qnycdn.jiansun.vip' +const MATERIAL_LIBRARY_SIGNED_CDN_PREFIX = '/prod/' +const MATERIAL_LIBRARY_S3_HOST = 's3.jiansun.vip' +const MATERIAL_LIBRARY_S3_PREFIX = '/gongxiangpan/' + +function readReachableMaterialHttpUrl(value: unknown) { + const url = readSafeHttpUrl(value) + if (!url) return null + try { + const parsed = new URL(url) + if ( + parsed.hostname === MATERIAL_LIBRARY_SIGNED_CDN_HOST && + parsed.pathname.startsWith(MATERIAL_LIBRARY_SIGNED_CDN_PREFIX) + ) { + parsed.hostname = MATERIAL_LIBRARY_S3_HOST + parsed.pathname = + MATERIAL_LIBRARY_S3_PREFIX + + parsed.pathname.slice(MATERIAL_LIBRARY_SIGNED_CDN_PREFIX.length) + parsed.search = '' + return parsed.toString() + } + } catch { + return url + } + return url +} + function readConfig(): MaterialLibraryConfig { const enabled = ['1', 'true', 'yes', 'on'].includes( (process.env.MATERIAL_LIBRARY_WHITE_BACKGROUND_API_ENABLED ?? '') @@ -119,8 +148,8 @@ function selectWhiteBackgroundImage(payload: unknown): ProductArchiveMaterialIma const selected = WHITE_BACKGROUND_VIEW_PRIORITY .map((viewKey) => fixed.find((item) => item.key === viewKey)) .find(Boolean) ?? fixed[0] - const previewUrl = readSafeHttpUrl(selected?.previewUrl ?? payload.data.previewUrl) - const downloadUrl = readSafeHttpUrl(selected?.downloadUrl) + const previewUrl = readReachableMaterialHttpUrl(selected?.previewUrl ?? payload.data.previewUrl) + const downloadUrl = readReachableMaterialHttpUrl(selected?.downloadUrl) const displayUrl = previewUrl && !isLegacyMaterialPreview(previewUrl) ? previewUrl : downloadUrl ?? previewUrl @@ -142,7 +171,7 @@ function selectSkuImage( const barcodeImage = barcode ? index.get(normalizeLookup(barcode)) : undefined if (barcodeImage) { return { - previewUrl: barcodeImage.previewUrl, + previewUrl: readReachableMaterialHttpUrl(barcodeImage.previewUrl) ?? barcodeImage.previewUrl, source: 'barcode', viewKey: 'sku-zip', viewLabel: '素材库 SKU 图', @@ -152,7 +181,7 @@ function selectSkuImage( const skuImage = sku ? index.get(normalizeLookup(sku)) : undefined if (!skuImage) return null return { - previewUrl: skuImage.previewUrl, + previewUrl: readReachableMaterialHttpUrl(skuImage.previewUrl) ?? skuImage.previewUrl, source: 'sku', viewKey: 'sku-zip', viewLabel: '素材库 SKU 图', @@ -225,7 +254,8 @@ export class ProductArchiveMaterialImageService { && persisted.source !== 'product' ) { const image = { - previewUrl: persisted.previewUrl, + previewUrl: + readReachableMaterialHttpUrl(persisted.previewUrl) ?? persisted.previewUrl, source: persisted.source === 'barcode' ? 'barcode' : 'sku', ...(persisted.viewKey ? { viewKey: persisted.viewKey } : {}), ...(persisted.viewLabel ? { viewLabel: persisted.viewLabel } : {}), @@ -371,7 +401,7 @@ export class ProductArchiveMaterialImageService { ? response.payload.data.items.filter(isRecord) : [] const selected = items.find((item) => item.isLatest === true) ?? items[0] - const previewUrl = readSafeHttpUrl(selected?.previewUrl) + const previewUrl = readReachableMaterialHttpUrl(selected?.previewUrl) if (!previewUrl) return null return { From 254cd92c5b6aa7b0a9090f4def6632658d7e82f3 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 9 Sep 2026 15:45:18 +0800 Subject: [PATCH 004/184] fix(product-archive): stream material library images through a same-origin endpoint the material library api hands out signed preview urls whose objects 404 on its cdn, so archive rows kept rendering empty placeholders; the batch endpoint now returns same-origin streaming urls (material-image?file=1) that resolve server-side, try the reachable s3 direct link first with the original signed url as fallback, and stream the bytes to the browser; the resolved image object is copied before url mapping so the service in-memory cache is no longer poisoned across requests. --- .../product-archive/material-image/route.ts | 53 ++++++++++++++++++- .../product-archive/material-images/route.ts | 20 +++++++ .../product-archive-material-image.service.ts | 6 +++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/furniture-3d-system/src/app/api/supply-chain/product-archive/material-image/route.ts b/furniture-3d-system/src/app/api/supply-chain/product-archive/material-image/route.ts index 79355391..1c83120f 100644 --- a/furniture-3d-system/src/app/api/supply-chain/product-archive/material-image/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/product-archive/material-image/route.ts @@ -1,13 +1,18 @@ -import { NextRequest } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { ErrorCode } from '@/server/core/errors' import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' -import { productArchiveMaterialImageService } from '@/server/services/product-archive-material-image.service' +import { + getReachableMaterialPreviewUrlCandidates, + productArchiveMaterialImageService, +} from '@/server/services/product-archive-material-image.service' const querySchema = z.object({ spu: z.string().trim().max(120).optional(), barcode: z.string().trim().max(120).optional(), sku: z.string().trim().max(120).optional(), + archiveId: z.string().trim().max(120).optional(), + file: z.enum(['1']).optional(), }).refine((value) => Boolean(value.barcode || value.sku), { message: '条码和 SKU 至少提供一个', }) @@ -20,6 +25,8 @@ export async function GET(request: NextRequest) { spu: request.nextUrl.searchParams.get('spu') || undefined, barcode: request.nextUrl.searchParams.get('barcode') || undefined, sku: request.nextUrl.searchParams.get('sku') || undefined, + archiveId: request.nextUrl.searchParams.get('archiveId') || undefined, + file: request.nextUrl.searchParams.get('file') || undefined, }) if (!parsed.success) { @@ -32,6 +39,48 @@ export async function GET(request: NextRequest) { } const image = await productArchiveMaterialImageService.resolve(parsed.data) + + if (!image?.previewUrl) { + return jsonError( + ErrorCode.FILE_NOT_FOUND, + '未匹配到素材库图片', + 404, + ctx.requestId, + ) + } + + // file=1:以图片字节流返回,浏览器无需直接访问外部素材库地址 + if (parsed.data.file === '1') { + for (const candidate of getReachableMaterialPreviewUrlCandidates(image.previewUrl)) { + try { + const upstream = await fetch(candidate, { + headers: { Accept: 'image/avif,image/webp,image/*,*/*;q=0.8' }, + cache: 'no-store', + signal: request.signal, + }) + const contentType = upstream.headers.get('content-type') || '' + if (upstream.ok && contentType.startsWith('image/')) { + return new NextResponse(upstream.body, { + status: 200, + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'private, max-age=3600', + }, + }) + } + } catch { + // 尝试下一个候选地址 + } + } + + return jsonError( + ErrorCode.FILE_NOT_FOUND, + '素材图片拉取失败', + 404, + ctx.requestId, + ) + } + return jsonSuccess(image, 200, ctx.requestId) }, { diff --git a/furniture-3d-system/src/app/api/supply-chain/product-archive/material-images/route.ts b/furniture-3d-system/src/app/api/supply-chain/product-archive/material-images/route.ts index b6088f12..c0b17f96 100644 --- a/furniture-3d-system/src/app/api/supply-chain/product-archive/material-images/route.ts +++ b/furniture-3d-system/src/app/api/supply-chain/product-archive/material-images/route.ts @@ -17,6 +17,17 @@ const batchSchema = z.object({ items: z.array(batchItemSchema).min(1).max(200), }).strict() +type BatchSourceItem = z.infer + +// 让浏览器经由本应用同源地址读取图片字节,而不是直接加载素材库外部地址 +function buildSameOriginImageUrl(item: BatchSourceItem) { + const params = new URLSearchParams({ archiveId: item.archiveId, file: '1' }) + if (item.spu) params.set('spu', item.spu) + if (item.barcode) params.set('barcode', item.barcode) + if (item.sku) params.set('sku', item.sku) + return `/api/supply-chain/product-archive/material-image?${params.toString()}` +} + export async function POST(request: NextRequest) { return handleRequest( request, @@ -36,6 +47,15 @@ export async function POST(request: NextRequest) { parsed.data.items, request.signal, ) + + for (const [index, item] of result.items.entries()) { + const source = parsed.data.items[index] + if (item.status === 'resolved' && item.image?.previewUrl) { + // 拷贝对象再改写,避免污染服务层内存缓存里的同一个对象 + item.image = { ...item.image, previewUrl: buildSameOriginImageUrl(source) } + } + } + return jsonSuccess(result, 200, ctx.requestId) }, { diff --git a/furniture-3d-system/src/server/services/product-archive-material-image.service.ts b/furniture-3d-system/src/server/services/product-archive-material-image.service.ts index bf0cf024..2fdc66ba 100644 --- a/furniture-3d-system/src/server/services/product-archive-material-image.service.ts +++ b/furniture-3d-system/src/server/services/product-archive-material-image.service.ts @@ -453,5 +453,11 @@ export class ProductArchiveMaterialImageService { } } +// 输出可直接被浏览器加载的候选地址:优先已回退到 s3 直链的地址,其次原始地址(如七牛签名地址) +export function getReachableMaterialPreviewUrlCandidates(previewUrl: string): string[] { + const rewritten = readReachableMaterialHttpUrl(previewUrl) ?? previewUrl + return rewritten === previewUrl ? [previewUrl] : [rewritten, previewUrl] +} + export const productArchiveMaterialImageService = new ProductArchiveMaterialImageService() From 1c0beffcb650b47e483c6d260aae7db4768430d4 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 9 Sep 2026 15:43:08 +0800 Subject: [PATCH 005/184] feat(barcode-print): add calibrated Gujia certificate template --- .../barcode-print/barcodePrintCertificate.ts | 12 ++++++++ .../barcodePrintGujiaCertificate2026.ts | 28 +++++++++++++++++++ ...barcodePrintGujiaCertificateLayout.test.ts | 12 ++++---- .../barcodePrintGujiaCertificateLayout.ts | 4 ++- 4 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificate2026.ts diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts index 1a676988..f3f2a230 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintCertificate.ts @@ -1,6 +1,7 @@ "use client"; import html2canvas from "html2canvas"; +import { applyGujiaCertificate2026Content } from "./barcodePrintGujiaCertificate2026"; import { GUJIA_CERTIFICATE_CANVAS, getGujiaCertificateFieldStyle, @@ -104,6 +105,7 @@ const SOFIA_CARTON_TITLE = "索菲亚外箱贴纸(10cm*7cm)"; const COMMON_WITH_MAKER_TITLE = "通用横版贴纸-带供应商(10cm*7cm)"; const COMMON_WITHOUT_MAKER_TITLE = "通用横版贴纸-不带供应商(10cm*7cm)"; const GUJIA_CERTIFICATE_TITLE = "顾家合格证卡片(11cm*13cm)"; +const GUJIA_CERTIFICATE_2026_TITLE = "顾家合格证卡片2026新版(11cm*13cm)"; const CERTIFICATE_FONT_FAMILY = "Microsoft YaHei, 微软雅黑, Arial, sans-serif"; const LANDSCAPE_FIELD_TOP = 302; @@ -741,6 +743,16 @@ const CERTIFICATE_TEMPLATES: CertificateTemplate[] = [ background: "hgz_gj.jpg", render: renderGujiaCertificate, }, + { + title: GUJIA_CERTIFICATE_2026_TITLE, + pageSize: "110mm 130mm", + background: "hgz_gj.jpg", + render: (record, qrCodeBase64, options, backgroundDataUrl) => + applyGujiaCertificate2026Content( + renderGujiaCertificate(record, qrCodeBase64, options, backgroundDataUrl), + record.executionStandard, + ), + }, ]; export const CERTIFICATE_PRINT_TEMPLATE_OPTIONS = CERTIFICATE_TEMPLATES.map( diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificate2026.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificate2026.ts new file mode 100644 index 00000000..e97552b3 --- /dev/null +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificate2026.ts @@ -0,0 +1,28 @@ +import { getGujiaCertificateFieldStyle } from "./barcodePrintGujiaCertificateLayout"; + +export function applyGujiaCertificate2026Content( + baseCertificate: HTMLElement, + executionStandard: string, +): HTMLElement { + // 新版使用独立节点,保留旧版的八个字段坐标与字体。 + const certificate = baseCertificate.cloneNode(true) as HTMLElement; + certificate.className = "certificate_main_gj_2026"; + + const manufacturer = certificate.querySelector(".item_5"); + const qualityLevel = certificate.querySelector(".item_7"); + const productionDate = certificate.querySelector(".item_8"); + if (!manufacturer || !qualityLevel || !productionDate) { + throw new Error("顾家合格证基础字段不完整。"); + } + + manufacturer.textContent = "欧伯娜家居有限公司"; + qualityLevel.textContent = "合格品"; + + const standard = productionDate.cloneNode(false) as HTMLElement; + standard.className = "item_9 item"; + standard.textContent = executionStandard.trim(); + Object.assign(standard.style, getGujiaCertificateFieldStyle(8)); + certificate.appendChild(standard); + + return certificate; +} diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.test.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.test.ts index 48ded5fe..dc94237d 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.test.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.test.ts @@ -1,5 +1,6 @@ import { GUJIA_CERTIFICATE_CANVAS, + GUJIA_FIELD_PRINT_VERTICAL_OFFSET, getGujiaCertificateFieldStyle, } from "./barcodePrintGujiaCertificateLayout"; @@ -18,11 +19,12 @@ describe("Gujia certificate layout", () => { } }); - it("uses the 2026 118.5px row spacing for exactly eight fields", () => { - expect(getGujiaCertificateFieldStyle(0).top).toBe("508px"); - expect(getGujiaCertificateFieldStyle(1).top).toBe("626.5px"); - expect(getGujiaCertificateFieldStyle(2).top).toBe("745px"); - expect(getGujiaCertificateFieldStyle(7).top).toBe("1337.5px"); + it("moves all dynamic fields up 6mm while preserving the 118.5px row spacing", () => { + expect(GUJIA_FIELD_PRINT_VERTICAL_OFFSET).toBe(-72); + expect(getGujiaCertificateFieldStyle(0).top).toBe("436px"); + expect(getGujiaCertificateFieldStyle(1).top).toBe("554.5px"); + expect(getGujiaCertificateFieldStyle(2).top).toBe("673px"); + expect(getGujiaCertificateFieldStyle(7).top).toBe("1265.5px"); }); it("keeps the original Gujia canvas dimensions", () => { diff --git a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.ts b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.ts index ec88480f..6f89ccea 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.ts +++ b/furniture-3d-system/src/app/intake/barcode-print/barcodePrintGujiaCertificateLayout.ts @@ -4,7 +4,9 @@ export const GUJIA_CERTIFICATE_CANVAS = { } as const; const GUJIA_FIELD_LEFT = 474; -const GUJIA_FIELD_TOP = 508; +// 实物卡片校准:动态字段相对红色底图整体上移 6mm(1323px / 110mm)。 +export const GUJIA_FIELD_PRINT_VERTICAL_OFFSET = -72; +const GUJIA_FIELD_TOP = 508 + GUJIA_FIELD_PRINT_VERTICAL_OFFSET; const GUJIA_FIELD_ROW_GAP = 118.5; export function getGujiaCertificateFieldStyle( From 0271da1b6988655719a729caf8ab8bb639aec9b0 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Wed, 9 Sep 2026 17:54:04 +0800 Subject: [PATCH 006/184] fix(barcode-print): recalibrate Gujia card and disable 2026 template --- .../BarcodePrintTemplateDialog.test.tsx | 29 ++++++++++- .../BarcodePrintTemplateDialog.tsx | 21 ++++++-- .../barcodePrintCertificate.test.ts | 49 ++++++++++++++++++- .../barcode-print/barcodePrintCertificate.ts | 19 +++++-- ...barcodePrintGujiaCertificateLayout.test.ts | 14 +++++- .../barcodePrintGujiaCertificateLayout.ts | 10 ++-- 6 files changed, 128 insertions(+), 14 deletions(-) diff --git a/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.test.tsx b/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.test.tsx index 72e727a6..6d46748d 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.test.tsx +++ b/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.test.tsx @@ -1,8 +1,35 @@ -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { BarcodePrintTemplateDialog } from './BarcodePrintTemplateDialog' describe('BarcodePrintTemplateDialog', () => { + it('marks a disabled certificate unavailable, skips a stale default and prevents submission', async () => { + const actor = userEvent.setup() + const onSubmit = jest.fn() + const disabledTitle = '顾家合格证卡片2026新版(11cm*13cm)' + const legacyTitle = '顾家合格证卡片(11cm*13cm)' + render( + , + ) + const select = screen.getByLabelText('打印模板') + expect(screen.getByRole('option', { name: `${disabledTitle}(暂时不可用)` })).toBeDisabled() + expect(select).toHaveValue(legacyTitle) + await actor.selectOptions(select, disabledTitle) + expect(select).toHaveValue(legacyTitle) + fireEvent.change(select, { target: { value: disabledTitle } }) + await actor.click(screen.getByRole('button', { name: '确定' })) + expect(onSubmit).not.toHaveBeenCalled() + await actor.selectOptions(select, legacyTitle) + await actor.click(screen.getByRole('button', { name: '确定' })) + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ templateTitle: legacyTitle })) + }) + it('submits the selected barcode template', async () => { const actor = userEvent.setup() const onSubmit = jest.fn() diff --git a/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.tsx b/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.tsx index 5729c3c4..02c0e53d 100644 --- a/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.tsx +++ b/furniture-3d-system/src/app/intake/barcode-print/BarcodePrintTemplateDialog.tsx @@ -6,6 +6,7 @@ import type { CertificateSpecialEditMode } from "./barcodePrintCertificate"; type PrintTemplateOption = { title: string; + disabled?: boolean; editMode?: CertificateSpecialEditMode; }; @@ -36,9 +37,9 @@ export function BarcodePrintTemplateDialog({ }) { const resolvedDefaultTemplateTitle = useMemo( () => - templates.some((template) => template.title === defaultTemplateTitle) + templates.some((template) => template.title === defaultTemplateTitle && !template.disabled) ? defaultTemplateTitle ?? "" - : (templates[0]?.title ?? ""), + : (templates.find((template) => !template.disabled)?.title ?? ""), [defaultTemplateTitle, templates], ); const [templateTitle, setTemplateTitle] = useState(resolvedDefaultTemplateTitle); @@ -66,10 +67,14 @@ export function BarcodePrintTemplateDialog({ if (!isOpen) return null; function submit() { - if (!templateTitle) { + if (!selectedTemplate) { setErrorMessage("请选择打印模板。"); return; } + if (selectedTemplate.disabled) { + setErrorMessage("该模板暂时不可用。"); + return; + } onSubmit({ templateTitle, @@ -125,8 +130,13 @@ export function BarcodePrintTemplateDialog({ > {templates.map((template) => ( - ))} @@ -155,6 +165,7 @@ export function BarcodePrintTemplateDialog({ + ))} + + {granularity === 'year' ? ( + + ) : ( + setPeriod(event.target.value)} /> + )} + + + {loading ? '查询中...' : !period ? '等待同步' : !period.hasCoverage ? '该周期暂无源数据' + : `${period.start} 至 ${period.end}${period.partial ? '(截至已有数据)' : ''}`} + + {period?.availableThrough && ( + + 数据截至 {period.availableThrough} + {period.syncedAt && ` · 同步 ${new Date(period.syncedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}`} + {period.lastAttemptStatus === 'FAILED' && ' · 最近同步失败'} + {period.lastAttemptStatus === 'RUNNING' && ' · 同步中'} + + )} + + + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveView.tsx b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveView.tsx index 9aa53330..58b20572 100644 --- a/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveView.tsx +++ b/furniture-3d-system/src/app/supply-chain/database/product-archive/ProductArchiveView.tsx @@ -20,6 +20,7 @@ import { useProductArchiveImageExport } from './useProductArchiveImageExport' import { useProductArchiveExport } from './useProductArchiveExport' import { ProductArchiveExportProgress } from './ProductArchiveExportProgress' import { ProductArchiveThemeStyles } from './ProductArchiveThemeStyles' +import { ProductArchiveSalesPeriodFilter } from './ProductArchiveSalesPeriodFilter' import type { ProductArchiveFilters, SupplyChainProductArchiveRow, @@ -44,7 +45,7 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool const [detailRow, setDetailRow] = useState(null) const [changeLogBarcode, setChangeLogBarcode] = useState(null) const queryFiltersAppliedRef = useRef(false) - const { rows, pageInfo, loading, error, reload } = useProductArchiveData( + const { rows, pageInfo, salesPeriod, loading, error, reload } = useProductArchiveData( currentPage, pageSize, filters, @@ -96,8 +97,9 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool })), [columnSettings.settings], ) - const activeFilterCount = Object.values(filters).filter((value) => - Array.isArray(value) ? value.length > 0 : Boolean(value), + const activeFilterCount = Object.entries(filters).filter(([key, value]) => + !['salesGranularity', 'salesPeriod'].includes(key) + && (Array.isArray(value) ? value.length > 0 : Boolean(value)), ).length const { exporting, exportImages } = useProductArchiveImageExport(filters) const { @@ -144,7 +146,10 @@ export function ProductArchiveView({ canDownload = false }: { canDownload?: bool + + 重置 + + + +
+
+ + + {COLUMNS.map((column) => ( + + ))} + + + + {COLUMNS.map((column) => ( + + ))} + + + + {data.rows.length === 0 ? ( + + + + ) : data.rows.map((row) => ( + + + + + + + + + + + + + + + + + + ))} + +
+ + {column.label} + {'filterField' in column && ( + + )} + +
当前筛选条件下没有商品
+ + {moneyText(row.realPerformance)} + + {moneyText(row.costPrice)}{row.shipQty ? integerText(row.shipQty) : '--'}{moneyText(row.afterSaleAmount)}{percentText(row.afterSaleCostRate)}{row.skuOperator || '--'}{row.spu || '--'}{row.sku || '--'}{row.barcode}{row.color || '--'}{moneyText(row.salesAmount)}{moneyText(row.grossProfit)}{percentText(row.grossMargin)}
+
+
+ + + + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/new-products/1688-dropship-data/sales-trend-periods.test.ts b/furniture-3d-system/src/app/supply-chain/new-products/1688-dropship-data/sales-trend-periods.test.ts new file mode 100644 index 00000000..4fff1565 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/new-products/1688-dropship-data/sales-trend-periods.test.ts @@ -0,0 +1,331 @@ +import { + dailyMonthComparisons, + dailyPointsBetween, + monthlyComparisons, + priorMonthPeriodComparison, + weeklyComparisons, + type DailyPoint, +} from './sales-trend-periods' + +describe('dailyPointsBetween', () => { + it('returns ordered days in the inclusive range, filling missing dates without changing input', () => { + const trend = [ + { date: '2026-09-03', value: '30.25' }, + { date: '2026-08-31', value: '999' }, + { date: '2026-09-01', value: '-10.25' }, + ] + const original = trend.map((point) => ({ ...point })) + expect(dailyPointsBetween(trend, '2026-09-01', '2026-09-03')).toEqual([ + { date: '2026-09-01', value: '-10.25' }, + { date: '2026-09-02', value: '0' }, + { date: '2026-09-03', value: '30.25' }, + ]) + expect(trend).toEqual(original) + }) + + it('fills an empty single-day range and includes leap day across a month boundary', () => { + expect(dailyPointsBetween([], '2026-09-01', '2026-09-01')).toEqual([{ date: '2026-09-01', value: '0' }]) + expect(dailyPointsBetween([], '2024-02-28', '2024-03-01').map((point) => point.date)).toEqual([ + '2024-02-28', '2024-02-29', '2024-03-01', + ]) + }) +}) + +describe('dailyMonthComparisons', () => { + it('compares daily values with the same date last month and fills either missing side with zero', () => { + expect(dailyMonthComparisons([ + { date: '2026-08-01', value: '20' }, + { date: '2026-08-02', value: '40' }, + { date: '2026-09-01', value: '30' }, + { date: '2026-09-03', value: '10' }, + ], '2026-09-01', '2026-09-04')).toEqual([ + { date: '2026-09-01', value: 30, previousDate: '2026-08-01', previousValue: 20, change: 10, changeRate: 0.5 }, + { date: '2026-09-02', value: 0, previousDate: '2026-08-02', previousValue: 40, change: -40, changeRate: -1 }, + { date: '2026-09-03', value: 10, previousDate: '2026-08-03', previousValue: 0, change: 10, changeRate: null }, + { date: '2026-09-04', value: 0, previousDate: '2026-08-04', previousValue: 0, change: 0, changeRate: null }, + ]) + }) + + it.each([ + ['2026-03-28', '2026-04-01', ['2026-02-28', '2026-02-28', '2026-02-28', '2026-02-28', '2026-03-01']], + ['2024-03-28', '2024-04-01', ['2024-02-28', '2024-02-29', '2024-02-29', '2024-02-29', '2024-03-01']], + ['2024-02-29', '2024-03-01', ['2024-01-29', '2024-02-01']], + ['2025-12-31', '2026-01-02', ['2025-11-30', '2025-12-01', '2025-12-02']], + ])('maps each date independently across month lengths, leap years, and years for %s–%s', (from, to, previousDates) => { + expect(dailyMonthComparisons([], from, to).map((point) => point.previousDate)).toEqual(previousDates) + }) + + it('preserves negative values and uses the absolute previous value for the rate', () => { + const history = [ + { date: '2026-08-01', value: '-20.50' }, + { date: '2026-09-01', value: '-10.25' }, + ] + const original = history.map((point) => ({ ...point })) + expect(dailyMonthComparisons(history, '2026-09-01', '2026-09-01')).toEqual([ + { date: '2026-09-01', value: -10.25, previousDate: '2026-08-01', previousValue: -20.5, change: 10.25, changeRate: 0.5 }, + ]) + expect(history).toEqual(original) + }) +}) + +describe('priorMonthPeriodComparison', () => { + it('sums the same date interval last month, keeping missing dates at zero and excluding adjacent dates', () => { + expect(priorMonthPeriodComparison([ + { date: '2026-07-31', value: '999' }, + { date: '2026-08-01', value: '20' }, + { date: '2026-08-07', value: '30' }, + { date: '2026-08-08', value: '999' }, + { date: '2026-09-01', value: '999' }, + ], '2026-09-01', '2026-09-07')).toEqual({ + ranges: [{ dateFrom: '2026-08-01', dateTo: '2026-08-07' }], value: 50, + }) + }) + + it('splits February-to-March ranges without counting the unused January tail', () => { + expect(priorMonthPeriodComparison([ + { date: '2026-01-28', value: '10' }, + { date: '2026-01-29', value: '1000' }, + { date: '2026-01-30', value: '2000' }, + { date: '2026-01-31', value: '3000' }, + { date: '2026-02-01', value: '20' }, + ], '2026-02-28', '2026-03-01')).toEqual({ + ranges: [ + { dateFrom: '2026-01-28', dateTo: '2026-01-28' }, + { dateFrom: '2026-02-01', dateTo: '2026-02-01' }, + ], + value: 30, + }) + }) + + it('counts a month-end historical date only once when multiple selected days clamp to it', () => { + expect(priorMonthPeriodComparison([ + { date: '2026-02-27', value: '1000' }, + { date: '2026-02-28', value: '15' }, + { date: '2026-03-01', value: '25' }, + ], '2026-03-29', '2026-04-01')).toEqual({ + ranges: [ + { dateFrom: '2026-02-28', dateTo: '2026-02-28' }, + { dateFrom: '2026-03-01', dateTo: '2026-03-01' }, + ], + value: 40, + }) + }) + + it('includes leap day once when the previous month is leap-year February', () => { + expect(priorMonthPeriodComparison([ + { date: '2024-02-28', value: '10' }, + { date: '2024-02-29', value: '20' }, + ], '2024-03-28', '2024-03-31')).toEqual({ + ranges: [{ dateFrom: '2024-02-28', dateTo: '2024-02-29' }], value: 30, + }) + }) + + it('maps year-crossing segments independently', () => { + expect(priorMonthPeriodComparison([ + { date: '2025-11-30', value: '10' }, + { date: '2025-12-01', value: '20' }, + { date: '2025-12-02', value: '30' }, + ], '2025-12-31', '2026-01-02')).toEqual({ + ranges: [ + { dateFrom: '2025-11-30', dateTo: '2025-11-30' }, + { dateFrom: '2025-12-01', dateTo: '2025-12-02' }, + ], + value: 60, + }) + }) + + it('preserves negative values and returns zero when every historical day is absent', () => { + const history = [{ date: '2026-08-01', value: '-10.25' }, { date: '2026-08-03', value: '0' }] + const original = history.map((point) => ({ ...point })) + expect(priorMonthPeriodComparison(history, '2026-09-01', '2026-09-03')).toEqual({ + ranges: [{ dateFrom: '2026-08-01', dateTo: '2026-08-03' }], value: -10.25, + }) + expect(priorMonthPeriodComparison([], '2026-09-01', '2026-09-03')).toEqual({ + ranges: [{ dateFrom: '2026-08-01', dateTo: '2026-08-03' }], value: 0, + }) + expect(history).toEqual(original) + }) +}) + +describe('weeklyComparisons', () => { + it('groups by Monday through Sunday and compares midweek selection boundaries with the same prior weekdays', () => { + const trend = [ + { date: '2026-08-26', value: '5' }, + { date: '2026-09-02', value: '20' }, + { date: '2026-09-03', value: '30' }, + { date: '2026-09-04', value: '100' }, + { date: '2026-09-09', value: '25' }, + { date: '2026-09-10', value: '50' }, + ] + expect(weeklyComparisons(trend, '2026-09-02', '2026-09-10')).toEqual([ + { currentFrom: '2026-08-31', currentTo: '2026-09-06', actualFrom: '2026-09-02', actualTo: '2026-09-06', previousFrom: '2026-08-26', previousTo: '2026-08-30', currentValue: 150, previousValue: 5, change: 145, changeRate: 29 }, + { currentFrom: '2026-09-07', currentTo: '2026-09-13', actualFrom: '2026-09-07', actualTo: '2026-09-10', previousFrom: '2026-08-31', previousTo: '2026-09-03', currentValue: 75, previousValue: 50, change: 25, changeRate: 0.5 }, + ]) + }) + + it('handles a short week across the year boundary', () => { + expect(weeklyComparisons([ + { date: '2025-12-24', value: '10' }, + { date: '2026-01-02', value: '40' }, + ], '2025-12-31', '2026-01-02')).toEqual([ + { currentFrom: '2025-12-29', currentTo: '2026-01-04', actualFrom: '2025-12-31', actualTo: '2026-01-02', previousFrom: '2025-12-24', previousTo: '2025-12-26', currentValue: 40, previousValue: 10, change: 30, changeRate: 3 }, + ]) + }) + + it('keeps complete Monday-to-Sunday weeks without partial-range properties', () => { + const result = weeklyComparisons([ + { date: '2026-08-24', value: '10' }, + { date: '2026-08-30', value: '20' }, + { date: '2026-08-31', value: '40' }, + { date: '2026-09-06', value: '50' }, + ], '2026-08-31', '2026-09-06') + expect(result).toEqual([ + { currentFrom: '2026-08-31', currentTo: '2026-09-06', previousFrom: '2026-08-24', previousTo: '2026-08-30', currentValue: 90, previousValue: 30, change: 60, changeRate: 2 }, + ]) + expect(result[0]).not.toHaveProperty('actualFrom') + expect(result[0]).not.toHaveProperty('actualTo') + }) + + it.each([ + ['2026-09-06', '2026-08-31', '2026-09-06', '2026-08-30'], + ['2026-09-07', '2026-09-07', '2026-09-13', '2026-08-31'], + ])('places a single selected day %s in its correct natural week', (selectedDate, currentFrom, currentTo, previousDate) => { + expect(weeklyComparisons([ + { date: previousDate, value: '10' }, + { date: selectedDate, value: '25' }, + ], selectedDate, selectedDate)).toEqual([ + { currentFrom, currentTo, actualFrom: selectedDate, actualTo: selectedDate, previousFrom: previousDate, previousTo: previousDate, currentValue: 25, previousValue: 10, change: 15, changeRate: 1.5 }, + ]) + }) + + it('splits a Sunday-to-Monday selection into two natural weeks', () => { + expect(weeklyComparisons([], '2026-09-06', '2026-09-07').map((period) => [ + period.currentFrom, period.currentTo, period.actualFrom, period.actualTo, + ])).toEqual([ + ['2026-08-31', '2026-09-06', '2026-09-06', '2026-09-06'], + ['2026-09-07', '2026-09-13', '2026-09-07', '2026-09-07'], + ]) + }) + + it('only includes selected dates even when trend contains data outside the selected week boundaries', () => { + const trend = [ + { date: '2026-08-25', value: '10' }, + { date: '2026-08-30', value: '20' }, + { date: '2026-08-31', value: '1000' }, + { date: '2026-09-01', value: '30' }, + { date: '2026-09-06', value: '40' }, + { date: '2026-09-07', value: '50' }, + { date: '2026-09-08', value: '2000' }, + ] + const result = weeklyComparisons(trend, '2026-09-01', '2026-09-07') + expect(result).toEqual([ + { currentFrom: '2026-08-31', currentTo: '2026-09-06', actualFrom: '2026-09-01', actualTo: '2026-09-06', previousFrom: '2026-08-25', previousTo: '2026-08-30', currentValue: 70, previousValue: 30, change: 40, changeRate: 40 / 30 }, + { currentFrom: '2026-09-07', currentTo: '2026-09-13', actualFrom: '2026-09-07', actualTo: '2026-09-07', previousFrom: '2026-08-31', previousTo: '2026-08-31', currentValue: 50, previousValue: 1000, change: -950, changeRate: -0.95 }, + ]) + expect(result.reduce((sum, period) => sum + period.currentValue, 0)).toBe(120) + }) + + it('preserves selected totals across full and partial weeks with missing dates', () => { + const trend = [ + { date: '2026-09-01', value: '1000' }, + { date: '2026-09-02', value: '10' }, + { date: '2026-09-06', value: '20' }, + { date: '2026-09-07', value: '30' }, + { date: '2026-09-13', value: '-5' }, + { date: '2026-09-14', value: '8' }, + { date: '2026-09-20', value: '7' }, + { date: '2026-09-22', value: '25' }, + { date: '2026-09-23', value: '2000' }, + ] + const result = weeklyComparisons(trend, '2026-09-02', '2026-09-22') + const dailyTotal = dailyPointsBetween(trend, '2026-09-02', '2026-09-22') + .reduce((sum, point) => sum + Number(point.value), 0) + expect(result.map((period) => period.currentValue)).toEqual([30, 25, 15, 25]) + expect(result.reduce((sum, period) => sum + period.currentValue, 0)).toBe(dailyTotal) + expect(result[1]).not.toHaveProperty('actualFrom') + expect(result[2]).not.toHaveProperty('actualTo') + expect(result.every((period) => new Date(`${period.currentFrom}T00:00:00Z`).getUTCDay() === 1)).toBe(true) + expect(result.every((period) => new Date(`${period.currentTo}T00:00:00Z`).getUTCDay() === 0)).toBe(true) + }) +}) + +describe('monthlyComparisons', () => { + it('compares month-to-date with the same dates of the previous month', () => { + expect(monthlyComparisons([ + { date: '2026-08-01', value: '10' }, + { date: '2026-08-07', value: '20' }, + { date: '2026-08-08', value: '999' }, + { date: '2026-09-01', value: '45' }, + ], '2026-09-01', '2026-09-07')).toEqual([ + { currentFrom: '2026-09-01', currentTo: '2026-09-07', previousFrom: '2026-08-01', previousTo: '2026-08-07', currentValue: 45, previousValue: 30, change: 15, changeRate: 0.5 }, + ]) + }) + + it.each([ + ['2026-09-01', '2026-09-30', '2026-08-01', '2026-08-31'], + ['2026-02-01', '2026-02-28', '2026-01-01', '2026-01-31'], + ['2024-02-01', '2024-02-29', '2024-01-01', '2024-01-31'], + ['2026-01-01', '2026-01-31', '2025-12-01', '2025-12-31'], + ])('compares the complete month %s–%s with the complete previous month', (from, to, previousFrom, previousTo) => { + expect(monthlyComparisons([{ date: previousTo, value: '31' }], from, to)).toEqual([ + { currentFrom: from, currentTo: to, previousFrom, previousTo, currentValue: 0, previousValue: 31, change: -31, changeRate: -1 }, + ]) + }) + + it.each([ + ['2026-03-20', '2026-03-31', '2026-02-20', '2026-02-28'], + ['2024-03-20', '2024-03-31', '2024-02-20', '2024-02-29'], + ['2026-03-30', '2026-03-31', '2026-02-28', '2026-02-28'], + ])('clamps the previous-month dates for %s–%s to the actual month end', (from, to, previousFrom, previousTo) => { + expect(monthlyComparisons([{ date: previousTo, value: '12' }], from, to)).toEqual([ + { currentFrom: from, currentTo: to, previousFrom, previousTo, currentValue: 0, previousValue: 12, change: -12, changeRate: -1 }, + ]) + }) + + it('splits a cross-month selection into partial and full natural months', () => { + expect(monthlyComparisons([], '2026-01-20', '2026-03-07').map((period) => [ + period.currentFrom, period.currentTo, period.previousFrom, period.previousTo, + ])).toEqual([ + ['2026-01-20', '2026-01-31', '2025-12-20', '2025-12-31'], + ['2026-02-01', '2026-02-28', '2026-01-01', '2026-01-31'], + ['2026-03-01', '2026-03-07', '2026-02-01', '2026-02-07'], + ]) + }) +}) + +describe.each([ + ['weekly', weeklyComparisons, '2026-08-25'], + ['monthly', monthlyComparisons, '2026-08-01'], +] as const)('%s comparison values', (_label, compare, previousDate) => { + it('keeps negative performance values and uses the absolute prior value as the rate denominator', () => { + const result = compare([ + { date: previousDate, value: '-20.50' }, + { date: '2026-09-01', value: '-10.25' }, + ], '2026-09-01', '2026-09-01') + expect(result[0]).toMatchObject({ currentValue: -10.25, previousValue: -20.5, change: 10.25, changeRate: 0.5 }) + }) + + it('returns no percentage when the prior period sums to zero', () => { + const trend = [{ date: '2026-09-01', value: '10' }] + expect(compare(trend, '2026-09-01', '2026-09-01')[0]).toMatchObject({ currentValue: 10, previousValue: 0, change: 10, changeRate: null }) + expect(compare([], '2026-09-01', '2026-09-01')[0]).toMatchObject({ currentValue: 0, previousValue: 0, change: 0, changeRate: null }) + }) +}) + +describe('date range validation', () => { + const trend: DailyPoint[] = [{ date: '2026-09-01', value: '10' }] + it.each([ + ['', '2026-09-01'], + ['2026-09-01', 'invalid'], + ['2026-09-02', '2026-09-01'], + ['2026-02-29', '2026-03-01'], + ['2026-09-01', '2026-09-31'], + ['2026-9-1', '2026-09-07'], + ])('does not calculate invalid or reversed range %s–%s', (from, to) => { + expect(dailyPointsBetween(trend, from, to)).toBe(trend) + expect(weeklyComparisons(trend, from, to)).toEqual([]) + expect(monthlyComparisons(trend, from, to)).toEqual([]) + expect(dailyMonthComparisons(trend, from, to)).toEqual([]) + expect(priorMonthPeriodComparison(trend, from, to)).toEqual({ ranges: [], value: 0 }) + }) +}) diff --git a/furniture-3d-system/src/app/supply-chain/new-products/1688-dropship-data/sales-trend-periods.ts b/furniture-3d-system/src/app/supply-chain/new-products/1688-dropship-data/sales-trend-periods.ts new file mode 100644 index 00000000..5c977857 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/new-products/1688-dropship-data/sales-trend-periods.ts @@ -0,0 +1,226 @@ +export type DailyPoint = { date: string; value: string } + +export type DailyMonthComparison = { + date: string + value: number + previousDate: string + previousValue: number + change: number + changeRate: number | null +} + +export type PriorMonthPeriodComparison = { + ranges: Array<{ dateFrom: string; dateTo: string }> + value: number +} + +export type PeriodComparison = { + currentFrom: string + currentTo: string + actualFrom?: string + actualTo?: string + previousFrom: string + previousTo: string + currentValue: number + previousValue: number + change: number + changeRate: number | null +} + +function dateText(date: Date) { + return date.toISOString().slice(0, 10) +} + +function dateAtUtc(value: string) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null + const date = new Date(`${value}T00:00:00.000Z`) + return Number.isNaN(date.getTime()) || dateText(date) !== value ? null : date +} + +function addUtcDays(date: Date, days: number) { + const result = new Date(date) + result.setUTCDate(result.getUTCDate() + days) + return result +} + +function monthEnd(date: Date) { + const result = new Date(date) + result.setUTCMonth(result.getUTCMonth() + 1, 0) + return result +} + +function priorMonthDate(date: Date) { + const result = new Date(date) + result.setUTCDate(1) + result.setUTCMonth(result.getUTCMonth() - 1) + result.setUTCDate(Math.min(date.getUTCDate(), monthEnd(result).getUTCDate())) + return result +} + +export function dailyPointsBetween(trend: DailyPoint[], dateFrom: string, dateTo: string): DailyPoint[] { + const start = dateAtUtc(dateFrom) + const end = dateAtUtc(dateTo) + if (!start || !end || start > end) return trend + + const valuesByDate = new Map(trend.map((point) => [point.date, point.value] as const)) + const points: DailyPoint[] = [] + for (let date = start; date <= end; date = addUtcDays(date, 1)) { + const text = dateText(date) + points.push({ date: text, value: valuesByDate.get(text) ?? '0' }) + } + return points +} + +export function dailyMonthComparisons( + history: DailyPoint[], + dateFrom: string, + dateTo: string, +): DailyMonthComparison[] { + const start = dateAtUtc(dateFrom) + const end = dateAtUtc(dateTo) + if (!start || !end || start > end) return [] + + const valuesByDate = new Map(history.map((point) => [point.date, Number(point.value)] as const)) + const comparisons: DailyMonthComparison[] = [] + for (let date = start; date <= end; date = addUtcDays(date, 1)) { + const currentDate = dateText(date) + const previousDate = dateText(priorMonthDate(date)) + const value = valuesByDate.get(currentDate) ?? 0 + const previousValue = valuesByDate.get(previousDate) ?? 0 + const change = value - previousValue + comparisons.push({ + date: currentDate, + value, + previousDate, + previousValue, + change, + changeRate: previousValue === 0 ? null : change / Math.abs(previousValue), + }) + } + return comparisons +} + +export function priorMonthPeriodComparison( + history: DailyPoint[], + dateFrom: string, + dateTo: string, +): PriorMonthPeriodComparison { + const start = dateAtUtc(dateFrom) + const end = dateAtUtc(dateTo) + const result: PriorMonthPeriodComparison = { ranges: [], value: 0 } + if (!start || !end || start > end) return result + + const valuesByDate = new Map(history.map((point) => [point.date, Number(point.value)] as const)) + const countedDates = new Set() + for (let currentFrom = start; currentFrom <= end;) { + const currentMonthEnd = monthEnd(currentFrom) + const currentTo = currentMonthEnd < end ? currentMonthEnd : end + const previousFrom = priorMonthDate(currentFrom) + const previousTo = priorMonthDate(currentTo) + result.ranges.push({ dateFrom: dateText(previousFrom), dateTo: dateText(previousTo) }) + // Month boundaries can leave gaps; sum each mapped segment instead of its enclosing range. + for (let date = previousFrom; date <= previousTo; date = addUtcDays(date, 1)) { + const text = dateText(date) + if (!countedDates.has(text)) { + result.value += valuesByDate.get(text) ?? 0 + countedDates.add(text) + } + } + currentFrom = addUtcDays(currentTo, 1) + } + return result +} + +function periodComparison( + valuesByDate: Map, + currentFrom: Date, + currentTo: Date, + previousFrom: Date, + previousTo: Date, +): PeriodComparison { + const sumBetween = (from: Date, to: Date) => { + let total = 0 + for (let date = from; date <= to; date = addUtcDays(date, 1)) { + total += valuesByDate.get(dateText(date)) ?? 0 + } + return total + } + const currentValue = sumBetween(currentFrom, currentTo) + const previousValue = sumBetween(previousFrom, previousTo) + const change = currentValue - previousValue + return { + currentFrom: dateText(currentFrom), + currentTo: dateText(currentTo), + previousFrom: dateText(previousFrom), + previousTo: dateText(previousTo), + currentValue, + previousValue, + change, + changeRate: previousValue === 0 ? null : change / Math.abs(previousValue), + } +} + +export function weeklyComparisons( + trend: DailyPoint[], + dateFrom: string, + dateTo: string, +): PeriodComparison[] { + const start = dateAtUtc(dateFrom) + const end = dateAtUtc(dateTo) + if (!start || !end || start > end) return [] + + const valuesByDate = new Map(trend.map((point) => [point.date, Number(point.value)] as const)) + const comparisons: PeriodComparison[] = [] + const firstMonday = addUtcDays(start, -((start.getUTCDay() + 6) % 7)) + for (let currentFrom = firstMonday; currentFrom <= end; currentFrom = addUtcDays(currentFrom, 7)) { + const currentTo = addUtcDays(currentFrom, 6) + const actualFrom = currentFrom < start ? start : currentFrom + const actualTo = currentTo > end ? end : currentTo + const comparison = periodComparison( + valuesByDate, + actualFrom, + actualTo, + addUtcDays(actualFrom, -7), + addUtcDays(actualTo, -7), + ) + const isPartialWeek = actualFrom.getTime() !== currentFrom.getTime() || actualTo.getTime() !== currentTo.getTime() + comparisons.push({ + ...comparison, + currentFrom: dateText(currentFrom), + currentTo: dateText(currentTo), + ...(isPartialWeek ? { actualFrom: dateText(actualFrom), actualTo: dateText(actualTo) } : {}), + }) + } + return comparisons +} + +export function monthlyComparisons( + trend: DailyPoint[], + dateFrom: string, + dateTo: string, +): PeriodComparison[] { + const start = dateAtUtc(dateFrom) + const end = dateAtUtc(dateTo) + if (!start || !end || start > end) return [] + + const valuesByDate = new Map(trend.map((point) => [point.date, Number(point.value)] as const)) + const comparisons: PeriodComparison[] = [] + for (let currentFrom = start; currentFrom <= end;) { + const currentMonthEnd = monthEnd(currentFrom) + const currentTo = currentMonthEnd < end ? currentMonthEnd : end + const previousMonth = new Date(currentFrom) + previousMonth.setUTCDate(1) + previousMonth.setUTCMonth(previousMonth.getUTCMonth() - 1) + const previousMonthEnd = monthEnd(previousMonth) + const previousFrom = new Date(previousMonth) + previousFrom.setUTCDate(Math.min(currentFrom.getUTCDate(), previousMonthEnd.getUTCDate())) + const previousTo = new Date(previousMonth) + const isFullMonth = currentFrom.getUTCDate() === 1 && currentTo.getTime() === currentMonthEnd.getTime() + previousTo.setUTCDate(isFullMonth + ? previousMonthEnd.getUTCDate() + : Math.min(currentTo.getUTCDate(), previousMonthEnd.getUTCDate())) + comparisons.push(periodComparison(valuesByDate, currentFrom, currentTo, previousFrom, previousTo)) + currentFrom = addUtcDays(currentTo, 1) + } + return comparisons +} diff --git a/furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/page.tsx b/furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/page.tsx new file mode 100644 index 00000000..93e2a3a0 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/page.tsx @@ -0,0 +1,38 @@ +import { ClipboardList } from 'lucide-react' +import { redirect } from 'next/navigation' +import { requireSession } from '@/lib/server-session' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' + +export const metadata = { title: '1688代发异常事项 | 供应链中心' } + +export default async function DropshipIssuesPage() { + const { authUser } = await requireSession('/login') + const access = await supplyChainPermissionSettingsService.getUserAccess( + authUser, + 'newProductDropshipIssues', + ) + if (!access.view) redirect('/supply-chain') + + return ( +
+
+ + + +
+

+ 1688代发异常事项 +

+

1688代发异常事项的记录与跟进处理

+
+
+ +
+

功能建设中

+

+ 1688代发异常事项模块正在设计中,上线后将在这里记录和跟进代发过程中的异常事项。 +

+
+
+ ) +} diff --git a/furniture-3d-system/src/lib/dropship-1688-filters.test.ts b/furniture-3d-system/src/lib/dropship-1688-filters.test.ts new file mode 100644 index 00000000..2f79076b --- /dev/null +++ b/furniture-3d-system/src/lib/dropship-1688-filters.test.ts @@ -0,0 +1,20 @@ +import { parseDropship1688Filters, serializeDropship1688Filters } from './dropship-1688-filters' + +describe('1688 column filter serialization', () => { + it('retains no-selection and exact empty/whitespace values while deduplicating', () => { + expect(parseDropship1688Filters(JSON.stringify({ + sku: { mode: 'include', values: [] }, + spu: { mode: 'include', values: [' A ', '', ' A '] }, + }))).toEqual({ sku: { mode: 'include', values: [] }, spu: { mode: 'include', values: ['', ' A '] } }) + }) + + it('omits unrestricted selections and preserves all-except values', () => { + expect(serializeDropship1688Filters({ sku: { mode: 'exclude', values: [] } })).toBe('') + const filters = { barcode: { mode: 'exclude' as const, values: ['DF-1'] } } + expect(parseDropship1688Filters(serializeDropship1688Filters(filters))).toEqual(filters) + }) + + it.each(['{', 'null', '{"sku":{"mode":"include","values":[3]}}', '{"unknown":{}}'])('rejects malformed filter values: %s', (input) => { + expect(parseDropship1688Filters(input)).toEqual({}) + }) +}) diff --git a/furniture-3d-system/src/lib/dropship-1688-filters.ts b/furniture-3d-system/src/lib/dropship-1688-filters.ts new file mode 100644 index 00000000..f091d713 --- /dev/null +++ b/furniture-3d-system/src/lib/dropship-1688-filters.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' + +export const FILTER_FIELDS = ['skuOperator', 'spu', 'sku', 'barcode'] as const +export type Dropship1688FilterField = typeof FILTER_FIELDS[number] +export const DROPSHIP1688_FILTER_MAX_BYTES = 900_000 + +const columnFilterSchema = z.object({ + mode: z.enum(['include', 'exclude']), + values: z.array(z.string().max(2048)).max(100_000), +}).strict() + +export const dropship1688FiltersSchema = z.object({ + skuOperator: columnFilterSchema.optional(), + spu: columnFilterSchema.optional(), + sku: columnFilterSchema.optional(), + barcode: columnFilterSchema.optional(), +}).strict() + +export type Dropship1688Filters = z.infer + +export function normalizeDropship1688Filters(filters: Dropship1688Filters): Dropship1688Filters { + const normalized: Dropship1688Filters = {} + for (const field of FILTER_FIELDS) { + const filter = filters[field] + if (!filter) continue + const values = [...new Set(filter.values)].sort() + if (filter.mode === 'exclude' && values.length === 0) continue + normalized[field] = { mode: filter.mode, values } + } + return normalized +} + +export function parseDropship1688Filters(value: string | null | undefined): Dropship1688Filters { + if (!value) return {} + try { + const parsed = dropship1688FiltersSchema.safeParse(JSON.parse(value)) + return parsed.success ? normalizeDropship1688Filters(parsed.data) : {} + } catch { + return {} + } +} + +export function serializeDropship1688Filters(filters: Dropship1688Filters): string { + const normalized = normalizeDropship1688Filters(filters) + return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : '' +} diff --git a/furniture-3d-system/src/lib/supply-chain-permission-settings.test.ts b/furniture-3d-system/src/lib/supply-chain-permission-settings.test.ts index ecdad3bc..67529112 100644 --- a/furniture-3d-system/src/lib/supply-chain-permission-settings.test.ts +++ b/furniture-3d-system/src/lib/supply-chain-permission-settings.test.ts @@ -100,6 +100,15 @@ describe('supply chain permission settings', () => { }) it('maps new product pages to their permission nodes', () => { + expect( + getSupplyChainPermissionTargetForPath('/supply-chain/new-products/1688-dropship-data'), + ).toBe('newProduct1688DropshipData') + expect( + getSupplyChainApiPermissionRequirement('/api/supply-chain/1688-dropship-data/filter-options', 'GET'), + ).toEqual({ targetKey: 'newProduct1688DropshipData', action: 'view' }) + expect( + getSupplyChainApiPermissionRequirement('/api/supply-chain/1688-dropship-data/filter-options', 'POST'), + ).toEqual({ targetKey: 'newProduct1688DropshipData', action: 'view' }) expect( getSupplyChainPermissionTargetForPath('/supply-chain/new-products/overview'), ).toBe('newProductOverview') @@ -129,6 +138,17 @@ describe('supply chain permission settings', () => { ).toEqual({ targetKey: 'scriptMonitor', action: 'view' }) }) + it('assigns only the dedicated DF image endpoint to the 1688 module', () => { + expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/1688-dropship-data/source-image', 'GET')) + .toEqual({ targetKey: 'newProduct1688DropshipData', action: 'view' }) + expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/1688-dropship-data/source-image', 'POST')) + .toEqual({ targetKey: 'newProduct1688DropshipData', action: 'edit' }) + expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/1688-dropship-data/source-image/other', 'GET')) + .toBeUndefined() + expect(getSupplyChainApiPermissionRequirement('/api/supply-chain/product-archive/source-image', 'GET')) + .toEqual({ targetKey: 'productArchive', action: 'view' }) + }) + it('keeps newly registered pages closed until a role or account opens them', () => { expect( resolveSupplyChainPermissionAccess({}, 'newProductOverview', ['design']), diff --git a/furniture-3d-system/src/lib/supply-chain-permission-settings.ts b/furniture-3d-system/src/lib/supply-chain-permission-settings.ts index eb572545..cc0824ce 100644 --- a/furniture-3d-system/src/lib/supply-chain-permission-settings.ts +++ b/furniture-3d-system/src/lib/supply-chain-permission-settings.ts @@ -26,6 +26,7 @@ export type SupplyChainPermissionTargetKey = | 'warehouseConfig' | 'storeAccountManagement' | 'newProductOverview' + | 'newProduct1688DropshipData' | 'newProductTerminationFeedback' | 'newProductUnifiedTable' | 'newProductSampleProgress' @@ -34,6 +35,7 @@ export type SupplyChainPermissionTargetKey = | 'purchaseDailyAffairs' | 'purchasePriceChange' | 'scriptMonitor' + | 'newProductDropshipIssues' | 'operationLogs' export type SupplyChainPermissionSubject = Omit< @@ -81,6 +83,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [ { key: 'warehouseConfig', label: '仓库配置', description: '出入库仓库维护' }, { key: 'storeAccountManagement', label: '店铺账号管理', description: '店铺账号人工维护与权限控制' }, { key: 'newProductOverview', label: '新品经营总览', description: '新品经营指标和阶段时效分析' }, + { key: 'newProduct1688DropshipData', label: '1688代发数据明细', description: '1688 导单店铺销售、履约和退款明细' }, { key: 'newProductTerminationFeedback', label: '新品终止任务反馈', description: '维护已撤销、已拒绝和已终止流程的原因' }, { key: 'newProductUnifiedTable', label: '新品开发进度总表', description: '新品流程公共数据源' }, { key: 'newProductSampleProgress', label: '打样进度明细', description: '新品打样进度公共数据源' }, @@ -89,6 +92,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [ { key: 'purchaseDailyAffairs', label: '日常事项', description: '采购日常协作' }, { key: 'purchasePriceChange', label: '产品变价', description: '采购价格变更协作' }, { key: 'scriptMonitor', label: '脚本监控', description: '跨机器脚本任务、状态和运行结果' }, + { key: 'newProductDropshipIssues', label: '1688代发异常事项', description: '1688代发异常事项' }, { key: 'operationLogs', label: '操作日志', description: '供应链操作审计' }, ] @@ -106,6 +110,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES: Partial< warehouseConfig: '/supply-chain/database/warehouse-config', storeAccountManagement: '/supply-chain/database/store-account-management', newProductOverview: '/supply-chain/new-products/overview', + newProduct1688DropshipData: '/supply-chain/new-products/1688-dropship-data', newProductTerminationFeedback: '/supply-chain/new-products/termination-feedback', newProductUnifiedTable: '/supply-chain/new-products/unified-table', newProductSampleProgress: '/supply-chain/new-products/sample-progress', @@ -114,6 +119,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES: Partial< purchaseDailyAffairs: '/supply-chain/purchase/daily-affairs', purchasePriceChange: '/supply-chain/purchase/price-change', scriptMonitor: '/supply-chain/script-monitor', + newProductDropshipIssues: '/supply-chain/new-products/dropship-issues', operationLogs: '/supply-chain/operation-logs', } @@ -321,6 +327,7 @@ export function getSupplyChainPermissionTargetForPath(pathname: string) { if (pathname === '/supply-chain/database/warehouse-config') return 'warehouseConfig' as const if (pathname === '/supply-chain/database/store-account-management') return 'storeAccountManagement' as const if (pathname === '/supply-chain/new-products/overview') return 'newProductOverview' as const + if (pathname === '/supply-chain/new-products/1688-dropship-data') return 'newProduct1688DropshipData' as const if (pathname === '/supply-chain/new-products/termination-feedback') return 'newProductTerminationFeedback' as const if (pathname === '/supply-chain/new-products/unified-table') return 'newProductUnifiedTable' as const if (pathname === '/supply-chain/new-products/sample-progress') return 'newProductSampleProgress' as const @@ -330,6 +337,7 @@ export function getSupplyChainPermissionTargetForPath(pathname: string) { if (pathname === '/supply-chain/purchase/daily-affairs') return 'purchaseDailyAffairs' as const if (pathname === '/supply-chain/purchase/price-change') return 'purchasePriceChange' as const if (pathname === '/supply-chain/script-monitor') return 'scriptMonitor' as const + if (pathname === '/supply-chain/new-products/dropship-issues') return 'newProductDropshipIssues' as const if (pathname === '/supply-chain/operation-logs') return 'operationLogs' as const return null } @@ -338,6 +346,12 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method: if (pathname === '/api/supply-chain/permission-settings') return null const action = method.toUpperCase() === 'GET' ? 'view' : 'edit' + if (pathname === '/api/supply-chain/1688-dropship-data/filter-options') { + return { targetKey: 'newProduct1688DropshipData' as const, action: method.toUpperCase() === 'POST' ? 'view' as const : action } + } + if (pathname === '/api/supply-chain/1688-dropship-data/source-image') { + return { targetKey: 'newProduct1688DropshipData' as const, action } + } if (pathname.startsWith('/api/supply-chain/product-archive/dashboard')) { return { targetKey: 'dashboard' as const, action } } diff --git a/furniture-3d-system/src/server/repositories/__tests__/dropship-1688-data.repository.test.ts b/furniture-3d-system/src/server/repositories/__tests__/dropship-1688-data.repository.test.ts new file mode 100644 index 00000000..0cc4f634 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/__tests__/dropship-1688-data.repository.test.ts @@ -0,0 +1,318 @@ +jest.mock('@/lib/prisma', () => ({ + prisma: { + supplyChainProductArchive: { + findMany: jest.fn(), + }, + supplyChainProductArchiveSalesPerformanceDaily: { + aggregate: jest.fn(), + groupBy: jest.fn(), + findMany: jest.fn(), + }, + }, +})) + +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { dropship1688DataRepository } from '../dropship-1688-data.repository' +import type { Dropship1688Filters } from '@/lib/dropship-1688-filters' + +const archiveFindMany = prisma.supplyChainProductArchive.findMany as jest.Mock +const dailyAggregate = prisma.supplyChainProductArchiveSalesPerformanceDaily.aggregate as jest.Mock +const dailyGroupBy = prisma.supplyChainProductArchiveSalesPerformanceDaily.groupBy as jest.Mock +const dailyFindMany = prisma.supplyChainProductArchiveSalesPerformanceDaily.findMany as jest.Mock + +function archive(id: string, barcode: string) { + return { + id, + barcode, + spu: `SPU-${id}`, + sku: `SKU-${id}`, + image: null, + color: '', + } +} + +function performance(barcode: string, realPerformance: string) { + return { + barcode, + _sum: { + shipQty: 0n, + salesAmount: new Prisma.Decimal(0), + realPerformance: new Prisma.Decimal(realPerformance), + clearSalesCost: new Prisma.Decimal(0), + sendFreight: new Prisma.Decimal(0), + afterSaleAmount: new Prisma.Decimal(0), + }, + _count: { _all: 1, salesAmount: 1 }, + } +} + +describe('dropship1688DataRepository', () => { + beforeEach(() => { + jest.clearAllMocks() + dailyAggregate.mockResolvedValue({ + _sum: { shipQty: 0n, shipAmount: new Prisma.Decimal(0), realPerformance: new Prisma.Decimal(0) }, + }) + dailyFindMany.mockResolvedValue([]) + }) + + it('sorts the complete result by filtered real performance before pagination', async () => { + archiveFindMany + .mockResolvedValueOnce([ + { id: 'archive-low', barcode: 'DF-LOW' }, + { id: 'archive-empty', barcode: 'DF-EMPTY' }, + { id: 'archive-high', barcode: 'DF-HIGH' }, + ]) + .mockResolvedValueOnce([ + archive('archive-low', 'DF-LOW'), + archive('archive-high', 'DF-HIGH'), + ]) + dailyGroupBy.mockResolvedValue([ + performance('DF-LOW', '10'), + performance('DF-HIGH', '99'), + ]) + + const result = await dropship1688DataRepository.listPage({ + keyword: '', + dateFrom: new Date('2026-08-01T00:00:00.000Z'), + dateTo: new Date('2026-09-09T00:00:00.000Z'), + skip: 0, + take: 2, + }) + + expect(result.archives.map((record) => record.barcode)).toEqual(['DF-HIGH', 'DF-LOW']) + expect(result.performance.map((record) => record.barcode)).toEqual(['DF-HIGH', 'DF-LOW']) + expect(result.total).toBe(3) + expect(archiveFindMany).toHaveBeenNthCalledWith(2, expect.objectContaining({ + where: { id: { in: ['archive-high', 'archive-low'] } }, + })) + }) + + it('places zero-performance products before negative performance and uses barcode as a tie-breaker', async () => { + archiveFindMany + .mockResolvedValueOnce([ + { id: 'archive-negative', barcode: 'DF-NEGATIVE' }, + { id: 'archive-b', barcode: 'DF-ZERO-B' }, + { id: 'archive-a', barcode: 'DF-ZERO-A' }, + ]) + .mockResolvedValueOnce([ + archive('archive-negative', 'DF-NEGATIVE'), + archive('archive-b', 'DF-ZERO-B'), + archive('archive-a', 'DF-ZERO-A'), + ]) + dailyGroupBy.mockResolvedValue([ + performance('DF-NEGATIVE', '-2'), + performance('DF-ZERO-B', '0'), + ]) + + const result = await dropship1688DataRepository.listPage({ + keyword: '', + dateFrom: new Date('2026-08-01T00:00:00.000Z'), + dateTo: new Date('2026-09-09T00:00:00.000Z'), + skip: 0, + take: 3, + }) + + expect(result.archives.map((record) => record.barcode)).toEqual([ + 'DF-ZERO-A', + 'DF-ZERO-B', + 'DF-NEGATIVE', + ]) + }) + + it('uses sales_amount independently of shipment totals and calculates profit from shared facts', async () => { + archiveFindMany.mockResolvedValue([archive('a', 'DF-A')]) + const record = performance('DF-A', '90') + record._sum.salesAmount = new Prisma.Decimal('120') + record._sum.clearSalesCost = new Prisma.Decimal('40') + record._sum.sendFreight = new Prisma.Decimal('5') + dailyGroupBy.mockResolvedValue([record, performance('UNMATCHED', '999')]) + const result = await dropship1688DataRepository.listPage({ + keyword: '', dateFrom: new Date('2026-09-01'), dateTo: new Date('2026-09-07'), skip: 0, take: 50, + }) + expect(result.performance[0]._sum.shipAmount?.toString()).toBe('120') + expect(result.performance[0]._sum.grossProfit.toString()).toBe('45') + expect(result.summary._sum.shipAmount?.toString()).toBe('120') + expect(result.summary._sum.realPerformance.toString()).toBe('90') + expect(dailyGroupBy).toHaveBeenCalledWith(expect.objectContaining({ + where: { barcode: { startsWith: 'DF-' }, statDate: { gte: new Date('2026-09-01'), lte: new Date('2026-09-07') } }, + _sum: expect.objectContaining({ salesAmount: true, clearSalesCost: true, sendFreight: true }), + })) + }) + + it('keeps incomplete sales totals unknown until historical facts have been backfilled', async () => { + archiveFindMany.mockResolvedValue([archive('a', 'DF-A')]) + const record = performance('DF-A', '90') + record._sum.salesAmount = new Prisma.Decimal('50') + record._count = { _all: 2, salesAmount: 1 } + dailyGroupBy.mockResolvedValue([record]) + const result = await dropship1688DataRepository.listPage({ + keyword: '', dateFrom: new Date('2026-09-01'), dateTo: new Date('2026-09-07'), skip: 0, take: 50, + }) + expect(result.performance[0]._sum.shipAmount).toBeNull() + expect(result.summary._sum.shipAmount).toBeNull() + expect(result.summary._sum.realPerformance.toString()).toBe('90') + }) + + it('matches ider-backed operators within the selected date range', async () => { + archiveFindMany.mockImplementation(({ where }) => Promise.resolve(where.id + ? [archive('a', 'DF-A')] + : [archive('a', 'DF-A'), archive('b', 'DF-B')])) + dailyFindMany.mockImplementation(({ distinct }) => Promise.resolve(distinct ? [{ barcode: 'DF-A' }] : [])) + dailyGroupBy.mockResolvedValue([]) + + const result = await dropship1688DataRepository.listPage({ + keyword: '子美', + dateFrom: new Date('2026-08-01T00:00:00.000Z'), + dateTo: new Date('2026-08-31T00:00:00.000Z'), + skip: 0, + take: 50, + }) + + expect(result.total).toBe(1) + expect(result.archives.map((row) => row.barcode)).toEqual(['DF-A']) + expect(dailyFindMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { + barcode: { startsWith: 'DF-' }, + statDate: { + gte: new Date('2026-08-01T00:00:00.000Z'), + lte: new Date('2026-08-31T00:00:00.000Z'), + }, + skuOperator: { contains: '子美', mode: 'insensitive' }, + }, + distinct: ['barcode'], + })) + }) + + it.each([ + ['2026-09-01', '2026-09-07', '2026-08-01'], + ['2026-09-18', '2026-10-07', '2026-08-01'], + ['2026-01-01', '2026-01-31', '2025-12-01'], + ['2024-03-31', '2024-03-31', '2024-02-01'], + ])('loads week and month comparison history for %s through %s without changing the aggregate range', async (dateFrom, dateTo, comparisonFrom) => { + archiveFindMany + .mockResolvedValueOnce([{ id: 'archive-1', barcode: 'DF-ONE' }]) + .mockResolvedValueOnce([archive('archive-1', 'DF-ONE')]) + dailyGroupBy.mockResolvedValue([performance('DF-ONE', '99')]) + + await dropship1688DataRepository.listPage({ + keyword: '', + dateFrom: new Date(`${dateFrom}T00:00:00.000Z`), + dateTo: new Date(`${dateTo}T00:00:00.000Z`), + skip: 0, + take: 50, + }) + + expect(dailyFindMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { + barcode: { in: ['DF-ONE'] }, + statDate: { + gte: new Date(`${comparisonFrom}T00:00:00.000Z`), + lte: new Date(`${dateTo}T00:00:00.000Z`), + }, + }, + })) + expect(dailyGroupBy).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + statDate: { + gte: new Date(`${dateFrom}T00:00:00.000Z`), + lte: new Date(`${dateTo}T00:00:00.000Z`), + }, + }), + })) + }) + + it.each<[Dropship1688Filters, string[], string]>([ + [{}, ['DF-C', 'DF-B', 'DF-A'], '60'], + [{ sku: { mode: 'include', values: ['SKU-a', 'SKU-c'] } }, ['DF-C', 'DF-A'], '40'], + [{ sku: { mode: 'exclude', values: [] } }, ['DF-C', 'DF-B', 'DF-A'], '60'], + [{ sku: { mode: 'include', values: [] } }, [], '0'], + [{ barcode: { mode: 'exclude', values: ['DF-C', 'DF-A'] } }, ['DF-B'], '20'], + [{ spu: { mode: 'include', values: ['SHARED'] }, sku: { mode: 'include', values: ['SKU-b', 'SKU-c'] } }, ['DF-B'], '20'], + [{ spu: { mode: 'include', values: [''] } }, ['DF-C'], '30'], + ])('filters complete candidates before summary, ranking and page slicing: %j', async (filters, expected, total) => { + const candidates = [ + { ...archive('a', 'DF-A'), spu: 'SHARED' }, + { ...archive('b', 'DF-B'), spu: 'SHARED' }, + { ...archive('c', 'DF-C'), spu: '' }, + ] + archiveFindMany.mockImplementation(({ where }) => Promise.resolve( + where.id ? candidates.filter((candidate) => where.id.in.includes(candidate.id)) : candidates, + )) + dailyGroupBy.mockResolvedValue([ + performance('DF-A', '10'), performance('DF-B', '20'), performance('DF-C', '30'), + ]) + + const result = await dropship1688DataRepository.listPage({ + keyword: '', + dateFrom: new Date('2026-09-01T00:00:00.000Z'), + dateTo: new Date('2026-09-07T00:00:00.000Z'), + skip: 0, + take: 1, + filters, + }) + + expect(result.archives.map((record) => record.barcode)).toEqual(expected.slice(0, 1)) + expect(result.total).toBe(expected.length) + expect(result.summary._sum.realPerformance.toString()).toBe(total) + }) + + it('filters by the latest trimmed non-empty operator in the selected period, not historical matches', async () => { + const candidates = [archive('a', 'DF-A'), archive('b', 'DF-B'), archive('empty', 'DF-EMPTY')] + archiveFindMany.mockImplementation(({ where }) => Promise.resolve( + where.id ? candidates.filter((candidate) => where.id.in.includes(candidate.id)) : candidates, + )) + dailyGroupBy.mockImplementation(({ by }) => Promise.resolve(by.length === 1 + ? [performance('DF-A', '10'), performance('DF-B', '20')] + : [ + { barcode: 'DF-A', skuOperator: '旧运营', _max: { statDate: new Date('2026-09-01') } }, + { barcode: 'DF-A', skuOperator: ' 新运营 / 子美 ', _max: { statDate: new Date('2026-09-05') } }, + { barcode: 'DF-A', skuOperator: ' ', _max: { statDate: new Date('2026-09-07') } }, + { barcode: 'DF-B', skuOperator: '旧运营', _max: { statDate: new Date('2026-09-06') } }, + ])) + const input = { + keyword: '', dateFrom: new Date('2026-09-01'), dateTo: new Date('2026-09-07'), skip: 0, take: 50, + } + const result = await dropship1688DataRepository.listPage({ + ...input, filters: { skuOperator: { mode: 'include', values: ['新运营 / 子美', ''] } }, + }) + expect(result.archives.map((record) => record.barcode)).toEqual(['DF-A', 'DF-EMPTY']) + expect(result.summary._sum.realPerformance.toString()).toBe('10') + expect(dailyGroupBy).toHaveBeenCalledWith(expect.objectContaining({ + by: ['barcode', 'skuOperator'], + where: expect.objectContaining({ statDate: { gte: input.dateFrom, lte: input.dateTo } }), + })) + + const historical = await dropship1688DataRepository.listPage({ + ...input, filters: { skuOperator: { mode: 'include', values: ['旧运营'] } }, + }) + expect(historical.archives.map((record) => record.barcode)).toEqual(['DF-B']) + }) + + it('lists all unique options with other column filters applied and its own filter omitted', async () => { + archiveFindMany.mockResolvedValue([ + { ...archive('a', 'DF-A'), spu: 'SHARED' }, + { ...archive('b', 'DF-B'), spu: 'SHARED' }, + { ...archive('c', 'DF-C'), spu: 'OTHER' }, + ]) + const result = await dropship1688DataRepository.listFilterOptions({ + field: 'sku', keyword: '', dateFrom: new Date('2026-09-01'), dateTo: new Date('2026-09-07'), + filters: { spu: { mode: 'include', values: ['SHARED'] }, sku: { mode: 'include', values: [] } }, + }) + expect(result.values).toEqual(['SKU-a', 'SKU-b']) + expect(dailyGroupBy).not.toHaveBeenCalled() + }) + + it('offers the displayed operator values and an empty option for products without period sales', async () => { + archiveFindMany.mockResolvedValue([archive('a', 'DF-A'), archive('b', 'DF-B')]) + dailyGroupBy.mockResolvedValue([ + { barcode: 'DF-A', skuOperator: '旧运营', _max: { statDate: new Date('2026-09-01') } }, + { barcode: 'DF-A', skuOperator: ' 新运营 / 子美 ', _max: { statDate: new Date('2026-09-06') } }, + ]) + const result = await dropship1688DataRepository.listFilterOptions({ + field: 'skuOperator', keyword: '', dateFrom: new Date('2026-09-01'), dateTo: new Date('2026-09-07'), + filters: { skuOperator: { mode: 'include', values: ['旧运营'] } }, + }) + expect(result.values).toEqual(['', '新运营 / 子美']) + }) +}) diff --git a/furniture-3d-system/src/server/repositories/__tests__/product-archive-sales-performance-source.repository.test.ts b/furniture-3d-system/src/server/repositories/__tests__/product-archive-sales-performance-source.repository.test.ts new file mode 100644 index 00000000..161c8b6e --- /dev/null +++ b/furniture-3d-system/src/server/repositories/__tests__/product-archive-sales-performance-source.repository.test.ts @@ -0,0 +1,93 @@ +/** @jest-environment node */ +jest.mock('mssql', () => ({ + __esModule: true, + default: { ConnectionPool: jest.fn(), Date: 'date' }, +})) + +import sql from 'mssql' +import { productArchiveSalesPerformanceSourceRepository as repository } from '../product-archive-sales-performance-source.repository' + +describe('daily sales source contract', () => { + const query = jest.fn() + const input = jest.fn() + const close = jest.fn() + const originalEnv = process.env + + beforeEach(() => { + jest.clearAllMocks() + process.env = { + ...originalEnv, + JST_REPORT_DB_HOST: 'source.invalid', + JST_REPORT_DB_NAME: 'report_test', + JST_REPORT_DB_USER: 'reader', + JST_REPORT_DB_PASSWORD: 'test-only', + JST_REPORT_DB_SCHEMA: 'rpt', + } + const request = { input, query } + input.mockReturnValue(request) + query.mockResolvedValue({ recordset: [] }) + const pool = { connected: true, request: () => request, close } + ;(sql.ConnectionPool as unknown as jest.Mock).mockImplementation(() => ({ connect: async () => pool })) + }) + + afterEach(async () => { + await repository.close() + process.env = originalEnv + }) + + async function dailySql() { + const periodStart = new Date('2026-09-01T00:00:00Z') + const periodEnd = new Date('2026-09-07T00:00:00Z') + await repository.listDailyAggregates({ periodStart, periodEnd }) + expect(input).toHaveBeenCalledWith('periodStart', sql.Date, periodStart) + expect(input).toHaveBeenCalledWith('periodEnd', sql.Date, periodEnd) + return (query.mock.calls[0][0] as string).replace(/\s+/g, ' ') + } + + it('retains all barcode facts and keeps salesAmount separate from mainline shipAmount', async () => { + const text = await dailySql() + const performance = text.split('), cost_values AS (')[0] + expect(performance).toContain('COALESCE(sales_amount, 0) AS decimal(38, 4)))) AS salesAmount') + expect(performance).toContain('COALESCE(clear_goods_cost, 0) AS decimal(38, 4)))) AS shipAmount') + expect(performance).toContain('COUNT_BIG(*) AS sourceRowCount') + expect(performance).not.toContain("LIKE N'DF-%'") + expect(text).toContain('LEFT JOIN operators') + expect(text).toContain('LEFT JOIN ranked_costs') + expect(text).not.toContain('SupplyChainProductArchive') + }) + + it('selects daily DF unit cost by single-unit priority, frequency and deterministic tie-break', async () => { + const text = await dailySql() + expect(text).toContain('CAST(goods_cost AS decimal(38,4)) / NULLIF(CAST(goods_count AS decimal(38,4)), 0)') + expect(text).toContain('CASE WHEN goods_count = 1 THEN 1 ELSE 0 END AS isSingleUnit') + expect(text).toContain('AND goods_count > 0 AND goods_cost > 0') + expect(text).toContain('GROUP BY barcode, statDate, unitCost') + expect(text).toContain('PARTITION BY barcode, statDate ORDER BY CASE WHEN singleUnitRows > 0 THEN 0 ELSE 1 END, singleUnitRows DESC, observationCount DESC, unitCost DESC') + expect(text).toContain('AND ranked_costs.statDate = performance.statDate AND ranked_costs.costRank = 1') + }) + + it('deduplicates ider within barcode/day and joins operators in stable order', async () => { + const text = await dailySql() + expect(text).toContain('operator_values AS ( SELECT DISTINCT') + expect(text).toContain("NULLIF(LTRIM(RTRIM(CAST(ider AS nvarchar(200)))), '') AS skuOperator") + expect(text).toContain("STRING_AGG(CAST(skuOperator AS nvarchar(max)), N' / ') WITHIN GROUP (ORDER BY skuOperator)") + expect(text).toContain("COALESCE(operators.skuOperator, N'') AS skuOperator") + expect(text).toContain('AND operators.statDate = performance.statDate') + expect(text.match(/LIKE N'DF-%'/g)).toHaveLength(2) + }) + + it('preserves enrichment fields and normalizes the source row count', async () => { + query.mockResolvedValue({ recordset: [{ + barcode: 'DF-EXAMPLE', statDate: '2026-09-01', sourceRowCount: '2', + skuOperator: 'Operator A / Operator B', unitCost: '70.0000', + salesAmount: '180.0000', shipAmount: '150.0000', + }] }) + expect(await repository.listDailyAggregates({ + periodStart: new Date('2026-09-01'), periodEnd: new Date('2026-09-07'), + })).toEqual([{ + barcode: 'DF-EXAMPLE', statDate: '2026-09-01', sourceRowCount: 2, + skuOperator: 'Operator A / Operator B', unitCost: '70.0000', + salesAmount: '180.0000', shipAmount: '150.0000', + }]) + }) +}) diff --git a/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-sales-performance-daily.repository.test.ts b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-sales-performance-daily.repository.test.ts new file mode 100644 index 00000000..a254798e --- /dev/null +++ b/furniture-3d-system/src/server/repositories/__tests__/supply-chain-product-archive-sales-performance-daily.repository.test.ts @@ -0,0 +1,89 @@ +/** @jest-environment node */ +jest.mock('pg', () => ({ Pool: jest.fn() })) + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { Pool } from 'pg' +import { DailySalesSyncRepository } from '../supply-chain-product-archive-sales-performance-daily.repository' +import type { ProductArchiveSalesPerformanceDailyAggregate } from '../product-archive-sales-performance-source.repository' + +const row: ProductArchiveSalesPerformanceDailyAggregate = { + barcode: 'DF-EXAMPLE', statDate: '2026-09-01', sourceRowCount: 2, + skuOperator: 'Operator A / Operator B', unitCost: '70.0000', salesAmount: '180.0000', + shipAmount: '150.0000', returnAmount: '0', realPerformance: '160.0000', + shipQty: '2', returnQty: '0', clearSalesCost: '140.0000', sendFreight: '5', + platformCommission: '0', xyCost: '0', afterSaleAmount: '0', pushFeeTotal: '0', + marketingFeeTotal: '0', advertisementFeeTotal: '0', sharedWarehousingSum: '0', otherFee: '0', operatingFee: '0', +} + +describe('daily sales staged publication', () => { + const query = jest.fn() + const release = jest.fn() + const end = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + query.mockImplementation(async (text: string) => { + if (text.includes('pg_try_advisory_lock')) return { rows: [{ locked: true }] } + if (text.startsWith('SELECT count(*)')) return { rows: [{ count: 1 }] } + return { rows: [], rowCount: 1 } + }) + ;(Pool as unknown as jest.Mock).mockImplementation(() => ({ + connect: async () => ({ query, release }), end, + })) + }) + + it('stages and atomically publishes all enrichment without altering the barcode/day key', async () => { + await new DailySalesSyncRepository().exclusive(async (session) => { + await session.stage([row]) + await session.publish('2026-09-01', '2026-09-07', 1) + }) + const calls = query.mock.calls as [string, unknown[]?][] + const stage = calls.find(([text]) => text.startsWith('INSERT INTO archive_sales_stage'))! + expect(JSON.parse(stage[1]![0] as string)).toEqual([row]) + expect(stage[0]).toContain('"skuOperator" text') + expect(stage[0]).toContain('"unitCost" numeric(24,4)') + expect(stage[0]).toContain('"salesAmount" numeric(24,4)') + const publish = calls.find(([text]) => text.startsWith('INSERT INTO "SupplyChainProductArchiveSalesPerformanceDaily"'))! + expect(publish[0]).toContain('"skuOperator", "unitCost", "salesAmount", "shipAmount"') + const statements = calls.map(([text]) => text) + expect(statements).toContain('CREATE UNIQUE INDEX ON archive_sales_stage ("barcode", "statDate")') + expect(statements.indexOf('BEGIN')).toBeLessThan(statements.indexOf(publish[0])) + expect(statements.indexOf('COMMIT')).toBeGreaterThan(statements.indexOf(publish[0])) + expect(statements).toContain('SELECT pg_advisory_unlock(90710, 1)') + expect(release).toHaveBeenCalledTimes(1) + expect(end).toHaveBeenCalledTimes(1) + }) + + it('rolls back a failed publication and preserves the prior published data', async () => { + const original = query.getMockImplementation()! + query.mockImplementation(async (text: string) => { + if (text.startsWith('INSERT INTO "SupplyChainProductArchiveSalesPerformanceDaily"')) { + throw new Error('publication failed') + } + return original(text) + }) + await expect(new DailySalesSyncRepository().exclusive(async (session) => { + await session.stage([row]) + return session.publish('2026-09-01', '2026-09-07', 1) + })).rejects.toThrow('publication failed') + expect(query).toHaveBeenCalledWith('ROLLBACK') + expect(query).not.toHaveBeenCalledWith('COMMIT') + expect(query.mock.calls.some(([text]) => text.includes("SET status='FAILED'"))).toBe(true) + expect(release).toHaveBeenCalledTimes(1) + }) +}) + +describe('daily sales enrichment migration', () => { + it('only adds nullable enrichment to the existing all-barcode table, without rewriting historical facts', () => { + const sql = readFileSync(join(process.cwd(), 'prisma/migrations/20260912120000_add_daily_sales_dropship_fields/migration.sql'), 'utf8') + .replace(/^--.*$/gm, '').trim() + expect(sql).toBe([ + 'ALTER TABLE "SupplyChainProductArchiveSalesPerformanceDaily"', + ' ADD COLUMN "skuOperator" TEXT NOT NULL DEFAULT \'\',', + ' ADD COLUMN "unitCost" DECIMAL(24,4),', + ' ADD COLUMN "salesAmount" DECIMAL(24,4);', + ].join('\n')) + expect(sql).not.toMatch(/\b(?:CREATE|DROP|DELETE|UPDATE|TRUNCATE|REFERENCES)\b/) + }) +}) diff --git a/furniture-3d-system/src/server/repositories/dropship-1688-data.repository.ts b/furniture-3d-system/src/server/repositories/dropship-1688-data.repository.ts new file mode 100644 index 00000000..48329a39 --- /dev/null +++ b/furniture-3d-system/src/server/repositories/dropship-1688-data.repository.ts @@ -0,0 +1,263 @@ +import { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { + FILTER_FIELDS, + type Dropship1688FilterField, + type Dropship1688Filters, +} from '@/lib/dropship-1688-filters' + +const ARCHIVE_SELECT = { + id: true, + barcode: true, + spu: true, + sku: true, + image: true, + color: true, +} satisfies Prisma.SupplyChainProductArchiveSelect + +const ARCHIVE_RANK_SELECT = { + id: true, + barcode: true, + spu: true, + sku: true, +} satisfies Prisma.SupplyChainProductArchiveSelect + +const ZERO_DECIMAL = new Prisma.Decimal(0) +// Preserve the database's Decimal(24, 4) amounts when summing filtered groups. +const SummaryDecimal = Prisma.Decimal.clone({ precision: 40 }) + +export type Dropship1688ArchiveRecord = Prisma.SupplyChainProductArchiveGetPayload<{ + select: typeof ARCHIVE_SELECT +}> + +type ArchiveCandidate = Prisma.SupplyChainProductArchiveGetPayload<{ + select: typeof ARCHIVE_RANK_SELECT +}> + +type FilterInput = { + keyword: string + dateFrom: Date + dateTo: Date + filters?: Dropship1688Filters +} + +function filterCandidates( + candidates: ArchiveCandidate[], + filters: Dropship1688Filters, + operators: Map, +) { + const predicates = FILTER_FIELDS.flatMap((field) => { + const filter = filters[field] + if (!filter) return [] + const values = new Set(filter.values) + return [(candidate: ArchiveCandidate) => { + const value = field === 'skuOperator' + ? operators.get(candidate.barcode) ?? '' + : candidate[field] + return values.has(value) === (filter.mode === 'include') + }] + }) + return candidates.filter((candidate) => predicates.every((predicate) => predicate(candidate))) +} + +class Dropship1688DataRepository { + private async candidates(input: FilterInput) { + const keyword = input.keyword.trim() + // The shared fact table retains unmatched barcodes and intentionally has no archive FK. + const [candidates, operatorMatches] = await Promise.all([ + prisma.supplyChainProductArchive.findMany({ + where: { barcode: { startsWith: 'DF-' } }, + select: ARCHIVE_RANK_SELECT, + }), + keyword + ? prisma.supplyChainProductArchiveSalesPerformanceDaily.findMany({ + where: { + barcode: { startsWith: 'DF-' }, + statDate: { gte: input.dateFrom, lte: input.dateTo }, + skuOperator: { contains: keyword, mode: 'insensitive' }, + }, + distinct: ['barcode'], + select: { barcode: true }, + }) + : Promise.resolve([]), + ]) + if (!keyword) return candidates + const matchingBarcodes = new Set(operatorMatches.map((row) => row.barcode)) + const normalized = keyword.toLocaleLowerCase('zh-CN') + return candidates.filter((row) => matchingBarcodes.has(row.barcode) + || [row.spu, row.sku, row.barcode].some((value) => value.toLocaleLowerCase('zh-CN').includes(normalized))) + } + + private async latestOperators(input: FilterInput) { + // Group history first, then pick the latest non-empty display value per barcode. + const history = await prisma.supplyChainProductArchiveSalesPerformanceDaily.groupBy({ + by: ['barcode', 'skuOperator'], + where: { + statDate: { gte: input.dateFrom, lte: input.dateTo }, + barcode: { startsWith: 'DF-' }, + }, + _max: { statDate: true }, + }) + const latest = new Map() + for (const record of history) { + const value = record.skuOperator.trim() + const date = record._max.statDate?.getTime() + if (!value || date === undefined) continue + if (date > (latest.get(record.barcode)?.date ?? -Infinity)) { + latest.set(record.barcode, { value, date }) + } + } + return new Map([...latest].map(([barcode, record]) => [barcode, record.value])) + } + + async listFilterOptions(input: FilterInput & { field: Dropship1688FilterField }) { + const filters = { ...input.filters } + delete filters[input.field] + const [candidates, operators] = await Promise.all([ + this.candidates(input), + input.field === 'skuOperator' || filters.skuOperator + ? this.latestOperators(input) + : Promise.resolve(new Map()), + ]) + const values = filterCandidates(candidates, filters, operators).map((candidate) => + input.field === 'skuOperator' ? operators.get(candidate.barcode) ?? '' : candidate[input.field], + ) + return { values: [...new Set(values)].sort((left, right) => left.localeCompare(right, 'zh-CN')) } + } + + async findAvailableDateRange() { + return prisma.supplyChainProductArchiveSalesPerformanceDaily.aggregate({ + where: { barcode: { startsWith: 'DF-' } }, + _min: { statDate: true }, + _max: { statDate: true }, + }) + } + + async listPage(input: FilterInput & { + skip: number + take: number + }) { + const dailyWhere = { + statDate: { gte: input.dateFrom, lte: input.dateTo }, + barcode: { startsWith: 'DF-' }, + } satisfies Prisma.SupplyChainProductArchiveSalesPerformanceDailyWhereInput + + const [candidates, dailyPerformance, operators] = await Promise.all([ + this.candidates(input), + prisma.supplyChainProductArchiveSalesPerformanceDaily.groupBy({ + by: ['barcode'], + where: dailyWhere, + _sum: { + shipQty: true, + salesAmount: true, + realPerformance: true, + clearSalesCost: true, + sendFreight: true, + afterSaleAmount: true, + }, + _count: { _all: true, salesAmount: true }, + }), + input.filters?.skuOperator + ? this.latestOperators(input) + : Promise.resolve(new Map()), + ]) + + const allPerformance = dailyPerformance.map((record) => ({ + barcode: record.barcode, + _sum: { + shipQty: record._sum.shipQty, + // A legacy row without salesAmount is not a zero sale or a shipped amount. + shipAmount: record._count.salesAmount === record._count._all ? record._sum.salesAmount : null, + realPerformance: record._sum.realPerformance, + grossProfit: new SummaryDecimal(record._sum.realPerformance ?? 0) + .minus(record._sum.clearSalesCost ?? 0).minus(record._sum.sendFreight ?? 0), + afterSaleAmount: record._sum.afterSaleAmount, + }, + })) + const performanceByBarcode = new Map( + allPerformance.map((record) => [record.barcode, record] as const), + ) + const rankCandidates = filterCandidates(candidates, input.filters ?? {}, operators) + const summary = { + _sum: rankCandidates.reduce((sum, candidate) => { + const values = performanceByBarcode.get(candidate.barcode)?._sum + return { + shipQty: sum.shipQty + (values?.shipQty ?? BigInt(0)), + shipAmount: sum.shipAmount === null || (values && values.shipAmount === null) + ? null : sum.shipAmount.plus(values?.shipAmount ?? ZERO_DECIMAL), + realPerformance: sum.realPerformance.plus(values?.realPerformance ?? ZERO_DECIMAL), + } + }, { + shipQty: BigInt(0), + shipAmount: new SummaryDecimal(0) as Prisma.Decimal | null, + realPerformance: new SummaryDecimal(0), + }), + } + rankCandidates.sort((left, right) => { + const leftPerformance = performanceByBarcode.get(left.barcode)?._sum.realPerformance + ?? ZERO_DECIMAL + const rightPerformance = performanceByBarcode.get(right.barcode)?._sum.realPerformance + ?? ZERO_DECIMAL + const performanceOrder = rightPerformance.comparedTo(leftPerformance) + if (performanceOrder !== 0) return performanceOrder + if (left.barcode < right.barcode) return -1 + if (left.barcode > right.barcode) return 1 + return 0 + }) + + const pageCandidates = rankCandidates.slice(input.skip, input.skip + input.take) + const pageIds = pageCandidates.map((archive) => archive.id) + const barcodes = pageCandidates.map((archive) => archive.barcode) + const priorWeekFrom = new Date(input.dateFrom) + priorWeekFrom.setUTCDate(priorWeekFrom.getUTCDate() - 7) + const priorMonthFrom = new Date(Date.UTC( + input.dateFrom.getUTCFullYear(), + input.dateFrom.getUTCMonth() - 1, + 1, + )) + const trendDateFrom = priorWeekFrom < priorMonthFrom ? priorWeekFrom : priorMonthFrom + const pageDailyWhere = { + barcode: { in: barcodes }, + statDate: { gte: trendDateFrom, lte: input.dateTo }, + } satisfies Prisma.SupplyChainProductArchiveSalesPerformanceDailyWhereInput + const [archives, trends] = barcodes.length === 0 + ? [[], []] + : await Promise.all([ + prisma.supplyChainProductArchive.findMany({ + where: { id: { in: pageIds } }, + select: ARCHIVE_SELECT, + }), + prisma.supplyChainProductArchiveSalesPerformanceDaily.findMany({ + where: pageDailyWhere, + select: { + barcode: true, + statDate: true, + realPerformance: true, + skuOperator: true, + unitCost: true, + }, + orderBy: [{ barcode: 'asc' }, { statDate: 'asc' }], + }), + ]) + + const pageOrderById = new Map( + pageCandidates.map((archive, index) => [archive.id, index] as const), + ) + archives.sort( + (left, right) => (pageOrderById.get(left.id) ?? 0) - (pageOrderById.get(right.id) ?? 0), + ) + const performance = barcodes + .map((barcode) => performanceByBarcode.get(barcode)) + .filter((record): record is NonNullable => Boolean(record)) + + return { + archives, + total: rankCandidates.length, + summary, + performance, + trends, + } + } +} + +export const dropship1688DataRepository = new Dropship1688DataRepository() diff --git a/furniture-3d-system/src/server/repositories/product-archive-sales-performance-source.repository.ts b/furniture-3d-system/src/server/repositories/product-archive-sales-performance-source.repository.ts index b5f96080..784d6687 100644 --- a/furniture-3d-system/src/server/repositories/product-archive-sales-performance-source.repository.ts +++ b/furniture-3d-system/src/server/repositories/product-archive-sales-performance-source.repository.ts @@ -24,6 +24,10 @@ export type ProductArchiveSalesPerformanceDailyAggregate = ProductArchiveSalesPerformanceAggregate & { statDate: string sourceRowCount: number + salesAmount: string + // Operator and unit-cost enrichment is sourced for DF barcodes only. + skuOperator: string + unitCost: string | null } export interface ProductArchiveSalesPerformanceSourceRepository { @@ -166,9 +170,11 @@ class ProductArchiveSalesPerformanceSourceRepositoryClass .input('periodStart', sql.Date, input.periodStart) .input('periodEnd', sql.Date, input.periodEnd) .query(` + WITH performance AS ( SELECT CONVERT(char(10), stat_date, 23) AS statDate, UPPER(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100))))) AS barcode, + CONVERT(varchar(64), SUM(CAST(COALESCE(sales_amount, 0) AS decimal(38, 4)))) AS salesAmount, CONVERT(varchar(64), SUM(CAST(COALESCE(clear_goods_cost, 0) AS decimal(38, 4)))) AS shipAmount, CONVERT(varchar(64), SUM(CAST(COALESCE(refund_amount, 0) AS decimal(38, 4)))) AS returnAmount, CONVERT(varchar(64), SUM(CAST(COALESCE(real_performance, 0) AS decimal(38, 4)))) AS realPerformance, @@ -191,7 +197,60 @@ class ProductArchiveSalesPerformanceSourceRepositoryClass AND stat_date <= @periodEnd AND NULLIF(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100)))), '') IS NOT NULL GROUP BY stat_date, UPPER(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100))))) - ORDER BY statDate, barcode + ), cost_values AS ( + SELECT + UPPER(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100))))) AS barcode, + CONVERT(char(10), stat_date, 23) AS statDate, + CAST( + CAST(goods_cost AS decimal(38,4)) / + NULLIF(CAST(goods_count AS decimal(38,4)), 0) + AS decimal(24,4) + ) AS unitCost, + CASE WHEN goods_count = 1 THEN 1 ELSE 0 END AS isSingleUnit + FROM ${this.sourceObject()} + WHERE stat_date >= @periodStart AND stat_date <= @periodEnd + AND UPPER(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100))))) LIKE N'DF-%' + AND goods_count > 0 + AND goods_cost > 0 + ), cost_counts AS ( + SELECT barcode, statDate, unitCost, + SUM(isSingleUnit) AS singleUnitRows, + COUNT_BIG(*) AS observationCount + FROM cost_values + GROUP BY barcode, statDate, unitCost + ), ranked_costs AS ( + SELECT barcode, statDate, unitCost, + ROW_NUMBER() OVER ( + PARTITION BY barcode, statDate + ORDER BY CASE WHEN singleUnitRows > 0 THEN 0 ELSE 1 END, + singleUnitRows DESC, observationCount DESC, unitCost DESC + ) AS costRank + FROM cost_counts + ), operator_values AS ( + SELECT DISTINCT + UPPER(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100))))) AS barcode, + CONVERT(char(10), stat_date, 23) AS statDate, + NULLIF(LTRIM(RTRIM(CAST(ider AS nvarchar(200)))), '') AS skuOperator + FROM ${this.sourceObject()} + WHERE stat_date >= @periodStart AND stat_date <= @periodEnd + AND UPPER(LTRIM(RTRIM(CAST(skuoid AS nvarchar(100))))) LIKE N'DF-%' + AND NULLIF(LTRIM(RTRIM(CAST(ider AS nvarchar(200)))), '') IS NOT NULL + ), operators AS ( + SELECT barcode, statDate, + STRING_AGG(CAST(skuOperator AS nvarchar(max)), N' / ') + WITHIN GROUP (ORDER BY skuOperator) AS skuOperator + FROM operator_values + GROUP BY barcode, statDate + ) + SELECT performance.*, + COALESCE(operators.skuOperator, N'') AS skuOperator, + CONVERT(varchar(64), ranked_costs.unitCost) AS unitCost + FROM performance + LEFT JOIN operators ON operators.barcode = performance.barcode + AND operators.statDate = performance.statDate + LEFT JOIN ranked_costs ON ranked_costs.barcode = performance.barcode + AND ranked_costs.statDate = performance.statDate AND ranked_costs.costRank = 1 + ORDER BY performance.statDate, performance.barcode `) return result.recordset.map((row) => ({ ...row, diff --git a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-performance-daily.repository.ts b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-performance-daily.repository.ts index 59c60014..1dd6daeb 100644 --- a/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-performance-daily.repository.ts +++ b/furniture-3d-system/src/server/repositories/supply-chain-product-archive-sales-performance-daily.repository.ts @@ -5,12 +5,12 @@ import type { ProductArchiveSalesPerformanceDailyAggregate } from './product-arc const TABLE = '"SupplyChainProductArchiveSalesPerformanceDaily"' const RUNS = '"SupplyChainProductArchiveSalesSyncRun"' -const columns = ['barcode', 'statDate', 'shipAmount', 'returnAmount', 'realPerformance', 'shipQty', 'returnQty', +const columns = ['barcode', 'statDate', 'skuOperator', 'unitCost', 'salesAmount', 'shipAmount', 'returnAmount', 'realPerformance', 'shipQty', 'returnQty', 'clearSalesCost', 'sendFreight', 'platformCommission', 'xyCost', 'afterSaleAmount', 'pushFeeTotal', 'marketingFeeTotal', 'advertisementFeeTotal', 'sharedWarehousingSum', 'otherFee', 'operatingFee', 'sourceRowCount'] as const const quoted = columns.map((name) => '"' + name + '"').join(', ') const recordTypes = columns.map((name) => '"' + name + '" ' + ( - name === 'barcode' ? 'text' : name === 'statDate' ? 'date' + name === 'barcode' || name === 'skuOperator' ? 'text' : name === 'statDate' ? 'date' : name === 'sourceRowCount' ? 'integer' : name === 'shipQty' || name === 'returnQty' ? 'bigint' : 'numeric(24,4)' )).join(', ') diff --git a/furniture-3d-system/src/server/services/__tests__/dropship-1688-data.service.test.ts b/furniture-3d-system/src/server/services/__tests__/dropship-1688-data.service.test.ts new file mode 100644 index 00000000..11fdb328 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/dropship-1688-data.service.test.ts @@ -0,0 +1,231 @@ +import { Prisma } from '@prisma/client' +import { dropship1688DataRepository } from '@/server/repositories/dropship-1688-data.repository' +import { dropship1688DataService } from '../dropship-1688-data.service' + +jest.mock('@/server/repositories/dropship-1688-data.repository', () => ({ + dropship1688DataRepository: { + findAvailableDateRange: jest.fn(), + listPage: jest.fn(), + listFilterOptions: jest.fn(), + }, +})) + +const repository = jest.mocked(dropship1688DataRepository) + +function pageResult(total = 1) { + return { + archives: [{ + id: 'archive-1', + barcode: 'DF-A1AD000205', + spu: 'DF-A1AD0002', + sku: 'DF-A1AD000205', + image: 'https://images.example.com/df.webp', + color: '奶咖色', + }], + total, + summary: { + _sum: { + shipQty: 1n, + shipAmount: new Prisma.Decimal('29'), + realPerformance: new Prisma.Decimal('67.23'), + }, + }, + performance: [{ + barcode: 'DF-A1AD000205', + _sum: { + shipQty: 1n, + shipAmount: new Prisma.Decimal('29'), + realPerformance: new Prisma.Decimal('67.23'), + grossProfit: new Prisma.Decimal('38.23'), + afterSaleAmount: new Prisma.Decimal('6.70'), + }, + }], + trends: [{ + barcode: 'DF-A1AD000205', + statDate: new Date('2026-09-08T00:00:00.000Z'), + realPerformance: new Prisma.Decimal('67.23'), + skuOperator: '九千七', + unitCost: new Prisma.Decimal('29'), + }], + } +} + +describe('dropship1688DataService', () => { + beforeEach(() => { + jest.clearAllMocks() + repository.findAvailableDateRange.mockResolvedValue({ + _min: { statDate: new Date('2026-03-31T00:00:00.000Z') }, + _max: { statDate: new Date('2026-09-09T00:00:00.000Z') }, + } as never) + repository.listPage.mockResolvedValue(pageResult() as never) + }) + + it('defaults to the latest data month and maps aggregate values for the page', async () => { + const result = await dropship1688DataService.listPage({ page: 1 }) + + expect(repository.listPage).toHaveBeenCalledWith({ + keyword: '', + dateFrom: new Date('2026-09-01T00:00:00.000Z'), + dateTo: new Date('2026-09-09T00:00:00.000Z'), + skip: 0, + take: 50, + }) + expect(result).toMatchObject({ + total: 1, + page: 1, + pageSize: 50, + totalPages: 1, + dateFrom: '2026-09-01', + dateTo: '2026-09-09', + dataUpdatedThrough: '2026-09-09', + summary: { + shipQty: '1', + salesAmount: '29.0000', + realPerformance: '67.2300', + }, + }) + expect(result.rows[0]).toMatchObject({ + barcode: 'DF-A1AD000205', + shipQty: '1', + salesAmount: '29.0000', + afterSaleAmount: '6.7000', + realPerformance: '67.2300', + afterSaleCostRate: new Prisma.Decimal('6.70').dividedBy('67.23').toFixed(8), + costPrice: '29.0000', + grossProfit: '38.2300', + grossMargin: new Prisma.Decimal('38.23').dividedBy('67.23').toFixed(8), + skuOperator: '九千七', + trend: [{ date: '2026-09-08', value: '67.2300' }], + comparisonTrend: [{ date: '2026-09-08', value: '67.2300' }], + }) + }) + + it('normalizes a reversed date range and reloads the last valid page', async () => { + repository.listPage + .mockResolvedValueOnce(pageResult(51) as never) + .mockResolvedValueOnce(pageResult(51) as never) + + const result = await dropship1688DataService.listPage({ + dateFrom: '2026-09-09', + dateTo: '2026-08-01', + keyword: ' DF-A1AD ', + page: 99, + }) + + expect(repository.listPage).toHaveBeenNthCalledWith(1, { + keyword: 'DF-A1AD', + dateFrom: new Date('2026-08-01T00:00:00.000Z'), + dateTo: new Date('2026-09-09T00:00:00.000Z'), + skip: 4900, + take: 50, + }) + expect(repository.listPage).toHaveBeenNthCalledWith(2, expect.objectContaining({ + skip: 50, + take: 50, + })) + expect(result.page).toBe(2) + expect(result.totalPages).toBe(2) + }) + + it('keeps unbackfilled sales amounts unknown in rows and summaries', async () => { + const source = pageResult() + repository.listPage.mockResolvedValue({ + ...source, + summary: { _sum: { ...source.summary._sum, shipAmount: null } }, + performance: source.performance.map((record) => ({ + ...record, + _sum: { ...record._sum, shipAmount: null }, + })), + } as never) + + const result = await dropship1688DataService.listPage({ page: 1 }) + + expect(result.rows[0].salesAmount).toBe('') + expect(result.summary.salesAmount).toBe('') + expect(result.rows[0].realPerformance).toBe('67.2300') + }) + + it('uses the last non-empty ider value in the selected period', async () => { + const resultWithOperatorHistory = pageResult() + resultWithOperatorHistory.trends = [ + { + barcode: 'DF-A1AD000205', + statDate: new Date('2026-09-07T00:00:00.000Z'), + realPerformance: new Prisma.Decimal('32.00'), + skuOperator: '旧运营', + unitCost: new Prisma.Decimal('28'), + }, + { + barcode: 'DF-A1AD000205', + statDate: new Date('2026-09-08T00:00:00.000Z'), + realPerformance: new Prisma.Decimal('67.23'), + skuOperator: '新运营', + unitCost: new Prisma.Decimal('29'), + }, + ] + repository.listPage.mockResolvedValue(resultWithOperatorHistory as never) + + const result = await dropship1688DataService.listPage({ page: 1 }) + + expect(result.rows[0]?.skuOperator).toBe('新运营') + expect(result.rows[0]?.costPrice).toBe('29.0000') + }) + + it('retains an explicit empty selection and passes filters to both page and option queries', async () => { + const filters = { sku: { mode: 'include' as const, values: [] } } + repository.listFilterOptions.mockResolvedValue({ values: ['SKU-1'] }) + const page = await dropship1688DataService.listPage({ filters }) + expect(page.filters).toEqual(filters) + expect(repository.listPage).toHaveBeenCalledWith(expect.objectContaining({ filters })) + + const options = await dropship1688DataService.listFilterOptions({ field: 'sku', filters }) + expect(options.values).toEqual(['SKU-1']) + expect(repository.listFilterOptions).toHaveBeenCalledWith({ + field: 'sku', keyword: '', filters, + dateFrom: new Date('2026-09-01'), dateTo: new Date('2026-09-09'), + }) + }) + + it('retains filters when no daily data is available', async () => { + repository.findAvailableDateRange.mockResolvedValue({ + _min: { statDate: null }, _max: { statDate: null }, + } as never) + const filters = { skuOperator: { mode: 'exclude' as const, values: [''] } } + const result = await dropship1688DataService.listPage({ filters }) + expect(result.filters).toEqual(filters) + expect(result.rows).toEqual([]) + expect(await dropship1688DataService.listFilterOptions({ field: 'skuOperator', filters })).toEqual({ values: [] }) + }) + + it('keeps previous-month history out of selected-period operator, cost, daily trend and totals', async () => { + const source = pageResult() + source.trends = [ + { + ...source.trends[0], statDate: new Date('2026-08-01'), skuOperator: '上月运营', + unitCost: new Prisma.Decimal('88'), realPerformance: new Prisma.Decimal('9999'), + }, + { ...source.trends[0], statDate: new Date('2026-08-31'), skuOperator: '上一周运营' }, + { ...source.trends[0], statDate: new Date('2026-09-01'), skuOperator: ' 新运营 / 子美 ' }, + { ...source.trends[0], statDate: new Date('2026-09-07'), skuOperator: ' ' }, + ] + repository.listPage.mockResolvedValue(source as never) + const result = await dropship1688DataService.listPage({ dateFrom: '2026-09-01', dateTo: '2026-09-07' }) + expect(result.rows[0].skuOperator).toBe('新运营 / 子美') + expect(result.rows[0].costPrice).toBe('29.0000') + expect(result.rows[0].realPerformance).toBe('67.2300') + expect(result.summary.realPerformance).toBe('67.2300') + expect(result.rows[0].trend.map((point) => point.date)).toEqual(['2026-09-01', '2026-09-07']) + expect(result.rows[0].comparisonTrend).toHaveLength(4) + expect(result.rows[0].comparisonTrend[0]).toEqual({ date: '2026-08-01', value: '9999.0000' }) + }) + + it('returns empty trend arrays for rows without selected-period performance', async () => { + const source = pageResult() + source.performance = [] + source.trends = [{ ...source.trends[0], statDate: new Date('2026-08-01') }] + repository.listPage.mockResolvedValue(source as never) + + const result = await dropship1688DataService.listPage({ dateFrom: '2026-09-01', dateTo: '2026-09-07' }) + expect(result.rows[0]).toMatchObject({ trend: [], comparisonTrend: [], costPrice: '', skuOperator: '' }) + }) +}) diff --git a/furniture-3d-system/src/server/services/__tests__/dropship-1688-filter-state.service.test.ts b/furniture-3d-system/src/server/services/__tests__/dropship-1688-filter-state.service.test.ts new file mode 100644 index 00000000..4d93efe2 --- /dev/null +++ b/furniture-3d-system/src/server/services/__tests__/dropship-1688-filter-state.service.test.ts @@ -0,0 +1,59 @@ +jest.mock('@/server/cache', () => ({ cacheService: { get: jest.fn(), set: jest.fn() } })) + +import { cacheService } from '@/server/cache' +import { DROPSHIP1688_FILTER_MAX_BYTES, type Dropship1688Filters } from '@/lib/dropship-1688-filters' +import { dropship1688FilterStateService } from '../dropship-1688-filter-state.service' + +const cache = new Map() + +describe('dropship1688FilterStateService', () => { + beforeEach(() => { + jest.clearAllMocks() + cache.clear() + jest.mocked(cacheService.get).mockImplementation(async (key) => (cache.get(key) ?? null) as never) + jest.mocked(cacheService.set).mockImplementation(async (key, value) => { cache.set(key, value) }) + }) + + it('stores large exact multi-select filters under a short user-scoped digest for 24 hours', async () => { + const filters: Dropship1688Filters = { + sku: { mode: 'include', values: Array.from({ length: 5000 }, (_, index) => `DF-SKU-${index}`) }, + spu: { mode: 'include', values: [' DF-1 ', ''] }, + } + const filterRef = await dropship1688FilterStateService.save('user-a', filters) + expect(filterRef).toMatch(/^[a-f0-9]{64}$/) + expect(cacheService.set).toHaveBeenCalledWith(`dropship-1688:filter-state:user-a:${filterRef}`, expect.any(Object), 86400) + const loaded = await dropship1688FilterStateService.load('user-a', filterRef) + expect(new Set(loaded?.sku?.values)).toEqual(new Set(filters.sku?.values)) + expect(loaded?.spu?.values).toEqual(['', ' DF-1 ']) + expect(await dropship1688FilterStateService.load('user-b', filterRef)).toBeNull() + }) + + it('returns the same reference for equivalent selections and preserves include-none', async () => { + const first = await dropship1688FilterStateService.save('user-a', { sku: { mode: 'include', values: ['B', 'A', 'B'] } }) + const second = await dropship1688FilterStateService.save('user-a', { sku: { mode: 'include', values: ['A', 'B'] } }) + expect(second).toBe(first) + const empty = await dropship1688FilterStateService.save('user-a', { sku: { mode: 'include', values: [] } }) + expect(await dropship1688FilterStateService.load('user-a', empty)).toEqual({ sku: { mode: 'include', values: [] } }) + }) + + it('returns null for expired, malformed or corrupted references rather than dropping the filter', async () => { + expect(await dropship1688FilterStateService.load('user-a', '../bad-ref')).toBeNull() + expect(cacheService.get).not.toHaveBeenCalled() + const ref = await dropship1688FilterStateService.save('user-a', { sku: { mode: 'include', values: [] } }) + cache.clear() + expect(await dropship1688FilterStateService.load('user-a', ref)).toBeNull() + cache.set(`dropship-1688:filter-state:user-a:${ref}`, { invalid: true }) + expect(await dropship1688FilterStateService.load('user-a', ref)).toBeNull() + cache.set(`dropship-1688:filter-state:user-a:${ref}`, {}) + expect(await dropship1688FilterStateService.load('user-a', ref)).toBeNull() + }) + + it('rejects oversized normalized filter state before touching cache', async () => { + const filters = { + sku: { mode: 'include' as const, values: Array.from({ length: 1000 }, (_, index) => `${index}${'x'.repeat(1000)}`) }, + } + expect(Buffer.byteLength(JSON.stringify(filters))).toBeGreaterThan(DROPSHIP1688_FILTER_MAX_BYTES) + await expect(dropship1688FilterStateService.save('user-a', filters)).rejects.toThrow('筛选条件过多') + expect(cacheService.set).not.toHaveBeenCalled() + }) +}) diff --git a/furniture-3d-system/src/server/services/dropship-1688-data.service.ts b/furniture-3d-system/src/server/services/dropship-1688-data.service.ts new file mode 100644 index 00000000..d5e8e4a2 --- /dev/null +++ b/furniture-3d-system/src/server/services/dropship-1688-data.service.ts @@ -0,0 +1,219 @@ +import { Prisma } from '@prisma/client' +import { + dropship1688DataRepository, + type Dropship1688ArchiveRecord, +} from '@/server/repositories/dropship-1688-data.repository' +import type { Dropship1688FilterField, Dropship1688Filters } from '@/lib/dropship-1688-filters' + +const PAGE_SIZE = 50 +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ + +function utcDate(value: string) { + if (!DATE_PATTERN.test(value)) return null + const date = new Date(`${value}T00:00:00.000Z`) + return Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== value + ? null + : date +} + +function dateText(value: Date) { + return value.toISOString().slice(0, 10) +} + +function firstDayOfUtcMonth(value: Date) { + return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), 1)) +} + +function decimalText(value: Prisma.Decimal | null | undefined) { + return value?.toFixed(4) ?? '' +} + +function percentageText(numerator: Prisma.Decimal, denominator: Prisma.Decimal) { + return denominator.isZero() ? '' : numerator.dividedBy(denominator).toFixed(8) +} + +export type Dropship1688DataRow = { + id: string + barcode: string + spu: string + sku: string + image: string + color: string + skuOperator: string + costPrice: string + shipQty: string + salesAmount: string + afterSaleAmount: string + realPerformance: string + afterSaleCostRate: string + grossProfit: string + grossMargin: string + trend: Array<{ date: string; value: string }> + comparisonTrend: Array<{ date: string; value: string }> +} + +function archiveRow(archive: Dropship1688ArchiveRecord): Dropship1688DataRow { + return { + id: archive.id, + barcode: archive.barcode, + spu: archive.spu, + sku: archive.sku, + image: archive.image?.trim() ?? '', + color: archive.color, + skuOperator: '', + costPrice: '', + shipQty: '', + salesAmount: '', + afterSaleAmount: '', + realPerformance: '', + afterSaleCostRate: '', + grossProfit: '', + grossMargin: '', + trend: [], + comparisonTrend: [], + } +} + +class Dropship1688DataService { + async listPage(input: { + keyword?: string + dateFrom?: string + dateTo?: string + page?: number + filters?: Dropship1688Filters + }) { + const available = await dropship1688DataRepository.findAvailableDateRange() + const availableFrom = available._min.statDate + const availableTo = available._max.statDate + if (!availableFrom || !availableTo) { + return { + rows: [] as Dropship1688DataRow[], + keyword: input.keyword?.trim() ?? '', + dateFrom: '', + dateTo: '', + total: 0, + page: 1, + pageSize: PAGE_SIZE, + totalPages: 1, + summary: { shipQty: '0', salesAmount: '0', realPerformance: '0' }, + dataUpdatedThrough: '', + filters: input.filters ?? {}, + } + } + + const requestedFrom = utcDate(input.dateFrom?.trim() ?? '') + ?? firstDayOfUtcMonth(availableTo) + const requestedTo = utcDate(input.dateTo?.trim() ?? '') ?? availableTo + const dateFrom = requestedFrom <= requestedTo ? requestedFrom : requestedTo + const dateTo = requestedFrom <= requestedTo ? requestedTo : requestedFrom + const keyword = input.keyword?.trim() ?? '' + const requestedPage = Number.isSafeInteger(input.page) ? Math.max(1, input.page ?? 1) : 1 + + const pageInput = { + keyword, + dateFrom, + dateTo, + skip: (requestedPage - 1) * PAGE_SIZE, + take: PAGE_SIZE, + ...(input.filters ? { filters: input.filters } : {}), + } + const firstResult = await dropship1688DataRepository.listPage(pageInput) + const totalPages = Math.max(1, Math.ceil(firstResult.total / PAGE_SIZE)) + const page = Math.min(requestedPage, totalPages) + const result = page === requestedPage + ? firstResult + : await dropship1688DataRepository.listPage({ + ...pageInput, + skip: (page - 1) * PAGE_SIZE, + }) + + const performanceByBarcode = new Map( + result.performance.map((record) => [record.barcode, record] as const), + ) + const trendsByBarcode = new Map() + const comparisonTrendsByBarcode = new Map() + const operatorByBarcode = new Map() + const costPriceByBarcode = new Map() + result.trends.forEach((record) => { + const comparisonTrend = comparisonTrendsByBarcode.get(record.barcode) ?? [] + comparisonTrend.push({ date: dateText(record.statDate), value: record.realPerformance.toFixed(4) }) + comparisonTrendsByBarcode.set(record.barcode, comparisonTrend) + + if (record.statDate < dateFrom || record.statDate > dateTo) return + const trend = trendsByBarcode.get(record.barcode) ?? [] + trend.push({ date: dateText(record.statDate), value: record.realPerformance.toFixed(4) }) + trendsByBarcode.set(record.barcode, trend) + const skuOperator = record.skuOperator.trim() + if (skuOperator) operatorByBarcode.set(record.barcode, skuOperator) + if (record.unitCost !== null) { + costPriceByBarcode.set(record.barcode, record.unitCost.toFixed(4)) + } + }) + const rows = result.archives.map((archive) => { + const row = archiveRow(archive) + const performance = performanceByBarcode.get(archive.barcode) + if (!performance) return row + const realPerformance = performance._sum.realPerformance ?? new Prisma.Decimal(0) + const grossProfit = performance._sum.grossProfit ?? new Prisma.Decimal(0) + const salesAmount = performance._sum.shipAmount + const afterSaleAmount = performance._sum.afterSaleAmount ?? new Prisma.Decimal(0) + return { + ...row, + costPrice: costPriceByBarcode.get(archive.barcode) ?? '', + shipQty: performance._sum.shipQty?.toString() ?? '0', + salesAmount: decimalText(salesAmount), + afterSaleAmount: decimalText(afterSaleAmount), + realPerformance: decimalText(realPerformance), + afterSaleCostRate: percentageText(afterSaleAmount, realPerformance), + grossProfit: decimalText(grossProfit), + grossMargin: percentageText(grossProfit, realPerformance), + skuOperator: operatorByBarcode.get(archive.barcode) ?? '', + trend: trendsByBarcode.get(archive.barcode) ?? [], + comparisonTrend: comparisonTrendsByBarcode.get(archive.barcode) ?? [], + } + }) + + return { + rows, + keyword, + dateFrom: dateText(dateFrom), + dateTo: dateText(dateTo), + total: result.total, + page, + pageSize: PAGE_SIZE, + totalPages, + summary: { + shipQty: result.summary._sum.shipQty?.toString() ?? '0', + salesAmount: decimalText(result.summary._sum.shipAmount), + realPerformance: decimalText(result.summary._sum.realPerformance) || '0', + }, + dataUpdatedThrough: dateText(availableTo), + filters: input.filters ?? {}, + } + } + + async listFilterOptions(input: { + field: Dropship1688FilterField + keyword?: string + dateFrom?: string + dateTo?: string + filters?: Dropship1688Filters + }) { + const available = await dropship1688DataRepository.findAvailableDateRange() + const availableTo = available._max.statDate + if (!availableTo) return { values: [] as string[] } + const requestedFrom = utcDate(input.dateFrom?.trim() ?? '') ?? firstDayOfUtcMonth(availableTo) + const requestedTo = utcDate(input.dateTo?.trim() ?? '') ?? availableTo + const dateFrom = requestedFrom <= requestedTo ? requestedFrom : requestedTo + const dateTo = requestedFrom <= requestedTo ? requestedTo : requestedFrom + return dropship1688DataRepository.listFilterOptions({ + field: input.field, + keyword: input.keyword?.trim() ?? '', + dateFrom, + dateTo, + filters: input.filters, + }) + } +} + +export const dropship1688DataService = new Dropship1688DataService() diff --git a/furniture-3d-system/src/server/services/dropship-1688-filter-state.service.ts b/furniture-3d-system/src/server/services/dropship-1688-filter-state.service.ts new file mode 100644 index 00000000..8d5f5dc9 --- /dev/null +++ b/furniture-3d-system/src/server/services/dropship-1688-filter-state.service.ts @@ -0,0 +1,50 @@ +import { createHash } from 'node:crypto' +import { + DROPSHIP1688_FILTER_MAX_BYTES, + dropship1688FiltersSchema, + normalizeDropship1688Filters, + serializeDropship1688Filters, + type Dropship1688Filters, +} from '@/lib/dropship-1688-filters' +import { cacheService } from '@/server/cache' +import { AppError, ErrorCode } from '@/server/core/errors' + +const FILTER_STATE_TTL_SECONDS = 24 * 60 * 60 +const FILTER_REF_PATTERN = /^[a-f0-9]{64}$/ + +function cacheKey(userId: string, filterRef: string) { + return `dropship-1688:filter-state:${userId}:${filterRef}` +} + +function filterDigest(serialized: string) { + return createHash('sha256').update(serialized).digest('hex') +} + +class Dropship1688FilterStateService { + async save(userId: string, filters: Dropship1688Filters): Promise { + const parsed = dropship1688FiltersSchema.safeParse(filters) + if (!parsed.success) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件不正确') + } + const normalized = normalizeDropship1688Filters(parsed.data) + const serialized = serializeDropship1688Filters(normalized) + if (Buffer.byteLength(serialized, 'utf8') > DROPSHIP1688_FILTER_MAX_BYTES) { + throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '筛选条件过多,请缩小选择范围') + } + const filterRef = filterDigest(serialized) + await cacheService.set(cacheKey(userId, filterRef), normalized, FILTER_STATE_TTL_SECONDS) + return filterRef + } + + async load(userId: string, filterRef: string): Promise { + if (!FILTER_REF_PATTERN.test(filterRef)) return null + const cached = await cacheService.get(cacheKey(userId, filterRef)) + const parsed = dropship1688FiltersSchema.safeParse(cached) + if (!parsed.success) return null + const normalized = normalizeDropship1688Filters(parsed.data) + if (filterDigest(serializeDropship1688Filters(normalized)) !== filterRef) return null + return normalized + } +} + +export const dropship1688FilterStateService = new Dropship1688FilterStateService() diff --git a/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts b/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts index c71313fd..04226827 100644 --- a/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts +++ b/furniture-3d-system/src/server/services/supply-chain-permission-settings.service.ts @@ -113,6 +113,7 @@ class SupplyChainPermissionSettingsService { 'warehouseConfig', 'storeAccountManagement', 'newProductOverview', + 'newProduct1688DropshipData', 'newProductTerminationFeedback', 'newProductUnifiedTable', 'newProductSampleProgress', @@ -121,6 +122,7 @@ class SupplyChainPermissionSettingsService { 'purchaseDailyAffairs', 'purchasePriceChange', 'scriptMonitor', + 'newProductDropshipIssues', 'operationLogs', ] satisfies SupplyChainPermissionTargetKey[] } @@ -136,6 +138,7 @@ class SupplyChainPermissionSettingsService { 'warehouseConfig', 'storeAccountManagement', 'newProductOverview', + 'newProduct1688DropshipData', 'newProductTerminationFeedback', 'newProductUnifiedTable', 'newProductSampleProgress', @@ -144,6 +147,7 @@ class SupplyChainPermissionSettingsService { 'purchaseDailyAffairs', 'purchasePriceChange', 'scriptMonitor', + 'newProductDropshipIssues', 'operationLogs', ] return targetKeys.filter((targetKey) => diff --git a/furniture-3d-system/src/server/services/supply-chain-product-archive-sales-performance-daily-sync.service.test.ts b/furniture-3d-system/src/server/services/supply-chain-product-archive-sales-performance-daily-sync.service.test.ts index 0c2f6a16..3f79d9da 100644 --- a/furniture-3d-system/src/server/services/supply-chain-product-archive-sales-performance-daily-sync.service.test.ts +++ b/furniture-3d-system/src/server/services/supply-chain-product-archive-sales-performance-daily-sync.service.test.ts @@ -7,6 +7,7 @@ import type { SalesSyncSession } from '@/server/repositories/supply-chain-produc function setup(latest: string | null = '2026-09-01') { const row = (statDate: string): ProductArchiveSalesPerformanceDailyAggregate => ({ barcode: 'BAR', statDate, sourceRowCount: 1, shipAmount: '10', returnAmount: '0', + salesAmount: '25', skuOperator: '', unitCost: null, realPerformance: '20', shipQty: '1', returnQty: '0', clearSalesCost: '5', sendFreight: '2', platformCommission: '1', xyCost: '0', afterSaleAmount: '0', pushFeeTotal: '0', marketingFeeTotal: '0', advertisementFeeTotal: '0', sharedWarehousingSum: '0', otherFee: '0', operatingFee: '0', @@ -47,4 +48,17 @@ describe('daily synchronization publication', () => { await expect(service.sync()).rejects.toThrow('regressed') expect(session.begin).not.toHaveBeenCalled() }) + it('passes sales amount and daily DF enrichment through staging without replacing shipAmount', async () => { + const { source, session, service } = setup() + const original = source.listDailyAggregates.getMockImplementation()! + source.listDailyAggregates.mockImplementation(async (input) => { + const rows = await original(input) + return rows.map((row) => ({ ...row, barcode: 'DF-EXAMPLE', skuOperator: 'Operator A', unitCost: '70.0000', salesAmount: '180.0000' })) + }) + await service.sync() + expect(session.stage.mock.calls[0][0][0]).toMatchObject({ + barcode: 'DF-EXAMPLE', shipAmount: '10', salesAmount: '180.0000', + skuOperator: 'Operator A', unitCost: '70.0000', sourceRowCount: 1, + }) + }) }) From 73be6c9019e2cbfde86b22e08b8e7757e0331b8f Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Mon, 14 Sep 2026 16:00:55 +0800 Subject: [PATCH 024/184] =?UTF-8?q?1688=E4=BB=A3=E5=8F=91=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E4=BA=8B=E9=A1=B9=EF=BC=9ASPU=E7=BA=A7=E7=BB=B4?= =?UTF-8?q?=E6=8A=A4=E8=A1=A8+=E8=BF=90=E8=90=A5=E7=AD=9B=E9=80=89+?= =?UTF-8?q?=E5=9B=BE=E7=BA=B8/=E8=A7=86=E9=A2=91=E7=AD=9B=E9=80=89+?= =?UTF-8?q?=E6=9D=A1=E7=A0=81=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新表 SupplyChainDropshipIssue:SPU 级普票/专票、退货地址运营维护,带操作日志 - 页面重构:删除 header,SPU 汇总行+展开条码明细(一码一行),操作列图标化 - 运营独立筛选项:可搜索/单选/多选(DropshipOpsFilter),默认按近7天销量合计降序 - 新增 是否有图纸/是否有安装视频 三态筛选(下拉一行排布),搜索支持条码维度 - 菜单排序:新品经营总览→新品终止任务反馈→1688代发数据明细→1688代发异常事项 - 平台标签标题注册 dropship-issues,修复顶部标签空白 --- .../migration.sql | 19 + furniture-3d-system/prisma/schema.prisma | 17 + .../dropship-issues/audit/route.test.ts | 50 +++ .../dropship-issues/audit/route.ts | 31 ++ .../dropship-issues/route.test.ts | 62 +++ .../new-products/dropship-issues/route.ts | 74 ++++ .../dropship-issues/skus/route.ts | 27 ++ .../dropship-issues/source-image/route.ts | 33 ++ .../dropship-issues/update/route.test.ts | 82 ++++ .../dropship-issues/update/route.ts | 47 ++ .../components/supply-chain-tabs.tsx | 1 + .../config/supply-chain-menu.test.ts | 2 +- .../supply-chain/config/supply-chain-menu.ts | 2 +- .../DropshipIssueAuditDrawer.tsx | 204 +++++++++ .../DropshipIssueEditDialog.tsx | 310 +++++++++++++ .../DropshipIssueImageCell.tsx | 109 +++++ .../DropshipIssuesPagination.tsx | 233 ++++++++++ .../dropship-issues/DropshipIssuesTable.tsx | 407 ++++++++++++++++++ .../dropship-issues/DropshipOpsFilter.tsx | 287 ++++++++++++ .../dropship-issues/DropshipSkuPanel.tsx | 120 ++++++ .../dropship-issues.module.css | 155 +++++++ .../new-products/dropship-issues/page.tsx | 198 ++++++++- .../new-products/dropship-issues/types.ts | 38 ++ .../supply-chain-permission-settings.test.ts | 9 + .../lib/supply-chain-permission-settings.ts | 3 + .../supply-chain-dropship-issue.repository.ts | 313 ++++++++++++++ ...upply-chain-dropship-issue.service.test.ts | 189 ++++++++ .../supply-chain-dropship-issue.service.ts | 168 ++++++++ 28 files changed, 3165 insertions(+), 25 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260914140000_add_dropship_issue_maintenance/migration.sql create mode 100644 furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/audit/route.test.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/audit/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/route.test.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/skus/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/source-image/route.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/update/route.test.ts create mode 100644 furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/update/route.ts create mode 100644 furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/DropshipIssueAuditDrawer.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/DropshipIssueEditDialog.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/DropshipIssueImageCell.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/DropshipIssuesPagination.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/DropshipIssuesTable.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/DropshipOpsFilter.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/DropshipSkuPanel.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/dropship-issues.module.css create mode 100644 furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/types.ts create mode 100644 furniture-3d-system/src/server/repositories/supply-chain-dropship-issue.repository.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/supply-chain-dropship-issue.service.test.ts create mode 100644 furniture-3d-system/src/server/services/supply-chain-dropship-issue.service.ts diff --git a/furniture-3d-system/prisma/migrations/20260914140000_add_dropship_issue_maintenance/migration.sql b/furniture-3d-system/prisma/migrations/20260914140000_add_dropship_issue_maintenance/migration.sql new file mode 100644 index 00000000..225961e4 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260914140000_add_dropship_issue_maintenance/migration.sql @@ -0,0 +1,19 @@ +-- Operations-maintained checklist per dropship (DF) SPU on the 1688 dropship +-- issues page. Read-only columns always come live from the product archive; +-- only the two operations-maintained fields live here. +CREATE TABLE "SupplyChainDropshipIssue" ( + "id" TEXT NOT NULL, + "spu" VARCHAR(120) NOT NULL, + "invoiceType" VARCHAR(16) NOT NULL DEFAULT '', + "returnAddress" TEXT NOT NULL DEFAULT '', + "updatedByName" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SupplyChainDropshipIssue_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "SupplyChainDropshipIssue_spu_key" ON "SupplyChainDropshipIssue"("spu"); +CREATE INDEX "SupplyChainDropshipIssue_updatedAt_idx" ON "SupplyChainDropshipIssue"("updatedAt"); + +ALTER TYPE "OperationLogAction" ADD VALUE IF NOT EXISTS 'SUPPLY_CHAIN_DROPSHIP_ISSUE_WRITE'; diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index e0545ab3..0d9695e8 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -814,6 +814,22 @@ model SupplyChainProductArchiveSalesSyncRun { @@index([status, startedAt]) } +// Operations-maintained checklist per dropship (DF) SPU: invoice type and +// return address are filled in on the 1688 dropship issues page. Read-only +// columns (image, ops owner, drawing/video flags) always come live from the +// product archive, so only these two fields live here. +model SupplyChainDropshipIssue { + id String @id @default(cuid()) + spu String @unique @db.VarChar(120) + invoiceType String @default("") @db.VarChar(16) + returnAddress String @default("") @db.Text + updatedByName String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([updatedAt]) +} + // 图纸与视频导出来源:按条码保存当前有效的图纸/视频状态,支持月度增量覆盖。 model SupplyChainProductArchiveImageCache { id String @id @default(cuid()) @@ -3198,6 +3214,7 @@ enum OperationLogAction { INTAKE_WORKSPACE_CONFIRMATION JUSHUITAN_PRODUCT_PUSH JUSHUITAN_VIRTUAL_INVENTORY_PUSH + SUPPLY_CHAIN_DROPSHIP_ISSUE_WRITE } enum OperationLogStatus { diff --git a/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/audit/route.test.ts b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/audit/route.test.ts new file mode 100644 index 00000000..f1cc8012 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/audit/route.test.ts @@ -0,0 +1,50 @@ +jest.mock('@/server/core/response', () => ({ + handleRequest: jest.fn(async (_request, handler) => handler({ requestId: 'audit-request' })), + jsonSuccess: jest.fn((data, status) => ({ success: true, data, status })), + jsonError: jest.fn((code, message, status) => ({ success: false, code, message, status })), +})) + +jest.mock('@/server/services/supply-chain-dropship-issue.service', () => ({ + supplyChainDropshipIssueService: { listAuditLogs: jest.fn() }, +})) + +import { NextRequest } from 'next/server' +import { handleRequest } from '@/server/core/response' +import { supplyChainDropshipIssueService } from '@/server/services/supply-chain-dropship-issue.service' +import { GET } from './route' + +function request(params: Record) { + return { + nextUrl: new URL( + `http://localhost/api/supply-chain/new-products/dropship-issues/audit?${new URLSearchParams(params)}`, + ), + } as NextRequest +} + +describe('dropship issues audit route', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(supplyChainDropshipIssueService.listAuditLogs).mockResolvedValue([]) + }) + + it('requires only authentication and returns audit rows for the SPU', async () => { + const response = await GET(request({ spu: ' df-a1 ' })) + expect(handleRequest).toHaveBeenCalledWith(expect.anything(), expect.any(Function), { + requireAuth: true, + }) + expect(supplyChainDropshipIssueService.listAuditLogs).toHaveBeenCalledWith('df-a1') + expect(response).toMatchObject({ + success: true, + status: 200, + data: { spu: 'DF-A1', rows: [] }, + }) + }) + + it.each([{ spu: '' }, { spu: 'x'.repeat(121) }])( + 'rejects invalid spu input: %j', + async (params) => { + expect(await GET(request(params))).toMatchObject({ success: false, status: 400 }) + expect(supplyChainDropshipIssueService.listAuditLogs).not.toHaveBeenCalled() + }, + ) +}) diff --git a/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/audit/route.ts b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/audit/route.ts new file mode 100644 index 00000000..c5a7c522 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/audit/route.ts @@ -0,0 +1,31 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplyChainDropshipIssueService } from '@/server/services/supply-chain-dropship-issue.service' + +const auditQuerySchema = z.object({ + spu: z.string().trim().min(1).max(120), +}) + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const parsed = auditQuerySchema.safeParse({ + spu: request.nextUrl.searchParams.get('spu') ?? undefined, + }) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'SPU 不能为空', 400, ctx.requestId) + } + + const rows = await supplyChainDropshipIssueService.listAuditLogs(parsed.data.spu) + return jsonSuccess( + { spu: parsed.data.spu.trim().toUpperCase(), rows }, + 200, + ctx.requestId, + ) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/route.test.ts b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/route.test.ts new file mode 100644 index 00000000..3eb07a13 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/route.test.ts @@ -0,0 +1,62 @@ +jest.mock('@/server/core/response', () => ({ + handleRequest: jest.fn(async (_request, handler) => handler({ requestId: 'list-request' })), + jsonSuccess: jest.fn((data, status) => ({ success: true, data, status })), + jsonError: jest.fn((code, message, status) => ({ success: false, code, message, status })), +})) + +jest.mock('@/server/services/supply-chain-dropship-issue.service', () => ({ + supplyChainDropshipIssueService: { listSpuPage: jest.fn() }, + DROPSHIP_INVOICE_TYPES: ['普票', '专票'], +})) + +import { NextRequest } from 'next/server' +import { handleRequest } from '@/server/core/response' +import { supplyChainDropshipIssueService } from '@/server/services/supply-chain-dropship-issue.service' +import { GET } from './route' + +function request(params: Record) { + return { + nextUrl: new URL( + `http://localhost/api/supply-chain/new-products/dropship-issues?${new URLSearchParams(params)}`, + ), + } as NextRequest +} + +describe('dropship issues SPU list route', () => { + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(supplyChainDropshipIssueService.listSpuPage).mockResolvedValue({ + rows: [], + pageInfo: { page: 1, pageSize: 50, total: 0, totalPages: 1 }, + }) + }) + + it('requires only authentication and forwards normalized filters', async () => { + const response = await GET( + request({ page: '2', pageSize: '30', keyword: 'DF-A', pendingInvoice: 'true' }), + ) + expect(handleRequest).toHaveBeenCalledWith(expect.anything(), expect.any(Function), { + requireAuth: true, + }) + expect(supplyChainDropshipIssueService.listSpuPage).toHaveBeenCalledWith({ + page: 2, + pageSize: 30, + keyword: 'DF-A', + pendingInvoice: true, + pendingReturnAddress: false, + invoiceType: undefined, + }) + expect(response).toMatchObject({ success: true, status: 200 }) + }) + + it.each([ + { page: '0' }, + { pageSize: '999' }, + { keyword: 'x'.repeat(121) }, + { pendingInvoice: 'yes' }, + { invoiceType: '增值税' }, + ])('rejects invalid query input: %j', async (params) => { + expect(await GET(request(params))).toMatchObject({ success: false, status: 400 }) + expect(supplyChainDropshipIssueService.listSpuPage).not.toHaveBeenCalled() + }) +}) diff --git a/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/route.ts b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/route.ts new file mode 100644 index 00000000..74871943 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/route.ts @@ -0,0 +1,74 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { + supplyChainDropshipIssueService, + DROPSHIP_INVOICE_TYPES, +} from '@/server/services/supply-chain-dropship-issue.service' + +const triStateSchema = z.preprocess( + (value) => (value === '' || value === null || value === undefined ? undefined : value), + z.enum(['true', 'false']).optional(), +) + +const listQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(200).default(50), + keyword: z.string().trim().max(120).optional(), + pendingInvoice: z.enum(['true', 'false']).optional(), + pendingReturnAddress: z.enum(['true', 'false']).optional(), + invoiceType: z.enum(DROPSHIP_INVOICE_TYPES).optional(), + ops: z.string().trim().max(500).optional(), + hasDrawing: triStateSchema, + hasVideo: triStateSchema, +}) + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const parsed = listQuerySchema.safeParse({ + page: request.nextUrl.searchParams.get('page') ?? undefined, + pageSize: request.nextUrl.searchParams.get('pageSize') ?? undefined, + keyword: request.nextUrl.searchParams.get('keyword') ?? undefined, + pendingInvoice: request.nextUrl.searchParams.get('pendingInvoice') ?? undefined, + pendingReturnAddress: + request.nextUrl.searchParams.get('pendingReturnAddress') ?? undefined, + invoiceType: request.nextUrl.searchParams.get('invoiceType') ?? undefined, + ops: request.nextUrl.searchParams.get('ops') ?? undefined, + hasDrawing: request.nextUrl.searchParams.get('hasDrawing') ?? undefined, + hasVideo: request.nextUrl.searchParams.get('hasVideo') ?? undefined, + }) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '查询参数不正确', 400, ctx.requestId) + } + + const result = await supplyChainDropshipIssueService.listSpuPage({ + page: parsed.data.page, + pageSize: parsed.data.pageSize, + keyword: parsed.data.keyword, + pendingInvoice: parsed.data.pendingInvoice === 'true', + pendingReturnAddress: parsed.data.pendingReturnAddress === 'true', + invoiceType: parsed.data.invoiceType, + opsHeads: parsed.data.ops + ? parsed.data.ops.split(',').map((name) => name.trim()).filter(Boolean) + : undefined, + hasDrawing: + parsed.data.hasDrawing === 'true' + ? true + : parsed.data.hasDrawing === 'false' + ? false + : undefined, + hasVideo: + parsed.data.hasVideo === 'true' + ? true + : parsed.data.hasVideo === 'false' + ? false + : undefined, + }) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/skus/route.ts b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/skus/route.ts new file mode 100644 index 00000000..4d04f5be --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/skus/route.ts @@ -0,0 +1,27 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplyChainDropshipIssueService } from '@/server/services/supply-chain-dropship-issue.service' + +const skusQuerySchema = z.object({ + spu: z.string().trim().min(1).max(120), +}) + +export async function GET(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const parsed = skusQuerySchema.safeParse({ + spu: request.nextUrl.searchParams.get('spu') ?? undefined, + }) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, 'SPU 不能为空', 400, ctx.requestId) + } + + const rows = await supplyChainDropshipIssueService.listSkusBySpu(parsed.data.spu) + return jsonSuccess({ spu: parsed.data.spu.trim().toUpperCase(), rows }, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/source-image/route.ts b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/source-image/route.ts new file mode 100644 index 00000000..4e7d88a0 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/source-image/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError } from '@/server/core/response' +import { productArchiveSourceImageService } from '@/server/services/product-archive-source-image.service' + +const querySchema = z.object({ + barcode: z.string().trim().max(100).regex(/^DF-.+/i).transform((value) => value.toUpperCase()), +}) + +export async function GET(request: NextRequest) { + return handleRequest(request, async (ctx) => { + const parsed = querySchema.safeParse({ + barcode: request.nextUrl.searchParams.get('barcode') || undefined, + }) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '代发条码参数不正确', 400, ctx.requestId) + } + + const image = await productArchiveSourceImageService.readByBarcode(parsed.data.barcode) + if (!image) { + return jsonError(ErrorCode.FILE_NOT_FOUND, '商品图片不存在', 404, ctx.requestId) + } + + return new NextResponse(image.bytes, { + headers: { + 'Content-Type': image.contentType, + 'Content-Length': String(image.bytes.byteLength), + 'Cache-Control': 'private, max-age=86400, stale-while-revalidate=604800', + }, + }) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/update/route.test.ts b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/update/route.test.ts new file mode 100644 index 00000000..6d3bb1da --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/update/route.test.ts @@ -0,0 +1,82 @@ +jest.mock('@/server/core/response', () => ({ + handleRequest: jest.fn(async (_request, handler) => + handler({ requestId: 'update-request', user: { id: 'user-1', name: '测试运营' } }), + ), + jsonSuccess: jest.fn((data, status) => ({ success: true, data, status })), + jsonError: jest.fn((code, message, status) => ({ success: false, code, message, status })), +})) + +jest.mock('@/server/services/supply-chain-permission-settings.service', () => ({ + supplyChainPermissionSettingsService: { getUserAccess: jest.fn() }, +})) + +jest.mock('@/server/services/supply-chain-dropship-issue.service', () => ({ + supplyChainDropshipIssueService: { updateSpu: jest.fn() }, + DROPSHIP_INVOICE_TYPES: ['普票', '专票'], +})) + +import { NextRequest } from 'next/server' +import { handleRequest } from '@/server/core/response' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' +import { supplyChainDropshipIssueService } from '@/server/services/supply-chain-dropship-issue.service' +import { PATCH } from './route' + +function request(body: unknown) { + return { + nextUrl: new URL('http://localhost/api/supply-chain/new-products/dropship-issues/update'), + json: async () => body, + } as unknown as NextRequest +} + +describe('dropship issues update route', () => { + beforeEach(() => { + jest.clearAllMocks() + jest + .mocked(supplyChainPermissionSettingsService.getUserAccess) + .mockResolvedValue({ view: true, edit: true } as never) + jest.mocked(supplyChainDropshipIssueService.updateSpu).mockResolvedValue({ + spu: 'DF-A1', + invoiceType: '普票', + returnAddress: '地址', + updatedByName: '测试运营', + changed: true, + } as never) + }) + + it('requires authentication, checks edit permission and forwards the actor', async () => { + const response = await PATCH( + request({ spu: 'DF-A1', invoiceType: '普票', returnAddress: '地址' }), + ) + expect(handleRequest).toHaveBeenCalledWith(expect.anything(), expect.any(Function), { + requireAuth: true, + }) + expect(supplyChainPermissionSettingsService.getUserAccess).toHaveBeenCalledWith( + { id: 'user-1', name: '测试运营' }, + 'newProductDropshipIssues', + ) + expect(supplyChainDropshipIssueService.updateSpu).toHaveBeenCalledWith( + { spu: 'DF-A1', invoiceType: '普票', returnAddress: '地址' }, + { id: 'user-1', name: '测试运营' }, + ) + expect(response).toMatchObject({ success: true, status: 200 }) + }) + + it('rejects accounts without edit permission with 403', async () => { + jest + .mocked(supplyChainPermissionSettingsService.getUserAccess) + .mockResolvedValue({ view: true, edit: false } as never) + expect( + await PATCH(request({ spu: 'DF-A1', invoiceType: '普票', returnAddress: '' })), + ).toMatchObject({ success: false, status: 403 }) + expect(supplyChainDropshipIssueService.updateSpu).not.toHaveBeenCalled() + }) + + it.each([ + { spu: '', invoiceType: '普票', returnAddress: '' }, + { spu: 'DF-A1', invoiceType: '收据', returnAddress: '' }, + { spu: 'DF-A1', invoiceType: '普票', returnAddress: 'x'.repeat(501) }, + ])('rejects invalid body: %j', async (body) => { + expect(await PATCH(request(body))).toMatchObject({ success: false, status: 400 }) + expect(supplyChainDropshipIssueService.updateSpu).not.toHaveBeenCalled() + }) +}) diff --git a/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/update/route.ts b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/update/route.ts new file mode 100644 index 00000000..cf70621b --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/new-products/dropship-issues/update/route.ts @@ -0,0 +1,47 @@ +import { NextRequest } from 'next/server' +import { z } from 'zod' +import { ErrorCode } from '@/server/core/errors' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service' +import { + supplyChainDropshipIssueService, + DROPSHIP_INVOICE_TYPES, +} from '@/server/services/supply-chain-dropship-issue.service' + +const updateBodySchema = z.object({ + spu: z.string().trim().min(1).max(120), + invoiceType: z.enum(['', ...DROPSHIP_INVOICE_TYPES]), + returnAddress: z.string().max(500), +}) + +export async function PATCH(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const access = await supplyChainPermissionSettingsService.getUserAccess( + ctx.user, + 'newProductDropshipIssues', + ) + if (!access.edit) { + return jsonError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有维护权限', 403, ctx.requestId) + } + + const body = await request.json().catch(() => null) + const parsed = updateBodySchema.safeParse(body) + if (!parsed.success) { + return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '保存内容不正确', 400, ctx.requestId) + } + + const result = await supplyChainDropshipIssueService.updateSpu( + { + spu: parsed.data.spu, + invoiceType: parsed.data.invoiceType, + returnAddress: parsed.data.returnAddress ?? '', + }, + { id: ctx.user.id, name: ctx.user.name }, + ) + return jsonSuccess(result, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx b/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx index eb21c435..4a7f8fa3 100644 --- a/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx +++ b/furniture-3d-system/src/app/supply-chain/components/supply-chain-tabs.tsx @@ -100,6 +100,7 @@ export const SUPPLY_CHAIN_ROUTE_TITLES: Record = { '/supply-chain/database/store-account-management': '店铺账号管理', '/supply-chain/new-products/unified-table': '新品开发进度总表', '/supply-chain/new-products/1688-dropship-data': '1688代发数据明细', + '/supply-chain/new-products/dropship-issues': '1688代发异常事项', '/supply-chain/new-products/sample-progress': '打样进度明细', '/supply-chain/purchase/finished-product-progress': '成品进度明细', '/supply-chain/purchase/supplier-recommended-products': '供应商新品推荐', diff --git a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts index 08a38dc2..43a42df0 100644 --- a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts +++ b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts @@ -91,8 +91,8 @@ describe('supply chain menu', () => { expect(items.map((item) => item.label)).toEqual([ '新品经营总览', - '1688代发数据明细', '新品终止任务反馈', + '1688代发数据明细', '1688代发异常事项', ]) }) diff --git a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts index b4727849..5cb3725a 100644 --- a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts +++ b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts @@ -55,8 +55,8 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [ { items: [ { label: '新品经营总览', route: '/supply-chain/new-products/overview', targetKey: 'newProductOverview' }, - { label: '1688代发数据明细', route: '/supply-chain/new-products/1688-dropship-data', targetKey: 'newProduct1688DropshipData' }, { label: '新品终止任务反馈', route: '/supply-chain/new-products/termination-feedback', targetKey: 'newProductTerminationFeedback' }, + { label: '1688代发数据明细', route: '/supply-chain/new-products/1688-dropship-data', targetKey: 'newProduct1688DropshipData' }, { label: '1688代发异常事项', route: '/supply-chain/new-products/dropship-issues', targetKey: 'newProductDropshipIssues' }, ], }, diff --git a/furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/DropshipIssueAuditDrawer.tsx b/furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/DropshipIssueAuditDrawer.tsx new file mode 100644 index 00000000..439956e1 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/DropshipIssueAuditDrawer.tsx @@ -0,0 +1,204 @@ +'use client' + +import { useEffect, useState } from 'react' +import { LoaderCircle, ScrollText, X } from 'lucide-react' +import type { ApiResponse } from '@/server/core/types' + +type DropshipIssueAuditEntry = { + id: string + actorName: string + message: string + details: unknown + createdAt: string +} + +function formatTime(value: string) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const pad = (input: number) => String(input).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +export function DropshipIssueAuditDrawer({ + spu, + onClose, +}: { + spu: string + onClose: () => void +}) { + const [rows, setRows] = useState(null) + const [error, setError] = useState('') + + useEffect(() => { + const controller = new AbortController() + setRows(null) + setError('') + async function load() { + try { + const response = await fetch( + `/api/supply-chain/new-products/dropship-issues/audit?spu=${encodeURIComponent(spu)}`, + { signal: controller.signal }, + ) + const payload = (await response.json()) as ApiResponse<{ rows: DropshipIssueAuditEntry[] }> + if (!response.ok || !payload.success || !payload.data) { + throw new Error(payload.error?.message || '操作日志加载失败') + } + setRows(payload.data.rows) + } catch (loadError) { + if (controller.signal.aborted) return + setError(loadError instanceof Error ? loadError.message : '操作日志加载失败') + } + } + void load() + return () => controller.abort() + }, [spu]) + + return ( +
+ + + +
+ ) +} diff --git a/furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/DropshipIssueEditDialog.tsx b/furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/DropshipIssueEditDialog.tsx new file mode 100644 index 00000000..0e6b79ef --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/new-products/dropship-issues/DropshipIssueEditDialog.tsx @@ -0,0 +1,310 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Check, CircleDollarSign, FileBadge, LoaderCircle, X } from 'lucide-react' +import type { DropshipIssueSpuRow } from './types' + +const INVOICE_OPTIONS = [ + { value: '普票', label: '普票', hint: '普通发票', Icon: CircleDollarSign }, + { value: '专票', label: '专票', hint: '增值税专用发票', Icon: FileBadge }, +] as const + +export function DropshipIssueEditDialog({ + row, + onClose, + onSaved, +}: { + row: DropshipIssueSpuRow + onClose: () => void + onSaved: (updated: { spu: string; invoiceType: string; returnAddress: string }) => void +}) { + const [invoiceType, setInvoiceType] = useState(row.invoiceType) + const [returnAddress, setReturnAddress] = useState(row.returnAddress) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + setInvoiceType(row.invoiceType) + setReturnAddress(row.returnAddress) + setError('') + }, [row]) + + async function save() { + if (saving) return + setSaving(true) + setError('') + try { + const response = await fetch('/api/supply-chain/new-products/dropship-issues/update', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + spu: row.spu, + invoiceType, + returnAddress, + }), + }) + const payload = await response.json().catch(() => null) + if (!response.ok || !payload?.success) { + throw new Error(payload?.error?.message || '保存失败') + } + onSaved({ spu: row.spu, invoiceType, returnAddress }) + onClose() + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : '保存失败') + } finally { + setSaving(false) + } + } + + return ( +
+
+
+
+

维护代发信息

+

{row.spu}

+
+ +
+ +
+
+
+ + +
+
+ {INVOICE_OPTIONS.map(({ value, label, hint, Icon }) => { + const isActive = invoiceType === value + return ( + + ) + })} +
+
+ +
+ +