fix(selfop): enable visible turnover rate sorting and formula help

This commit is contained in:
2026-10-15 14:31:24 +08:00
parent 5e8c17c82f
commit 68284ad2bf
4 changed files with 40 additions and 6 deletions
@@ -13,7 +13,7 @@ vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) }))
const toolbar = defineComponent({ name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup(_, { slots }) { return () => h('div', slots.actions?.()) } }) const toolbar = defineComponent({ name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup(_, { slots }) { return () => h('div', slots.actions?.()) } })
const table = defineComponent({ name: 'DataTable', props: ['data', 'columns', 'total', 'page'], emits: ['update:page', 'update:pageSize'], setup(p, { slots }) { return () => h('div', [slots.filters?.(), slots['toolbar-start']?.(), slots['toolbar-actions']?.(), slots['before-results']?.(), ...(p.columns ?? []).map((column: { headerSlotName?: string; prop: string; label: string }) => column.headerSlotName ? slots[column.headerSlotName]?.({ column: { property: column.prop, label: column.label } }) : null), ...(p.data ?? []).flatMap((row: TurnoverRow) => (p.columns ?? []).map((column: { slotName?: string; prop: keyof TurnoverRow }) => column.slotName && slots[column.slotName] ? slots[column.slotName]?.({ row }) : String(row[column.prop] ?? '')))]) } }) const table = defineComponent({ name: 'DataTable', props: ['data', 'columns', 'total', 'page'], emits: ['update:page', 'update:pageSize'], setup(p, { slots }) { return () => h('div', [slots.filters?.(), slots['toolbar-start']?.(), slots['toolbar-actions']?.(), slots['before-results']?.(), ...(p.columns ?? []).map((column: { headerSlotName?: string; prop: string; label: string }) => column.headerSlotName ? slots[column.headerSlotName]?.({ column: { property: column.prop, label: column.label } }) : null), ...(p.data ?? []).flatMap((row: TurnoverRow) => (p.columns ?? []).map((column: { slotName?: string; prop: keyof TurnoverRow }) => column.slotName && slots[column.slotName] ? slots[column.slotName]?.({ row }) : String(row[column.prop] ?? '')))]) } })
const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } }) const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: (event: MouseEvent) => emit('click', event) }, slots.default?.()) } })
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } }) const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const total = { value: 100, knownSubtotal: 100, unknownSkuCount: 0, reasonCodes: [] } const total = { value: 100, knownSubtotal: 100, unknownSkuCount: 0, reasonCodes: [] }
const risk = { matchedSkuCount: 0, knownPhysicalQuantity: 0, unknownSkuCount: 0, physicalQuantityRatio: 0 } const risk = { matchedSkuCount: 0, knownPhysicalQuantity: 0, unknownSkuCount: 0, physicalQuantityRatio: 0 }
@@ -208,6 +208,26 @@ describe('inventory turnover page fixed snapshot behavior', () => {
await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises() await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ baseWindowDays: 7, sortBy: 'AVERAGE_DAILY_DESC' }) expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ baseWindowDays: 7, sortBy: 'AVERAGE_DAILY_DESC' })
}) })
it.each(['SKU', 'OWNER'] as const)('sorts the default percentage column for %s and keeps formula help independent', async (groupBy) => {
const w = page(); await flushPromises()
await change(w, { groupBy, customerCodes: ['OWNER-A'], rangePreset: 'LAST_7_DAYS' }, true)
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
expect(w.find('button[data-column="quantityTurnoverCount"]').exists()).toBe(false)
const before = mocks.turnover.mock.calls.length
await w.get('button[aria-label="期间周转率计算说明"]').trigger('click'); await flushPromises()
expect(mocks.turnover).toHaveBeenCalledTimes(before)
expect(w.text()).toContain('期间周转率=所选 7 天完整出库量 ÷ 同期平均实物库存 × 100%')
expect(w.text()).toContain('平均实物库存=同期每日末实物库存之和 ÷ 天数')
const header = w.get('button[data-column="quantityTurnoverRate"]')
for (const direction of ['DESC', 'ASC']) {
await header.trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy, customerCodes: ['OWNER-A'], baseWindowDays: 7, endDate: '2026-10-12', hideZeroInventory: true, sortBy: `TURNOVER_${direction}`, current: 1, snapshotContext: 'token-A', includeOverview: false })
expect(header.attributes('aria-label')).toContain(direction === 'ASC' ? '当前升序' : '当前降序')
expect(w.findComponent(toolbar).props('modelValue').sortBy).toBe(`TURNOVER_${direction}`)
}
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
expect(w.get('button[data-column="quantityTurnoverCount"]').attributes('aria-label')).toContain('当前升序')
})
it('keeps the successful sort marker and page when a header query fails', async () => { it('keeps the successful sort marker and page when a header query fails', async () => {
const w = page(); await flushPromises() const w = page(); await flushPromises()
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises() w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
@@ -91,7 +91,7 @@ const columns = computed<DataTableColumn[]>(() => [
{ prop: 'transitQuantity', label: '在途', slotName: 'transitQuantity', minWidth: 125 }, { prop: 'transitQuantity', label: '在途', slotName: 'transitQuantity', minWidth: 125 },
...([7, 15, 30] as const).map(n => ({ prop: `outbound${n}`, label: `${n}日出库`, slotName: `outbound${n}`, minWidth: 160 })), ...([7, 15, 30] as const).map(n => ({ prop: `outbound${n}`, label: `${n}日出库`, slotName: `outbound${n}`, minWidth: 160 })),
{ prop: 'averageDailyOutboundQuantity', label: `${applied.value?.baseWindowDays ?? 30}日日均`, slotName: 'daily', minWidth: 110 }, { prop: 'averageDailyOutboundQuantity', label: `${applied.value?.baseWindowDays ?? 30}日日均`, slotName: 'daily', minWidth: 110 },
{ prop: 'quantityTurnoverRate', label: '期间周转率', slotName: 'turnoverRate', minWidth: 145 }, { prop: 'quantityTurnoverRate', label: '期间周转率', slotName: 'turnoverRate', minWidth: 180 },
...(ownerMode.value ? [{ prop: 'noOutboundInWindow', label: '无出库SKU / 实物', slotName: 'noOutbound', minWidth: 190 }, { prop: 'slowCoverage', label: '慢消化SKU / 待判断', slotName: 'slow', minWidth: 175 }] : [{ prop: 'inventoryCoverageDays', label: '预计可售天数', slotName: 'coverage', minWidth: 150 }]), ...(ownerMode.value ? [{ prop: 'noOutboundInWindow', label: '无出库SKU / 实物', slotName: 'noOutbound', minWidth: 190 }, { prop: 'slowCoverage', label: '慢消化SKU / 待判断', slotName: 'slow', minWidth: 175 }] : [{ prop: 'inventoryCoverageDays', label: '预计可售天数', slotName: 'coverage', minWidth: 150 }]),
...(expanded.value ? [{ prop: 'frozenQuantity', label: '冻结件数', slotName: 'frozenQuantity', minWidth: 125 }, { prop: 'defectiveQuantity', label: '次品实物', slotName: 'defectiveQuantity', minWidth: 125 }, { prop: 'averagePhysicalQuantity', label: '期间平均实物', slotName: 'averageStock', minWidth: 145 }, { prop: 'quantityTurnoverCount', label: '期间件数周转', slotName: 'turnover', minWidth: 145 }, { prop: 'lastObservedOutboundDate', label: '最近已观测出库日', slotName: 'lastDate', minWidth: 180 }] : []), ...(expanded.value ? [{ prop: 'frozenQuantity', label: '冻结件数', slotName: 'frozenQuantity', minWidth: 125 }, { prop: 'defectiveQuantity', label: '次品实物', slotName: 'defectiveQuantity', minWidth: 125 }, { prop: 'averagePhysicalQuantity', label: '期间平均实物', slotName: 'averageStock', minWidth: 145 }, { prop: 'quantityTurnoverCount', label: '期间件数周转', slotName: 'turnover', minWidth: 145 }, { prop: 'lastObservedOutboundDate', label: '最近已观测出库日', slotName: 'lastDate', minWidth: 180 }] : []),
].map(column => ({ ...column, headerSlotName: turnoverSortColumns[column.prop] ? 'sortHeader' : undefined }))) ].map(column => ({ ...column, headerSlotName: turnoverSortColumns[column.prop] ? 'sortHeader' : undefined })))
@@ -279,10 +279,22 @@ onUnmounted(() => { exportController?.abort(); fence.cancel(); runtimeFence.canc
<p v-if="inputError" role="alert">{{ inputError }}</p><p v-if="notice" role="status">{{ notice }}</p> <p v-if="inputError" role="alert">{{ inputError }}</p><p v-if="notice" role="status">{{ notice }}</p>
<DataTable :data="data?.rows ?? []" :columns="columns" :loading="loading" :total="data?.total ?? 0" :page="page" :page-size="size" appearance="integrated" :empty-text="!warehouses.length ? '暂无可用仓库' : error ? '读取失败,请重试' : '暂无符合条件的数据'" @update:page="page = $event; load()" @update:page-size="size = $event; search()"> <DataTable :data="data?.rows ?? []" :columns="columns" :loading="loading" :total="data?.total ?? 0" :page="page" :page-size="size" appearance="integrated" :empty-text="!warehouses.length ? '暂无可用仓库' : error ? '读取失败,请重试' : '暂无符合条件的数据'" @update:page="page = $event; load()" @update:page-size="size = $event; search()">
<template #sortHeader="{ column }"> <template #sortHeader="{ column }">
<span class="turnover-sort-heading">
<button type="button" class="turnover-sort" :data-column="column.property" :disabled="loading" :aria-label="`${column.label}:${columnSortDirection(column.property) === 'ASC' ? '当前升序,' : columnSortDirection(column.property) === 'DESC' ? '当前降序,' : ''}点击${columnSortDirection(column.property) === 'DESC' ? '升序' : '降序'}排列`" @click.stop="sortColumn(column.property)"> <button type="button" class="turnover-sort" :data-column="column.property" :disabled="loading" :aria-label="`${column.label}:${columnSortDirection(column.property) === 'ASC' ? '当前升序,' : columnSortDirection(column.property) === 'DESC' ? '当前降序,' : ''}点击${columnSortDirection(column.property) === 'DESC' ? '升序' : '降序'}排列`" @click.stop="sortColumn(column.property)">
{{ column.label }} {{ column.label }}
<span class="turnover-sort-arrows" aria-hidden="true"><span :class="{ active: columnSortDirection(column.property) === 'ASC' }">▲</span><span :class="{ active: columnSortDirection(column.property) === 'DESC' }">▼</span></span> <span class="turnover-sort-arrows" aria-hidden="true"><span :class="{ active: columnSortDirection(column.property) === 'ASC' }">▲</span><span :class="{ active: columnSortDirection(column.property) === 'DESC' }">▼</span></span>
</button> </button>
<el-popover v-if="column.property === 'quantityTurnoverRate'" title="期间周转率计算说明" trigger="click" placement="bottom" :width="380" teleported>
<template #reference><el-button link aria-label="期间周转率计算说明" title="查看计算公式" @click.stop><Info :size="14" /></el-button></template>
<div class="turnover-rules">
<p>期间周转率=所选 {{ applied?.baseWindowDays ?? 30 }} 天完整出库量 ÷ 同期平均实物库存 × 100%。</p>
<p>平均实物库存=同期每日末实物库存之和 ÷ 天数。历史库存由流水还原并核对;包含冻结、次品实物,不含在途。</p>
<p>货主汇总使用汇总出库量 ÷ 汇总平均实物库存,不取各 SKU 百分比的平均值。</p>
<p>缺少完整销量、合格历史库存或平均库存为 0 时显示“—”;当前库存不代替历史平均库存。100% 表示所选期间出库量相当于平均库存周转一次。</p>
<p>点击列名切换从高到低/从低到高,对全部匹配记录排序,“—”排在最后。</p>
</div>
</el-popover>
</span>
</template> </template>
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span></h2></slot></template> <template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span></h2></slot></template>
<template #toolbar-actions><el-button :disabled="loading || !data || exporting" title="导出当前查询的全部记录与当前显示列(CSV)" @click="exportRows">{{ exporting ? `导出中 ${exportProgress} 条` : '导出' }}</el-button><el-button v-if="exporting" @click="exportController?.abort()">取消导出</el-button><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷流水还原并经库存锚点核对的每日末实物库存均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p> <template #toolbar-actions><el-button :disabled="loading || !data || exporting" title="导出当前查询的全部记录与当前显示列(CSV)" @click="exportRows">{{ exporting ? `导出中 ${exportProgress} 条` : '导出' }}</el-button><el-button v-if="exporting" @click="exportController?.abort()">取消导出</el-button><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷流水还原并经库存锚点核对的每日末实物库存均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p>
@@ -391,6 +403,7 @@ onUnmounted(() => { exportController?.abort(); fence.cancel(); runtimeFence.canc
</AdminPage> </AdminPage>
</template> </template>
<style scoped> <style scoped>
.turnover-sort-heading { display: inline-flex; align-items: center; gap: var(--admin-space-1); }
.turnover-sort { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; } .turnover-sort { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; }
.turnover-sort:disabled { cursor: wait; } .turnover-sort:disabled { cursor: wait; }
.turnover-sort:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 3px; } .turnover-sort:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 3px; }
@@ -163,5 +163,6 @@
| 库存周转/零库存筛选样式 | 用户截图 `codex-clipboard-c21dd284-0aed-4db0-840b-172260c2e3fb.png` | 将原生复选框从查询按钮组移到列表快捷筛选右侧,使用 Element 带边框复选框并垂直居中;无概览、无货主条件时不渲染空白概览行;首次读取失败不提示保留上次结果 | 保留默认选中、即时查询及禁用行为;视觉待人工1920×1080、100%截图确认 | | 库存周转/零库存筛选样式 | 用户截图 `codex-clipboard-c21dd284-0aed-4db0-840b-172260c2e3fb.png` | 将原生复选框从查询按钮组移到列表快捷筛选右侧,使用 Element 带边框复选框并垂直居中;无概览、无货主条件时不渲染空白概览行;首次读取失败不提示保留上次结果 | 保留默认选中、即时查询及禁用行为;视觉待人工1920×1080、100%截图确认 |
| 库存周转/多选货主、导出及周转率 | 用户截图 `codex-clipboard-b295bb06-d54f-45a8-9d9f-d7562da01ac8.png` 与本轮追加要求 | 原货主下拉改为可搜索多选,选项折叠并可清空,SKU/货主两视图及概览、图表同集合查询;列表顶部导出当前查询全部匹配记录为CSV,包含当前显示列与扩展列,显示进度、支持取消;默认显示期间周转率=期间件数周转次数×100%,未知保留—,悬停说明原因及口径 | 多选范围、跨页导出、CSV转义、空值与零值回归;视觉待人工1920×1080、100%截图确认 | | 库存周转/多选货主、导出及周转率 | 用户截图 `codex-clipboard-b295bb06-d54f-45a8-9d9f-d7562da01ac8.png` 与本轮追加要求 | 原货主下拉改为可搜索多选,选项折叠并可清空,SKU/货主两视图及概览、图表同集合查询;列表顶部导出当前查询全部匹配记录为CSV,包含当前显示列与扩展列,显示进度、支持取消;默认显示期间周转率=期间件数周转次数×100%,未知保留—,悬停说明原因及口径 | 多选范围、跨页导出、CSV转义、空值与零值回归;视觉待人工1920×1080、100%截图确认 |
| 库存周转/期间周转率表头 | 用户截图 `codex-clipboard-94ff8645-7783-42b1-a82d-3170295b823a.png` 要求计算口径与排序直接放在默认百分比列 | 默认百分比列接入 TURNOVER 服务端全量升降序,与扩展周转次数及排序下拉同步;保留货主、日期、固定版本、隐藏零库存并回到第一页。列名旁信息按钮独立展示公式、日末均值、货主汇总与未知口径,不触发查询 | 默认列实际点击、两种分组、双向排序与说明按钮回归;视觉待人工1920×1080、100%截图确认 |
| 经营总览/指标卡说明 | 用户反馈“完整”下方破折号含义不清 | 无异常原因时不渲染说明占位符,待核验为0时隐藏该行;真实异常、正数待核验、未知主数值及已知小计保留 | 页面回归及构建;视觉待人工截图确认 | | 经营总览/指标卡说明 | 用户反馈“完整”下方破折号含义不清 | 无异常原因时不渲染说明占位符,待核验为0时隐藏该行;真实异常、正数待核验、未知主数值及已知小计保留 | 页面回归及构建;视觉待人工截图确认 |
+4 -4
View File
@@ -6,19 +6,19 @@ export const turnoverSortColumns: Record<string, TurnoverSortMetric> = {
outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30',
averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE',
frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE',
averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER', quantityTurnoverRate: 'TURNOVER',
} }
const metricLabels: Record<TurnoverSortMetric, string> = { const metricLabels: Record<TurnoverSortMetric, string> = {
INVENTORY_VOLUME: '库存立方数', OUTBOUND_VOLUME: '所选时段出库立方数(估算)', UNIT_VOLUME: '单件体积', INVENTORY_VOLUME: '库存立方数', OUTBOUND_VOLUME: '所选时段出库立方数(估算)', UNIT_VOLUME: '单件体积',
PHYSICAL: '实物件数', AVAILABLE: '正品可售', TRANSIT: '在途', OUTBOUND_7: '完整7日出库', PHYSICAL: '实物件数', AVAILABLE: '正品可售', TRANSIT: '在途', OUTBOUND_7: '完整7日出库',
OUTBOUND_15: '完整15日出库', OUTBOUND_30: '完整30日出库', AVERAGE_DAILY: '基准窗口日均出库', OUTBOUND_15: '完整15日出库', OUTBOUND_30: '完整30日出库', AVERAGE_DAILY: '基准窗口日均出库',
COVERAGE: '预计可售天数', FROZEN: '冻结件数', DEFECTIVE: '次品实物', COVERAGE: '预计可售天数', FROZEN: '冻结件数', DEFECTIVE: '次品实物',
AVERAGE_PHYSICAL: '期间平均实物', TURNOVER: '期间件数周转', AVERAGE_PHYSICAL: '期间平均实物', TURNOVER: '期间周转率',
} }
export const turnoverSortOptions: { value: TurnoverSort; label: string }[] = [ export const turnoverSortOptions: { value: TurnoverSort; label: string }[] = [
...(Object.keys(metricLabels) as TurnoverSortMetric[]).flatMap(metric => [ ...(Object.keys(metricLabels) as TurnoverSortMetric[]).flatMap(metric => [
{ value: `${metric}_DESC` as TurnoverSort, label: `${metricLabels[metric]}从多到少` }, { value: `${metric}_DESC` as TurnoverSort, label: `${metricLabels[metric]}${metric === 'TURNOVER' ? '从高到低' : '从多到少'}` },
{ value: `${metric}_ASC` as TurnoverSort, label: `${metricLabels[metric]}从少到多` }, { value: `${metric}_ASC` as TurnoverSort, label: `${metricLabels[metric]}${metric === 'TURNOVER' ? '从低到高' : '从少到多'}` },
]), ]),
{ value: 'NO_OUTBOUND_PHYSICAL_DESC', label: '已确认无出库实物最多' }, { value: 'NO_OUTBOUND_PHYSICAL_DESC', label: '已确认无出库实物最多' },
] ]