生产更新:同步已提交的海外仓分析及业务修复(2026-10-16) #20
@@ -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('、') || '—'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user