diff --git a/AGENTS.md b/AGENTS.md index 57371fb7..1d2ee46d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ | 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` | | 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 | | 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 | +| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 | | 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 | | 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg | | FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 | diff --git a/src/app/router/index.ts b/src/app/router/index.ts index 9fe2ee6e..e1e2b161 100644 --- a/src/app/router/index.ts +++ b/src/app/router/index.ts @@ -34,6 +34,7 @@ import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes' import { pdaReleaseRoutes } from '@/modules/pda-releases/routes' import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes' import { appointmentCalendarRoutes } from '@/modules/appointment-calendar/routes' +import { selfOperatedRoutes } from '@/modules/self-operated/routes' const router = createRouter({ history: createWebHistory(), @@ -81,6 +82,7 @@ const router = createRouter({ ...pdaReleaseRoutes, ...sailingCalendarRoutes, ...appointmentCalendarRoutes, + ...selfOperatedRoutes, { path: '403', name: 'forbidden', diff --git a/src/core/navigation/adminNavigation.test.ts b/src/core/navigation/adminNavigation.test.ts index 12c2c7e8..f7bb11ad 100644 --- a/src/core/navigation/adminNavigation.test.ts +++ b/src/core/navigation/adminNavigation.test.ts @@ -8,6 +8,14 @@ import { } from './adminNavigation' describe('adminNavigation', () => { + it('opens all self-operated read pages with list permission and keeps sync alone insufficient', () => { + const read = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:list'])) + expect(getNavigationLeaves(read).map(item => item.path)).toEqual([ + '/dashboard', '/self-operated/sales', '/self-operated/inventory', '/self-operated/collection', + ]) + const sync = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:sync'])) + expect(getNavigationLeaves(sync).map(item => item.path)).toEqual(['/dashboard']) + }) it('offers the common container menu to overseas-only operators', () => { const visible = filterAdminNavigation(adminNavigation, new Set(['platform:firstleg:terminal-drayage:list'])) expect(getNavigationLeaves(visible).map(item => item.path)).toEqual(['/dashboard', '/container-loading/domestic']) @@ -27,11 +35,12 @@ describe('adminNavigation', () => { { title: '海外业务', children: ['采购订单', '本土卡车', '本土散板'] }, { title: '客户服务', children: ['异常订单'] }, { title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] }, + { title: '自营业务', children: ['销量分析', '库存分析', '数据采集'] }, { title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] }, { title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] }, ]) - expect(visible).toHaveLength(12) - expect(visible.flatMap(node => node.children ?? [])).toHaveLength(52) + expect(visible).toHaveLength(13) + expect(visible.flatMap(node => node.children ?? [])).toHaveLength(55) expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined() }) @@ -73,7 +82,7 @@ describe('adminNavigation', () => { expect(paths).toContain('/system/permissions') expect(new Set(paths).size).toBe(paths.length) const duplicatedPermissions = permissions.filter((permission, index) => permissions.indexOf(permission) !== index) - expect(new Set(duplicatedPermissions)).toEqual(new Set()) + expect(new Set(duplicatedPermissions)).toEqual(new Set(['platform:self-operated-analytics:list'])) expect(leaves.every((item) => Boolean(item.icon))).toBe(true) }) diff --git a/src/core/navigation/adminNavigation.ts b/src/core/navigation/adminNavigation.ts index 571b3797..d675e3b2 100644 --- a/src/core/navigation/adminNavigation.ts +++ b/src/core/navigation/adminNavigation.ts @@ -181,6 +181,16 @@ export const adminNavigation: AdminNavigationNode[] = [ { key: 'sales-commission', title: '销售提成', icon: Database, path: '/prototype/sales/commission' }, ], }, + { + key: 'self-operated', + title: '自营业务', + icon: PrototypeSalesIcon, + children: [ + { key: 'self-operated-sales', title: '销量分析', icon: ClipboardList, path: '/self-operated/sales', permission: 'platform:self-operated-analytics:list' }, + { key: 'self-operated-inventory', title: '库存分析', icon: WarehouseIcon, path: '/self-operated/inventory', permission: 'platform:self-operated-analytics:list' }, + { key: 'self-operated-collection', title: '数据采集', icon: Database, path: '/self-operated/collection', permission: 'platform:self-operated-analytics:list' }, + ], + }, { key: 'general', title: '通用', diff --git a/src/modules/self-operated/AGENTS.md b/src/modules/self-operated/AGENTS.md new file mode 100644 index 00000000..9c430be3 --- /dev/null +++ b/src/modules/self-operated/AGENTS.md @@ -0,0 +1,8 @@ +# 自营经营分析 + +- 模块入口 `routes.ts`;正式页面 `pages/SalesView.vue`、`InventoryView.vue`、`CollectionView.vue`。根路由、导航、IAM 由总控装配。 +- API 只经统一 `shared/api/http.ts` 消费 Lastleg 的平台自营统计合同;不得调用 Integration 或持有来源凭据。 +- 北京时间 `Asia/Shanghai`,每 SKU 件数 `PCS`;货主不合并同名 SKU。未知数量保留空值,库存可售天数只用服务端结果。 +- 经营查询固定服务端版本和 queryAsOf;显式刷新/切仓重新取上下文,轮询只读运行状态。请求取消和世代校验共同防迟到响应。 +- 任务编号仅用 taskNo,不以数据库 ID 回退。重试需独立 sync 权限与 allowedActions,沿 POST sync-tasks 的 RETRY 和 requestKey。 +- 功能和视觉边界见 `operation-matrix.md`;未确认承载不新增浮层或独立详情。视觉待人工 1920×1080、100% 截图。 diff --git a/src/modules/self-operated/api/analytics.test.ts b/src/modules/self-operated/api/analytics.test.ts new file mode 100644 index 00000000..17a4a414 --- /dev/null +++ b/src/modules/self-operated/api/analytics.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { analyticsApi } from './analytics' + +const http = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn() })) +vi.mock('@/shared/api/http', () => ({ http, unwrapApiResponse: (value: { data: unknown }) => value.data })) +beforeEach(() => { vi.clearAllMocks(); http.get.mockResolvedValue({ data: {} }); http.post.mockResolvedValue({ data: { taskNo: 'COL-001', status: 'QUEUED' } }) }) +describe('self-operated platform transport contract', () => { + it('uses local fixed-version owners, owner overview and inventory attention endpoints', async () => { + const fixed = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', salesRunId: '11', inventoryRunId: '12', queryAsOf: '2026-10-12T01:00:00Z' } + await analyticsApi.owners({ ...fixed, keyword: 'OWNER_', current: 2, size: 20 }) + await analyticsApi.ownerOverview({ ...fixed, customerCode: 'OWNER_A', coverageDaysThreshold: 60 }) + await analyticsApi.inventory({ ...fixed, customerCode: 'OWNER_A', attention: 'COVERAGE_GT_THRESHOLD', sortBy: 'PHYSICAL_DESC', coverageDaysThreshold: 60, current: 2, size: 20 }) + expect(http.get.mock.calls.map(call => call[0])).toEqual(['owners', 'owner-overview', 'inventory-coverage'].map(path => `/platform/v1/last-leg/self-operated-analytics/${path}`)) + expect(http.get.mock.calls[1]?.[1].params).toEqual({ ...fixed, customerCode: 'OWNER_A', coverageDaysThreshold: 60 }) + expect(http.get.mock.calls[2]?.[1].params).toMatchObject({ attention: 'COVERAGE_GT_THRESHOLD', sortBy: 'PHYSICAL_DESC', current: 2 }) + }) + it('uses unified gateway domain with fixed context and abort signal, never supplier APIs', async () => { + const abort = new AbortController() + const query = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', current: 2, size: 20, salesRunId: '9007199254740999', inventoryRunId: '0', queryAsOf: '2026-10-12T01:00:00Z', salesScopeHash: 'hash' } + await analyticsApi.summary(query, abort.signal) + expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/self-operated-analytics/sku-sales-summary', { params: query, signal: abort.signal }) + }) + it('reads task details by escaped taskNo with independent runtime list upper pair', async () => { + await analyticsApi.task({ sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A' }, 'COL/001') + expect(http.get.mock.calls[0]?.[0]).toBe('/platform/v1/last-leg/self-operated-analytics/sync-tasks/COL%2F001') + const params = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', current: 2, size: 20, listUpperCreatedAt: '2026-10-12T01:00:00', listUpperId: '9007199254740999' } + await analyticsApi.tasks(params) + expect(http.get.mock.calls[1]?.[1].params).toEqual(params) + }) + it('posts one RETRY command with business task number and exact idempotency key', async () => { + const command = { action: 'RETRY' as const, taskNo: 'COL-001', warehouseCode: 'WH-A', requestKey: 'request-001', reason: '恢复原任务' } + expect(await analyticsApi.retry(command)).toEqual({ taskNo: 'COL-001', status: 'QUEUED' }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/self-operated-analytics/sync-tasks', command, { signal: undefined }) + }) +}) diff --git a/src/modules/self-operated/api/analytics.ts b/src/modules/self-operated/api/analytics.ts new file mode 100644 index 00000000..9e4f3b3c --- /dev/null +++ b/src/modules/self-operated/api/analytics.ts @@ -0,0 +1,25 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { BusinessPage, BusinessQuery, DailyResponse, 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' +async function get(path: string, params: object, signal?: AbortSignal): Promise { + const response = await http.get, ApiEnvelope>(`${base}/${path}`, { params, signal }) + return unwrapApiResponse(response) +} +export const analyticsApi = { + owners: (query: OwnerQuery, signal?: AbortSignal) => get>('owners', query, signal), + ownerOverview: (query: OwnerOverviewQuery, signal?: AbortSignal) => get('owner-overview', query, signal), + summary: (query: BusinessQuery, signal?: AbortSignal) => get>('sku-sales-summary', query, signal), + daily: (query: BusinessQuery, signal?: AbortSignal) => get('sku-daily-sales', query, signal), + hot: (query: BusinessQuery, signal?: AbortSignal) => get>('hot-skus', query, signal), + slow: (query: BusinessQuery, signal?: AbortSignal) => get>('slow-skus', query, signal), + inventory: (query: BusinessQuery, signal?: AbortSignal) => get>('inventory-coverage', query, signal), + overview: (sourceAccount: string, warehouseCode?: string, signal?: AbortSignal) => get('sync-overview', { sourceAccount, warehouseCode }, signal), + tasks: (query: RuntimeQuery, signal?: AbortSignal) => get>('sync-tasks', query, signal), + task: (scope: Scope, taskNo: string, signal?: AbortSignal) => get(`sync-tasks/${encodeURIComponent(taskNo)}`, scope, signal), + runs: (query: RuntimeQuery, signal?: AbortSignal) => get>('sync-runs', query, signal), + async retry(command: RetryCommand, signal?: AbortSignal): Promise { + const response = await http.post, ApiEnvelope>(`${base}/sync-tasks`, command, { signal }) + return unwrapApiResponse(response) + }, +} diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue new file mode 100644 index 00000000..e58b2617 --- /dev/null +++ b/src/modules/self-operated/components/AnalyticsView.vue @@ -0,0 +1,224 @@ + + diff --git a/src/modules/self-operated/components/OwnerOverviewPanel.test.ts b/src/modules/self-operated/components/OwnerOverviewPanel.test.ts new file mode 100644 index 00000000..7277648c --- /dev/null +++ b/src/modules/self-operated/components/OwnerOverviewPanel.test.ts @@ -0,0 +1,24 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import OwnerOverviewPanel from './OwnerOverviewPanel.vue' +import type { OwnerOverview } from '../types' + +describe('owner overview calculation window', () => { + it('shows the thirty-day calculation range rather than the longer collected history', () => { + const quantity = { overallTotal: null, knownSubtotal: null, unknownSkuCount: 1, dataStatus: 'UNKNOWN', reasonCodes: [] } + const risk = { matchedSkuCount: null, matchedPhysicalQuantity: null, physicalQuantityRatio: null, knownMatchedSkuCount: null, knownMatchedPhysicalQuantity: null, unassessableSkuCount: 1, unassessableKnownPhysicalQuantity: null, reasonCodes: [] } + const data: OwnerOverview = { + context: { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', queryAsOf: '2026-10-12T01:00:00Z', queryExpiresAt: '2026-10-12T01:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12', completeDayFrom: '2026-08-13', completeDayUntil: '2026-10-12', rangeStartDate: '2026-09-12', rangeEndDateExclusive: '2026-10-12', salesStatus: 'COMPLETE', inventoryStatus: 'STALE', inventoryObservedFrom: '2026-10-10T00:00:00Z', inventoryObservedUntil: '2026-10-10T00:10:00Z' }, + customerCode: 'OWNER-A', customerName: null, skuCount: 1, + physicalSkuCount: quantity, physicalQuantity: quantity, availableQuantity: quantity, frozenQuantity: quantity, + defectivePhysicalQuantity: quantity, transitQuantity: quantity, outboundQuantity30d: quantity, + averageDailyOutboundQuantity: quantity, noOutbound30d: risk, coverageAboveThreshold: risk, + } + const wrapper = mount(OwnerOverviewPanel, { props: { data } }) + expect(wrapper.text()).toContain('近30完整日计算区间 2026-09-12 至 2026-10-12') + expect(wrapper.text()).not.toContain('2026-08-13') + expect(wrapper.text()).toContain('销量质量:完整;库存质量:过期') + expect(wrapper.text()).toContain('库存观测时段:2026/10/10 08:00:00 至 2026/10/10 08:10:00') + wrapper.unmount() + }) +}) diff --git a/src/modules/self-operated/components/OwnerOverviewPanel.vue b/src/modules/self-operated/components/OwnerOverviewPanel.vue new file mode 100644 index 00000000..481573b6 --- /dev/null +++ b/src/modules/self-operated/components/OwnerOverviewPanel.vue @@ -0,0 +1,30 @@ + + diff --git a/src/modules/self-operated/components/OwnerPicker.test.ts b/src/modules/self-operated/components/OwnerPicker.test.ts new file mode 100644 index 00000000..08969d86 --- /dev/null +++ b/src/modules/self-operated/components/OwnerPicker.test.ts @@ -0,0 +1,41 @@ +import { defineComponent, h } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import OwnerPicker from './OwnerPicker.vue' +import type { QueryContext } from '../types' + +const owners = vi.hoisted(() => vi.fn()) +vi.mock('../api/analytics', () => ({ analyticsApi: { owners } })) +const context: QueryContext = { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', queryAsOf: '2026-10-12T01:00:00Z', queryExpiresAt: '2026-10-12T01:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12' } +const select = defineComponent({ props: ['remoteMethod'], setup(props, { slots }) { return () => h('div', [h('button', { onClick: () => props.remoteMethod('Ab_') }, '前缀搜索'), slots.default?.()]) } }) +const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } }) +const option = defineComponent({ props: ['label'], setup(props) { return () => h('span', props.label) } }) +const global = { stubs: { 'el-select': select, 'el-button': button, 'el-option': option } } +const wrappers: ReturnType[] = [] +beforeEach(() => { owners.mockReset(); owners.mockResolvedValue({ context, records: [{ customerCode: 'OWNER-A', customerName: '货主甲' }], total: 25 }) }) +afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount())) +describe('fixed-context owner selector', () => { + it('uses backend pagination and prefix search without changing the pinned context', async () => { + const wrapper = mount(OwnerPicker, { props: { modelValue: '', sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', context }, global }); wrappers.push(wrapper) + await flushPromises() + await wrapper.findAll('button').find(node => node.text() === '下一页货主')!.trigger('click'); await flushPromises() + expect(owners.mock.calls.at(-1)?.[0]).toMatchObject({ current: 2, size: 20, salesRunId: '11', inventoryRunId: '12', queryAsOf: context.queryAsOf }) + await wrapper.findAll('button').find(node => node.text() === '前缀搜索')!.trigger('click'); await flushPromises() + expect(owners.mock.calls.at(-1)?.[0]).toMatchObject({ keyword: 'Ab_', current: 1, queryAsOf: context.queryAsOf }) + expect(wrapper.text()).toContain('OWNER-A · 货主甲') + }) + it('ignores late options from the old warehouse and rejects mixed-version choices', async () => { + let resolve!: (value: object) => void + owners.mockImplementationOnce(() => new Promise(done => { resolve = done })).mockResolvedValue({ context, records: [{ customerCode: 'OWNER-B', customerName: null }], total: 1 }) + const wrapper = mount(OwnerPicker, { props: { modelValue: '', sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', context }, global }); wrappers.push(wrapper) + await wrapper.setProps({ warehouseCode: 'WH-B' }); await flushPromises() + resolve({ context, records: [{ customerCode: 'OLD-OWNER', customerName: null }], total: 1 }); await flushPromises() + expect(wrapper.text()).toContain('OWNER-B') + expect(wrapper.text()).not.toContain('OLD-OWNER') + expect(owners.mock.calls[0]?.[1].aborted).toBe(true) + owners.mockResolvedValue({ context: { ...context, inventoryRunId: '99' }, records: [{ customerCode: 'WRONG-VERSION' }], total: 1 }) + await wrapper.findAll('button').find(node => node.text() === '前缀搜索')!.trigger('click'); await flushPromises() + expect(wrapper.text()).not.toContain('WRONG-VERSION') + expect(wrapper.text()).toContain('货主选项版本已变化') + }) +}) diff --git a/src/modules/self-operated/components/OwnerPicker.vue b/src/modules/self-operated/components/OwnerPicker.vue new file mode 100644 index 00000000..81a2a4e0 --- /dev/null +++ b/src/modules/self-operated/components/OwnerPicker.vue @@ -0,0 +1,53 @@ + + diff --git a/src/modules/self-operated/components/SkuTrend.vue b/src/modules/self-operated/components/SkuTrend.vue new file mode 100644 index 00000000..7a44e301 --- /dev/null +++ b/src/modules/self-operated/components/SkuTrend.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md new file mode 100644 index 00000000..a4aad808 --- /dev/null +++ b/src/modules/self-operated/operation-matrix.md @@ -0,0 +1,19 @@ +# 自营分析逐操作矩阵 + +依据:`docs/自营业务/领星一件代发数据统计一期方案.md` 的“Admin 菜单与采集页面”“SKU 销量列表与趋势图”“页面交付和视觉边界”。本模块没有已确认视觉原型。 + +| 页面 | 操作 | 依据与承载结论 | 布局与验证 | +| --- | --- | --- | --- | +| /self-operated/sales | 仓库、货主、SKU、周期查询及分页 | 已确认主页面;服务端固定版本与北京时间日期 | 复用 AdminPage、QueryToolbar、DataTable、高密度样式;待人工截图确认 | +| /self-operated/sales | SKU 趋势 | 主页面内嵌单 SKU 日趋势,保留整个日轴,未知断线 | 不新建详情路由或浮层;待人工截图确认 | +| /self-operated/sales | 热销、滞销页签 | 主页面页签;独立完整日窗口 | 热销 7/30 日,滞销 30 日;待人工截图确认 | +| /self-operated/inventory | 货主概览、SKU 占仓关注及排序 | 方案“货主概览与库存关注”“Admin 菜单与采集页面”已确认同页联动;概览保持货主全量,SKU/关注/分页仅作用明细 | 复用连续表格,未知与部分小计分开;件数不是空间;待人工截图确认 | +| /self-operated/inventory | SKU 每日趋势 | 方案“货主概览与库存关注”确认点击 SKU 沿 daily 接口同页下钻 | 点击 SKU 展示内嵌趋势,不新增操作列、菜单或浮层;待人工截图确认 | +| 销量/库存 | 货主选择 | 方案“货主概览与库存关注接口合同”确认本仓固定版本服务器分页选项 | 编码前缀检索、翻页、精确选择;待人工截图确认 | +| /self-operated/collection | 每日库存观测积累 | 方案“每日库存观测留存”确认采集页显示近30完整日样本、缺口、起点 | 服务端结果直出,不计算周转率;待人工截图确认 | +| /self-operated/collection | 运行概览、逻辑任务列表与分页 | 已确认主页面,执行/发布/覆盖独立展示 | 不提供整体百分比;待人工截图确认 | +| /self-operated/collection | 任务详情和历史尝试 | 用户本轮确认采集主页面内嵌只读详情和历史尝试;总控已冻结 | 不新增弹框、抽屉、独立详情;待人工截图确认 | +| /self-operated/collection | 恢复任务确认 | 用户本轮确认内嵌原因表单+显式确认步骤;RETRY 单一命令 | 不通过前端推断资格;受理不是采集成功;待人工截图确认 | +| 三页 | 新增/编辑/审核/删除/文件预览 | 不在一期页面业务范围 | 不创建操作入口 | + +自动测试与人工视觉验收分别记录;无真实截图不宣称视觉通过。 diff --git a/src/modules/self-operated/pages/CollectionView.vue b/src/modules/self-operated/pages/CollectionView.vue new file mode 100644 index 00000000..df92690d --- /dev/null +++ b/src/modules/self-operated/pages/CollectionView.vue @@ -0,0 +1,218 @@ + + diff --git a/src/modules/self-operated/pages/InventoryView.vue b/src/modules/self-operated/pages/InventoryView.vue new file mode 100644 index 00000000..afc9ffa5 --- /dev/null +++ b/src/modules/self-operated/pages/InventoryView.vue @@ -0,0 +1,4 @@ + + diff --git a/src/modules/self-operated/pages/SalesView.vue b/src/modules/self-operated/pages/SalesView.vue new file mode 100644 index 00000000..36f6542c --- /dev/null +++ b/src/modules/self-operated/pages/SalesView.vue @@ -0,0 +1,4 @@ + + diff --git a/src/modules/self-operated/pages/workbench.test.ts b/src/modules/self-operated/pages/workbench.test.ts new file mode 100644 index 00000000..e29bf836 --- /dev/null +++ b/src/modules/self-operated/pages/workbench.test.ts @@ -0,0 +1,139 @@ +import { defineComponent, h } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import AnalyticsView from '../components/AnalyticsView.vue' +import CollectionView from './CollectionView.vue' +import type { Metric, Overview, OwnerOverview, QueryContext, SalesSummary, Task, WarehouseOverview } from '../types' + +const mocks = vi.hoisted(() => ({ api: { owners: vi.fn(), ownerOverview: vi.fn(), summary: vi.fn(), daily: vi.fn(), hot: vi.fn(), slow: vi.fn(), inventory: vi.fn(), overview: vi.fn(), tasks: vi.fn(), task: vi.fn(), runs: vi.fn(), retry: vi.fn() }, canSync: true })) +vi.mock('../api/analytics', () => ({ analyticsApi: mocks.api })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission.endsWith(':list') || mocks.canSync }) })) +vi.mock('vue-router', () => ({ useRoute: () => ({ query: { warehouseCode: 'WH-A' } }), useRouter: () => ({ push: vi.fn() }) })) + +const context: QueryContext = { salesRunId: '11', inventoryRunId: '0', salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: '2026-10-12T01:00:00Z', queryExpiresAt: '2026-10-12T01:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12', completeDayFrom: '2026-08-13', completeDayUntil: '2026-10-12', rangeStartDate: '2026-09-12', rangeEndDateExclusive: '2026-10-12' } +const quantity = { overallTotal: null, knownSubtotal: '128', unknownSkuCount: 1, dataStatus: 'PARTIAL', reasonCodes: ['INVENTORY_UNKNOWN'] } +const risk = { matchedSkuCount: null, matchedPhysicalQuantity: null, physicalQuantityRatio: null, knownMatchedSkuCount: 1, knownMatchedPhysicalQuantity: '128', unassessableSkuCount: 1, unassessableKnownPhysicalQuantity: null, reasonCodes: ['INVENTORY_UNKNOWN'] } +const owner: OwnerOverview = { context, customerCode: 'OWNER-A', customerName: '货主甲', skuCount: 2, physicalSkuCount: quantity, physicalQuantity: quantity, availableQuantity: quantity, frozenQuantity: quantity, defectivePhysicalQuantity: quantity, transitQuantity: quantity, outboundQuantity30d: quantity, averageDailyOutboundQuantity: quantity, noOutbound30d: risk, coverageAboveThreshold: { ...risk, reasonCodes: ['THRESHOLD_NOT_SET'] } } +const metric: Metric = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', customerCode: 'OWNER-A', sku: 'SKU-A', productName: '商品A', quantityUnit: 'PCS', outboundQuantity: null, skuOutboundOrderCount: null, physicalQuantity: '128', availableQuantity: '100', frozenQuantity: '20', defectivePhysicalQuantity: '8', transitQuantity: '30', outboundQuantity30d: null, averageDailyOutboundQuantity: null, inventoryCoverageDays: null, inventoryObservedAt: null, status: 'UNKNOWN', reasonCodes: ['INVENTORY_UNKNOWN'], attentionReasons: ['UNASSESSABLE'] } +const summary: SalesSummary = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', customerCode: 'OWNER-A', sku: 'SKU-A', productName: '商品A', quantityUnit: 'PCS', outboundQuantity: null, observedQuantity: '3', dataStatus: 'PARTIAL', reasonCodes: ['SALES_NOT_COVERED'], completeDayCount: 0, partialDayCount: 1, unknownDayCount: 0 } +const task: Task = { taskNo: 'COL-A', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'DEAD', applyState: 'BLOCKED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: ['RETRY'], actionBlockReason: null, progress: null, queryFrom: null, queryUntil: null } +const overview: Overview = { sourceAccount: 'lingxing-primary', environmentLabel: 'TEST', collectionMode: 'OFF', observedAt: '2026-10-12T01:01:00Z', warehouses: [] } +const passthrough = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } }) +const queryStub = defineComponent({ props: ['modelValue'], emits: ['update:modelValue', 'search'], setup(props, { emit }) { return () => h('button', { 'data-test': 'switch-warehouse', onClick: () => { emit('update:modelValue', { ...props.modelValue, warehouseCode: 'WH-B' }); emit('search') } }, '切换仓库') } }) +const tableStub = defineComponent({ props: ['data'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.sku?.({ row })]))]) } }) +const button = defineComponent({ props: ['disabled', 'nativeType'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, type: props.nativeType || 'button', onClick: () => emit('click') }, slots.default?.()) } }) +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 options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, 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 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 } +beforeEach(() => { + vi.clearAllMocks() + mocks.canSync = true + mocks.api.overview.mockResolvedValue(overview) + mocks.api.summary.mockResolvedValue({ context, records: [summary], total: 1 }) + mocks.api.hot.mockResolvedValue({ context, records: [], total: 0 }) + mocks.api.inventory.mockResolvedValue({ context, records: [metric], total: 45 }) + mocks.api.ownerOverview.mockResolvedValue(owner) + mocks.api.daily.mockResolvedValue({ context, summary, points: [] }) + mocks.api.tasks.mockResolvedValue({ records: [task], total: 1, listUpperCreatedAt: '2026-10-12T01:00:00', listUpperId: '10' }) + mocks.api.task.mockResolvedValue({ task, sharedUpstream: null, protection: {}, observedAt: overview.observedAt }) + mocks.api.runs.mockResolvedValue({ records: [], total: 0, listUpperCreatedAt: '2026-10-12T01:00:00', listUpperId: '10' }) + mocks.api.retry.mockResolvedValue({ taskNo: task.taskNo, status: 'QUEUED' }) +}) +afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) }) +describe('self-operated actual workbench request state', () => { + it('keeps owner overview full-scope and fixed-version when inventory attention and pagination change', async () => { + const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper) + await flushPromises() + expect(mocks.api.ownerOverview).not.toHaveBeenCalled() + wrapper.findComponent({ name: 'OwnerPicker' }).vm.$emit('update:modelValue', 'OWNER-A') + await flushPromises() + expect(mocks.api.ownerOverview.mock.calls.at(-1)?.[0]).toEqual(expect.objectContaining({ customerCode: 'OWNER-A', salesRunId: '11', inventoryRunId: '0', queryAsOf: context.queryAsOf })) + const selects = wrapper.findAllComponents(passthrough).filter(item => item.attributes('aria-label') === '库存关注') + selects[0]?.vm.$emit('update:modelValue', 'FROZEN_STOCK') + await findButton(wrapper, '应用库存关注').trigger('click'); await flushPromises() + wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises() + expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ current: 2, customerCode: 'OWNER-A', attention: 'FROZEN_STOCK', queryAsOf: context.queryAsOf }) + const params = mocks.api.ownerOverview.mock.calls.at(-1)?.[0] + for (const key of ['sku', 'attention', 'current', 'size', 'rangePreset', 'sortBy']) expect(params).not.toHaveProperty(key) + expect(wrapper.text()).toContain('已知有效小计(部分资料)') + expect(wrapper.text()).toContain('未设置查询阈值') + expect(wrapper.text()).toContain('— · 未完整') + }) + 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) + await flushPromises() + const select = wrapper.findAllComponents(passthrough).find(item => item.attributes('aria-label') === '库存关注') + select?.vm.$emit('update:modelValue', 'COVERAGE_GT_THRESHOLD') + await findButton(wrapper, '应用库存关注').trigger('click'); await flushPromises() + expect(mocks.api.inventory).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('选择超过阈值关注时必须填写') + await findButton(wrapper, 'SKU-A').trigger('click'); await flushPromises() + expect(mocks.api.daily.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-A', sku: 'SKU-A', rangePreset: 'CUSTOM', startDate: '2026-09-12', endDate: '2026-10-11', queryAsOf: context.queryAsOf }) + expect(mocks.api.daily.mock.calls.at(-1)?.[0].startDate).not.toBe(context.completeDayFrom) + expect(mocks.api.daily.mock.calls.at(-1)?.[0]).not.toHaveProperty('attention') + expect(wrapper.findComponent({ name: 'SkuTrend' }).exists()).toBe(true) + }) + it('shows actual inventory daily sample gaps without inventing an accumulation date', async () => { + mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', protection: {}, inventoryDailySamples: { windowFrom: '2026-09-12', windowUntil: '2026-10-12', expectedDays: 30, sampleCount: 0, missingDates: ['2026-09-12'], accumulationStartedOn: null } } as WarehouseOverview] }) + const wrapper = mount(CollectionView, options); wrappers.push(wrapper) + await flushPromises() + expect(wrapper.text()).toContain('已留存 0 / 30 个日样本') + expect(wrapper.text()).toContain('数据积累起点 尚无日样本') + expect(wrapper.text()).toContain('缺口日期:2026-09-12') + }) + it('pins versions and date across range and ranking switches, ranking omits chart parameters', async () => { + const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper) + await flushPromises() + await findButton(wrapper, '本月').trigger('click'); await flushPromises() + expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'THIS_MONTH', queryAsOf: context.queryAsOf, salesRunId: '11', inventoryRunId: '0' }) + await findButton(wrapper, '热销Top100').trigger('click'); await flushPromises() + expect(mocks.api.hot.mock.calls.at(-1)?.[0]).toMatchObject({ queryAsOf: context.queryAsOf, windowDays: 30 }) + expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('rangePreset') + expect(mocks.api.hot.mock.calls.at(-1)?.[0]).not.toHaveProperty('customerCode', '') + }) + it('late first warehouse response cannot overwrite second warehouse data', async () => { + let resolveOld!: (result: object) => void + mocks.api.summary.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })).mockResolvedValue({ context, records: [{ ...summary, warehouseCode: 'WH-B', sku: 'SKU-B' }], total: 1 }) + const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper) + await flushPromises() + await wrapper.find('[data-test="switch-warehouse"]').trigger('click'); await flushPromises() + resolveOld({ context, records: [summary], total: 1 }); await flushPromises() + expect(wrapper.text()).toContain('SKU-B') + expect(wrapper.text()).not.toContain('SKU-A') + expect(mocks.api.summary.mock.calls[0]?.[1].aborted).toBe(true) + }) + it('failed runtime polling preserves prior available data and leaves fixed context untouched', async () => { + mocks.api.overview.mockRejectedValue(new Error('offline')) + const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper) + await flushPromises() + expect(wrapper.text()).toContain('SKU-A') + expect(wrapper.text()).toContain('3') + expect(wrapper.text()).toContain('状态更新失败') + expect(wrapper.text()).toContain('2026/10/12 09:00:00') + }) + it('requires inline confirmation and preserves same key on ambiguous retry failure', async () => { + mocks.api.retry.mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ taskNo: task.taskNo, status: 'QUEUED' }) + const wrapper = mount(CollectionView, options); wrappers.push(wrapper) + await flushPromises() + await findButton(wrapper, '详情 / 恢复').trigger('click'); await flushPromises() + await wrapper.find('textarea').setValue('已修复,恢复原任务') + await findButton(wrapper, '核对恢复请求').trigger('click') + expect(mocks.api.retry).not.toHaveBeenCalled() + await wrapper.find('form').trigger('submit'); await flushPromises() + const first = mocks.api.retry.mock.calls[0]?.[0] + await wrapper.find('form').trigger('submit'); await flushPromises() + expect(mocks.api.retry.mock.calls[1]?.[0]).toEqual(first) + expect(first).toMatchObject({ action: 'RETRY', taskNo: 'COL-A', warehouseCode: 'WH-A', reason: '已修复,恢复原任务' }) + expect(first).not.toHaveProperty('taskId') + expect(wrapper.text()).toContain('恢复请求已排队受理,尚未完成采集或发布') + }) + it('read-only permission allows task details but never creates retry form', async () => { + mocks.canSync = false + const wrapper = mount(CollectionView, options); wrappers.push(wrapper) + await flushPromises() + await findButton(wrapper, '详情 / 恢复').trigger('click'); await flushPromises() + expect(wrapper.find('form').exists()).toBe(false) + expect(wrapper.text()).toContain('没有任务恢复权限') + }) +}) diff --git a/src/modules/self-operated/routes.ts b/src/modules/self-operated/routes.ts new file mode 100644 index 00000000..ee71eefb --- /dev/null +++ b/src/modules/self-operated/routes.ts @@ -0,0 +1,7 @@ +import type { RouteRecordRaw } from 'vue-router' + +export const selfOperatedRoutes = [ + { path: 'self-operated/sales', name: 'self-operated-sales', component: () => import('./pages/SalesView.vue'), meta: { title: '销量分析', permission: 'platform:self-operated-analytics:list' } }, + { path: 'self-operated/inventory', name: 'self-operated-inventory', component: () => import('./pages/InventoryView.vue'), meta: { title: '库存分析', permission: 'platform:self-operated-analytics:list' } }, + { path: 'self-operated/collection', name: 'self-operated-collection', component: () => import('./pages/CollectionView.vue'), meta: { title: '数据采集', permission: 'platform:self-operated-analytics:list' } }, +] satisfies RouteRecordRaw[] diff --git a/src/modules/self-operated/state.test.ts b/src/modules/self-operated/state.test.ts new file mode 100644 index 00000000..61c79267 --- /dev/null +++ b/src/modules/self-operated/state.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RequestFence, RetryIntent, RuntimePoller, contextParams, progressText, sameContext, shownQuantity, taskRetryAllowed, trendSegments, validateCustomRange } from './state' +import type { DailyPoint, QueryContext, Task } from './types' + +const context: QueryContext = { salesRunId: '9007199254740999', inventoryRunId: null, salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: '2026-10-12T15:00:00+08:00', queryExpiresAt: '2026-10-12T15:30:00+08:00', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12' } +const task: Task = { taskNo: 'COL-TEST-001', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'FAILED', applyState: 'BLOCKED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: ['RETRY'], actionBlockReason: null, progress: null, queryFrom: null, queryUntil: null } +describe('self-operated query and command protection', () => { + it('aborts old warehouse request and rejects late completion even if adapter ignores abort', () => { + const fence = new RequestFence() + const oldWarehouse = fence.begin() + const newWarehouse = fence.begin() + expect(oldWarehouse.signal.aborted).toBe(true) + expect(oldWarehouse.current()).toBe(false) + expect(newWarehouse.current()).toBe(true) + fence.cancel() + expect(newWarehouse.current()).toBe(false) + }) + it('preserves unavailable family and long string IDs in version context across ranges', () => { + expect(contextParams(context)).toEqual({ salesRunId: '9007199254740999', inventoryRunId: '0', salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: context.queryAsOf }) + expect(sameContext(context, { ...context, rangeStartDate: '2026-10-01' })).toBe(true) + expect(sameContext(context, { ...context, inventoryRunId: 'new' })).toBe(false) + expect(sameContext(context, { ...context, queryAsOf: '2026-10-13T00:01:00+08:00' })).toBe(false) + }) + it('validates real dates, inclusive 366-day maximum and frozen anchor date', () => { + expect(validateCustomRange('2026-02-30', '2026-03-01')).toBeTruthy() + expect(validateCustomRange('2025-10-12', '2026-10-12', '2026-10-12')).toBe('') + expect(validateCustomRange('2025-10-11', '2026-10-12', '2026-10-12')).toContain('366') + expect(validateCustomRange('2026-10-12', '2026-10-13', '2026-10-12')).toContain('基准日') + }) + it('shows partial observed quantity and keeps unknown null distinct from reliable zero', () => { + expect(shownQuantity({ outboundQuantity: null, observedQuantity: '2.75', dataStatus: 'PARTIAL' })).toBe('2.75') + expect(shownQuantity({ outboundQuantity: null, observedQuantity: null, dataStatus: 'UNKNOWN' })).toBeNull() + expect(shownQuantity({ outboundQuantity: '0', observedQuantity: '0', dataStatus: 'COMPLETE' })).toBe('0') + }) + it('requires permission, server eligibility, correct warehouse and reason for retry', () => { + const scope = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A' } + expect(taskRetryAllowed(task, true, scope, '修复后恢复')).toBe(true) + expect(taskRetryAllowed(task, false, scope, '修复后恢复')).toBe(false) + expect(taskRetryAllowed({ ...task, allowedActions: [] }, true, scope, '修复后恢复')).toBe(false) + expect(taskRetryAllowed({ ...task, actionBlockReason: '账号冷却' }, true, scope, '修复后恢复')).toBe(false) + expect(taskRetryAllowed(task, true, { ...scope, warehouseCode: 'WH-B' }, '修复后恢复')).toBe(false) + expect(taskRetryAllowed(task, true, scope, ' ')).toBe(false) + }) + it('reuses exact retry request after ambiguous failure, changes key only for changed intent or accepted request', () => { + const intent = new RetryIntent() + let counter = 0 + const key = () => `key-${++counter}` + const first = intent.create(task.taskNo, 'WH-A', ' 修复 ', key) + expect(intent.create(task.taskNo, 'WH-A', '修复', key)).toBe(first) + expect(intent.create(task.taskNo, 'WH-A', '新的原因', key).requestKey).toBe('key-2') + intent.accepted() + expect(intent.create(task.taskNo, 'WH-A', '新的原因', key).requestKey).toBe('key-3') + expect(first).not.toHaveProperty('taskId') + }) + it('breaks trend at unknown days and retains a single reliable zero point', () => { + const point = (businessDate: string, value: string | null, status: string): DailyPoint => ({ businessDate, outboundQuantity: value, observedQuantity: value, dataStatus: status, reasonCodes: [], quantityUnit: 'PCS' }) + const segments = trendSegments([point('2026-10-10', '0', 'COMPLETE'), point('2026-10-11', null, 'UNKNOWN'), point('2026-10-12', '4', 'PARTIAL')]) + expect(segments).toHaveLength(2) + expect(segments[0]?.path).not.toContain(' L') + expect(segments[1]?.partial).toBe(true) + expect(trendSegments([point('2026-10-12', '0', 'COMPLETE')])).toHaveLength(1) + expect(trendSegments([point('2026-10-12', null, 'UNKNOWN')])).toEqual([]) + }) + it('does not invent progress denominator or combine warehouse and account counts', () => { + const text = progressText({ progressScope: 'ACCOUNT_SHARED', countBasis: 'ATTEMPT_ONLY', attemptNo: 2, scanRound: 2, phase: 'DIRECTORY', completed: 3, total: null, totalKnown: false, unit: 'PAGE', sliceFrom: null, sliceUntil: null }) + expect(text).toContain('账号共享') + expect(text).toContain('本次尝试') + expect(text).toContain('总量待确认') + expect(text).not.toContain('%') + }) +}) +describe('self-operated polling lifecycle', () => { + afterEach(() => vi.useRealTimers()) + it('uses 10s active and 60s idle, stops while hidden and after leave', async () => { + vi.useFakeTimers() + const read = vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false) + const cancel = vi.fn() + const poller = new RuntimePoller(read, cancel) + poller.start() + await vi.advanceTimersByTimeAsync(9999) + expect(read).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(read).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(59999) + expect(read).toHaveBeenCalledTimes(2) + poller.setVisible(false) + await vi.advanceTimersByTimeAsync(120000) + expect(read).toHaveBeenCalledTimes(2) + poller.setVisible(true) + await vi.advanceTimersByTimeAsync(0) + expect(read).toHaveBeenCalledTimes(3) + poller.stop() + await vi.advanceTimersByTimeAsync(120000) + expect(read).toHaveBeenCalledTimes(3) + expect(cancel).toHaveBeenCalled() + }) +}) diff --git a/src/modules/self-operated/state.ts b/src/modules/self-operated/state.ts new file mode 100644 index 00000000..63963680 --- /dev/null +++ b/src/modules/self-operated/state.ts @@ -0,0 +1,160 @@ +import type { BusinessQuery, DailyPoint, Progress, QueryContext, RetryCommand, Scope, Task } from './types' + +// Aborting saves work; generation fencing also protects against adapters that return after abort. +export class RequestFence { + private generation = 0 + private controller: AbortController | null = null + begin() { + this.cancel() + const generation = this.generation + const controller = new AbortController() + this.controller = controller + return { signal: controller.signal, current: () => generation === this.generation && !controller.signal.aborted } + } + cancel() { this.controller?.abort(); this.controller = null; this.generation += 1 } +} +export function contextParams(context: QueryContext | null): Partial { + if (!context) return {} + return { + salesRunId: context.salesRunId ?? '0', inventoryRunId: context.inventoryRunId ?? '0', + salesScopeHash: context.salesScopeHash, inventoryScopeHash: context.inventoryScopeHash, + queryAsOf: context.queryAsOf, + } +} +export function sameContext(a: QueryContext, b: QueryContext) { + return a.salesRunId === b.salesRunId && a.inventoryRunId === b.inventoryRunId + && a.salesScopeHash === b.salesScopeHash && a.inventoryScopeHash === b.inventoryScopeHash + && a.queryAsOf === b.queryAsOf && a.queryExpiresAt === b.queryExpiresAt +} +export function validateCustomRange(start: string, end: string, anchor?: string) { + const validDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value) + && !Number.isNaN(Date.parse(`${value}T00:00:00Z`)) && new Date(`${value}T00:00:00Z`).toISOString().slice(0, 10) === value + if (!validDate(start) || !validDate(end)) return '请选择有效的起止日期' + if (start > end) return '开始日期不得晚于结束日期' + if (anchor && end > anchor) return '结束日期不得晚于本次查询基准日' + if ((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / 86400000 + 1 > 366) return '最多查询 366 个自然日' + return '' +} +export function shownQuantity(row: { outboundQuantity: string | number | null; observedQuantity: string | number | null; dataStatus: string }) { + return row.dataStatus === 'COMPLETE' ? row.outboundQuantity : row.observedQuantity +} +export function quantityText(value: string | number | null | undefined) { return value === null || value === undefined ? '—' : String(value) } +export function beijingTime(value: string | null | undefined) { + if (!value) return '—' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '时间待核对' + return new Intl.DateTimeFormat('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }).format(date) +} +export const statusLabels: Record = { + COMPLETE: '完整', PARTIAL: '未完整', UNKNOWN: '未知', QUEUED: '排队中', RUNNING: '执行中', + WAITING: '等待中', WAITING_UPSTREAM: '等待上游', RETRY_WAIT: '待重试', YIELDED: '预算等待', + FAILED: '失败', DEAD: '待处理', SUCCEEDED: '执行成功', ROUTED: '账号共享采集完成', + SAMPLE_DONE: '测试样本结束', PUBLISHED: '已发布', REUSED: '沿用已有结果', NOT_PUBLISHED: '未发布', + PENDING: '待处理', BLOCKED: '修订阻断', OFF: '关闭', VALIDATION: '验证采样', BUSINESS: '正式采集', + FRESH: '有效', STALE: '过期', INVALID: '异常', OBSERVING: '观察中', HEALTHY: '正常', + STALE_HEARTBEAT: '心跳过期,执行状态待确认', + UNCONFIRMED: '执行状态待确认', TERMINATED: '尝试已结束', ABORTED: '已停止', + NOT_STARTED: '尚未开始', FETCHING: '采集中', FETCHED: '采集完成', VALIDATING: '校验中', + BUILDING: '计算中', READY: '待发布', INITIALIZING: '首采中', COVERED: '覆盖完整', + ACCOUNT_DISCOVERY: '账号共享出库目录', WAREHOUSE_SYNC: '本仓采集', + VALID: '有效', CURRENT: '已核对', LAGGING: '核对滞后', MISSING: '尚无可用数据', + UNCOVERED: '覆盖不足', INCOMPLETE: '未完整', + NO_AVAILABLE_INVENTORY: '无可售库存', UNIT_UNKNOWN_OR_CONFLICT: '数量单位待核对', + SALES_UNTRUSTED: '销量待核对', COVERAGE_INSUFFICIENT: '覆盖不足', OBSERVATION_INSUFFICIENT: '观察期不足', + SALES_UNKNOWN: '销量未知', NO_OUTBOUND_30_DAYS: '近30天无出库', INVENTORY_INVALID: '库存异常', + INVENTORY_STALE: '库存过期', INVENTORY_UNKNOWN: '库存未知', +} +export const reasonLabels: Record = { + NO_OUTBOUND_30D: '近30天无出库', COVERAGE_GT_THRESHOLD: '可售天数超过本次阈值', + FROZEN_STOCK: '存在冻结库存', DEFECTIVE_STOCK: '存在次品实物库存', UNASSESSABLE: '数据不足/待观察', + THRESHOLD_NOT_SET: '未设置查询阈值', NO_PHYSICAL_INVENTORY: '无实物库存', + METRIC_DEPENDENCY_UNKNOWN: '该指标依赖资料未知', SALES_WINDOW_UNAVAILABLE: '完整30日销量不可用', + RISK_DEPENDENCY_UNKNOWN: '部分SKU关注判断未知', NO_OUTBOUND_IN_COLLECTED_HISTORY: '已采集历史内未见出库', + SALES_NOT_COVERED: '销量未覆盖', SKU_HISTORY_UNKNOWN: 'SKU历史未知', QUANTITY_UNIT_UNKNOWN: '数量单位未知', + INVENTORY_NOT_PUBLISHED: '库存尚未发布', SALES_NOT_PUBLISHED: '销量尚未发布', INVENTORY_STALE: '库存快照过期', + INVENTORY_UNKNOWN: '库存未知', INVENTORY_INVALID: '库存异常', OBSERVATION_INCOMPLETE: '观察期不足', + SCOPE_CHANGED: '配置范围已变化', UPSTREAM_NOT_READY: '账号共享上游未就绪', TASK_NOT_RETRYABLE: '任务当前不可恢复', + COLLECTION_MODE_NOT_BUSINESS: '当前不是正式采集模式', VALIDATION_NOT_RETRYABLE: '验证会话不可恢复', + ACCOUNT_SHARED_TASK: '账号共享任务由协调器恢复', SOURCE_TIMEZONE_UNKNOWN: '来源时区未确认', + UNIT_UNKNOWN_OR_CONFLICT: '数量单位未知或不一致', SALES_DAY_INCOMPLETE: '销量日期未完整', + SALES_NOT_COVERED_OR_SKU_HISTORY_UNKNOWN: '销量未覆盖或SKU历史未知', SCOPE_LEASE_BUSY: '本仓任务正在执行', + COLLECTION_OFF: '采集模式关闭', COLLECTION_MODE_INVALID: '采集模式配置无效', BUSINESS_ENVIRONMENT_REJECTED: '正式采集只允许生产环境', + EXECUTION_DISABLED: '采集执行未启用', WAREHOUSE_NOT_CONFIGURED: '仓库未配置', TIMEZONE_OR_UNIT_NOT_CONFIRMED: '时间或件数口径配置不符合确认值', + ACCOUNT_PAUSED: '账号采集已暂停', ACCOUNT_COOLDOWN: '账号处于冷却期', EXCLUSIVE_GRANT_NOT_VALID: '账号独占窗口授权无效', + DAILY_BUDGET_NOT_CONFIGURED: '账号日额度未配置', DAILY_BUDGET_EXHAUSTED: '账号日额度已用完', + SALES_MISSING: '尚无销量发布版本', INVENTORY_MISSING: '尚无库存发布版本', SALES_BLOCKED: '销量修订阻断', + SALES_STALE: '销量数据过期', REVISION_REVIEW_UNKNOWN: '修订核对状态未知', REVISION_REVIEW_LAGGING: '修订核对滞后', +} +export function reasonsText(reasons: readonly string[] | null | undefined) { return reasons?.map(reason => reasonLabels[reason] ?? statusLabels[reason] ?? '存在待核对项').join('、') || '—' } +export function phaseText(phase: string | null | undefined) { + const phases: Record = { DIRECTORY: '获取出库目录', DISCOVERY: '获取出库目录', DETAIL: '获取出库详情', DETAILS: '获取出库详情', INVENTORY: '获取产品库存', VALIDATE: '校验', VALIDATION: '校验', MERGE: '归并', BUILD: '统计计算', PUBLISH: '发布', WAIT_UPSTREAM: '等待账号共享上游', WAIT_BUDGET: '预算等待', WAITING_SOURCE: '等待来源准备', WAITING: '等待执行', SAMPLE_DONE: '样本结束', FAILED: '执行失败', FETCH: '获取数据', PLANNED: '已规划', FETCHED: '数据已取完', VALIDATED: '已校验', BUILDING: '统计计算', BUILT: '计算完成', PUBLISHED: '已发布' } + return phase ? phases[phase] ?? '阶段待核对' : '阶段待确认' +} +export function label(value: string | null | undefined) { return value ? statusLabels[value] ?? '待核对' : '—' } +const unitLabels: Record = { PAGE: '页', ORDER: '单', INVENTORY_ROW: '库存行', BUSINESS_KEY: '业务键', SKU: 'SKU', BATCH: '批', NONE: '无计数' } +export function unitText(unit: string | null | undefined) { return unitLabels[unit ?? ''] ?? '计数单位待确认' } +export function progressText(progress: Partial | null | undefined) { + if (!progress) return '阶段进度待确认' + const scope = progress.progressScope === 'ACCOUNT_SHARED' ? '账号共享' : progress.progressScope === 'WAREHOUSE' ? '本仓' : '范围待确认' + const basis = progress.countBasis === 'TASK_CUMULATIVE' ? '任务累计' : progress.countBasis === 'ATTEMPT_ONLY' ? '本次尝试' : '计数口径待确认' + if (progress.unit === 'NONE') return `${scope} · ${phaseText(progress.phase)} · 无对象计数,保留已提交阶段摘要` + const total = progress.totalKnown && progress.total != null ? ` / ${progress.total}` : ',总量待确认' + return `${scope} · ${phaseText(progress.phase)} · ${basis}${progress.scanRound == null ? '' : ` · 第 ${progress.scanRound} 轮`} · ${quantityText(progress.completed)}${total} ${unitLabels[progress.unit ?? ''] ?? '单位待确认'}` +} +export function taskRetryAllowed(task: Task | null, canSync: boolean, scope: Scope, reason: string) { + return Boolean(canSync && task?.taskNo && task.warehouseCode === scope.warehouseCode + && task.allowedActions.includes('RETRY') && !task.actionBlockReason && reason.trim()) +} +export class RetryIntent { + private pending: RetryCommand | null = null + create(taskNo: string, warehouseCode: string, reason: string, key: () => string = () => crypto.randomUUID()): RetryCommand { + if (this.pending?.taskNo === taskNo && this.pending.warehouseCode === warehouseCode && this.pending.reason === reason.trim()) return this.pending + this.pending = { action: 'RETRY', requestKey: key(), taskNo, warehouseCode, reason: reason.trim() } + return this.pending + } + accepted() { this.pending = null } +} +export function hasActiveTask(tasks: readonly Task[]) { + return tasks.some(task => ['QUEUED', 'RUNNING', 'WAITING', 'WAITING_UPSTREAM', 'RETRY_WAIT', 'YIELDED'].includes(task.taskStatus)) +} +export class RuntimePoller { + private timer: ReturnType | null = null + private active = false + private visible = true + private generation = 0 + constructor(private readonly read: () => Promise, private readonly cancel: () => void) {} + start() { this.active = true; this.restart() } + setVisible(visible: boolean) { this.visible = visible; this.restart() } + stop() { this.active = false; this.restart() } + private restart() { + this.generation += 1 + if (this.timer) clearTimeout(this.timer) + this.timer = null + this.cancel() + if (this.active && this.visible) void this.tick(this.generation) + } + private async tick(generation: number) { + let busy = false + try { busy = await this.read() } catch { /* Caller records failure without replacing last data. */ } + if (generation === this.generation && this.active && this.visible) this.timer = setTimeout(() => void this.tick(generation), busy ? 10000 : 60000) + } +} +export function trendSegments(points: DailyPoint[]) { + const max = Math.max(1, ...points.map(point => Number(shownQuantity(point) ?? 0))) + const segments: { path: string; partial: boolean }[] = [] + let path = '' + let partial = false + const flush = () => { if (path) segments.push({ path, partial }); path = '' } + points.forEach((point, index) => { + const quantity = shownQuantity(point) + if (quantity == null || point.dataStatus === 'UNKNOWN') { flush(); return } + const x = points.length === 1 ? 400 : 30 + index / (points.length - 1) * 740 + const y = 175 - Number(quantity) / max * 145 + const pointPartial = point.dataStatus !== 'COMPLETE' + if (path && partial !== pointPartial) { flush() } + path += `${path ? ' L' : 'M'}${x},${y}` + partial = pointPartial + }) + flush() + return segments +} diff --git a/src/modules/self-operated/types.ts b/src/modules/self-operated/types.ts new file mode 100644 index 00000000..b4393106 --- /dev/null +++ b/src/modules/self-operated/types.ts @@ -0,0 +1,217 @@ +export type Quantity = string | number | null +export type RangePreset = 'TODAY' | 'LAST_3_DAYS' | 'THIS_WEEK' | 'LAST_15_DAYS' | 'THIS_MONTH' | 'CUSTOM' +export type InventoryAttention = 'ALL' | 'NO_OUTBOUND_30D' | 'COVERAGE_GT_THRESHOLD' | 'FROZEN_STOCK' | 'DEFECTIVE_STOCK' | 'UNASSESSABLE' +export type InventorySort = 'AVAILABLE_DESC' | 'COVERAGE_DAYS_DESC' | 'PHYSICAL_DESC' | 'OUTBOUND_30D_ASC' +export interface QueryContext { + salesRunId: string | null + inventoryRunId: string | null + salesScopeHash: string | null + inventoryScopeHash: string | null + queryAsOf: string + queryExpiresAt: string + businessTimezone: string + anchorDate: string + rangeStartDate?: string + rangeEndDateExclusive?: string + salesCoveredFrom?: string | null + salesCoveredUntil?: string | null + inventoryObservedFrom?: string | null + inventoryObservedUntil?: string | null + salesPublishedAt?: string | null + completeDayFrom?: string | null + completeDayUntil?: string | null + calculationWindowDays?: number | null + salesStatus?: string + inventoryStatus?: string + rebuildRequired?: boolean + revisionReviewStatus?: string + reasonCodes?: string[] +} +export interface Scope { sourceAccount: string; warehouseCode: string } +export interface BusinessQuery extends Scope { + customerCode?: string + sku?: string + current: number + size: number + rangePreset?: RangePreset + startDate?: string + endDate?: string + windowDays?: 7 | 30 + attention?: InventoryAttention + sortBy?: InventorySort + coverageDaysThreshold?: number + salesRunId?: string | null + inventoryRunId?: string | null + salesScopeHash?: string | null + inventoryScopeHash?: string | null + queryAsOf?: string +} +export interface SalesSummary { + sourceAccount: string + customerCode: string + warehouseCode: string + sku: string + productName: string | null + quantityUnit: string | null + outboundQuantity: Quantity + observedQuantity: Quantity + dataStatus: string + reasonCodes: string[] + completeDayCount: number + partialDayCount: number + unknownDayCount: number +} +export interface DailyPoint { + businessDate: string + outboundQuantity: Quantity + observedQuantity: Quantity + dataStatus: string + reasonCodes: string[] + quantityUnit: string | null + coveredFrom?: string | null + coveredUntil?: string | null +} +export interface DailyResponse { context: QueryContext; summary: SalesSummary; points: DailyPoint[] } +export interface Metric { + sourceAccount: string + customerCode: string + warehouseCode: string + sku: string + productName: string | null + quantityUnit: string | null + outboundQuantity: Quantity + skuOutboundOrderCount: number | null + physicalQuantity: Quantity + availableQuantity: Quantity + frozenQuantity: Quantity + transitQuantity: Quantity + averageDailyOutboundQuantity: Quantity + inventoryCoverageDays: Quantity + inventoryObservedAt: string | null + status: string + reasonCodes: string[] + calculationWindowDays?: number + customerName?: string | null + defectivePhysicalQuantity?: Quantity + outboundQuantity30d?: Quantity + lastObservedOutboundDate?: string | null + observedHistoryStartDate?: string | null + attentionReasons?: string[] + quantityStatuses?: Record +} +export interface OwnerOption { customerCode: string; customerName: string | null } +export type OwnerQuery = Omit & { keyword?: string } +export type OwnerOverviewQuery = Omit & { customerCode: string } +export interface QuantityTotal { overallTotal: Quantity; knownSubtotal: Quantity; unknownSkuCount: Quantity; dataStatus: string; reasonCodes: string[] } +export interface RiskShare { matchedSkuCount: Quantity; matchedPhysicalQuantity: Quantity; physicalQuantityRatio: Quantity; knownMatchedSkuCount: Quantity; knownMatchedPhysicalQuantity: Quantity; unassessableSkuCount: Quantity; unassessableKnownPhysicalQuantity: Quantity; reasonCodes: string[] } +export interface OwnerOverview { + context: QueryContext + customerCode: string + customerName: string | null + skuCount: Quantity + physicalSkuCount: QuantityTotal + physicalQuantity: QuantityTotal + availableQuantity: QuantityTotal + frozenQuantity: QuantityTotal + defectivePhysicalQuantity: QuantityTotal + transitQuantity: QuantityTotal + outboundQuantity30d: QuantityTotal + averageDailyOutboundQuantity: QuantityTotal + noOutbound30d: RiskShare + coverageAboveThreshold: RiskShare +} +export interface BusinessPage { context: QueryContext; records: T[]; total: number; current: number; size: number; excludedReasons?: Record } +export interface Progress { + progressScope: string + countBasis: string + attemptNo: number | null + scanRound: number | null + phase: string | null + completed: number | null + total: number | null + totalKnown: boolean + unit: string | null + sliceFrom: string | null + sliceUntil: string | null + progressUpdatedAt?: string | null + stages?: Partial[] + validationState?: string | null + invalidCount?: number | null + unknownCount?: number | null + pendingReviewCount?: number | null + countUnit?: string | null + summaryAsOf?: string | null +} +export interface Run { + attemptNo: number | null + status: string + phase: string | null + startedAt: string | null + finishedAt: string | null + progressUpdatedAt: string | null + heartbeatAt: string | null + executionHealth: string | null + elapsedSeconds: number | null + errorCode: string | null + failureSummary: string | null + progress?: Partial | null +} +export interface Task { + taskNo: string + taskType: string + dataFamily: string + warehouseCode: string + queryFrom: string | null + queryUntil: string | null + taskStatus: string + applyState: string | null + attemptCount: number + latestAttemptNo: number | null + resultPublicationState: string | null + nextRetryAt: string | null + allowedActions: string[] + actionBlockReason: string | null + progress: Partial | null + latestAttempt?: Run | null + failureSummary?: string | null + executionPurpose?: string | null + progressUpdatedAt?: string | null + validation?: Validation | null +} +export interface RuntimePage { records: T[]; total: number; current: number; size: number; listUpperCreatedAt: string | null; listUpperId: string | null; observedAt: string } +export interface RuntimeQuery extends Scope { current: number; size: number; taskNo?: string; listUpperCreatedAt?: string | null; listUpperId?: string | null } +export interface Protection { accountCooldownUntil: string | null; accountPauseReason: string | null; dailyRequestBudget: number | null; reservedHttpRequests: number | null; confirmedHttpRequests: number | null; budgetDate: string | null } +export interface Validation { validationSessionNo: string | null; budgetLimits: Record | null; budgetConsumed: Record | null; deadlineAt: string | null; stopReason: string | null } +export interface TaskDetail { task: Task; sharedUpstream: Task | null; protection: Protection; observedAt: string } +export interface WarehouseOverview { + warehouseCode: string + warehouseName: string | null + dataFamily: string + executionEnabled: boolean + publicationEnabled: boolean + disabledReason: string | null + nextScheduledAt: string | null + observedAt: string + initialFrom: string | null + initialUntil: string | null + initialCoverageStatus: string | null + salesCoveredFrom: string | null + salesCoveredUntil: string | null + lastSuccessfulTaskNo: string | null + lastSucceededAt: string | null + lastPublishedAt: string | null + inventoryObservedFrom: string | null + inventoryObservedUntil: string | null + dataFreshness: string | null + rebuildRequired: boolean + revisionReviewStatus: string | null + protection: Protection + currentTask?: Task | null + sharedUpstream?: Task | null + salesRunId?: string | null + inventoryRunId?: string | null + inventoryDailySamples?: { windowFrom: string; windowUntil: string; expectedDays: number; sampleCount: number; missingDates: string[]; accumulationStartedOn: string | null } | null +} +export interface Overview { sourceAccount: string; environmentLabel: string; collectionMode: string; observedAt: string; warehouses: WarehouseOverview[] } +export interface RetryCommand { action: 'RETRY'; requestKey: string; warehouseCode: string; taskNo: string; reason: string } +export interface RetryResult { taskNo: string; status: string } diff --git a/src/styles/adminDensity.test.ts b/src/styles/adminDensity.test.ts index 7fcf4521..d6b7d11b 100644 --- a/src/styles/adminDensity.test.ts +++ b/src/styles/adminDensity.test.ts @@ -90,7 +90,10 @@ const notificationConsumers = [ 'notifications/components/TemplateEditorDialog.vue', ] const systemConsumers = ['iam/pages/InternalUsersView.vue', 'iam/pages/RolesView.vue', 'iam/pages/PermissionsView.vue'] +const selfOperatedPages = ['self-operated/components/AnalyticsView.vue', 'self-operated/pages/CollectionView.vue'] +const selfOperatedConsumers = [...selfOperatedPages, 'self-operated/components/SkuTrend.vue'] const consumers = [ + ...selfOperatedConsumers, 'overseas-business/components/FleetArrangementSummary.vue', ...notificationConsumers, ...systemConsumers, @@ -131,6 +134,7 @@ const customerPages = [ { title: '海外仓阶梯费用', path: '/customers/overseas-storage-fees', file: 'customers/pages/OverseasStorageFeesView.vue' }, ] const densityPages = [...new Set([ + ...selfOperatedPages, ...notificationConsumers.filter(file => file.includes('/pages/')), ...systemConsumers, ...generalCatalogConsumers.filter(file => file.includes('/pages/')), @@ -442,7 +446,7 @@ describe('approved Admin density ownership', () => { for (const node of nodes) { if (node.type !== 1) continue const isActionSlot = actionSlot || (parentTag === 'DataTable' && node.props.some(prop => prop.type === 7 && prop.name === 'slot' && prop.arg?.type === 4 && prop.arg.content === 'actions')) - if (densityPages.includes(file) && isActionSlot && node.tag === 'el-button') expect(classesOf(node), `${file}: row action`).toContain('density-action') + if (densityPages.includes(file) && !selfOperatedPages.includes(file) && isActionSlot && node.tag === 'el-button') expect(classesOf(node), `${file}: row action`).toContain('density-action') if (['AppDialog', 'el-dialog'].includes(node.tag)) expect(classesOf(node), `${file}: teleported dialog`).toContain('admin-density-dialog') if (['AppDrawer', 'el-drawer'].includes(node.tag)) expect(classesOf(node), `${file}: teleported drawer`).toContain('admin-density-drawer') visit(node.children, isActionSlot, node.tag) @@ -452,6 +456,28 @@ describe('approved Admin density ownership', () => { } }) + it('opts the confirmed self-operated pages into integrated text actions without changing older outlined consumers', () => { + expect(getNavigationLeaves(adminNavigation).map(node => node.path)).toEqual(expect.arrayContaining([ + '/self-operated/sales', '/self-operated/inventory', '/self-operated/collection', + ])) + for (const file of selfOperatedPages) { + const template = parse(read(`modules/${file}`)).descriptor.template!.ast! + let tables = 0 + const visit = (nodes: typeof template.children) => { + for (const node of nodes) { + if (node.type !== 1) continue + if (node.tag === 'DataTable') { + expect(node.props.some(prop => prop.type === 6 && prop.name === 'appearance' && prop.value?.content === 'integrated'), `${file}: integrated text actions`).toBe(true) + tables += 1 + } + visit(node.children) + } + } + visit(template.children) + expect(tables).toBeGreaterThan(0) + } + }) + it('keeps tokens and shared rules opt-in and free of business selectors', () => { expect(shared).toContain("@import './adminDensityTokens.css'") for (const css of [tokens, shared]) cssRoot(css).walkRules(rule => {