feat(self-operated): add transit column and streamline dashboard ranking explanations
This commit is contained in:
@@ -13,7 +13,7 @@ const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { r
|
||||
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
|
||||
const total = { value: 100, knownSubtotal: 100, unknownSkuCount: 0, reasonCodes: [] }
|
||||
const risk = { matchedSkuCount: 0, knownPhysicalQuantity: 0, unknownSkuCount: 0, physicalQuantityRatio: 0 }
|
||||
const row: TurnoverRow = { customerCode: 'OWNER-A', customerName: null, sku: 'SAME', productName: '商品', physicalQuantity: total, availableQuantity: total, frozenQuantity: total, defectiveQuantity: total, physicalSkuCount: 1, windows: [7, 15, 30].map(days => ({ days: days as 7 | 15 | 30, startDate: '2026-09-13', endDateExclusive: '2026-10-13', outboundQuantity: 300, observedQuantity: 300, averageDailyOutboundQuantity: 10, completeDayCount: days, partialDayCount: 0, unknownDayCount: 0, dataStatus: 'COMPLETE', reasonCodes: [] })), averageDailyOutboundQuantity: 10, inventoryCoverageDays: 10, coverageStatus: 'VALID', averagePhysicalQuantity: null, quantityTurnoverCount: null, turnoverStatus: 'INSUFFICIENT', qualifiedSampleDays: 0, requiredSampleDays: 30, missingSampleDates: ['2026-10-12'], sampleReasonCodes: [], lastObservedOutboundDate: null, attentionReasons: [], noOutboundInWindow: risk, slowCoverage: risk, unassessableSkuCount: 0 }
|
||||
const row: TurnoverRow = { customerCode: 'OWNER-A', customerName: null, sku: 'SAME', productName: '商品', physicalQuantity: total, availableQuantity: total, frozenQuantity: total, defectiveQuantity: total, transitQuantity: total, physicalSkuCount: 1, windows: [7, 15, 30].map(days => ({ days: days as 7 | 15 | 30, startDate: '2026-09-13', endDateExclusive: '2026-10-13', outboundQuantity: 300, observedQuantity: 300, averageDailyOutboundQuantity: 10, completeDayCount: days, partialDayCount: 0, unknownDayCount: 0, dataStatus: 'COMPLETE', reasonCodes: [] })), averageDailyOutboundQuantity: 10, inventoryCoverageDays: 10, coverageStatus: 'VALID', averagePhysicalQuantity: null, quantityTurnoverCount: null, turnoverStatus: 'INSUFFICIENT', qualifiedSampleDays: 0, requiredSampleDays: 30, missingSampleDates: ['2026-10-12'], sampleReasonCodes: [], lastObservedOutboundDate: null, attentionReasons: [], noOutboundInWindow: risk, slowCoverage: risk, unassessableSkuCount: 0 }
|
||||
function response(q: TurnoverQuery): TurnoverResponse {
|
||||
return { context: { snapshotContext: q.snapshotContext || `token-${q.warehouseCode}`, sourceAccount: q.sourceAccount, warehouseCode: q.warehouseCode, endDate: q.endDate, baseWindowDays: q.baseWindowDays, queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', inventoryObservedFrom: null, inventoryObservedUntil: null, sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1', dailySamples: [], missingSampleDates: ['2026-10-12'], accumulationStartedOn: null, analyticsContext: { salesRunId: '11', inventoryRunId: '22', salesScopeHash: 'a', inventoryScopeHash: 'b', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13' } }, summary: q.includeOverview ? { physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 }
|
||||
}
|
||||
@@ -29,7 +29,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
it('sorts every numeric header on the server in both directions with the same snapshot', async () => {
|
||||
const w = page(); await flushPromises()
|
||||
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
|
||||
const metrics = { physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER' }
|
||||
const metrics = { physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER' }
|
||||
for (const [prop, metric] of Object.entries(metrics)) {
|
||||
// Start on a later page and with another sort to prove that a header resets pagination.
|
||||
await change(w, { sortBy: metric === 'PHYSICAL' ? 'AVAILABLE_DESC' : 'PHYSICAL_DESC' }, true)
|
||||
|
||||
@@ -56,6 +56,7 @@ const columns = computed<DataTableColumn[]>(() => [
|
||||
{ prop: 'customerCode', label: '所属货主', slotName: 'owner', minWidth: 160 },
|
||||
...(ownerMode.value ? [{ prop: 'physicalSkuCount', label: '有实物SKU数', minWidth: 125 }] : []),
|
||||
{ prop: 'physicalQuantity', label: '实物件数', slotName: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slotName: 'availableQuantity', minWidth: 145 },
|
||||
{ prop: 'transitQuantity', label: '在途', slotName: 'transitQuantity', minWidth: 125 },
|
||||
...([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 },
|
||||
...(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 }]),
|
||||
@@ -266,7 +267,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
</template>
|
||||
<template #sku="{ row }"><el-button link type="primary" @click="showTrend(castRow(row))">{{ castRow(row).sku }}</el-button></template>
|
||||
<template #owner="{ row }"><el-button v-if="ownerMode" link type="primary" @click="selectOwner(castRow(row).customerCode)">{{ castRow(row).customerName || castRow(row).customerCode }}</el-button><span v-else>{{ castRow(row).customerName || castRow(row).customerCode }}</span></template>
|
||||
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectiveQuantity'] as const" :key="key" #[key]="{ row }">{{ totalText(castRow(row)[key]) }}</template>
|
||||
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'transitQuantity', 'frozenQuantity', 'defectiveQuantity'] as const" :key="key" #[key]="{ row }">{{ totalText(castRow(row)[key]) }}</template>
|
||||
<template v-for="n in [7, 15, 30] as TurnoverWindow[]" :key="n" #[`outbound${n}`]="{ row }">{{ windowText(castRow(row).windows.find(w => w.days === n)) }}</template>
|
||||
<template #daily="{ row }">{{ turnoverNumber(castRow(row).averageDailyOutboundQuantity) }}</template>
|
||||
<template #coverage="{ row }">{{ turnoverNumber(castRow(row).inventoryCoverageDays, 1) }}<small>{{ reasonsText([castRow(row).coverageStatus]) }}</small></template>
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface BarRow { key: string; label: string; secondary?: string; value:
|
||||
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]!))
|
||||
const palette = ['#5575ed', '#3da9ba', '#e8a54b', '#b083dc', '#df8397', '#8a9ab4', '#a2adbf']
|
||||
export function barChartOption(rows: BarRow[], unit: string, title: string, ranked = false): EChartsOption {
|
||||
const valueText = (value: Quantity | undefined) => unit === '天' && value != null && Number.isFinite(Number(value)) ? Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2, useGrouping: false }) : quantityText(value)
|
||||
return {
|
||||
animation: false,
|
||||
grid: { left: 12, right: 48, top: 12, bottom: 24, containLabel: true },
|
||||
@@ -16,11 +17,11 @@ export function barChartOption(rows: BarRow[], unit: string, title: string, rank
|
||||
tooltip: { trigger: 'item', renderMode: 'html', confine: true, extraCssText: 'max-width: min(300px, 80vw); white-space: normal; overflow-wrap: anywhere;', formatter: params => {
|
||||
const item = Array.isArray(params) ? params[0] : params
|
||||
const row = item ? rows[item.dataIndex] : undefined
|
||||
return row ? `<strong>${escapeHtml(row.label)}</strong><br/>${row.secondary ? `${escapeHtml(row.secondary)}<br/>` : ''}${escapeHtml(quantityText(row.value))} ${escapeHtml(unit)}` : ''
|
||||
return row ? `<strong>${escapeHtml(row.label)}</strong><br/>${row.secondary ? `${escapeHtml(row.secondary)}<br/>` : ''}${escapeHtml(valueText(row.value))} ${escapeHtml(unit)}` : ''
|
||||
} },
|
||||
aria: { enabled: true, description: `${title},横轴为${unit}。未知不作为零值;详细数据和键盘操作在下方完整明细弹框。` },
|
||||
series: [{ type: 'bar', barMaxWidth: ranked ? 14 : 18, data: rows.map((row, index) => ({ value: row.value == null ? null : Number(row.value), itemStyle: { color: palette[ranked ? 0 : index % palette.length], borderRadius: [0, 4, 4, 0] } })),
|
||||
label: { show: true, position: 'right', formatter: params => quantityText(rows[params.dataIndex]?.value) } }],
|
||||
label: { show: true, position: 'right', formatter: params => valueText(rows[params.dataIndex]?.value) } }],
|
||||
}
|
||||
}
|
||||
export function progressGaugeOption(percent: number | null, running = false): EChartsOption {
|
||||
|
||||
@@ -129,3 +129,7 @@
|
||||
| 库存周转/日均出库对比 | 用户截图 `codex-clipboard-16bbd875-0161-4bc1-b86b-08436e702fe6.png` 确认柱顶显示数字、去除图下重复卡片 | 保留所选时段与7/15/30日对比(去重);柱顶显示最多两位小数,单位在纵轴;图下仅展示不完整窗口覆盖提示,完整数值不重复;原查看明细入口保留 | 定向测试与类型检查;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
| 库存周转/可售天数阈值 | 用户截图 `codex-clipboard-814d8477-e5aa-4839-a6e4-12526740efd7.png` 指出搜索区裸数字30含义不明 | 删除搜索区重复阈值输入,仅在消化较慢/较快条件下保留具名的“可售天数阈值(天)”;说明与统计时间范围无关,其余关注条件不提交隐藏阈值;复用现有页内输入与应用按钮 | 定向交互与类型检查;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
| 经营总览/三张榜单精简 | 用户截图确认说明过多占位 | 日期、资格数量、完整性详情及统计口径收进右上角“说明”;卡片仅留一行必要的未采齐/待评估/过期提示,三个图表顶部对齐;消化天数图上最多两位小数,明细保留原值 | 构建与交互回归;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
| 库存周转/在途列 | 用户要求在库存周转表格添加在途 | 正品可售后默认展示在途,SKU及货主汇总共用固定库存版本;表头及筛选区支持全量升降序,未知末尾;全品质独立汇总,缺失显示已知小计与待核验数,不计入实物、可售或周转分母 | 字段映射、品质聚合、未知传播及排序回归;视觉待人工1920×1080、100%截图确认 |
|
||||
|
||||
@@ -93,8 +93,8 @@ describe('warehouse operating dashboard', () => {
|
||||
data.noOutboundSkus = { ...data.noOutboundSkus, items: [], eligibleCount: '0', unassessableCount: '2741', dataStatus: 'PARTIAL' }
|
||||
mocks.dashboard.mockResolvedValue(data)
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '待评估原因')).toHaveLength(2)
|
||||
await findButton(wrapper, '待评估原因').trigger('click'); await flushPromises()
|
||||
expect(wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '说明')).toHaveLength(3)
|
||||
await wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '说明')[1]!.trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('全仓库存分布的待核验原因')
|
||||
expect(wrapper.text()).toContain('待评估 2711 SKU')
|
||||
expect(wrapper.text()).toContain('待评估 2741 SKU')
|
||||
@@ -241,7 +241,7 @@ describe('warehouse operating dashboard', () => {
|
||||
expect(wrapper.findAllComponents(DashboardTrend)).toHaveLength(1)
|
||||
expect(wrapper.text()).toContain('所选时段无出库库存Top10')
|
||||
expect(wrapper.text()).not.toContain('近30日无出库')
|
||||
expect(wrapper.findAllComponents(DashboardBars).slice(1).every(chart => chart.text().includes('2026-10-10 至 2026-10-14'))).toBe(true)
|
||||
expect(wrapper.findAllComponents(DashboardBars).slice(1).every(chart => !chart.text().includes('2026-10-10 至 2026-10-14'))).toBe(true)
|
||||
expect(wrapper.text()).toContain('区间已采集出库量')
|
||||
expect(wrapper.text()).toContain('已知小计 128')
|
||||
expect(wrapper.text()).toContain('另 1 SKU 待核验')
|
||||
@@ -301,6 +301,8 @@ describe('warehouse operating dashboard', () => {
|
||||
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, hotSkus: { ...dashboardFixture.hotSkus, windowStartDate: '2026-10-13', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 1, eligibleCount: '0', items: [], dataStatus: 'UNKNOWN', reasonCodes: ['SALES_WINDOW_INCOMPLETE'] } })
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(wrapper.findAllComponents(DashboardBars)[1]!.props('rows')).toEqual([])
|
||||
expect(wrapper.find('.dashboard-rankings').text()).not.toContain('未采集日期不计为零出库')
|
||||
await findButton(wrapper, '说明').trigger('click'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('未采集日期不计为零出库')
|
||||
await findButton(wrapper, '查看Top100').trigger('click')
|
||||
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ startDate: '2026-10-13', endDate: '2026-10-13', rangePreset: 'CUSTOM' })
|
||||
@@ -309,7 +311,7 @@ describe('warehouse operating dashboard', () => {
|
||||
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, hotSkus: { ...dashboardFixture.hotSkus, windowStartDate: '2026-10-01', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 13, dataStatus: 'PARTIAL', reasonCodes: ['SALES_WINDOW_INCOMPLETE'], items: [{ ...dashboardFixture.hotSkus.items[0], windowOutboundQuantity: '37', value: '37', averageDailyOutboundQuantity: null, inventoryCoverageDays: null }] } })
|
||||
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
|
||||
expect(wrapper.text()).toContain('所选时段热销Top10')
|
||||
expect(wrapper.text()).toContain('按所选时段已采集量排名')
|
||||
expect(wrapper.text()).toContain('数据未采齐,排名仅供参考')
|
||||
const hot = wrapper.findAllComponents(DashboardBars)[1]!
|
||||
expect(hot.props('rows')[0]).toMatchObject({ value: '37' })
|
||||
expect(hot.props('rows')[0].detail).toContain('13天窗口已采集出库 37 件')
|
||||
|
||||
@@ -234,16 +234,20 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
|
||||
<section v-for="ranking in rankingNames" :key="ranking.key" class="dashboard-chart">
|
||||
<DashboardBars ranked :details-width="ranking.key === 'hotSkus' ? '1280px' : undefined" :rows="rankingRows(data[ranking.key])" :title="ranking.label" :unit="ranking.unit" :disabled="contextExpired" @select="index => showTrend(data![ranking.key].items[Number(index)]!, data![ranking.key])">
|
||||
<template #actions>
|
||||
<ChartDetailsDialog v-if="ranking.key !== 'hotSkus'" inline button-label="待评估原因" title="采集覆盖与待评估原因" width="1040px" :reset-on="data">
|
||||
<InventoryRankingDiagnostics :data="data" :warehouse="runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode" :historical="contextExpired || stockExpired" />
|
||||
</ChartDetailsDialog>
|
||||
<el-button link type="primary" :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button>
|
||||
</template>
|
||||
<template #intro>
|
||||
<ChartDetailsDialog inline button-label="说明" :title="`${ranking.label} · 统计说明`" width="1040px" :reset-on="data">
|
||||
<p>{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。</p>
|
||||
<p>{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}</p>
|
||||
<p v-if="ranking.key === 'hotSkus' && data.hotSkus.dataStatus !== 'COMPLETE'">按所选时段已采集量排名,数据尚未采齐,排名可能随采集变化;未采集日期不计为零出库。</p>
|
||||
<p v-else-if="ranking.key !== 'hotSkus' && data[ranking.key].dataStatus !== 'COMPLETE'">本榜仍需所选区间完整销量与库存观察资料;资料不足的 SKU 暂不评估,未采集日期不计为零出库。</p>
|
||||
<InventoryRankingDiagnostics v-if="ranking.key !== 'hotSkus'" :data="data" :warehouse="runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode" :historical="contextExpired || stockExpired" />
|
||||
</ChartDetailsDialog>
|
||||
<el-button link type="primary" :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button>
|
||||
</template>
|
||||
<template #intro>
|
||||
<div class="dashboard-ranking-status" role="status">
|
||||
<span v-if="contextExpired || ranking.key !== 'hotSkus' && stockExpired">历史数据 · 待刷新</span>
|
||||
<span v-else-if="data[ranking.key].dataStatus !== 'COMPLETE'">{{ ranking.key === 'hotSkus' ? '数据未采齐,排名仅供参考' : Number(data[ranking.key].unassessableCount) > 0 ? `${quantityText(data[ranking.key].unassessableCount)} 个SKU待评估` : '数据待核验' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="ranking.key === 'hotSkus'" #details="{ select }"><HotSkuDetails :ranking="data.hotSkus" :disabled="contextExpired" @select="index => select(rankingRows(data!.hotSkus)[index]!)" /></template>
|
||||
</DashboardBars>
|
||||
@@ -273,6 +277,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
|
||||
.dashboard-metric-status { color: var(--metric-color); }
|
||||
.dashboard-kpis p { margin: var(--admin-space-1) 0; }
|
||||
.dashboard-main-charts { display: grid; grid-template-columns: 2fr 1fr; gap: var(--admin-space-4); }
|
||||
.dashboard-ranking-status { min-height: 24px; margin-bottom: var(--admin-space-2); color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption); }
|
||||
.dashboard-rankings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); }
|
||||
.dashboard-chart { min-width: 0; padding: 18px; background: var(--admin-surface); border: 1px solid #e5eaf4; border-radius: 8px; animation: dashboard-reveal .6s ease-out both; }
|
||||
.dashboard-chart p { color: #76849c; font-size: var(--admin-font-caption); line-height: 1.7; }
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { TurnoverSort, TurnoverSortMetric } from './turnoverTypes'
|
||||
|
||||
export const turnoverSortColumns: Record<string, TurnoverSortMetric> = {
|
||||
physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE',
|
||||
physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT',
|
||||
outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30',
|
||||
averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE',
|
||||
frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE',
|
||||
averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER',
|
||||
}
|
||||
const metricLabels: Record<TurnoverSortMetric, string> = {
|
||||
PHYSICAL: '实物件数', AVAILABLE: '正品可售', OUTBOUND_7: '完整7日出库',
|
||||
PHYSICAL: '实物件数', AVAILABLE: '正品可售', TRANSIT: '在途', OUTBOUND_7: '完整7日出库',
|
||||
OUTBOUND_15: '完整15日出库', OUTBOUND_30: '完整30日出库', AVERAGE_DAILY: '基准窗口日均出库',
|
||||
COVERAGE: '预计可售天数', FROZEN: '冻结件数', DEFECTIVE: '次品实物',
|
||||
AVERAGE_PHYSICAL: '期间平均实物', TURNOVER: '期间件数周转',
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { QueryContext, Quantity } from './types'
|
||||
export type TurnoverWindow = number
|
||||
export type TurnoverGroup = 'SKU' | 'OWNER'
|
||||
export type TurnoverFocus = 'ALL' | 'NO_OUTBOUND_IN_WINDOW' | 'COVERAGE_GT' | 'COVERAGE_LE' | 'FROZEN' | 'DEFECTIVE' | 'UNASSESSABLE'
|
||||
export type TurnoverSortMetric = 'PHYSICAL' | 'AVAILABLE' | 'OUTBOUND_7' | 'OUTBOUND_15' | 'OUTBOUND_30' | 'AVERAGE_DAILY' | 'COVERAGE' | 'FROZEN' | 'DEFECTIVE' | 'AVERAGE_PHYSICAL' | 'TURNOVER'
|
||||
export type TurnoverSortMetric = 'PHYSICAL' | 'AVAILABLE' | 'TRANSIT' | 'OUTBOUND_7' | 'OUTBOUND_15' | 'OUTBOUND_30' | 'AVERAGE_DAILY' | 'COVERAGE' | 'FROZEN' | 'DEFECTIVE' | 'AVERAGE_PHYSICAL' | 'TURNOVER'
|
||||
export type TurnoverSort = `${TurnoverSortMetric}_${'ASC' | 'DESC'}` | 'NO_OUTBOUND_PHYSICAL_DESC'
|
||||
export interface TurnoverQuery {
|
||||
sourceAccount: string; warehouseCode: string; endDate: string; baseWindowDays: TurnoverWindow
|
||||
@@ -22,7 +22,7 @@ export interface TurnoverTotal { value: Quantity; knownSubtotal: Quantity; unkno
|
||||
export interface TurnoverRisk { matchedSkuCount: Quantity; knownPhysicalQuantity: Quantity; unknownSkuCount: number; physicalQuantityRatio: Quantity }
|
||||
export interface TurnoverRow {
|
||||
customerCode: string; customerName: string | null; sku: string | null; productName: string | null
|
||||
physicalQuantity: TurnoverTotal; availableQuantity: TurnoverTotal; frozenQuantity: TurnoverTotal; defectiveQuantity: TurnoverTotal
|
||||
physicalQuantity: TurnoverTotal; availableQuantity: TurnoverTotal; frozenQuantity: TurnoverTotal; defectiveQuantity: TurnoverTotal; transitQuantity: TurnoverTotal
|
||||
physicalSkuCount: Quantity; windows: TurnoverWindowMetric[]; averageDailyOutboundQuantity: Quantity
|
||||
inventoryCoverageDays: Quantity; coverageStatus: string; averagePhysicalQuantity: Quantity
|
||||
quantityTurnoverCount: Quantity; turnoverStatus: string; qualifiedSampleDays: number; requiredSampleDays: number
|
||||
|
||||
Reference in New Issue
Block a user