feat(selfop): expose reconstructed inventory evidence in turnover and export

This commit is contained in:
2026-10-15 13:30:38 +08:00
parent 50b06dca10
commit 5e8c17c82f
6 changed files with 34 additions and 9 deletions
@@ -21,7 +21,7 @@ const volume = { value: 1.25, knownSubtotal: 1.25, missingDimensionQuantity: 0,
const volumes = [7, 15, 30].map(days => ({ days, startDate: '2026-09-13', endDateExclusive: '2026-10-13', volume }))
const row: TurnoverRow = { inventoryVolumeM3: volume, outboundVolumesM3: volumes, unitVolumeM3: 0.0125, dimensionSource: 'WAREHOUSE_MEASURED', 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 ? { inventoryVolumeM3: volume, outboundVolumesM3: volumes, physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 }
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: 'FLOW_EOD_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 ? { inventoryVolumeM3: volume, outboundVolumesM3: volumes, physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 }
}
const wrappers: ReturnType<typeof mount>[] = []
function page() { const w = mount(InventoryTurnoverView, { global: { components: { ElCheckbox }, stubs: { AdminPage: shell, DataTable: table, QueryToolbar: toolbar, AnalyticsChart: true, ChartDetailsDialog: true, AppDialog: defineComponent({ name: 'AppDialog', props: ['modelValue', 'title'], setup(p, { slots }) { return () => h('div', p.modelValue ? slots.default?.() : []) } }), ApiErrorMeta: true, 'el-button': button, 'el-tag': shell, 'el-popover': defineComponent({ setup(_, { slots }) { return () => h('div', [slots.reference?.(), slots.default?.()]) } }) } } }); wrappers.push(w); return w }
@@ -69,7 +69,9 @@ describe('inventory turnover page fixed snapshot behavior', () => {
it('preserves exported column order, notes, unknowns, zero percentages and escapes CSV text', () => {
const q = { sourceAccount: 'primary', warehouseCode: 'W', endDate: '2026-10-14', baseWindowDays: 7, groupBy: 'SKU' as const, focus: 'ALL' as const, sortBy: 'PHYSICAL_DESC' as const, current: 1, size: 20, includeOverview: true }
const csv = turnoverCsv([{ ...row, sku: '=SUM(1,2)', productName: '含"引号\n换行', quantityTurnoverCount: 1.25 }], [{ prop: 'quantityTurnoverRate', label: '期间周转率' }, { prop: 'sku', label: 'SKU' }, { prop: 'productName', label: '商品名称' }], q)
expect(csv).toContain('\uFEFF"期间周转率","SKU","商品名称"\r\n"125%","\'=SUM(1,2)"')
expect(csv).toContain('\uFEFF"期间周转率","SKU","商品名称","统计期间"')
expect(csv).toContain('\r\n"125%","\'=SUM(1,2)"')
expect(csv).toContain('库存流水还原(日末实物库存)')
expect(csv).toContain('含""引号\n换行')
expect(turnoverPercent(null)).toBe('—'); expect(turnoverPercent(0)).toBe('0%')
})
@@ -285,7 +285,7 @@ onUnmounted(() => { exportController?.abort(); fence.cancel(); runtimeFence.canc
</button>
</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>
<p>历史库存记录用于计算期间平均库存和周转次数,与已采集的销量天数不同;记录不足不代表销量采集失败。</p>
<template v-if="data?.rows.length">
<label>查看本页历史库存记录
@@ -386,7 +386,7 @@ onUnmounted(() => { exportController?.abort(); fence.cancel(); runtimeFence.canc
<template #lastDate="{ row }">{{ castRow(row).lastObservedOutboundDate || '已采集历史内未见出库' }}</template>
</DataTable>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" append-to-body fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog>
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="历史库存记录与销量覆盖" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerName || evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};历史库存记录 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }}天;积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}</p><p>缺少库存记录的日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。</p><p>日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天采集成功的合格计划库存样本,最早次日查询时才能纳入窗口;当天样本数仍为0不代表采集未成功,历史缺日不能补零。</p></template></AppDialog>
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="历史库存记录与销量覆盖" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerName || evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};历史库存记录 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }}天;历史覆盖起点 {{ data.context.accumulationStartedOn || '尚无合格历史记录' }}</p><p>缺少库存记录的日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日库存口径:北京时间每日结束时的全品质实物库存,由完整库存流水和独立库存锚点还原;本次查询固定于 {{ beijingTime(data.context.queryAsOf) }} 的已发布版本。</p><p>历史库存记录与销量覆盖分别校验。流水缺页、库存锚点不符或正在校正的日期保持未知;不以当前库存代替历史库存,也不将缺失数据补零。</p></template></AppDialog>
</template>
</AdminPage>
</template>
@@ -7,6 +7,11 @@ export function turnoverPercent(value: TurnoverRow['quantityTurnoverCount']) {
}
function cell(row: TurnoverRow, key: string, query: TurnoverQuery): string {
switch (key) {
case 'period': return `截至 ${query.endDate},最近 ${query.baseWindowDays} 天`
case 'periodOutbound': return windowText(row.windows.find(w => w.days === query.baseWindowDays))
case 'historySource': return '库存流水还原(日末实物库存)'
case 'historyDays': return `${row.qualifiedSampleDays}/${row.requiredSampleDays}`
case 'historyReason': return turnoverReasonsText(row.sampleReasonCodes)
case 'sku': return row.sku ?? '—'
case 'productName': return row.productName ?? '—'
case 'customerCode': return row.customerName || row.customerCode
@@ -33,6 +38,12 @@ function csvCell(value: string) {
return `"${safe.replaceAll('"', '""')}"`
}
export function turnoverCsv(rows: TurnoverRow[], columns: TurnoverExportColumn[], query: TurnoverQuery) {
const required: TurnoverExportColumn[] = [
{ prop: 'period', label: '统计期间' }, { prop: 'periodOutbound', label: '期间出库件数' },
{ prop: 'averagePhysicalQuantity', label: '期间平均日末库存' }, { prop: 'quantityTurnoverRate', label: '期间周转率' },
{ prop: 'historySource', label: '历史库存来源' }, { prop: 'historyDays', label: '合格历史库存天数' }, { prop: 'historyReason', label: '历史库存说明' },
]
columns = [...columns, ...required.filter(c => !columns.some(existing => existing.prop === c.prop))]
return '\uFEFF' + [columns.map(c => csvCell(c.label)).join(','), ...rows.map(row => columns.map(c => csvCell(cell(row, c.prop, query))).join(','))].join('\r\n')
}
export async function collectTurnoverExport(query: TurnoverQuery, total: number, fetchPage: (q: TurnoverQuery, signal: AbortSignal) => Promise<TurnoverResponse>, signal: AbortSignal, progress: (count: number) => void) {
@@ -11,8 +11,8 @@ describe('turnover honest precision and charts', () => {
expect(volumeNote(value, true)).toContain('按当前商品尺寸估算')
})
it('explains missing samples and zero average stock without exposing internal statuses', () => {
expect(turnoverReasonsText(['INSUFFICIENT'])).toBe('合格日库存样本不足')
expect(turnoverReasonsText(['ZERO_AVERAGE_STOCK'])).toContain('每日观测未捕获存量')
expect(turnoverReasonsText(['INSUFFICIENT'])).toBe('合格日末库存记录不足')
expect(turnoverReasonsText(['ZERO_AVERAGE_STOCK'])).toContain('期间平均日末库存为0')
expect(turnoverReasonsText(['INVENTORY_UNKNOWN_OR_STALE'])).toContain('已过期')
})
it('keeps tiny positive values distinct from zero and unknown', () => {
@@ -3,10 +3,22 @@ import type { TurnoverCharts, TurnoverTotal, TurnoverVolume, TurnoverWindowMetri
import type { EChartsOption } from 'echarts'
import { reasonsText } from './state'
const turnoverLabels: Record<string, string> = {
INSUFFICIENT: '合格日库存样本不足', ZERO_AVERAGE_STOCK: '每日观测未捕获存量,无法计算周转',
INDEPENDENT_ANCHOR_MISSING: '缺少独立库存对账记录', INDEPENDENT_ANCHOR_QUALITY_MISSING: '独立库存品质记录不完整',
INDEPENDENT_ANCHOR_INVALID: '独立库存记录无效', INDEPENDENT_ANCHOR_COVERAGE_MISSING: '库存对账区间流水未齐',
INDEPENDENT_ANCHOR_MISMATCH: '流水与独立库存记录不符', INDEPENDENT_ANCHOR_FLOW_UNKNOWN: '库存对账流水待核验',
ANCHOR_TIME_AMBIGUOUS: '采集库存时发生变动,待稳定库存记录核验', ANCHOR_MISSING: '缺少库存锚点',
FLOW_COVERAGE_GAP: '库存流水覆盖不完整', FLOW_TYPE_UNVERIFIED: '流水类型尚未核验', CHAIN_DISCONTINUITY: '批次结存不连续',
CHAIN_ORDER_AMBIGUOUS: '同秒流水顺序待核验', FLOW_QUANTITY_OR_TIME_INVALID: '流水数量或时间待核验',
FLOW_QUANTITY_CONFLICT: '流水变动数量不一致', NEGATIVE_CHAIN_STOCK: '批次结存出现负数',
NEGATIVE_RECONSTRUCTED_STOCK: '还原库存出现负数', UNIT_UNKNOWN: '库存数量单位待确认',
ANCHOR_DUPLICATE: '库存锚点重复', ANCHOR_QUANTITY_INVALID: '库存锚点数量无效',
ANCHOR_INTERVAL_INVALID: '库存观测时间无效', ANCHOR_BEFORE_DAY_END: '库存锚点早于日末',
FLOW_ID_INVALID: '流水标识无效', FLOW_ID_CONFLICT: '流水标识存在冲突', UNKNOWN_QUALITY: '库存品质待确认',
CHAIN_IDENTITY_MISSING: '流水库位或批次缺失',
INSUFFICIENT: '合格日末库存记录不足', ZERO_AVERAGE_STOCK: '期间平均日末库存为0,无法计算周转',
INVENTORY_UNKNOWN_OR_STALE: '当前库存未知、异常或已过期', NOT_APPLICABLE: '货主不计算整体可售天数',
SALES_OR_STOCK_UNASSESSABLE: '部分商品的销量或库存不足以判断',
SALES_WINDOW_UNAVAILABLE: '同期销量或商品观察证据不足',
SALES_WINDOW_UNAVAILABLE: '同期完整销量不足',
}
export function turnoverReasonsText(reasons: readonly string[] | null | undefined) {
return reasons?.map(reason => turnoverLabels[reason] ?? reasonsText([reason])).join('、') || '—'
+1 -1
View File
@@ -40,7 +40,7 @@ export interface TurnoverContext {
snapshotContext: string; sourceAccount: string; warehouseCode: string; endDate: string
baseWindowDays: TurnoverWindow; queryAsOf: string; queryExpiresAt: string; analyticsContext: QueryContext
inventoryObservedFrom: string | null; inventoryObservedUntil: string | null
sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1'; dailySamples: { date: string; runId: string | null }[]
sampleRuleVersion: 'FLOW_EOD_V1'; dailySamples: { date: string; dayVersionId: string | null }[]
missingSampleDates: string[]; accumulationStartedOn: string | null
}
export interface TurnoverOverview {