From 3f728cd0b2c8392960ad9487fd10e991282ea1ee Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 09:23:29 +0800 Subject: [PATCH] fix(selfop): render inventory turnover cells and data status --- .../components/AnalyticsView.vue | 6 +- .../components/InventoryTurnoverView.test.ts | 52 ++++++++++- .../components/InventoryTurnoverView.vue | 88 +++++++++++++------ src/modules/self-operated/operation-matrix.md | 3 + .../self-operated/pages/InventoryView.test.ts | 9 +- .../self-operated/pages/InventoryView.vue | 18 +++- .../self-operated/turnoverPresentation.ts | 18 +++- 7 files changed, 158 insertions(+), 36 deletions(-) diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue index 05b3f386..39a6e834 100644 --- a/src/modules/self-operated/components/AnalyticsView.vue +++ b/src/modules/self-operated/components/AnalyticsView.vue @@ -18,6 +18,7 @@ import { RequestFence, RuntimePoller, contextParams, hasActiveTask, label, beiji import SkuTrend from './SkuTrend.vue' import { expired, inventoryExpired, parseDashboardDrilldown, previousDate, type DashboardDrilldown } from '../dashboardNavigation' +const emit = defineEmits<{ warehouseSelected: [code: string] }>() const props = defineProps<{ mode: 'sales' | 'inventory' }>() const route = useRoute() const router = useRouter() @@ -74,7 +75,7 @@ const sourceAccount = 'lingxing-primary' const warehouseCode = computed(() => String(filters.value.warehouseCode ?? '')) let selectedWarehouse = warehouseCode.value function defaultWarehouse() { return overview.value?.warehouses.find(row => row.warehouseCode)?.warehouseCode ?? '' } -watch(warehouseCode, code => { if (code) selectedWarehouse = code }) +watch(warehouseCode, code => { if (code) { selectedWarehouse = code; emit('warehouseSelected', code) } }, { immediate: true }) const fields = computed(() => { const result: QueryField[] = [ { key: 'warehouseCode', type: 'select', label: '仓库', width: 210, placeholder: '请选择仓库(必选)', props: { clearable: false }, options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] }, @@ -84,7 +85,7 @@ const fields = computed(() => { { key: 'salesWindowDays', type: 'select', label: '销量窗口', width: 150, props: { clearable: false }, options: [{ value: 10, label: '10个完整自然日' }, { value: 30, label: '30个完整自然日' }] }, { key: 'salesWindowEndDate', type: 'date', label: '统计截止日(含)', width: 180, props: { clearable: false } }, { key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, ...(defaultInventoryWindow.value ? [{ value: 'NO_OUTBOUND_30D', label: '近30天无出库' }] : []), { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] }, - { key: 'threshold', type: 'input', label: '可售天数阈值', width: 160, placeholder: '可售天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } }, + { key: 'threshold', type: 'input', label: '可售天数阈值', width: 180, placeholder: '天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } }, { key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: [{ value: 'AVAILABLE_DESC', label: '正品可售量从高到低' }, { value: 'COVERAGE_DAYS_DESC', label: '可售天数从高到低' }, { value: 'PHYSICAL_DESC', label: '全品质实物从高到低' }, ...(defaultInventoryWindow.value ? [{ value: 'OUTBOUND_30D_ASC', label: '近30天出库从低到高' }] : [])] }, ) else { @@ -283,6 +284,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu

没有自营经营数据查看权限。

+ diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index 0afd7f85..42968086 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -92,3 +92,6 @@ | 数据采集/账号运行看板排版 | 用户本轮截图及此前原型:环图在左,三项数量与预计时间在右,下方阶段流程;当前右侧安排栏挤占进度区 | 主进度改为通栏,三项数字连续排列、预计时间对齐数字区域;后续安排移至阶段下方。使用现有 Admin 白底、蓝紫强调和统一间距,移除数量卡片渐变装饰;刷新仍在标题右侧,接口错误与未知数据如实保留 | 仅前端布局;待人工1920×1080、100%截图确认 | | 数据采集/后续安排位置(2026-10-14) | 用户截图反馈等待处理被移至阶段栏下方;工作区改动将主区域变为单列 | 恢复左侧当前进度、右侧290px后续安排双列,处理阶段保持下方整行;保留自动采集计划和其他业务信息 | 定向测试通过;待人工1920×1080、100%截图确认 | + +| 库存周转/概览与明细(2026-10-14) | 用户截图要求优化样式,并出现对象文本、英文状态、空白出库列 | 概览移出筛选区,四色指标卡区分主数值与核验提示,图表白底分组、空数据提示,快捷筛选显示选中状态;统计说明移入右上角信息气泡。修正 DataTable slotName 契约,趋势及证据弹框根部挂载 | 自动验证另列;待人工1920×1080、100%截图确认 | +| 库存分析/视图切换 | 用户示例要求切换置于搜索内 | 库存周转与库存明细共用下划线导航,经 view-tabs 插槽放入各自表格搜索区顶部;切换保留当前仓库,清除另一视图专属条件 | 待人工1920×1080、100%截图确认 | diff --git a/src/modules/self-operated/pages/InventoryView.test.ts b/src/modules/self-operated/pages/InventoryView.test.ts index 39a3a763..eb49c13d 100644 --- a/src/modules/self-operated/pages/InventoryView.test.ts +++ b/src/modules/self-operated/pages/InventoryView.test.ts @@ -5,15 +5,22 @@ import InventoryView from './InventoryView.vue' const mocks = vi.hoisted(() => ({ route: { query: {} as Record }, push: vi.fn() })) vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) })) const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } }) -const options = { global: { stubs: { AnalyticsView: true, InventoryTurnoverView: true, 'el-button': button } } } +const options = { global: { stubs: { AnalyticsView: defineComponent({ name: 'AnalyticsView', props: ['mode'], setup(_, { slots }) { return () => h('section', { class: 'search-region' }, slots['view-tabs']?.()) } }), InventoryTurnoverView: defineComponent({ name: 'InventoryTurnoverView', setup(_, { slots }) { return () => h('section', { class: 'search-region' }, slots['view-tabs']?.()) } }), 'el-button': button } } } beforeEach(() => { mocks.route = reactive({ query: {} }); mocks.push.mockReset() }) describe('inventory view entry', () => { it('defaults to turnover and switches to detail without reusing turnover filters', async () => { mocks.route.query = { warehouseCode: 'US02', focus: 'FROZEN' } const w = mount(InventoryView, options) expect(w.findComponent({ name: 'InventoryTurnoverView' }).exists()).toBe(true) + expect(w.find('.search-region .inventory-views').exists()).toBe(true) + expect(w.find('button[aria-pressed="true"]').text()).toBe('库存周转') await w.findAll('button').find(b => b.text() === '库存明细')!.trigger('click') expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/inventory', query: { view: 'DETAIL', warehouseCode: 'US02' } }) + mocks.route.query = { view: 'DETAIL', warehouseCode: 'US02' }; await flushPromises() + expect(w.find('button[aria-pressed="true"]').text()).toBe('库存明细') + w.findComponent({ name: 'AnalyticsView' }).vm.$emit('warehouseSelected', 'US01') + await w.findAll('button').find(b => b.text() === '库存周转')!.trigger('click') + expect(mocks.push).toHaveBeenLastCalledWith({ path: '/self-operated/inventory', query: { view: 'TURNOVER', warehouseCode: 'US01' } }) w.unmount() }) it('preserves original strict detail links and rejects contradictory parameters', async () => { diff --git a/src/modules/self-operated/pages/InventoryView.vue b/src/modules/self-operated/pages/InventoryView.vue index b80abcff..64512c6c 100644 --- a/src/modules/self-operated/pages/InventoryView.vue +++ b/src/modules/self-operated/pages/InventoryView.vue @@ -16,9 +16,19 @@ function switchView(view: 'TURNOVER' | 'DETAIL') { } - + diff --git a/src/modules/self-operated/turnoverPresentation.ts b/src/modules/self-operated/turnoverPresentation.ts index f1e828a0..7c3a3df3 100644 --- a/src/modules/self-operated/turnoverPresentation.ts +++ b/src/modules/self-operated/turnoverPresentation.ts @@ -1,4 +1,4 @@ -import type { Quantity } from './types' +import type { Quantity, WarehouseOverview } from './types' import type { TurnoverCharts, TurnoverTotal, TurnoverWindowMetric } from './turnoverTypes' import type { EChartsOption } from 'echarts' import { reasonsText } from './state' @@ -21,6 +21,22 @@ export function totalText(total: TurnoverTotal) { if (total.value == null && total.knownSubtotal == null && total.unknownSkuCount === 0) return turnoverReasonsText(total.reasonCodes) return total.value != null ? turnoverNumber(total.value) : `已知 ${turnoverNumber(total.knownSubtotal)};${total.unknownSkuCount} SKU待核验` } +export function turnoverTotalNote(total: TurnoverTotal) { + if (total.value != null) return '已确认' + const known = total.knownSubtotal == null ? '暂无已知小计' : `已知小计 ${turnoverNumber(total.knownSubtotal)}` + return `${known} · ${total.unknownSkuCount} SKU待核验` +} +export function inventoryCollectionNotice(warehouse: WarehouseOverview | undefined) { + const task = warehouse?.currentTask + if (!task || !['DEAD', 'RETRY_WAIT'].includes(task.taskStatus) || task.latestAttempt?.status !== 'FAILED') return '' + const reasons: Record = { + SELFOP_STOCK_DUPLICATE: '库存分页出现重复记录', + SELFOP_STOCK_COUNT_MISMATCH: '库存扫描条数与来源总数不符', + SELFOP_PAGE_TOTAL_CHANGED: '库存扫描过程中分页总数变化', + } + const reason = reasons[task.latestAttempt.errorCode ?? ''] ?? '库存采集未成功' + return `${task.taskStatus === 'RETRY_WAIT' ? '库存采集失败,等待重试' : '库存采集失败,已停止自动重试'}:${reason}。任务 ${task.taskNo}` +} export function windowText(metric: TurnoverWindowMetric | undefined) { if (!metric) return '—' return metric.dataStatus === 'COMPLETE' ? turnoverNumber(metric.outboundQuantity) : `已采集 ${turnoverNumber(metric.observedQuantity)},覆盖不足`