feat(product-archive): 全量档案第二个成本价列改为结算价(模块化 ×0.9)

- 列与变更记录标签:成本价(新品)→ 结算价 / 结算价(新品),表头与导出同步生效
- 结算价在查询映射层实时计算:非模块化沿用成本价原样,模块化按 0.9 折
  (先取整到分再打折、四舍五入保留两位);模块化以商品标签含「模块化」判断,不落库
- 修复列设置合并时 localStorage 旧 label 覆盖默认列名的问题:内置列名
  一律以当前默认定义为准,改名对已保存列设置的用户才能生效
This commit is contained in:
WuYongJie 2026-10-14 17:09:30 +08:00
parent 67834358d2
commit 46ca3bf786
5 changed files with 39 additions and 5 deletions

View File

@ -93,7 +93,7 @@ export const ARCHIVE_COLUMNS: ArchiveColumnGroup[] = [
{ prop: 'priceType', label: '价格类型', width: 100 },
{ prop: 'purchasePrice', label: '采购价', width: 90 },
{ prop: 'costPrice', label: '成本价', width: 90 },
{ prop: 'costPriceSecondary', label: '成本价', width: 90 },
{ prop: 'costPriceSecondary', label: '结算价', width: 90 },
{ prop: 'shipAmount', label: '发货金额', width: 100 },
{ prop: 'returnAmount', label: '退货金额', width: 100 },
{ prop: 'salesAmount', label: '销售金额', width: 100 },

View File

@ -195,7 +195,8 @@ function loadSettings(): ColumnSetting[] {
return dc
? {
...dc,
label: typeof sc.label === 'string' && sc.label.trim() ? sc.label.trim() : dc.label,
// 内置列的 label 以当前默认定义为准:列名是字典口径不是用户偏好,
// 否则改名(如「成本价」→「结算价」)会被 localStorage 里的旧值盖住。
width: Number.isFinite(sc.width) && sc.width > 0 ? sc.width : dc.width,
visible: sc.visible !== false,
}

View File

@ -59,7 +59,7 @@ export const PRODUCT_ARCHIVE_CHANGE_FIELDS = [
{ key: 'stockFactory', label: '工厂库存' },
{ key: 'purchasePrice', label: '采购价' },
{ key: 'costPrice', label: '成本价(聚水潭)' },
{ key: 'costPriceSecondary', label: '成本价(新品)' },
{ key: 'costPriceSecondary', label: '结算价(新品)' },
] as const
export type ProductArchiveChangeFieldKey =

View File

@ -2,6 +2,7 @@ import {
resolveProductArchiveWarehouseFields,
SupplyChainProductArchiveService,
} from '../supply-chain-product-archive.service'
import { settlementPriceText } from '../supply-chain-product-archive.mapper'
import { calculateJstPublicAvailableStock } from '@/lib/supply-chain-product-archive-inventory'
import {
calculateProductArchiveVolume,
@ -197,9 +198,21 @@ describe('supply-chain product archive field mapping', () => {
stockJianshang: '60',
purchasePrice: '88.50',
costPrice: '76.20',
costPriceSecondary: '72.30',
costPriceSecondary: '76.20',
}),
)
})
it('derives the settlement price from the cost price and modular product tag', () => {
expect(settlementPriceText('76.20', '模块化成品,预估尺寸')).toBe('68.58')
expect(settlementPriceText('76.20', '模块化部件,简尚-崔桥工厂半成品包装项目仓')).toBe('68.58')
expect(settlementPriceText('123.45', '模块化成品')).toBe('111.11')
expect(settlementPriceText('85', '模块化部件')).toBe('76.50')
expect(settlementPriceText('76.20', '1688一件代发,已匹配')).toBe('76.20')
expect(settlementPriceText('76.20', '')).toBe('76.20')
expect(settlementPriceText('', '模块化成品')).toBe('')
expect(settlementPriceText(null, '模块化成品')).toBe('')
expect(settlementPriceText('abc', '模块化成品')).toBe('')
})
})

View File

@ -37,6 +37,26 @@ function percentageText(
return value ? `${value.mul(100).toFixed(2)}%` : ''
}
// 结算价展示口径(2026-10-14 用户确认):结算价 = 成本价 × 系数,非模块化 ×1
// 原样展示,模块化 ×0.9(四舍五入保留两位)。模块化以产品标签含「模块化」为
// 准(聚水潭标签里的“模块化成品/模块化部件”)。查询映射层实时计算,不落库。
const SETTLEMENT_MODULAR_TAG = '模块化'
const SETTLEMENT_MODULAR_FACTOR = 0.9
export function settlementPriceText(
costPrice: string | null | undefined,
productTag: string | null | undefined,
) {
const cost = costPrice?.trim() ?? ''
if (!cost) return ''
if (!(productTag ?? '').includes(SETTLEMENT_MODULAR_TAG)) return cost
const value = Number(cost)
if (!Number.isFinite(value)) return ''
// 先取整到分再打折,避免浮点误差让 123.45×0.9 这类半厘值四舍五入方向漂移。
const settled = Math.round(Math.round(value * 100) * SETTLEMENT_MODULAR_FACTOR) / 100
return settled.toFixed(2)
}
export function normalizeSupplierName(value: string) {
return value.trim().toLocaleLowerCase('zh-CN')
}
@ -120,7 +140,7 @@ export function mapProductArchiveRow({
stockFactory: record.stockFactory,
purchasePrice: record.purchasePrice,
costPrice: record.costPrice,
costPriceSecondary: record.costPriceSecondary,
costPriceSecondary: settlementPriceText(record.costPrice, record.productTag),
shipAmount: decimalText(record.salesPerformance?.shipAmount ?? null),
returnAmount: decimalText(record.salesPerformance?.returnAmount ?? null),
salesAmount: decimalText(record.salesPerformance?.salesAmount ?? null),