From 46364d3f895f64c5eab3a56122f66175656217c6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 16:15:40 +0800 Subject: [PATCH] feat(selfop): show customer names and filter sales and turnover --- src/modules/self-operated/AGENTS.md | 2 +- src/modules/self-operated/api/analytics.ts | 1 + .../components/AnalyticsView.vue | 24 +++++++++++-------- .../CollectionAccountMonitor.test.ts | 6 +++++ .../components/CollectionAccountMonitor.vue | 8 ++++--- .../components/CollectionTaskProgress.vue | 6 ++--- .../components/HotSkuDetails.test.ts | 3 ++- .../components/HotSkuDetails.vue | 2 +- .../components/InventoryTurnoverView.test.ts | 13 ++++++++-- .../components/InventoryTurnoverView.vue | 7 +++++- src/modules/self-operated/operation-matrix.md | 9 +++++++ .../self-operated/pages/OverviewView.vue | 2 +- .../self-operated/pages/workbench.test.ts | 17 ++++++++++--- src/modules/self-operated/state.ts | 2 +- src/modules/self-operated/types.ts | 3 ++- .../self-operated/useOwnerDirectory.test.ts | 24 +++++++++++++++++++ .../self-operated/useOwnerDirectory.ts | 24 +++++++++++++++++++ 17 files changed, 125 insertions(+), 28 deletions(-) create mode 100644 src/modules/self-operated/useOwnerDirectory.test.ts create mode 100644 src/modules/self-operated/useOwnerDirectory.ts diff --git a/src/modules/self-operated/AGENTS.md b/src/modules/self-operated/AGENTS.md index cdd9bd6a..51e1f007 100644 --- a/src/modules/self-operated/AGENTS.md +++ b/src/modules/self-operated/AGENTS.md @@ -2,7 +2,7 @@ - 模块入口 `routes.ts`;正式页面 `pages/OverviewView.vue`、`SalesView.vue`、`InventoryView.vue`、`CollectionView.vue`。根路由、导航、IAM 由总控装配。 - 文档路径从工作区根起算;本目录代码路径从当前模块起算。总览设计变更查 `docs/自营业务/经营总览图表设计.md`,采集页面变更查 `docs/自营业务/数据采集页面设计.md`;纯文案或局部代码定位不默认加载设计全文。 -- `analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;时间筛选统一影响区间出库、日均、日趋势、库存分布及三个榜单,热销按已采集量排名并标明覆盖不足,库存评估按所选区间完整销量及观察资格计算;库存数量仍取固定当前版本。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD` 并从原图上下文传递 CUSTOM 起止日期,不能混用普通库存的10/30日窗口,过期禁止冒充原图结果。不得恢复货主独立筛选或面板。 +- `analyticsApi.dashboard` 独立于采集 `overview`。六项指标、日趋势、七桶和三榜共用一次服务端上下文;时间筛选统一影响区间出库、日均、日趋势、库存分布及三个榜单,热销按已采集量排名并标明覆盖不足,库存评估按所选区间完整销量及观察资格计算;库存数量仍取固定当前版本。`dashboardNavigation.ts` 校验下钻版本、日期、枚举、互斥条件和固定返回入口;库存严格下钻必须保留 `drilldownFrom=DASHBOARD` 并从原图上下文传递 CUSTOM 起止日期,不能混用普通库存的10/30日窗口,过期禁止冒充原图结果。经营总览不增加货主筛选或面板;销量、库存明细和库存周转支持按客户名称/编码选择货主,名称取 queryAsOf 时可见的完整领星资料批次,按账号+货主编码关联。 - API 只经统一 `shared/api/http.ts` 消费 Lastleg 的平台自营统计合同;不得调用 Integration 或持有来源凭据。 - 北京时间 `Asia/Shanghai`,每 SKU 件数 `PCS`;货主不合并同名 SKU。未知数量保留空值,库存可售天数只用服务端结果。 - 经营查询固定服务端版本和 queryAsOf;显式刷新/切仓重新取上下文,轮询只读运行状态。请求取消和世代校验共同防迟到响应。 diff --git a/src/modules/self-operated/api/analytics.ts b/src/modules/self-operated/api/analytics.ts index 3ba7877d..9f9a1c06 100644 --- a/src/modules/self-operated/api/analytics.ts +++ b/src/modules/self-operated/api/analytics.ts @@ -8,6 +8,7 @@ async function get(path: string, params: object, signal?: AbortSignal): Promi return unwrapApiResponse(response) } export const analyticsApi = { + ownerDirectory: (query: { sourceAccount: string; warehouseCode: string } & Partial, signal?: AbortSignal) => get('owner-directory', query, signal), 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), diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue index fc0e584b..11ee052f 100644 --- a/src/modules/self-operated/components/AnalyticsView.vue +++ b/src/modules/self-operated/components/AnalyticsView.vue @@ -16,6 +16,7 @@ import { analyticsApi } from '../api/analytics' import type { BusinessQuery, DailyResponse, InventoryAttention, InventoryMetric, InventoryQuery, InventorySort, Metric, Overview, QueryContext, RangePreset, SalesSummary } from '../types' import { RequestFence, RuntimePoller, contextParams, hasActiveTask, label, beijingTime, reasonsText, quantityText, sameContext, shownQuantity, validateCustomRange } from '../state' import SkuTrend from './SkuTrend.vue' +import { useOwnerDirectory } from '../useOwnerDirectory' import { expired, inventoryExpired, parseDashboardDrilldown, previousDate, type DashboardDrilldown } from '../dashboardNavigation' import { overviewRange, overviewRangeOptions } from '../overviewRange' import { inventorySortColumns, inventorySortOptions } from '../inventorySorting' @@ -37,9 +38,10 @@ function readDrilldown() { } readDrilldown() function beijingToday() { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()) } -function initialFilters(): QueryModel { return { warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: drilldown.value?.analysis ?? 'summary', rangePreset: drilldown.value?.rangePreset ?? 'LAST_30_DAYS', windowDays: 30, salesWindowDays: 30, salesWindowEndDate: previousDate(drilldown.value?.context.anchorDate ?? beijingToday()), attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customRange: drilldown.value?.startDate && drilldown.value?.endDate ? [drilldown.value.startDate, drilldown.value.endDate] : [] } } +function initialFilters(): QueryModel { return { customerCode: drilldown.value?.customerCode ?? '', warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: drilldown.value?.analysis ?? 'summary', rangePreset: drilldown.value?.rangePreset ?? 'LAST_30_DAYS', windowDays: 30, salesWindowDays: 30, salesWindowEndDate: previousDate(drilldown.value?.context.anchorDate ?? beijingToday()), attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customRange: drilldown.value?.startDate && drilldown.value?.endDate ? [drilldown.value.startDate, drilldown.value.endDate] : [] } } +const ownerDirectory = useOwnerDirectory() const filters = ref(initialFilters()) -const applied = ref({ sku: String(filters.value.sku) }) +const applied = ref({ sku: String(filters.value.sku), customerCode: String(filters.value.customerCode || '') }) const appliedWindow = ref({ salesWindowDays: 30 as number, salesWindowEndDate: String(filters.value.salesWindowEndDate) }) const displayedWindowDays = computed(() => context.value?.calculationWindowDays ?? appliedWindow.value.salesWindowDays) const overview = ref(null) @@ -94,6 +96,7 @@ watch(warehouseCode, code => { if (code) { selectedWarehouse = code; emit('wareh 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()] }, + { key: 'customerCode', label: '客户', type: 'select', width: 210, options: ownerDirectory.options.value, placeholder: '名称或编码搜索', props: { filterable: true, clearable: true, disabled: !!drilldown.value } }, { key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' }, ] if (props.mode === 'inventory') result.push( @@ -185,7 +188,7 @@ function asMetric(row: unknown) { return row as Metric } function asInventory(row: unknown) { return row as InventoryMetric } function query(): InventoryQuery { const result: InventoryQuery = { sourceAccount, warehouseCode: warehouseCode.value, sku: applied.value.sku || undefined, current: current.value, size: size.value, ...contextParams(context.value) } - if (drilldown.value?.customerCode) result.customerCode = drilldown.value.customerCode + if (drilldown.value?.customerCode || applied.value.customerCode) result.customerCode = drilldown.value?.customerCode || applied.value.customerCode if (summaryMode.value) { Object.assign(result, appliedSalesRange.value, { salesSort: salesSort.value }) } else if (props.mode === 'sales' && tab.value === 'hot') { @@ -221,6 +224,7 @@ async function load(reset = false, keepContext = false) { if (context.value && !sameContext(context.value, result.context)) throw new Error('查询版本已变化,请重新查询') context.value = result.context rows.value = result.records + void ownerDirectory.load(result.context, warehouseCode.value, request.signal) total.value = result.total daily.value = null if (props.mode === 'inventory') displayedInventorySort.value = params.sortBy ?? 'AVAILABLE_DESC' @@ -289,7 +293,7 @@ function search() { const keepContext = !newData.value && !newDay.value && props.mode === 'inventory' && nextWindow.salesWindowDays === appliedWindow.value.salesWindowDays && nextWindow.salesWindowEndDate === appliedWindow.value.salesWindowEndDate && sortBy.value !== appliedInventory.value.sortBy appliedWindow.value = nextWindow appliedInventory.value = { attention: attention.value, sortBy: sortBy.value, coverageDaysThreshold: rawThreshold ? Number(rawThreshold) : undefined } - applied.value = { sku: String(filters.value.sku ?? '').trim() } + applied.value = { sku: String(filters.value.sku ?? '').trim(), customerCode: String(filters.value.customerCode || '') } current.value = 1 void load(true, keepContext) } @@ -297,8 +301,8 @@ async function reset() { salesSort.value = 'QUANTITY_DESC' applyingRoute = true drilldown.value = null; origin.value = null - filters.value = { ...initialFilters(), warehouseCode: warehouseCode.value || selectedWarehouse || defaultWarehouse(), sku: '' } - appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } + filters.value = { ...initialFilters(), warehouseCode: warehouseCode.value || selectedWarehouse || defaultWarehouse(), sku: '', customerCode: '' } + appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: '', customerCode: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } await nextTick(); applyingRoute = false; void load(true) } function returnOverview() { @@ -307,9 +311,9 @@ function returnOverview() { } async function clearDrilldown() { drilldown.value = null - filters.value = { ...filters.value, sku: '', analysis: 'summary', rangePreset: 'LAST_30_DAYS', windowDays: 30, attention: 'ALL', sortBy: 'AVAILABLE_DESC', threshold: '', customRange: [] } + filters.value = { ...filters.value, sku: '', customerCode: '', analysis: 'summary', rangePreset: 'LAST_30_DAYS', windowDays: 30, attention: 'ALL', sortBy: 'AVAILABLE_DESC', threshold: '', customRange: [] } appliedSalesRange.value = draftSalesRange() - applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }; current.value = 1 + applied.value = { sku: '', customerCode: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }; current.value = 1 await nextTick(); void load() } function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, sku: applied.value.sku } }) } @@ -329,7 +333,7 @@ onMounted(() => { visibility(); poller.start(); document.addEventListener('visib watch(() => route.query, async () => { if (route.path && route.path !== `/self-operated/${props.mode}`) return applyingRoute = true; dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false; rows.value = []; total.value = 0 - readDrilldown(); context.value = drilldown.value?.context ?? null; filters.value = initialFilters(); appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: String(filters.value.sku ?? '') }; appliedInventory.value = { attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' }; current.value = 1 + readDrilldown(); context.value = drilldown.value?.context ?? null; filters.value = initialFilters(); appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: String(filters.value.sku ?? ''), customerCode: String(filters.value.customerCode || '') }; appliedInventory.value = { attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' }; current.value = 1 if (!warehouseCode.value && !routeProblem.value) filters.value.warehouseCode = defaultWarehouse() await nextTick(); applyingRoute = false; void load() }) @@ -343,7 +347,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu - +