feat(product-archive): 新增「销售金额」表头字段(取每日快照 sales_amount)

- 列表「数据信息」分组新增「销售金额」,位置固定在「真实业绩」前一列
- 取值来自每日快照表 SupplyChainProductArchiveSalesPerformanceDaily.sales_amount,
  与发货金额/退货金额/真实业绩走同一链路,随页面 日/月/年 周期聚合
- 表头漏斗支持按销售金额筛选(可搜索多选标准弹层),列设置/详情弹窗/资料导出随列配置自动生效,
  字段总数 89 → 90
- 新增一次性 localStorage 列序迁移,把上线前保存过的列设置里的新列挪回「真实业绩」前
- 不改数据库结构、不改每日同步脚本;旧的全周期汇总表不加列、不回填
This commit is contained in:
WuYongJie 2026-09-28 16:02:22 +08:00
parent 0fde0bfe4b
commit 8b76cd57db
14 changed files with 211 additions and 12 deletions

View File

@ -95,6 +95,7 @@ export const ARCHIVE_COLUMNS: ArchiveColumnGroup[] = [
{ prop: 'costPriceSecondary', label: '成本价', width: 90 },
{ prop: 'shipAmount', label: '发货金额', width: 100 },
{ prop: 'returnAmount', label: '退货金额', width: 100 },
{ prop: 'salesAmount', label: '销售金额', width: 100 },
{ prop: 'realPerformance', label: '真实业绩', width: 100 },
{ prop: 'shipQty', label: '发货件数', width: 90 },
{ prop: 'grossProfit', label: '毛利额', width: 90 },

View File

@ -1,4 +1,6 @@
import {
migrateSalesAmountColumnOrder,
migrateSalesAmountColumnOrderOnce,
migrateWarehouseColumnOrder,
migrateWarehouseColumnOrderOnce,
type ColumnSetting,
@ -92,3 +94,110 @@ describe('product archive warehouse column order migration', () => {
expect(storage.setItem).not.toHaveBeenCalled()
})
})
describe('product archive sales amount column order migration', () => {
it('moves the appended 销售金额 back in front of 真实业绩', () => {
const settings: ColumnSetting[] = [{
group: '数据信息',
columns: [
{ prop: 'purchasePrice', label: '采购价', width: 90, visible: true },
{ prop: 'shipAmount', label: '发货金额', width: 100, visible: true },
{ prop: 'returnAmount', label: '退货金额', width: 100, visible: true },
{ prop: 'realPerformance', label: '真实业绩', width: 100, visible: true },
{ prop: 'salesAmount', label: '销售金额', width: 100, visible: true },
{ prop: 'shipQty', label: '发货件数', width: 90, visible: true },
],
}]
const migrated = migrateSalesAmountColumnOrder(settings)
expect(migrated[0]?.columns.map((column) => column.prop)).toEqual([
'purchasePrice',
'shipAmount',
'returnAmount',
'salesAmount',
'realPerformance',
'shipQty',
])
})
it('keeps hidden state and width of the migrated column', () => {
const settings: ColumnSetting[] = [{
group: '数据信息',
columns: [
{ prop: 'realPerformance', label: '真实业绩', width: 140, visible: true },
{ prop: 'salesAmount', label: '销售金额', width: 210, visible: false },
],
}]
const migrated = migrateSalesAmountColumnOrder(settings)
expect(migrated[0]?.columns.find((column) => column.prop === 'salesAmount')).toEqual(
expect.objectContaining({ width: 210, visible: false }),
)
})
it('does not change other groups or settings without the column', () => {
const otherGroup: ColumnSetting = {
group: '仓库信息',
columns: [
{ prop: 'warehouseHead', label: '仓库负责人', width: 110, visible: true },
{ prop: 'salesAmount', label: '销售金额', width: 100, visible: true },
],
}
const dataGroup: ColumnSetting = {
group: '数据信息',
columns: [
{ prop: 'shipAmount', label: '发货金额', width: 100, visible: true },
{ prop: 'realPerformance', label: '真实业绩', width: 100, visible: true },
],
}
const migrated = migrateSalesAmountColumnOrder([otherGroup, dataGroup])
expect(migrated[0]).toBe(otherGroup)
expect(migrated[1]).toBe(dataGroup)
})
it('persists the migrated order before marking the migration complete', () => {
const settings: ColumnSetting[] = [{
group: '数据信息',
columns: [
{ prop: 'realPerformance', label: '真实业绩', width: 100, visible: true },
{ prop: 'salesAmount', label: '销售金额', width: 100, visible: true },
],
}]
const storage = {
getItem: jest.fn(() => null),
setItem: jest.fn(),
}
migrateSalesAmountColumnOrderOnce(settings, storage)
expect(storage.setItem.mock.calls.map(([key]) => key)).toEqual([
'caigou_archive_columns',
'caigou_archive_columns_sales_amount_v1',
])
expect(JSON.parse(storage.setItem.mock.calls[0]?.[1] ?? '[]')[0].columns.map(
(column: { prop: string }) => column.prop,
)).toEqual(['salesAmount', 'realPerformance'])
})
it('preserves later manual sorting after the one-time migration', () => {
const settings: ColumnSetting[] = [{
group: '数据信息',
columns: [
{ prop: 'realPerformance', label: '真实业绩', width: 100, visible: true },
],
}]
const storage = {
getItem: jest.fn(() => '1'),
setItem: jest.fn(),
}
const migrated = migrateSalesAmountColumnOrderOnce(settings, storage)
expect(migrated).toBe(settings)
expect(storage.setItem).not.toHaveBeenCalled()
})
})

View File

@ -68,6 +68,47 @@ export function migrateWarehouseColumnOrderOnce(
return migrated
}
const SALES_AMOUNT_ORDER_MIGRATION_KEY = 'caigou_archive_columns_sales_amount_v1'
const SALES_AMOUNT_COLUMN_PROP = 'salesAmount'
const SALES_AMOUNT_ANCHOR_PROP = 'realPerformance'
const DATA_INFO_GROUP_NAME = '数据信息'
/**
* 新增「销售金额」后,已保存过列设置的浏览器会把新字段追加到分组末尾。
* 这里把它挪回「真实业绩」前面,和默认表头顺序一致。
*/
export function migrateSalesAmountColumnOrder(settings: ColumnSetting[]): ColumnSetting[] {
return settings.map((group) => {
if (group.group !== DATA_INFO_GROUP_NAME) return group
const column = group.columns.find((entry) => entry.prop === SALES_AMOUNT_COLUMN_PROP)
if (!column) return group
const rest = group.columns.filter((entry) => entry.prop !== SALES_AMOUNT_COLUMN_PROP)
const anchor = rest.findIndex((entry) => entry.prop === SALES_AMOUNT_ANCHOR_PROP)
if (anchor < 0) return group
const columns = [...rest]
columns.splice(anchor, 0, column)
const unchanged = columns.every((entry, index) => entry === group.columns[index])
return unchanged ? group : { ...group, columns }
})
}
export function migrateSalesAmountColumnOrderOnce(
settings: ColumnSetting[],
storage: Pick<Storage, 'getItem' | 'setItem'>,
): ColumnSetting[] {
if (storage.getItem(SALES_AMOUNT_ORDER_MIGRATION_KEY) === '1') {
return settings
}
const migrated = migrateSalesAmountColumnOrder(settings)
storage.setItem(STORAGE_KEY, JSON.stringify(migrated))
storage.setItem(SALES_AMOUNT_ORDER_MIGRATION_KEY, '1')
return migrated
}
export function persistColumnSettings(settings: ColumnSetting[]) {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
@ -144,7 +185,10 @@ function loadSettings(): ColumnSetting[] {
columns: [...orderedCols, ...customColumns],
}
})
return migrateWarehouseColumnOrderOnce(merged, localStorage)
return migrateSalesAmountColumnOrderOnce(
migrateWarehouseColumnOrderOnce(merged, localStorage),
localStorage,
)
} catch {
return defaults
}

View File

@ -61,6 +61,7 @@ export type SupplyChainProductArchiveRow = {
costPriceSecondary: string
shipAmount: string
returnAmount: string
salesAmount: string
realPerformance: string
shipQty: string
grossProfit: string
@ -213,6 +214,7 @@ export type ProductArchiveFilters = {
returnQty?: string[]
shipAmount?: string[]
returnAmount?: string[]
salesAmount?: string[]
realPerformance?: string[]
shipQty?: string[]
grossProfit?: string[]
@ -244,7 +246,7 @@ export const PRODUCT_ARCHIVE_FILTER_OPTION_KEYS = [
'afterSalesRate', 'afterSalesCost', 'afterSalesCostRate', 'returnQty', 'returnRate',
'partsResendCount', 'partsRate', 'installDocId', 'hasDrawing', 'hasVideo',
'distributionIds', 'opsPriceLimit', 'createDays', 'shipAmount', 'returnAmount',
'realPerformance', 'shipQty', 'grossProfit', 'grossMargin', 'feeRatio',
'salesAmount', 'realPerformance', 'shipQty', 'grossProfit', 'grossMargin', 'feeRatio',
'operatingProfit',
] as const

View File

@ -2,6 +2,7 @@ import sql from 'mssql'
export type ProductArchiveSalesPerformanceAggregate = {
barcode: string
salesAmount: string
shipAmount: string
returnAmount: string
realPerformance: string
@ -24,7 +25,6 @@ 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
@ -138,6 +138,7 @@ class ProductArchiveSalesPerformanceSourceRepositoryClass
.query<ProductArchiveSalesPerformanceAggregate>(`
SELECT
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,

View File

@ -4,7 +4,8 @@ import { Prisma } from '@prisma/client'
import { matchesPerformanceFilters, performanceText, type PeriodPerformance } from './supply-chain-product-archive-sales-period.repository'
import { PRODUCT_ARCHIVE_EMPTY_FILTER_VALUE as empty } from '@/lib/supply-chain-product-archive'
const value: PeriodPerformance = {
shipAmount: new Prisma.Decimal('8.8400'), returnAmount: new Prisma.Decimal(0), realPerformance: new Prisma.Decimal(20),
shipAmount: new Prisma.Decimal('8.8400'), returnAmount: new Prisma.Decimal(0), salesAmount: new Prisma.Decimal('10.5'),
realPerformance: new Prisma.Decimal(20),
shipQty: 1n, returnQty: 0n, grossProfit: new Prisma.Decimal(4), grossMargin: new Prisma.Decimal('.12345678'),
feeRatio: null, operatingProfit: new Prisma.Decimal(-1),
}
@ -13,6 +14,14 @@ it('matches decimal amounts by value and exposes rates as the table shows them',
expect(performanceText(value, 'grossMargin')).toBe('12.35%')
expect(matchesPerformanceFilters(value, { grossMargin: ['0.12345678'] })).toBe(true)
})
it('filters 销售金额 with the same decimal rule as other period amounts', () => {
expect(performanceText(value, 'salesAmount')).toBe('10.5')
expect(matchesPerformanceFilters(value, { salesAmount: ['10.50'] })).toBe(true)
expect(matchesPerformanceFilters(value, { salesAmount: ['10.5'] })).toBe(true)
expect(matchesPerformanceFilters(value, { salesAmount: ['11'] })).toBe(false)
expect(matchesPerformanceFilters(null, { salesAmount: [empty] })).toBe(true)
})
it('treats missing rows and undefined ratios as empty, including when other filters apply', () => {
expect(matchesPerformanceFilters(null, { shipAmount: [empty] })).toBe(true)
expect(matchesPerformanceFilters(value, { feeRatio: [empty] })).toBe(true)

View File

@ -8,11 +8,19 @@ import { buildProductArchiveWhereWithTextRules } from './supply-chain-product-ar
import type { ProductArchiveRecord } from './supply-chain-product-archive-select'
export const PERFORMANCE_KEYS = [
'shipAmount', 'returnAmount', 'realPerformance', 'shipQty', 'grossProfit',
'shipAmount', 'returnAmount', 'salesAmount', 'realPerformance', 'shipQty', 'grossProfit',
'grossMargin', 'feeRatio', 'operatingProfit', 'returnQty',
] as const
export type PerformanceKey = typeof PERFORMANCE_KEYS[number]
export type PeriodPerformance = NonNullable<ProductArchiveRecord['salesPerformance']>
/**
* 周期业绩:按页面选中的 日/月/年 从每日快照表聚合出来的值。
*
* 基础字段与 productArchiveSelect.salesPerformance 对齐;salesAmount 只有每日快照表有,
* 旧的全周期汇总表没有这一列,所以在这里扩展类型,避免为旧表加列和做迁移。
*/
export type PeriodPerformance = NonNullable<ProductArchiveRecord['salesPerformance']> & {
salesAmount: Prisma.Decimal
}
export async function readSalesCoverage(): Promise<SalesCoverage> {
const [row] = await prisma.$queryRaw<SalesCoverage[]>(Prisma.sql`
@ -58,7 +66,7 @@ export async function listPeriodPerformance(filters: ProductArchiveFilters = {})
const period = resolveSalesPeriod(filters, coverage)
const performanceByBarcode = new Map<string, PeriodPerformance>()
if (!period.hasCoverage) return { period, performanceByBarcode }
const money = ['shipAmount', 'returnAmount', 'realPerformance', 'clearSalesCost', 'sendFreight',
const money = ['shipAmount', 'returnAmount', 'salesAmount', 'realPerformance', 'clearSalesCost', 'sendFreight',
'platformCommission', 'xyCost', 'afterSaleAmount', 'pushFeeTotal', 'marketingFeeTotal',
'advertisementFeeTotal', 'sharedWarehousingSum', 'otherFee', 'operatingFee'] as const
const aggregates = await prisma.$queryRaw<ProductArchiveSalesPerformanceAggregate[]>(Prisma.sql`
@ -73,6 +81,7 @@ export async function listPeriodPerformance(filters: ProductArchiveFilters = {})
const value = calculateProductArchiveSalesPerformance(aggregate)
performanceByBarcode.set(value.barcode, {
shipAmount: new Prisma.Decimal(value.shipAmount), returnAmount: new Prisma.Decimal(value.returnAmount),
salesAmount: new Prisma.Decimal(value.salesAmount),
realPerformance: new Prisma.Decimal(value.realPerformance), shipQty: value.shipQty, returnQty: value.returnQty,
grossProfit: new Prisma.Decimal(value.grossProfit),
grossMargin: value.grossMargin === null ? null : new Prisma.Decimal(value.grossMargin),

View File

@ -4,6 +4,7 @@ describe('calculateProductArchiveSalesPerformance', () => {
it('calculates gross profit and gross margin from the confirmed components', () => {
expect(calculateProductArchiveSalesPerformance({
barcode: ' sku-001 ',
salesAmount: '80',
shipAmount: '70',
returnAmount: '5.5',
realPerformance: '100',
@ -22,6 +23,7 @@ describe('calculateProductArchiveSalesPerformance', () => {
operatingFee: '2',
})).toEqual({
barcode: 'SKU-001',
salesAmount: '80.0000',
shipAmount: '70.0000',
returnAmount: '5.5000',
realPerformance: '100.0000',
@ -37,6 +39,7 @@ describe('calculateProductArchiveSalesPerformance', () => {
it('leaves gross margin empty when real performance is zero', () => {
const result = calculateProductArchiveSalesPerformance({
barcode: 'SKU-002',
salesAmount: '0',
shipAmount: '0',
returnAmount: '0',
realPerformance: '0',

View File

@ -77,8 +77,8 @@ describe('product archive full export', () => {
expect(repository.openRowSource).toHaveBeenCalledWith({ productName: ['测试桌'] })
expect(result.total).toBe(1)
expect(result.fieldCount).toBe(89)
expect(headers).toHaveLength(89)
expect(result.fieldCount).toBe(90)
expect(headers).toHaveLength(90)
expect(headers).toContain('商品名称')
expect(headers).toContain('配件率')
expect(worksheet.getCell(2, headers.indexOf('商品名称') + 1).value).toBe('测试桌')
@ -98,7 +98,7 @@ describe('product archive full export', () => {
expect(worksheet.views).toEqual([
expect.objectContaining({ state: 'frozen', ySplit: 1 }),
])
expect(ARCHIVE_COLUMNS.flatMap((group) => group.columns)).toHaveLength(89)
expect(ARCHIVE_COLUMNS.flatMap((group) => group.columns)).toHaveLength(90)
})
it('streams rows in batches instead of materialising them all', async () => {

View File

@ -20,6 +20,7 @@ describe('SupplyChainProductArchiveSalesPerformanceSyncService', () => {
listAggregates: jest.fn().mockResolvedValue([
{
barcode: ' sku-001 ',
salesAmount: '80',
shipAmount: '70',
returnAmount: '5',
realPerformance: '100',
@ -39,6 +40,7 @@ describe('SupplyChainProductArchiveSalesPerformanceSyncService', () => {
},
{
barcode: 'NOT-IN-ARCHIVE',
salesAmount: '20',
shipAmount: '20',
returnAmount: '0',
realPerformance: '20',

View File

@ -3,6 +3,7 @@ import type { ProductArchiveSalesPerformanceAggregate } from '@/server/repositor
export type ProductArchiveSalesPerformanceValues = {
barcode: string
salesAmount: string
shipAmount: string
returnAmount: string
realPerformance: string
@ -40,6 +41,7 @@ export function calculateProductArchiveSalesPerformance(
const barcode = normalizeSalesPerformanceBarcode(aggregate.barcode)
if (!barcode) throw new Error('Sales performance barcode is required')
const salesAmount = decimal(aggregate.salesAmount, 'salesAmount')
const shipAmount = decimal(aggregate.shipAmount, 'shipAmount')
const returnAmount = decimal(aggregate.returnAmount, 'returnAmount')
const realPerformance = decimal(aggregate.realPerformance, 'realPerformance')
@ -69,6 +71,7 @@ export function calculateProductArchiveSalesPerformance(
return {
barcode,
salesAmount: salesAmount.toFixed(4),
shipAmount: shipAmount.toFixed(4),
returnAmount: returnAmount.toFixed(4),
realPerformance: realPerformance.toFixed(4),

View File

@ -170,7 +170,7 @@ export class SupplyChainProductArchiveExportService {
* 返回时只做完了「确定要导哪些行」这步(查 id 快照、装载仓库配置),xlsx 字节
* 还没开始产;调用方拿到 stream 立刻响应,边生成边下发,堆占用与总行数无关。
*
* 这条路径取代了原来的 `createWorkbook`(整包 writeBuffer)——旧写法在 89 列下
* 这条路径取代了原来的 `createWorkbook`(整包 writeBuffer)——旧写法在 90 列下
* 约 60MB / 千行,生产 768MB 堆实际只能撑约 5000 行,99434 行量级的全量导出必崩。
*/
async openWorkbook(params: { filters?: ProductArchiveFilters }): Promise<ProductArchiveExportStream> {

View File

@ -107,6 +107,7 @@ const productArchiveFiltersSchema = z.object({
createDays: archiveValueArraySchema.optional(),
shipAmount: archiveValueArraySchema.optional(),
returnAmount: archiveValueArraySchema.optional(),
salesAmount: archiveValueArraySchema.optional(),
realPerformance: archiveValueArraySchema.optional(),
shipQty: archiveValueArraySchema.optional(),
grossProfit: archiveValueArraySchema.optional(),

View File

@ -1,7 +1,21 @@
import type { Prisma } from '@prisma/client'
import type { SupplyChainProductArchiveRow } from '@/lib/supply-chain-product-archive'
import { storageTypeLabel } from '@/lib/supply-chain-product-archive-values'
import type { ProductArchiveRecord } from '@/server/repositories/supply-chain-product-archive.repository'
/**
* 页面和导出拿到的是「已叠加周期业绩」的档案行:salesPerformance 会被换成按 日/月/年
* 从每日快照表聚合出来的对象,比 productArchiveSelect 的结果多一个 salesAmount
* (每日快照表有这一列,旧的全周期汇总表没有)。这里放宽入参类型,两种形状都接受。
*/
type MappableArchiveRecord = Omit<ProductArchiveRecord, 'salesPerformance'> & {
salesPerformance?:
| (NonNullable<ProductArchiveRecord['salesPerformance']> & {
salesAmount?: Prisma.Decimal | null
})
| null
}
const DAY_IN_MS = 24 * 60 * 60 * 1000
function calculateCreateDays(createdAt: Date, now: Date) {
@ -34,7 +48,7 @@ export function mapProductArchiveRow({
record,
now,
}: {
record: ProductArchiveRecord
record: MappableArchiveRecord
now: Date
}) {
return {
@ -102,6 +116,7 @@ export function mapProductArchiveRow({
costPriceSecondary: record.costPriceSecondary,
shipAmount: decimalText(record.salesPerformance?.shipAmount ?? null),
returnAmount: decimalText(record.salesPerformance?.returnAmount ?? null),
salesAmount: decimalText(record.salesPerformance?.salesAmount ?? null),
realPerformance: decimalText(record.salesPerformance?.realPerformance ?? null),
shipQty: record.salesPerformance?.shipQty.toString() ?? '',
grossProfit: decimalText(record.salesPerformance?.grossProfit ?? null),