From 2cf9129606f3f51f09d57366df1a90eee13f456b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 00:07:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/navigation/adminNavigation.test.ts | 2 +- src/core/navigation/adminNavigation.ts | 2 +- src/modules/self-operated/api/analytics.ts | 2 + .../components/AnalyticsView.vue | 19 +- .../components/CollectionAccountMonitor.vue | 22 +- .../components/InventoryTurnoverView.test.ts | 98 ++++++++ .../components/InventoryTurnoverView.vue | 236 ++++++++++++++++++ src/modules/self-operated/operation-matrix.md | 6 + .../self-operated/pages/InventoryView.test.ts | 29 +++ .../self-operated/pages/InventoryView.vue | 22 +- .../self-operated/pages/workbench.test.ts | 8 +- .../self-operated/turnoverNavigation.test.ts | 23 ++ .../self-operated/turnoverNavigation.ts | 48 ++++ .../turnoverPresentation.test.ts | 17 ++ .../self-operated/turnoverPresentation.ts | 30 +++ src/modules/self-operated/turnoverTypes.ts | 48 ++++ 16 files changed, 592 insertions(+), 20 deletions(-) create mode 100644 src/modules/self-operated/components/InventoryTurnoverView.test.ts create mode 100644 src/modules/self-operated/components/InventoryTurnoverView.vue create mode 100644 src/modules/self-operated/pages/InventoryView.test.ts create mode 100644 src/modules/self-operated/turnoverNavigation.test.ts create mode 100644 src/modules/self-operated/turnoverNavigation.ts create mode 100644 src/modules/self-operated/turnoverPresentation.test.ts create mode 100644 src/modules/self-operated/turnoverPresentation.ts create mode 100644 src/modules/self-operated/turnoverTypes.ts diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index b8fe7009..db1a7e2d 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -35,7 +35,7 @@ describe('adminNavigation', () => { { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, { title: '客户服务', children: ['异常订单'] }, { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, - { title: '自营业务', children: ['经营总览', '销量分析', '库存分析', '数据采集'] }, + { title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] }, { title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] }, { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, ]) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 08e58634..65b13116 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -183,7 +183,7 @@ export const adminNavigation: AdminNavigationNode[] = [ }, { key: 'self-operated', - title: '自营业务', + title: '海外仓', icon: PrototypeSalesIcon, children: [ { key: 'self-operated-overview', title: '经营总览', icon: PrototypeDashboardIcon, path: '/self-operated/overview', permission: 'platform:self-operated-analytics:list' }, diff --git a/src/modules/self-operated/api/analytics.ts b/src/modules/self-operated/api/analytics.ts index f880b625..3ba7877d 100644 --- a/src/modules/self-operated/api/analytics.ts +++ b/src/modules/self-operated/api/analytics.ts @@ -1,4 +1,5 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { TurnoverQuery, TurnoverResponse } from '../turnoverTypes' import type { BusinessPage, BusinessQuery, Dashboard, DashboardQuery, DailyResponse, InventoryMetric, InventoryQuery, Metric, Overview, OwnerOption, OwnerOverview, OwnerOverviewQuery, OwnerQuery, RetryCommand, RetryResult, Run, RuntimePage, RuntimeQuery, SalesSummary, Scope, TaskDetail } from '../types' const base = '/platform/v1/last-leg/self-operated-analytics' @@ -7,6 +8,7 @@ async function get(path: string, params: object, signal?: AbortSignal): Promi return unwrapApiResponse(response) } export const analyticsApi = { + turnover: (query: TurnoverQuery, signal?: AbortSignal) => get('inventory-turnover', query, signal), dashboard: (query: DashboardQuery, signal?: AbortSignal) => get('dashboard', query, signal), owners: (query: OwnerQuery, signal?: AbortSignal) => get>('owners', query, signal), ownerOverview: (query: OwnerOverviewQuery, signal?: AbortSignal) => get('owner-overview', query, signal), diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue index 1f9b58e8..05b3f386 100644 --- a/src/modules/self-operated/components/AnalyticsView.vue +++ b/src/modules/self-operated/components/AnalyticsView.vue @@ -1,5 +1,6 @@ + + diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index f488b7fa..7f6d85a9 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -85,3 +85,9 @@ | 图表完整明细遮罩与定位 | 用户截图显示被卡片裁剪 | 所有新增图表弹框 append-to-body;不受卡片 transform 和滚动区域限制 | 待人工1920×1080、100%截图确认 | | 热销Top10/完整明细字段去重 | 用户截图中SKU、货主、出库量重复 | 热销专用1280px弹框表格,身份与各指标独立列,统计区间与覆盖提醒移至表头上方;其他榜单字段保留 | 同SKU不同货主、零/未知值、下钻回归;待人工截图确认 | + +| 库存分析/统计说明与采集时间 | 2026-10-13用户截图要求参考经营总览右上角信息按钮,原提示占据筛选与表格之间空间 | AnalyticsView共用库存/销量工作台将常规口径和采集时间移入右侧460px点击信息气泡,根部挂载、长内容内部滚动;保留页面查询失败、过期/新数据、日期未应用与来源条件提示 | 说明字段与查询逻辑不变;待人工1920×1080、100%截图确认 | + +| 数据采集/账号运行看板排版 | 用户本轮截图及此前原型:环图在左,三项数量与预计时间在右,下方阶段流程;当前右侧安排栏挤占进度区 | 主进度改为通栏,三项数字连续排列、预计时间对齐数字区域;后续安排移至阶段下方。使用现有 Admin 白底、蓝紫强调和统一间距,移除数量卡片渐变装饰;刷新仍在标题右侧,接口错误与未知数据如实保留 | 仅前端布局;待人工1920×1080、100%截图确认 | + +| 数据采集/后续安排位置(2026-10-14) | 用户截图反馈等待处理被移至阶段栏下方;工作区改动将主区域变为单列 | 恢复左侧当前进度、右侧290px后续安排双列,处理阶段保持下方整行;保留自动采集计划和其他业务信息 | 定向测试通过;待人工1920×1080、100%截图确认 | diff --git a/src/modules/self-operated/pages/InventoryView.test.ts b/src/modules/self-operated/pages/InventoryView.test.ts new file mode 100644 index 00000000..39a3a763 --- /dev/null +++ b/src/modules/self-operated/pages/InventoryView.test.ts @@ -0,0 +1,29 @@ +import { defineComponent, h, reactive } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +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 } } } +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) + await w.findAll('button').find(b => b.text() === '库存明细')!.trigger('click') + expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/inventory', query: { view: 'DETAIL', warehouseCode: 'US02' } }) + w.unmount() + }) + it('preserves original strict detail links and rejects contradictory parameters', async () => { + mocks.route.query = { from: 'overview', context: 'fixed-version' } + const w = mount(InventoryView, options) + expect(w.findComponent({ name: 'AnalyticsView' }).props('mode')).toBe('inventory') + mocks.route.query = { from: 'overview', view: 'TURNOVER' }; await flushPromises() + expect(w.find('[role="alert"]').text()).toContain('不能混用') + expect(w.findComponent({ name: 'AnalyticsView' }).exists()).toBe(false) + expect(w.findComponent({ name: 'InventoryTurnoverView' }).exists()).toBe(false) + w.unmount() + }) +}) diff --git a/src/modules/self-operated/pages/InventoryView.vue b/src/modules/self-operated/pages/InventoryView.vue index afc9ffa5..b80abcff 100644 --- a/src/modules/self-operated/pages/InventoryView.vue +++ b/src/modules/self-operated/pages/InventoryView.vue @@ -1,4 +1,24 @@ - + + diff --git a/src/modules/self-operated/pages/workbench.test.ts b/src/modules/self-operated/pages/workbench.test.ts index f0ce5f7b..19727891 100644 --- a/src/modules/self-operated/pages/workbench.test.ts +++ b/src/modules/self-operated/pages/workbench.test.ts @@ -28,7 +28,7 @@ const button = defineComponent({ props: ['disabled', 'nativeType'], emits: ['cli const input = defineComponent({ props: ['modelValue', 'id'], emits: ['update:modelValue'], setup(props, { emit }) { return () => h('textarea', { id: props.id, value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value) }) } }) const loadError = defineComponent({ props: ['message'], setup(props) { return () => h('p', { role: 'alert' }, props.message) } }) const dialog = defineComponent({ props: ['modelValue', 'title', 'confirmLoading'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-test': 'detail-dialog' }, slots.default?.()) : null } }) -const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, AppDialog: dialog, SkuTrend: true, OwnerPicker: true, 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } } +const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, AppDialog: dialog, SkuTrend: true, OwnerPicker: true, 'el-popover': defineComponent({ setup(_, { slots }) { return () => h('aside', { 'data-test': 'information-popover' }, [slots.reference?.(), slots.default?.()]) } }), 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } } const wrappers: ReturnType[] = [] function findButton(wrapper: ReturnType, text: string) { const result = wrapper.findAll('button').find(item => item.text() === text); if (!result) throw new Error(`missing button ${text}`); return result } async function setFilters(wrapper: ReturnType, values: Record) { const toolbar = wrapper.findComponent(queryStub); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() } @@ -270,7 +270,8 @@ describe('self-operated actual workbench request state', () => { mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', lastPublishedAt: '2026-10-12T02:00:00Z', inventoryObservedFrom: '2026-10-12T01:30:00Z', inventoryObservedUntil: '2026-10-12T01:45:00Z' } as WarehouseOverview] }) const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper) await flushPromises() - const note = wrapper.get('[data-testid="inventory-observation"]') + expect(wrapper.find('[data-test="information-popover"] [aria-label="统计口径与采集时间"]').exists()).toBe(true) + const note = wrapper.get('[data-test="information-popover"] [data-testid="inventory-observation"]') expect(note.text()).toContain('2026/10/12 08:00:00 至 2026/10/12 08:12:00') expect(note.text()).toContain('本仓一轮完整分页观测') expect(note.text()).toContain('不代表同一瞬间的库存') @@ -312,7 +313,8 @@ describe('self-operated actual workbench request state', () => { expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).not.toHaveProperty('customerCode') expect(mocks.api.ownerOverview).not.toHaveBeenCalled() expect(wrapper.text()).not.toContain('货主经营') - expect(wrapper.find('details').attributes('open')).toBeUndefined() + expect(wrapper.find('details').exists()).toBe(false) + expect(wrapper.find('[aria-label="统计口径与采集时间"]').exists()).toBe(true) }) it('rejects missing threshold and drills inventory SKU to fixed 30-day daily data', async () => { const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper) diff --git a/src/modules/self-operated/turnoverNavigation.test.ts b/src/modules/self-operated/turnoverNavigation.test.ts new file mode 100644 index 00000000..f7b887b0 --- /dev/null +++ b/src/modules/self-operated/turnoverNavigation.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { inventoryView, turnoverRoute, validateTurnover, yesterdayBeijing } from './turnoverNavigation' +import type { TurnoverQuery } from './turnoverTypes' +const valid: TurnoverQuery = { sourceAccount: 'primary', warehouseCode: 'W', groupBy: 'SKU', endDate: '2026-10-12', baseWindowDays: 30, focus: 'ALL', sortBy: 'PHYSICAL_DESC', current: 1, size: 20, includeOverview: true } +describe('inventory view and turnover query contract', () => { + it('defaults to turnover but preserves strict dashboard and legacy detail links', () => { + expect(inventoryView({ warehouseCode: 'W', sku: 'X' })).toBe('TURNOVER') + for (const q of [{ from: 'overview' }, { drilldownFrom: 'DASHBOARD' }, { salesWindowDays: '10' }, { attention: 'FROZEN_STOCK' }, { view: 'DETAIL' }]) expect(inventoryView(q)).toBe('DETAIL') + }) + it('rejects conflicting views and unknown or duplicate parameters', () => { + for (const q of [{ view: 'TURNOVER', from: 'overview' }, { view: 'TURNOVER', attention: 'ALL' }, { view: 'DETAIL', groupBy: 'SKU' }, { view: ['TURNOVER'] }, { view: 'OTHER' }]) expect(() => inventoryView(q)).toThrow() + expect(() => turnoverRoute({ salesRunId: 'invented' })).toThrow() + expect(() => turnoverRoute({ groupBy: ['OWNER', 'SKU'] })).toThrow() + }) + it('validates independent focus, threshold, group, date and pagination boundaries', () => { + expect(() => validateTurnover(valid, '2026-10-12')).not.toThrow() + for (const patch of [{ endDate: '2026-02-30' }, { endDate: '2026-10-13' }, { groupBy: 'OWNER', sku: 'X' }, { groupBy: 'OWNER', sortBy: 'COVERAGE_DESC' }, { focus: 'COVERAGE_GT' }, { coverageDaysThreshold: 1.5 }, { size: 101 }, { current: 0 }, { baseWindowDays: 10 }]) expect(() => validateTurnover({ ...valid, ...patch } as TurnoverQuery, '2026-10-12')).toThrow() + expect(() => validateTurnover({ ...valid, focus: 'COVERAGE_LE', coverageDaysThreshold: 45 }, '2026-10-12')).not.toThrow() + }) + it('uses Beijing yesterday across UTC day boundaries', () => { + expect(yesterdayBeijing(new Date('2026-10-12T16:01:00Z'))).toBe('2026-10-12') + }) +}) diff --git a/src/modules/self-operated/turnoverNavigation.ts b/src/modules/self-operated/turnoverNavigation.ts new file mode 100644 index 00000000..63390c8a --- /dev/null +++ b/src/modules/self-operated/turnoverNavigation.ts @@ -0,0 +1,48 @@ +import type { TurnoverQuery, TurnoverSort } from './turnoverTypes' +export const turnoverSorts: TurnoverSort[] = ['PHYSICAL_DESC', 'AVAILABLE_DESC', 'OUTBOUND_7_DESC', 'OUTBOUND_15_DESC', 'OUTBOUND_30_DESC', 'COVERAGE_ASC', 'COVERAGE_DESC', 'TURNOVER_DESC', 'NO_OUTBOUND_PHYSICAL_DESC'] +const detailOnly = ['salesWindowDays', 'salesWindowEndDate', 'attention', 'coverageSelection', 'coverageBucket', 'bucketRuleVersion'] +const turnoverOnly = ['groupBy', 'baseWindowDays', 'focus', 'turnoverStatus', 'snapshotContext'] +function scalar(q: Record, key: string) { + const v = q[key] + if (v == null) return undefined + if (typeof v !== 'string' || !v.trim()) throw new Error(`参数 ${key} 无效`) + return v +} +export function inventoryView(q: Record): 'TURNOVER' | 'DETAIL' { + const view = scalar(q, 'view') + if (view && view !== 'TURNOVER' && view !== 'DETAIL') throw new Error('库存视图参数无效') + const strict = q.from === 'overview' || q.drilldownFrom === 'DASHBOARD' + const detail = strict || detailOnly.some(k => q[k] != null) + if (view === 'TURNOVER' && detail) throw new Error('库存周转不能混用总览下钻或库存明细条件') + const selected = detail || view === 'DETAIL' ? 'DETAIL' : 'TURNOVER' + if (selected === 'DETAIL' && turnoverOnly.some(k => q[k] != null)) throw new Error('库存明细不能携带周转专属条件') + return selected +} +export function yesterdayBeijing(now = new Date()) { + const date = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now) + return new Date(Date.parse(`${date}T00:00:00Z`) - 86400000).toISOString().slice(0, 10) +} +export function validateTurnover(q: TurnoverQuery, latestDate = yesterdayBeijing()) { + if (!q.warehouseCode.trim()) throw new Error('请选择可用仓库') + if (!/^\d{4}-\d{2}-\d{2}$/.test(q.endDate) || !Number.isFinite(Date.parse(q.endDate)) || new Date(q.endDate).toISOString().slice(0, 10) !== q.endDate || q.endDate > latestDate) throw new Error('截止日必须是已结束的北京时间日期') + if (![7, 15, 30].includes(q.baseWindowDays)) throw new Error('计算基准仅支持7、15、30天') + if (!['SKU', 'OWNER'].includes(q.groupBy)) throw new Error('分组无效') + if (q.groupBy === 'OWNER' && q.sku) throw new Error('货主汇总不能携带SKU搜索') + if (!['ALL', 'NO_OUTBOUND_30D', 'COVERAGE_GT', 'COVERAGE_LE', 'FROZEN', 'DEFECTIVE', 'UNASSESSABLE'].includes(q.focus)) throw new Error('关注条件无效') + if (q.coverageDaysThreshold != null && (!Number.isSafeInteger(q.coverageDaysThreshold) || q.coverageDaysThreshold <= 0)) throw new Error('阈值必须为正整数天数') + if (['COVERAGE_GT', 'COVERAGE_LE'].includes(q.focus) && q.coverageDaysThreshold == null) throw new Error('请填写快慢判断的天数阈值') + if (q.turnoverStatus && q.turnoverStatus !== 'INSUFFICIENT') throw new Error('周转样本条件无效') + if (!turnoverSorts.includes(q.sortBy) || q.groupBy === 'OWNER' && ['COVERAGE_ASC', 'COVERAGE_DESC'].includes(q.sortBy) || q.groupBy === 'SKU' && q.sortBy === 'NO_OUTBOUND_PHYSICAL_DESC') throw new Error('排序不适用于当前分组') + if (!Number.isSafeInteger(q.current) || q.current < 1 || !Number.isSafeInteger(q.size) || q.size < 1 || q.size > 100) throw new Error('分页参数无效') +} +export function turnoverRoute(q: Record): Partial { + if (inventoryView(q) !== 'TURNOVER') throw new Error('当前为库存明细入口') + const allowed = new Set(['view', 'warehouseCode', 'sku', 'customerCode', 'endDate', 'baseWindowDays', 'groupBy', 'focus', 'coverageDaysThreshold', 'turnoverStatus', 'sortBy', 'snapshotContext', 'current', 'size']) + for (const key of Object.keys(q)) if (!allowed.has(key)) throw new Error(`库存周转不接受参数 ${key}`) + const result: Record = {} + for (const key of allowed) { + const value = scalar(q, key) + if (value != null && key !== 'view') result[key] = ['baseWindowDays', 'coverageDaysThreshold', 'current', 'size'].includes(key) ? Number(value) : value + } + return result as Partial +} diff --git a/src/modules/self-operated/turnoverPresentation.test.ts b/src/modules/self-operated/turnoverPresentation.test.ts new file mode 100644 index 00000000..b9a8bdfe --- /dev/null +++ b/src/modules/self-operated/turnoverPresentation.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { turnoverChart, turnoverNumber, windowText } from './turnoverPresentation' +import type { TurnoverCharts, TurnoverWindowMetric } from './turnoverTypes' +describe('turnover honest precision and charts', () => { + it('keeps tiny positive values distinct from zero and unknown', () => { + expect(turnoverNumber(0.000001)).toBe('<0.01') + expect(turnoverNumber(0.01, 1)).toBe('<0.1') + expect(turnoverNumber(0)).toBe('0'); expect(turnoverNumber(null)).toBe('—') + }) + it('does not draw partial-window totals as daily averages or zero bars', () => { + const partial: TurnoverWindowMetric = { days: 15, startDate: '2026-09-28', endDateExclusive: '2026-10-13', dataStatus: 'PARTIAL', observedQuantity: 150, outboundQuantity: null, averageDailyOutboundQuantity: null, completeDayCount: 7, partialDayCount: 1, unknownDayCount: 7, reasonCodes: [] } + const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, averageDaily: [{ ...partial, days: 7, dataStatus: 'COMPLETE', averageDailyOutboundQuantity: 10 }, partial] } + const option = turnoverChart(charts, 'daily') + expect(JSON.stringify(option.series)).toContain('"data":[10,null]') + expect(windowText(partial)).toBe('已采集 150,覆盖不足') + }) +}) diff --git a/src/modules/self-operated/turnoverPresentation.ts b/src/modules/self-operated/turnoverPresentation.ts new file mode 100644 index 00000000..8da906c9 --- /dev/null +++ b/src/modules/self-operated/turnoverPresentation.ts @@ -0,0 +1,30 @@ +import type { Quantity } from './types' +import type { TurnoverCharts, TurnoverTotal, TurnoverWindowMetric } from './turnoverTypes' +import type { EChartsOption } from 'echarts' +export function turnoverNumber(value: Quantity | undefined, decimals = 2) { + if (value == null || value === '' || !Number.isFinite(Number(value))) return '—' + const n = Number(value) + if (n > 0 && n < 10 ** -decimals) return `<${(10 ** -decimals).toFixed(decimals)}` + return n.toLocaleString('zh-CN', { maximumFractionDigits: decimals }) +} +export function totalText(total: TurnoverTotal) { + return total.value != null ? turnoverNumber(total.value) : `已知 ${turnoverNumber(total.knownSubtotal)};${total.unknownSkuCount} SKU待核验` +} +export function windowText(metric: TurnoverWindowMetric | undefined) { + if (!metric) return '—' + return metric.dataStatus === 'COMPLETE' ? turnoverNumber(metric.outboundQuantity) : `已采集 ${turnoverNumber(metric.observedQuantity)},覆盖不足` +} +export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'daily'): EChartsOption { + const owners = kind === 'owners' + const rows = owners ? charts.ownerPhysicalTop : charts.averageDaily + return { + aria: { enabled: true }, animationDuration: 450, color: ['#6c7bea'], + tooltip: { trigger: 'axis', renderMode: 'richText' }, grid: { left: 12, right: 24, top: 16, bottom: 20, containLabel: true }, + xAxis: owners ? { type: 'value', name: '件' } : { type: 'category', data: charts.averageDaily.map(w => `${w.days}日`) }, + yAxis: owners ? { type: 'category', inverse: true, data: charts.ownerPhysicalTop.map(w => w.customerName || w.customerCode) } : { type: 'value', name: '件/日' }, + series: [{ type: 'bar', barMaxWidth: 24, itemStyle: { borderRadius: 4 }, data: rows.map((_, i) => { + const value = owners ? charts.ownerPhysicalTop[i]?.physicalQuantity : charts.averageDaily[i]?.dataStatus === 'COMPLETE' ? charts.averageDaily[i]?.averageDailyOutboundQuantity : null + return value == null ? null : Number(value) + }) }], + } +} diff --git a/src/modules/self-operated/turnoverTypes.ts b/src/modules/self-operated/turnoverTypes.ts new file mode 100644 index 00000000..d89c385d --- /dev/null +++ b/src/modules/self-operated/turnoverTypes.ts @@ -0,0 +1,48 @@ +import type { QueryContext, Quantity } from './types' + +// Inventory-turnover integration contract. Backend implementation and HTTP acceptance are pending. +export type TurnoverWindow = 7 | 15 | 30 +export type TurnoverGroup = 'SKU' | 'OWNER' +export type TurnoverFocus = 'ALL' | 'NO_OUTBOUND_30D' | 'COVERAGE_GT' | 'COVERAGE_LE' | 'FROZEN' | 'DEFECTIVE' | 'UNASSESSABLE' +export type TurnoverSort = 'PHYSICAL_DESC' | 'AVAILABLE_DESC' | 'OUTBOUND_7_DESC' | 'OUTBOUND_15_DESC' | 'OUTBOUND_30_DESC' | 'COVERAGE_ASC' | 'COVERAGE_DESC' | 'TURNOVER_DESC' | 'NO_OUTBOUND_PHYSICAL_DESC' +export interface TurnoverQuery { + sourceAccount: string; warehouseCode: string; endDate: string; baseWindowDays: TurnoverWindow + groupBy: TurnoverGroup; customerCode?: string; sku?: string; focus: TurnoverFocus + coverageDaysThreshold?: number; turnoverStatus?: 'INSUFFICIENT'; sortBy: TurnoverSort + current: number; size: number; includeOverview: boolean; snapshotContext?: string +} +export interface TurnoverWindowMetric { + days: TurnoverWindow; startDate: string; endDateExclusive: string + outboundQuantity: Quantity; observedQuantity: Quantity; averageDailyOutboundQuantity: Quantity + completeDayCount: number; partialDayCount: number; unknownDayCount: number + dataStatus: string; reasonCodes: string[] +} +export interface TurnoverTotal { value: Quantity; knownSubtotal: Quantity; unknownSkuCount: number; reasonCodes: string[] } +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 + physicalSkuCount: Quantity; windows: TurnoverWindowMetric[]; averageDailyOutboundQuantity: Quantity + inventoryCoverageDays: Quantity; coverageStatus: string; averagePhysicalQuantity: Quantity + quantityTurnoverCount: Quantity; turnoverStatus: string; qualifiedSampleDays: number; requiredSampleDays: number + missingSampleDates: string[]; sampleReasonCodes: string[]; lastObservedOutboundDate: string | null + attentionReasons: string[]; noOutbound30d: TurnoverRisk; slowCoverage: TurnoverRisk; unassessableSkuCount: number +} +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 }[] + missingSampleDates: string[]; accumulationStartedOn: string | null +} +export interface TurnoverOverview { + physicalQuantity: TurnoverTotal; availableQuantity: TurnoverTotal; noOutboundSkuCount: TurnoverTotal; unassessableSkuCount: TurnoverTotal +} +export interface TurnoverCharts { + ownerPhysicalTop: { customerCode: string; customerName: string | null; physicalQuantity: Quantity }[] + unknownOwnerCount: number; averageDaily: TurnoverWindowMetric[] +} +export interface TurnoverResponse { + context: TurnoverContext; summary: TurnoverOverview | null; charts: TurnoverCharts | null + rows: TurnoverRow[]; total: number +}