Compare commits

...
39 Commits
Author SHA1 Message Date
ouyangxq a2ec169eb3 Sync optional CDN base configuration from GitHub dev [skip ci] 2026-10-14 21:06:09 +08:00
ouyangxq 46364d3f89 feat(selfop): show customer names and filter sales and turnover 2026-10-14 16:15:40 +08:00
ouyangxq 2a9c23ebc6 feat(self-operated): add transit column and streamline dashboard ranking explanations 2026-10-14 14:09:24 +08:00
ouyangxq adfb1a5cb3 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-14 14:01:01 +08:00
ouyangxq ff9ca74ba4 fix(self-operated): clarify turnover chart values and coverage threshold 2026-10-14 14:00:35 +08:00
liuyihe cb38114db9 优化 2026-10-14 13:53:15 +08:00
ouyangxq e7411eb03f feat(self-operated): unify completed-day ranges and analytics sorting 2026-10-14 12:39:16 +08:00
liuyihe 6b30e35d07 优化 2026-10-14 12:14:29 +08:00
liuyihe 0830d7a3a3 优化 2026-10-14 11:43:32 +08:00
ouyangxq 6a905419fc fix(self-operated): render dashboard KPIs with omitted unknown values 2026-10-14 11:22:59 +08:00
ouyangxq d8a4160bc7 fix(self-operated): clarify today's unavailable sales and tooltip precision 2026-10-14 11:18:47 +08:00
ouyangxq e0f54edcc9 优化 2026-10-14 11:02:56 +08:00
ouyangxq 3f728cd0b2 fix(selfop): render inventory turnover cells and data status 2026-10-14 09:23:29 +08:00
ouyangxq 3291d34fb8 fix(selfop): label order processing independently of detail requests 2026-10-14 08:57:21 +08:00
ouyangxq 02a9d72ef8 fix: preserve turnover deep-link snapshots and clarify data status 2026-10-14 01:52:20 +08:00
ouyangxq 2cf9129606 优化 2026-10-14 00:07:05 +08:00
ouyangxq 4c4a3db1eb feat(self-operated): add charts and continuous collection status 2026-10-13 20:44:31 +08:00
ouyangxq 698ca34529 fix(self-operated): follow selected dates in hot rankings 2026-10-13 18:47:56 +08:00
ouyangxq 133654327d refactor(self-operated): simplify collection account monitor 2026-10-13 17:29:45 +08:00
liuyihe 260409c071 优化样式 2026-10-13 17:14:56 +08:00
ouyangxq c7d9af0165 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-13 16:54:14 +08:00
ouyangxq bda0743520 feat: focus collection page on account activity and progress 2026-10-13 16:52:09 +08:00
liuyihe 50df363f86 优化 2026-10-13 16:49:08 +08:00
liuyihe c61ae525e1 优化 2026-10-13 14:47:06 +08:00
liuyihe 8bf7b20b89 优化 2026-10-13 13:46:56 +08:00
ouyangxq 1de73b5f1c fix: keep analytics date ranges together and apply atomically 2026-10-13 13:43:16 +08:00
ouyangxq 6bdaff3fdf fix(selfop): distinguish preparation progress from warehouse tasks 2026-10-13 12:50:29 +08:00
ouyangxq 9dcd991ddb feat(selfop): select inventory sales window and cutoff date 2026-10-13 12:36:45 +08:00
ouyangxq 58df0ed9e3 fix(selfop): preserve warehouse navigation and refine overview presentation 2026-10-13 11:06:20 +08:00
ouyangxq b73dc0d410 feat(selfop): add operating dashboard and preserve drilldown context 2026-10-13 00:45:41 +08:00
ouyangxq cd3a32f372 fix(selfop): show inventory observation time range 2026-10-13 00:10:54 +08:00
ouyangxq 600fdcb4b4 fix(selfop): unify collection layout and show details in dialogs 2026-10-12 22:06:45 +08:00
ouyangxq 57c50e6876 Merge remote-tracking branch 'origin/release/test' into release/test 2026-10-12 21:37:13 +08:00
ouyangxq 449d0c4965 refactor(self-operated): focus analytics pages on SKU metrics 2026-10-12 21:36:24 +08:00
liuyihe 2186f1f3a6 优化 2026-10-12 21:19:07 +08:00
ouyangxq 533273769d feat: add platform self-operated sales inventory and collection views 2026-10-12 20:33:39 +08:00
liuyihe cc4856ceac 优化 2026-10-12 18:01:41 +08:00
liuyihe 3f6d74dee1 优化 2026-10-12 13:43:21 +08:00
ouyangxq 92bdd32f86 fix(appointments): unify calendar layout and cabinet numbers 2026-10-12 11:45:00 +08:00
120 changed files with 5826 additions and 276 deletions
+1
View File
@@ -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 存在代替页面接入完成 |
+2
View File
@@ -16,10 +16,12 @@
"@lucide/vue": "^1.27.0",
"axios": "^1.18.1",
"dayjs": "^1.11.21",
"echarts": "^6.1.0",
"element-plus": "^2.14.3",
"pinia": "^4.0.2",
"sortablejs": "^1.15.7",
"vue": "^3.5.40",
"vue-echarts": "^8.3.2",
"vue-router": "^5.2.0"
},
"devDependencies": {
+37
View File
@@ -20,6 +20,9 @@ importers:
dayjs:
specifier: ^1.11.21
version: 1.11.21
echarts:
specifier: ^6.1.0
version: 6.1.0
element-plus:
specifier: ^2.14.3
version: 2.14.3(vue@3.5.40(typescript@5.9.3))
@@ -32,6 +35,9 @@ importers:
vue:
specifier: ^3.5.40
version: 3.5.40(typescript@5.9.3)
vue-echarts:
specifier: ^8.3.2
version: 8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3))
vue-router:
specifier: ^5.2.0
version: 5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
@@ -641,6 +647,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
echarts@6.1.0:
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
editorconfig@1.0.7:
resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
engines: {node: '>=14'}
@@ -1150,6 +1159,9 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -1325,6 +1337,12 @@ packages:
vue-component-type-helpers@3.3.8:
resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==}
vue-echarts@8.3.2:
resolution: {integrity: sha512-FbJD49OZZZv1I8aclHQ+gcQxU+Y9nNWQMuod1sewE7/YjGQUJAaeVcf6WrSfQDBRPke+WoVsUtdRFIqPOsRrnw==}
peerDependencies:
echarts: ^6.0.0
vue: ^3.3.0
vue-router@5.2.0:
resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==}
peerDependencies:
@@ -1406,6 +1424,9 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
zrender@6.1.0:
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
snapshots:
'@asamuzakjp/css-color@5.1.11':
@@ -1960,6 +1981,11 @@ snapshots:
eastasianwidth@0.2.0: {}
echarts@6.1.0:
dependencies:
tslib: 2.3.0
zrender: 6.1.0
editorconfig@1.0.7:
dependencies:
'@one-ini/wasm': 0.1.1
@@ -2436,6 +2462,8 @@ snapshots:
dependencies:
punycode: 2.3.1
tslib@2.3.0: {}
tslib@2.8.1:
optional: true
@@ -2563,6 +2591,11 @@ snapshots:
vue-component-type-helpers@3.3.8: {}
vue-echarts@8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3)):
dependencies:
echarts: 6.1.0
vue: 3.5.40(typescript@5.9.3)
vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)):
dependencies:
'@babel/generator': 8.0.0
@@ -2658,3 +2691,7 @@ snapshots:
xmlchars@2.2.0: {}
yaml@2.9.0: {}
zrender@6.1.0:
dependencies:
tslib: 2.3.0
+2
View File
@@ -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',
+12 -3
View File
@@ -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/overview', '/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(56)
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)
})
+11
View File
@@ -181,6 +181,17 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'sales-commission', title: '销售提成', icon: Database, path: '/prototype/sales/commission' },
],
},
{
key: 'self-operated',
title: '海外仓',
icon: PrototypeSalesIcon,
children: [
{ key: 'self-operated-overview', title: '经营总览', icon: PrototypeDashboardIcon, path: '/self-operated/overview', permission: 'platform:self-operated-analytics:list' },
{ 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: '通用',
@@ -3,6 +3,19 @@ import { describe, expect, it } from 'vitest'
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
describe('appointment calendar interactions', () => {
it('keeps creation beside the title and record actions beside data notices', () => {
const wrapper = mount(AppointmentCalendarPanel, {
props: { canCreate: true, events: [], notices: ['数据范围不完整'] },
slots: { 'toolbar-actions': '<button>未定时安排</button><button>取消记录</button>' },
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
})
expect(wrapper.get('.calendar-label').text()).toContain('新增预约')
const actions = wrapper.get('[aria-label="预约记录与数据提示"]')
expect(actions.text()).toContain('未定时安排')
expect(actions.text()).toContain('取消记录')
expect(actions.get('summary').text()).toContain('数据提示')
wrapper.unmount()
})
const render = () => mount(AppointmentCalendarPanel, {
props: { initialDate: '2026-09-24', canCreate: true, events: [
{ key: 'a', type: 'delivery', date: '2026-09-24', time: '09:00-11:00', title: 'ONT8 预约送货', fields: [], canEdit: true, canDelete: true },
@@ -84,10 +84,9 @@ defineExpose({ selectedDate, mode })
<div class="calendar-label">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
<strong>预约日历</strong>
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
</div>
<div class="calendar-controls">
<details v-if="notices.length" class="calendar-data-notices"><summary>数据提示 · {{ notices.length }}</summary><div class="calendar-notice-content" role="status"><p v-for="notice in notices" :key="notice">{{ notice }}</p><slot name="notice-actions" /></div></details>
<el-button v-if="canCreate" type="primary" :disabled="loading" @click="emit('create', selectedDate)">+ 新增预约</el-button>
<div class="view-toggle" role="group" aria-label="预约视图">
<button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button>
<button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button>
@@ -97,6 +96,10 @@ defineExpose({ selectedDate, mode })
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button>
<el-button @click="select(dateKey(new Date()))">今天</el-button>
</div>
<div class="calendar-record-actions" role="group" aria-label="预约记录与数据提示">
<slot name="toolbar-actions" />
<details v-if="notices.length" class="calendar-data-notices"><summary>数据提示 · {{ notices.length }}</summary><div class="calendar-notice-content" role="status"><p v-for="notice in notices" :key="notice">{{ notice }}</p><slot name="notice-actions" /></div></details>
</div>
</header>
<div class="calendar-options">
<div class="calendar-legend"><span v-if="showArrivals" class="legend arrival">船舶到港</span><span class="legend delivery">预约送仓</span><span class="legend pickup">预约提柜</span><span v-if="showTrips" class="legend trip">用车计划</span></div>
@@ -149,6 +152,8 @@ defineExpose({ selectedDate, mode })
.calendar-main, .calendar-detail { background: #fff; border: 1px solid #e4e9f2; border-radius: 10px; overflow: hidden; }
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid #edf0f6; }
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: 10px; }
.calendar-record-actions { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.calendar-record-actions :deep(.el-button + .el-button) { margin-left: 0; }
.calendar-label > svg { color: #617ce9; }
.calendar-label > strong { font-size: var(--admin-font-section); }
.calendar-options { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--admin-table-border); }
@@ -40,7 +40,7 @@ function render(openCalendar = true) {
const unregister = register(() => !routing.containerPending)
onBeforeUnmount(unregister)
}, template: '<div>整柜跟进工作区</div>' },
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', mounted() { this.$emit('layers', { arrivals: true, trips: true }) }, props: ['events', 'incomplete', 'notices'], template: '<div><button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button><slot name="notice-actions" /></div>' },
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', mounted() { this.$emit('layers', { arrivals: true, trips: true }) }, props: ['events', 'incomplete', 'notices'], template: '<div><button data-create @click="$emit(\'create\', \'2026-10-09\')">新增预约</button><slot name="notice-actions" /><slot name="toolbar-actions" /></div>' },
AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm', 'beforeClose'], template: '<div v-if="modelValue"><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button v-if="showConfirm !== false" data-save @click="$emit(\'confirm\')">保存</button></div>' },
ElForm: { template: '<form><slot /></form>' },
ElFormItem: { template: '<label><slot /></label>' },
@@ -70,6 +70,19 @@ describe('appointment calendar data integration', () => {
vi.mocked(createFbaAppointment).mockResolvedValue({ version: 0, id: '1', appointmentNo: 'APT-AB', plannedDate: '2026-10-09', appointmentAt: null, originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 2, fbaWarehouseCode: 'ONT8', items: [] })
})
it('displays the existing D cabinet number in both arrival and pickup events', async () => {
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', cabinetNo: 'D0007', arrivalAt: '2026-10-09T08:00:00', arrivalType: 'ESTIMATED' }])
vi.mocked(listPickupCalendar).mockResolvedValueOnce({ records: [{ containerBookingId: '1', cabinetNo: 'D0007', containerNo: null, destinationPortName: null, destinationWarehouseCode: null, fleetSupplierName: null, pickupAppointmentAt: '2026-10-09T09:00:00', actualPickupAt: null }], truncated: false, unclassifiedCount: 0, scopeBasis: 'US_DESTINATION_WAREHOUSE', timeBasis: 'SOURCE_LOCAL_UNSPECIFIED' })
const wrapper = render(); await flushPromises()
const events = wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')
expect(events).toHaveLength(2)
for (const event of events) {
expect(event.fields).toContainEqual({ label: '柜次号', value: 'D0007' })
expect(event.title).toContain('D0007')
}
wrapper.unmount()
})
it('opens a dated calendar appointment read-only for list-only users', async () => {
routing.canWrite=false
const record={version:0,id:'1',appointmentNo:'AP-DATED',plannedDate:'2026-10-15',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[]}
@@ -90,6 +103,7 @@ describe('appointment calendar data integration', () => {
vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [record], total: 1})
vi.mocked(getFbaAppointment).mockResolvedValue(record)
const wrapper=render(); await flushPromises()
await wrapper.get('[data-unscheduled]').trigger('click')
await wrapper.findAll('button').find(button=>button.text()==='查看 / 继续')!.trigger('click'); await flushPromises()
expect(wrapper.find('option[value="CANCELLED"]').exists()).toBe(false)
await wrapper.get('[data-cancel-editing]').trigger('click'); await flushPromises()
@@ -97,13 +111,13 @@ describe('appointment calendar data integration', () => {
expect(wrapper.find('[data-save]').exists()).toBe(false)
expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
})
it('loads cancellation records only after expansion and opens cancelled appointments read-only', async () => {
it('loads cancellation records only after opening the dialog and opens cancelled appointments read-only', async () => {
const record = { version: 1, id: '1', appointmentNo: 'AP-CANCEL', originWarehouseId: '100', appointmentStatus: 'CANCELLED', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] }
vi.mocked(getFbaAppointment).mockResolvedValue(record)
const wrapper=render();await flushPromises()
expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false)
const records=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消记录')!
;(records.element as HTMLDetailsElement).open=true;await records.trigger('toggle')
const records=wrapper.get('[data-cancellations]')
await records.trigger('click')
await wrapper.get('[data-history-view]').trigger('click');await flushPromises()
expect(getFbaAppointment).toHaveBeenCalledWith('AP-CANCEL')
expect(wrapper.find('[data-save]').exists()).toBe(false)
@@ -119,8 +133,8 @@ describe('appointment calendar data integration', () => {
it('refreshes an expanded cancellation list after a successful cancellation', async () => {
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([{version:0,id:'1',appointmentNo:'AP-CANCEL',originWarehouseId:'100',appointmentStatus:'PENDING',selectedCount:1,fbaWarehouseCode:'ABE3',items:[],plannedDate:'2026-10-15'}])
const wrapper=render();await flushPromises()
const records=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消记录')!
;(records.element as HTMLDetailsElement).open=true;await records.trigger('toggle')
const records=wrapper.get('[data-cancellations]')
await records.trigger('click')
const previous=wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm
wrapper.getComponent({name:'AppointmentCalendarPanel'}).vm.$emit('delete',{key:'AP-CANCEL'});await flushPromises()
wrapper.getComponent({name:'AppointmentCancellationDialog'}).vm.$emit('cancelled');await flushPromises()
@@ -150,6 +164,7 @@ describe('appointment calendar data integration', () => {
vi.mocked(listUnscheduledAppointments).mockResolvedValue({ records: [record], total: 1 })
vi.mocked(getFbaAppointment).mockResolvedValue(record)
const wrapper = render(); await flushPromises()
await wrapper.get('[data-unscheduled]').trigger('click')
await wrapper.findAll('button').find(b => b.text() === '查看 / 继续')!.trigger('click'); await flushPromises()
expect(wrapper.find('[data-save]').exists()).toBe(false)
const dialog = wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!
@@ -179,7 +194,7 @@ describe('appointment calendar data integration', () => {
expect(wrapper.text()).not.toContain('缩小日期范围')
})
it('shows the original pickup time once without claiming a port time zone and retains partial-source warnings', async () => {
vi.mocked(listPickupCalendar).mockResolvedValueOnce({records: [{containerBookingId: '123',bookingNo: 'CB-TEST',containerNo: 'TEST-CONT',destinationPortName: 'Port',destinationWarehouseCode: 'US-W',fleetSupplierName: 'Fleet',pickupAppointmentAt: '2026-10-09T11:00:00',actualPickupAt: null}],truncated: false,unclassifiedCount: 1,scopeBasis: 'US_DESTINATION_WAREHOUSE',timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'})
vi.mocked(listPickupCalendar).mockResolvedValueOnce({records: [{containerBookingId: '123',cabinetNo: 'D0001',containerNo: 'TEST-CONT',destinationPortName: 'Port',destinationWarehouseCode: 'US-W',fleetSupplierName: 'Fleet',pickupAppointmentAt: '2026-10-09T11:00:00',actualPickupAt: null}],truncated: false,unclassifiedCount: 1,scopeBasis: 'US_DESTINATION_WAREHOUSE',timeBasis: 'SOURCE_LOCAL_UNSPECIFIED'})
const wrapper=render(); await flushPromises()
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
expect(panel.props('events')).toEqual([expect.objectContaining({type:'pickup',date:'2026-10-09',time:'11:00',sourcePath:'/container-loading/overseas/123'})])
@@ -196,7 +211,7 @@ describe('appointment calendar data integration', () => {
})
it('keeps successful arrival data visible when the pickup source fails', async () => {
vi.mocked(listPickupCalendar).mockRejectedValueOnce(new Error('pickup unavailable'))
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{containerBookingId:'1',bookingNo:'CB-1',arrivalAt:'2026-10-09T09:00:00',arrivalType:'ESTIMATED'}])
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{containerBookingId:'1',cabinetNo:'D0001',arrivalAt:'2026-10-09T09:00:00',arrivalType:'ESTIMATED'}])
const wrapper=render(); await flushPromises()
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
expect(panel.props('events')).toEqual([expect.objectContaining({type:'arrival',key:'arrival-1'})])
@@ -248,7 +263,7 @@ describe('appointment calendar data integration', () => {
expect(listPlatformWarehouses).toHaveBeenCalledWith(expect.objectContaining({ warehouseType: 'FBA', status: 'ACTIVE', size: 50 }))
expect(wrapper.find('input[placeholder="例如 ONT8"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('查询可预约货物')
expect(wrapper.find('details').attributes('open')).toBeUndefined()
expect(wrapper.findComponent({name:'AppointmentCancellationRecords'}).exists()).toBe(false)
await wrapper.get('select[aria-label="预约仓库"]').setValue('ONT8')
await flushPromises()
expect(listInboundOrders).toHaveBeenLastCalledWith({ page: 1, size: 10, fbaWarehouseCode: 'ONT8' })
@@ -283,6 +298,8 @@ describe('appointment calendar data integration', () => {
const wrapper=render(); await flushPromises()
expect(wrapper.text()).toContain('未定时安排(1)')
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([])
expect(wrapper.findComponent({name:'DataTable'}).exists()).toBe(false)
await wrapper.get('[data-unscheduled]').trigger('click')
expect(wrapper.findComponent({name:'DataTable'}).props('data')[0]).not.toHaveProperty('appointmentAt')
})
@@ -335,7 +352,7 @@ describe('appointment calendar data integration', () => {
it('keeps available arrivals and marks the result incomplete when appointments fail', async () => {
vi.mocked(listFbaAppointmentCalendar).mockRejectedValueOnce(new Error('预约暂不可用'))
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', bookingNo: 'C-1', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }])
vi.mocked(listArrivalCalendar).mockResolvedValueOnce([{ containerBookingId: '1', cabinetNo: 'D0001', arrivalAt: '2026-10-09T09:00:00', arrivalType: 'ESTIMATED' }])
const wrapper = render()
await flushPromises()
const calendar = wrapper.findComponent({ name: 'AppointmentCalendarPanel' })
@@ -31,6 +31,7 @@ import { listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, typ
import { listTripCalendar, projectTripCalendar, tripIssues, type TripCalendarEntry } from './tripCalendar'
const cancellationTarget = ref<FbaAppointmentDto | null>(null)
const unscheduledOpen = ref(false)
const cancellationsOpen = ref(false), cancellationsRevision = ref(0), cancellationHistoryOpen = ref(false), forceReadOnly = ref(false)
const appointmentReadOnly = computed(() => forceReadOnly.value || editing.value?.appointmentStatus === 'CANCELLED' || !access.can(permission.inboundOrderAppointment))
const access = useAccessPreviewStore()
@@ -130,22 +131,22 @@ const columns: DataTableColumn[] = [
const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival => ({
key: `arrival-${arrival.containerBookingId}`, type: 'arrival' as const,
date: arrival.arrivalAt.slice(0, 10), time: arrival.arrivalAt.slice(11, 16),
title: `${arrival.voyageNo || arrival.bookingNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`,
title: `${arrival.voyageNo || arrival.cabinetNo} ${arrival.arrivalType === 'ACTUAL' ? '实际到港' : '预计到港'}`,
fields: [
{ label: '船名 / 航次', value: [arrival.vesselName, arrival.voyageNo].filter(Boolean).join(' / ') },
{ label: '目的港', value: arrival.destinationPortName },
{ label: '已装货柜号', value: arrival.containerNo },
{ label: '柜次号', value: arrival.bookingNo },
{ label: '柜次号', value: arrival.cabinetNo },
{ label: '货物去向', value: arrival.destinationWarehouseCode },
],
})), ...pickups.value.map(pickup => ({
key: `pickup-${pickup.containerBookingId}`, type: 'pickup' as const,
date: pickup.pickupAppointmentAt.slice(0, 10), time: pickup.pickupAppointmentAt.slice(11, 16),
title: `${pickup.containerNo || pickup.bookingNo || '未关联柜号'} 预约提柜`,
title: `${pickup.containerNo || pickup.cabinetNo || '未关联柜号'} 预约提柜`,
sourcePath: `/container-loading/overseas/${encodeURIComponent(pickup.containerBookingId)}`,
subtitle: '美国目的仓相关提柜安排',
fields: [
{ label: '柜次号', value: pickup.bookingNo }, { label: '目的港', value: pickup.destinationPortName },
{ label: '柜次号', value: pickup.cabinetNo }, { label: '目的港', value: pickup.destinationPortName },
{ label: '目的仓', value: pickup.destinationWarehouseCode }, { label: '车队', value: pickup.fleetSupplierName },
{ label: '实际提柜', value: pickup.actualPickupAt || '尚未记录' },
{ label: '时间口径', value: '原提柜业务时间,港口时区待核实' },
@@ -416,13 +417,23 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
<div v-if="calendarVisited" v-show="workView === 'calendar'">
<LoadErrorState v-if="loadError" :title="errorTitle" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<LoadErrorState v-for="error in sourceErrors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
<LoadErrorState v-if="unscheduledError" title="未定时安排加载失败" :message="unscheduledError.message" @retry="loadUnscheduled" />
<details v-if="unscheduledTotal" class="appointment-extra"><summary>未定时安排({{ unscheduledTotal }})</summary><DataTable v-model:page="unscheduledPage" v-model:page-size="unscheduledSize" :data="unscheduled" :columns="unscheduledColumns" :total="unscheduledTotal" row-key="appointmentNo" @update:page="loadUnscheduled" @update:page-size="unscheduledPage = 1; loadUnscheduled()"><template #actions="{ row }"><el-button @click="openEdit({key: row.appointmentNo})">查看 / 继续</el-button></template></DataTable></details>
<details class="appointment-extra" @toggle="cancellationsOpen = ($event.target as HTMLDetailsElement).open"><summary>取消记录</summary><AppointmentCancellationRecords v-if="cancellationsOpen" :key="cancellationsRevision" @view="openEdit({key: $event}, true)" /></details>
<AppointmentCalendarPanel :notices="[pickupNotice, showTripLayer ? tripNotice : ''].filter(Boolean)" :events="events" :loading="loading" :incomplete="sourceErrors.length > 0 || !!pickupNotice || (showTripLayer && !!tripNotice)" :can-create="access.can(permission.inboundOrderAppointment)" @layers="showTripLayer = $event.trips" @range="load" @create="openCreate" @view="openEdit($event, true)" @edit="openEdit" @delete="remove" @open="openSource">
<template #toolbar-actions>
<el-button aria-haspopup="dialog" data-unscheduled @click="unscheduledOpen = true">未定时安排({{ unscheduledError ? '加载失败' : unscheduledTotal }})</el-button>
<el-button aria-haspopup="dialog" data-cancellations @click="cancellationsOpen = true">取消记录</el-button>
</template>
<template #notice-actions><el-button v-if="showTripLayer && unresolvedTrips.length" link type="primary" @click="unresolvedDialogOpen = true">查看待核实车次({{ unresolvedTrips.length }})</el-button></template>
</AppointmentCalendarPanel>
</div>
<AppDialog v-if="unscheduledOpen" v-model="unscheduledOpen" title="未定时安排" width="min(1000px, calc(100vw - 48px))" :show-confirm="false" cancel-text="关闭" class="admin-density-dialog">
<LoadErrorState v-if="unscheduledError" title="未定时安排加载失败" :message="unscheduledError.message" @retry="loadUnscheduled" />
<DataTable v-model:page="unscheduledPage" v-model:page-size="unscheduledSize" :data="unscheduled" :columns="unscheduledColumns" :total="unscheduledTotal" :results-visible="!unscheduledError" row-key="appointmentNo" empty-text="暂无未定时安排" @update:page="loadUnscheduled" @update:page-size="unscheduledPage = 1; loadUnscheduled()">
<template #actions="{ row }"><el-button @click="openEdit({key: row.appointmentNo})">查看 / 继续</el-button></template>
</DataTable>
</AppDialog>
<AppDialog v-if="cancellationsOpen" v-model="cancellationsOpen" title="取消记录" width="min(1160px, calc(100vw - 48px))" :show-confirm="false" cancel-text="关闭" class="admin-density-dialog">
<AppointmentCancellationRecords :key="cancellationsRevision" @view="openEdit({key: $event}, true)" />
</AppDialog>
<AppDialog v-if="unresolvedDialogOpen" v-model="unresolvedDialogOpen" :title="`待核实车次(${unresolvedTrips.length})`" width="min(1000px, calc(100vw - 48px))" :show-confirm="false" class="admin-density-dialog">
<p>这些车次缺少地点或时区,暂未计入日历。原计划时间保留 UTC。</p>
<DataTable v-model:page="unresolvedPage" v-model:page-size="unresolvedSize" :data="unresolvedRows" :columns="unresolvedColumns" :total="unresolvedTrips.length" row-key="tripNo" @update:page-size="unresolvedPage = 1">
@@ -470,7 +481,5 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
.calendar-form { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 var(--admin-space-6); }
.calendar-form :deep(.el-select), .calendar-form :deep(.el-date-editor) { width: 100%; }
.appointment-cargo { padding: 12px 0; line-height: 1.7; overflow-wrap: anywhere; font-size: 13px; }
.appointment-extra { margin-bottom: var(--admin-space-5); }
.appointment-extra summary { cursor: pointer; margin-bottom: var(--admin-space-3); font-size: var(--admin-font-body); }
.appointment-hint { color: var(--el-text-color-secondary); font-size: var(--admin-font-body); margin: var(--admin-space-5) 0; }
</style>
@@ -7,15 +7,38 @@ vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSou
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
function render() {
return mount(AppointmentCargoPanel, { global: { stubs: {
function render(attach = false) {
return mount(AppointmentCargoPanel, { attachTo: attach ? document.body : undefined, global: { stubs: {
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters"/><slot name="notices"/><button data-next @click="$emit(\'update:page\',2)">下一页</button></div>' },
QueryToolbar: { name: 'QueryToolbar', emits: ['search'], template: '<button data-search @click="$emit(\'search\')">查询</button>' },
LoadErrorState: true, ApiErrorMeta: true, RowActions: true,
LoadErrorState: true, ApiErrorMeta: true, RowActions: true, AppointmentExternalCandidates: { name: 'AppointmentExternalCandidates', template: '<section class="integrated-query-group"><slot name="query-tabs"/></section>' },
} } })
}
describe('appointment planning cargo query', () => {
beforeEach(() => { vi.resetAllMocks(); vi.mocked(listPlanningCargo).mockResolvedValue(empty) })
it('switches cargo tabs by click and keyboard without reloading first-leg cargo', async () => {
const wrapper = render(true); await flushPromises()
const tabs = () => wrapper.findAll('[role="tab"]')
expect(tabs().map(tab => tab.text())).toEqual(['头程货物', '外接货物'])
expect(wrapper.find('.integrated-query-group [role="tablist"]').exists()).toBe(true)
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
await tabs()[1]!.trigger('click')
expect(tabs()[1]!.attributes('aria-selected')).toBe('true')
expect(wrapper.findComponent({ name: 'AppointmentExternalCandidates' }).exists()).toBe(true)
await tabs()[1]!.trigger('keydown', { key: 'ArrowRight' })
await flushPromises()
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
expect(document.activeElement).toBe(tabs()[0]!.element)
expect(tabs()[1]!.attributes('tabindex')).toBe('-1')
await tabs()[0]!.trigger('keydown', { key: 'End' })
await flushPromises()
expect(tabs()[1]!.attributes('aria-selected')).toBe('true')
expect(document.activeElement).toBe(tabs()[1]!.element)
await tabs()[1]!.trigger('keydown', { key: 'Home' })
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('uses one server-page request per entry, page change and reset', async () => {
const wrapper = render(); await flushPromises()
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
@@ -13,6 +13,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo, type PlanningSource } from './planningApi'
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
import AppointmentExternalCandidates from './AppointmentExternalCandidates.vue'
import AppointmentCargoTabs from './AppointmentCargoTabs.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const router = useRouter()
@@ -149,11 +150,8 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
</script>
<template>
<div role="group" aria-label="预约货源类型">
<el-button :type="candidateMode === 'firstleg' ? 'primary' : 'default'" @click="candidateMode = 'firstleg'">头程货物</el-button>
<el-button :type="candidateMode === 'external' ? 'primary' : 'default'" @click="candidateMode = 'external'">外接货物</el-button>
</div>
<AppointmentExternalCandidates v-if="candidateMode === 'external'" :key="externalRevision" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :selected-keys="Object.keys(selected)" :busy="batchLoading || creating" @select="addSource" @followup="openFollowup($event.source)">
<template #query-tabs><AppointmentCargoTabs v-model="candidateMode" /></template>
<template #toolbar-actions>
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
@@ -173,7 +171,12 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
</template>
<template #filters><QueryToolbar v-model="query" ariaLabel="在途货物查询" :fields="fields" :loading="loading" @search="load(true)" /></template>
<template #filters>
<section class="integrated-query-group" aria-label="头程货物类型与筛选">
<AppointmentCargoTabs v-if="candidateMode === 'firstleg'" v-model="candidateMode" />
<QueryToolbar v-model="query" ariaLabel="在途货物查询" :fields="fields" :loading="loading" @search="load(true)" />
</section>
</template>
<template #notices>
<div v-if="externalSource" class="cargo-note">
待加入来源:{{ externalSource.bookingNo || externalSource.inboundCode || '尚未关联业务编号' }} · {{ externalSource.originWarehouseName }} → {{ externalSource.destinationWarehouseCode }}
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { nextTick } from 'vue'
const mode = defineModel<'firstleg' | 'external'>({ required: true })
const tabs = [{ key: 'firstleg', label: '头程货物' }, { key: 'external', label: '外接货物' }] as const
async function handleKeydown(event: KeyboardEvent, index: number) {
let next: number
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length
else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length
else if (event.key === 'Home') next = 0
else if (event.key === 'End') next = tabs.length - 1
else return
event.preventDefault()
const key = tabs[next]!.key
mode.value = key
await nextTick()
document.getElementById(`appointment-cargo-tab-${key}`)?.focus()
}
</script>
<template>
<div class="density-status-tabs" role="tablist" aria-label="预约货源类型">
<button v-for="(tab, index) in tabs" :id="`appointment-cargo-tab-${tab.key}`" :key="tab.key" type="button" role="tab"
:aria-selected="mode === tab.key" :tabindex="mode === tab.key ? 0 : -1"
:class="{ 'is-active': mode === tab.key }"
@click="mode = tab.key" @keydown="handleKeydown($event, index)">{{ tab.label }}</button>
</div>
</template>
@@ -44,7 +44,12 @@ onBeforeUnmount(() => { ++request; controller?.abort() })
<template>
<DataTable v-model:page="page" v-model:page-size="size" appearance="integrated" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" @update:page="load()" @update:page-size="load(true)">
<template #toolbar-actions><slot name="toolbar-actions" /></template>
<template #filters><QueryToolbar v-model="query" ariaLabel="外接预约候选查询" :fields="fields" :loading="loading" @search="load(true)" /></template>
<template #filters>
<section class="integrated-query-group" aria-label="外接货物类型与筛选">
<slot name="query-tabs" />
<QueryToolbar v-model="query" ariaLabel="外接预约候选查询" :fields="fields" :loading="loading" @search="load(true)" />
</section>
</template>
<template #notices>
<slot name="notices" />
<LoadErrorState v-if="error" title="外接候选加载失败" :message="error.message" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
@@ -32,9 +32,13 @@ describe('appointment tables use actual shared table slots', () => {
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
await flushPromises()
expect(listExternalAppointmentCandidates).not.toHaveBeenCalled()
expect(wrapper.get('.data-table__filters [role="tablist"]').text()).toContain('头程货物')
await wrapper.findAll('button').find(b => b.text() === '选择本页可合批货物')!.trigger('click')
await wrapper.findAll('button').find(b => b.text() === '外接货物')!.trigger('click'); await flushPromises()
expect(listExternalAppointmentCandidates).toHaveBeenCalledTimes(1)
expect(wrapper.findAll('.data-table__filters [role="tablist"]')).toHaveLength(1)
expect(wrapper.get('.data-table__filters [role="tablist"]').isVisible()).toBe(true)
expect(wrapper.get('.data-table__filters [role="tab"][aria-selected="true"]').text()).toBe('外接货物')
expect(wrapper.text()).toContain('B-1')
await wrapper.findAll('button').find(b => b.text() === '加入合批')!.trigger('click')
await wrapper.findAll('button').find(b => b.text().startsWith('合批核对') && b.isVisible())!.trigger('click'); await flushPromises()
@@ -6,6 +6,9 @@
| 页面/操作 | 原型承载与布局 | 实现约束 |
|---|---|---|
| 补充计划时段 | 用户2026-10-12新增预约截图:开始、结束时段输入框各占整行过长 | 共用 FbaAppointmentFields 的补充区域沿用三列网格,计划开始、结束时段同排各占一列,与主字段对齐;夏令时说明整行、两个时差输入同排。日历新增/编辑、合批安排、入仓单预约同步生效;字段语义及保存不变,待人工截图确认 |
| 预约补充记录入口 | 用户2026-10-12截图1:原顶部未定时安排、取消记录两行默认折叠文字要求优化 | 用户2026-10-12后续确认覆盖页内展开方案:入口并入日历工具栏,与数据提示同组;未定时安排1000px、取消记录1160px居中弹框,保留独立分页及错误重试,取消记录仅打开时加载。新增预约紧邻日历标题,月/周及日期操作连续排列 |
| 送仓与排车货源页签 | 用户2026-10-12截图2、3:参考尾程订单下划线页签 | 头程货物、外接货物通过同一局部页签组件复用 density-status-tabs,两种模式均放入表格 filters 的 integrated-query-group 内并紧邻搜索;点击及方向键/Home/End切换,保留跨货源选择;新增、编辑、取消预约沿用原承载方式。待人工1920×1080、100%截图确认 |
| 预约列表 | 左侧月/周日历,右侧选中日期详情 | 保留双栏日历;到港蓝色、送仓绿色,区分预计/实际到港 |
| 新增预约 | 居中表单,按用户简化要求使用1080px三列 | 仓库远程搜索并自动加载货物;主区保留计划日期、登记阶段及可选备注。系统单号自动生成,不要求外部约号和预计装车时间;计划时段收至补充信息。选择“已取得对方正式确认”才展开外部号和确认时段,并显示地点时区 |
| 编辑预约 | 复用居中表单 | 与入仓单共用记录;版本校验;真实货物归属不允许手填更改 |
+2 -2
View File
@@ -2,7 +2,7 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export interface ArrivalCalendarEntry {
containerBookingId: string
bookingNo: string
cabinetNo: string
containerNo?: string | null
vesselName?: string | null
voyageNo?: string | null
@@ -14,7 +14,7 @@ export interface ArrivalCalendarEntry {
export interface PickupCalendarEntry {
containerBookingId: string
bookingNo: string
cabinetNo: string
containerNo: string | null
destinationPortName: string | null
destinationWarehouseCode: string | null
@@ -182,7 +182,7 @@ describe('platform container loading API', () => {
it('queries workbench containers with the same global filters and excludes the retired booking parameter', async () => {
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: {
records: [{ bookingId: 9007199254740993n, bookingNo: 'BK-1', cabinetNo: 'CAB-1', containerNo: null, warehouseId: 8, warehouseName: '义乌仓', channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡', containerType: '40HQ', planId: 9007199254740994n, planNo: 'LP-1', planStatus: 'EDITING', version: '2' }],
records: [{ bookingId: 9007199254740993n, cabinetNo: 'CAB-1', containerNo: null, warehouseId: 8, warehouseName: '义乌仓', channelCode: 'MATSON_CLX_TRUCK', channelName: '美森正班海卡', containerType: '40HQ', planId: 9007199254740994n, planNo: 'LP-1', planStatus: 'EDITING', version: '2' }],
page: '1', size: '200', total: '1', pages: '1',
} })
@@ -8,7 +8,7 @@ import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: vi.fn() }))
const overview: ContainerOverview = {
container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', pickupCode: '00123456', pickupCodeUpdatedBy: '13', pickupCodeUpdatedByName: '操作员', pickupCodeUpdatedAt: '2026-08-13T10:30:00', version: 2 },
container: { id: '7', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', pickupCode: '00123456', pickupCodeUpdatedBy: '13', pickupCodeUpdatedByName: '操作员', pickupCodeUpdatedAt: '2026-08-13T10:30:00', version: 2 },
loadingPlanId: '9',
pda: { taskId: '10', loadingPlanId: '9', containerBookingId: '7', taskStatus: 'IN_PROGRESS', workflowStatus: 'AWAITING_REVIEW', taskVersion: 2, itemCount: 4, completedItemCount: 4, unresolvedItemCount: 0, photoCounts: [{ evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', count: 2 }], photos: [{ evidenceId: '20', tenantId: '21', inboundId: '22', evidenceType: 'PHOTO_STEP_EMPTY_CONTAINER', fileId: '23', fileName: 'empty.jpg', status: 'ACTIVE', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download', uploadedAt: '2026-08-13T12:30:00' }], exceptionReasons: ['一项待复核'] },
documents: [{ id: '11', documentType: 'DELIVERY_ORDER', fileId: '9007199254740999', fileName: 'do.pdf', fileHash: null, uploadedBy: '13', uploadedByName: '操作员', uploadedAt: '2026-08-13T10:00:00', version: 1, completed: false }],
@@ -10,7 +10,7 @@ vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.
vi.mock('../api/platformContainerLoading', () => ({ linkContainerOceanBooking: vi.fn() }))
const container = {
id: '9001', cabinetNo: 'CAB-1', bookingNo: 'CB-1', containerNo: null, planId: '7001', planStatus: 'EDITING',
id: '9001', cabinetNo: 'CAB-1', containerNo: null, planId: '7001', planStatus: 'EDITING',
sourceType: 'SIMULATION', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null,
oceanBookingId: null, billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ',
shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: null,
@@ -75,9 +75,9 @@ describe('ContainersView', () => {
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
vi.mocked(updateContainerExpectedArrival).mockReset().mockResolvedValue({ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T16:00:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 1 })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
})
it('loads the real paged contract and renders core container fields', async () => {
@@ -296,7 +296,7 @@ describe('ContainersView', () => {
it('uses stable view labels when cabinetNo is missing', async () => {
const base = {
id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0,
id: '9001', planId: null, cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', containerType: '40HQ', planStatus: 'WAIT_LOADING' as const, version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500,
@@ -305,9 +305,9 @@ describe('ContainersView', () => {
}
vi.mocked(listContainers).mockResolvedValueOnce({
records: [
{ ...base, id: '9002', cabinetNo: undefined, containerNo: 'MSCU-2', bookingNo: 'BK-2' },
{ ...base, id: '9003', cabinetNo: '', containerNo: null, bookingNo: 'BK-3' },
{ ...base, id: '9004', cabinetNo: null, containerNo: null, bookingNo: '' },
{ ...base, id: '9002', cabinetNo: undefined, containerNo: 'MSCU-2' },
{ ...base, id: '9003', cabinetNo: '', containerNo: null },
{ ...base, id: '9004', cabinetNo: null, containerNo: null },
],
page: 1, size: 20, total: 3, pages: 1,
} as never)
@@ -319,13 +319,13 @@ describe('ContainersView', () => {
const labels = wrapper.findAll('.el-table__body button')
.map((button) => button.attributes('aria-label'))
.filter((label): label is string => Boolean(label))
expect(labels).toEqual(expect.arrayContaining(['查看 MSCU-2', '查看 BK-3', '查看货柜详情']))
expect(labels).toEqual(['查看 MSCU-2', '查看货柜详情', '查看货柜详情'])
expect(labels.every((label) => !/undefined|null/.test(label))).toBe(true)
})
it('routes each plan status to its owning workflow without reopening pushed drafts', async () => {
const base = {
id: '9001', planId: '9101', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', version: 0,
id: '9001', planId: '9101', cabinetNo: 'CB-1', containerNo: 'MSCU-1', warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', version: 0,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, maxWeightKg: 19500,
@@ -632,7 +632,7 @@ describe('ContainersView', () => {
it('filters OB-260825-0183 for CAB-260825-0215 because the real warehouses differ', async () => {
vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '215', planId: null, bookingNo: 'CB-260825-0215', cabinetNo: 'CAB-260825-0215', containerNo: null,
id: '215', planId: null, cabinetNo: 'CAB-260825-0215', containerNo: null,
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'EDITING',
capacityCbm: 86, maxWeightKg: 29000, version: 7,
@@ -679,7 +679,7 @@ describe('ContainersView', () => {
it('keeps a three-field match selectable when only capacity snapshots differ and blocks duplicate waves', async () => {
let resolveCandidates!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
vi.mocked(listContainers).mockResolvedValue({ records: [{
id: '315', planId: null, bookingNo: 'CB-CAPACITY-SNAPSHOT', cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null,
id: '315', planId: null, cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null,
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
channelGroupCode: 'EXX', channelCode: null, channelName: null, containerType: '45HQ', planStatus: 'WAIT_LOADING',
capacityCbm: 86, maxWeightKg: 29000, version: 7,
@@ -313,7 +313,7 @@ function localDateTimeValue() {
return now.toISOString().slice(0, 19)
}
function containerViewLabel(row: ContainerBookingResource) {
const reference = [row.cabinetNo, row.containerNo, row.bookingNo]
const reference = [row.cabinetNo, row.containerNo]
.find((value) => typeof value === 'string' && value.trim())
return reference ? `查看 ${reference}` : '查看货柜详情'
}
@@ -64,7 +64,7 @@ vi.mock('@/modules/warehouses', () => ({
vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups: vi.fn().mockReturnValue(new Map()) }))
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
id: '9007199254740993', planId: '9007199254740994', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: '31', shippingCompanyName: '美森轮船',
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
@@ -101,7 +101,7 @@ function candidatePage(records: unknown[], page = 1, size = 20, total = records.
function containerPage(records: ContainerBookingResource[] = [container]) {
return {
records: records.map((item) => ({
bookingId: item.id, bookingNo: item.bookingNo, cabinetNo: item.cabinetNo, containerNo: item.containerNo,
bookingId: item.id, cabinetNo: item.cabinetNo, containerNo: item.containerNo,
warehouseId: item.warehouseId, warehouseName: item.warehouseName || '', channelGroupCode: item.channelGroupCode,
channelCode: item.channelCode, channelName: item.channelName,
containerType: item.containerType, capacityCbm: item.capacityCbm, maxWeightKg: item.maxWeightKg,
@@ -18,7 +18,7 @@ vi.mock('../smartLoadingPlanGroups', () => ({ loadSmartLoadingPlanCoLoadGroups:
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const container = {
id: '9007199254740993', planId: '9007199254740994', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
id: '9007199254740993', planId: '9007199254740994', cabinetNo: 'CB-1', containerNo: 'MSCU-1',
warehouseId: '8', warehouseName: '义乌仓', channelGroupCode: 'MATSON_CLX', channelCode: null, channelName: null, containerType: '40HQ', planStatus: 'EDITING' as const, version: 1,
billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null,
departurePortCode: null, departurePortName: null, destinationPortCode: null, destinationPortName: null,
+1 -3
View File
@@ -4,7 +4,6 @@ export interface ContainerBookingResource {
id: string
planId: string | null
planNo?: string | null
bookingNo: string
containerNo: string | null
cabinetNo: string
sourceType?: 'SIMULATION' | 'OCEAN_BOOKING'
@@ -175,7 +174,7 @@ export interface ContainerTimelineEntry {
}
export interface ContainerOverview {
container: Pick<ContainerBookingResource, 'id' | 'bookingNo' | 'cabinetNo' | 'containerNo' | 'warehouseId' | 'warehouseName' | 'voyageNo' | 'vesselName' | 'pickupCode' | 'pickupCodeUpdatedBy' | 'pickupCodeUpdatedByName' | 'pickupCodeUpdatedAt' | 'version'> & { status: string }
container: Pick<ContainerBookingResource, 'id' | 'cabinetNo' | 'containerNo' | 'warehouseId' | 'warehouseName' | 'voyageNo' | 'vesselName' | 'pickupCode' | 'pickupCodeUpdatedBy' | 'pickupCodeUpdatedByName' | 'pickupCodeUpdatedAt' | 'version'> & { status: string }
loadingPlanId: string | null
pda: ContainerPdaSummary | null
documents: ContainerOverviewDocument[]
@@ -373,7 +372,6 @@ export interface SmartLoadingCandidatePage {
export interface SmartLoadingWorkbenchContainer {
bookingId: string
bookingNo: string
cabinetNo: string
containerNo: string | null
warehouseId: string
@@ -21,7 +21,7 @@ vi.mock('../api/platformOutboundDispatch', () => ({ listOutboundDispatches: vi.f
const customer = { id: '10001', tenantCode: 'CUS-1', tenantName: '示例客户', legalName: '示例客户有限公司', status: 'ACTIVE' } as PlatformTenantResource
const order = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户' } as UnifiedOrderResource
const secondOrder = { ...order, orderId: '20002', orderNo: 'ORD-002', inboundId: '30002', inboundCode: 'IN-002' } as UnifiedOrderResource
const container = { id: '40001', bookingNo: 'BK-001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001' } as ContainerBookingResource
const container = { id: '40001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001' } as ContainerBookingResource
const row = { orderId: '20001', orderNo: 'ORD-001', inboundId: '30001', inboundCode: 'IN-001', customerId: '10001', customerName: '示例客户', containerBookingId: '40001', cabinetNo: 'CAB-001', containerNo: 'MSCU-001', oceanBookingStatus: 'RELEASED', truckAppointmentStatus: 'COMPLETED', pickTaskStatus: 'AWAITING_REVIEW', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, gate: { passed: true, items: [], checkedAt: '2026-08-11T10:00:00' } } as OutboundDispatchResource
enableAutoUnmount(afterEach)
@@ -41,7 +41,7 @@ let disposed = false
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', options: customers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'orderId', type: 'select', label: '入仓码', width: 280, placeholder: filters.value.customerId ? '请选择该客户的入仓码' : '请选择入仓码', options: orders.value.map((item) => ({ label: item.inboundCode || '入仓码暂缺', value: String(item.orderId), disabled: item.inboundId == null })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'containerBookingId', type: 'select', label: '柜次', width: 240, placeholder: '请选择柜号或集装箱号', options: containers.value.map((item) => ({ label: `${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`, value: item.id })), props: { filterable: true, loading: optionsLoading.value } },
{ key: 'containerBookingId', type: 'select', label: '柜次', width: 240, placeholder: '请选择柜号或集装箱号', options: containers.value.map((item) => ({ label: `${item.cabinetNo || '—'}${item.containerNo ? ` / ${item.containerNo}` : ''}`, value: item.id })), props: { filterable: true, loading: optionsLoading.value } },
])
const columns = [
@@ -0,0 +1,15 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { downloadCargoDiscardPhoto, getCargoDiscard } from './cargoDiscards'
vi.mock('@/shared/api/http', async importOriginal => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn() } }))
beforeEach(() => vi.mocked(http.get).mockReset())
it('reads submitted photos from detail and downloads a blob in the explicit customer scope', async () => {
const photos = [{ fileId: '101', palletNo: 'PALLET-A', fileName: 'discard.png', createdAt: '2026-10-14T10:00:00', submittedAt: '2026-10-14T10:01:00' }]
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', data: { summary: { discardNo: 'CD/1', customerId: '7' }, photos, items: [], histories: [] } })
expect((await getCargoDiscard('CD/1', '7')).photos).toEqual(photos)
const blob = new Blob(['image'], { type: 'image/png' })
vi.mocked(http.get).mockResolvedValueOnce(blob)
expect(await downloadCargoDiscardPhoto('CD/1', '101', '7')).toBe(blob)
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/cargo-discards/CD%2F1/photos/files/101/download', { params: { customerId: '7' }, responseType: 'blob' })
})
@@ -4,7 +4,8 @@ import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrder
export interface CargoDiscardQuery { page: number; size: number; customerId?: string; discardNo?: string; inboundCode?: string; overseasWarehouseId?: string; status?: string }
export interface CargoDiscardSummary { id: string; customerId?: string | null; discardNo: string; inboundCodes?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; totalPackages?: number | null; totalFee?: number | string | null; currency?: string | null; status?: string | null; createdBy?: string | null; createdAt?: string | null; reviewedAt?: string | null }
export interface CargoDiscardItem { id: string; orderId?: string | null; inboundCode?: string | null; skuCode?: string | null; productName?: string | null; availablePackagesSnapshot?: number | null; discardPackages?: number | null }
export interface CargoDiscardDetail { summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] }
export interface CargoDiscardPhoto { fileId: string; palletNo: string; fileName: string; createdAt: string; submittedAt: string }
export interface CargoDiscardDetail { photos: CargoDiscardPhoto[]; summary: CargoDiscardSummary; feePerPackage?: number | string | null; remark?: string | null; reviewedBy?: string | null; reviewRemark?: string | null; items: CargoDiscardItem[]; histories: { fromStatus?: string | null; toStatus?: string | null; operatorId?: string | null; remark?: string | null; createdAt?: string | null }[] }
export interface CargoDiscardCandidate { orderId?: string | null; sourceType: string; returnCartonId?: string | null; cartonMark?: string | null; inboundCode?: string | null; inboundOrderNo?: string | null; overseasWarehouseId?: string | null; overseasWarehouseName?: string | null; availablePackages?: number; skus: { skuCode: string; productName?: string | null; availablePackages?: number }[] }
export async function listCargoDiscards(query: CargoDiscardQuery) {
@@ -24,3 +25,7 @@ export async function completeCargoDiscard(discardNo: string, customerId?: strin
return unwrapApiResponse(response)
}
export function downloadCargoDiscardPhoto(discardNo: string, fileId: string, customerId: string) {
return http.get<Blob, Blob>(`/platform/v1/last-leg/cargo-discards/${encodeURIComponent(discardNo)}/photos/files/${encodeURIComponent(fileId)}/download`, { params: { customerId }, responseType: 'blob' })
}
@@ -99,7 +99,7 @@ export interface LastMileOrderSummaryDto {
deliveryOrderNo?: string | null
outboundOrderNo?: string | null
relabelOrderNo?: string | null
relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; createdAt: string }>
relabelApplications?: Array<{ relabelNo: string; status: string; quantity: number; quantityUnit?: string; createdAt: string }>
relabelStatus?: string | null
palletMergeFlag?: string | null
/** Optional grouping facts returned by newer last-leg services. */
@@ -358,8 +358,10 @@ export interface LastMileOrderDetailDto {
returnReceipt?: {
order: { returnNo: string; status?: string; receivedQuantity: number; expectedQuantity: number }
items?: Array<{ skuCode: string; productNameZh?: string; expectedQuantity: number; receivedQuantity: number }>
receipts: Array<{ receiptNo: string; unitType: string; unitCode?: string; palletCode?: string; receivedQuantity: number; boxCount: number; signedAt: string }>
cartons?: Array<{ receiptNo: string; palletCode?: string; cartonMark: string; countStatus: string; countedAt?: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number }> }>
receipts: Array<{ receiptNo: string; unitType: string; unitCode?: string; palletCode?: string; receivedQuantity: number; boxCount: number; signedAt: string; length?: number | null; width?: number | null; height?: number | null; weight?: number | null }>
cartons?: Array<{ receiptNo: string; palletCode?: string; cartonMark: string; countStatus: string; countedAt?: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number }>; length?: number | null; width?: number | null; height?: number | null; weight?: number | null }>
packingDetails?: import('./returnInventory').ReturnCartonMergeHistory[]
currentCartons?: Array<{ carton: import('./returnInventory').ReturnCartonDetail; ownership: Array<{ returnNo: string; skuCode: string; quantity: number }>; orderQuantity: number; labelStatus: string; canRelabel: boolean; blockReason?: string }>
} | null
order: LastMileOrderDto
manualNodes?: ManualNodesResponse['nodes']
@@ -52,7 +52,7 @@ export async function listReturnOutboundCandidates(customerId: string, warehouse
const response = await http.get<ApiEnvelope<ReturnOutboundCandidate[]>, ApiEnvelope<ReturnOutboundCandidate[]>>('/platform/v1/last-leg/outbound-orders/return/candidates', { params: { customerId, warehouseId } })
return unwrapApiResponse(response)
}
export async function createReturnOutbound(customerId: string, payload: { recipient: string; overseasWarehouseId: string; returnNo: string; cartonMarks: string[]; skuCode?: string; quantity?: number; items?: Array<{ cartonMark: string; skuCode: string; quantity: number }>; unitType?: 'BOX' | 'PALLET'; remark?: string; idempotencyKey: string }) {
export async function createReturnOutbound(customerId: string, payload: { recipient: string; overseasWarehouseId: string; returnNo: string; cartonMarks: string[]; unitType?: 'BOX' | 'PALLET'; remark?: string; idempotencyKey: string }) {
const response = await http.post<ApiEnvelope<OutboundRow>, ApiEnvelope<OutboundRow>>('/platform/v1/last-leg/outbound-orders/return', payload, { params: { customerId } })
return unwrapApiResponse(response)
}
@@ -66,6 +66,8 @@ export interface RelabelSummaryDto {
totalPackageCount?: number | null
notifiedPackageCount?: number | null
skuCodes?: string | null
productNameZh?: string | null
labelRequirement?: string | null
totalFileId?: string | null
totalFileName?: string | null
totalFileUrl?: string | null
@@ -87,6 +89,8 @@ export interface RelabelSummaryDto {
export interface RelabelItemDto {
id: string
operationType?: 'CARTON_LABEL' | 'SKU_LABEL' | 'CARTON_AND_SKU'
skuItems?: Array<{ skuCode: string; newSkuCode: string; quantity: number; remark?: string }>
skuCode?: string | null
productNameZh?: string | null
totalPackageCount?: number | null
@@ -142,6 +146,7 @@ export interface ReturnPackingPlanPayload {
remark?: string
idempotencyKey: string
sourceCartonMarks?: string[]
sourceDeductions?: Array<{ cartonMark: string; skuCode: string; quantity: number }>
}
export interface ReturnPackingPlanDto extends Omit<ReturnPackingPlanPayload, 'remark'> {
@@ -155,6 +160,10 @@ export interface ReturnPackingPlanDto extends Omit<ReturnPackingPlanPayload, 're
pushedAt?: string | null
completedAt?: string | null
resultCartons?: import('./returnInventory').ReturnCartonDetail[]
sources?: Array<{ cartonMark: string; sourceReturnNo?: string; participation: 'FULL' | 'PARTIAL' | 'UNKNOWN'; items: ReturnPackingPlanBoxItem[]; originalItems: Array<{ skuCode: string; productNameZh?: string; quantity: number }> }>
completionCartons?: import('./returnInventory').ReturnCartonLabel[]
transfers?: import('./returnInventory').ReturnCartonMergeHistory[]
completedBy?: string
}
export async function createReturnPackingPlan(payload: ReturnPackingPlanPayload) {
@@ -2,8 +2,8 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; looseAvailableQuantity?: number; cartonQuantity: number; updatedAt: string }
export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string }
export interface ReturnStockRow { customerId: string; overseasWarehouseId: string; skuCode: string; productNameZh?: string; totalQuantity: number; availableQuantity: number; relabelLockedQuantity: number; looseAvailableQuantity?: number; cartonQuantity: number; updatedAt: string }
export interface ReturnCartonRow { customerId: string; overseasWarehouseId: string; cartonMark: string; sourceReturnNo?: string; sourceUnitType?: 'PALLET' | 'BOX' | 'PARCEL' | string; sourceBatchNo?: string; status: string; fbaCartonMark?: string; destinationWarehouseCode?: string; destinationWarehouseName?: string; relabelNo?: string; relabelStatus?: string; relabelledQuantity?: number; relabelReservedQuantity?: number; skuTypes: number; totalQuantity: number; length?: number; width?: number; height?: number; weight?: number; createdAt: string }
export interface ReturnCartonMergeHistory { planNo?: string; sourceCartonMark: string; targetCartonMark: string; returnNo: string; skuCode: string; quantity: number; createdAt: string }
export interface ReturnCartonDetail extends ReturnCartonRow { customerId: string; items: Array<{ skuCode: string; productNameZh?: string; quantity: number; availableQuantity?: number }>; mergeHistory?: ReturnCartonMergeHistory[] }
export interface ReturnCartonLabel extends ReturnCartonDetail { printableHtml: string }
@@ -14,17 +14,21 @@ defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string
</template>
<el-form-item :label="model.appointmentStatus === 'FAILED' ? '原因' : '备注'" :required="model.appointmentStatus === 'FAILED'"><el-input v-model="model.appointmentRemark" maxlength="1000" placeholder="有需交接的事项时填写" /></el-form-item>
<details class="appointment-fields__extra"><summary>补充计划时段与夏令时信息</summary>
<div class="appointment-fields__extra-grid">
<el-form-item label="计划开始时段"><el-date-picker v-model="model.plannedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="只知道日期时不必填写" /></el-form-item>
<el-form-item label="计划结束时段"><el-date-picker v-model="model.plannedEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
<template v-if="model.appointmentStatus === 'SUCCESS'"><p>当地时钟回拨、同一时间出现两次时,需按对方确认明确时差。</p>
<el-form-item label="确认开始时差"><el-input v-model="model.startOffset" placeholder="仅重叠时刻需填,如 -07:00" /></el-form-item>
<el-form-item label="确认结束时差"><el-input v-model="model.endOffset" placeholder="仅重叠时刻需填,如 -08:00" /></el-form-item>
</template>
</div>
</details>
</el-form>
</template>
<style scoped>
.appointment-fields { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 var(--admin-space-6); }
.appointment-fields, .appointment-fields__extra-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:0 var(--admin-space-6); }
.appointment-fields :deep(.el-select), .appointment-fields :deep(.el-date-editor) { width:100%; }
.appointment-fields__extra { grid-column:1 / -1; }
.appointment-fields__extra summary { cursor:pointer; margin-bottom:var(--admin-space-3); }
.appointment-fields__extra-grid > p { grid-column:1 / -1; }
</style>
@@ -110,6 +110,19 @@ it('does not submit a physical pallet fallback when its actual plan source ident
wrapper.unmount()
})
it('submits relabel physical pallet identity without requiring an inbound plan pallet', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'relabel-draft', containers: [
{ id: 'container', capacity: 30, palletIds: ['101'] },
] }])
vi.mocked(listDispatchCandidates).mockResolvedValue([{ ...plan, planPalletId: null, sourceStage: 'RELABEL', orderRefs: 'RL-1' }])
vi.mocked(preArrangeDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-1' })
const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['101'], returnReceiptIds: [], relabelNo: 'RL-1' })
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
expect(wrapper.emitted('created')).toHaveLength(1)
wrapper.unmount()
})
it('does not send a second batch while the original command is pending', async () => {
vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
@@ -330,7 +330,8 @@ async function submit() {
ElMessage.warning('草稿中存在重复托盘或无效装车容器,请核对分组;原草稿已保留')
return
}
if (assigned.value.some(item => item.sourceType === 'RETURN_RECEIPT'
if (assigned.value.some(item => item.sourceStage === 'RELABEL'
? !String(item.warehousePalletId || '').trim() : item.sourceType === 'RETURN_RECEIPT'
? !String(item.returnReceiptId || '').trim() : !String(item.planPalletId || '').trim())) {
ElMessage.warning('所选托盘来源标识缺失,请重新加载核对;原草稿已保留')
return
@@ -1,6 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
@@ -9,24 +9,42 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import StatusBadge from '@/shared/components/StatusBadge.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { getApiErrorDetails } from '@/shared/api/http'
import { listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
import { downloadCargoDiscardPhoto, listCargoDiscards, getCargoDiscard, reviewCargoDiscard, type CargoDiscardDetail, type CargoDiscardSummary } from '../api/cargoDiscards'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { overseasWarehousePermissions as permission } from '../permissions'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
const access = useAccessPreviewStore(); const rows = ref<CargoDiscardSummary[]>([]); const customers = ref<PlatformTenantResource[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const detailOpen = ref(false); const detail = ref<CargoDiscardDetail | null>(null); const filters = ref<QueryModel>({ customerId: '', discardNo: '', inboundCode: '', status: '' })
const fields: QueryField[] = [{ key: 'customerId', type: 'select', label: '客户', placeholder: '请选择客户', options: [] }, { key: 'discardNo', type: 'input', label: '废弃单号', placeholder: '废弃单号', width: 220 }, { key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码', width: 180 }, { key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [{ label: '待审核', value: 'PENDING_REVIEW' }, { label: '已审核', value: 'APPROVED' }, { label: 'PDA处理中', value: 'PDA_COMPLETED' }, { label: '已完成', value: 'COMPLETED' }, { label: '已驳回', value: 'REJECTED' }, { label: '已取消', value: 'CANCELLED' }] }]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'discardNo', label: '废弃单号', minWidth: 180 }, { key: 'inboundCodes', label: '入仓码', minWidth: 180 }, { key: 'overseasWarehouseName', label: '仓库', minWidth: 140 }, { key: 'totalPackages', label: '总箱数', width: 90 }, { key: 'totalFee', label: '废弃费用', width: 120, formatter: (row: CargoDiscardSummary) => `${row.currency || 'USD'} ${row.totalFee ?? 0}` }, { key: 'createdBy', label: '申请人', width: 110 }, { key: 'createdAt', label: '创建时间', minWidth: 170 }, { key: 'status', label: '状态', width: 150, slotName: 'status' }]
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待客户确认', COMPLETED: '客户已确认', REJECTED: '已驳回', CANCELLED: '已取消' }
const statusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', APPROVED: '已审核,待 PDA', PDA_COMPLETED: 'PDA 已完成,待仓库确认', COMPLETED: '已完成', REJECTED: '已驳回', CANCELLED: '已取消' }
const photoUrls = reactive<Record<string, string>>({}); const photoLoading = reactive<Record<string, boolean>>({}); const photoErrors = reactive<Record<string, boolean>>({})
const photoPreviewUrls = computed(() => (detail.value?.photos || []).map(photo => photoUrls[photo.fileId]).filter(Boolean))
let photoGeneration = 0
function clearPhotos() { photoGeneration++; Object.values(photoUrls).forEach(URL.revokeObjectURL); for (const map of [photoUrls, photoLoading, photoErrors]) Object.keys(map).forEach(key => delete map[key]) }
async function loadPhoto(fileId: string) {
const current = detail.value; const generation = photoGeneration
if (!current || photoLoading[fileId]) return
photoLoading[fileId] = true; photoErrors[fileId] = false
try { const blob = await downloadCargoDiscardPhoto(current.summary.discardNo, fileId, String(current.summary.customerId)); if (photoGeneration !== generation || detail.value !== current || !detailOpen.value) return; if (photoUrls[fileId]) URL.revokeObjectURL(photoUrls[fileId]); photoUrls[fileId] = URL.createObjectURL(blob) }
catch { if (generation === photoGeneration) photoErrors[fileId] = true }
finally { if (generation === photoGeneration) photoLoading[fileId] = false }
}
watch(detailOpen, value => { if (!value) clearPhotos() })
onBeforeUnmount(clearPhotos)
function text(key: string) { return String(filters.value[key] || '').trim() || undefined }
async function load(model: QueryModel = filters.value) { Object.assign(filters.value, model); loading.value = true; try { const result = await listCargoDiscards({ page: page.value, size: pageSize.value, customerId: text('customerId'), discardNo: text('discardNo'), inboundCode: text('inboundCode'), status: text('status') }); rows.value = result.records; total.value = result.total } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单列表加载失败').message) } finally { loading.value = false } }
async function openDetail(row: CargoDiscardSummary) { try { detail.value = await getCargoDiscard(row.discardNo, row.customerId || undefined); detailOpen.value = true } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单详情加载失败').message) } }
async function openDetail(row: CargoDiscardSummary) { try { detail.value = await getCargoDiscard(row.discardNo, row.customerId || undefined); detailOpen.value = true; clearPhotos(); void Promise.all(detail.value.photos.map(photo => loadPhoto(photo.fileId))) } catch (e) { ElMessage.error(getApiErrorDetails(e, '废弃单详情加载失败').message) } }
async function audit(row: CargoDiscardSummary) { try { await ElMessageBox.confirm('审核通过后将推送 PDA 执行废弃,确认继续?', '审核确认', { confirmButtonText: '通过并推送 PDA', cancelButtonText: '取消' }); await reviewCargoDiscard(row.discardNo, 'APPROVE', undefined, row.customerId || undefined); ElMessage.success('已审核通过并推送 PDA'); await load() } catch (e) { if (e !== 'cancel') ElMessage.error(getApiErrorDetails(e, '审核失败').message) } }
onMounted(async () => { try { const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }); customers.value = result.records; const field = fields[0]; if (field.type === 'select') field.options = [{ label: '全部客户', value: '' }, ...customers.value.map(item => ({ label: item.tenantName, value: String(item.id) }))]; await load() } catch (e) { ElMessage.error(getApiErrorDetails(e, '客户列表加载失败').message) } })
</script>
<template>
<AdminPage class="admin-density"><ListPageLayout aria-label="废弃单列表"><section><DataTable appearance="integrated" :stripe="false" :data="rows" :columns="columns" :loading="loading" :total="total" v-model:page="page" v-model:page-size="pageSize" preference-key="overseas-cargo-discards" row-key="id" empty-text="暂无废弃单" @update:page="load()" @update:page-size="page = 1; load()"><template #toolbar-start><h1>废弃单</h1></template><template #filters><QueryToolbar ariaLabel="废弃单筛选" v-model="filters" :fields="fields" :loading="loading" preference-key="overseas-cargo-discards-query" @search="page = 1; load($event)" @reset="page = 1; load($event)" /></template><template #actions="{ row }"><el-button size="small" @click="openDetail(row as CargoDiscardSummary)">详情</el-button><el-button v-if="access.can(permission.discardApprove) && (row as CargoDiscardSummary).status === 'PENDING_REVIEW'" size="small" type="primary" @click="audit(row as CargoDiscardSummary)">审核并推送 PDA</el-button></template><template #status="{ row }"><StatusBadge :status="statusLabels[String((row as CargoDiscardSummary).status)] || String((row as CargoDiscardSummary).status)" /></template><template #pagination-left>共 {{ total }} 条废弃单</template></DataTable></section></ListPageLayout></AdminPage>
<AppDialog v-model="detailOpen" title="废弃单详情" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))"><template v-if="detail"><el-descriptions :column="3" border><el-descriptions-item label="废弃单号">{{ detail.summary.discardNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[String(detail.summary.status)] }}</el-descriptions-item><el-descriptions-item label="仓库">{{ detail.summary.overseasWarehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="入仓码" :span="2">{{ detail.summary.inboundCodes || '—' }}</el-descriptions-item><el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item></el-descriptions><h3>废弃明细</h3><el-table :data="detail.items" border><el-table-column prop="inboundCode" label="入仓码"/><el-table-column prop="skuCode" label="SKU"/><el-table-column prop="productName" label="品名"/><el-table-column prop="discardPackages" label="申请箱数" width="100"/></el-table><h3>状态流转</h3><el-timeline><el-timeline-item v-for="(item, index) in detail.histories" :key="index" :timestamp="item.createdAt || ''">{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}</el-timeline-item></el-timeline></template></AppDialog>
<AppDialog v-model="detailOpen" title="废弃单详情" :show-confirm="false" cancel-text="关闭" width="min(920px, calc(100vw - 28px))"><template v-if="detail"><el-descriptions :column="3" border><el-descriptions-item label="废弃单号">{{ detail.summary.discardNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabels[String(detail.summary.status)] }}</el-descriptions-item><el-descriptions-item label="仓库">{{ detail.summary.overseasWarehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="入仓码" :span="2">{{ detail.summary.inboundCodes || '—' }}</el-descriptions-item><el-descriptions-item label="备注">{{ detail.remark || '—' }}</el-descriptions-item></el-descriptions><h3>废弃明细</h3><el-table :data="detail.items" border><el-table-column prop="inboundCode" label="入仓码"/><el-table-column prop="skuCode" label="SKU"/><el-table-column prop="productName" label="品名"/><el-table-column prop="discardPackages" label="申请箱数" width="100"/></el-table><h3>废弃现场照片</h3><div v-if="detail.photos.length" class="discard-photo-grid"><div v-for="photo in detail.photos" :key="photo.fileId" class="discard-photo-item"><el-image v-if="photoUrls[photo.fileId]" :src="photoUrls[photo.fileId]" :preview-src-list="photoPreviewUrls" :initial-index="photoPreviewUrls.indexOf(photoUrls[photo.fileId])" fit="cover" preview-teleported /><span v-else-if="photoLoading[photo.fileId]">照片加载中…</span><el-button v-else @click="loadPhoto(photo.fileId)">{{ photoErrors[photo.fileId] ? '加载失败,点击重试' : '加载照片' }}</el-button><small>托盘:{{ photo.palletNo }} · {{ photo.submittedAt }}</small></div></div><p v-else>暂无已提交的废弃现场照片</p><h3>状态流转</h3><el-timeline><el-timeline-item v-for="(item, index) in detail.histories" :key="index" :timestamp="item.createdAt || ''">{{ statusLabels[item.toStatus || ''] || item.toStatus }} {{ item.remark ? `— ${item.remark}` : '' }}</el-timeline-item></el-timeline></template></AppDialog>
</template>
<style scoped>
.discard-photo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.discard-photo-item{display:flex;flex-direction:column;gap:6px}.discard-photo-item .el-image{width:100%;height:150px;border-radius:6px}.discard-photo-item small{color:var(--cc-text-secondary)}
</style>
@@ -57,6 +57,16 @@ function isSingleLineBusinessField(field: DetailField) { return field.label ===
const referenceValue = computed(() => lastMileOrderReference(detail.value?.order.orderNo, '订单编码待补充'))
const isReturnOrder = computed(() => detail.value?.order.orderType === 'RETURN_RELABEL')
const returnReceiptUnitLabel = computed(() => detail.value?.returnReceipt?.receipts.some(receipt => receipt.unitType === 'PARCEL') ? '包裹' : '箱')
const signedReturnCartons = computed(() => (detail.value?.returnReceipt?.cartons || []).map(box => {
const parcel = detail.value?.returnReceipt?.receipts.find(receipt => receipt.receiptNo === box.receiptNo && receipt.unitType === 'PARCEL')
return { ...box, length: box.length ?? parcel?.length, width: box.width ?? parcel?.width, height: box.height ?? parcel?.height, weight: box.weight ?? parcel?.weight }
}))
function returnCartonVolume(box: { length?: number | null; width?: number | null; height?: number | null }) {
if (box.length == null || box.width == null || box.height == null) return '—'
const volume = box.length * box.width * box.height / 1_000_000
return Number.isFinite(volume) && volume >= 0 ? volume.toFixed(6).replace(/\.?0+$/, '') : '—'
}
const showReturnForecast = computed(() => isReturnOrder.value && (detail.value?.returnReceipt?.order.status
? detail.value.returnReceipt.order.status !== 'COMPLETED'
: ['PENDING_REVIEW', 'PENDING_RECEIPT', 'PARTIALLY_RECEIVED', 'REJECTED', 'CANCELLED'].includes(detail.value?.order.currentStatus || '')))
@@ -168,7 +178,7 @@ const cargoColumns = computed<DataTableColumn[]>(() => isReturnOrder.value ? [
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 120, align: 'right' },
])
const documentColumns: DataTableColumn[] = [
{ key: 'status', prop: 'status', label: '状态', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '换标数量(件)', minWidth: 130 },
{ key: 'status', prop: 'status', label: '状态', minWidth: 130 }, { key: 'quantity', prop: 'quantity', label: '换标数量(箱/件)', minWidth: 150, formatter: (row: { quantity?: number; quantityUnit?: string }) => `${row.quantity ?? '—'}${row.quantityUnit || '件'}` },
{ key: 'type', prop: 'type', label: '单据类型', minWidth: 150 }, { key: 'number', prop: 'number', label: '单据编号', minWidth: 200 },
{ key: 'createdAt', prop: 'createdAt', label: '生成时间', minWidth: 170 }, { key: 'quote', prop: 'quote', label: '报价', minWidth: 150 },
]
@@ -249,11 +259,12 @@ onMounted(load)
<el-table-column prop="boxCount" label="实收箱数" width="100" />
<el-table-column prop="receivedQuantity" label="实收数量" width="100" />
</el-table>
<h3>签收箱号明细({{ detail.returnReceipt.cartons?.length || 0 }} 箱)</h3>
<h3>签收{{ returnReceiptUnitLabel }}明细({{ detail.returnReceipt.cartons?.length || 0 }} {{ returnReceiptUnitLabel }})</h3>
<el-collapse v-if="detail.returnReceipt.cartons?.length">
<el-collapse-item v-for="box in detail.returnReceipt.cartons" :key="box.cartonMark" :name="box.cartonMark">
<template #title>{{ box.cartonMark }} · {{ box.countStatus === 'CONFIRMED' ? `已确认 · ${box.items.length} 种 SKU · ${box.items.reduce((sum, item) => sum + Number(item.quantity), 0)} 件` : box.countStatus === 'PENDING' ? '待清点' : '未留存箱内签收明细' }}</template>
<el-collapse-item v-for="box in signedReturnCartons" :key="box.cartonMark" :name="box.cartonMark">
<template #title><span class="return-carton-title"><span>{{ box.cartonMark }} · {{ box.countStatus === 'CONFIRMED' ? `已确认 · ${box.items.length} 种 SKU · ${box.items.reduce((sum, item) => sum + Number(item.quantity), 0)} 件` : box.countStatus === 'PENDING' ? '待清点' : '未留存箱内签收明细' }}</span><span class="return-carton-measurements">长 {{ box.length ?? '—' }} cm · 宽 {{ box.width ?? '—' }} cm · 高 {{ box.height ?? '—' }} cm · 体积 {{ returnCartonVolume(box) }} m³ · 重量 {{ box.weight ?? '—' }} kg</span></span></template>
<p>签收单 {{ box.receiptNo }}<template v-if="box.palletCode"> · 托盘 {{ box.palletCode }}</template><template v-if="box.countedAt"> · 确认时间 {{ formatOverseasDateTime(box.countedAt) }}</template></p>
<p v-if="box.length != null || box.width != null || box.height != null || box.weight != null">测量:{{ box.length ?? '—' }} × {{ box.width ?? '—' }} × {{ box.height ?? '—' }} cm · 重量 {{ box.weight ?? '—' }} kg</p>
<el-table v-if="box.countStatus === 'CONFIRMED'" :data="box.items" size="small" border>
<el-table-column prop="skuCode" label="SKU" min-width="220" />
<el-table-column prop="productNameZh" label="中文品名" min-width="180" />
@@ -263,6 +274,28 @@ onMounted(load)
</el-collapse-item>
</el-collapse>
<el-empty v-else description="暂无签收箱号明细" />
<h3>合箱记录</h3>
<el-table :data="detail.returnReceipt.packingDetails || []" size="small" border empty-text="暂无合箱记录">
<el-table-column prop="planNo" label="合箱计划" min-width="210" />
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="220" />
<el-table-column prop="targetCartonMark" label="目标箱唛" min-width="220" />
<el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-table-column prop="quantity" label="本单合入数量" width="130" />
</el-table>
<h3>当前关联实物箱</h3>
<el-collapse v-if="detail.returnReceipt.currentCartons?.length">
<el-collapse-item v-for="current in detail.returnReceipt.currentCartons" :key="current.carton.cartonMark" :name="`current-${current.carton.cartonMark}`">
<template #title>{{ current.carton.cartonMark }} · 整箱 {{ current.carton.totalQuantity }} 件 · 本单 {{ current.orderQuantity }} 件 · {{ current.carton.sourceReturnNo?.includes(',') ? '跨来源单合箱' : '单一来源' }}</template>
<p>来源单:{{ current.carton.sourceReturnNo }} · 状态:{{ current.carton.status }} · 我方箱唛:{{ current.labelStatus === 'CONFIRMED' ? '已确认贴标' : '待贴标确认' }}</p>
<el-table :data="current.ownership" size="small" border>
<el-table-column prop="returnNo" label="来源退货单" min-width="200" />
<el-table-column prop="skuCode" label="SKU" min-width="190" />
<el-table-column prop="quantity" label="来源贡献数量" width="130" />
</el-table>
<p>{{ current.canRelabel ? '后续贴标按此当前箱唛选择整箱;跨来源单同 SKU 的部分贴标须先现场分拣确认。' : current.blockReason }}</p>
</el-collapse-item>
</el-collapse>
<el-empty v-else description="暂无当前关联实物箱" />
</section>
<el-empty v-else description="暂无退货签收记录" />
</el-tab-pane>
@@ -292,6 +325,8 @@ onMounted(load)
<style scoped>
.source-heading { margin: 0; padding: var(--admin-space-3); font-size: var(--admin-font-section); font-weight: 600; }
.return-carton-title { display:flex; align-items:center; flex-wrap:wrap; gap:8px 16px; padding:4px 0; line-height:1.5; }
.return-carton-measurements { color:var(--cc-text-secondary); font-weight:400; }
.detail-fields > div.wide { grid-column: span 2; }
.detail-field-value--business { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.package-difference--nonzero { color: var(--el-color-danger); font-weight: 600; }
@@ -1137,7 +1137,7 @@ onMounted(() => { void load() })
<template v-if="statusTab !== '柜子'" #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderPalletMerge)" plain :disabled="!palletMergeSelectionEditable || actionLoading" @click="submitPalletMerge(true)">合托</el-button><el-button v-if="access.can(permission.lastMileOrderPalletMerge)" plain :disabled="!canCancelPalletMerge || actionLoading" @click="submitPalletMerge(false)">取消合托</el-button><el-button v-if="selectedReturnRows.length" type="warning" @click="openReturnPackingPlan"><PackagePlus :size="16" />创建退货合箱计划({{ selectedReturnRows.length }})</el-button><el-button v-if="access.can(permission.lastMileOrderExport) && access.can('platform:download:list')" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || hasSelectedRejectedReturn() || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate) && selectedRows.length === 1 && canGenerateInboundPlan(selectedRows[0])" type="primary" plain :disabled="hasSelectedInboundPlan() || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
<template #relabelOrderNo="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.relabelNo }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelOrderNo }}</span></template>
<template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }} · {{ application.quantity }} 件</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template>
<template #relabelStatus="{ row }"><div v-for="application in row.relabelApplications" :key="application.relabelNo" class="relabel-suborder">{{ application.statusLabel }}</div><span v-if="!row.relabelApplications?.length">{{ row.relabelStatus }}</span></template>
<template #actions="{ row }"><RowActions text-actions :actions="lastMileRowActions(row as LastMileOrderListItem)" :more-label="`更多尾程订单操作 · ${(row as LastMileOrderListItem).orderNo || '当前记录'}`" @action="handleLastMileRowAction($event, row as LastMileOrderListItem)" /></template>
<template #businessOrderNo="{ row }"><el-button v-if="businessOrderLink(row.businessType, row.businessOrderNo, access.can)" link type="primary" @click="router.push(businessOrderLink(row.businessType, row.businessOrderNo, access.can)!)">{{ row.businessOrderNo }}</el-button><span v-else>{{ row.businessOrderNo }}</span></template>
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
@@ -38,10 +38,7 @@ async function open(sku: string) {
const order = wrapper.findAllComponents(ElSelect).find(select => select.props('placeholder') === '选择已签收入库的退货单')!
order.vm.$emit('update:modelValue', 'RT-1'); order.vm.$emit('change', 'RT-1'); await flushPromises()
await wrapper.get('input[placeholder="请输入收件人、电话和地址"]').setValue('本地测试收件信息')
const mode = wrapper.findAllComponents({ name: 'ElRadioGroup' }).find(group => group.text().includes('单 SKU'))!
mode.vm.$emit('update:modelValue', 'SKU'); mode.vm.$emit('change', 'SKU'); await flushPromises()
const left = wrapper.findAllComponents({ name: 'ElTable' }).filter(table => table.classes().includes('return-result-table'))[0]!
left.findAllComponents(ElCheckbox).find(check => check.element.closest('tr')?.textContent?.includes(sku))!.vm.$emit('change', true); await flushPromises()
}
function right() { return wrapper.findAllComponents({ name: 'ElTable' }).filter(table => table.classes().includes('return-result-table'))[1]! }
async function selectMarks(marks: string[]) {
@@ -51,59 +48,24 @@ async function selectMarks(marks: string[]) {
}
await flushPromises()
}
describe('single SKU outbound source and piece quantity', () => {
it('creates one outbound with multiple SKUs and independent quantities on source rows', async () => {
describe('physical return outbound', () => {
it('submits whole multi-SKU parcels and boxes without a direct partial payload', async () => {
await open('SKU-A')
const left = wrapper.findAllComponents({ name: 'ElTable' }).filter(table => table.classes().includes('return-result-table'))[0]!
left.findAllComponents(ElCheckbox).find(check => check.element.closest('tr')?.textContent?.includes('SKU-B'))!.vm.$emit('change', true); await flushPromises()
expect(right().props('data')).toHaveLength(3)
await selectMarks(['LOOSE-A', 'PARCEL'])
const inputs = wrapper.findAllComponents(ElInputNumber)
inputs[0]!.vm.$emit('update:modelValue', 3)
inputs[2]!.vm.$emit('update:modelValue', 2)
await flushPromises()
expect(wrapper.findAllComponents(ElInputNumber)).toHaveLength(0)
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ items: [{ cartonMark: 'LOOSE-A', skuCode: 'SKU-A', quantity: 3 }, { cartonMark: 'PARCEL', skuCode: 'SKU-B', quantity: 2 }] }))
expect(api.create.mock.calls[0][1].skuCode).toBeUndefined()
expect(wrapper.text()).not.toContain('选择来源后填写件数')
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ cartonMarks: ['LOOSE-A', 'PARCEL'], unitType: 'BOX' }))
const request = api.create.mock.calls[0][1]
expect(request.items).toBeUndefined(); expect(request.skuCode).toBeUndefined(); expect(request.quantity).toBeUndefined()
})
it('selects only loose SKU sources scoped to the return order and excludes packed boxes', async () => {
it('includes physical parcels while excluding other return orders', async () => {
await open('SKU-A')
expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['LOOSE-A', 'LOOSE-B'])
expect(wrapper.text()).not.toContain('OTHER-ORDER')
await selectMarks(['LOOSE-A', 'LOOSE-B'])
expect(wrapper.findAllComponents(ElInputNumber).map(input => input.props('modelValue'))).toEqual([5, 5])
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ items: [{ cartonMark: 'LOOSE-A', skuCode: 'SKU-A', quantity: 5 }, { cartonMark: 'LOOSE-B', skuCode: 'SKU-A', quantity: 5 }], cartonMarks: ['LOOSE-A', 'LOOSE-B'] }))
const marks = right().props('data').map((row: { cartonMark: string }) => row.cartonMark)
expect(marks).toContain('PARCEL'); expect(marks).toContain('LOOSE-A'); expect(marks).not.toContain('OTHER-ORDER')
expect(wrapper.text()).toContain('合箱')
})
it('submits six pieces from two five-piece loose sources', async () => {
await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B'])
wrapper.findAllComponents(ElInputNumber)[1]!.vm.$emit('update:modelValue', 1); await flushPromises()
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ items: [{ cartonMark: 'LOOSE-A', skuCode: 'SKU-A', quantity: 5 }, { cartonMark: 'LOOSE-B', skuCode: 'SKU-A', quantity: 1 }] }))
})
it('allows loose PARCEL sources even when the box/pallet candidate endpoint excludes them', async () => {
await open('SKU-B'); expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['PARCEL'])
await selectMarks(['PARCEL']); wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 2); await flushPromises()
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ items: [{ cartonMark: 'PARCEL', skuCode: 'SKU-B', quantity: 2 }], cartonMarks: ['PARCEL'] }))
})
it('never offers a parcel as a physical box in box/pallet mode', async () => {
await open('SKU-B')
const mode = wrapper.findAllComponents({ name: 'ElRadioGroup' }).find(group => group.text().includes('单 SKU'))!
mode.vm.$emit('update:modelValue', 'BOX'); mode.vm.$emit('change', 'BOX'); await flushPromises()
expect(right().props('data').some((row: { sourceUnitType: string }) => row.sourceUnitType === 'PARCEL')).toBe(false)
expect(right().text()).not.toContain('包裹散件')
})
it.each([[0,1],[11,5]])('clamps input %s to the allowed bound %s', async (quantity, expected) => {
await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B'])
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', quantity); await flushPromises()
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({items: expect.arrayContaining([expect.objectContaining({cartonMark: 'LOOSE-A', quantity: expected})])}))
})
it.each([1.5])('blocks invalid quantity %s before creating an order', async quantity => {
await open('SKU-A'); await selectMarks(['LOOSE-A', 'LOOSE-B'])
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', quantity); await flushPromises()
it('blocks submission without a physical parcel or carton', async () => {
await open('SKU-A')
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
expect(api.create).not.toHaveBeenCalled()
})
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OutboundsView from './OutboundsView.vue'
import { listPlatformTenants } from '@/modules/iam'
const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn(), returnCandidates: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn() }))
const api = vi.hoisted(() => ({ candidates: vi.fn(), create: vi.fn(), warehouses: vi.fn(), returnCandidates: vi.fn(), stock: vi.fn(), cartons: vi.fn(), carton: vi.fn(), transfers: vi.fn(), bindDestination: vi.fn() }))
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.carton }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
@@ -14,6 +14,7 @@ vi.mock('../api/outbounds', () => ({
listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }),
listOneClickOutboundCandidates: api.candidates,
createOneClickDropshipOutbound: api.create,
listWarehouseTransferOutboundCandidates: api.transfers, bindWarehouseTransferDestination: api.bindDestination,
cancelOutbound: vi.fn(), createReturnOutbound: vi.fn(), listReturnOutboundCandidates: api.returnCandidates,
}))
@@ -49,7 +50,7 @@ async function selectBothPages() {
}
describe('outbound candidate search and selection', () => {
it('limits single SKU quantities and selectable sources to loose stock of the selected return, excluding pallet and box contents', async () => {
it('offers whole physical parcels and boxes scoped to the selected return', async () => {
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: 'customer-a', tenantName: '客户甲', status: 'ACTIVE' }], page: 1, size: 200, total: 1, pages: 1 } as Awaited<ReturnType<typeof listPlatformTenants>>)
const sources = [
{ cartonMark: 'P-A', sourceUnitType: 'PALLET', skuCode: 'SKU-A', quantity: 5 },
@@ -70,18 +71,13 @@ describe('outbound candidate search and selection', () => {
customer.vm.$emit('update:modelValue', 'customer-a'); customer.vm.$emit('change'); await flushPromises()
const order = wrapper.findAllComponents(ElSelect).find(row => row.props('placeholder') === '选择已签收入库的退货单')!
order.vm.$emit('update:modelValue', 'RT-MIXED'); order.vm.$emit('change'); await flushPromises()
const mode = wrapper.findAllComponents({ name: 'ElRadioGroup' }).find(row => row.text().includes('单 SKU'))!
mode.vm.$emit('update:modelValue', 'SKU'); mode.vm.$emit('change'); await flushPromises()
const stock = wrapper.findAll('.return-list-card')[0]!
expect(stock.findAll('tbody tr').map(row => row.text().replace(/\s/g, ''))).toEqual(['SKU-A1', 'SKU-B5'])
stock.findAllComponents(ElCheckbox)[0]!.vm.$emit('change', true); await flushPromises()
const sourceTable = wrapper.findAll('.return-list-card')[1]!
expect(sourceTable.findAll('tbody tr')).toHaveLength(1)
expect(sourceTable.text()).toContain('SKU-A × 1')
sourceTable.findAllComponents(ElCheckbox)[0]!.vm.$emit('change', true); await flushPromises()
expect(wrapper.findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(1)
mode.vm.$emit('update:modelValue', 'BOX'); mode.vm.$emit('change'); await flushPromises()
expect(wrapper.findAll('.return-list-card')[1]!.findAll('tbody tr')).toHaveLength(3)
expect(sourceTable.findAll('tbody tr')).toHaveLength(5)
expect(sourceTable.text()).toContain('SP-A')
expect(sourceTable.text()).toContain('BOX-A')
expect(sourceTable.text()).not.toContain('SP-OTHER')
expect(wrapper.findComponent({ name: 'ElInputNumber' }).exists()).toBe(false)
expect(wrapper.text()).toContain('合箱')
})
beforeEach(() => {
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
@@ -89,6 +85,8 @@ describe('outbound candidate search and selection', () => {
vi.stubGlobal('localStorage', { getItem: () => null, setItem: () => undefined, removeItem: () => undefined })
api.candidates.mockReset().mockResolvedValue(fixtures.map(item => ({ ...item })))
api.create.mockReset().mockResolvedValue({})
api.transfers.mockReset().mockResolvedValue([])
api.bindDestination.mockReset().mockResolvedValue([])
api.warehouses.mockReset().mockResolvedValue({ records: [
{ id: 'warehouse-a', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
{ id: 'warehouse-b', warehouseName: '纽约仓', warehouseCode: 'US-NY-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
@@ -96,6 +94,40 @@ describe('outbound candidate search and selection', () => {
})
afterEach(() => { wrapper?.unmount(); vi.unstubAllGlobals() })
it('groups pallet candidates by customer and transfer and binds each selected transfer once', async () => {
const transfer = { customerId: 'customer-a', transferNo: 'WT-TEST', overseasWarehouseId: 'warehouse-a', transferStatus: 'WAIT_FBA_DESTINATION', needRelabel: false }
api.transfers.mockResolvedValue([
{ ...transfer, packageCount: 4, volumeCbm: 0.15 },
{ ...transfer, packageCount: 6, volumeCbm: 0.08 },
{ ...transfer, customerId: 'customer-b', packageCount: 2, volumeCbm: 0.1 },
])
api.warehouses.mockResolvedValue({ records: [
{ id: 'warehouse-a', warehouseName: '洛杉矶仓', warehouseCode: 'US-LAX-01', warehouseType: 'OVERSEAS', status: 'ACTIVE' },
{ id: 'fba-a', warehouseName: 'ABE5', warehouseCode: 'ABE5', warehouseType: 'FBA', status: 'ACTIVE' },
] })
await openCreate()
const type = wrapper.findComponent({ name: 'ElRadioGroup' })
type.vm.$emit('update:modelValue', 'WAREHOUSE_TRANSFER_OUTBOUND')
type.vm.$emit('change')
await flushPromises()
const table = wrapper.findAllComponents({ name: 'ElTable' }).find(row => row.classes().includes('dropship-candidate-table'))!
expect(table.props('data')).toEqual([
expect.objectContaining({ customerId: 'customer-a', packageCount: 10, volumeCbm: 0.23 }),
expect.objectContaining({ customerId: 'customer-b', packageCount: 2, volumeCbm: 0.1 }),
])
for (const checkbox of table.findAllComponents(ElCheckbox)) checkbox.vm.$emit('change', true)
const destination = wrapper.findAllComponents(ElSelect).find(row => row.props('placeholder') === '请选择FBA目的仓')!
destination.vm.$emit('update:modelValue', 'fba-a')
await flushPromises()
await wrapper.find('[data-test="submit-outbound"]').trigger('click')
await flushPromises()
expect(api.bindDestination).toHaveBeenCalledOnce()
expect(api.bindDestination).toHaveBeenCalledWith(expect.objectContaining({ transfers: [
{ customerId: 'customer-a', transferNo: 'WT-TEST' },
{ customerId: 'customer-b', transferNo: 'WT-TEST' },
], destinationWarehouseCode: 'ABE5' }))
})
it('offers enabled customers from later pages when creating a return outbound', async () => {
vi.mocked(listPlatformTenants)
.mockResolvedValueOnce({ records: [{ id: 'customer-a', tenantName: '柏悦物流demo2', status: 'ACTIVE' }], page: 1, size: 200, total: 201, pages: 2 } as Awaited<ReturnType<typeof listPlatformTenants>>)
@@ -36,9 +36,6 @@ const selectedDestinationWarehouseId = ref('')
const deliveryAddress = ref(''); const postalCode = ref(''); const contactName = ref(''); const contactPhone = ref('')
const returnStockRows = ref<ReturnStockRow[]>([])
const returnCartonRows = ref<ReturnCartonRow[]>([])
const selectedReturnSkus = ref<string[]>([])
const returnPickMode = ref<'BOX' | 'SKU'>('BOX')
const returnSourceQuantities = ref<Record<string, number | undefined>>({})
const returnSkuQueryInput = ref(''); const returnSkuQuery = ref('')
const returnCartonQueryInput = ref(''); const returnCartonQuery = ref('')
const returnSkuPage = ref(1); const returnCartonPage = ref(1); const returnUnitType = ref<'BOX' | 'PALLET'>('BOX')
@@ -65,16 +62,7 @@ function transferCustomerName(row: WarehouseTransferOutboundCandidate) {
const customer = tenantOptions.value.find(item => String(item.id) === String(row.customerId))
return customer?.legalName?.trim() || customer?.tenantName?.trim() || `客户 ${row.customerId}`
}
const selectedReturnStock = computed(() => returnStockRows.value.map((item) => {
if (returnPickMode.value !== 'SKU') return item
const quantity = selectedReturnCartons.value.reduce((sum, carton) => {
if (carton.sourceUnitType !== 'PARCEL') return sum
if (selectedReturnNo.value && !(carton.sourceReturnNo || '').split(',').includes(selectedReturnNo.value)) return sum
const items = returnCartonDetails.value[carton.cartonMark]?.items
return items?.length === 1 && items[0]?.skuCode === item.skuCode ? sum + Number(items[0].availableQuantity ?? items[0].quantity) : sum
}, 0)
return { ...item, availableQuantity: quantity }
}).filter(item => Number(item.availableQuantity || 0) > 0))
const selectedReturnStock = computed(() => returnStockRows.value.filter(item => Number(item.availableQuantity || 0) > 0))
const selectedReturnCartons = computed(() => returnCartonRows.value.filter((item) => item.status === 'AVAILABLE'))
const returnOrderOptions = computed(() => [...new Set([
...returnCandidates.value.flatMap(item => (item.returnNo || '').split(',')),
@@ -86,25 +74,16 @@ const queryTokens = (value: string) => value.split(/[,,\n]+/).map((item) => it
const matchesAnyToken = (value: string, query: string) => { const tokens = queryTokens(query); const normalized = value.replace(/\s+/g, '').toLowerCase(); return !tokens.length || tokens.some((token) => normalized.includes(token)) }
const filteredReturnStocks = computed(() => selectedReturnStock.value.filter((item) => matchesAnyToken(`${item.skuCode},${item.productNameZh || ''}`, returnSkuQuery.value)))
const filteredReturnCartons = computed(() => selectedReturnCartons.value.filter((item) => {
if (returnPickMode.value === 'BOX' && item.sourceUnitType === 'PARCEL') return false
if (selectedReturnNo.value && !(item.sourceReturnNo || returnCandidateByMark.value[item.cartonMark]?.returnNo || '').split(',').includes(selectedReturnNo.value)) return false
if (returnPickMode.value === 'SKU') {
if (item.sourceUnitType !== 'PARCEL') return false
const detail = returnCartonDetails.value[item.cartonMark]
if (!selectedReturnSkus.value.length || detail?.items?.length !== 1 || !selectedReturnSkus.value.includes(detail.items[0]?.skuCode || '')) return false
}
const detailSkus = returnCartonDetails.value[item.cartonMark]?.items?.map((detail) => detail.skuCode).join(',') || ''
return matchesAnyToken(`${item.cartonMark},${item.fbaCartonMark || ''},${detailSkus}`, returnCartonQuery.value)
}))
const pagedReturnStocks = computed(() => filteredReturnStocks.value.slice((returnSkuPage.value - 1) * returnSkuPageSize.value, returnSkuPage.value * returnSkuPageSize.value))
const pagedReturnCartons = computed(() => filteredReturnCartons.value.slice((returnCartonPage.value - 1) * returnCartonPageSize.value, returnCartonPage.value * returnCartonPageSize.value))
const effectiveReturnMarks = computed(() => [...new Set(selectedReturnMarks.value)].filter(Boolean) as string[])
function returnSourceAvailable(mark: string) {
const item = returnCartonDetails.value[mark]?.items[0]
return Number(item?.availableQuantity ?? item?.quantity ?? 0)
}
watch([selectedReturnNo, selectedCustomerId, selectedWarehouseId], () => {
selectedReturnSkus.value = []; selectedReturnMarks.value = []; returnSourceQuantities.value = {}
selectedReturnMarks.value = []
})
const returnMarksModel = computed({ get: () => selectedReturnMarks.value, set: (value: string[]) => { selectedReturnMarks.value = value } })
const filteredDropshipCandidates = computed(() => dropshipCandidates.value.filter(item => !dropshipQuery.value.trim() || [item.orderNo, item.inboundCode, item.customerName, item.palletNo, item.skuCode, item.productName].join(' ').toLowerCase().includes(dropshipQuery.value.trim().toLowerCase())))
@@ -154,12 +133,24 @@ async function loadInventoryOptions() {
async function openCreate() { createOpen.value = true; outboundType.value = 'ONE_CLICK_DROPSHIP'; selectedCustomerId.value = ''; selectedWarehouseId.value = ''; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; transferCandidates.value = []; destinationType.value = 'FBA'; selectedDestinationWarehouseId.value = ''; deliveryAddress.value = ''; postalCode.value = ''; contactName.value = ''; contactPhone.value = ''; dropshipCandidates.value = []; dropshipQueryInput.value = ''; dropshipQuery.value = ''; dropshipPage.value = 1; returnSkuQueryInput.value = ''; returnSkuQuery.value = ''; returnCartonQueryInput.value = ''; returnCartonQuery.value = ''; returnSkuPage.value = 1; returnCartonPage.value = 1; await loadInventoryOptions(); await loadDropshipCandidates() }
async function loadDropshipCandidates() { if (!selectedWarehouseId.value) { dropshipCandidates.value = []; return }; dropshipPage.value = 1; inventoryLoading.value = true; try { dropshipCandidates.value = await listOneClickOutboundCandidates(selectedCustomerId.value || undefined, { warehouseId: selectedWarehouseId.value }) } catch (cause) { dropshipCandidates.value = []; ElMessage.error(getApiErrorDetails(cause, '一件代发订单加载失败').message) } finally { inventoryLoading.value = false } }
async function changeDropshipWarehouse() { selectedDropshipIds.value = []; await loadDropshipCandidates() }
async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() }
async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; selectedReturnSkus.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; dropshipCandidates.value = []; transferCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates(); if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') await loadTransferCandidates() }
async function selectCustomer() { selectedPositionId.value = ''; selectedDropshipIds.value = []; selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; dropshipCandidates.value = []; dropshipPage.value = 1; if (outboundType.value === 'RETURN_OUTBOUND') { if (selectedCustomerId.value && selectedWarehouseId.value) await loadReturnCandidates(); return }; await loadDropshipCandidates() }
async function changeOutboundType() { selectedReturnNo.value = ''; selectedReturnMarks.value = []; returnCandidates.value = []; returnStockRows.value = []; returnCartonRows.value = []; returnCartonDetails.value = {}; selectedDropshipIds.value = []; selectedTransferPalletIds.value = []; dropshipCandidates.value = []; transferCandidates.value = []; if (outboundType.value === 'ONE_CLICK_DROPSHIP') await loadDropshipCandidates(); if (outboundType.value === 'WAREHOUSE_TRANSFER_OUTBOUND') await loadTransferCandidates() }
async function loadTransferCandidates() {
inventoryLoading.value = true
try {
transferCandidates.value = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined })
const pallets = await listWarehouseTransferOutboundCandidates({ customerId: selectedCustomerId.value || undefined, warehouseId: selectedWarehouseId.value || undefined })
// The API returns pallet rows, but destination binding applies to the whole transfer.
const transfers = new Map<string, WarehouseTransferOutboundCandidate>()
for (const pallet of pallets) {
const key = transferSelectionKey(pallet)
const transfer = transfers.get(key)
if (transfer) {
transfer.packageCount += pallet.packageCount
transfer.grossWeightKg = sumTransferMeasurement(transfer.grossWeightKg, pallet.grossWeightKg)
transfer.volumeCbm = sumTransferMeasurement(transfer.volumeCbm, pallet.volumeCbm)
} else transfers.set(key, { ...pallet })
}
transferCandidates.value = [...transfers.values()]
const candidateKeys = new Set(transferCandidates.value.map(transferSelectionKey))
selectedTransferPalletIds.value = selectedTransferPalletIds.value.filter(key => candidateKeys.has(key))
if (transferCandidates.value.length === 1) selectedTransferPalletIds.value = [transferSelectionKey(transferCandidates.value[0])]
@@ -170,6 +161,9 @@ async function loadTransferCandidates() {
} finally { inventoryLoading.value = false }
}
function transferSelectionKey(row: WarehouseTransferOutboundCandidate) { return `${row.customerId}:${row.transferNo}` }
function sumTransferMeasurement(left?: number | null, right?: number | null) {
return left == null && right == null ? null : Number(((left ?? 0) + (right ?? 0)).toFixed(6))
}
function toggleTransferPallet(row: WarehouseTransferOutboundCandidate, checked: boolean | string | number) { const key = transferSelectionKey(row); selectedTransferPalletIds.value = checked ? [...new Set([...selectedTransferPalletIds.value, key])] : selectedTransferPalletIds.value.filter(id => id !== key) }
function transferDestinationLabel(row: WarehouseTransferOutboundCandidate) {
if (row.destinationType === 'PRIVATE' || row.privateDestinationAddress?.trim()) {
@@ -181,7 +175,7 @@ function transferDestinationLabel(row: WarehouseTransferOutboundCandidate) {
function transferHasDestination(row: WarehouseTransferOutboundCandidate) {
return Boolean(row.destinationWarehouseCode?.trim() || row.privateDestinationAddress?.trim())
}
async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnSkus.value = []; selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } }
async function loadReturnCandidates() { if (!selectedCustomerId.value || !selectedWarehouseId.value) return; inventoryLoading.value = true; try { const [candidates, stock, cartons] = await Promise.all([listReturnOutboundCandidates(selectedCustomerId.value, selectedWarehouseId.value), listReturnStock({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' }), listReturnCartons({ page: 1, size: 200, customerId: selectedCustomerId.value, overseasWarehouseId: selectedWarehouseId.value, status: 'AVAILABLE' })]); returnCandidates.value = candidates; returnStockRows.value = stock.records; returnCartonRows.value = cartons.records; const details = await Promise.all(cartons.records.map(async (row) => [row.cartonMark, await getReturnCarton(row.cartonMark)] as const)); returnCartonDetails.value = Object.fromEntries(details); selectedReturnMarks.value = [] } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '客户库存加载失败').message) } finally { inventoryLoading.value = false } }
function dropshipCandidateKey(item: OneClickOutboundCandidate) { return `${item.inventoryPositionId}:${item.skuCode || ''}` }
function isDropshipSelected(item: OneClickOutboundCandidate) { return selectedDropshipIds.value.includes(dropshipCandidateKey(item)) }
function toggleDropshipCandidate(item: OneClickOutboundCandidate, checked: boolean | string | number) { const key = dropshipCandidateKey(item); selectedDropshipIds.value = checked ? [...new Set([...selectedDropshipIds.value, key])] : selectedDropshipIds.value.filter(value => value !== key) }
@@ -196,15 +190,9 @@ function resetDropshipCandidates() { dropshipQueryInput.value = ''; dropshipQuer
function normalizeBatchQuery(value: string) { return value.split(/[,,\n]+/).map((item) => item.replace(/\s+/g, '')).filter(Boolean).join(',') }
function searchReturnSkus() { returnSkuQueryInput.value = normalizeBatchQuery(returnSkuQueryInput.value); returnSkuQuery.value = returnSkuQueryInput.value; returnSkuPage.value = 1 }
function searchReturnCartons() { returnCartonQueryInput.value = normalizeBatchQuery(returnCartonQueryInput.value); returnCartonQuery.value = returnCartonQueryInput.value; returnCartonPage.value = 1 }
function toggleReturnSku(sku: string, checked: boolean | string | number) {
selectedReturnSkus.value = checked ? [...new Set([...selectedReturnSkus.value, sku])] : selectedReturnSkus.value.filter(value => value !== sku)
if (!checked) selectedReturnMarks.value = selectedReturnMarks.value.filter(mark => returnCartonDetails.value[mark]?.items[0]?.skuCode !== sku)
}
function changeReturnPickMode() { selectedReturnSkus.value = []; selectedReturnMarks.value = []; returnUnitType.value = 'BOX' }
function changeReturnUnitType() { selectedReturnMarks.value = [] }
function toggleReturnCarton(cartonMark: string, checked: boolean | string | number) {
selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, cartonMark])] : selectedReturnMarks.value.filter(value => value !== cartonMark)
if (checked && returnPickMode.value === 'SKU') returnSourceQuantities.value[cartonMark] = returnSourceAvailable(cartonMark)
}
function toggleReturnPallet(palletCode: string, checked: boolean | string | number) {
const marks = returnCandidates.value.filter(item => item.palletCode === palletCode && item.cartonMark).map(item => item.cartonMark!)
@@ -226,24 +214,13 @@ async function submit() {
} else if (outboundType.value === 'RETURN_OUTBOUND') {
if (!recipient.value.trim()) { ElMessage.warning('请填写收件信息'); return }
if (!selectedReturnNo.value) { ElMessage.warning('请选择退货单'); return }
if (returnPickMode.value === 'SKU') {
if (!selectedReturnSkus.value.length) { ElMessage.warning('请至少选择一个 SKU'); return }
if (selectedReturnSkus.value.some(sku => !effectiveReturnMarks.value.some(mark => returnCartonDetails.value[mark]?.items[0]?.skuCode === sku))) {
ElMessage.warning('请为每个已选 SKU 选择散件来源并填写出库数量'); return
}
if (effectiveReturnMarks.value.some(mark => !filteredReturnCartons.value.some(row => row.cartonMark === mark)
|| !Number.isSafeInteger(returnSourceQuantities.value[mark]) || Number(returnSourceQuantities.value[mark]) <= 0
|| Number(returnSourceQuantities.value[mark]) > returnSourceAvailable(mark))) {
ElMessage.warning('请在明细中填写大于0且不超过来源可用数量的整数'); return
}
}
// Return outbound is physical-unit based. Partial SKU quantities must go
// through the PDA packing flow and produce a new labelled carton first.
if (!selectedReturnMarks.value.length) {
ElMessage.warning(returnPickMode.value === 'SKU'
? filteredReturnCartons.value.length ? '请在右侧选择该 SKU 的散件来源记录' : '该退货单没有此 SKU 的可用散件库存;箱内库存请按箱/托出库'
: '请选择出库箱号或托盘')
ElMessage.warning('请选择出库包裹/箱号或托盘')
return
}
await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, items: returnPickMode.value === 'SKU' ? effectiveReturnMarks.value.map(mark => ({ cartonMark: mark, skuCode: returnCartonDetails.value[mark]!.items[0]!.skuCode, quantity: Number(returnSourceQuantities.value[mark]) })) : undefined, unitType: returnPickMode.value === 'SKU' ? 'BOX' : returnUnitType.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` })
await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, unitType: returnUnitType.value, remark: remark.value.trim() || undefined, idempotencyKey: `web-return-${Date.now()}` })
ElMessage.success('退货出库单和待派送单已创建,作业已推送 PDA')
} else {
const selected = dropshipCandidates.value.filter(item => selectedDropshipIds.value.includes(dropshipCandidateKey(item)))
@@ -363,21 +340,21 @@ onMounted(load)
<el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item>
<el-form-item label="海外仓" required><el-select v-model="selectedWarehouseId" :disabled="!selectedCustomerId" filterable placeholder="请选择海外仓" @change="loadReturnCandidates"><el-option v-for="item in warehouseOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item>
<el-form-item label="退货单" required><el-select v-model="selectedReturnNo" filterable placeholder="选择已签收入库的退货单" @change="selectedReturnMarks = []"><el-option v-for="no in returnOrderOptions" :key="no" :label="no" :value="no" /></el-select></el-form-item>
<p v-if="returnPickMode === 'SKU'" class="candidate-hint">单 SKU 只展示该退货单的可用散件库存,可填写全部或部分件数,由 PDA 扫描 SKU 拣货。</p><el-form-item label="出库维度"><el-radio-group v-model="returnPickMode" @change="changeReturnPickMode"><el-radio-button value="BOX">箱 / 托</el-radio-button><el-radio-button value="SKU">单 SKU</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="returnPickMode === 'BOX'" label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按箱号出库</el-radio-button><el-radio-button value="PALLET">按托盘出库</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="returnPickMode === 'BOX' && returnUnitType === 'PALLET' && returnPallets.length" label="整托选择"><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></el-form-item>
<p class="candidate-hint">整包裹、整箱或整托出库。包裹或箱子只出部分商品时,请先在退货合箱作业生成新箱并贴标,再选择新箱出库。</p>
<el-form-item label="箱托方式"><el-radio-group v-model="returnUnitType" @change="changeReturnUnitType"><el-radio-button value="BOX">按包裹 / 箱号出库</el-radio-button><el-radio-button value="PALLET">按托盘出库</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="returnUnitType === 'PALLET' && returnPallets.length" label="整托选择"><div class="return-pallet-options"><el-checkbox v-for="code in returnPallets" :key="code" :model-value="returnCandidates.filter(item => item.palletCode === code && item.cartonMark).every(item => selectedReturnMarks.includes(item.cartonMark!))" @change="toggleReturnPallet(code, $event)">{{ code }}</el-checkbox></div></el-form-item>
<div class="return-lists">
<section class="return-list-card">
<div class="list-title"><span>SKU库存</span><small>按 SKU 查询</small></div>
<div class="candidate-query"><el-input v-model="returnSkuQueryInput" clearable placeholder="如:SKU001,SKU002" /><el-button type="primary" @click="searchReturnSkus">查询</el-button></div>
<el-table :data="pagedReturnStocks" border stripe class="return-result-table" max-height="400"><el-table-column v-if="returnPickMode === 'SKU'" label="选择" width="60" align="center"><template #default="{ row }"><el-checkbox :model-value="selectedReturnSkus.includes(row.skuCode)" @change="toggleReturnSku(row.skuCode, $event)" /></template></el-table-column><el-table-column prop="skuCode" label="SKU" min-width="150" show-overflow-tooltip /><el-table-column prop="productNameZh" label="品名" min-width="150" show-overflow-tooltip /><el-table-column label="可用数量" width="110" align="right"><template #default="{ row }">{{ row.availableQuantity }}</template></el-table-column></el-table>
<div class="return-pagination"><span>{{ returnPickMode === 'SKU' ? `已选择 ${selectedReturnSkus.length} 个 SKU` : 'SKU 库存' }}</span><el-pagination v-model:current-page="returnSkuPage" v-model:page-size="returnSkuPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnStocks.length" layout="total, sizes, prev, pager, next" @size-change="returnSkuPage = 1" /></div>
<el-table :data="pagedReturnStocks" border stripe class="return-result-table" max-height="400"><el-table-column prop="skuCode" label="SKU" min-width="150" show-overflow-tooltip /><el-table-column prop="productNameZh" label="品名" min-width="150" show-overflow-tooltip /><el-table-column label="可用数量" width="110" align="right"><template #default="{ row }">{{ row.availableQuantity }}</template></el-table-column></el-table>
<div class="return-pagination"><span>SKU 库存</span><el-pagination v-model:current-page="returnSkuPage" v-model:page-size="returnSkuPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnStocks.length" layout="total, sizes, prev, pager, next" @size-change="returnSkuPage = 1" /></div>
</section>
<section class="return-list-card">
<div class="list-title"><span>{{ returnPickMode === 'SKU' ? 'SKU 来源明细' : '箱号列表' }}</span><small>支持多个箱号或 SKU,用英文逗号分隔</small></div>
<div class="list-title"><span>包裹 / 箱号列表</span><small>支持多个箱号或 SKU,用英文逗号分隔</small></div>
<div class="candidate-query"><el-input v-model="returnCartonQueryInput" clearable placeholder="如:RC001,RC002 或 SKU001,SKU002" /><el-button type="primary" @click="searchReturnCartons">查询</el-button></div>
<el-table :data="pagedReturnCartons" border stripe class="return-result-table" max-height="400"><el-table-column label="选择" width="60" align="center" fixed="left"><template #default="{ row }"><el-checkbox :model-value="selectedReturnMarks.includes(row.cartonMark)" @change="toggleReturnCarton(row.cartonMark, $event)" /></template></el-table-column><el-table-column :label="returnPickMode === 'SKU' ? '库存来源' : '箱号'" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '包裹散件(按 SKU 拣货)' : row.cartonMark }}</template></el-table-column><el-table-column v-if="returnPickMode !== 'SKU'" label="托盘" min-width="150"><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '无需托盘' : returnCandidateByMark[row.cartonMark]?.palletCode || '散箱' }}</template></el-table-column><el-table-column :label="returnPickMode === 'SKU' ? 'SKU及可用数量' : '箱内SKU及数量'" min-width="230"><template #default="{ row }">{{ returnCartonDetails[row.cartonMark]?.items?.map((item) => `${item.skuCode} × ${item.quantity}`).join(',') || '加载中' }}</template></el-table-column><el-table-column v-if="returnPickMode === 'SKU'" label="出库数量(件)" width="150" fixed="right"><template #default="{ row }"><el-input-number v-model="returnSourceQuantities[row.cartonMark]" :disabled="!selectedReturnMarks.includes(row.cartonMark)" :min="1" :max="returnSourceAvailable(row.cartonMark)" :precision="0" controls-position="right" size="small" placeholder="数量" style="width: 120px" :aria-label="`出库数量 ${returnCartonDetails[row.cartonMark]?.items[0]?.skuCode} ${row.cartonMark}`" /></template></el-table-column></el-table>
<div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个{{ returnPickMode === 'SKU' ? '来源' : '箱号' }}</span><el-pagination v-model:current-page="returnCartonPage" v-model:page-size="returnCartonPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnCartons.length" layout="total, sizes, prev, pager, next" @size-change="returnCartonPage = 1" /></div>
<el-table :data="pagedReturnCartons" border stripe class="return-result-table" max-height="400"><el-table-column label="选择" width="60" align="center" fixed="left"><template #default="{ row }"><el-checkbox :model-value="selectedReturnMarks.includes(row.cartonMark)" @change="toggleReturnCarton(row.cartonMark, $event)" /></template></el-table-column><el-table-column label="包裹 / 箱号" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.cartonMark }}</template></el-table-column><el-table-column label="托盘" min-width="150"><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '无需托盘' : returnCandidateByMark[row.cartonMark]?.palletCode || '散箱' }}</template></el-table-column><el-table-column label="包裹 / 箱内 SKU 及数量" min-width="230"><template #default="{ row }">{{ returnCartonDetails[row.cartonMark]?.items?.map((item) => `${item.skuCode} × ${item.quantity}`).join(',') || '加载中' }}</template></el-table-column></el-table>
<div class="return-pagination"><span>已选择 {{ selectedReturnMarks.length }} 个包裹 / 箱</span><el-pagination v-model:current-page="returnCartonPage" v-model:page-size="returnCartonPageSize" :page-sizes="[5, 10, 20, 50]" :total="filteredReturnCartons.length" layout="total, sizes, prev, pager, next" @size-change="returnCartonPage = 1" /></div>
</section>
</div>
</template>
@@ -0,0 +1,27 @@
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import { getRelabelOrder } from '../api/relabelOrders'
import RelabelOrderDetailView from './RelabelOrderDetailView.vue'
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { orderId: 'RL-1' } }), useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => false }) }))
vi.mock('../api/relabelOrders', () => ({ getRelabelOrder: vi.fn(), confirmRelabelCompletion: vi.fn(), getRelabelReviewTask: vi.fn(), returnRelabelForRework: vi.fn(), downloadRelabelCompletionPhoto: vi.fn(), downloadRelabelPdf: vi.fn(), uploadRelabelPdf: vi.fn() }))
it('reloads authoritative status when a cached detail tab is reopened without double loading initially', async () => {
vi.mocked(getRelabelOrder).mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-1', status: 'PENDING_REVIEW' }, items: [], history: [] })
.mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-1', status: 'PENDING_PROCESSING' }, items: [], history: [] })
const active = ref(true)
const Host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => active.value ? h(RelabelOrderDetailView) : h('div') }) })
const wrapper = mount(Host, { global: { stubs: { OverseasReadOnlyDetail: true, AppDialog: true } } })
await flushPromises()
expect(getRelabelOrder).toHaveBeenCalledTimes(1)
expect(wrapper.findComponent({ name: 'OverseasReadOnlyDetail' }).props('statusText')).toBe('待审核')
active.value = false
await flushPromises()
active.value = true
await flushPromises()
expect(getRelabelOrder).toHaveBeenCalledTimes(2)
expect(wrapper.findComponent({ name: 'OverseasReadOnlyDetail' }).props('statusText')).toBe('待处理')
wrapper.unmount()
})
@@ -18,6 +18,9 @@ describe('RelabelOrderDetailView', () => {
expect(source).toContain("PENDING_REVIEW: '待审核'")
expect(source).toContain('value?.application.customerOrderNo || value?.application.lastlegOrderNo')
expect(source).toContain('item.destinationFbaWarehouseCode || item.destinationWarehouseId')
expect(source).toContain('items.flatMap(relabelSkuCodes)')
expect(source).toContain('v-for="(sku, index) in item.skuItems"')
expect(source).toContain('{{ sku.skuCode }} → {{ sku.newSkuCode }} × {{ sku.quantity }} 件')
})
it('lets platform operators confirm completion or return PDA work for rework', () => {
@@ -1,6 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, ref, onBeforeUnmount } from 'vue'
import { computed, onActivated, onMounted, ref, onBeforeUnmount } from 'vue'
import { CheckCircle2, Eye, FileUp, Undo2 } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -14,6 +14,7 @@ import { downloadRelabelCompletionPhoto, downloadRelabelPdf, getRelabelOrder, up
import { formatOverseasDateTime } from '../lastMilePresentation'
import OverseasReadOnlyDetail from '../components/OverseasReadOnlyDetail.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { relabelSkuCodes } from '../utils/relabelSkuDisplay'
const route = useRoute()
const router = useRouter()
@@ -80,7 +81,7 @@ const fieldValues = computed<Record<string, string | number>>(() => {
资金状态: walletStatus(value?.application.walletStatus),
创建时间: formatOverseasDateTime(value?.application.createdAt),
更新时间: value?.history.length ? formatOverseasDateTime(value.history[value.history.length - 1]?.createdAt) : '—',
商品SKU: items.map((item) => text(item.skuCode)).join('、') || '—',
商品SKU: [...new Set(items.flatMap(relabelSkuCodes))].join('、') || '—',
中文品名: items.map((item) => text(item.productNameZh)).join('、') || '—',
箱数: Number(value?.application.totalPackageCount || 0),
预算总价: '—',
@@ -100,6 +101,7 @@ async function load() {
}
function back() { void router.push({ name: 'overseas-relabel-orders' }) }
onMounted(load)
onActivated(() => { if (!loading.value) void load() })
onBeforeUnmount(() => { if (previewUrl.value) URL.revokeObjectURL(previewUrl.value) })
async function uploadPdf(event: Event, scope: 'TOTAL' | 'ITEM', itemId?: string) {
const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''
@@ -135,7 +137,7 @@ async function previewCompletionPhoto(fileId: string) {
<table class="density-table">
<thead><tr><th>我方箱唛</th><th>FBA 箱唛</th><th>商品 SKU</th><th>中文品名</th><th>FBA 货件编号</th><th>换标新 SKU</th><th>目的仓</th><th>明细PDF</th></tr></thead>
<tbody>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ text(item.skuCode) }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td>{{ text(item.newSkuCode) }}</td><td>{{ text(item.destinationFbaWarehouseCode || item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
<tr v-for="item in detail?.items || []" :key="item.id"><td class="mark-code">{{ text(item.cartonMark) }}</td><td class="mark-code">{{ text(item.fbaCartonMark) }}</td><td>{{ relabelSkuCodes(item).join('、') || '—' }}</td><td>{{ text(item.productNameZh) }}</td><td>{{ text(item.shipmentNo) }}</td><td><template v-if="item.skuItems?.length"><div v-for="(sku, index) in item.skuItems" :key="index">{{ sku.skuCode }} → {{ sku.newSkuCode }} × {{ sku.quantity }} 件</div></template><span v-else>{{ text(item.newSkuCode) }}</span></td><td>{{ text(item.destinationFbaWarehouseCode || item.destinationWarehouseId) }}</td><td><span v-if="item.relabelFileName" class="document-actions density-actions"><strong>{{ item.relabelFileName }}</strong><el-button class="density-action" data-tone="neutral" size="small" @click="previewPdf(item.relabelFileId, item.relabelFileName, 'ITEM', item.id)">预览</el-button></span><label v-else class="inline-file-picker"><FileUp :size="14" />上传<input type="file" accept="application/pdf,.pdf" :disabled="uploading" @change="uploadPdf($event, 'ITEM', item.id)" /></label></td></tr>
<tr v-if="!detail?.items.length"><td class="empty-cell" colspan="8">暂无箱唛换标信息</td></tr>
</tbody>
</table>
@@ -6,6 +6,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import {
changeRelabelOrderStatus,
createRelabelOrder,
getRelabelOrder,
listRelabelOrders,
listRelabelWarehouseOptions,
} from '../api/relabelOrders'
@@ -18,6 +19,7 @@ vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/relabelOrders', () => ({
changeRelabelOrderStatus: vi.fn(),
createRelabelOrder: vi.fn(),
getRelabelOrder: vi.fn(),
listRelabelOrders: vi.fn(),
listRelabelWarehouseOptions: vi.fn(),
}))
@@ -40,6 +42,25 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('RelabelOrdersView', () => {
it('shows every SKU and quantity in the review dialog while keeping one box', async () => {
vi.mocked(listRelabelOrders).mockResolvedValueOnce({ records: [{ id: '1', relabelNo: 'RL-MULTI', skuCodes: 'SKU-A,SKU-B', totalPackageCount: 1, status: 'PENDING_REVIEW' }], page: 1, size: 10, total: 1, pages: 1 })
vi.mocked(getRelabelOrder).mockResolvedValueOnce({ application: { id: '1', relabelNo: 'RL-MULTI', totalPackageCount: 1 }, items: [{ id: '1', skuCode: 'SKU-A', newSkuCode: 'NEW-A', skuItems: [{ skuCode: 'SKU-A', newSkuCode: 'NEW-A', quantity: 10 }, { skuCode: 'SKU-B', newSkuCode: 'NEW-B', quantity: 5 }] }], history: [] })
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('SKU-A,SKU-B')
await wrapper.findAll('button').find(button => button.text().trim() === '审核')!.trigger('click')
await flushPromises()
expect(formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('SKU-A、SKU-B')
expect(formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('NEW-A × 10 件;NEW-B × 5 件')
expect(formItem(wrapper, '箱数').findComponent({ name: 'ElInputNumber' }).props('modelValue')).toBe(1)
})
it('renders product names and labeling requirements supplied by the list projection', async () => {
vi.mocked(listRelabelOrders).mockResolvedValueOnce({ records: [{ id: '1', relabelNo: 'RL-1', productNameZh: '商品甲、商品乙', labelRequirement: '贴标要求111', status: 'PENDING_PROCESSING' }], page: 1, size: 10, total: 1, pages: 1 })
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('商品甲、商品乙')
expect(wrapper.text()).toContain('贴标要求111')
})
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
@@ -48,6 +69,7 @@ describe('RelabelOrdersView', () => {
vi.mocked(listRelabelWarehouseOptions).mockReset()
vi.mocked(createRelabelOrder).mockReset()
vi.mocked(changeRelabelOrderStatus).mockReset()
vi.mocked(getRelabelOrder).mockReset().mockResolvedValue({ application: { relabelNo: 'RL-1' }, items: [], history: [] } as never)
})
it('loads shared receiving warehouse options independently from the selected customer', async () => {
@@ -133,6 +155,27 @@ describe('RelabelOrdersView', () => {
expect(labels).toEqual(['审核通过', '已驳回'])
})
it('loads the full detail before opening audit so item fields are echoed', async () => {
vi.mocked(listRelabelOrders).mockResolvedValueOnce({
records: [{ id: '1', relabelNo: 'RL-1', customerOrderNo: 'CO-1', skuCodes: 'SKU-OLD', totalPackageCount: 2, chargeAmount: 0.01, status: 'PENDING_REVIEW' }],
page: 1, size: 10, total: 1, pages: 1,
})
vi.mocked(getRelabelOrder).mockResolvedValueOnce({
application: { relabelNo: 'RL-1', customerOrderNo: 'CO-1', chargeAmount: 12.5 },
remark: '请按新标签处理',
items: [{ skuCode: 'SKU-NEW', productNameZh: '中文商品', totalPackageCount: 3, shipmentNo: 'SHIP-9', newSkuCode: 'NEW-SKU-9', labelRequirement: '换箱唛和SKU标' }],
history: [],
} as never)
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
await wrapper.findAll('button').find((button) => button.text().trim() === '审核')!.trigger('click')
await flushPromises()
expect(formItem(wrapper, '商品SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('SKU-NEW')
expect(formItem(wrapper, '中文品名').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('中文商品')
expect(formItem(wrapper, '货件号').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('SHIP-9')
expect(formItem(wrapper, '新SKU').findComponent({ name: 'ElInput' }).props('modelValue')).toBe('NEW-SKU-9')
})
it('passes the relabel order number search to the backend', async () => {
const wrapper = mount(RelabelOrdersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -1,6 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onActivated, onMounted, reactive, ref, watch } from 'vue'
import { relabelSkuCodes } from '../utils/relabelSkuDisplay'
import { Plus, PackagePlus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
@@ -14,7 +15,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { changeRelabelOrderStatus, createRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type RelabelWarehouseOptionDto } from '../api/relabelOrders'
import { changeRelabelOrderStatus, createRelabelOrder, getRelabelOrder, listRelabelOrders, listRelabelWarehouseOptions, type RelabelWarehouseOptionDto } from '../api/relabelOrders'
import { overseasWarehousePermissions as permission } from '../permissions'
import { formatOverseasDateTime, relabelBusinessStatusLabel } from '../lastMilePresentation'
import type { RelabelOrderListItem } from '../types'
@@ -55,6 +56,7 @@ const columns: DataTableColumn[] = [
const dialogOpen = ref(false)
const dialogMode = ref<'create' | 'edit'>('create')
const submitting = ref(false)
const detailLoading = ref(false)
const editSourceStatus = ref('')
const form = reactive({ orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined as number | undefined, budgetTotal: undefined as number | undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined as number | undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
const createIdempotencyKey = ref('')
@@ -73,7 +75,7 @@ function openPackingPlan() {
if (customerIds.length > 1) { ElMessage.warning('合箱请选择同一客户的退货库存'); return }
void router.push({ name: 'overseas-return-inventory', query: { packing: '1', ...(customerIds[0] ? { customerId: customerIds[0] } : {}) } })
}
function openForm(mode: 'create' | 'edit', source?: object) {
async function openForm(mode: 'create' | 'edit', source?: object) {
const row = source as RelabelOrderListItem | undefined
dialogMode.value = mode
Object.assign(form, { orderNo: '', customerId: '', relatedOrderNo: '', sku: '', productName: '', cartons: undefined, budgetTotal: undefined, status: 'PENDING_PROCESSING', labelRequirement: '', relabelCost: undefined, remark: '', warehouseId: '', labelDeadline: '', contactName: '', contactPhone: '', shipmentNo: '', newSkuCode: '' })
@@ -89,6 +91,27 @@ function openForm(mode: 'create' | 'edit', source?: object) {
void loadWarehouses()
}
dialogOpen.value = true
if (mode === 'edit' && row?.orderNo) {
detailLoading.value = true
try {
const detail = await getRelabelOrder(String(row.orderNo))
Object.assign(form, {
orderNo: String(detail.application?.relabelNo || row.orderNo || ''),
relatedOrderNo: String(detail.application?.lastlegOrderNo || detail.application?.customerOrderNo || row.relatedOrderNo || ''),
sku: [...new Set(detail.items.flatMap(relabelSkuCodes))].join('、') || row.sku || '',
productName: [...new Set(detail.items.map(line => line.productNameZh?.trim()).filter(Boolean))].join('、') || row.productName || '',
cartons: optionalNumber(detail.application?.totalPackageCount ?? row.cartons),
labelRequirement: [...new Set(detail.items.map(line => line.labelRequirement?.trim()).filter(Boolean))].join(';') || row.labelRequirement || '',
shipmentNo: [...new Set(detail.items.map(line => line.shipmentNo?.trim()).filter(Boolean))].join('、'),
newSkuCode: detail.items.flatMap(line => line.skuItems?.length ? line.skuItems.map(sku => `${sku.newSkuCode} × ${sku.quantity} 件`) : [line.newSkuCode || '']).filter(Boolean).join(';'),
relabelCost: optionalNumber(detail.application?.chargeAmount ?? row.relabelCost),
remark: String(detail.remark || detail.application?.reviewRemark || row.remark || ''),
})
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '换标单详情加载失败,请重试').message)
dialogOpen.value = false
} finally { detailLoading.value = false }
}
}
async function loadCustomers() {
@@ -182,7 +205,7 @@ async function load(model: QueryModel = filters.value) {
const result = await listRelabelOrders({ page: page.value, size: pageSize.value, relabelNo: String(filters.value.relabelNo || '').trim() || undefined, skuCode: String(filters.value.sku || '').trim() || undefined, status: String(filters.value.status || '').trim() || undefined })
rows.value = result.records.map((item) => ({
customerId: '', orderNo: String(item.relabelNo), relatedOrderNo: String(item.lastlegOrderNo || item.customerOrderNo || '—'), sku: String(item.skuCodes || '—'),
productName: '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
productName: item.productNameZh?.trim() || '—', cartons: Number(item.totalPackageCount || 0), budgetTotal: null, relabelCost: Number(item.chargeAmount || 0), walletStatus: walletStatus(item.walletStatus), walletErrorMessage: item.walletErrorMessage || '', labelRequirement: item.labelRequirement?.trim() || '—', fileName: '—', status: relabelStatus(item.status), rawStatus: String(item.status || ''), remark: '',
reviewChannel: item.reviewChannel || undefined,
createdAt: formatOverseasDateTime(item.createdAt),
}))
@@ -193,6 +216,7 @@ async function load(model: QueryModel = filters.value) {
function search(model: QueryModel) { page.value = 1; void load(model) }
function reset(model: QueryModel) { page.value = 1; void load(model) }
onMounted(load)
onActivated(() => { if (!loading.value) void load() })
watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</script>
@@ -212,13 +236,13 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting" @confirm="submitForm">
<AppDialog class="admin-density-dialog" v-model="dialogOpen" :title="dialogMode === 'create' ? '新增换标单' : '审核换标单'" width="min(760px, calc(100vw - 28px))" :confirm-text="dialogMode === 'create' ? '保存换标单' : '提交审核'" :confirm-loading="submitting || detailLoading" :confirm-disabled="detailLoading" @confirm="submitForm">
<el-form class="density-form" label-position="top"><h3>订单信息</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="换标单号"><el-input :model-value="dialogMode === 'create' ? '系统自动生成' : form.orderNo || '—'" readonly /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="关联订单" required><el-input v-model="form.relatedOrderNo" :disabled="dialogMode === 'edit'" placeholder="请输入关联订单号" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" placeholder="请选择 ACTIVE 客户"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="公共海外收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehousesLoading" placeholder="请选择 ACTIVE+RECEIVING 海外仓"><el-option v-for="item in warehouses" :key="item.id" :label="`${item.warehouseName} · ${item.warehouseCode}`" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" :disabled="dialogMode === 'edit'" placeholder="请输入商品SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" :type="dialogMode === 'edit' ? 'textarea' : 'text'" :autosize="{ minRows: 1, maxRows: 4 }" :disabled="dialogMode === 'edit'" placeholder="请输入商品SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="中文品名" required><el-input v-model="form.productName" :disabled="dialogMode === 'edit'" placeholder="请输入中文品名" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="箱数" required><el-input-number v-model="form.cartons" :disabled="dialogMode === 'edit'" :min="1" :precision="0" controls-position="right" placeholder="请输入箱数" /></el-form-item></el-col>
<el-col v-if="dialogMode === 'create'" :span="12"><el-form-item label="截止时间" required><el-date-picker v-model="form.labelDeadline" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择截止时间" /></el-form-item></el-col>
@@ -228,7 +252,7 @@ watch(dialogOpen, (open) => { if (!open) createIdempotencyKey.value = '' })
</el-row><h3>作业要求</h3><el-row :gutter="16">
<el-col :span="12"><el-form-item label="贴标要求" required><el-select v-model="form.labelRequirement" :disabled="dialogMode === 'edit'" placeholder="请选择贴标要求"><el-option v-for="value in ['换箱唛', '换SKU标', '换箱唛和SKU标']" :key="value" :label="value" :value="value" /></el-select></el-form-item></el-col>
<el-col :span="12"><el-form-item label="货件号" required><el-input v-model="form.shipmentNo" :disabled="dialogMode === 'edit'" placeholder="请输入货件号" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :disabled="dialogMode === 'edit'" placeholder="请输入新SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="新SKU" required><el-input v-model="form.newSkuCode" :type="dialogMode === 'edit' ? 'textarea' : 'text'" :autosize="{ minRows: 1, maxRows: 4 }" :disabled="dialogMode === 'edit'" placeholder="请输入新SKU" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="换标费用(CNY)" required><el-input-number v-model="form.relabelCost" :disabled="dialogMode === 'edit'" :min="0.01" :precision="2" controls-position="right" placeholder="请输入换标费用" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-col>
</el-row></el-form>
@@ -5,7 +5,7 @@ import ElementPlus from 'element-plus'
import DataTable from '@/shared/components/DataTable.vue'
import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayoutPreference'
import { getReturnCarton, listReturnCartons, listReturnInventoryMovements, listReturnStock, listReturnPackingSources } from '../api/returnInventory'
import { listReturnPackingPlans } from '../api/relabelOrders'
import { createReturnPackingPlan, listReturnPackingPlans } from '../api/relabelOrders'
import ReturnInventoryManagementView from './ReturnInventoryManagementView.vue'
import { listPlatformTenants } from '@/modules/iam'
const routeQuery = vi.hoisted(() => ({} as Record<string, string>))
@@ -25,7 +25,7 @@ vi.mock('../api/relabelOrders', () => ({
reviewReturnPackingPlan: vi.fn(), cancelReturnPackingPlan: vi.fn(),
}))
const wrappers: VueWrapper[] = []
const stock = { customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', skuCode: 'SKU-TEST', totalQuantity: 8, availableQuantity: 6, cartonQuantity: 1, updatedAt: '2026-09-30' }
const stock = { customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', skuCode: 'SKU-TEST', totalQuantity: 8, availableQuantity: 6, relabelLockedQuantity: 2, cartonQuantity: 1, updatedAt: '2026-09-30' }
const carton = { customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', cartonMark: 'RETURN-BOX-1', status: 'AVAILABLE', skuTypes: 1, totalQuantity: 8, createdAt: '2026-09-30' }
async function mountView(mode: 'inventory' | 'cartons') {
const wrapper = mount(ReturnInventoryManagementView, {
@@ -56,6 +56,35 @@ afterEach(() => {
})
describe('ReturnInventoryManagementView shared list', () => {
it('退货库存展示服务端换标占用列', async () => {
const wrapper = await mountView('inventory')
const columns = wrapper.findComponent(DataTable).props('columns')
expect(columns).toEqual(expect.arrayContaining([expect.objectContaining({ prop: 'relabelLockedQuantity', label: '换标占用(件)' })]))
expect(wrapper.findComponent(DataTable).props('data')[0]).toEqual(expect.objectContaining({ relabelLockedQuantity: 2 }))
})
it('keeps partial quantities bound to each physical parcel and box when they share a SKU', async () => {
vi.mocked(listReturnPackingSources).mockResolvedValue({ records: [
{ ...carton, cartonMark: 'PARCEL-A', sourceUnitType: 'PARCEL', sourceReturnNo: 'RETURN-1', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
{ ...carton, cartonMark: 'BOX-A', sourceUnitType: 'BOX', sourceReturnNo: 'RETURN-1', items: [{ skuCode: 'SKU-A', quantity: 4, availableQuantity: 4 }] },
], page: 1, size: 200, total: 2, pages: 1 })
const wrapper = await mountView('inventory')
await wrapper.findAll('button').find(button => button.text().includes('创建合箱计划'))!.trigger('click'); await flushPromises()
const form = wrapper.findAllComponents({ name: 'ElForm' }).find(form => form.classes().includes('packing-form'))!
const customer = form.getComponent({ name: 'ElSelect' })
customer.vm.$emit('update:modelValue', 'customer-1'); customer.vm.$emit('change', 'customer-1'); await flushPromises()
const mode = form.getComponent({ name: 'ElRadioGroup' })
mode.vm.$emit('update:modelValue', 'SKU'); mode.vm.$emit('change', 'SKU'); await flushPromises()
const rows = form.findAll('.packing-sku-row')
expect(rows).toHaveLength(2)
rows.forEach(row => row.getComponent({ name: 'ElCheckbox' }).vm.$emit('change', true)); await flushPromises()
rows[1]!.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 2); await flushPromises()
wrapper.findAllComponents({ name: 'AppDialog' }).find(dialog => dialog.props('modelValue'))!.vm.$emit('confirm'); await flushPromises()
expect(createReturnPackingPlan).toHaveBeenCalledWith(expect.objectContaining({
mode: 'SKU', sourceCartonMarks: ['PARCEL-A', 'BOX-A'],
sourceDeductions: [{ cartonMark: 'PARCEL-A', skuCode: 'SKU-A', quantity: 1 }, { cartonMark: 'BOX-A', skuCode: 'SKU-A', quantity: 2 }],
boxes: [{ items: [{ skuCode: 'SKU-A', quantity: 3 }] }],
}))
})
it('prefills the selected return orders after loading sources from the tail-order entry', async () => {
Object.assign(routeQuery, { packing: '1', customerId: 'customer-1', overseasWarehouseId: 'warehouse-1', returnNos: 'RETURN-1' })
vi.mocked(listReturnPackingSources).mockResolvedValue({ records: [{ ...carton, sourceReturnNo: 'RETURN-1', sourceUnitType: 'BOX', items: [{ skuCode: 'SKU-TEST', quantity: 8, availableQuantity: 8 }] }], page: 1, size: 200, total: 1, pages: 1 })
@@ -248,3 +277,12 @@ describe('ReturnInventoryManagementView shared list', () => {
expect(document.body.textContent).toContain('SKU-TEST')
})
})
it('箱列表不展示换标单号和换标进度', async () => {
vi.mocked(listReturnCartons).mockResolvedValue({ records: [{ ...carton, relabelStatus: 'PARTIALLY_COMPLETED', relabelledQuantity: 3, relabelReservedQuantity: 2 }], page: 1, size: 20, total: 1, pages: 1 })
const wrapper = await mountView('cartons')
expect(wrapper.text()).not.toContain('换标单号')
expect(wrapper.text()).not.toContain('换标进度')
expect(wrapper.text()).not.toContain('实际完成 3 件')
expect(wrapper.text()).not.toContain('处理中占用 2 件')
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { returnRelabelStatusLabel } from "../utils/returnRelabelProgress";
import "@/styles/adminDensity.css";
import { computed, onMounted, reactive, ref, watch } from "vue";
import dayjs from "dayjs";
@@ -54,7 +55,7 @@ const warehouses = ref<Array<{ id: string; warehouseName: string }>>([]);
const optionsLoading = ref(false),
packingDialogOpen = ref(false),
packingSaving = ref(false);
const packingSkuRows = ref<Array<Pick<ReturnStockRow, 'skuCode' | 'availableQuantity'>>>([]),
const packingSkuRows = ref<Array<{ cartonMark: string; skuCode: string; availableQuantity: number; key: string }>>([]),
packingReturnOptions = ref<
Array<{ returnNo: string; unitTypes: string[]; cartonCount: number }>
>([]);
@@ -176,9 +177,20 @@ function openPlanDetail(row: Record<string, unknown>) {
planDetail.value = planRows.value.find(plan => plan.planNo === row.planNo);
planDetailOpen.value = !!planDetail.value;
}
const planCartonItemRows = computed(() => (planDetail.value?.resultCartons || []).flatMap(carton =>
const planCartonItemRows = computed(() => (planDetail.value?.completionCartons || []).flatMap(carton =>
carton.items.map(item => ({ ...carton, skuCode: item.skuCode, quantity: item.quantity })),
));
const planSourceRows = computed(() => (planDetail.value?.sources || []).flatMap(source => {
const original = source.originalItems?.length ? source.originalItems : source.items;
const existingTarget = planDetail.value?.status === 'COMPLETED' && planDetail.value.completionCartons?.some(carton => carton.cartonMark === source.cartonMark);
return original.map(item => {
const take = existingTarget ? 0 : source.items.find(line => line.skuCode === item.skuCode)?.quantity || 0;
return { cartonMark: source.cartonMark, sourceReturnNo: source.sourceReturnNo, participation: source.participation,
role: existingTarget ? '目标原箱(保留)' : '取出来源',
skuCode: item.skuCode, originalQuantity: source.originalItems?.length ? item.quantity : null, take,
remaining: source.originalItems?.length ? item.quantity - take : null };
});
}));
const plannedItemRows = computed(() => (planDetail.value?.boxes || []).flatMap((box, index) =>
(box.items || []).map(item => ({ boxNo: index + 1, skuCode: item.skuCode, quantity: item.quantity })),
));
@@ -311,7 +323,7 @@ async function loadPackingSources() {
}
function sourceReturnNos(source: ReturnPackingSource) { return (source.sourceReturnNo || '').split(',').filter(Boolean); }
function updatePackingScope() {
const sources = packingSources.value.filter(row => packingForm.mode === 'SKU' ? row.sourceUnitType === 'PARCEL' : row.sourceUnitType !== 'PARCEL');
const sources = packingSources.value;
const options = new Map<string, { returnNo: string; unitTypes: string[]; cartonCount: number }>();
for (const source of sources) for (const returnNo of sourceReturnNos(source)) {
const option = options.get(returnNo) || { returnNo, unitTypes: [], cartonCount: 0 };
@@ -320,13 +332,11 @@ function updatePackingScope() {
}
packingReturnOptions.value = [...options.values()];
const scoped = sources.filter(row => !packingForm.returnNos.length || sourceReturnNos(row).every(no => packingForm.returnNos.includes(no)));
packingBoxRows.value = scoped.filter(row => row.sourceUnitType !== 'PARCEL').map(row => ({ cartonMark: row.cartonMark, returnNo: row.sourceReturnNo || '', unitType: row.sourceUnitType || 'BOX', items: row.items }));
const quantities = new Map<string, number>();
for (const source of scoped.filter(row => row.sourceUnitType === 'PARCEL')) for (const item of source.items)
quantities.set(item.skuCode, (quantities.get(item.skuCode) || 0) + Number(item.availableQuantity ?? item.quantity));
packingSkuRows.value = [...quantities].filter(([, quantity]) => quantity > 0).map(([skuCode, quantity]) => ({
skuCode, availableQuantity: quantity,
}));
packingBoxRows.value = scoped.map(row => ({ cartonMark: row.cartonMark, returnNo: row.sourceReturnNo || '', unitType: row.sourceUnitType || 'BOX', items: row.items }));
packingSkuRows.value = scoped.flatMap(source => source.items.map(item => ({
cartonMark: source.cartonMark, skuCode: item.skuCode,
availableQuantity: Number(item.availableQuantity ?? item.quantity), key: `${source.cartonMark}|${item.skuCode}`,
}))).filter(item => item.availableQuantity > 0);
}
function togglePackingSku(sku: string, checked: boolean) {
if (checked) packingForm.skuQuantities[sku] = 1;
@@ -341,12 +351,14 @@ function changePackingMode() {
updatePackingScope();
}
async function submitPackingPlan() {
const selectedBoxes = packingSources.value.filter(row => packingForm.sourceCartonMarks.includes(row.cartonMark));
const selectedSources = packingForm.mode === 'BOX' ? selectedBoxes : packingSources.value.filter(row => row.sourceUnitType === 'PARCEL' && (!packingForm.returnNos.length || sourceReturnNos(row).every(no => packingForm.returnNos.includes(no))));
const returnNos = [...new Set(selectedSources.flatMap(sourceReturnNos))];
const items = Object.entries(packingForm.skuQuantities)
.filter(([, quantity]) => Number(quantity) > 0)
.map(([skuCode, quantity]) => ({ skuCode, quantity: Number(quantity) }));
const sourceDeductions = packingSkuRows.value.filter(row => Number(packingForm.skuQuantities[row.key]) > 0)
.map(row => ({ cartonMark: row.cartonMark, skuCode: row.skuCode, quantity: Number(packingForm.skuQuantities[row.key]) }));
const selectedMarks = packingForm.mode === 'BOX' ? packingForm.sourceCartonMarks : [...new Set(sourceDeductions.map(row => row.cartonMark))];
const selectedBoxes = packingSources.value.filter(row => selectedMarks.includes(row.cartonMark));
const returnNos = [...new Set(selectedBoxes.flatMap(sourceReturnNos))];
const quantities = new Map<string, number>();
sourceDeductions.forEach(row => quantities.set(row.skuCode, (quantities.get(row.skuCode) || 0) + row.quantity));
const items = [...quantities].map(([skuCode, quantity]) => ({ skuCode, quantity }));
const boxes = [{ items }];
if (
!packingForm.customerId || !packingForm.overseasWarehouseId ||
@@ -368,7 +380,8 @@ async function submitPackingPlan() {
returnNos,
mode: packingForm.mode,
boxes,
sourceCartonMarks: packingForm.mode === 'BOX' ? packingForm.sourceCartonMarks : [],
sourceCartonMarks: selectedMarks,
sourceDeductions: packingForm.mode === 'SKU' ? sourceDeductions : undefined,
remark: packingForm.remark.trim() || undefined,
idempotencyKey:
globalThis.crypto?.randomUUID?.() || `return-packing-${Date.now()}`,
@@ -425,6 +438,7 @@ const stockColumns: DataTableColumn[] = [
prop: "availableQuantity",
align: "right",
},
{ key: "relabelLockedQuantity", label: "换标占用(件)", prop: "relabelLockedQuantity", minWidth: 140, align: "right" },
{ key: "updatedAt", label: "更新时间", prop: "updatedAt", minWidth: 180 },
];
const movementColumns: DataTableColumn[] = [
@@ -487,8 +501,6 @@ const cartonColumns: DataTableColumn[] = [
minWidth: 180,
},
{ key: "carton-6", label: "目的仓", minWidth: 180, slotName: "carton-6" },
{ key: "relabelNo", label: "换标单号", prop: "relabelNo", minWidth: 200 },
{ key: "carton-8", label: "换标状态", width: 110, slotName: "carton-8" },
{ key: "carton-9", label: "状态", width: 100, slotName: "carton-9" },
{ key: "skuTypes", label: "SKU种类", prop: "skuTypes", align: "right" },
{
@@ -667,11 +679,10 @@ const tableColumns = computed(() =>
warehouseName(row.overseasWarehouseId)
}}</template>
<template #movement-4="{ row }">{{
row.movementType === "RECEIPT"
? "退货签收"
: row.movementType === "MERGE"
? "退货合箱"
: "退货出库"
({ RECEIPT: "退货签收", MERGE: "退货合箱", OUTBOUND: "退货出库",
SKU_CONVERSION_OUT: "SKU换标", SKU_CONVERSION_IN: "SKU换标",
SKU_CONVERSION_REVERSE_IN: "SKU换标撤销", SKU_CONVERSION_REVERSE_OUT: "SKU换标撤销"
} as Record<string, string>)[row.movementType] || row.movementType || "—"
}}</template>
<template #plan-customer="{ row }">{{ customerName(row.customerId) }}</template>
<template #plan-return-nos="{ row }">{{
@@ -679,9 +690,9 @@ const tableColumns = computed(() =>
}}</template
><template #plan-mode="{ row }">{{
row.mode === "SKU"
? "按 SKU 合箱"
? "部分包裹/箱合箱"
: row.mode === "BOX"
? "按箱合箱"
? "整包裹/箱合箱"
: row.mode
}}</template
><template #plan-status="{ row }">{{
@@ -732,13 +743,6 @@ const tableColumns = computed(() =>
<template #carton-6="{ row }">{{
row.destinationWarehouseCode || row.destinationWarehouseName || "—"
}}</template>
<template #carton-8="{ row }">{{
row.relabelStatus === "COMPLETED"
? "已完成"
: row.relabelStatus === "BOUND"
? "已绑定"
: row.relabelStatus || "未绑定"
}}</template>
<template #carton-9="{ row }">{{ statusLabel(row.status) }}</template>
<template #carton-12="{ row }">{{
row.length ? `${row.length}×${row.width}×${row.height} cm` : "—"
@@ -767,9 +771,9 @@ const tableColumns = computed(() =>
:value="item.value" /></el-select></el-form-item
><el-form-item label="海外仓"><el-select v-model="packingForm.overseasWarehouseId" style="width:100%" @change="loadPackingSources"><el-option v-for="warehouse in warehouseOptions" :key="warehouse.value" :label="warehouse.label" :value="warehouse.value" /></el-select></el-form-item><el-form-item label="合箱方式"
><el-radio-group v-model="packingForm.mode" class="packing-mode" @change="changePackingMode"
><el-radio-button label="BOX">按箱合箱</el-radio-button
><el-radio-button label="BOX">整包裹/箱合箱</el-radio-button
><el-radio-button label="SKU"
>按 SKU 合箱</el-radio-button
>部分包裹/箱合箱</el-radio-button
></el-radio-group
></el-form-item
><el-form-item label="来源退货单筛选(可选,后台自动追溯)"
@@ -779,7 +783,7 @@ const tableColumns = computed(() =>
placeholder="默认加载该客户全部可用库存;多个退货单号请用英文逗号分隔"
style="width: 100%"
><template #suffix><span v-if="packingForm.returnNos.length" class="return-nos-count">已识别 {{ packingForm.returnNos.length }} 个</span></template></el-input></el-form-item
><el-form-item v-if="packingForm.mode === 'SKU'" label="合箱 SKU"
><el-form-item v-if="packingForm.mode === 'SKU'" label="来源包裹/箱和合箱数量"
><div class="packing-sku-list-wrap">
<el-input
v-model="packingForm.skuSearchInput"
@@ -791,19 +795,19 @@ const tableColumns = computed(() =>
<div class="packing-sku-list-head"><span>选择 / SKU</span><span>可用数量</span><span>合箱数量</span></div>
<div
v-for="item in visiblePackingSkuRows"
:key="item.skuCode"
:key="item.key"
class="packing-sku-row"
>
<el-checkbox
:model-value="
packingForm.skuQuantities[item.skuCode] !== undefined
packingForm.skuQuantities[item.key] !== undefined
"
@change="togglePackingSku(item.skuCode, Boolean($event))"
>{{ item.skuCode }}</el-checkbox
@change="togglePackingSku(item.key, Boolean($event))"
>{{ item.cartonMark }} · {{ item.skuCode }}</el-checkbox
><span>可用 {{ item.availableQuantity }}</span
><el-input-number
v-if="packingForm.skuQuantities[item.skuCode] !== undefined"
v-model="packingForm.skuQuantities[item.skuCode]"
v-if="packingForm.skuQuantities[item.key] !== undefined"
v-model="packingForm.skuQuantities[item.key]"
:min="1"
:max="item.availableQuantity"
:controls="false"
@@ -812,7 +816,7 @@ const tableColumns = computed(() =>
</div>
<el-empty
v-if="!visiblePackingSkuRows.length"
:description="packingForm.skuSearchInput.trim() || packingForm.returnNos.length ? '没有符合查询条件的可用 SKU' : '该客户暂无包裹签收的单 SKU 库存'"
:description="packingForm.skuSearchInput.trim() || packingForm.returnNos.length ? '没有符合查询条件的可用 SKU' : '该客户暂无可用包裹/箱库存'"
/></div></div></el-form-item
><el-form-item v-if="packingForm.mode === 'BOX'" label="箱维度明细"
><div class="packing-sku-list-wrap">
@@ -821,14 +825,14 @@ const tableColumns = computed(() =>
<div class="packing-box-list-head"><span>选择</span><span>箱唛</span><span>来源退货单</span><span>类型</span><span>SKU 明细</span></div>
<div v-for="box in visiblePackingBoxRows" :key="box.cartonMark" class="packing-box-list-row">
<el-checkbox-group v-model="packingForm.sourceCartonMarks"><el-checkbox :value="box.cartonMark" :aria-label="`选择箱唛 ${box.cartonMark}`"><span /></el-checkbox></el-checkbox-group>
<strong>{{ box.cartonMark }}</strong><span>{{ box.returnNo || '—' }}</span><span>{{ box.unitType === 'PALLET' ? '托盘载体' : '箱子' }}</span><span class="packing-box-items"><span v-for="item in box.items" :key="item.skuCode">{{ item.skuCode }} × {{ item.quantity }}</span></span>
<strong>{{ box.cartonMark }}</strong><span>{{ box.returnNo || '—' }}</span><span>{{ box.unitType === 'PALLET' ? '托盘载体' : box.unitType === 'PARCEL' ? '包裹' : '箱子' }}</span><span class="packing-box-items"><span v-for="item in box.items" :key="item.skuCode">{{ item.skuCode }} × {{ item.quantity }}</span></span>
</div>
<el-empty v-if="!visiblePackingBoxRows.length" :description="packingForm.skuSearchInput.trim() ? '没有包含该 SKU 的可用箱子' : '所选客户暂无箱维度库存'" />
</div></div></el-form-item
><el-alert
type="info"
:closable="false"
title="散 SKU 创建新箱,箱规由 PDA 填写;按箱合并由 PDA 扫描已有目标箱。提交即锁定来源库存,取消计划可释放。" /><el-form-item
title="部分包裹/箱必须生成独立新箱并贴标签;整包裹/箱合并可使用已有目标箱。箱规由 PDA 填写。提交即锁定来源库存,取消计划可释放。" /><el-form-item
label="合箱备注(可选)"
><el-input
v-model="packingForm.remark"
@@ -854,7 +858,7 @@ const tableColumns = computed(() =>
customerName(planDetail.customerId)
}}</el-descriptions-item
><el-descriptions-item label="合箱方式">{{
planDetail.mode === "SKU" ? "按 SKU 合箱" : "按箱合箱"
planDetail.mode === "SKU" ? "部分包裹/箱合箱" : "整包裹/箱合箱"
}}</el-descriptions-item
><el-descriptions-item label="状态">{{
planDetail.status === 'COMPLETED' ? '已完成' : planDetail.status === 'READY' ? '待 PDA 作业' : planDetail.status === 'CANCELLED' ? '已取消' : '待审核'
@@ -865,14 +869,37 @@ const tableColumns = computed(() =>
><el-descriptions-item label="备注" :span="2">{{
planDetail.remark || "—"
}}</el-descriptions-item></el-descriptions
><el-table v-if="planDetail.resultCartons?.length" :data="planCartonItemRows" class="detail-items">
>
<h3>计划来源箱明细</h3>
<el-table :data="planSourceRows" class="detail-items" empty-text="暂无来源箱明细">
<el-table-column prop="sourceReturnNo" label="来源退货单" min-width="210" />
<el-table-column prop="cartonMark" label="来源箱唛" min-width="230" />
<el-table-column prop="role" label="实际角色" min-width="140" />
<el-table-column label="参与方式" width="90"><template #default="{ row }">{{ row.participation === 'FULL' ? '整箱' : row.participation === 'PARTIAL' ? '部分' : '未留存' }}</template></el-table-column>
<el-table-column prop="skuCode" label="SKU" min-width="190" />
<el-table-column label="合箱前数量" width="110"><template #default="{ row }">{{ row.originalQuantity ?? '—' }}</template></el-table-column>
<el-table-column prop="take" label="计划取出" width="100" />
<el-table-column label="预计剩余" width="100"><template #default="{ row }">{{ row.remaining ?? '—' }}</template></el-table-column>
</el-table>
<template v-if="planDetail.status === 'COMPLETED'">
<h3>实际合箱流向</h3>
<el-table :data="planDetail.transfers || []" class="detail-items" empty-text="暂无合箱流向">
<el-table-column prop="returnNo" label="来源退货单" min-width="210" />
<el-table-column prop="sourceCartonMark" label="来源箱唛" min-width="230" />
<el-table-column prop="targetCartonMark" label="实际目标箱唛" min-width="230" />
<el-table-column prop="skuCode" label="SKU" min-width="180" />
<el-table-column prop="quantity" label="本次合入" width="100" />
</el-table>
<h3>合箱完成时结果({{ planDetail.completedBy || '操作员未留存' }})</h3>
<p>结果数量为完成时快照;目标原箱内容包含在整箱数量中,本次合入数量见上表。</p>
</template>
<el-table v-if="planDetail.completionCartons?.length" :data="planCartonItemRows" class="detail-items">
<el-table-column prop="cartonMark" label="实际箱唛" min-width="250" />
<el-table-column prop="skuCode" label="SKU" min-width="300" />
<el-table-column prop="quantity" label="数量" width="90" align="right" />
<el-table-column prop="locationCode" label="库位" min-width="100" />
<el-table-column prop="palletCode" label="载体托盘" min-width="120" />
<el-table-column label="箱规 / 重量" min-width="190"><template #default="{ row }">{{ row.length ?? '—' }} × {{ row.width ?? '—' }} × {{ row.height ?? '—' }} cm / {{ row.weight ?? '—' }} kg</template></el-table-column>
<el-table-column label="合箱完成时间" min-width="170"><template #default>{{ planDetail.completedAt ? dayjs(planDetail.completedAt).format('YYYY-MM-DD HH:mm:ss') : '—' }}</template></el-table-column>
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button class="density-action" data-tone="neutral" @click="printLabel(row.cartonMark)">打印箱唛</el-button></template></el-table-column>
<el-table-column label="操作" width="130"><template #default="{ row }"><el-button class="density-action" data-tone="neutral" :disabled="!planDetail.resultCartons?.some(carton => carton.cartonMark === row.cartonMark && carton.status !== 'MERGED' && carton.totalQuantity > 0)" @click="printLabel(row.cartonMark)">打印当前箱唛</el-button></template></el-table-column>
</el-table>
<el-table v-else :data="plannedItemRows" class="detail-items" empty-text="待 PDA 填写箱子明细">
<el-table-column prop="boxNo" label="箱序号" width="90" />
@@ -912,12 +939,8 @@ const tableColumns = computed(() =>
detail.relabelNo || "—"
}}</el-descriptions-item
><el-descriptions-item label="换标状态">{{
detail.relabelStatus === "COMPLETED"
? "已完成"
: detail.relabelStatus === "BOUND"
? "已绑定"
: detail.relabelStatus || "未绑定"
}}</el-descriptions-item
returnRelabelStatusLabel(detail.relabelStatus)
}}|实际完成 {{ detail.relabelledQuantity ?? "—" }} 件|处理中占用 {{ detail.relabelReservedQuantity ?? "—" }} 件</el-descriptions-item
><el-descriptions-item label="状态">{{
statusLabel(detail.status)
}}</el-descriptions-item
@@ -0,0 +1,7 @@
import { expect, it } from 'vitest'
import { relabelSkuCodes } from './relabelSkuDisplay'
it('uses every nested SKU, deduplicates and preserves carton-only SKU lists', () => {
expect(relabelSkuCodes({ skuCode: 'FIRST', skuItems: [{ skuCode: 'FIRST' }, { skuCode: 'SECOND' }, { skuCode: 'FIRST' }] })).toEqual(['FIRST', 'SECOND'])
expect(relabelSkuCodes({ skuCode: 'FIRST,SECOND', skuItems: [] })).toEqual(['FIRST', 'SECOND'])
})
@@ -0,0 +1,9 @@
interface RelabelSkuSource {
skuCode?: string | null
skuItems?: Array<{ skuCode: string }> | null
}
export function relabelSkuCodes(item: RelabelSkuSource): string[] {
const codes = item.skuItems?.length ? item.skuItems.map(line => line.skuCode) : (item.skuCode || '').split(',')
return [...new Set(codes.map(code => code.trim()).filter(Boolean))]
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { returnRelabelStatusLabel } from './returnRelabelProgress'
describe('退货箱换标状态', () => {
it('申请绑定不表示完成,部分和全部完成分别显示', () => {
expect(returnRelabelStatusLabel('BOUND')).toBe('处理中')
expect(returnRelabelStatusLabel('PROCESSING')).toBe('处理中')
expect(returnRelabelStatusLabel('PARTIALLY_COMPLETED')).toBe('部分完成')
expect(returnRelabelStatusLabel('COMPLETED')).toBe('全部完成')
expect(returnRelabelStatusLabel()).toBe('—')
expect(returnRelabelStatusLabel('UNLABELLED')).toBe('未换标')
expect(returnRelabelStatusLabel('UNKNOWN')).toBe('—')
})
})
@@ -0,0 +1,7 @@
export function returnRelabelStatusLabel(status?: string) {
const labels: Record<string, string> = {
UNLABELLED: '未换标', BOUND: '处理中', PROCESSING: '处理中',
PARTIALLY_COMPLETED: '部分完成', COMPLETED: '全部完成',
}
return status ? labels[status] || '—' : '—'
}
@@ -7,7 +7,7 @@ import { ApiResponseError } from '@/shared/api/http'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/platformPortSchedules', () => ({ getPortSchedule: vi.fn(), createPortSchedule: vi.fn(), updatePortSchedule: vi.fn() }))
const container = { id: 'C1', customerId: '0', customerName: '平台订舱', oceanBookingId: 'B1', currentPortScheduleId: 'S1', bookingNo: 'BOOK1', cabinetNo: 'D1', containerNo: 'TEST', vesselName: 'OLD', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', estimatedSailingAt: '2026-10-04T01:00:00', estimatedPortArrivalAt: '2026-10-20T01:00:00' }
const container = { id: 'C1', customerId: '0', customerName: '平台订舱', oceanBookingId: 'B1', currentPortScheduleId: 'S1', billOfLadingNo: 'BL1', cabinetNo: 'D1', containerNo: 'TEST', vesselName: 'OLD', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', estimatedSailingAt: '2026-10-04T01:00:00', estimatedPortArrivalAt: '2026-10-20T01:00:00' }
const original = { id: 'S1', scheduleNo: 'PS1', version: 5, customerId: '0', oceanBookingId: 'B1', containerBookingId: 'C1', sourceType: 'CARRIER', sourceName: '官网', sourceRef: 'REF', vesselName: 'OLD', voyageNo: 'V1', departurePortCode: 'CNSHA', destinationPortCode: 'USLAX', etdAt: container.estimatedSailingAt, etaAt: container.estimatedPortArrivalAt, observedAt: '2026-10-01T00:00:00', expiresAt: '2026-10-10T00:00:00', portCutoffAt: '2026-10-03T00:00:00', remark: '原备注' }
const result = () => ({ portSchedule: { schedule: { ...original }, expired: false }, histories: [] })
interface VM { begin(): Promise<void>; save(): Promise<void>; form: Record<string, string>; error: string; open: boolean; schedule: ReturnType<typeof result> | null }
@@ -8,7 +8,7 @@ import type { PortScheduleData, PortScheduleDetail, PortScheduleSaveRequest } fr
const props = defineProps<{ container: {
id: string; currentPortScheduleId?: string | null; customerId?: string | null; customerName?: string | null
oceanBookingId?: string | null; bookingNo: string; cabinetNo: string; containerNo: string | null
oceanBookingId?: string | null; billOfLadingNo?: string | null; cabinetNo: string; containerNo: string | null
vesselName: string | null; voyageNo: string | null; departurePortCode: string | null; departurePortName?: string | null
destinationPortCode: string | null; destinationPortName?: string | null
estimatedSailingAt: string | null; estimatedPortArrivalAt: string | null
@@ -103,7 +103,7 @@ onBeforeUnmount(() => { generation++ })
</div>
<AppDialog v-model="open" class="admin-density-dialog" :title="container.currentPortScheduleId ? '原柜船期与修改历史' : '登记原柜船期'" :width="container.currentPortScheduleId ? '1000px' : '1120px'" :show-confirm="canWrite" :confirm-disabled="loading || !!prerequisite || (!!container.currentPortScheduleId && !schedule)" :confirm-loading="busy" :confirm-text="pending ? '重试原请求' : '保存船期'" @confirm="save">
<div v-loading="loading" class="schedule-body">
<p>{{ container.cabinetNo || '—' }} · {{ container.containerNo || '—' }} · 订舱 {{ container.bookingNo || '—' }} · {{ container.customerName || '—' }}</p>
<p>{{ container.cabinetNo || '—' }} · {{ container.containerNo || '—' }} · 提单 {{ container.billOfLadingNo || '—' }} · {{ container.customerName || '—' }}</p>
<el-alert v-if="error" :title="error" type="error" :closable="false" />
<p v-if="!container.currentPortScheduleId">请登记本柜的船名、航次和预计时间,并按实际信息填写来源及有效期。</p>
<el-form label-position="top" class="density-form" :disabled="!canWrite || busy || !!pending || loading">
+10
View File
@@ -0,0 +1,10 @@
# 自营经营分析
- 模块入口 `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日窗口,过期禁止冒充原图结果。经营总览不增加货主筛选或面板;销量、库存明细和库存周转支持按客户名称/编码选择货主,名称取 queryAsOf 时可见的完整领星资料批次,按账号+货主编码关联。
- 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% 截图。
@@ -0,0 +1,47 @@
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('forwards the inventory window, inclusive end and pinned page without renaming them', async () => {
const params = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', salesWindowDays: 10 as const, salesWindowEndDate: '2026-10-12', current: 2, size: 20, salesRunId: '11', inventoryRunId: '12', queryAsOf: '2026-10-13T00:00:00Z' }
await analyticsApi.inventory(params)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/self-operated-analytics/inventory-coverage', { params, signal: undefined })
})
it('keeps dashboard business aggregation separate from sync-overview runtime polling', async () => {
const params = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' as const, salesRunId: '11', inventoryRunId: '12', queryAsOf: '2026-10-13T00:00:00Z' }
await analyticsApi.dashboard(params)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/self-operated-analytics/dashboard', { params, signal: undefined })
expect(params).not.toHaveProperty('customerCode')
expect(params).not.toHaveProperty('current')
})
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 })
})
})
@@ -0,0 +1,29 @@
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'
async function get<T>(path: string, params: object, signal?: AbortSignal): Promise<T> {
const response = await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(`${base}/${path}`, { params, signal })
return unwrapApiResponse(response)
}
export const analyticsApi = {
ownerDirectory: (query: { sourceAccount: string; warehouseCode: string } & Partial<BusinessQuery>, signal?: AbortSignal) => get<OwnerOption[]>('owner-directory', query, signal),
turnover: (query: TurnoverQuery, signal?: AbortSignal) => get<TurnoverResponse>('inventory-turnover', query, signal),
dashboard: (query: DashboardQuery, signal?: AbortSignal) => get<Dashboard>('dashboard', query, signal),
owners: (query: OwnerQuery, signal?: AbortSignal) => get<BusinessPage<OwnerOption>>('owners', query, signal),
ownerOverview: (query: OwnerOverviewQuery, signal?: AbortSignal) => get<OwnerOverview | null>('owner-overview', query, signal),
summary: (query: BusinessQuery, signal?: AbortSignal) => get<BusinessPage<SalesSummary>>('sku-sales-summary', query, signal),
daily: (query: BusinessQuery, signal?: AbortSignal) => get<DailyResponse>('sku-daily-sales', query, signal),
hot: (query: BusinessQuery, signal?: AbortSignal) => get<BusinessPage<Metric>>('hot-skus', query, signal),
slow: (query: BusinessQuery, signal?: AbortSignal) => get<BusinessPage<Metric>>('slow-skus', query, signal),
inventory: (query: InventoryQuery, signal?: AbortSignal) => get<BusinessPage<InventoryMetric>>('inventory-coverage', query, signal),
overview: (sourceAccount: string, warehouseCode?: string, signal?: AbortSignal) => get<Overview>('sync-overview', { sourceAccount, warehouseCode }, signal),
tasks: (query: RuntimeQuery, signal?: AbortSignal) => get<RuntimePage<TaskDetail['task']>>('sync-tasks', query, signal),
task: (scope: Scope, taskNo: string, signal?: AbortSignal) => get<TaskDetail>(`sync-tasks/${encodeURIComponent(taskNo)}`, scope, signal),
runs: (query: RuntimeQuery, signal?: AbortSignal) => get<RuntimePage<Run>>('sync-runs', query, signal),
async retry(command: RetryCommand, signal?: AbortSignal): Promise<RetryResult> {
const response = await http.post<ApiEnvelope<RetryResult>, ApiEnvelope<RetryResult>>(`${base}/sync-tasks`, command, { signal })
return unwrapApiResponse(response)
},
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest'
import type { Task } from './types'
import { taskStateText, taskProgressText, taskCompletionText } from './collectionPresentation'
const base: Task = { taskNo: 'T-A', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'RUNNING', applyState: 'NOT_STARTED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: { phase: 'DETAIL', completed: 792, total: 2014, totalKnown: true, unit: 'ORDER' }, queryFrom: null, queryUntil: null }
describe('plain collection task presentation', () => {
it('requires the matching yielded attempt before describing RETRY_WAIT as normal continuation', () => {
const latestAttempt: NonNullable<Task['latestAttempt']> = { attemptNo: 1, status: 'YIELDED', phase: 'DETAIL', startedAt: null, finishedAt: null, progressUpdatedAt: null, heartbeatAt: null, executionHealth: null, elapsedSeconds: 40, errorCode: null, failureSummary: null }
const yielded = { ...base, taskStatus: 'RETRY_WAIT', latestAttempt }
expect(taskStateText(yielded)).toBe('本批已完成,等待续采')
for (const task of [
{ ...yielded, latestAttemptNo: 2 }, { ...yielded, attemptCount: 2 },
{ ...yielded, latestAttempt: { ...latestAttempt, status: 'FAILED' } },
{ ...yielded, latestAttempt: { ...latestAttempt, errorCode: 'TIMEOUT' } },
{ ...yielded, latestAttempt: undefined },
]) expect(taskStateText(task)).toBe('等待重试')
})
it('distinguishes data preparation, waiting, stopped and running', () => {
expect(taskStateText(base)).toBe('正在执行')
expect(taskStateText({ ...base, taskStatus: 'WAITING_SOURCE' })).toBe('等待数据准备')
expect(taskStateText({ ...base, taskStatus: 'READY' })).toBe('等待执行')
expect(taskStateText({ ...base, taskStatus: 'YIELDED' })).toBe('本批已完成,等待续采')
expect(taskStateText({ ...base, taskStatus: 'DEAD' })).toBe('已停止,需处理')
expect(taskProgressText({ ...base, taskStatus: 'WAITING_SOURCE', attemptCount: 0 })).toBe('尚未开始,等待数据准备')
})
it('shows a stage percentage without presenting it as overall completion', () => {
expect(taskProgressText(base)).toBe('处理出库订单,已处理 792 / 2014单(39.3%,当前阶段)')
expect(taskCompletionText(base)).toBe('完成时间暂无法预计')
expect(taskCompletionText({ ...base, taskStatus: 'SUCCEEDED' })).toBe('执行完成,结果尚未发布')
expect(taskCompletionText({ ...base, resultPublicationState: 'PUBLISHED' })).toBe('结果已可用')
})
it('does not create percentages for unknown totals, zero denominators or repeated-scan counts above the total', () => {
for (const progress of [{ ...base.progress, totalKnown: false }, { ...base.progress, total: 0 }, { ...base.progress, completed: 2015 }]) expect(taskProgressText({ ...base, progress })).not.toContain('%')
expect(taskProgressText({ ...base, progress: { ...base.progress, completed: 0 } })).toContain('0 / 2014单(0%,当前阶段)')
})
})
@@ -0,0 +1,38 @@
import type { Task } from './types'
import { label, phaseText, quantityText, unitText } from './state'
export function isNormalContinuation(task: Task | null | undefined) {
const attempt = task?.latestAttempt
return task?.taskStatus === 'RETRY_WAIT' && attempt?.status === 'YIELDED'
&& task.latestAttemptNo != null && task.latestAttemptNo > 0
&& attempt.attemptNo === task.latestAttemptNo && task.attemptCount === task.latestAttemptNo
&& !attempt.errorCode && !attempt.failureSummary
}
export function taskStateText(task: Task | null | undefined) {
if (!task) return '尚无任务'
if (isNormalContinuation(task)) return '本批已完成,等待续采'
const states: Record<string, string> = { WAITING_SOURCE: '等待数据准备', WAITING_UPSTREAM: '等待数据准备', READY: '等待执行', QUEUED: '等待执行', WAITING: '等待执行', RUNNING: '正在执行', RETRY_WAIT: '等待重试', YIELDED: '本批已完成,等待续采', DEAD: '已停止,需处理', SUCCEEDED: '执行完成', FAILED: '执行失败' }
return states[task.taskStatus] ?? label(task.taskStatus)
}
export function taskProgressText(task: Task | null | undefined) {
if (!task) return '尚无任务'
if (['WAITING_SOURCE', 'WAITING_UPSTREAM'].includes(task.taskStatus)) return '尚未开始,等待数据准备'
if (task.attemptCount === 0 && ['READY', 'QUEUED', 'WAITING'].includes(task.taskStatus)) return '尚未开始,等待执行'
const progress = task.progress
if (!progress?.phase) return task.taskStatus === 'SUCCEEDED' ? '本任务执行完成' : '当前进度尚未提供'
const phase = phaseText(progress.phase)
if (progress.completed == null || !progress.unit || progress.unit === 'NONE') return phase
const amount = `${quantityText(progress.completed)}${unitText(progress.unit)}`
if (!progress.totalKnown || progress.total == null) return `${phase},已处理 ${amount},总量尚未确定`
const total = Number(progress.total), completed = Number(progress.completed)
const percent = Number.isFinite(total) && Number.isFinite(completed) && total > 0 && completed >= 0 && completed <= total ? `(${Math.floor(completed / total * 1000) / 10}%,当前阶段)` : ''
return `${phase},已处理 ${quantityText(progress.completed)} / ${quantityText(progress.total)}${unitText(progress.unit)}${percent}`
}
export function taskCompletionText(task: Task | null | undefined) {
if (!task) return '—'
if (task.resultPublicationState === 'PUBLISHED' || task.resultPublicationState === 'REUSED') return '结果已可用'
if (task.taskStatus === 'SUCCEEDED') return '执行完成,结果尚未发布'
if (['DEAD', 'FAILED', 'ABORTED'].includes(task.taskStatus)) return '已停止,需先处理异常'
return '完成时间暂无法预计'
}
@@ -0,0 +1,14 @@
<script setup lang="ts">
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import type { EChartsOption, ECElementEvent } from 'echarts'
import { BarChart, GaugeChart, LineChart } from 'echarts/charts'
import { AriaComponent, DataZoomComponent, GridComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
use([LineChart, BarChart, GaugeChart, GridComponent, TooltipComponent, DataZoomComponent, AriaComponent, CanvasRenderer])
defineProps<{ option: EChartsOption; mergeUpdates?: boolean }>()
defineEmits<{ select: [event: ECElementEvent] }>()
</script>
<template>
<VChart :option="option" :init-options="{ renderer: 'canvas' }" :update-options="{ notMerge: !mergeUpdates }" autoresize @click="$emit('select', $event)" />
</template>
@@ -0,0 +1,430 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { Info } from '@lucide/vue'
import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
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'
import { turnoverNumber as dailyAverageText } from '../turnoverPresentation'
const emit = defineEmits<{ warehouseSelected: [code: string] }>()
const props = defineProps<{ mode: 'sales' | 'inventory' }>()
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
const routeProblem = ref('')
const drilldown = ref<DashboardDrilldown | null>(null)
const origin = ref<DashboardDrilldown | null>(null)
function readDrilldown() {
routeProblem.value = ''
try { drilldown.value = parseDashboardDrilldown(route.query, props.mode); origin.value = drilldown.value }
catch (cause) { drilldown.value = null; origin.value = null; routeProblem.value = cause instanceof Error ? cause.message : '经营总览下钻条件无效' }
}
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 { 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<QueryModel>(initialFilters())
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<Overview | null>(null)
const context = ref<QueryContext | null>(drilldown.value?.context ?? null)
const rows = ref<(SalesSummary | Metric | InventoryMetric)[]>([])
const daily = ref<DailyResponse | null>(null)
const trendOpen = ref(false)
const trendLoading = ref(false)
const trendError = ref('')
const trendErrorDetails = ref<ApiErrorDetails | null>(null)
const attention = computed(() => (filters.value.attention || 'ALL') as InventoryAttention)
const sortBy = computed(() => (filters.value.sortBy || 'AVAILABLE_DESC') as InventorySort)
const thresholdInput = computed(() => String(filters.value.threshold ?? ''))
const appliedInventory = ref<{ attention: InventoryAttention; sortBy: InventorySort; coverageDaysThreshold?: number }>({ attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' })
const displayedInventorySort = ref<InventorySort | null>(null)
const loading = ref(false)
const error = ref('')
const errorDetails = ref<ApiErrorDetails | null>(null)
const runtimeError = ref('')
const newData = ref(false)
const newDay = ref(false)
const current = ref(1)
let changingSortPage = false
async function setSortPage(page: number) {
changingSortPage = true
current.value = page
await nextTick()
changingSortPage = false
}
const size = ref(20)
const total = ref(0)
type SalesPreset = RangePreset | 'LAST_3_DAYS' | 'LAST_7_DAYS' | 'LAST_15_DAYS' | 'LAST_30_DAYS'
const preset = computed(() => (filters.value.rangePreset || 'LAST_30_DAYS') as SalesPreset)
const customRange = computed(() => Array.isArray(filters.value.customRange) ? filters.value.customRange : [])
const customStart = computed(() => String(customRange.value[0] ?? ''))
const customEnd = computed(() => String(customRange.value[1] ?? ''))
type SalesRange = { rangePreset: RangePreset; startDate?: string; endDate?: string }
function draftSalesRange(): SalesRange {
try { return overviewRange(preset.value, origin.value?.context.anchorDate ?? beijingToday(), customStart.value, customEnd.value) }
catch { return { rangePreset: 'CUSTOM', startDate: customStart.value, endDate: customEnd.value } }
}
const appliedSalesRange = ref<SalesRange | null>(draftSalesRange())
const salesRangePending = computed(() => props.mode === 'sales' && tab.value === 'summary' && JSON.stringify(draftSalesRange()) !== JSON.stringify(appliedSalesRange.value))
const tab = computed(() => (filters.value.analysis || 'summary') as 'summary' | 'hot' | 'slow')
const windowDays = computed(() => Number(filters.value.windowDays || 30) as 7 | 30)
const sourceAccount = 'lingxing-primary'
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
let selectedWarehouse = warehouseCode.value
function defaultWarehouse() { return overview.value?.warehouses.find(row => row.warehouseCode)?.warehouseCode ?? '' }
watch(warehouseCode, code => { if (code) { selectedWarehouse = code; emit('warehouseSelected', code) } }, { immediate: true })
const fields = computed<QueryField[]>(() => {
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(
{ key: 'attention', type: 'select', label: '库存关注', width: 200, props: { 'aria-label': '库存关注' }, options: [{ value: 'ALL', label: '全部库存关注' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT_THRESHOLD', label: '可售天数超过阈值' }, { value: 'FROZEN_STOCK', label: '冻结库存' }, { value: 'DEFECTIVE_STOCK', label: '次品库存' }, { value: 'UNASSESSABLE', label: '数据不足/待观察' }] },
{ key: 'threshold', type: 'input', label: '可售天数阈值', width: 180, placeholder: '天数阈值(天)', props: { 'aria-label': '可售天数阈值', inputmode: 'numeric' } },
{ key: 'sortBy', type: 'select', label: '库存排序', width: 210, options: inventorySortOptions },
)
else {
result.push({ key: 'analysis', type: 'select', label: '销量类别', width: 170, props: { clearable: false }, options: [{ value: 'summary', label: 'SKU销量' }, { value: 'hot', label: '热销Top100' }, { value: 'slow', label: '滞销Top100' }] })
}
result.splice(2, 0, { key: 'rangePreset', type: 'select', label: '统计时间范围', width: 180, props: { clearable: false }, options: overviewRangeOptions })
if (preset.value === 'CUSTOM') result.splice(3, 0, { key: 'customRange', type: 'daterange', label: '日期范围', width: 300, props: { disabledDate: (date: Date) => date.toLocaleDateString('en-CA') >= (origin.value?.context.anchorDate ?? beijingToday()) } })
return drilldown.value ? result.map(field => field.key === 'warehouseCode' ? field : { ...field, props: { ...field.props, disabled: true } }) : result
})
const salesSort = ref<'QUANTITY_ASC' | 'QUANTITY_DESC'>('QUANTITY_DESC')
async function sortSalesQuantity() {
if (loading.value || !summaryMode.value) return
const previous = salesSort.value, previousPage = current.value
salesSort.value = previous === 'QUANTITY_DESC' ? 'QUANTITY_ASC' : 'QUANTITY_DESC'
await setSortPage(1)
if (await load() === false) { salesSort.value = previous; await setSortPage(previousPage) }
}
const summaryMode = computed(() => props.mode === 'sales' && tab.value === 'summary')
const columns = computed<DataTableColumn[]>(() => [
...(summaryMode.value ? [{ key: 'actions', label: '操作', width: 110, slotName: 'actions', fixed: 'left' }] : []),
{ prop: 'sku', label: 'SKU', minWidth: 170, ...(props.mode === 'inventory' ? { slotName: 'sku' } : {}) },
{ prop: 'productName', label: '商品名称', minWidth: 190 },
{ key: 'owner', label: '所属货主', minWidth: 140, slotName: 'owner' },
{ key: 'unit', label: '单位', width: 70, slotName: 'unit' },
...(props.mode === 'inventory' ? [
{ key: 'physicalQuantity', label: '全品质实物', width: 110, slotName: 'physicalQuantity' },
{ key: 'availableQuantity', label: '正品可售', width: 110, slotName: 'availableQuantity' },
{ key: 'frozenQuantity', label: '全品质冻结', width: 110, slotName: 'frozenQuantity' },
{ key: 'defectivePhysicalQuantity', label: '次品实物', width: 110, slotName: 'defectivePhysicalQuantity' },
{ key: 'transitQuantity', label: '在途', width: 110, slotName: 'transitQuantity' },
{ key: 'windowOutboundQuantity', label: `${displayedWindowDays.value}天窗口出库`, width: 120, slotName: 'windowOutboundQuantity' },
{ key: 'averageDailyOutboundQuantity', label: `${displayedWindowDays.value}天日均出库`, width: 140, slotName: 'averageDailyOutboundQuantity' },
{ key: 'inventoryCoverageDays', label: '可售天数', width: 110, slotName: 'inventoryCoverageDays' },
{ key: 'lastOutbound', label: '最近已观测出库', minWidth: 220, slotName: 'lastOutbound' },
{ key: 'attention', label: '关注原因', minWidth: 160, slotName: 'attention' },
] : [{ key: 'quantity', label: summaryMode.value || tab.value === 'hot' ? '区间出库量 / 已采集量' : '完整窗口出库量', width: 200, slotName: 'quantity', ...(summaryMode.value ? { headerSlotName: 'salesSortHeader' } : {}) }]),
{ key: 'status', label: '数据状态', minWidth: 130, slotName: 'status' },
{ key: 'reason', label: '说明', minWidth: 220, slotName: 'reason' },
].map(column => ['quantity', ...Object.keys(inventorySortColumns)].includes(String(column.key)) ? {
...column, align: 'right',
...(props.mode === 'inventory' && inventorySortColumns[String(column.key)] ? { prop: String(column.key), headerSlotName: 'inventorySortHeader', width: Math.max(Number(column.width ?? 0), 140) } : {}),
} : column))
function inventorySortDirection(prop: string) {
const metric = inventorySortColumns[prop]
return displayedInventorySort.value === `${metric}_ASC` ? 'ASC' : displayedInventorySort.value === `${metric}_DESC` ? 'DESC' : undefined
}
async function sortInventoryColumn(prop: string) {
const metric = inventorySortColumns[prop]
if (!metric || props.mode !== 'inventory' || loading.value) return
const previousSort = appliedInventory.value.sortBy, previousPage = current.value
const nextSort: InventorySort = `${metric}_${inventorySortDirection(prop) === 'DESC' ? 'ASC' : 'DESC'}`
appliedInventory.value = { ...appliedInventory.value, sortBy: nextSort }
filters.value = { ...filters.value, sortBy: nextSort }
await setSortPage(1)
if (await load() === false) {
appliedInventory.value = { ...appliedInventory.value, sortBy: previousSort }
filters.value = { ...filters.value, sortBy: previousSort }
await setSortPage(previousPage)
}
}
function inventorySalesGap(row: InventoryMetric) {
const status = row.quantityStatuses?.windowOutboundQuantity
if (status === 'UNIT_UNKNOWN_OR_CONFLICT' || row.status === 'UNIT_UNKNOWN_OR_CONFLICT') return '数量单位待核验'
if (status === 'SALES_UNTRUSTED' || context.value?.rebuildRequired) return '新采集数据待发布'
if (status === 'COVERAGE_INSUFFICIENT' || row.salesCoverage === 'COVERAGE_INSUFFICIENT') return '销量窗口未采齐'
return '暂无完整窗口数据'
}
function inventoryReasonText(row: InventoryMetric) {
const reasons = row.reasonCodes?.filter(reason => reason !== 'OBSERVATION_INSUFFICIENT') ?? []
const observationNote = row.observationEligible === false ? '商品历史观察不足(仅提示,不影响完整窗口统计)' : ''
return [reasons.length ? reasonsText(reasons) : '', observationNote].filter(Boolean).join('、') || '—'
}
function inventoryMissingReason(row: InventoryMetric, coverage = false) {
if (coverage && row.status === 'NO_OUTBOUND_IN_WINDOW') return '所选时段无出库'
if (coverage && row.status?.startsWith('INVENTORY_')) return '当前可售库存待核验'
return inventorySalesGap(row)
}
const dataFence = new RequestFence()
const dailyFence = new RequestFence()
const runtimeFence = new RequestFence()
function asSummary(row: unknown) { return row as SalesSummary }
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 || 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') {
Object.assign(result, appliedSalesRange.value)
}
if (props.mode === 'sales' && tab.value === 'slow') Object.assign(result, appliedSalesRange.value)
if (props.mode === 'inventory') {
Object.assign(result, appliedInventory.value, drilldown.value?.inventory)
result.sortBy = appliedInventory.value.sortBy
if (drilldown.value) Object.assign(result, { rangePreset: 'CUSTOM', startDate: drilldown.value.startDate, endDate: drilldown.value.endDate })
else Object.assign(result, appliedWindow.value)
}
return result
}
async function load(reset = false, keepContext = false) {
if (!permitted.value || !warehouseCode.value || routeProblem.value) return
if (origin.value && context.value && expired(context.value)) { error.value = '原图查询已过期,请返回经营总览整体刷新'; return false }
if (drilldown.value && props.mode === 'inventory' && context.value && inventoryExpired(context.value)) { error.value = '原图库存已过期,请刷新总览'; return false }
if (summaryMode.value && !appliedSalesRange.value) return
if (reset) { if (!origin.value && !keepContext) context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
trendOpen.value = false
dailyFence.cancel()
const request = dataFence.begin()
loading.value = true
error.value = ''
errorDetails.value = null
try {
const params = query()
const result = props.mode === 'inventory' ? await analyticsApi.inventory(params, request.signal)
: tab.value === 'hot' ? await analyticsApi.hot(params, request.signal)
: tab.value === 'slow' ? await analyticsApi.slow(params, request.signal) : await analyticsApi.summary(params, request.signal)
if (!request.current()) return
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'
return true
} catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '查询失败,请重新查询'); error.value = errorDetails.value.message; return false } }
finally { if (request.current()) loading.value = false }
}
async function showTrend(row: SalesSummary | Metric | InventoryMetric) {
trendOpen.value = true
trendLoading.value = true
daily.value = null
trendError.value = ''
trendErrorDetails.value = null
const request = dailyFence.begin()
try {
const params: BusinessQuery = { sourceAccount, warehouseCode: warehouseCode.value, customerCode: row.customerCode, sku: row.sku, current: 1, size: 20, ...contextParams(context.value), rangePreset: props.mode === 'inventory' ? 'CUSTOM' : appliedSalesRange.value?.rangePreset }
if (props.mode === 'inventory' && context.value?.rangeStartDate && context.value.rangeEndDateExclusive) {
params.startDate = context.value.rangeStartDate
params.endDate = new Date(Date.parse(`${context.value.rangeEndDateExclusive}T00:00:00Z`) - 86400000).toISOString().slice(0, 10)
} else if (params.rangePreset === 'CUSTOM') { params.startDate = appliedSalesRange.value?.startDate; params.endDate = appliedSalesRange.value?.endDate }
const result = await analyticsApi.daily(params, request.signal)
if (!request.current()) return
if (!context.value || !sameContext(context.value, result.context)) throw new Error('趋势版本与列表不一致,请重新查询')
daily.value = result
} catch (cause) { if (request.current()) { trendErrorDetails.value = getApiErrorDetails(cause, 'SKU趋势读取失败'); trendError.value = trendErrorDetails.value.message } }
finally { if (request.current()) trendLoading.value = false }
}
async function readRuntime() {
if (!permitted.value) return false
const request = runtimeFence.begin()
try {
const result = await analyticsApi.overview(sourceAccount, undefined, request.signal)
if (!request.current()) return false
overview.value = result
if (!warehouseCode.value && !routeProblem.value) filters.value.warehouseCode = defaultWarehouse()
runtimeError.value = ''
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date())
newDay.value = Boolean(context.value && context.value.anchorDate !== today)
const warehouse = result.warehouses.filter(row => row.warehouseCode === warehouseCode.value)
if (context.value && warehouse.some(row => row.lastPublishedAt && Date.parse(row.lastPublishedAt) > Date.parse(context.value!.queryAsOf))) newData.value = true
return hasActiveTask(result.warehouses.flatMap(row => [row.currentTask, row.sharedUpstream].filter((task): task is NonNullable<typeof task> => Boolean(task))))
} catch (cause) { if (request.current()) runtimeError.value = getApiErrorMessage(cause, '状态更新失败,保留上次观测结果'); return false }
}
const poller = new RuntimePoller(readRuntime, () => runtimeFence.cancel())
watch(trendOpen, open => { if (!open) { dailyFence.cancel(); daily.value = null; trendLoading.value = false; trendError.value = ''; trendErrorDetails.value = null } })
function visibility() { poller.setVisible(!document.hidden) }
function clearSalesResults() { dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false; daily.value = null; rows.value = []; total.value = 0; loading.value = false; error.value = ''; errorDetails.value = null }
function applySalesRange(anchor?: string) {
try { appliedSalesRange.value = overviewRange(preset.value, anchor ?? beijingToday(), customStart.value, customEnd.value); return true }
catch (cause) { clearSalesResults(); appliedSalesRange.value = null; error.value = (cause as Error).message; return false }
}
function search() {
if (!applySalesRange(origin.value?.context.anchorDate ?? beijingToday())) return
let nextWindow = appliedWindow.value
if (props.mode === 'inventory' && !drilldown.value) {
const range = appliedSalesRange.value!
nextWindow = { salesWindowDays: Math.round((Date.parse(range.endDate!) - Date.parse(range.startDate!)) / 86400000) + 1, salesWindowEndDate: range.endDate! }
}
const rawThreshold = thresholdInput.value.trim()
if (props.mode === 'inventory' && ((rawThreshold && (!/^\d+$/.test(rawThreshold) || !Number.isSafeInteger(Number(rawThreshold)) || Number(rawThreshold) <= 0)) || (attention.value === 'COVERAGE_GT_THRESHOLD' && !rawThreshold))) {
error.value = '可售天数阈值须为正整数;选择超过阈值关注时必须填写'
return
}
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(), customerCode: String(filters.value.customerCode || '') }
current.value = 1
void load(true, keepContext)
}
async function reset() {
salesSort.value = 'QUANTITY_DESC'
applyingRoute = true
drilldown.value = null; origin.value = null
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() {
const saved = origin.value
void router.push({ path: '/self-operated/overview', query: saved ? { warehouseCode: warehouseCode.value, context: JSON.stringify(saved.context), overviewReturn: JSON.stringify(saved.returnTo) } : {} })
}
async function clearDrilldown() {
drilldown.value = null
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: '', 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 } }) }
let applyingRoute = false
watch(warehouseCode, () => { if (applyingRoute) return; drilldown.value = null; origin.value = null; trendOpen.value = false; dataFence.cancel(); dailyFence.cancel(); rows.value = []; total.value = 0; context.value = null; daily.value = null; error.value = ''; current.value = 1; void load(true) })
watch([customStart, customEnd], () => { if (applyingRoute) return; error.value = ''; errorDetails.value = null })
watch([preset, tab], () => {
if (applyingRoute) return
if (props.mode === 'inventory') { search(); return }
clearSalesResults()
if (!applySalesRange(origin.value?.context.anchorDate ?? context.value?.anchorDate ?? beijingToday())) return
current.value = 1; void load()
})
watch([current, size], () => { if (!applyingRoute && !changingSortPage) void load() })
onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() })
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 ?? ''), 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()
})
onActivated(() => { visibility(); poller.start() })
onDeactivated(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false })
onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); document.removeEventListener('visibilitychange', visibility) })
</script>
<template>
<AdminPage class="admin-density">
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
<ListPageLayout v-else>
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" :empty-text="!warehouseCode ? '请选择仓库后查询SKU' : error ? '查询失败,请重试' : '暂无符合条件的SKU数据'">
<template v-if="$slots['view-tabs']" #toolbar-start><slot name="view-tabs" /></template>
<template #filters><p v-if="ownerDirectory.error.value" role="alert">{{ ownerDirectory.error.value }}</p><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营SKU筛选" :preference-key="`self-operated-${mode}-query`" :loading="loading" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices>
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-window-summary">销量统计(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }},共 {{ displayedWindowDays }} 天。{{ origin ? '沿用经营总览所选区间;未完整的日期不推算日均。' : '不含今天;今天的出库不计入日均及可售天数。' }}库存使用最新已查询的采集值。</p>
<p v-if="routeProblem" role="alert">{{ routeProblem }};已停止查询,不能按全仓条件继续。<el-button @click="returnOverview">返回经营总览</el-button></p>
<div v-if="origin" class="density-actions"><span>{{ drilldown?.summary ?? '来自经营总览 · 来源筛选已清除,保留原有效版本' }}</span><el-button v-if="drilldown" @click="clearDrilldown">清除来源条件</el-button><el-button @click="returnOverview">返回经营总览</el-button></div>
<p v-if="runtimeError" class="analytics-note" role="status">{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}</p>
<LoadErrorState v-if="error" title="查询未完成" :message="error" :loading="loading" compact @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<p v-if="salesRangePending" class="analytics-note" role="status">日期范围尚未应用,请选择完整起止日期后点击查询。<template v-if="rows.length">当前列表及每日趋势仍使用上次已应用的统计区间。</template></p>
<p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据可用。' }}<template v-if="origin">请返回经营总览刷新原图。</template><template v-else>当前列表仍使用上次查询版本。<el-button :disabled="loading" @click="search">更新数据</el-button></template></p>
</template>
<template #toolbar-actions><el-button :disabled="!warehouseCode" @click="link('/self-operated/collection')">查看采集状态</el-button><el-button :disabled="!warehouseCode" @click="link(mode === 'sales' ? '/self-operated/inventory' : '/self-operated/sales')">{{ mode === 'sales' ? '查看库存' : '查看销量' }}</el-button>
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间" teleported>
<template #reference><el-button aria-label="统计口径与采集时间" title="统计口径与采集时间"><Info :size="16" aria-hidden="true" /></el-button></template>
<div class="analytics-information admin-density">
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-observation">本仓库存采集时间(北京时间):{{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.inventoryObservedFrom && context.inventoryObservedUntil ? '当前列表来自本仓一轮完整分页观测,各页在该时段内采集,不代表同一瞬间的库存。' : '尚无完整库存观测时段,缺失时间不以发布时间代替。' }}</p>
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-window">销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。</p>
<div class="analytics-rules">
<p>{{ summaryMode ? '最近3/7/15/30天均截至昨日(北京时间);自定义按所选起止日期查询。完整区间显示出库量,未完整区间显示已采集量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
<p v-if="mode === 'inventory'">预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口无出库时不推算有限天数。实物含全品质,可售仅正品;冻结、次品不重复计入实物,在途不计当前占仓。可售天数阈值只用于本次查询。</p>
<p v-if="context">查询时间:{{ beijingTime(context.queryAsOf) }};有效至 {{ beijingTime(context.queryExpiresAt) }}。销量覆盖截至 {{ context.salesCoveredUntil ? beijingTime(context.salesCoveredUntil) : '尚无可用数据' }}。{{ context.rebuildRequired ? '数据修订待重建。' : '' }}</p>
<p v-if="context">实际计算区间:{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ?? '—' }}(截止日不含)<template v-if="context.calculationWindowDays != null">;{{ context.calculationWindowDays }} 天</template>。</p>
<p v-for="state in (overview?.warehouses ?? []).filter(item => item.warehouseCode === warehouseCode)" :key="state.dataFamily">{{ state.dataFamily === 'INVENTORY' ? '库存' : '销量' }}数据:{{ label(state.dataFreshness) }};最近发布时间 {{ beijingTime(state.lastPublishedAt) }}。</p>
</div>
</div>
</el-popover>
</template>
<template #salesSortHeader="{ column }">
<button type="button" class="inventory-sort" data-column="salesQuantity" :disabled="loading" :aria-label="`${column.label}:当前${salesSort === 'QUANTITY_ASC' ? '升序' : '降序'},点击切换排序`" @click.stop="sortSalesQuantity">
{{ column.label }}<span class="inventory-sort-arrows" aria-hidden="true"><span :class="{ active: salesSort === 'QUANTITY_ASC' }">▲</span><span :class="{ active: salesSort === 'QUANTITY_DESC' }">▼</span></span>
</button>
</template>
<template #inventorySortHeader="{ column }">
<button type="button" class="inventory-sort" :data-column="column.property" :disabled="loading" :aria-label="`${column.label}:${inventorySortDirection(column.property) === 'ASC' ? '当前升序,' : inventorySortDirection(column.property) === 'DESC' ? '当前降序,' : ''}点击${inventorySortDirection(column.property) === 'DESC' ? '升序' : '降序'}排列`" @click.stop="sortInventoryColumn(column.property)">
{{ column.label }}<span class="inventory-sort-arrows" aria-hidden="true"><span :class="{ active: inventorySortDirection(column.property) === 'ASC' }">▲</span><span :class="{ active: inventorySortDirection(column.property) === 'DESC' }">▼</span></span>
</button>
</template>
<template #actions="{ row }"><el-button class="density-action" @click="showTrend(asSummary(row))">每日趋势</el-button></template>
<template #sku="{ row }"><el-button class="density-action" :aria-label="`查看 ${asMetric(row).customerCode} ${asMetric(row).sku} 每日趋势`" @click="showTrend(asMetric(row))">{{ asMetric(row).sku }}</el-button></template>
<template #owner="{ row }"><span>{{ asMetric(row).customerName || asMetric(row).customerCode || '—' }}</span><small v-if="asMetric(row).customerName && asMetric(row).customerCode" class="analytics-owner-code">{{ asMetric(row).customerCode }}</small></template>
<template #unit="{ row }">{{ asMetric(row).quantityUnit === 'PCS' ? '件' : asMetric(row).quantityUnit || '—' }}</template>
<template #lastOutbound="{ row }">{{ asMetric(row).lastObservedOutboundDate ?? '已采集历史内未见出库' }}<br />历史覆盖起点:{{ asMetric(row).observedHistoryStartDate ?? '—' }}</template>
<template #attention="{ row }">{{ reasonsText(asMetric(row).attentionReasons) }}</template>
<template #quantity="{ row }">{{ quantityText(summaryMode ? shownQuantity(asSummary(row)) : asMetric(row).outboundQuantity) }} {{ summaryMode && shownQuantity(asSummary(row)) == null ? '(暂无可用数据)' : (summaryMode ? asSummary(row).dataStatus !== 'COMPLETE' : tab === 'hot' && asMetric(row).salesCoverage !== 'COMPLETE') ? '(已采集,未完整)' : '' }}</template>
<template #status="{ row }">{{ label(summaryMode ? asSummary(row).dataStatus : asMetric(row).status) }}</template>
<template #reason="{ row }">{{ mode === 'inventory' ? inventoryReasonText(asInventory(row)) : reasonsText(summaryMode ? asSummary(row).reasonCodes : asMetric(row).reasonCodes) }}</template>
<template #windowOutboundQuantity="{ row }">
<template v-if="asInventory(row).windowOutboundQuantity != null">{{ quantityText(asInventory(row).windowOutboundQuantity) }}</template>
<template v-else><span>{{ asInventory(row).observedWindowOutboundQuantity != null ? `已采集 ${quantityText(asInventory(row).observedWindowOutboundQuantity)}` : '—' }}</span><small class="inventory-missing" :title="asInventory(row).earliestReliableEvidenceAt ? `商品最早可靠记录(北京时间):${beijingTime(asInventory(row).earliestReliableEvidenceAt)}` : undefined">{{ inventorySalesGap(asInventory(row)) }}</small></template>
</template>
<template v-for="key in ['averageDailyOutboundQuantity', 'inventoryCoverageDays'] as const" :key="key" #[key]="{ row }">
{{ key === 'averageDailyOutboundQuantity' ? dailyAverageText(asInventory(row)[key]) : quantityText(asInventory(row)[key]) }}<small v-if="asInventory(row)[key] == null" class="inventory-missing">{{ inventoryMissingReason(asInventory(row), key === 'inventoryCoverageDays') }}</small>
</template>
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity'] as const" :key="key" #[key]="{ row }">{{ quantityText(asInventory(row)[key]) }}<span v-if="asMetric(row).quantityStatuses?.[key] && asMetric(row).quantityStatuses?.[key] !== 'VALID'">({{ label(asMetric(row).quantityStatuses?.[key]) }})</span></template>
</DataTable>
</ListPageLayout>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable>
<div class="admin-density">
<LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError" compact><template #details><ApiErrorMeta v-if="trendErrorDetails" :details="trendErrorDetails" /></template></LoadErrorState>
<template v-if="daily">
<SkuTrend :data="daily" />
<el-button @click="router.push({ path: mode === 'inventory' ? '/self-operated/sales' : '/self-operated/inventory', query: { warehouseCode, sku: daily.summary.sku } })">{{ mode === 'inventory' ? '在销量页查看该SKU(全部货主)' : '在库存页查看该SKU(全部货主)' }}</el-button>
</template>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.inventory-sort { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; }
.inventory-sort:disabled { cursor: wait; }
.inventory-sort:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 3px; }
.inventory-sort-arrows { display: inline-flex; flex-direction: column; color: var(--el-text-color-placeholder); font-size: 9px; line-height: 10px; }
.inventory-sort-arrows .active { color: var(--el-color-primary); }
.inventory-missing { display: block; color: var(--el-text-color-secondary); }
.analytics-note, .analytics-rules { margin: 0; font-size: var(--admin-font-caption); }
.analytics-information { max-height: 65vh; overflow-y: auto; overflow-wrap: anywhere; }
.analytics-information > p { margin: var(--admin-space-2) 0; }
.analytics-rules p { margin: var(--admin-space-2) 0; }
.analytics-owner-code { display: block; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
</style>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { watch } from 'vue'
import { ElButton } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
const props = withDefaults(defineProps<{ title: string; width?: string; resetOn?: unknown; inline?: boolean; buttonLabel?: string }>(), { width: '1040px' })
const open = defineModel<boolean>({ default: false })
watch(() => props.resetOn, () => { open.value = false })
</script>
<template>
<div class="chart-disclosure" :class="{ 'chart-disclosure-inline': inline }">
<ElButton :link="inline" :type="inline ? 'primary' : undefined" :title="title" @click="open = true">{{ buttonLabel || title }}</ElButton>
<AppDialog v-model="open" class="admin-density-dialog" :title="title" :width="width" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close>
<div class="admin-density chart-evidence"><slot /></div>
</AppDialog>
</div>
</template>
<style scoped>
.chart-disclosure { margin-top: var(--admin-space-3); }
.chart-disclosure-inline { margin-top: 0; display: inline-flex; }
.chart-evidence :deep(table) { width: 100%; }
.chart-evidence :deep(td) { overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,81 @@
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import CollectionAccountMonitor from './CollectionAccountMonitor.vue'
import CollectionTaskProgress from './CollectionTaskProgress.vue'
import type { Overview, Task, WarehouseOverview } from '../types'
const child: Task = { taskNo: 'CHILD', warehouseCode: 'US01', dataFamily: 'OUTBOUND', taskStatus: 'WAITING_SOURCE', allowedActions: [], progress: null, taskType: 'BACKFILL', queryFrom: null, queryUntil: null, applyState: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, actionBlockReason: null }
const source: Task = { ...child, taskNo: 'SOURCE', warehouseCode: '', taskStatus: 'RUNNING', progress: { phase: 'DETAIL', progressScope: 'ACCOUNT_SHARED', countBasis: 'TASK_CUMULATIVE', completed: 339, total: 2787, totalKnown: true, unit: 'ORDER' } }
function overview(account: Task | null = source): Overview {
return { sourceAccount: 'lingxing-primary', collectionMode: 'BUSINESS', environmentLabel: 'TEST', observedAt: '2026-10-13T08:00:00Z', accountActivity: account,
warehouses: [{ warehouseCode: 'US01', dataFamily: 'OUTBOUND', activeTasks: [child], sharedUpstream: account }, { warehouseCode: 'US02', dataFamily: 'OUTBOUND', activeTasks: [{ ...child, taskNo: 'CHILD-2', warehouseCode: 'US02' }], sharedUpstream: account }] as WarehouseOverview[] }
}
const options = { global: { stubs: { 'el-button': true } } }
describe('single account collection monitor', () => {
it('identifies account master-data work without calling it outbound detail fetching', () => {
const task = { ...source, dataFamily: 'MASTER_DATA', taskType: 'MASTER_DATA', progress: { ...source.progress!, phase: 'PRODUCTS', unit: 'PAGE', completed: 2, total: 86 } }
const data = { ...overview(task), warehouses: [] }
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: data } })
expect(w.text()).toContain('客户/商品资料'); expect(w.text()).toContain('获取商品资料'); expect(w.text()).not.toContain('获取出库目录'); w.unmount()
})
it('shows server schedules once per family and time, excluding disabled scopes', async () => {
const data = overview()
data.warehouses.forEach(row => { row.executionEnabled = true; row.nextScheduledAt = '2026-10-13T16:05:00Z' })
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: data } })
expect(w.find('.account-schedules').text()).toContain('US01 / US02')
expect(w.find('.account-schedules').text()).toContain('2026/10/14 00:05:00')
expect(w.find('.account-schedules').text()).toContain('计划时间不代表当前任务完成时间')
await w.setProps({ stale: true })
expect(w.find('.account-schedules').text()).toContain('上次自动采集计划')
await w.setProps({ overview: { ...data, warehouses: data.warehouses.map(row => ({ ...row, executionEnabled: false })) } })
expect(w.find('.account-schedules').exists()).toBe(false)
w.unmount()
})
it('keeps the account snapshot when warehouse references contain older unknown counts', () => {
const data = overview()
data.warehouses[1]!.sharedUpstream = { ...source, progress: { ...source.progress!, phase: 'DIRECTORY', totalKnown: false, total: null, completed: 0 } }
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: data } })
expect(w.findComponent(CollectionTaskProgress).props('task')).toEqual(source)
expect(w.text()).toContain('12.1%')
w.unmount()
})
it('does not claim idle or an empty queue when the first status read fails', () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: null, stale: true } })
expect(w.text()).toContain('暂未取得账号状态')
expect(w.text()).toContain('暂未取得安排')
expect(w.text()).not.toContain('待机')
expect(w.text()).not.toContain('保留最后一次')
expect(w.text()).not.toContain('暂无其他待处理项')
w.unmount()
})
it('shows account work once despite two waiting warehouses and keeps the phase counts', () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview() } })
expect(w.findAllComponents(CollectionTaskProgress)).toHaveLength(1)
expect(w.findComponent(CollectionTaskProgress).props('task').taskNo).toBe('SOURCE')
expect(w.text()).toContain('12.1%'); expect(w.text()).toContain('2448')
expect(w.text()).toContain('US01 · 出库销量'); expect(w.text()).toContain('US02 · 出库销量')
expect(w.text()).toContain('完整队列数量及下一任务顺序尚未提供')
expect(w.text()).not.toContain('查看配置'); expect(w.text()).not.toContain('执行心跳')
w.unmount()
})
it('moves on when account work ends and does not present historical completion as running', async () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview() } })
const next = overview({ ...source, taskStatus: 'SUCCEEDED' })
const inventory = { ...child, taskNo: 'STOCK', warehouseCode: 'US02', dataFamily: 'INVENTORY', taskStatus: 'RUNNING' }
next.warehouses[1]!.activeTasks = [inventory]
await w.setProps({ overview: next })
expect(w.findComponent(CollectionTaskProgress).props('task').taskNo).toBe('STOCK')
await w.setProps({ overview: { ...next, warehouses: [] } })
expect(w.text()).toContain('当前没有正在运行的采集')
expect(w.findComponent(CollectionTaskProgress).exists()).toBe(false)
w.unmount()
})
it('preserves real last counts when stale without claiming an estimate', () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview(), stale: true } })
expect(w.text()).toContain('339'); expect(w.text()).toContain('状态更新失败')
expect(w.text()).toContain('已暂停估算')
expect(w.find('.account-flow li[data-state="active"]').exists()).toBe(false)
w.unmount()
})
})
@@ -0,0 +1,175 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Activity, Check, Clock3, Database, Pause, RefreshCw } from '@lucide/vue'
import type { Overview, Task } from '../types'
import CollectionTaskProgress from './CollectionTaskProgress.vue'
import { beijingTime, phaseText, label } from '../state'
import { taskStateText } from '../collectionPresentation'
const props = defineProps<{ overview: Overview | null; loading?: boolean; stale?: boolean; visible?: boolean }>()
defineEmits<{ refresh: [] }>()
const activeStates = new Set(['RUNNING', 'APPLYING', 'READY', 'QUEUED', 'WAITING', 'WAITING_SOURCE', 'WAITING_UPSTREAM', 'RETRY_WAIT', 'YIELDED'])
const tasks = computed(() => {
const all = [props.overview?.accountActivity, ...(props.overview?.warehouses.flatMap(w => [...(w.activeTasks ?? []), w.currentTask, w.sharedUpstream]) ?? [])]
const unique = new Map<string, Task>()
// Account activity is authoritative; warehouse references must not overwrite it.
for (const task of all) if (task && !unique.has(task.taskNo)) unique.set(task.taskNo, task)
return [...unique.values()].filter(t => activeStates.has(t.taskStatus))
})
const running = computed(() => tasks.value.filter(t => ['RUNNING', 'APPLYING'].includes(t.taskStatus)))
const current = computed(() => running.value[0] ?? tasks.value[0] ?? null)
const waiting = computed(() => tasks.value.filter(t => t.taskNo !== current.value?.taskNo && !['RUNNING', 'APPLYING'].includes(t.taskStatus)))
const upcoming = computed(() => {
const groups = new Map<string, number>()
for (const task of waiting.value) {
const name = `${task.warehouseCode || '账号'} · ${task.dataFamily === 'MASTER_DATA' ? '客户/商品资料' : task.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量'}`
groups.set(name, (groups.get(name) ?? 0) + 1)
}
return [...groups].map(([name, count]) => ({ name, count }))
})
const schedules = computed(() => {
if (props.overview?.collectionMode === 'OFF') return []
const groups = new Map<string, { family: string; at: string; warehouses: string[] }>()
for (const row of props.overview?.warehouses ?? []) {
if (!row.executionEnabled || !row.nextScheduledAt || !Number.isFinite(Date.parse(row.nextScheduledAt))) continue
const key = `${row.dataFamily}:${row.nextScheduledAt}`
const group = groups.get(key) ?? { family: row.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量', at: row.nextScheduledAt, warehouses: [] }
if (!group.warehouses.includes(row.warehouseCode)) group.warehouses.push(row.warehouseCode)
groups.set(key, group)
}
return [...groups.values()].sort((a, b) => Date.parse(a.at) - Date.parse(b.at))
})
const nextStep = computed(() => {
const phase = current.value?.progress?.phase
if (phase === 'CUSTOMERS') return '获取商品资料 → 校验完整批次 → 发布资料'
if (phase === 'PRODUCTS') return '校验完整批次 → 发布资料'
if (['DIRECTORY', 'DISCOVERY'].includes(phase ?? '')) return '处理出库订单 → 更新各仓出库数据 → 统计计算 → 发布结果'
if (['DETAIL', 'DETAILS'].includes(phase ?? '')) return '更新各仓出库数据 → 统计计算 → 发布结果'
if (['INVENTORY', 'FETCH'].includes(phase ?? '')) return '库存完整性校验 → 统计计算 → 发布结果'
if (['VALIDATE', 'VALIDATION', 'MERGE'].includes(phase ?? '')) return '统计计算 → 发布结果'
if (['BUILD', 'BUILDING', 'BUILT'].includes(phase ?? '')) return '发布结果'
if (['PUBLISH', 'PUBLISHED'].includes(phase ?? '')) return '本次发布完成后,由调度继续安排后续任务'
return '等待调度确认下一步'
})
const scopeText = computed(() => current.value?.dataFamily === 'MASTER_DATA' ? '领星账号 · 客户/商品资料' : current.value?.taskNo === props.overview?.accountActivity?.taskNo || current.value?.progress?.progressScope === 'ACCOUNT_SHARED' ? '领星账号 · 出库数据' : `${current.value?.warehouseCode || '领星账号'} · ${current.value?.dataFamily === 'MASTER_DATA' ? '客户/商品资料' : current.value?.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量'}`)
const warehouseGroups = computed(() => [...new Set(props.overview?.warehouses.map(w => w.warehouseCode) ?? [])].map(code => ({ code, rows: props.overview!.warehouses.filter(w => w.warehouseCode === code) })))
const latestPublication = computed(() => (props.overview?.warehouses ?? []).filter(w => w.lastPublishedAt).sort((a, b) => Date.parse(b.lastPublishedAt!) - Date.parse(a.lastPublishedAt!))[0])
const idleReason = computed(() => {
if (!props.overview) return props.loading ? '正在连接,读取账号状态' : '暂未取得账号状态'
if (props.overview.collectionMode === 'OFF') return '采集已关闭'
if (props.overview.warehouses.some(w => w.protection?.accountPauseReason)) return '账号已暂停'
if (props.overview.warehouses.some(w => ['DEAD', 'FAILED', 'ABORTED'].includes(w.currentTask?.taskStatus ?? ''))) return '暂无活动任务,最近有任务停止'
return '等待下一次采集'
})
const flow = computed(() => current.value?.dataFamily === 'MASTER_DATA' ? [{ name: '获取客户资料', phases: ['CUSTOMERS'] }, { name: '获取商品资料', phases: ['PRODUCTS'] }, { name: '发布资料', phases: ['PUBLISH', 'PUBLISHED'] }] : current.value?.dataFamily === 'INVENTORY' ? [
{ name: '读取库存', phases: ['INVENTORY', 'FETCH'] }, { name: '完整性校验', phases: ['VALIDATE', 'VALIDATION'] }, { name: '统计计算', phases: ['BUILD', 'BUILDING'] }, { name: '发布结果', phases: ['PUBLISH', 'PUBLISHED'] },
] : [{ name: '获取出库目录', phases: ['DIRECTORY', 'DISCOVERY'] }, { name: '处理出库订单', phases: ['DETAIL', 'DETAILS'] }, { name: '更新各仓出库数据', phases: ['MERGE', 'VALIDATE', 'VALIDATION'] }, { name: '统计计算', phases: ['BUILD', 'BUILDING'] }, { name: '发布结果', phases: ['PUBLISH', 'PUBLISHED'] }])
function nodeState(phases: string[]) {
const p = current.value?.progress
if (!p) return 'idle'
const evidence = [p, ...(p.stages ?? [])].find(s => phases.includes(s.phase ?? '') && s.attemptNo != null && s.attemptNo === p.attemptNo && s.scanRound === p.scanRound && s.sliceFrom === p.sliceFrom && s.sliceUntil === p.sliceUntil && s.progressScope === p.progressScope && s.stageFinishedAt)
if (evidence) return 'done'
return !props.stale && phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle'
}
</script>
<template>
<section class="account-monitor" aria-label="领星账号运行进度" :data-running="running.length > 0 && !stale">
<header class="account-header"><div class="account-brand"><Database :size="22" /><div><h2>数据采集</h2><span>领星账号 · 运行看板</span></div></div><div class="account-controls"><span class="account-live"><i />{{ stale ? '更新中断' : !overview ? '连接中' : running.length ? '采集中' : '待机' }}</span><el-button :loading="loading" @click="$emit('refresh')"><RefreshCw :size="15" />刷新状态</el-button></div></header>
<div class="account-body">
<p v-if="stale" role="status">{{ overview ? '状态更新失败,以下保留最后一次读取的进度。' : '暂未取得账号状态,请稍后刷新。' }}</p>
<div class="account-main"><section class="account-primary">
<template v-if="current">
<div class="account-now"><div><span class="account-eyebrow">{{ running.length ? '正在进行' : '当前等待' }}</span><h3>{{ current.progress?.phase ? phaseText(current.progress.phase) : taskStateText(current) }}</h3><p>{{ scopeText }}<template v-if="current.queryFrom"> · {{ beijingTime(current.queryFrom) }} 至 {{ beijingTime(current.queryUntil) }}(结束时刻不含)</template></p></div><span class="account-status">{{ taskStateText(current) }}</span></div>
<CollectionTaskProgress :key="current.taskNo" :task="current" compact :stale="stale" :visible="visible" :observed-at="overview?.observedAt" />
<p v-if="running.length > 1">同时运行:<span v-for="task in running.slice(1)" :key="task.taskNo">{{ task.warehouseCode || '账号' }} · {{ phaseText(task.progress?.phase) }};</span></p>
</template>
<div v-else class="account-empty"><div class="standby-ring"><Pause :size="38" /><span>{{ loading ? '读取中' : !overview ? '未连接' : '待机' }}</span></div><div><span class="account-eyebrow">{{ overview ? '当前没有正在运行的采集' : '连接状态' }}</span><h3>{{ idleReason }}</h3><p>有新任务时自动更新进度。</p><div class="last-result"><Check :size="18" v-if="latestPublication" /><Clock3 :size="18" v-else /><div><strong>{{ latestPublication ? '最近发布结果' : '最近发布结果尚未提供' }}</strong><span v-if="latestPublication">{{ latestPublication.warehouseCode }} · {{ latestPublication.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<br />{{ beijingTime(latestPublication.lastPublishedAt) }}</span></div></div></div></div>
<section class="account-flow"><div class="account-section-title"><h4><Activity :size="16" />处理阶段</h4><span>只标记已确认的推进状态</span></div><ol :style="{ gridTemplateColumns: `repeat(${flow.length}, minmax(0, 1fr))` }"><li v-for="(node, index) in flow" :key="node.name" :data-state="nodeState(node.phases)"><span class="step-index"><Check v-if="nodeState(node.phases) === 'done'" :size="16" /><template v-else>{{ index + 1 }}</template></span><strong>{{ node.name }}</strong><small>{{ nodeState(node.phases) === 'done' ? '已确认完成' : nodeState(node.phases) === 'active' ? '正在处理' : current || !overview ? '等待状态确认' : '待机' }}</small></li></ol></section>
<section class="account-results" v-if="warehouseGroups.length"><div class="account-section-title"><h4><Database :size="16" />各仓最新数据</h4><span>采集进度与报表发布分别确认</span></div><div class="warehouse-results"><article v-for="group in warehouseGroups" :key="group.code"><h4>{{ group.code }}</h4><div v-for="row in group.rows" :key="row.dataFamily" class="warehouse-result"><span>{{ row.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<small>{{ row.lastPublishedAt ? beijingTime(row.lastPublishedAt) : '暂无发布结果' }}</small></span><span :class="{ 'result-ready': Boolean(row.lastPublishedAt) }">{{ row.lastPublishedAt ? '已有结果' : '尚未发布' }}<small>{{ row.lastPublishedAt ? label(row.dataFreshness) : '—' }}</small></span></div></article></div></section>
</section><aside class="account-upnext"><span class="account-eyebrow">后续安排</span><h3>{{ !overview ? '暂未取得安排' : upcoming.length ? '等待处理' : '等待新任务' }}</h3><ul v-if="upcoming.length"><li v-for="group in upcoming" :key="group.name"><span>{{ group.name }}</span><b>{{ group.count }} 项</b></li></ul><p v-else>{{ overview ? '当前摘要中暂无其他待处理项' : '连接恢复后更新后续任务' }}</p><div v-if="current" class="account-next"><strong>本阶段之后</strong><span>{{ nextStep }}</span></div><div v-if="schedules.length" class="account-next account-schedules"><strong>{{ stale ? '上次自动采集计划' : '下次自动采集计划' }}</strong><span v-for="schedule in schedules" :key="schedule.family + schedule.at">{{ schedule.family }} · {{ schedule.warehouses.join(' / ') }}<br />{{ beijingTime(schedule.at) }}(北京时间)</span><small>计划时间不代表当前任务完成时间。</small></div><small>仅已知活动摘要;完整队列数量及下一任务顺序尚未提供。</small></aside></div>
<footer><span>最近推进 {{ beijingTime(current?.progressUpdatedAt) }}</span><span>状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间</span></footer>
</div>
</section>
</template>
<style scoped>
.account-monitor { color: var(--el-text-color-primary); background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius-lg); overflow: hidden; }
.account-header, .account-header > div, .account-now, .account-next, .account-pending, footer { display: flex; align-items: center; gap: var(--admin-space-4); }
.account-header { justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--el-border-color-light); }
h2, h3, p { margin: 0; }
h2 { font-size: var(--admin-font-section); }
h3 { font-size: 22px; margin: var(--admin-space-2) 0; }
.account-body { padding: var(--admin-space-6); }
.account-body > p[role="status"] { margin-bottom: var(--admin-space-5); color: var(--el-color-warning-dark-2); }
.account-header span, p, footer, small { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.account-now { justify-content: space-between; align-items: flex-start; }
.account-eyebrow { color: var(--el-color-primary); font-size: var(--admin-font-caption); }
.account-status { color: var(--el-color-primary); background: var(--el-color-primary-light-9); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); white-space: nowrap; }
.account-next { padding: var(--admin-space-4); background: var(--el-fill-color-light); border-left: 3px solid var(--el-color-primary); }
.account-next small { display: block; margin-top: var(--admin-space-2); }
.account-pending { flex-wrap: wrap; margin-top: var(--admin-space-5); }
.account-pending small { flex-basis: 100%; }
footer { justify-content: space-between; margin-top: var(--admin-space-5); padding-top: var(--admin-space-4); border-top: 1px solid var(--el-border-color-light); }
.account-main { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--admin-space-6); align-items: start; }
.account-primary { min-width: 0; }
.account-brand, .account-controls, .account-live, .account-section-title, h4 { display: flex; align-items: center; gap: 10px; }
.account-brand > svg { color: var(--el-color-primary); background: var(--el-color-primary-light-9); padding: var(--admin-space-3); box-sizing: content-box; border-radius: var(--admin-radius-lg); }
.account-header { background: var(--el-bg-color); }
.account-header h2 { margin-bottom: 4px; }
.account-live { color: var(--el-text-color-secondary); background: var(--el-fill-color-light); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius-lg); }
[data-running="true"] .account-live { color: var(--el-color-success-dark-2); background: var(--el-color-success-light-9); }
.account-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--el-text-color-secondary); }
[data-running="true"] .account-live i { background: var(--el-color-success); animation: signal 2s ease-in-out infinite; }
.account-controls :deep(.el-button) { gap: 8px; }
.account-upnext { border-left: 1px solid var(--el-border-color-light); padding-left: 24px; }
.account-upnext h3 { font-size: 20px; margin: 8px 0 12px; }
.account-upnext ul { padding: 0; margin: 0; list-style: none; }
.account-upnext li { display: flex; justify-content: space-between; padding: 12px 0; gap: 8px; border-bottom: 1px solid var(--el-border-color-light); font-size: var(--admin-font-caption); }
.account-upnext > small { display: block; margin-top: 18px; line-height: 1.7; }
.account-next { display: grid; padding: 14px; margin-top: 12px; border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); border-radius: var(--admin-radius); font-size: var(--admin-font-caption); line-height: 1.8; }
.account-empty { display: grid; grid-template-columns: 180px minmax(0, 1fr); align-items: center; gap: var(--admin-space-6); min-height: 190px; }
.standby-ring { width: 168px; height: 168px; border: 10px solid var(--el-border-color-light); border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--el-text-color-secondary); box-sizing: border-box; }
.last-result { display: flex; gap: 12px; padding: 16px; margin-top: 24px; background: var(--el-fill-color-light); border-radius: 6px; }
.last-result > svg { color: var(--el-color-success); }
.last-result strong, .last-result span { display: block; font-size: var(--admin-font-caption); line-height: 1.8; }
.last-result span { color: var(--el-text-color-secondary); }
.account-flow, .account-results { margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px solid var(--el-border-color-light); }
.account-section-title { justify-content: space-between; margin-bottom: var(--admin-space-5); }
h4 { margin: 0; font-size: var(--admin-font-body); }
.account-section-title > span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.account-flow ol { display: grid; gap: 16px; list-style: none; margin: 0; padding: 0; }
.account-flow li { border-top: 2px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-lighter); padding: var(--admin-space-3); display: grid; grid-template-columns: 28px 1fr; gap: 8px; align-items: center; }
.account-flow li small { grid-column: 2; }
.account-flow li strong { font-size: var(--admin-font-body); }
.step-index { display: grid; place-items: center; border-radius: 50%; width: 26px; height: 26px; background: var(--el-fill-color); font-size: var(--admin-font-caption); }
.account-flow li[data-state="active"] { border-color: var(--el-color-primary); color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.account-flow li[data-state="active"] .step-index { background: var(--el-color-primary); color: white; }
.account-flow li[data-state="done"] { border-color: var(--el-color-success); background: var(--el-color-success-light-9); }
.account-flow li[data-state="done"] .step-index { background: var(--el-color-success); color: white; }
.warehouse-results { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
.warehouse-results article { background: var(--el-fill-color-lighter); padding: 16px 20px; border: 1px solid var(--el-border-color-light); border-top: 3px solid var(--el-color-primary); border-radius: var(--admin-radius-lg); }
.warehouse-results article:nth-child(2n) { border-top-color: var(--el-color-success); }
.warehouse-results article h4 { color: var(--el-color-primary); }
.warehouse-results article:nth-child(2n) h4 { color: var(--el-color-success-dark-2); }
.warehouse-result { display: flex; justify-content: space-between; gap: 12px; padding-top: 14px; font-size: var(--admin-font-body); }
.warehouse-result small { display: block; margin-top: 4px; }
.warehouse-result > span:last-child { text-align: right; }
.result-ready { color: var(--el-color-success); }
.account-monitor :deep(.collection-count-grid > div) { padding-inline: var(--admin-space-5); }
.account-monitor :deep(.collection-count-grid > div + div) { border-left: 1px solid var(--el-border-color-light); }
.account-monitor :deep(.collection-count-grid dd) { font-size: 36px; font-weight: 600; color: var(--el-text-color-primary); overflow-wrap: anywhere; }
.account-monitor :deep(.collection-count-grid > div:nth-child(2) dd) { color: var(--el-color-success-dark-2); }
.account-monitor :deep(.collection-count-grid > div:nth-child(3) dd) { color: var(--el-color-warning-dark-2); }
.account-monitor :deep(.collection-count-grid dd small) { display: block; margin-top: var(--admin-space-2); font-weight: 400; }
.account-monitor :deep(.collection-progress) { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--admin-space-3) var(--admin-space-6); align-items: center; }
.account-monitor :deep(.collection-stage-counts) { display: contents; }
.account-monitor :deep(.collection-ring) { width: 180px; height: 180px; grid-row: 1 / 3; }
.account-monitor :deep(.compact-counts) { grid-column: 2; }
.account-monitor :deep(.collection-progress > p) { grid-column: 1 / -1; }
.account-monitor :deep(.ring-label strong) { color: var(--el-color-primary); }
.account-monitor :deep(.collection-estimate) { grid-column: 2; margin: 0; padding: var(--admin-space-4) var(--admin-space-5); border-radius: var(--admin-radius); background: var(--el-color-primary-light-9); }
.account-monitor :deep(.collection-estimate > strong) { width: 100%; color: var(--el-color-primary); }
.account-monitor :deep(.collection-estimate > span) { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
@keyframes signal { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } }
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AnalyticsChart from './AnalyticsChart.vue'
import { progressGaugeOption } from '../dashboardChartOptions'
import type { Progress } from '../types'
import { beijingTime, phaseText, quantityText, unitText } from '../state'
const props = defineProps<{ progress: Partial<Progress> | null | undefined; taskNo: string; stale?: boolean; compact?: boolean; running?: boolean }>()
const identity = computed(() => JSON.stringify([props.taskNo, props.progress?.progressScope, props.progress?.countBasis, props.progress?.attemptNo, props.progress?.phase, props.progress?.scanRound, props.progress?.sliceFrom, props.progress?.sliceUntil, props.progress?.unit]))
const changedTotal = ref(false)
watch([identity, () => props.progress?.total], ([id, total], [oldId, oldTotal]) => { changedTotal.value = id === oldId ? changedTotal.value || (oldTotal != null && total != null && total !== oldTotal) : false })
const counts = computed(() => {
const p = props.progress
if (!p || p.completed == null || p.total == null || !p.totalKnown || !p.unit || p.unit === 'NONE' || !['ACCOUNT_SHARED', 'WAREHOUSE'].includes(p.progressScope ?? '') || !['TASK_CUMULATIVE', 'ATTEMPT_ONLY'].includes(p.countBasis ?? '') || changedTotal.value) return null
const done = Number(p.completed), total = Number(p.total)
if (!Number.isSafeInteger(done) || !Number.isSafeInteger(total) || done < 0 || total < done || total <= 0) return null
return { remaining: total - done, percent: Number(BigInt(done) * 1000n / BigInt(total)) / 10 }
})
const scope = computed(() => props.progress?.progressScope === 'ACCOUNT_SHARED' ? '账号共享' : props.progress?.progressScope === 'WAREHOUSE' ? '本仓' : '范围待确认')
const indeterminate = computed(() => Boolean(props.running && !props.stale && !counts.value))
const option = computed(() => progressGaugeOption(counts.value?.percent ?? null, indeterminate.value))
const readingDirectory = computed(() => !props.progress?.totalKnown && ['DIRECTORY', 'DISCOVERY'].includes(props.progress?.phase ?? ''))
</script>
<template>
<section class="collection-stage-counts" aria-label="当前阶段数量">
<div class="collection-ring" :role="counts ? 'progressbar' : 'img'" :aria-label="`${scope} · ${phaseText(progress?.phase)} · ${counts ? '当前阶段' : '总量待确认'}`" :aria-valuenow="counts?.percent" :aria-valuemin="counts ? 0 : undefined" :aria-valuemax="counts ? 100 : undefined">
<AnalyticsChart :option="option" merge-updates class="ring-chart" :class="{ 'is-indeterminate': indeterminate }" aria-hidden="true" />
<div class="ring-label"><strong>{{ counts ? `${counts.percent.toFixed(1)}%` : indeterminate ? '执行中' : '—' }}</strong><span>{{ stale ? '上次阶段记录' : '当前阶段' }}</span></div>
</div>
<div :class="{ 'compact-counts': compact }">
<p v-if="!compact">{{ phaseText(progress?.phase) }} · {{ scope }}<span v-if="progress?.scanRound != null"> · 第 {{ progress.scanRound }} 轮</span></p>
<dl class="collection-count-grid"><div><dt>总量</dt><dd>{{ progress?.totalKnown ? quantityText(progress.total) : compact ? '—' : '待确认' }} <small>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</small></dd></div><div><dt>已完成</dt><dd>{{ quantityText(progress?.completed) }} <small>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</small></dd></div><div><dt>剩余</dt><dd>{{ counts ? counts.remaining : '—' }} <small>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</small></dd></div></dl>
<p v-if="changedTotal">总量发生变化,正在复核;暂不展示比例。</p>
<p v-else-if="!counts">{{ readingDirectory ? '目录总页数尚未返回,已读取数量见“已完成”。' : progress?.totalKnown && Number(progress.total) === 0 ? '本阶段数量为零;是否完成以阶段及发布证据为准。' : '总量或计数依据待确认,不估算完成比例。' }}</p>
<p v-else-if="compact" aria-hidden="true">&nbsp;</p>
<p v-if="!compact && scope === '账号共享'">来源账号的阶段数量,不能当作本仓出库量或整个同步完成率。</p>
<p v-if="!compact && (progress?.sliceFrom || progress?.sliceUntil)">当前切片:{{ beijingTime(progress?.sliceFrom) }} 至 {{ beijingTime(progress?.sliceUntil) }}(结束边界不含)</p>
</div>
</section>
</template>
<style scoped>
.collection-stage-counts { display: grid; grid-template-columns: 156px minmax(0, 1fr); align-items: center; gap: var(--admin-space-6); }
.collection-ring { width: 156px; height: 156px; position: relative; }
.compact-counts { min-width: 0; }
.ring-chart { width: 100%; height: 100%; }
.ring-chart.is-indeterminate { animation: collection-spin 2.4s linear infinite; }
@keyframes collection-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .ring-chart.is-indeterminate { animation: none; } }
.ring-label { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.ring-label strong { font-size: 28px; font-weight: 500; font-variant-numeric: tabular-nums; }
.collection-count-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); margin: var(--admin-space-4) 0; }
dt, small, .ring-label span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
dd { margin: var(--admin-space-2) 0 0; font-size: 30px; font-variant-numeric: tabular-nums; }
p { margin: var(--admin-space-2) 0; font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,145 @@
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import CollectionTaskProgress from './CollectionTaskProgress.vue'
import CollectionProgressGauge from './CollectionProgressGauge.vue'
import type { Task, TaskDetail, StageEstimate, WarehouseOverview } from '../types'
const task: Task = { taskNo: 'TASK-W', warehouseCode: 'W', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'WAITING_SOURCE', dependencyState: 'WAITING_CONFIRMED', progressSourceTaskNo: 'TASK-S', queryFrom: null, queryUntil: null, applyState: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null }
const progress = { phase: 'DETAIL', progressScope: 'ACCOUNT_SHARED', countBasis: 'TASK_CUMULATIVE', attemptNo: 2, scanRound: 1, completed: 339, total: 2787, totalKnown: true, unit: 'ORDER', sliceFrom: null, sliceUntil: null, progressUpdatedAt: '2026-10-13T01:01:00Z' }
const source: Task = { ...task, taskNo: 'TASK-S', taskStatus: 'RUNNING', dependencyState: 'NONE', progressSourceTaskNo: null, attemptCount: 2, progress }
const detail = { task, sharedUpstream: source, observedAt: '2026-10-13T01:02:00Z', protection: {} } as TaskDetail
const wrappers: ReturnType<typeof mount>[] = []
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
describe('collection task progress evidence', () => {
it('animates unknown totals only during active visible execution and retains the chart across polls', async () => {
const unknown = { ...source, progress: { ...progress, totalKnown: false, total: null } }
const w = mount(CollectionTaskProgress, { props: { task: unknown, visible: true } }); wrappers.push(w)
const chart = w.get('.ring-chart').element
expect(w.get('.ring-chart').classes()).toContain('is-indeterminate')
expect(w.get('.ring-label strong').text()).toBe('执行中')
await w.setProps({ task: { ...unknown, progress: { ...unknown.progress, completed: 46 } } })
expect(w.get('.ring-chart').element).toBe(chart)
for (const state of [{ stale: true }, { stale: false, visible: false }, { visible: true, task: { ...unknown, taskStatus: 'RETRY_WAIT' } }]) {
await w.setProps(state)
expect(w.get('.ring-chart').classes()).not.toContain('is-indeterminate')
}
await w.setProps({ task: source, visible: true, stale: false })
expect(w.get('.ring-label strong').text()).toBe('12.1%')
expect(w.get('.ring-chart').classes()).not.toContain('is-indeterminate')
})
it('explains a confirmed normal batch continuation without hiding failures or protection blocks', async () => {
const yielded: Task = { ...source, taskStatus: 'RETRY_WAIT', blockingReason: 'RETRY_SCHEDULED', latestAttemptNo: 2,
latestAttempt: { attemptNo: 2, status: 'YIELDED', phase: 'DETAIL', startedAt: null, finishedAt: null, progressUpdatedAt: null, heartbeatAt: null, executionHealth: null, elapsedSeconds: 20, errorCode: null, failureSummary: null } }
const w = mount(CollectionTaskProgress, { props: { task: yielded, compact: true, visible: true } }); wrappers.push(w)
expect(w.find('.collection-estimate').text()).toContain('本批已完成,等待续采')
await w.setProps({ task: { ...yielded, latestAttempt: { ...yielded.latestAttempt!, status: 'FAILED' } } })
expect(w.find('.collection-estimate').text()).toContain('等待计划重试')
await w.setProps({ task: { ...yielded, blockingReason: 'ACCOUNT_COOLDOWN' } })
expect(w.find('.collection-estimate').text()).not.toContain('本批已完成,等待续采')
})
it('reads serialized Long seconds and rejects missing, malformed or unsafe values', async () => {
const w = mount(CollectionTaskProgress, { props: { task: { ...source, elapsedSeconds: '607' }, compact: true } }); wrappers.push(w)
expect(w.text()).toContain('任务累计耗时 10 分 7 秒')
await w.setProps({ task: { ...source, elapsedSeconds: '0' } })
expect(w.text()).toContain('任务累计耗时 0 分 0 秒')
for (const elapsedSeconds of [null, undefined, '', ' ', '-1', '1.5', 'NaN', '9007199254740992', -1]) {
await w.setProps({ task: { ...source, elapsedSeconds } })
expect(w.text()).toContain('任务累计耗时 —')
}
})
it('shows task lifetime across retries and stage changes without borrowing upstream elapsed time', async () => {
const current = { ...task, elapsedSeconds: 607 }
const w = mount(CollectionTaskProgress, { props: { task: current, detail: { ...detail, sharedUpstream: { ...source, elapsedSeconds: 7 } } } }); wrappers.push(w)
expect(w.text()).toContain('任务累计耗时 10 分 7 秒')
expect(w.text()).toContain('含排队与重试')
await w.setProps({ task: { ...current, taskStatus: 'RUNNING', dependencyState: 'SATISFIED', attemptCount: 3, elapsedSeconds: 667, progress: { ...progress, phase: 'MERGE', stageStartedAt: '2026-10-13T01:01:59Z' } } })
expect(w.text()).toContain('任务累计耗时 11 分 7 秒')
await w.setProps({ task: { ...current, elapsedSeconds: null } })
expect(w.text()).toContain('任务累计耗时 —')
})
it('keeps waiting reasons below the gauge and explains unknown directory totals', async () => {
const w = mount(CollectionTaskProgress, { props: { task: { ...source, taskStatus: 'RETRY_WAIT', blockingReason: 'RETRY_SCHEDULED' }, compact: true, visible: true } }); wrappers.push(w)
expect(w.element.firstElementChild?.classList.contains('collection-stage-counts')).toBe(true)
expect(w.find('.collection-estimate').text()).toContain('等待计划重试')
expect(w.text()).toContain('12.1%')
await w.setProps({ task: { ...source, progress: { ...progress, phase: 'DIRECTORY', unit: 'PAGE', totalKnown: false, total: null, completed: 0 } } })
expect(w.find('.collection-count-grid dd').text()).toContain('—')
expect(w.text()).toContain('目录总页数尚未返回')
expect(w.find('[role="progressbar"]').exists()).toBe(false)
})
it('uses only a confirmed source and does not infer earlier stage completion from the current phase', async () => {
const w = mount(CollectionTaskProgress, { props: { task, detail } }); wrappers.push(w)
expect(w.text()).toContain('TASK-S'); expect(w.text()).toContain('12.1%'); expect(w.text()).toContain('2448')
expect(w.text()).toContain('账号共享'); expect(w.findAll('li')[0]?.text()).toContain('状态未确认')
expect(w.text()).not.toContain('✓')
await w.setProps({ task: { ...task, dependencyState: 'WAITING_UNCONFIRMED', progressSourceTaskNo: null } })
expect(w.text()).toContain('关联进度尚未确认'); expect(w.text()).not.toContain('339'); expect(w.text()).not.toContain('12.1%')
})
it('accepts only explicit matching stage completion and keeps publication independent', () => {
const w = mount(CollectionTaskProgress, { props: { task, detail: { ...detail, sharedUpstream: { ...source, progress: { ...progress, stages: [{ ...progress, phase: 'DIRECTORY', stageFinishedAt: '2026-10-13T01:00:00Z' }, { ...progress, phase: 'PUBLISH', stageFinishedAt: '2026-10-13T01:00:00Z' }] } } } } }); wrappers.push(w)
expect(w.findAll('li')[0]?.text()).toContain('✓ 已结束')
expect(w.findAll('li')[4]?.text()).not.toContain('✓')
expect(w.text()).toContain('报表可用状态待确认')
})
it('retains confirmed source evidence after dependency failure without borrowing its current count', () => {
const w = mount(CollectionTaskProgress, { props: { task: { ...task, dependencyState: 'FAILED', progressSourceTaskNo: task.taskNo, blockingReason: 'UPSTREAM_FAILED' }, detail } }); wrappers.push(w)
expect(w.text()).toContain('已确认关联来源:TASK-S')
expect(w.text()).toContain('关联来源失败,等待恢复')
expect(w.text()).not.toContain('12.1%')
})
it('treats COUNT_COMPLETE including zero of zero only as completed counts', () => {
const stageEstimate: StageEstimate = { status: 'UNAVAILABLE', progressIdentity: 'zero-count', completed: '0', total: '0', sampleFrom: null, sampleUntil: null, completedDelta: null, ratePerSecond: null, remainingSeconds: null, estimatedAt: null, validUntil: null, unavailableReason: 'COUNT_COMPLETE' }
const w = mount(CollectionTaskProgress, { props: { task, visible: true, detail: { ...detail, sharedUpstream: { ...source, progress: { ...progress, completed: 0, total: 0 }, stageEstimate } } } }); wrappers.push(w)
expect(w.text()).toContain('计数已齐,等待阶段确认')
expect(w.text()).not.toContain('✓ 已结束')
expect(w.text()).not.toContain('预计还需')
expect(w.find('[role="progressbar"]').exists()).toBe(false)
})
it('uses unavailable reasons as the primary estimate text and prioritizes explicit stage evidence', async () => {
const e: StageEstimate = { status: 'UNAVAILABLE', progressIdentity: 'identity', completed: 339, total: 2787, sampleFrom: null, sampleUntil: null, completedDelta: null, ratePerSecond: null, remainingSeconds: null, estimatedAt: null, validUntil: null, unavailableReason: 'TERMINAL' }
const w = mount(CollectionTaskProgress, { props: { task, visible: true, detail: { ...detail, sharedUpstream: { ...source, stageEstimate: e } } } }); wrappers.push(w)
for (const [reason, expected] of [['TERMINAL', '任务已结束,不再估算'], ['COUNT_COMPLETE', '计数已齐,等待阶段确认'], ['NO_RECENT_PROGRESS', '近期无新增进度,暂不估算'], ['SAMPLING', '本次执行采样中,需积累至少1分钟的有效进度'], ['UNRECOGNIZED', '估算依据待确认']]) {
await w.setProps({ detail: { ...detail, sharedUpstream: { ...source, stageEstimate: { ...e, unavailableReason: reason } } } })
expect(w.find('.collection-estimate strong').text()).toBe(expected)
}
await w.setProps({ detail: { ...detail, sharedUpstream: { ...source, progress: { ...progress, stageFinishedAt: '2026-10-13T01:02:00Z' }, stageEstimate: { ...e, unavailableReason: 'SAMPLING' } } } })
expect(w.find('.collection-estimate strong').text()).toContain('本阶段已结束')
expect(w.text()).not.toContain('本次执行采样中')
})
it('distinguishes unobserved warehouse metadata from a confirmed missing publication', async () => {
const w = mount(CollectionTaskProgress, { props: { task, detail } }); wrappers.push(w)
expect(w.text()).toContain('报表可用状态待确认')
expect(w.text()).not.toContain('尚无可用数据')
await w.setProps({ warehouse: { lastPublishedAt: null } as WarehouseOverview })
expect(w.text()).toContain('尚无可用数据')
expect(w.text()).not.toContain('报表可用状态待确认')
await w.setProps({ warehouse: { lastPublishedAt: '2026-10-13T01:00:00Z' } as WarehouseOverview })
expect(w.text()).toContain('已发布数据')
expect(w.text()).not.toContain('尚无可用数据')
})
it('withdraws ETA when expired, count identity changes, hidden, or reads fail', async () => {
vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-13T01:02:00Z'))
const stageEstimate: StageEstimate = { status: 'AVAILABLE', progressIdentity: 'source-attempt-2-detail-1', completed: 339, total: 2787, sampleFrom: '2026-10-13T01:00:00Z', sampleUntil: '2026-10-13T01:02:00Z', completedDelta: 60, ratePerSecond: 0.5, remainingSeconds: 4896, estimatedAt: '2026-10-13T01:02:00Z', validUntil: '2026-10-13T01:02:10Z', unavailableReason: null }
const d = { ...detail, sharedUpstream: { ...source, stageEstimate } }
const w = mount(CollectionTaskProgress, { props: { task, detail: d, visible: true } }); wrappers.push(w)
expect(w.text()).toContain('约82分钟')
await w.setProps({ stale: true }); expect(w.text()).not.toContain('约82分钟'); expect(w.text()).toContain('状态更新失败')
await w.setProps({ stale: false, visible: false }); expect(w.text()).not.toContain('约82分钟')
await w.setProps({ visible: true, detail: { ...d, sharedUpstream: { ...d.sharedUpstream, progress: { ...progress, completed: 340 } } } }); expect(w.text()).not.toContain('约82分钟')
await w.setProps({ detail: d }); await vi.advanceTimersByTimeAsync(10001); expect(w.text()).toContain('估算已过期'); expect(w.text()).not.toContain('约82分钟')
})
it('never paints a percentage for unknown, zero, invalid, or changing denominators', async () => {
const w = mount(CollectionProgressGauge, { props: { taskNo: source.taskNo, progress } }); wrappers.push(w)
expect(w.find('[role="progressbar"]').attributes('aria-valuenow')).toBe('12.1')
await w.setProps({ progress: { ...progress, total: 3000 } }); expect(w.find('[role="progressbar"]').exists()).toBe(false); expect(w.text()).toContain('正在复核')
await w.setProps({ progress: { ...progress, attemptNo: 3, totalKnown: false } }); expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress: { ...progress, attemptNo: 4, total: 0, completed: 0 } }); expect(w.text()).toContain('本阶段数量为零'); expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress: { ...progress, attemptNo: 5, total: 1, completed: 2 } }); expect(w.find('[role="progressbar"]').exists()).toBe(false)
})
it('shows the percentage once an initially unknown total becomes available', async () => {
const w = mount(CollectionProgressGauge, { props: { taskNo: source.taskNo, progress: { ...progress, totalKnown: false, total: null } } }); wrappers.push(w)
expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress })
expect(w.find('[role="progressbar"]').attributes('aria-valuenow')).toBe('12.1')
})
})
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import type { Progress, Task, TaskDetail, WarehouseOverview } from '../types'
import CollectionProgressGauge from './CollectionProgressGauge.vue'
import { beijingTime, label, phaseText, quantityText, reasonsText, unitText } from '../state'
import { taskStateText, isNormalContinuation } from '../collectionPresentation'
const props = defineProps<{ task: Task; detail?: TaskDetail | null; warehouse?: WarehouseOverview | null; stale?: boolean; visible?: boolean; observedAt?: string | null; compact?: boolean }>()
const confirmedSource = computed(() => {
const source = props.detail?.sharedUpstream
return source && ['WAITING_CONFIRMED', 'SATISFIED', 'FAILED'].includes(props.task.dependencyState ?? '') ? source : null
})
const workingTask = computed(() => confirmedSource.value && props.task.dependencyState === 'WAITING_CONFIRMED' && props.task.progressSourceTaskNo === confirmedSource.value.taskNo ? confirmedSource.value : props.task)
const blockingText = computed(() => isNormalContinuation(props.task) && props.task.blockingReason === 'RETRY_SCHEDULED' ? '本批已完成,等待续采' : ({ UPSTREAM_UNCONFIRMED: '关联来源尚未确认', UPSTREAM_NOT_READY: '等待账号共享来源就绪', UPSTREAM_FAILED: '关联来源失败,等待恢复', RETRY_SCHEDULED: '等待计划重试', EXECUTION_YIELDED: '本批已完成,等待续采', QUEUED: '排队等待执行' } as Record<string, string>)[props.task.blockingReason ?? ''] ?? reasonsText(props.task.blockingReason ? [props.task.blockingReason] : []))
const progress = computed(() => workingTask.value.progress)
const now = ref(Date.now())
let expiryTimer: ReturnType<typeof setTimeout> | undefined
watch(() => [workingTask.value.stageEstimate?.validUntil, props.visible], () => {
if (expiryTimer) clearTimeout(expiryTimer)
now.value = Date.now()
const until = Date.parse(workingTask.value.stageEstimate?.validUntil ?? '')
if (props.visible !== false && Number.isFinite(until) && until > now.value) expiryTimer = setTimeout(() => { now.value = Date.now() }, Math.min(until - now.value + 1, 2147483647))
}, { immediate: true })
onUnmounted(() => { if (expiryTimer) clearTimeout(expiryTimer) })
const estimate = computed(() => {
const e = workingTask.value.stageEstimate, p = progress.value
if (p?.stageFinishedAt || !e || e.status !== 'AVAILABLE' || props.stale || props.visible === false || !e.progressIdentity || !p || !p.totalKnown || String(e.completed) !== String(p.completed) || String(e.total) !== String(p.total) || !e.validUntil || Date.parse(e.validUntil) <= now.value || !Number.isFinite(Date.parse(e.validUntil)) || e.remainingSeconds == null || !Number.isFinite(Number(e.remainingSeconds)) || Number(e.remainingSeconds) <= 0 || !Number.isFinite(Number(e.ratePerSecond)) || Number(e.ratePerSecond) <= 0) return null
return e
})
const estimateReason = computed(() => ({ TERMINAL: '任务已结束,不再估算', PAUSED: '执行已暂停;恢复后重新测速', HEALTH_UNCONFIRMED: '执行健康待确认,已暂停估算', TOTAL_UNKNOWN: '总量待确认,暂不估算', INVALID_PROGRESS: '阶段计数异常,正在复核', COUNT_COMPLETE: '计数已齐,等待阶段确认', SAMPLING: '本次执行采样中,需积累至少1分钟的有效进度', NO_RECENT_PROGRESS: '近期无新增进度,暂不估算', SAMPLE_EXPIRED: '测速样本已过期,等待新检查点', INVALID_SAMPLE: '测速依据待复核,暂不估算' } as Record<string, string>)[workingTask.value.stageEstimate?.unavailableReason ?? ''] ?? '估算依据待确认')
const estimateText = computed(() => {
if (progress.value?.stageFinishedAt) return '本阶段已结束;后续计算与发布分别确认'
if (props.stale) return '状态更新失败,已暂停估算'
if (props.visible === false) return '页面已暂停更新,已暂停估算'
const e = workingTask.value.stageEstimate
if (estimate.value) return Number(estimate.value.remainingSeconds) < 60 ? '当前阶段预计还需约1分钟内' : `当前阶段预计还需约${Math.ceil(Number(estimate.value.remainingSeconds) / 60)}分钟`
if (e?.status === 'AVAILABLE') return '估算已过期或依据变化,等待重新估算'
if (e?.status === 'UNAVAILABLE' || e?.unavailableReason) return estimateReason.value
if (e?.status === 'PAUSED') return '已暂停估算'
if (!progress.value?.totalKnown) return '总量待确认,暂不估算'
return '估算依据待确认'
})
const taskElapsed = computed(() => {
const value = props.task.elapsedSeconds
if (value == null || typeof value === 'string' && !/^\d+$/.test(value)) return null
const seconds = Number(value)
return Number.isSafeInteger(seconds) && seconds >= 0 ? seconds : null
})
const flow = computed(() => props.task.dataFamily === 'MASTER_DATA' ? [{ title: '客户资料', scope: '账号共享', phases: ['CUSTOMERS'] }, { title: '商品资料', scope: '账号共享', phases: ['PRODUCTS'] }, { title: '结果发布', scope: '账号共享', phases: ['PUBLISH', 'PUBLISHED'] }] : props.task.dataFamily === 'INVENTORY' ? [
{ title: '本仓库存分页', scope: '本仓', phases: ['INVENTORY', 'FETCH'] }, { title: '完整性校验', scope: '本仓', phases: ['VALIDATE', 'VALIDATION'] }, { title: '统计计算', scope: '本仓', phases: ['BUILD', 'BUILDING', 'BUILT'] }, { title: '结果发布', scope: '本仓', phases: ['PUBLISH', 'PUBLISHED'] },
] : [
{ title: '来源目录', scope: '账号共享', phases: ['DIRECTORY', 'DISCOVERY'] }, { title: '来源详情', scope: '账号共享', phases: ['DETAIL', 'DETAILS'] }, { title: '更新本仓出库数据', scope: '本仓', phases: ['MERGE', 'VALIDATE', 'VALIDATION'] }, { title: '统计计算', scope: '本仓', phases: ['BUILD', 'BUILDING', 'BUILT'] }, { title: '结果发布', scope: '本仓', phases: ['PUBLISH', 'PUBLISHED'] },
])
function stageState(node: { title: string; scope: string; phases: string[] }) {
const task = node.scope === '账号共享' && props.task.dataFamily !== 'MASTER_DATA' ? confirmedSource.value : props.task
if (!task) return '状态未确认'
if (node.title === '结果发布' && task.resultPublicationState === 'PUBLISHED') return '✓ 已发布'
if (node.title === '结果发布' && task.resultPublicationState === 'REUSED') return '复用已有结果'
const p = task.progress
if (!p) return '状态未确认'
const matching = (stage: Partial<Progress>) => node.phases.includes(stage.phase ?? '') && stage.attemptNo != null && stage.attemptNo === p.attemptNo && stage.scanRound === p.scanRound && stage.sliceFrom === p.sliceFrom && stage.sliceUntil === p.sliceUntil && stage.progressScope === p.progressScope
if ([p, ...(p.stages ?? [])].some(stage => matching(stage) && stage.stageFinishedAt)) return '✓ 已结束'
if (node.phases.includes(p.phase ?? '') && ['RUNNING', 'APPLYING'].includes(task.taskStatus)) return '正在处理'
if (node.scope === '本仓' && ['WAITING_CONFIRMED', 'WAITING_UNCONFIRMED', 'FAILED'].includes(task.dependencyState ?? '')) return '等待来源'
return '状态未确认'
}
</script>
<template>
<section class="collection-progress" aria-label="关注任务进展">
<template v-if="!compact">
<div class="density-actions"><strong>{{ task.warehouseCode }} · {{ task.dataFamily === 'MASTER_DATA' ? '客户/商品资料' : task.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}</strong><span>{{ taskStateText(task) }}</span></div>
<p class="collection-caption">数据范围 {{ beijingTime(task.queryFrom) }} 至 {{ beijingTime(task.queryUntil) }}(结束边界不含)</p>
<p v-if="task.dependencyState === 'WAITING_UNCONFIRMED'">本任务等待来源;关联进度尚未确认。</p>
<p v-if="confirmedSource">已确认关联来源:{{ confirmedSource.taskNo }} · 账号共享 · {{ taskStateText(confirmedSource) }};来源完成后,本仓仍需更新出库数据、计算与发布。</p>
</template>
<p v-if="!compact && (task.blockingReason || task.latestAttempt?.failureSummary)" role="status">{{ task.blockingReason ? blockingText : task.latestAttempt?.failureSummary }}</p>
<CollectionProgressGauge :progress="progress" :task-no="workingTask.taskNo" :running="!stale && visible !== false && !progress?.stageFinishedAt && ['RUNNING', 'APPLYING'].includes(workingTask.taskStatus)" :stale="stale" :compact="compact" />
<div class="collection-estimate"><strong>{{ compact && !stale && visible !== false && task.blockingReason ? blockingText : estimateText }}</strong><span v-if="compact && task.latestAttempt?.failureSummary" role="status">{{ task.latestAttempt.failureSummary }}</span><span v-if="estimate">最近速度 {{ quantityText(estimate.ratePerSecond) }} {{ unitText(progress?.unit) }}/秒;估算更新 {{ beijingTime(estimate.estimatedAt) }}</span><span>仅当前阶段;整个任务完成时间尚未估算。</span></div>
<p>任务累计耗时 {{ taskElapsed == null ? '—' : `${Math.floor(taskElapsed / 60)} 分 ${taskElapsed % 60} 秒` }}<small>(含排队与重试)</small></p>
<template v-if="!compact">
<ol class="collection-flow" :style="{ gridTemplateColumns: `repeat(${flow.length}, minmax(0, 1fr))` }" aria-label="处理阶段"><li v-for="node in flow" :key="node.title" :data-state="stageState(node)"><strong>{{ node.title }}</strong><small>{{ node.scope }}</small><span>{{ stageState(node) }}</span></li></ol>
<div class="collection-clocks"><div>页面读取时间<strong>{{ beijingTime(detail?.observedAt || observedAt) }}</strong><small>读取成功不代表采集推进</small></div><div>最近真实推进 · {{ workingTask === task ? '本仓' : '账号共享' }}<strong>{{ beijingTime(workingTask.progressUpdatedAt || progress?.progressUpdatedAt) }}</strong><small>{{ task.attemptCount === 0 && workingTask === task ? '本仓尚未开始执行' : '以已提交检查点为准' }}</small></div><div>执行心跳<strong>{{ beijingTime(workingTask.latestAttempt?.heartbeatAt) }}</strong><small>{{ label(workingTask.latestAttempt?.executionHealth) }};心跳不代表完成数增加</small></div></div>
<p>本次结果:{{ label(task.resultPublicationState) }}。当前报表:{{ !warehouse ? '报表可用状态待确认' : warehouse.lastPublishedAt ? `使用 ${beijingTime(warehouse.lastPublishedAt)} 已发布数据` : '尚无可用数据' }}。</p>
<p v-if="warehouse">{{ task.dataFamily === 'INVENTORY' ? '库存观测' : '销量连续覆盖' }}:{{ beijingTime(task.dataFamily === 'INVENTORY' ? warehouse.inventoryObservedFrom : warehouse.salesCoveredFrom) }} 至 {{ beijingTime(task.dataFamily === 'INVENTORY' ? warehouse.inventoryObservedUntil : warehouse.salesCoveredUntil) }};{{ label(warehouse.dataFreshness) }}。</p>
<p v-if="warehouse?.initialFrom || warehouse?.initialUntil">首采目标 {{ beijingTime(warehouse.initialFrom) }} 至 {{ beijingTime(warehouse.initialUntil) }}(结束边界不含);{{ label(warehouse.initialCoverageStatus) }}。单日完成不代表10/30日观察窗口已齐全。</p>
</template>
</section>
</template>
<style scoped>
.collection-progress { min-width: 0; padding-block: var(--admin-space-4); }
p { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
.collection-caption, small { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.collection-estimate { display: flex; gap: var(--admin-space-4); flex-wrap: wrap; align-items: baseline; background: var(--el-fill-color-light); padding: var(--admin-space-3); margin-block: var(--admin-space-4); }
.collection-flow { display: grid; gap: var(--admin-space-4); list-style: none; padding: 0; margin: var(--admin-space-5) 0; }
.collection-flow li { display: grid; gap: var(--admin-space-2); border-top: 2px solid var(--el-border-color); padding-top: var(--admin-space-3); }
.collection-flow li[data-state^="✓"] { border-color: var(--el-color-success); }
.collection-flow li[data-state="正在处理"] { border-color: var(--el-color-primary); }
.collection-clocks { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: var(--admin-space-4); border-top: 1px solid var(--el-border-color); padding-top: var(--admin-space-4); }
.collection-clocks div { display: grid; gap: var(--admin-space-2); }
.collection-clocks strong { font-weight: 500; font-variant-numeric: tabular-nums; }
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { ECElementEvent } from 'echarts'
import { quantityText } from '../state'
import { barChartOption, type BarRow } from '../dashboardChartOptions'
import AnalyticsChart from './AnalyticsChart.vue'
import DashboardChartHeading from './DashboardChartHeading.vue'
export type { BarRow } from '../dashboardChartOptions'
const props = defineProps<{ rows: BarRow[]; unit: string; disabled?: boolean; title: string; ranked?: boolean; detailsWidth?: string }>()
const emit = defineEmits<{ select: [key: string] }>()
const option = computed(() => barChartOption(props.rows, props.unit, props.title, props.ranked))
const detailsOpen = ref(false)
watch(() => props.rows, () => { detailsOpen.value = false })
function select(row: BarRow) { if (!props.disabled && row.value != null) { detailsOpen.value = false; emit('select', row.key) } }
function selectChart(event: ECElementEvent) {
const row = event.componentType === 'yAxis' ? props.rows.find(row => row.key === String(event.value)) : event.componentType === 'series' ? props.rows[event.dataIndex] : undefined
if (row) select(row)
}
</script>
<template>
<div :aria-label="title" class="dashboard-bars">
<DashboardChartHeading :title="title">
<span v-if="rows.length" class="chart-details-action"><el-button link type="primary" title="查看完整明细" @click="detailsOpen = true">明细</el-button></span>
<slot name="actions" />
</DashboardChartHeading>
<slot name="intro" />
<slot v-if="!rows.length" name="empty"><p>暂无符合条件的 SKU;请核对覆盖与待评估资料。</p></slot>
<template v-else>
<AnalyticsChart :option="option" :style="{ height: `${Math.max(240, rows.length * 38 + 65)}px` }" :aria-label="title" @select="selectChart" />
<p v-if="rows.some(row => row.value == null)">存在待核验数量,以“—”表示,不作为零值。</p>
<AppDialog v-model="detailsOpen" class="admin-density-dialog" :title="`${title} · 完整明细`" :width="detailsWidth || '1040px'" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close>
<div class="admin-density chart-details">
<slot name="details" :select="select">
<p>当前图表共 {{ rows.length }} 项;点击项目查看对应明细。</p>
<table class="density-table"><thead><tr><th v-if="ranked" class="rank-column">排名</th><th class="name-column">项目/完整名称</th><th class="quantity-column">数量({{ unit }})</th><th>说明</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="row.key"><td v-if="ranked">{{ index + 1 }}</td><td><button type="button" :disabled="disabled || row.value == null" @click="select(row)">{{ row.label }}</button><small v-if="row.secondary">{{ row.secondary }}</small></td><td>{{ quantityText(row.value) }}</td><td>{{ row.detail || '—' }}</td></tr></tbody></table>
</slot>
</div>
</AppDialog>
</template>
</div>
</template>
<style scoped>
.dashboard-bars { min-width: 0; }
small { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
td { overflow-wrap: anywhere; vertical-align: top; }
.chart-details-action { display: inline-flex; }
.chart-details table { width: 100%; table-layout: fixed; }
.rank-column { width: 56px; }
.name-column { width: 30%; }
.quantity-column { width: 120px; }
.chart-details button { font: inherit; color: var(--el-color-primary); background: transparent; border: 0; cursor: pointer; padding: var(--admin-space-2); }
.chart-details button:disabled { color: var(--el-text-color-secondary); cursor: default; }
</style>
@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{ title: string }>()
</script>
<template>
<div class="dashboard-chart-heading">
<h3>{{ title }}</h3>
<div class="dashboard-chart-actions"><slot /></div>
</div>
</template>
<style scoped>
.dashboard-chart-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-2); }
h3 { display: flex; align-items: center; gap: var(--admin-space-2); margin: 0; font-size: var(--admin-font-section); }
h3::before { content: ''; width: 3px; height: 14px; border-radius: 2px; background: #5575ed; flex-shrink: 0; }
.dashboard-chart-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--admin-space-3); margin-left: auto; }
.dashboard-chart-actions :deep(.el-button + .el-button) { margin-left: 0; }
</style>
@@ -0,0 +1,60 @@
vi.mock('@/shared/components/AppDialog.vue', () => ({ default: { props: ['modelValue', 'title', 'appendToBody'], template: '<section v-if="modelValue" role="dialog"><h2>{{ title }}</h2><slot /></section>' } }))
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import DashboardTrend from './DashboardTrend.vue'
import DashboardBars from './DashboardBars.vue'
import type { DailyPoint } from '../types'
function point(date: string, value: number | null, dataStatus = 'COMPLETE'): DailyPoint {
return { businessDate: date, outboundQuantity: dataStatus === 'COMPLETE' ? value : null, observedQuantity: value, dataStatus, reasonCodes: [], quantityUnit: 'PCS' }
}
describe('dashboard chart integrity and interaction', () => {
it('fills only contiguous known segments and keeps unknown days unavailable for drilldown', async () => {
const wrapper = mount(DashboardTrend, { props: { points: [point('2026-10-01', 2), point('2026-10-02', 4), point('2026-10-03', null, 'UNKNOWN'), point('2026-10-04', 1, 'PARTIAL')] } })
const chart = wrapper.findComponent({ name: 'VChart' })
const series = chart.props('option').series
expect(series[1].data).toEqual([2, 4, null, null])
expect(series[0].data[3].symbol).toBe('emptyCircle')
expect(wrapper.find('[role=dialog]').exists()).toBe(false)
expect(wrapper.find('.dashboard-chart-heading .chart-disclosure button').classes()).toContain('is-link')
await wrapper.find('.chart-disclosure button').trigger('click')
const days = wrapper.findAll('tbody button')
await days[2]!.trigger('click')
expect(wrapper.emitted('select')).toBeUndefined()
chart.vm.$emit('click', { componentType: 'series', dataIndex: 3 })
expect(wrapper.emitted('select')).toEqual([['2026-10-04']])
await wrapper.setProps({ disabled: true })
chart.vm.$emit('click', { componentType: 'series', dataIndex: 0 })
expect(wrapper.emitted('select')).toHaveLength(1)
await wrapper.setProps({ points: [point('2026-10-05', 7)] })
expect(wrapper.find('.trend-tooltip').exists()).toBe(false)
wrapper.unmount()
})
it('preserves zero and unknown bars, exact relative widths and disabled selection', async () => {
const wrapper = mount(DashboardBars, { global: { stubs: { ElButton: { template: '<button><slot /></button>' } } }, props: { title: '分布', unit: 'SKU', rows: [
{ key: 'a', label: 'A', value: 20, detail: '' }, { key: 'b', label: 'B', value: 5, detail: '' },
{ key: 'zero', label: '零值', value: 0, detail: '' }, { key: 'unknown', label: '未知', value: null, detail: '' },
] } })
const chart = wrapper.findComponent({ name: 'VChart' })
expect(chart.props('option').series[0].data.map((point: { value: number | null }) => point.value)).toEqual([20, 5, 0, null])
expect(wrapper.find('[role=dialog]').exists()).toBe(false)
await wrapper.find('.dashboard-chart-heading .chart-details-action button').trigger('click')
expect(wrapper.find('[role=dialog]').text()).toContain('分布 · 完整明细')
await wrapper.findAll('tbody button')[3]!.trigger('click')
expect(wrapper.emitted('select')).toBeUndefined()
await wrapper.findAll('tbody button')[2]!.trigger('click')
expect(wrapper.find('[role=dialog]').exists()).toBe(false)
expect(wrapper.emitted('select')).toEqual([['zero']])
chart.vm.$emit('click', { componentType: 'yAxis', value: 'a' })
expect(wrapper.emitted('select')).toEqual([['zero'], ['a']])
await wrapper.setProps({ disabled: true })
await wrapper.find('.chart-details-action button').trigger('click')
await wrapper.findAll('tbody button')[0]!.trigger('click')
chart.vm.$emit('click', { componentType: 'series', dataIndex: 0 })
expect(wrapper.emitted('select')).toHaveLength(2)
await wrapper.setProps({ rows: [{ key: 'new', label: '新数据', value: 1, detail: '' }] })
expect(wrapper.find('[role=dialog]').exists()).toBe(false)
wrapper.unmount()
})
})
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import ChartDetailsDialog from './ChartDetailsDialog.vue'
import DashboardChartHeading from './DashboardChartHeading.vue'
import type { ECElementEvent } from 'echarts'
import type { DailyPoint } from '../types'
import { label, quantityText, reasonsText, shownQuantity } from '../state'
import { skuTrendOption } from '../skuTrendChart'
import AnalyticsChart from './AnalyticsChart.vue'
const props = defineProps<{ points: DailyPoint[]; disabled?: boolean }>()
const emit = defineEmits<{ select: [date: string] }>()
const option = computed(() => ({ ...skuTrendOption(props.points), aria: { enabled: true, description: '全仓每日出库趋势,点击已知日点查看当日销量。缺失日期断线,未完整日虚线。' } }))
const hasData = computed(() => props.points.some(point => point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null))
const detailsOpen = ref(false)
function available(point: DailyPoint) { return !props.disabled && point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null }
function select(point: DailyPoint) { if (available(point)) { detailsOpen.value = false; emit('select', point.businessDate) } }
function selectChart(event: ECElementEvent) { const point = props.points[event.dataIndex]; if (event.componentType === 'series' && point) select(point) }
</script>
<template>
<div>
<DashboardChartHeading title="出库趋势">
<ChartDetailsDialog v-model="detailsOpen" inline button-label="每日数值与覆盖原因" title="查看每日数值与覆盖原因" :reset-on="points"><table class="density-table"><thead><tr><th>日期(点击查看)</th><th>正式量</th><th>已采集量</th><th>状态</th><th>原因</th></tr></thead><tbody><tr v-for="point in points" :key="point.businessDate"><td><button type="button" :disabled="!available(point)" @click="select(point)">{{ point.businessDate }}</button></td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr></tbody></table></ChartDetailsDialog>
</DashboardChartHeading>
<slot name="intro" />
<p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 未知及缺失日期断线;今日为已采集累计</p>
<AnalyticsChart :option="option" class="dashboard-trend" aria-label="全仓每日出库趋势" @select="selectChart" />
<p v-if="!hasData">所选日期暂无可靠出库资料,未知不按零显示。</p>
</div>
</template>
<style scoped>
.dashboard-trend { width: 100%; height: 340px; }
.trend-legend { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
td button { font: inherit; color: var(--el-color-primary); background: transparent; border: 0; cursor: pointer; padding: var(--admin-space-2); }
td button:disabled { color: var(--el-text-color-secondary); cursor: default; }
</style>
@@ -0,0 +1,20 @@
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import HotSkuDetails from './HotSkuDetails.vue'
import type { DashboardRanking, DashboardItem } from '../types'
it('shows each SKU identity and ranked quantity once, preserves unknown and owner-specific drilldown', async () => {
const item: DashboardItem = { sku: 'SKU-X', customerCode: 'OWNER-A', productName: 'SKU-X 商品', value: 164, physicalQuantity: 891, availableQuantity: 0, averageDailyOutboundQuantity: null, inventoryCoverageDays: null, unit: 'PCS', windowOutboundQuantity: 164, skuOutboundOrderCount: 1, frozenQuantity: 0, defectivePhysicalQuantity: 0 }
const ranking: DashboardRanking = { windowStartDate: '2026-10-01', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 13, eligibleCount: 2, unassessableCount: 0, dataStatus: 'PARTIAL', reasonCodes: [], items: [item, { ...item, customerCode: 'OWNER-B', customerName: '货主乙', productName: 'SKU-X' }] }
const wrapper = mount(HotSkuDetails, { props: { ranking } })
const cells = wrapper.findAll('tbody tr')[0]!.findAll('td').map(cell => cell.text())
expect(cells).toEqual(['1', 'SKU-X', '商品', 'OWNER-A', '164', '891', '0', '—', '—'])
expect(wrapper.findAll('tbody tr')[1]!.findAll('td')[2]!.text()).toBe('—')
expect(wrapper.findAll('tbody tr')[1]!.findAll('td')[3]!.text()).toBe('货主乙')
expect(wrapper.text()).toContain('时段已采集出库量')
await wrapper.findAll('button')[1]!.trigger('click')
expect(wrapper.emitted('select')).toEqual([[1]])
await wrapper.setProps({ disabled: true })
await wrapper.findAll('button')[0]!.trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
wrapper.unmount()
})
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { DashboardRanking } from '../types'
import { label, quantityText, reasonsText } from '../state'
defineProps<{ ranking: DashboardRanking; disabled?: boolean }>()
const emit = defineEmits<{ select: [index: number] }>()
function productName(sku: string, name: string | null) {
const text = name?.trim() || ''
if (text === sku) return '—'
return (text.startsWith(`${sku} `) ? text.slice(sku.length).trim() : text) || '—'
}
</script>
<template>
<div class="hot-sku-details">
<p>{{ ranking.windowStartDate }} 至 {{ ranking.windowEndDateExclusive }}(截止日不含) · {{ label(ranking.dataStatus) }}<template v-if="ranking.reasonCodes.length"> · {{ reasonsText(ranking.reasonCodes) }}</template></p>
<p>按所选时段{{ ranking.dataStatus === 'COMPLETE' ? '出库量' : '已采集出库量' }}排名;点击 SKU 查看每日趋势。<template v-if="ranking.dataStatus !== 'COMPLETE'">数据尚未采齐,排名可能变化;未采集日期不计为零出库。</template>“—”表示暂无可靠数据。</p>
<table class="density-table"><thead><tr><th class="rank">排名</th><th class="sku">SKU</th><th>商品名称</th><th class="owner">货主</th><th class="number">{{ ranking.dataStatus === 'COMPLETE' ? '时段出库量' : '时段已采集出库量' }}(件)</th><th class="number">实物库存(件)</th><th class="number">可售库存(件)</th><th class="number">窗口日均(件)</th><th class="number">预计可售(天)</th></tr></thead><tbody>
<tr v-for="(item, index) in ranking.items" :key="`${item.customerCode}:${item.sku}`"><td>{{ index + 1 }}</td><td><button type="button" :disabled="disabled || item.value == null" @click="emit('select', index)">{{ item.sku }}</button></td><td>{{ productName(item.sku, item.productName) }}</td><td>{{ item.customerName || item.customerCode }}</td><td class="number">{{ quantityText(item.value) }}</td><td class="number">{{ quantityText(item.physicalQuantity) }}</td><td class="number">{{ quantityText(item.availableQuantity) }}</td><td class="number">{{ quantityText(item.averageDailyOutboundQuantity) }}</td><td class="number">{{ quantityText(item.inventoryCoverageDays) }}</td></tr>
</tbody></table>
</div>
</template>
<style scoped>
.hot-sku-details table { width: 100%; table-layout: fixed; }
th, td { overflow-wrap: anywhere; }
.rank { width: 44px; }
.sku { width: 16%; }
.owner { width: 90px; }
.number { width: 86px; text-align: right; font-variant-numeric: tabular-nums; }
button { border: 0; padding: 0; background: transparent; color: var(--el-color-primary); font: inherit; text-align: left; overflow-wrap: anywhere; cursor: pointer; }
button:disabled { color: var(--el-text-color-secondary); cursor: default; }
</style>
@@ -0,0 +1,39 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import InventoryRankingDiagnostics from './InventoryRankingDiagnostics.vue'
import { dashboardFixture } from '../pages/dashboard.fixture'
describe('inventory ranking evidence', () => {
it('shows fixed-query coverage and keeps distribution counts distinct from ranking counts', () => {
const data = structuredClone(dashboardFixture)
Object.assign(data.context, { completeDayFrom: '2026-10-01', completeDayUntil: '2026-10-13', salesCoveredFrom: '2026-09-30T16:00:00Z', salesCoveredUntil: '2026-10-12T16:00:00Z', inventoryStatus: 'VALID' })
data.longCoverageSkus.unassessableCount = '2711'
data.noOutboundSkus.unassessableCount = '2741'
data.coverageDistribution.unknownReasons = { SALES_WINDOW_UNAVAILABLE: '2700', OBSERVATION_INSUFFICIENT: '11', PHYSICAL_INVENTORY_UNKNOWN: '0' }
const wrapper = mount(InventoryRankingDiagnostics, { props: { data, warehouse: '测试仓', historical: false } })
expect(wrapper.text()).toContain('2026-10-01 至 2026-10-13(截止日不含)')
expect(wrapper.text()).toContain('2026/10/01 00:00:00')
expect(wrapper.text()).toContain('待评估 2711 SKU')
expect(wrapper.text()).toContain('待评估 2741 SKU')
expect(wrapper.text()).toContain('不是两个榜单各自的排除明细')
expect(wrapper.text()).toContain('每个SKU仅列首个命中的原因')
expect(wrapper.text()).toContain('仅补齐窗口内销量不一定满足此条件')
expect(wrapper.text()).not.toContain('实物库存待核验')
})
it('preserves unknown counts and marks stale evidence without claiming complete data', () => {
const data = structuredClone(dashboardFixture)
data.longCoverageSkus.unassessableCount = null
data.coverageDistribution.unknownReasons = { OBSERVATION_INSUFFICIENT: null }
const wrapper = mount(InventoryRankingDiagnostics, { props: { data, warehouse: '测试仓', historical: true } })
expect(wrapper.text()).toContain('待评估 — SKU')
expect(wrapper.text()).toContain('尚无可确认的完整日覆盖范围')
expect(wrapper.text()).toContain('当前为历史查询结果')
expect(wrapper.findAll('table')[1]!.text()).toContain('观察期不足—')
})
it('does not interpret absent cause counts as proof that data is complete', () => {
const data = structuredClone(dashboardFixture)
data.coverageDistribution.unknownReasons = {}
const wrapper = mount(InventoryRankingDiagnostics, { props: { data, warehouse: '测试仓', historical: false } })
expect(wrapper.text()).toContain('不能据此认定资料全部齐备')
})
})
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Dashboard } from '../types'
import { beijingTime, label, quantityText, reasonsText } from '../state'
const props = defineProps<{ data: Dashboard; warehouse: string; historical: boolean }>()
const explanations: Record<string, string> = {
PHYSICAL_INVENTORY_UNKNOWN: '实物库存缺失、异常或已过期,需核对库存采集结果。',
AVAILABLE_INVENTORY_UNKNOWN: '正品可售库存缺失、异常或已过期,暂不能计算消化天数。',
UNIT_UNKNOWN_OR_CONFLICT: '销量或库存的数量单位未确认或不一致。',
SALES_WINDOW_UNAVAILABLE: '所选区间的完整销量不可用,需核对覆盖范围、销量状态与修订核对结果。',
OBSERVATION_INSUFFICIENT: '缺少统计窗口起点及以前的本仓有效出库或库存观察依据;仅补齐窗口内销量不一定满足此条件。',
METRIC_DEPENDENCY_UNKNOWN: '计算所需资料仍有未知项,需进入库存分析核对具体SKU。',
}
const causes = computed(() => Object.entries(props.data.coverageDistribution.unknownReasons)
.filter(([, count]) => count == null || !Number.isFinite(Number(count)) || Number(count) > 0))
</script>
<template>
<div class="inventory-ranking-diagnostics">
<p><strong>{{ warehouse || '—' }}</strong> · 查询时间 {{ beijingTime(data.context.queryAsOf) }}(北京时间)</p>
<p v-if="historical" role="status">当前为历史查询结果,请整体刷新后再核对最新采集覆盖。</p>
<table class="density-table">
<tbody>
<tr><th scope="row">榜单要求的销量区间</th><td>{{ data.longCoverageSkus.windowStartDate }} 至 {{ data.longCoverageSkus.windowEndDateExclusive }}(截止日不含)</td></tr>
<tr><th scope="row">已发布销量覆盖</th><td>{{ beijingTime(data.context.salesCoveredFrom) }} 至 {{ beijingTime(data.context.salesCoveredUntil) }}(截止时刻不含)</td></tr>
<tr><th scope="row">完整日覆盖</th><td v-if="data.context.completeDayFrom && data.context.completeDayUntil">{{ data.context.completeDayFrom }} 至 {{ data.context.completeDayUntil }}(截止日不含)</td><td v-else>尚无可确认的完整日覆盖范围</td></tr>
<tr><th scope="row">销量状态</th><td>{{ label(data.context.salesStatus) }};所选时段日均:{{ label(data.kpis.averageDailyOutboundQuantity.dataStatus) }} · {{ reasonsText(data.kpis.averageDailyOutboundQuantity.reasonCodes) }}</td></tr>
<tr><th scope="row">库存采集时间</th><td>{{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</td></tr>
<tr><th scope="row">库存状态/有效期</th><td>{{ label(data.context.inventoryStatus) }};有效至 {{ beijingTime(data.context.inventoryFreshUntil) }}</td></tr>
<tr><th scope="row">本次查询提示</th><td>{{ reasonsText(data.context.reasonCodes) }}</td></tr>
<tr><th scope="row">预计消化时间最长</th><td>符合条件 {{ quantityText(data.longCoverageSkus.eligibleCount) }} SKU;待评估 {{ quantityText(data.longCoverageSkus.unassessableCount) }} SKU</td></tr>
<tr><th scope="row">所选时段无出库库存</th><td>符合条件 {{ quantityText(data.noOutboundSkus.eligibleCount) }} SKU;待评估 {{ quantityText(data.noOutboundSkus.unassessableCount) }} SKU</td></tr>
</tbody>
</table>
<h4>全仓库存分布的待核验原因</h4>
<p>以下为全仓库存分布的原因计数,不是两个榜单各自的排除明细。每个SKU仅列首个命中的原因,补齐后仍可能有其他条件待满足。</p>
<table v-if="causes.length" class="density-table">
<thead><tr><th scope="col">原因</th><th scope="col">SKU数</th><th scope="col">需要核对的数据</th></tr></thead>
<tbody><tr v-for="[reason, count] in causes" :key="reason"><td>{{ reasonsText([reason]) }}</td><td>{{ quantityText(count) }}</td><td>{{ explanations[reason] || '接口未提供更细说明,请核对库存分析中的SKU资料。' }}</td></tr></tbody>
</table>
<p v-else>本次查询未列出非零的具体原因计数,不能据此认定资料全部齐备。</p>
<p>消化榜还要求正可售库存且日均出库大于零;无出库榜要求正实物库存且完整窗口出库为零。两榜均需满足本仓观察资格;未知销量不能按零处理。</p>
</div>
</template>
<style scoped>
table { width: 100%; }
th, td { text-align: left; overflow-wrap: anywhere; }
tbody th { width: 180px; }
</style>
@@ -0,0 +1,263 @@
import { defineComponent, h, reactive } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import InventoryTurnoverView from './InventoryTurnoverView.vue'
import type { TurnoverQuery, TurnoverResponse, TurnoverRow } from '../turnoverTypes'
const mocks = vi.hoisted(() => ({ route: { query: {} as Record<string, string> }, allowed: true, ownerDirectory: vi.fn(), overview: vi.fn(), turnover: vi.fn(), daily: vi.fn(), push: vi.fn() }))
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) }))
const toolbar = defineComponent({ name: 'QueryToolbar', props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search', 'reset'], setup: () => () => h('div') })
const table = defineComponent({ name: 'DataTable', props: ['data', 'columns', 'total', 'page'], emits: ['update:page', 'update:pageSize'], setup(p, { slots }) { return () => h('div', [slots.filters?.(), slots['toolbar-start']?.(), slots['toolbar-actions']?.(), slots['before-results']?.(), ...(p.columns ?? []).map((column: { headerSlotName?: string; prop: string; label: string }) => column.headerSlotName ? slots[column.headerSlotName]?.({ column: { property: column.prop, label: column.label } }) : null), ...(p.data ?? []).flatMap((row: TurnoverRow) => (p.columns ?? []).map((column: { slotName?: string; prop: keyof TurnoverRow }) => column.slotName && slots[column.slotName] ? slots[column.slotName]?.({ row }) : String(row[column.prop] ?? '')))]) } })
const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } })
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const total = { value: 100, knownSubtotal: 100, unknownSkuCount: 0, reasonCodes: [] }
const risk = { matchedSkuCount: 0, knownPhysicalQuantity: 0, unknownSkuCount: 0, physicalQuantityRatio: 0 }
const row: TurnoverRow = { customerCode: 'OWNER-A', customerName: null, sku: 'SAME', productName: '商品', physicalQuantity: total, availableQuantity: total, frozenQuantity: total, defectiveQuantity: total, transitQuantity: total, physicalSkuCount: 1, windows: [7, 15, 30].map(days => ({ days: days as 7 | 15 | 30, startDate: '2026-09-13', endDateExclusive: '2026-10-13', outboundQuantity: 300, observedQuantity: 300, averageDailyOutboundQuantity: 10, completeDayCount: days, partialDayCount: 0, unknownDayCount: 0, dataStatus: 'COMPLETE', reasonCodes: [] })), averageDailyOutboundQuantity: 10, inventoryCoverageDays: 10, coverageStatus: 'VALID', averagePhysicalQuantity: null, quantityTurnoverCount: null, turnoverStatus: 'INSUFFICIENT', qualifiedSampleDays: 0, requiredSampleDays: 30, missingSampleDates: ['2026-10-12'], sampleReasonCodes: [], lastObservedOutboundDate: null, attentionReasons: [], noOutboundInWindow: risk, slowCoverage: risk, unassessableSkuCount: 0 }
function response(q: TurnoverQuery): TurnoverResponse {
return { context: { snapshotContext: q.snapshotContext || `token-${q.warehouseCode}`, sourceAccount: q.sourceAccount, warehouseCode: q.warehouseCode, endDate: q.endDate, baseWindowDays: q.baseWindowDays, queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', inventoryObservedFrom: null, inventoryObservedUntil: null, sampleRuleVersion: 'DAILY_SCHEDULED_FIRST_V1', dailySamples: [], missingSampleDates: ['2026-10-12'], accumulationStartedOn: null, analyticsContext: { salesRunId: '11', inventoryRunId: '22', salesScopeHash: 'a', inventoryScopeHash: 'b', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2099-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13' } }, summary: q.includeOverview ? { physicalQuantity: total, availableQuantity: total, noOutboundSkuCount: total, unassessableSkuCount: total } : null, charts: q.includeOverview ? { ownerPhysicalTop: [], unknownOwnerCount: 0, averageDaily: row.windows } : null, rows: [{ ...row, sku: q.groupBy === 'OWNER' ? null : row.sku }], total: 40 }
}
const wrappers: ReturnType<typeof mount>[] = []
function page() { const w = mount(InventoryTurnoverView, { global: { stubs: { AdminPage: shell, DataTable: table, QueryToolbar: toolbar, AnalyticsChart: true, ChartDetailsDialog: true, AppDialog: true, ApiErrorMeta: true, 'el-button': button, 'el-tag': shell, 'el-popover': defineComponent({ setup(_, { slots }) { return () => h('div', [slots.reference?.(), slots.default?.()]) } }) } } }); wrappers.push(w); return w }
async function change(w: ReturnType<typeof mount>, patch: object, search = false) {
const t = w.findComponent(toolbar); t.vm.$emit('update:modelValue', { ...t.props('modelValue'), ...patch }); await flushPromises()
if (search) { t.vm.$emit('search'); await flushPromises() }
}
beforeEach(() => { vi.clearAllMocks(); mocks.ownerDirectory.mockResolvedValue([{ customerCode: "OWNER-A", customerName: "货主甲" }]); mocks.allowed = true; mocks.route = reactive({ query: { endDate: '2026-10-12' } }); mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A' }, { warehouseCode: 'B' }] }); mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q)); sessionStorage.clear() })
afterEach(() => wrappers.splice(0).forEach(w => w.unmount()))
describe('inventory turnover page fixed snapshot behavior', () => {
it('filters turnover by directory customer and keeps the code in pagination', async () => {
const w = page(); await flushPromises()
expect(w.findComponent(toolbar).props('fields').find((f: { key: string }) => f.key === 'customerCode').options).toEqual([{ value: 'OWNER-A', label: '货主甲 (OWNER-A)' }])
await change(w, { customerCode: 'OWNER-A' })
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCode: 'OWNER-A', current: 1, includeOverview: true })
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCode: 'OWNER-A', current: 2, snapshotContext: 'token-A' })
})
it('sorts every numeric header on the server in both directions with the same snapshot', async () => {
const w = page(); await flushPromises()
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
const metrics = { physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER' }
for (const [prop, metric] of Object.entries(metrics)) {
// Start on a later page and with another sort to prove that a header resets pagination.
await change(w, { sortBy: metric === 'PHYSICAL' ? 'AVAILABLE_DESC' : 'PHYSICAL_DESC' }, true)
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
const header = w.get(`button[data-column="${prop}"]`)
for (const direction of ['DESC', 'ASC']) {
await header.trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ sortBy: `${metric}_${direction}`, current: 1, snapshotContext: 'token-A', includeOverview: false })
expect(w.findComponent(toolbar).props('modelValue').sortBy).toBe(`${metric}_${direction}`)
expect(header.attributes('aria-label')).toContain(direction === 'ASC' ? '当前升序' : '当前降序')
}
}
await change(w, { rangePreset: 'LAST_7_DAYS' })
expect(w.get('button[data-column="averageDailyOutboundQuantity"]').text()).toContain('7日日均')
await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ baseWindowDays: 7, sortBy: 'AVERAGE_DAILY_DESC' })
})
it('keeps the successful sort marker and page when a header query fails', async () => {
const w = page(); await flushPromises()
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
mocks.turnover.mockRejectedValueOnce(new Error('offline'))
await w.get('button[data-column="outbound15"]').trigger('click'); await flushPromises()
expect(w.findComponent(table).props('page')).toBe(2)
expect(w.get('button[data-column="physicalQuantity"]').attributes('aria-label')).toContain('当前降序')
expect(w.get('button[data-column="outbound15"]').attributes('aria-label')).not.toContain('当前')
expect(w.findComponent(table).props('data')).toHaveLength(1)
})
it('supports owner numeric sorting and restores the default header on reset', async () => {
const w = page(); await flushPromises()
await change(w, { groupBy: 'OWNER' })
expect(w.find('button[data-column="inventoryCoverageDays"]').exists()).toBe(false)
await w.get('button[data-column="averageDailyOutboundQuantity"]').trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sortBy: 'AVERAGE_DAILY_DESC' })
w.findComponent(toolbar).vm.$emit('reset'); await flushPromises()
expect(w.get('button[data-column="physicalQuantity"]').attributes('aria-label')).toContain('当前降序')
})
it('keeps published historical averages visible while explaining pending rebuild separately', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.context.analyticsContext.rebuildRequired = true
result.context.analyticsContext.salesPublishedAt = '2026-10-14T01:40:24Z'
result.charts!.averageDaily = row.windows.map(window => ({ ...window,
averageDailyOutboundQuantity: window.days === 7 ? 1234.567143 : window.days === 15 ? 2100 : null,
outboundQuantity: window.days === 30 ? null : window.outboundQuantity,
completeDayCount: Math.min(window.days, 18), unknownDayCount: window.days === 30 ? 12 : 0,
dataStatus: window.days === 30 ? 'PARTIAL' : 'COMPLETE',
}))
return result
})
const w = page(); await flushPromises()
const chart = w.findComponent({ name: 'AnalyticsChart' }).props('option') as { series: { data: (number | null)[]; label: { show: boolean; formatter: (params: { value: number | null }) => string } }[] }
expect(chart.series[0]!.label.show).toBe(true)
expect(chart.series[0]!.label.formatter({ value: chart.series[0]!.data[0]! })).toBe('1,234.57')
expect(chart.series[0]!.label.formatter({ value: chart.series[0]!.data[1]! })).toBe('2,100')
expect(chart.series[0]!.data[2]).toBeNull()
expect(w.find('.turnover-window-summary').exists()).toBe(false)
expect(w.text()).toContain('完整 18 / 30 日')
expect(w.text()).toContain('已发布销量版本计算')
expect(w.text()).toContain('新采集数据待重算')
expect(w.findAllComponents({ name: 'AnalyticsChart' }).length).toBeGreaterThan(0)
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => response(q))
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
expect(w.text()).not.toContain('新采集数据待重算')
})
it('queries fast and slow tabs with a visible default threshold, preserving custom values', async () => {
const w = page(); await flushPromises()
const select = async (label: string) => { await w.findAll('.turnover-focus button').find(b => b.text() === label)!.trigger('click'); await flushPromises() }
await select('消化较慢')
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ focus: 'COVERAGE_GT', coverageDaysThreshold: 30, sortBy: 'COVERAGE_DESC', current: 1 })
expect(w.find('.turnover-focus button[aria-pressed="true"]').text()).toBe('消化较慢')
await w.get('input[aria-label="可售天数阈值(天)"]').setValue('60')
await select('消化较快')
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ focus: 'COVERAGE_LE', coverageDaysThreshold: 60, sortBy: 'COVERAGE_ASC' })
const calls = mocks.turnover.mock.calls.length
await w.get('input[aria-label="可售天数阈值(天)"]').setValue('0')
await select('消化较慢')
expect(mocks.turnover).toHaveBeenCalledTimes(calls)
expect(w.get('.turnover-filter-feedback').text()).toContain('正整数')
expect(w.find('.turnover-focus button[aria-pressed="true"]').text()).toBe('消化较快')
await select('全部')
expect(mocks.turnover.mock.calls.at(-1)![0].coverageDaysThreshold).toBeUndefined()
expect(w.find('.turnover-threshold').exists()).toBe(false)
expect(w.findComponent(toolbar).props('fields').some((field: { key: string }) => field.key === 'coverageDaysThreshold')).toBe(false)
})
it('queries the remaining focus tabs and uses owner-compatible sorting', async () => {
const w = page(); await flushPromises()
await change(w, { groupBy: 'OWNER' })
for (const [label, focus, sortBy] of [['所选时段无出库', 'NO_OUTBOUND_IN_WINDOW', 'NO_OUTBOUND_PHYSICAL_DESC'], ['待评估', 'UNASSESSABLE', 'PHYSICAL_DESC'], ['消化较慢', 'COVERAGE_GT', 'PHYSICAL_DESC'], ['消化较快', 'COVERAGE_LE', 'PHYSICAL_DESC'], ['全部', 'ALL', 'PHYSICAL_DESC']]) {
await w.findAll('.turnover-focus button').find(b => b.text() === label)!.trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', focus, sortBy, current: 1 })
}
})
it('shows stale inventory and failed collection without presenting unknown totals as zero', async () => {
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY', observedAt: '2026-10-14T00:48:00Z', currentTask: { taskNo: 'LXCOL-TEST-STOCK', taskStatus: 'DEAD', latestAttempt: { status: 'FAILED', errorCode: 'SELFOP_STOCK_DUPLICATE' } } }] })
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.context.analyticsContext.inventoryStatus = 'STALE'
result.context.inventoryObservedUntil = '2026-10-12T16:15:00Z'
result.summary!.physicalQuantity = { value: null, knownSubtotal: null, unknownSkuCount: 5402, reasonCodes: ['INVENTORY_UNKNOWN_OR_STALE'] }
result.summary!.noOutboundSkuCount = { value: null, knownSubtotal: 0, unknownSkuCount: 5402, reasonCodes: ['SALES_OR_STOCK_UNASSESSABLE'] }
return result
})
const w = page(); await flushPromises()
expect(w.text()).toContain('当前库存已过期')
expect(w.text()).toContain('库存分页出现重复记录')
expect(w.text()).toContain('LXCOL-TEST-STOCK')
expect(w.findAll('.turnover-cards article strong').map(n => n.text())).toEqual(['—', '100', '—', '100'])
expect(w.findAll('.turnover-cards article small')[2]!.text()).toBe('已知小计 0 · 5402 SKU待核验')
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY', currentTask: { taskNo: 'LXCOL-TEST-STOCK', taskStatus: 'SUCCEEDED' } }] })
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => { const result = response(q); result.context.analyticsContext.inventoryStatus = 'VALID'; return result })
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
expect(mocks.overview).toHaveBeenCalledTimes(2)
expect(w.text()).not.toContain('当前库存已过期'); expect(w.text()).not.toContain('库存分页出现重复记录')
expect(w.findAll('.turnover-cards article strong')[0]!.text()).toBe('100')
})
it('does not show another warehouse failure and separates runtime refresh failure from report data', async () => {
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'A', dataFamily: 'INVENTORY' }, { warehouseCode: 'B', dataFamily: 'INVENTORY', currentTask: { taskNo: 'LXCOL-OTHER', taskStatus: 'DEAD', latestAttempt: { status: 'FAILED', errorCode: 'SELFOP_STOCK_DUPLICATE' } } }] })
const w = page(); await flushPromises()
expect(w.text()).not.toContain('LXCOL-OTHER')
mocks.overview.mockRejectedValue(new Error('unavailable'))
await w.findAll('button').find(b => b.text() === '整体刷新')!.trigger('click'); await flushPromises()
expect(w.text()).toContain('采集状态更新失败')
expect(w.text()).not.toContain('库存周转读取失败')
expect(w.findAll('.turnover-cards article strong')[0]!.text()).toBe('100')
})
it('renders quantity and window cells through the shared table slot contract', async () => {
mocks.turnover.mockImplementation(async (q: TurnoverQuery) => {
const result = response(q)
result.rows[0]!.attentionReasons = ['UNASSESSABLE']
return result
})
const w = page(); await flushPromises()
expect(w.text()).not.toContain('[object Object]')
expect(w.text()).not.toContain('UNASSESSABLE')
expect(w.text()).toContain('数据不足/待观察')
expect(w.text()).toContain('300')
expect(w.text()).toContain('暂无可排名的库存数据')
expect(w.findAll('.turnover-cards article strong').map(n => n.text())).toEqual(['100', '100', '100', '100'])
expect(w.findAllComponents({ name: 'AppDialog' }).every(d => d.attributes('append-to-body') !== undefined)).toBe(true)
})
it('honors an explicit new route token in the same scope and reloads its overview', async () => {
const w = page(); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBeUndefined()
mocks.route.query = { endDate: '2026-10-12', warehouseCode: 'A', snapshotContext: 'token-T2' }
await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ warehouseCode: 'A', snapshotContext: 'token-T2', includeOverview: true })
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-T2', includeOverview: false, current: 2 })
})
it('loads the first warehouse once and keeps charts/context across filtered pagination', async () => {
const w = page(); await flushPromises()
expect(mocks.turnover).toHaveBeenCalledTimes(1)
expect(mocks.turnover.mock.calls[0]![0]).toMatchObject({ warehouseCode: 'A', groupBy: 'SKU', baseWindowDays: 30, includeOverview: true })
await change(w, { sku: 'SAME' }, true)
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-A', includeOverview: false, sku: 'SAME', current: 1 })
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ snapshotContext: 'token-A', current: 2, includeOverview: false })
expect(w.findAll('.turnover-cards article')).toHaveLength(4)
})
it('rejects unauthorized deep links and does not query without warehouses or permission', async () => {
mocks.route.query = { warehouseCode: 'NO' }; const w = page(); await flushPromises()
expect(w.text()).toContain('指定仓库不可用'); expect(mocks.turnover).not.toHaveBeenCalled()
mocks.route.query = {}; mocks.overview.mockResolvedValue({ warehouses: [] }); page(); await flushPromises(); expect(mocks.turnover).not.toHaveBeenCalled()
mocks.allowed = false; mocks.overview.mockClear(); page(); await flushPromises(); expect(mocks.overview).not.toHaveBeenCalled()
})
it('cancels old warehouse reads and never restores their late response', async () => {
let finish!: (value: TurnoverResponse) => void
mocks.turnover.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const w = page(); await flushPromises(); const old = mocks.turnover.mock.calls[0]!
await change(w, { warehouseCode: 'B' })
expect((old[1] as AbortSignal).aborted).toBe(true)
finish(response(old[0])); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0].warehouseCode).toBe('B')
expect(w.text()).toContain('本仓概览')
await change(w, { sku: 'X' }, true)
expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBe('token-B')
})
it('clears SKU sorting for owners and preserves original owner page on return', async () => {
const w = page(); await flushPromises()
await change(w, { sku: 'SAME', sortBy: 'COVERAGE_DESC' }, true)
await change(w, { groupBy: 'OWNER' })
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'OWNER', sku: undefined, sortBy: 'PHYSICAL_DESC', snapshotContext: 'token-A', includeOverview: true })
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
await w.findAll('button').find(b => b.text() === 'OWNER-A')!.trigger('click'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ groupBy: 'SKU', customerCode: 'OWNER-A', snapshotContext: 'token-A' })
await w.findAll('button').find(b => b.text() === '返回货主汇总')!.trigger('click'); await flushPromises()
expect(w.findComponent(table).props('page')).toBe(2)
expect(w.findComponent(toolbar).props('modelValue').groupBy).toBe('OWNER')
expect(w.findComponent(toolbar).props('modelValue')).toMatchObject({ rangePreset: 'CUSTOM', customRange: ['2026-09-13', '2026-10-12'] })
w.findComponent(table).vm.$emit('update:page', 1); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ endDate: '2026-10-12', baseWindowDays: 30, snapshotContext: 'token-A' })
})
it('blocks invalid thresholds and preserves known data on same-scope refresh failure', async () => {
const w = page(); await flushPromises()
await change(w, { focus: 'COVERAGE_GT', coverageDaysThreshold: '' }, true)
expect(mocks.turnover).toHaveBeenCalledTimes(1); expect(w.text()).toContain('请填写')
mocks.turnover.mockRejectedValue(new Error('offline'))
await change(w, { coverageDaysThreshold: 60 }, true)
expect(w.findComponent(table).props('data')).toHaveLength(1)
expect(w.text()).toContain('保留')
})
it('passes exact SKU identity and fixed versions into trend and cancels a closed request', async () => {
let finish!: (value: object) => void
mocks.daily.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const w = page(); await flushPromises()
await w.findAll('button').find(b => b.text() === 'SAME')!.trigger('click'); await flushPromises()
const [q, signal] = mocks.daily.mock.calls[0]!
expect(q).toMatchObject({ customerCode: 'OWNER-A', sku: 'SAME', warehouseCode: 'A', salesRunId: '11', inventoryRunId: '22', queryAsOf: '2026-10-13T00:00:00Z', startDate: '2026-09-13', endDate: '2026-10-12' })
const dialog = w.findAllComponents({ name: 'AppDialog' }).find(d => d.props('title') === 'SKU每日出库趋势')!
dialog.vm.$emit('update:modelValue', false); await flushPromises()
expect((signal as AbortSignal).aborted).toBe(true)
finish({}); await flushPromises()
expect(w.findComponent({ name: 'SkuTrend' }).exists()).toBe(false)
})
it('does not combine rows from a changed token with prior charts', async () => {
const w = page(); await flushPromises()
mocks.turnover.mockImplementationOnce(async (q: TurnoverQuery) => { const r = response(q); r.context.snapshotContext = 'different'; r.rows = []; return r })
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
expect(w.text()).toContain('查询版本已变化')
expect(w.findComponent(table).props('data')).toHaveLength(1)
})
})
@@ -0,0 +1,334 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Info } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { analyticsApi } from '../api/analytics'
import { RequestFence, beijingTime, contextParams } from '../state'
import type { DailyResponse, Overview } from '../types'
import { overviewRange, overviewRangeOptions, beijingDate } from '../overviewRange'
import type { RangePreset } from '../types'
import type { TurnoverQuery, TurnoverResponse, TurnoverRow, TurnoverSort, TurnoverWindow } from '../turnoverTypes'
import { turnoverRoute, validateTurnover, yesterdayBeijing } from '../turnoverNavigation'
import { turnoverSortColumns, turnoverSortOptions } from '../turnoverSorting'
import { totalText, turnoverTotalNote, inventoryCollectionNotice, turnoverChart, turnoverNumber, windowText, turnoverReasonsText as reasonsText } from '../turnoverPresentation'
import AnalyticsChart from './AnalyticsChart.vue'
import ChartDetailsDialog from './ChartDetailsDialog.vue'
import SkuTrend from './SkuTrend.vue'
import { useOwnerDirectory } from '../useOwnerDirectory'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const ownerDirectory = useOwnerDirectory()
const emit = defineEmits<{ warehouseSelected: [code: string] }>()
class TurnoverContractError extends Error {}
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
const runtime = ref<Overview | null>(null), data = ref<TurnoverResponse | null>(null)
const error = ref<ApiErrorDetails | null>(null), inputError = ref(''), notice = ref(''), loading = ref(false)
const filters = ref<QueryModel>({ warehouseCode: '', endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' })
const page = ref(1), size = ref(20), expanded = ref(false), chartsOpen = ref(true)
const fence = new RequestFence(), runtimeFence = new RequestFence(), trendFence = new RequestFence()
const trendOpen = ref(false), trendLoading = ref(false), daily = ref<DailyResponse | null>(null), trendError = ref<ApiErrorDetails | null>(null)
const evidence = ref<TurnoverRow | null>(null), evidenceOpen = ref(false)
const applied = ref<TurnoverQuery | null>(null)
let linkedSnapshot: string | undefined
let ownerReturn: { query: TurnoverQuery; result: TurnoverResponse; filters: QueryModel } | null = null
let initialized = false, active = true
const warehouses = computed(() => [...new Map((runtime.value?.warehouses ?? []).map(w => [w.warehouseCode, { value: w.warehouseCode, label: w.warehouseName || w.warehouseCode }])).values()])
const fields = computed<QueryField[]>(() => [
{ key: 'warehouseCode', label: '仓库', type: 'select', width: 170, options: warehouses.value, props: { clearable: false } },
{ key: 'rangePreset', label: '统计时间范围', type: 'select', width: 180, props: { clearable: false }, options: overviewRangeOptions },
...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'customRange', label: '日期范围', type: 'daterange' as const, width: 300, props: { disabledDate: (date: Date) => date.toLocaleDateString('en-CA') > yesterdayBeijing() } }] : []),
{ key: 'groupBy', label: '分组', type: 'select', width: 120, options: [{ value: 'SKU', label: '按SKU' }, { value: 'OWNER', label: '按货主' }], props: { clearable: false } },
{ key: 'customerCode', label: '客户', type: 'select', width: 210, options: ownerDirectory.options.value, placeholder: '名称或编码搜索', props: { filterable: true, clearable: true } },
...(filters.value.groupBy === 'SKU' ? [{ key: 'sku', label: 'SKU', type: 'input' as const, width: 175, placeholder: 'SKU精确查询' }] : []),
{ key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] },
{ key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSortOptions.filter(({ value }) => filters.value.groupBy === 'OWNER' ? !value.startsWith('COVERAGE_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') },
])
const ownerMode = computed(() => applied.value?.groupBy === 'OWNER')
const columns = computed<DataTableColumn[]>(() => [
...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slotName: 'sku', minWidth: 155 }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
{ prop: 'customerCode', label: '所属货主', slotName: 'owner', minWidth: 160 },
...(ownerMode.value ? [{ prop: 'physicalSkuCount', label: '有实物SKU数', minWidth: 125 }] : []),
{ prop: 'physicalQuantity', label: '实物件数', slotName: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slotName: 'availableQuantity', minWidth: 145 },
{ prop: 'transitQuantity', label: '在途', slotName: 'transitQuantity', minWidth: 125 },
...([7, 15, 30] as const).map(n => ({ prop: `outbound${n}`, label: `${n}日出库`, slotName: `outbound${n}`, minWidth: 160 })),
{ prop: 'averageDailyOutboundQuantity', label: `${applied.value?.baseWindowDays ?? 30}日日均`, slotName: 'daily', minWidth: 110 },
...(ownerMode.value ? [{ prop: 'noOutboundInWindow', label: '无出库SKU / 实物', slotName: 'noOutbound', minWidth: 190 }, { prop: 'slowCoverage', label: '慢消化SKU / 待判断', slotName: 'slow', minWidth: 175 }] : [{ prop: 'inventoryCoverageDays', label: '预计可售天数', slotName: 'coverage', minWidth: 150 }]),
{ prop: 'attentionReasons', label: '状态与样本', slotName: 'status', minWidth: 180 },
...(expanded.value ? [{ prop: 'frozenQuantity', label: '冻结件数', slotName: 'frozenQuantity', minWidth: 125 }, { prop: 'defectiveQuantity', label: '次品实物', slotName: 'defectiveQuantity', minWidth: 125 }, { prop: 'averagePhysicalQuantity', label: '期间平均实物', slotName: 'averageStock', minWidth: 145 }, { prop: 'quantityTurnoverCount', label: '期间件数周转', slotName: 'turnover', minWidth: 145 }, { prop: 'lastObservedOutboundDate', label: '最近已观测出库日', slotName: 'lastDate', minWidth: 180 }] : []),
].map(column => ({ ...column, headerSlotName: turnoverSortColumns[column.prop] ? 'sortHeader' : undefined })))
function columnSortDirection(prop: string) {
const metric = turnoverSortColumns[prop]
if (!metric) return undefined
return applied.value?.sortBy === `${metric}_ASC` ? 'ASC' : applied.value?.sortBy === `${metric}_DESC` ? 'DESC' : undefined
}
async function sortColumn(prop: string) {
const metric = turnoverSortColumns[prop]
if (!metric || loading.value) return
const sortBy: TurnoverSort = `${metric}_${columnSortDirection(prop) === 'DESC' ? 'ASC' : 'DESC'}`
filters.value = { ...filters.value, sortBy }
await search()
}
const cards = computed(() => data.value?.summary ? [
{ name: '实物件数', value: data.value.summary.physicalQuantity }, { name: '正品可售件数', value: data.value.summary.availableQuantity },
{ name: '所选时段无出库SKU', value: data.value.summary.noOutboundSkuCount }, { name: '待评估SKU', value: data.value.summary.unassessableSkuCount },
] : [])
const incompleteDailyWindows = computed(() => data.value?.charts?.averageDaily.filter(w => w.dataStatus !== 'COMPLETE' || w.averageDailyOutboundQuantity == null) ?? [])
const runtimeError = ref('')
const inventoryRuntime = computed(() => runtime.value?.warehouses.find(w => w.warehouseCode === applied.value?.warehouseCode && w.dataFamily === 'INVENTORY'))
const collectionNotice = computed(() => !data.value || runtimeError.value ? '' : inventoryCollectionNotice(inventoryRuntime.value))
async function refreshRuntime() {
const request = runtimeFence.begin()
try {
const result = await analyticsApi.overview('lingxing-primary', undefined, request.signal)
if (request.current()) { runtime.value = result; runtimeError.value = '' }
} catch { if (request.current()) runtimeError.value = '采集状态更新失败,请到数据采集查看;库存查询结果仍按其观测时间展示。' }
}
function castRow(row: unknown) { return row as TurnoverRow }
function restoreThreshold(warehouse: string) {
try {
const saved = sessionStorage.getItem(`selfop-turnover-threshold:${warehouse}`)
if (saved && /^\d+$/.test(saved) && Number.isSafeInteger(Number(saved)) && Number(saved) > 0) return saved
} catch { /* optional preference */ }
return ''
}
function quickFocus(focus: TurnoverQuery['focus'], sortBy: TurnoverSort) {
const needsThreshold = ['COVERAGE_GT', 'COVERAGE_LE'].includes(focus)
const threshold = filters.value.coverageDaysThreshold
filters.value = { ...filters.value, focus, sortBy, coverageDaysThreshold: needsThreshold && (threshold == null || String(threshold).trim() === '') ? 30 : threshold }; void search()
}
function query(refresh = false): TurnoverQuery {
const custom = Array.isArray(filters.value.customRange) ? filters.value.customRange : []
const anchor = !refresh && data.value ? beijingDate(Date.parse(data.value.context.queryAsOf)) : beijingDate(Date.now())
const range = overviewRange(filters.value.rangePreset as RangePreset, anchor, String(custom[0] ?? ''), String(custom[1] ?? ''))
const days = Math.round((Date.parse(range.endDate) - Date.parse(range.startDate)) / 86400000) + 1
return { sourceAccount: 'lingxing-primary', warehouseCode: String(filters.value.warehouseCode || ''), endDate: range.endDate, baseWindowDays: days, groupBy: filters.value.groupBy as TurnoverQuery['groupBy'], customerCode: String(filters.value.customerCode || '') || undefined, sku: String(filters.value.sku || '') || undefined, focus: filters.value.focus as TurnoverQuery['focus'], coverageDaysThreshold: !['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.value.focus)) || filters.value.coverageDaysThreshold === '' || filters.value.coverageDaysThreshold == null ? undefined : Number(filters.value.coverageDaysThreshold), turnoverStatus: (filters.value.turnoverStatus || undefined) as TurnoverQuery['turnoverStatus'], sortBy: filters.value.sortBy as TurnoverSort, current: page.value, size: size.value, includeOverview: true }
}
function scopeKey(q: TurnoverQuery) { return [q.warehouseCode, q.endDate, q.baseWindowDays].join('|') }
function overviewKey(q: TurnoverQuery) { return [scopeKey(q), q.customerCode, q.groupBy].join('|') }
async function load(refresh = false) {
if (!permitted.value || !active) return
fence.cancel(); inputError.value = ''; evidenceOpen.value = false; trendOpen.value = false
let q: TurnoverQuery
try { q = query(refresh) } catch (cause) { loading.value = false; inputError.value = (cause as Error).message; return }
if (applied.value && applied.value.warehouseCode !== q.warehouseCode) data.value = null
try {
validateTurnover(q)
if (!warehouses.value.some(w => w.value === q.warehouseCode)) throw new Error('指定仓库不可用,请重新选择有权限的仓库')
} catch (cause) { loading.value = false; inputError.value = (cause as Error).message; return }
const sameScope = applied.value && scopeKey(applied.value) === scopeKey(q)
q.snapshotContext = refresh ? undefined : linkedSnapshot ?? (sameScope ? data.value?.context.snapshotContext : undefined)
linkedSnapshot = undefined
q.includeOverview = refresh || q.snapshotContext !== data.value?.context.snapshotContext || !data.value?.summary || !applied.value || overviewKey(applied.value) !== overviewKey(q)
if (!q.snapshotContext) { q.current = 1; page.value = 1; q.includeOverview = true }
const previous = data.value
const request = fence.begin(); loading.value = true
if (refresh) void refreshRuntime()
try {
const result = await analyticsApi.turnover(q, request.signal)
if (!request.current()) return
if (result.context.warehouseCode !== q.warehouseCode || result.context.endDate !== q.endDate || result.context.baseWindowDays !== q.baseWindowDays) throw new TurnoverContractError('返回的查询范围不一致,请整体刷新')
if (q.snapshotContext && result.context.snapshotContext !== q.snapshotContext) throw new TurnoverContractError('查询版本已变化,请整体刷新')
if (q.includeOverview && (!result.summary || !result.charts)) throw new TurnoverContractError('本次概览未完整返回,请整体刷新')
data.value = { ...result, summary: q.includeOverview ? result.summary : previous?.summary ?? null, charts: q.includeOverview ? result.charts : previous?.charts ?? null }
applied.value = q; error.value = null; emit('warehouseSelected', q.warehouseCode)
void ownerDirectory.load(result.context.analyticsContext, q.warehouseCode, request.signal)
try {
if (q.coverageDaysThreshold != null) sessionStorage.setItem(`selfop-turnover-threshold:${q.warehouseCode}`, String(q.coverageDaysThreshold))
} catch { /* optional preference */ }
} catch (cause) { if (request.current()) { error.value = getApiErrorDetails(cause, cause instanceof TurnoverContractError ? cause.message : '库存周转读取失败;请重试'); if (data.value && applied.value) page.value = applied.value.current } }
finally { if (request.current()) loading.value = false }
}
async function search() { page.value = 1; await load() }
function reset() {
const warehouseCode = filters.value.warehouseCode || applied.value?.warehouseCode || warehouses.value[0]?.value || ''
filters.value = { warehouseCode, endDate: yesterdayBeijing(), baseWindowDays: 30, rangePreset: 'LAST_30_DAYS', customRange: [], groupBy: 'SKU', sku: '', customerCode: '', focus: 'ALL', coverageDaysThreshold: '', turnoverStatus: '', sortBy: 'PHYSICAL_DESC' }
ownerReturn = null; page.value = 1; void load(true)
}
function changeFilters(next: QueryModel) {
notice.value = ''
if (next.groupBy !== filters.value.groupBy) {
next = { ...next, sku: '', sortBy: 'PHYSICAL_DESC' }; notice.value = '已切换分组,SKU条件及专属排序已清除'
}
const changed = ['warehouseCode', 'rangePreset', 'groupBy', 'customerCode'].some(k => next[k] !== filters.value[k])
if (next.warehouseCode !== filters.value.warehouseCode) next = { ...next, coverageDaysThreshold: restoreThreshold(String(next.warehouseCode || '')) }
filters.value = next
if (changed) { page.value = 1; void load() }
}
function selectOwner(code: string) {
if (applied.value?.groupBy === 'OWNER' && data.value) ownerReturn = { query: { ...applied.value }, result: data.value, filters: { ...filters.value, customRange: [...(filters.value.customRange as string[] ?? [])] } }
filters.value = { ...filters.value, groupBy: 'SKU', customerCode: code, sku: '', sortBy: 'PHYSICAL_DESC' }; page.value = 1; void load()
}
function returnOwners() {
if (!ownerReturn) return
if (Date.parse(ownerReturn.result.context.queryExpiresAt) <= Date.now()) { inputError.value = '原货主汇总版本已过期,请整体刷新'; return }
fence.cancel(); trendOpen.value = false; evidenceOpen.value = false; loading.value = false; error.value = null
const saved = ownerReturn; filters.value = { ...saved.filters, ...saved.query, sku: '', customerCode: saved.query.customerCode || '', coverageDaysThreshold: saved.query.coverageDaysThreshold ?? '', turnoverStatus: saved.query.turnoverStatus || '' }
applied.value = saved.query; page.value = saved.query.current; data.value = saved.result; ownerReturn = null
}
async function showTrend(row: TurnoverRow) {
if (!row.sku || !data.value || !applied.value) return
const metric = row.windows.find(w => w.days === applied.value!.baseWindowDays)
if (!metric) return
trendOpen.value = true; daily.value = null; trendError.value = null; trendLoading.value = true
const request = trendFence.begin(), context = data.value.context
try {
const end = new Date(Date.parse(`${metric.endDateExclusive}T00:00:00Z`) - 86400000).toISOString().slice(0, 10)
const result = await analyticsApi.daily({ sourceAccount: applied.value.sourceAccount, warehouseCode: applied.value.warehouseCode, customerCode: row.customerCode, sku: row.sku, rangePreset: 'CUSTOM', startDate: metric.startDate, endDate: end, current: 1, size: 100, ...contextParams(context.analyticsContext) }, request.signal)
if (!request.current()) return
if (result.context.salesRunId !== context.analyticsContext.salesRunId || result.context.inventoryRunId !== context.analyticsContext.inventoryRunId || result.context.queryAsOf !== context.queryAsOf) throw new Error('趋势版本与周转列表不一致,请整体刷新')
daily.value = result
} catch (cause) { if (request.current()) trendError.value = getApiErrorDetails(cause, '趋势读取失败') }
finally { if (request.current()) trendLoading.value = false }
}
watch(trendOpen, open => { if (!open) { trendFence.cancel(); daily.value = null; trendLoading.value = false } })
function readRoute() {
try {
const initial = turnoverRoute(route.query); linkedSnapshot = initial.snapshotContext
filters.value = { ...filters.value, ...initial, ...(initial.endDate ? { rangePreset: 'CUSTOM', customRange: [new Date(Date.parse(initial.endDate) - ((initial.baseWindowDays ?? 30) - 1) * 86400000).toISOString().slice(0, 10), initial.endDate] } : {}) }; page.value = initial.current ?? 1; size.value = initial.size ?? 20
return true
} catch (cause) { fence.cancel(); data.value = null; inputError.value = (cause as Error).message; return false }
}
async function initialize() {
if (!permitted.value || !readRoute()) return
const request = runtimeFence.begin()
try {
const result = await analyticsApi.overview('lingxing-primary', undefined, request.signal)
if (!request.current()) return
runtime.value = result; runtimeError.value = ''
if (!filters.value.warehouseCode) filters.value.warehouseCode = warehouses.value[0]?.value ?? ''
if (route.query.coverageDaysThreshold == null) filters.value.coverageDaysThreshold = restoreThreshold(String(filters.value.warehouseCode || ''))
if (filters.value.warehouseCode) await load()
} catch (cause) { if (request.current()) error.value = getApiErrorDetails(cause, '仓库列表读取失败') }
}
watch(() => route.query, () => { if (active && readRoute()) void load() })
watch(chartsOpen, value => { try { sessionStorage.setItem('selfop-turnover-charts', String(value)) } catch { /* optional preference */ } })
onMounted(() => { initialized = true; try { chartsOpen.value = sessionStorage.getItem('selfop-turnover-charts') !== 'false' } catch { /* optional preference */ } void initialize() })
onDeactivated(() => { active = false; fence.cancel(); runtimeFence.cancel(); trendFence.cancel(); loading.value = false })
onActivated(() => { const resume = initialized && !active; active = true; if (resume) void initialize() })
onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() })
</script>
<template>
<AdminPage class="admin-density turnover-page">
<p v-if="!permitted" role="alert">没有库存分析查看权限。</p>
<template v-else>
<LoadErrorState v-if="error" title="库存周转读取失败" :message="error.message" compact @retry="runtime ? load() : initialize()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="inputError" role="alert">{{ inputError }}</p><p v-if="notice" role="status">{{ notice }}</p>
<DataTable :data="data?.rows ?? []" :columns="columns" :loading="loading" :total="data?.total ?? 0" :page="page" :page-size="size" appearance="integrated" :empty-text="!warehouses.length ? '暂无可用仓库' : error ? '读取失败,请重试' : '暂无符合条件的数据'" @update:page="page = $event; load()" @update:page-size="size = $event; search()">
<template #sortHeader="{ column }">
<button type="button" class="turnover-sort" :data-column="column.property" :disabled="loading" :aria-label="`${column.label}:${columnSortDirection(column.property) === 'ASC' ? '当前升序,' : columnSortDirection(column.property) === 'DESC' ? '当前降序,' : ''}点击${columnSortDirection(column.property) === 'DESC' ? '升序' : '降序'}排列`" @click.stop="sortColumn(column.property)">
{{ column.label }}
<span class="turnover-sort-arrows" aria-hidden="true"><span :class="{ active: columnSortDirection(column.property) === 'ASC' }">▲</span><span :class="{ active: columnSortDirection(column.property) === 'DESC' }">▼</span></span>
</button>
</template>
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span></h2></slot></template>
<template #toolbar-actions><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷每日首次合格计划库存的实物均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p></div></el-popover></template>
<template #filters>
<p v-if="ownerDirectory.error.value" role="alert">{{ ownerDirectory.error.value }}</p>
<QueryToolbar :model-value="filters" :fields="fields" ariaLabel="库存周转查询" preference-key="selfop-turnover-query" @update:model-value="changeFilters" @search="search" @reset="reset" />
</template>
<template #before-results>
<div class="turnover-context">
<template v-if="filters.customerCode"><el-tag closable @close="filters.customerCode = ''; search()">货主:{{ filters.customerCode }}</el-tag><el-button v-if="ownerReturn" link @click="returnOwners">返回货主汇总</el-button></template>
<template v-if="data"><span>{{ applied?.customerCode ? '本货主概览' : '本仓概览' }} · 截止 {{ applied?.endDate }} · 基准 {{ applied?.baseWindowDays }} 天</span><span>当前库存采集 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</span><span v-if="error">保留 {{ beijingTime(data.context.queryAsOf) }} 查询结果</span></template>
</div>
<p v-if="data?.context.analyticsContext.inventoryStatus === 'STALE'" class="turnover-context" role="status">当前库存已过期,实物、可售、货主排名及预计可售天数暂不可用;最近库存观测完成于 {{ beijingTime(data.context.inventoryObservedUntil) }}。采集成功后请整体刷新。</p>
<p v-else-if="data?.context.analyticsContext.inventoryStatus === 'UNKNOWN'" class="turnover-context" role="status">当前尚无可用库存版本,请查看数据采集。</p>
<p v-if="collectionNotice" class="turnover-context" role="status">{{ collectionNotice }};采集状态截至 {{ beijingTime(inventoryRuntime?.observedAt) }}。<el-button link @click="router.push('/self-operated/collection')">查看数据采集</el-button></p>
<p v-if="runtimeError" class="turnover-context" role="status">{{ runtimeError }}</p>
<div v-if="cards.length" class="turnover-cards"><article v-for="card in cards" :key="card.name"><span>{{ card.name }}</span><strong>{{ turnoverNumber(card.value.value) }}</strong><small>{{ turnoverTotalNote(card.value) }}</small></article></div>
<template v-if="data?.charts">
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCode ? '本货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
<div v-if="chartsOpen" class="turnover-charts">
<section><header class="turnover-chart-header"><h3>货主实物件数 Top10</h3><ChartDetailsDialog inline button-label="查看明细" title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></header><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p></section>
<section><header class="turnover-chart-header"><h3>日均出库对比(含所选时段)</h3><ChartDetailsDialog inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog></header><p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? '日均暂不可用' : '覆盖不足' }}(完整 {{ window.completeDayCount }} / {{ window.days }} 日)</span></div></section>
</div>
</template>
<nav class="turnover-focus" aria-label="库存关注快捷筛选">
<button type="button" :aria-pressed="applied?.focus === 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部</button>
<button type="button" :aria-pressed="applied?.focus === 'NO_OUTBOUND_IN_WINDOW'" @click="quickFocus('NO_OUTBOUND_IN_WINDOW', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">所选时段无出库</button>
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_GT'" @click="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')">消化较慢</button>
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_LE'" @click="quickFocus('COVERAGE_LE', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_ASC')">消化较快</button>
<button type="button" :aria-pressed="applied?.focus === 'UNASSESSABLE'" @click="quickFocus('UNASSESSABLE', 'PHYSICAL_DESC')">待评估</button>
</nav>
<div v-if="['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold">
<label>可售天数阈值 <input v-model="filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="可售天数阈值(天)" @keydown.enter.prevent="search()" /> 天</label>
<el-button @click="search()">应用</el-button>
<span>按当前可售库存预计还能销售的天数筛选:超过阈值为较慢,不超过阈值为较快。此数值不是上方的统计时间范围;快捷筛选首次默认30天,可修改。零可售或无出库不归为较快。</span>
</div>
<p v-if="inputError" class="turnover-filter-feedback" role="alert">{{ inputError }}</p>
<p v-else-if="loading" class="turnover-filter-feedback" role="status">正在更新筛选结果…</p>
<p v-else-if="error" class="turnover-filter-feedback" role="alert">筛选未更新,当前保留上次结果。{{ error.message }}</p>
</template>
<template #sku="{ row }"><el-button link type="primary" @click="showTrend(castRow(row))">{{ castRow(row).sku }}</el-button></template>
<template #owner="{ row }"><el-button v-if="ownerMode" link type="primary" @click="selectOwner(castRow(row).customerCode)">{{ castRow(row).customerName || castRow(row).customerCode }}</el-button><span v-else>{{ castRow(row).customerName || castRow(row).customerCode }}</span></template>
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'transitQuantity', 'frozenQuantity', 'defectiveQuantity'] as const" :key="key" #[key]="{ row }">{{ totalText(castRow(row)[key]) }}</template>
<template v-for="n in [7, 15, 30] as TurnoverWindow[]" :key="n" #[`outbound${n}`]="{ row }">{{ windowText(castRow(row).windows.find(w => w.days === n)) }}</template>
<template #daily="{ row }">{{ turnoverNumber(castRow(row).averageDailyOutboundQuantity) }}</template>
<template #coverage="{ row }">{{ turnoverNumber(castRow(row).inventoryCoverageDays, 1) }}<small>{{ reasonsText([castRow(row).coverageStatus]) }}</small></template>
<template #status="{ row }"><small>{{ reasonsText(castRow(row).attentionReasons) }}</small><span v-if="ownerMode">待评估 {{ castRow(row).unassessableSkuCount }} SKU</span><el-button link @click="evidence = castRow(row); evidenceOpen = true">日样本 {{ castRow(row).qualifiedSampleDays }}/{{ castRow(row).requiredSampleDays }}</el-button></template>
<template #noOutbound="{ row }">{{ turnoverNumber(castRow(row).noOutboundInWindow.matchedSkuCount) }} SKU / {{ turnoverNumber(castRow(row).noOutboundInWindow.knownPhysicalQuantity) }} 件<small>待判断 {{ castRow(row).noOutboundInWindow.unknownSkuCount }} SKU</small></template>
<template #slow="{ row }">{{ applied?.coverageDaysThreshold ? `${turnoverNumber(castRow(row).slowCoverage.matchedSkuCount)} SKU / ${castRow(row).slowCoverage.unknownSkuCount} SKU` : '未设查询阈值' }}</template>
<template #averageStock="{ row }">{{ turnoverNumber(castRow(row).averagePhysicalQuantity) }}</template>
<template #turnover="{ row }">{{ turnoverNumber(castRow(row).quantityTurnoverCount) }} 次/{{ applied?.baseWindowDays }}日</template>
<template #lastDate="{ row }">{{ castRow(row).lastObservedOutboundDate || '已采集历史内未见出库' }}</template>
</DataTable>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" append-to-body fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog>
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="周转样本与覆盖证据" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};合格日样本 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }};积累起点 {{ data.context.accumulationStartedOn || '尚无日样本' }}</p><p>缺口日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日代表规则:每天首次合格计划采集;仅纳入 {{ beijingTime(data.context.queryAsOf) }} 以前已发布代表。</p><p>日样本是每日库存观测,不是出库历史天数;补采和验证任务不计入。今天采集成功的合格计划库存样本,最早次日查询时才能纳入窗口;当天样本数仍为0不代表采集未成功,历史缺日不能补零。</p></template></AppDialog>
</template>
</AdminPage>
</template>
<style scoped>
.turnover-sort { display: inline-flex; align-items: center; gap: 6px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; }
.turnover-sort:disabled { cursor: wait; }
.turnover-sort:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 3px; }
.turnover-sort-arrows { display: inline-flex; flex-direction: column; color: var(--el-text-color-placeholder); font-size: 9px; line-height: 10px; }
.turnover-sort-arrows .active { color: var(--el-color-primary); }
.turnover-page :deep(.data-table--integrated > .data-table__summary) { padding: 12px 20px 0; }
.turnover-page :deep(.data-table--integrated > .el-table) { width: calc(100% - 40px); margin-inline: 20px; }
.turnover-title { margin: 0; font-size: var(--admin-font-section); display: flex; align-items: center; gap: 16px; }
.turnover-title span { font-size: var(--admin-font-caption); font-weight: 400; color: var(--el-text-color-secondary); }
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-cards { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 12px; padding: 4px 0 12px; }
.turnover-cards article { position: relative; overflow: hidden; padding: 16px 20px; border: 1px solid var(--el-color-primary-light-8); background: linear-gradient(120deg, #fff, var(--el-color-primary-light-9)); border-radius: 10px; color: var(--el-color-primary); }
.turnover-cards article:nth-child(2) { color: #248260; border-color: #dbeee5; background: linear-gradient(120deg, #fff, #edf8f1); }
.turnover-cards article:nth-child(3) { color: #b37a22; border-color: #f4e7d3; background: linear-gradient(120deg, #fff, #fff6e8); }
.turnover-cards article:nth-child(4) { color: #8a62bf; border-color: #e9e0f5; background: linear-gradient(120deg, #fff, #f6f0fc); }
.turnover-cards article::after { content: ''; position: absolute; width: 88px; height: 88px; border-radius: 50%; right: -22px; top: -30px; background: currentColor; opacity: .07; pointer-events: none; }
.turnover-cards article > span { color: var(--el-text-color-regular); }
.turnover-cards strong { display: block; font-size: 28px; margin: 8px 0 4px; font-variant-numeric: tabular-nums; }
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 0 12px; align-items: stretch; }
.turnover-charts section { height: 360px; box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 10px; }
.turnover-chart { flex: 1 1 0; height: 0; min-height: 120px; width: 100%; }
.turnover-chart-empty { flex: 1 1 0; min-height: 0; padding: 16px; text-align: center; }
.turnover-chart-header { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.turnover-chart-header h3 { margin: 0; }
.turnover-chart-header :deep(.chart-disclosure) { flex-shrink: 0; }
.turnover-chart-empty { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; color: var(--el-text-color-secondary); background: var(--el-fill-color-lighter); border-radius: 8px; margin-top: 12px; }
.turnover-chart-empty strong { color: var(--el-text-color-regular); }
.turnover-chart-note { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin: 8px 0 0; }
.turnover-window-gaps { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 8px; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-focus { display: flex; flex-wrap: wrap; gap: 24px; margin: 4px 0 0; border-bottom: 1px solid var(--el-border-color-light); }
.turnover-focus button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 12px 4px; font: inherit; color: var(--el-text-color-regular); cursor: pointer; }
.turnover-focus button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
.turnover-focus button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.turnover-threshold { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 10px 0; font-size: var(--admin-font-caption); }
.turnover-threshold label { display: flex; align-items: center; gap: 8px; }
.turnover-threshold input { width: 76px; padding: 6px 8px; border: 1px solid var(--el-border-color); border-radius: var(--admin-radius); font: inherit; color: var(--el-text-color-primary); }
.turnover-threshold > span { color: var(--el-text-color-secondary); }
.turnover-filter-feedback { margin: 8px 0; color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption); }
h3 { margin: 0 0 12px; font-size: var(--admin-font-body); border-left: 3px solid var(--el-color-primary); padding-left: 10px; }
small { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-rules { font-size: var(--admin-font-caption); line-height: 1.7; }
</style>
@@ -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()
})
})
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { OwnerOverview, QuantityTotal, RiskShare } from '../types'
import { beijingTime, label, quantityText, reasonsText } from '../state'
defineProps<{ data: OwnerOverview }>()
const metrics: { key: keyof Pick<OwnerOverview, 'physicalSkuCount' | 'physicalQuantity' | 'availableQuantity' | 'frozenQuantity' | 'defectivePhysicalQuantity' | 'transitQuantity' | 'outboundQuantity30d' | 'averageDailyOutboundQuantity'>; name: string }[] = [
{ key: 'physicalSkuCount', name: '有实物库存 SKU 数' }, { key: 'physicalQuantity', name: '全品质实物件数' },
{ key: 'availableQuantity', name: '正品可售件数' }, { key: 'frozenQuantity', name: '全品质冻结件数' },
{ key: 'defectivePhysicalQuantity', name: '次品实物件数' }, { key: 'transitQuantity', name: '在途件数' },
{ key: 'outboundQuantity30d', name: '近30天出库件数' }, { key: 'averageDailyOutboundQuantity', name: '30天日均出库' },
]
const risks: { key: 'noOutbound30d' | 'coverageAboveThreshold'; name: string }[] = [{ key: 'noOutbound30d', name: '近30天无出库' }, { key: 'coverageAboveThreshold', name: '可售天数超过本次阈值' }]
function totalText(metric: QuantityTotal) { return `${quantityText(metric.overallTotal)} · ${label(metric.dataStatus)}` }
function ratioText(risk: RiskShare) { return risk.physicalQuantityRatio == null ? '—' : `${(Number(risk.physicalQuantityRatio) * 100).toFixed(2)}%` }
</script>
<template>
<section aria-label="货主经营概览">
<h3>货主 {{ data.customerCode }} {{ data.customerName ?? '' }} · 经营概览</h3>
<p>固定所选货主全量范围,不受下方 SKU、关注筛选或分页影响。SKU 候选数 {{ quantityText(data.skuCount) }}。实物件数不是空间占比,不自动评级。</p>
<p>近30完整日计算区间 {{ data.context.rangeStartDate ?? '—' }} 至 {{ data.context.rangeEndDateExclusive ?? '—' }}(结束边界不含);销量质量:{{ label(data.context.salesStatus) }};库存质量:{{ label(data.context.inventoryStatus) }}。库存观测时段:{{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}。</p>
<table class="density-table">
<thead><tr><th>指标</th><th>正式总量 / 质量</th><th>已知有效小计(部分资料)</th><th>未知 SKU 数</th><th>依据</th></tr></thead>
<tbody><tr v-for="metric in metrics" :key="metric.key"><th>{{ metric.name }}</th><td>{{ totalText(data[metric.key]) }}</td><td>{{ quantityText(data[metric.key].knownSubtotal) }}</td><td>{{ quantityText(data[metric.key].unknownSkuCount) }}</td><td>{{ reasonsText(data[metric.key].reasonCodes) }}</td></tr></tbody>
</table>
<table class="density-table">
<thead><tr><th>关注</th><th>命中 SKU 数 / 实物件数</th><th>占货主实物件数比例</th><th>已确认命中小计</th><th>待判断 SKU / 已知实物件数</th><th>依据</th></tr></thead>
<tbody><tr v-for="risk in risks" :key="risk.key"><th>{{ risk.name }}</th><td>{{ quantityText(data[risk.key].matchedSkuCount) }} / {{ quantityText(data[risk.key].matchedPhysicalQuantity) }}</td><td>{{ ratioText(data[risk.key]) }}</td><td>{{ quantityText(data[risk.key].knownMatchedSkuCount) }} SKU / {{ quantityText(data[risk.key].knownMatchedPhysicalQuantity) }} PCS</td><td>{{ quantityText(data[risk.key].unassessableSkuCount) }} / {{ quantityText(data[risk.key].unassessableKnownPhysicalQuantity) }}</td><td>{{ reasonsText(data[risk.key].reasonCodes) }}</td></tr></tbody>
</table>
<p>占比的分母为本货主全品质实物正式总量 {{ quantityText(data.physicalQuantity.overallTotal) }} PCS({{ label(data.physicalQuantity.dataStatus) }})。冻结及次品均已包含在实物中,在途不计当前占仓。{{ Number(data.physicalQuantity.overallTotal) === 0 && data.physicalQuantity.overallTotal !== null ? '本货主无实物库存,占比不适用。' : '' }}</p>
</section>
</template>
@@ -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<typeof mount>[] = []
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('货主选项版本已变化')
})
})
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue'
import { analyticsApi } from '../api/analytics'
import type { OwnerOption, QueryContext } from '../types'
import { contextParams, RequestFence, sameContext } from '../state'
import { getApiErrorMessage } from '@/shared/api/http'
const props = defineProps<{ modelValue: string; sourceAccount: string; warehouseCode: string; context: QueryContext | null }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const options = ref<OwnerOption[]>([])
const keyword = ref('')
const current = ref(1)
const total = ref(0)
const loading = ref(false)
const error = ref('')
const fence = new RequestFence()
async function load() {
const request = fence.begin()
if (!props.warehouseCode || !props.context) { options.value = []; total.value = 0; loading.value = false; return }
loading.value = true
error.value = ''
try {
const result = await analyticsApi.owners({ sourceAccount: props.sourceAccount, warehouseCode: props.warehouseCode, ...contextParams(props.context), keyword: keyword.value || undefined, current: current.value, size: 20 }, request.signal)
if (!request.current()) return
if (!props.context || !sameContext(props.context, result.context)) {
options.value = []; total.value = 0; error.value = '货主选项版本已变化,请整体刷新'
return
}
options.value = result.records
total.value = result.total
} catch (cause) { if (request.current()) { options.value = []; total.value = 0; error.value = getApiErrorMessage(cause, '货主选项读取失败') } }
finally { if (request.current()) loading.value = false }
}
function search(value: string) { keyword.value = value; current.value = 1; void load() }
function page(delta: number) { current.value += delta; void load() }
watch(() => [props.warehouseCode, props.context?.queryAsOf, props.context?.salesRunId, props.context?.inventoryRunId], () => { current.value = 1; keyword.value = ''; void load() }, { immediate: true })
onUnmounted(() => fence.cancel())
</script>
<template>
<div class="density-actions">
<label>货主
<el-select :model-value="modelValue" :disabled="!warehouseCode || !context" clearable filterable remote :remote-method="search" :loading="loading" placeholder="全部货主;输入编码前缀查找" aria-label="货主选择" @update:model-value="emit('update:modelValue', String($event ?? ''))">
<el-option v-if="modelValue && !options.some(option => option.customerCode === modelValue)" :value="modelValue" :label="modelValue" />
<el-option v-for="option in options" :key="option.customerCode" :value="option.customerCode" :label="option.customerName ? `${option.customerCode} · ${option.customerName}` : option.customerCode" />
</el-select>
</label>
<el-button :disabled="loading || current <= 1" @click="page(-1)">上一页货主</el-button>
<span>第 {{ current }} 页 · 共 {{ total }} 个货主</span>
<el-button :disabled="loading || current * 20 >= total" @click="page(1)">下一页货主</el-button>
<span v-if="error" role="alert">{{ error }}</span>
<span v-else-if="context && !loading && !total">当前查询无可用货主资料;未发布数据不代表仓库没有货主。</span>
</div>
</template>
@@ -0,0 +1,39 @@
vi.mock('@/shared/components/AppDialog.vue', () => ({ default: { props: ['modelValue'], template: '<section v-if="modelValue"><slot /></section>' } }))
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent } from 'vue'
vi.mock('vue-echarts', () => ({ default: defineComponent({ name: 'VChart', props: ['option', 'autoresize', 'initOptions'], template: '<div />' }) }))
import SkuTrend from './SkuTrend.vue'
import type { DailyResponse } from '../types'
function data(status = 'COMPLETE'): DailyResponse {
return {
context: { salesRunId: null, inventoryRunId: null, salesScopeHash: null, inventoryScopeHash: null, queryAsOf: '', queryExpiresAt: '', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13' },
summary: { sourceAccount: 'test', customerCode: 'OWNER', warehouseCode: 'WH', sku: 'SKU', productName: null, quantityUnit: 'PCS', outboundQuantity: '30', observedQuantity: '30', dataStatus: status, reasonCodes: [], completeDayCount: 30, partialDayCount: 0, unknownDayCount: 0 },
points: Array.from({ length: 30 }, (_, index) => ({ businessDate: `2026-09-${String(index + 1).padStart(2, '0')}`, outboundQuantity: status === 'UNKNOWN' ? null : '1', observedQuantity: status === 'UNKNOWN' ? null : '1', dataStatus: status, quantityUnit: 'PCS', reasonCodes: [] })),
}
}
describe('SKU daily trend axes', () => {
it('shows both axes, intermediate dates, zero and maximum ticks for a 30-day window', () => {
const w = mount(SkuTrend, { props: { data: data() } })
const chart = w.findComponent({ name: 'VChart' })
const option = chart.props('option')
expect(option.xAxis).toMatchObject({ type: 'category', name: '日期(北京时间)' })
expect(option.xAxis.data).toHaveLength(30)
expect(option.yAxis).toMatchObject({ type: 'value', min: 0, name: '出库量(件)' })
expect(option.series[1].data).toEqual(Array(30).fill(1))
expect(option.tooltip.trigger).toBe('axis')
expect(chart.props('initOptions')).toEqual({ renderer: 'canvas' })
expect(w.find('svg').exists()).toBe(false)
w.unmount()
})
it('retains axes and date markers for an unknown window without drawing zero points', () => {
const w = mount(SkuTrend, { props: { data: data('UNKNOWN') } })
expect(w.text()).toContain('未知日期不作为零销量')
const option = w.findComponent({ name: 'VChart' }).props('option')
expect(option.series[0].data.every((point: { value: number | null }) => point.value === null)).toBe(true)
expect(option.series).toHaveLength(1)
expect(option.xAxis.data).toHaveLength(30)
w.unmount()
})
})
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import ChartDetailsDialog from './ChartDetailsDialog.vue'
import AnalyticsChart from './AnalyticsChart.vue'
import type { DailyResponse } from '../types'
import { label, quantityText, shownQuantity, reasonsText } from '../state'
import { skuTrendOption } from '../skuTrendChart'
const props = defineProps<{ data: DailyResponse }>()
const option = computed(() => skuTrendOption(props.data.points))
const hasData = computed(() => props.data.points.some(point => point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null))
</script>
<template>
<section aria-label="SKU每日出库趋势">
<h3>{{ data.summary.sku }} · 每日出库趋势</h3>
<p>所属货主:{{ data.summary.customerCode || '—' }} · {{ data.summary.productName || '—' }}</p>
<p>{{ data.summary.dataStatus === 'COMPLETE' ? '完整出库量' : '已采集出库量' }}:{{ quantityText(shownQuantity(data.summary)) }} {{ data.summary.quantityUnit === 'PCS' ? '件' : '单位未知' }};{{ label(data.summary.dataStatus) }}。今日为已采集累计。</p>
<p v-if="!hasData">所选范围尚无可靠数据,未知日期不作为零销量。</p>
<p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 空缺:未知,缺失日期断线</p>
<AnalyticsChart :option="option" class="sku-trend" aria-label="SKU每日出库趋势" />
<ChartDetailsDialog title="每日数量、完整性及覆盖证据" :reset-on="data">
<table class="density-table"><thead><tr><th>北京时间日期</th><th>完整出库量</th><th>已采集量</th><th>状态</th><th>覆盖截止</th><th>缺口原因</th></tr></thead><tbody>
<tr v-for="point in data.points" :key="point.businessDate"><td>{{ point.businessDate }}</td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ point.coveredUntil ?? '—' }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr>
</tbody></table>
</ChartDetailsDialog>
</section>
</template>
<style scoped>
.sku-trend { display: block; width: 100%; height: 340px; }
.trend-legend { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
</style>
@@ -0,0 +1,35 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import TaskExecutionFacts from './TaskExecutionFacts.vue'
import type { Task } from '../types'
import { hasActiveTask, label } from '../state'
const waiting: Task = { taskNo: 'LOCAL-A', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'WAITING_SOURCE', applyState: 'NOT_STARTED', attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: 'TASK_NOT_RETRYABLE', progress: null, queryFrom: null, queryUntil: null }
describe('collection execution facts', () => {
it('explains why a waiting warehouse has no attempt times or quality counts and keeps polling active', () => {
const wrapper = mount(TaskExecutionFacts, { props: { task: waiting, scopeLabel: '本仓任务' } })
expect(wrapper.text()).toContain('本仓任务尚未开始执行尝试')
expect(wrapper.text()).toContain('正在等待前面的数据准备任务完成')
expect(wrapper.text()).toContain('尚未生成校验摘要')
expect(wrapper.text()).not.toContain('耗时 0 秒')
expect(label(waiting.taskStatus)).toBe('等待来源数据')
expect(hasActiveTask([waiting])).toBe(true)
wrapper.unmount()
})
it('renders real upstream attempt times and preserves zero counts separately from missing counts', () => {
const upstream: Task = { ...waiting, taskNo: 'SHARED-A', taskStatus: 'RUNNING', attemptCount: 1, progress: { validationState: 'VALIDATING', invalidCount: 0, unknownCount: null, pendingReviewCount: 2, countUnit: 'ORDER', summaryAsOf: '2026-10-13T01:02:00Z' }, latestAttempt: { attemptNo: 1, status: 'FETCHING', phase: 'DETAIL', startedAt: '2026-10-13T01:00:00Z', finishedAt: null, progressUpdatedAt: '2026-10-13T01:02:00Z', heartbeatAt: '2026-10-13T01:02:00Z', executionHealth: 'HEALTHY', elapsedSeconds: 120, errorCode: null, failureSummary: null } }
const wrapper = mount(TaskExecutionFacts, { props: { task: upstream, scopeLabel: '账号共享上游' } })
expect(wrapper.text()).toContain('2026/10/13 09:00:00')
expect(wrapper.text()).toContain('结束 尚未结束')
expect(wrapper.text()).toContain('耗时 120 秒')
expect(wrapper.text()).toContain('心跳 2026/10/13 09:02:00 · 正常')
expect(wrapper.text()).toContain('异常 0,未知 —,待核对 2(单)')
expect(wrapper.text()).not.toContain('尚未生成校验摘要')
wrapper.unmount()
})
it('does not describe an unavailable recorded attempt as never started', () => {
const wrapper = mount(TaskExecutionFacts, { props: { task: { ...waiting, attemptCount: 1, taskStatus: 'DEAD' }, scopeLabel: '本仓任务' } })
expect(wrapper.text()).toContain('尝试详情尚不可用')
expect(wrapper.text()).not.toContain('尚未开始执行尝试')
wrapper.unmount()
})
})
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Task } from '../types'
import { beijingTime, label, quantityText, unitText } from '../state'
const props = defineProps<{ task: Task; scopeLabel: string }>()
const quality = computed(() => props.task.progress)
const hasQuality = computed(() => [quality.value?.validationState, quality.value?.invalidCount, quality.value?.unknownCount, quality.value?.pendingReviewCount, quality.value?.summaryAsOf].some(value => value != null))
</script>
<template>
<div class="task-execution-facts" :aria-label="`${scopeLabel}执行信息`">
<p v-if="task.latestAttempt">尝试开始 {{ beijingTime(task.latestAttempt.startedAt) }};结束 {{ task.latestAttempt.finishedAt ? beijingTime(task.latestAttempt.finishedAt) : ['FETCHING', 'READY', 'VALIDATED', 'BUILDING'].includes(task.latestAttempt.status) ? '尚未结束' : '未记录' }};耗时 {{ quantityText(task.latestAttempt.elapsedSeconds) }} 秒;心跳 {{ beijingTime(task.latestAttempt.heartbeatAt) }} · {{ label(task.latestAttempt.executionHealth) }}。</p>
<p v-else-if="task.attemptCount === 0">{{ scopeLabel }}尚未开始执行尝试,开始、结束、耗时和心跳尚未产生。<template v-if="task.taskStatus === 'WAITING_SOURCE'">正在等待前面的数据准备任务完成。</template></p>
<p v-else>{{ scopeLabel }}尝试详情尚不可用,暂无法显示开始、结束、耗时和心跳。</p>
<p v-if="hasQuality">数据质量:{{ label(quality?.validationState) }};异常 {{ quantityText(quality?.invalidCount) }},未知 {{ quantityText(quality?.unknownCount) }},待核对 {{ quantityText(quality?.pendingReviewCount) }}({{ unitText(quality?.countUnit) }});摘要时间 {{ beijingTime(quality?.summaryAsOf) }}。未计算的数量不默认为零。</p>
<p v-else>数据质量:尚未生成校验摘要,异常、未知及待核对数量尚未计算。</p>
</div>
</template>
<style scoped>
p { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import type { GaugeSeriesOption, BarSeriesOption } from 'echarts'
import { barChartOption, progressGaugeOption } from './dashboardChartOptions'
describe('self-operated ECharts configurations', () => {
it('wraps a concise tooltip and escapes source text rather than inserting markup', () => {
const option = barChartOption([{ key: 'sku', label: '<img src=x>', secondary: 'A&B', value: 12, detail: 'very long detail belongs in the dialog' }], '件', '热销', true)
const tooltip = option.tooltip as { formatter: (value: { dataIndex: number }) => string; extraCssText: string }
const content = tooltip.formatter({ dataIndex: 0 })
expect(content).toContain('&lt;img src=x&gt;')
expect(content).toContain('A&amp;B')
expect(content).not.toContain('very long detail')
expect(tooltip.extraCssText).toContain('white-space: normal')
})
it('renders unknown progress as an empty track without a zero progress value', () => {
const unknown = (progressGaugeOption(null).series as GaugeSeriesOption[])[0]!
expect(unknown.progress?.show).toBe(false)
expect(unknown.data).toEqual([])
const known = (progressGaugeOption(12.1).series as GaugeSeriesOption[])[0]!
expect(known.progress?.show).toBe(true)
expect(known.data).toEqual([{ value: 12.1 }])
})
it('keeps the seven bucket order, zero, null and common numeric scale', () => {
const rows = Array.from({ length: 7 }, (_, index) => ({ key: `bucket-${index}`, label: `分组${index}`, value: index === 6 ? null : index, detail: '' }))
const option = barChartOption(rows, 'SKU', '库存分布')
expect(option.yAxis).toMatchObject({ inverse: true, data: rows.map(row => row.key) })
expect(option.xAxis).toMatchObject({ type: 'value', min: 0 })
expect((option.series as BarSeriesOption[])[0]?.data?.[0]).toMatchObject({ value: 0 })
expect((option.series as BarSeriesOption[])[0]?.data?.[6]).toMatchObject({ value: null })
})
})
@@ -0,0 +1,35 @@
import type { EChartsOption } from 'echarts'
import type { Quantity } from './types'
import { quantityText } from './state'
export interface BarRow { key: string; label: string; secondary?: string; value: Quantity; detail: string }
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]!))
const palette = ['#5575ed', '#3da9ba', '#e8a54b', '#b083dc', '#df8397', '#8a9ab4', '#a2adbf']
export function barChartOption(rows: BarRow[], unit: string, title: string, ranked = false): EChartsOption {
const valueText = (value: Quantity | undefined) => unit === '天' && value != null && Number.isFinite(Number(value)) ? Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2, useGrouping: false }) : quantityText(value)
return {
animation: false,
grid: { left: 12, right: 48, top: 12, bottom: 24, containLabel: true },
xAxis: { type: 'value', min: 0, name: unit, splitNumber: 3, minInterval: 1, axisLabel: { color: '#697586' }, splitLine: { lineStyle: { type: 'dashed', color: '#e5eaf3' } } },
yAxis: { type: 'category', inverse: true, data: rows.map(row => row.key), triggerEvent: true,
axisTick: { show: false }, axisLine: { show: false },
axisLabel: { width: ranked ? 116 : 130, overflow: 'truncate', color: '#344054', rich: { rank: { color: '#5575ed', fontWeight: 'bold', width: 24, align: 'left' } }, formatter: key => { const index = rows.findIndex(row => row.key === key); const row = rows[index]; return row ? `${ranked ? `{rank|${index + 1}} ` : ''}${row.label}` : key } } },
tooltip: { trigger: 'item', renderMode: 'html', confine: true, extraCssText: 'max-width: min(300px, 80vw); white-space: normal; overflow-wrap: anywhere;', formatter: params => {
const item = Array.isArray(params) ? params[0] : params
const row = item ? rows[item.dataIndex] : undefined
return row ? `<strong>${escapeHtml(row.label)}</strong><br/>${row.secondary ? `${escapeHtml(row.secondary)}<br/>` : ''}${escapeHtml(valueText(row.value))} ${escapeHtml(unit)}` : ''
} },
aria: { enabled: true, description: `${title},横轴为${unit}。未知不作为零值;详细数据和键盘操作在下方完整明细弹框。` },
series: [{ type: 'bar', barMaxWidth: ranked ? 14 : 18, data: rows.map((row, index) => ({ value: row.value == null ? null : Number(row.value), itemStyle: { color: palette[ranked ? 0 : index % palette.length], borderRadius: [0, 4, 4, 0] } })),
label: { show: true, position: 'right', formatter: params => valueText(rows[params.dataIndex]?.value) } }],
}
}
export function progressGaugeOption(percent: number | null, running = false): EChartsOption {
const animate = typeof window !== 'undefined' && !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
return { animation: animate && percent != null, animationDuration: 0, animationDurationUpdate: 700, animationEasingUpdate: 'cubicOut', series: [{ id: 'collection-progress', type: 'gauge', startAngle: 90, endAngle: -270, radius: '86%', min: 0, max: 100,
pointer: { show: false }, progress: { show: percent != null || running, roundCap: true, width: 10 },
axisLine: { lineStyle: { width: 10, color: [[1, '#e8eaf5']] } }, itemStyle: { color: '#6879ee' },
axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, title: { show: false }, detail: { show: false },
silent: true, data: percent == null && !running ? [] : [{ value: percent ?? 18 }],
}] }
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { buckets, expired, inventoryExpired, parseDashboardDrilldown, previousDate } from './dashboardNavigation'
import type { QueryContext } from './types'
const context: QueryContext = { salesRunId: '11', inventoryRunId: '12', salesScopeHash: 'sales', inventoryScopeHash: 'stock', queryAsOf: '2026-10-13T00:00:00Z', queryExpiresAt: '2026-10-13T00:30:00Z', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13', rangeStartDate: '2026-09-29', rangeEndDateExclusive: '2026-10-14', inventoryDrilldownExpiresAt: '2026-10-13T00:10:00Z' }
const base = { from: 'overview', warehouseCode: 'WH-A', context: JSON.stringify(context), overviewReturn: JSON.stringify({ rangePreset: 'LAST_15_DAYS', scroll: 650 }) }
describe('dashboard exact drilldown contract', () => {
it.each(buckets)('maps $code to exact server bucket rather than an attention approximation', ({ code }) => {
const parsed = parseDashboardDrilldown({ ...base, coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' }, 'inventory')!
expect(parsed.inventory).toMatchObject({ coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', drilldownFrom: 'DASHBOARD', attention: 'ALL' })
expect(parsed.context).toEqual(context)
expect(parsed).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-09-29', endDate: '2026-10-13' })
expect(parsed.returnTo).toEqual({ rangePreset: 'LAST_15_DAYS', scroll: 650 })
})
it('preserves exact owner/SKU, maps hot enum and the independent finite candidate selector', () => {
expect(parseDashboardDrilldown({ ...base, analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-09-13', endDate: '2026-10-12', customerCode: 'OWNER-B', sku: 'SKU-X' }, 'sales')).toMatchObject({ customerCode: 'OWNER-B', analysis: 'hot' })
expect(parseDashboardDrilldown({ ...base, coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' }, 'inventory')?.inventory).toMatchObject({ coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' })
expect(parseDashboardDrilldown({ ...base, analysis: 'SKU', rangePreset: 'CUSTOM', startDate: '2026-10-12', endDate: '2026-10-12' }, 'sales')).toMatchObject({ analysis: 'summary', startDate: '2026-10-12', endDate: '2026-10-12' })
})
it.each([
{ coverageBucket: 'BAD', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' },
{ coverageBucket: 'DAYS_GT_90', bucketRuleVersion: 'BAD' },
{ bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' },
{ coverageBucket: 'DAYS_GT_90', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', attention: 'NO_OUTBOUND_IN_WINDOW' },
{ coverageSelection: 'FINITE_POSITIVE', attention: 'NO_OUTBOUND_IN_WINDOW' },
{ coverageSelection: 'FINITE_POSITIVE', coverageBucket: 'UNKNOWN', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' },
{ context: '{}' }, { context: JSON.stringify({ ...context, rangeStartDate: undefined }) }, { context: JSON.stringify({ ...context, rangeEndDateExclusive: '2026-09-01' }) }, { warehouseCode: '' }, { drilldownFrom: 'OTHER' },
])('rejects malformed/conflicting parameters without widening to the whole warehouse: %j', invalid => {
expect(() => parseDashboardDrilldown(Object.fromEntries(Object.entries({ ...base, ...invalid }).filter(([, value]) => value !== undefined)) as Record<string, string>, 'inventory')).toThrow()
})
it('rejects invalid date/analysis/source and keeps warehouse selection mandatory', () => {
expect(parseDashboardDrilldown({ ...base, analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-09-01', endDate: '2026-10-13' }, 'sales')).toMatchObject({ analysis: 'hot', rangePreset: 'CUSTOM', startDate: '2026-09-01', endDate: '2026-10-13' })
expect(() => parseDashboardDrilldown({ ...base, analysis: 'HOT', windowDays: '30' }, 'sales')).toThrow()
expect(() => parseDashboardDrilldown({ ...base, analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-10-13', endDate: '2026-10-12' }, 'sales')).toThrow()
expect(() => parseDashboardDrilldown({ ...base, analysis: 'ALL' }, 'sales')).toThrow()
expect(() => parseDashboardDrilldown({ ...base, analysis: 'SKU', rangePreset: 'CUSTOM', startDate: '2026-02-30', endDate: '2026-02-30' }, 'sales')).toThrow()
expect(() => parseDashboardDrilldown({ ...base, from: 'https://example.com' }, 'sales')).toThrow()
expect(() => parseDashboardDrilldown({ coverageBucket: 'UNKNOWN' }, 'inventory')).toThrow()
})
it('expires inventory earlier without expiring sales and subtracts date boundaries without local timezone drift', () => {
const now = Date.parse('2026-10-13T00:15:00Z')
expect(inventoryExpired(context, now)).toBe(true)
expect(expired(context, now)).toBe(false)
expect(previousDate('2026-03-01')).toBe('2026-02-28')
})
})
@@ -0,0 +1,104 @@
import type { LocationQuery } from 'vue-router'
import type { BusinessQuery, CoverageBucket, QueryContext, RangePreset } from './types'
import { validateCustomRange } from './state'
import { inventorySortOptions } from './inventorySorting'
export const buckets: { code: CoverageBucket; label: string }[] = [
{ code: 'DAYS_0_30', label: '0~30天(含30)' }, { code: 'DAYS_30_60', label: '30~60天(含60)' },
{ code: 'DAYS_60_90', label: '60~90天(含90)' }, { code: 'DAYS_GT_90', label: '超过90天' },
{ code: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { code: 'NO_AVAILABLE', label: '无可售库存' }, { code: 'UNKNOWN', label: '库存或销量待核验' },
]
export const rangeOptions: { value: RangePreset; label: string }[] = [{ value: 'TODAY', label: '今日' }, { value: 'LAST_3_DAYS', label: '最近3天' }, { value: 'LAST_7_DAYS', label: '最近7天' }, { value: 'LAST_30_DAYS', label: '最近30天' }, { value: 'THIS_WEEK', label: '本周' }, { value: 'LAST_15_DAYS', label: '近15天' }, { value: 'THIS_MONTH', label: '本月' }, { value: 'CUSTOM', label: '自定义' }]
export interface OverviewReturn { rangePreset: RangePreset; startDate?: string; endDate?: string; scroll: number }
export interface DashboardDrilldown {
context: QueryContext
customerCode?: string
analysis: 'summary' | 'hot'
rangePreset: RangePreset
startDate: string
endDate: string
inventory: Pick<BusinessQuery, 'attention' | 'sortBy' | 'coverageBucket' | 'bucketRuleVersion' | 'coverageSelection' | 'drilldownFrom'>
returnTo: OverviewReturn
summary: string
}
function scalar(query: LocationQuery, key: string, required = false): string {
const value = query[key]
if (value === undefined && !required) return ''
if (typeof value !== 'string' || !value || value.length > 12000 || /[\u0000-\u001f]/.test(value)) throw new Error(`经营总览下钻参数 ${key} 无效`)
return value
}
export function parseContext(raw: string): QueryContext {
let value: QueryContext
try { value = JSON.parse(raw) as QueryContext } catch { throw new Error('经营总览版本上下文无效') }
if (!value || typeof value !== 'object' || !/^\d{4}-\d{2}-\d{2}$/.test(value.anchorDate ?? '') || value.businessTimezone !== 'Asia/Shanghai') throw new Error('经营总览版本上下文无效')
for (const key of ['salesRunId', 'inventoryRunId'] as const) if (value[key] !== null && (typeof value[key] !== 'string' || !/^\d+$/.test(value[key]))) throw new Error('经营总览版本编号无效')
for (const key of ['salesScopeHash', 'inventoryScopeHash'] as const) if (value[key] !== null && (typeof value[key] !== 'string' || value[key]!.length > 512)) throw new Error('经营总览版本范围无效')
for (const key of ['queryAsOf', 'queryExpiresAt'] as const) if (typeof value[key] !== 'string' || !Number.isFinite(Date.parse(value[key]))) throw new Error('经营总览查询时间无效')
if (validateCustomRange(value.anchorDate, value.anchorDate) || Date.parse(value.queryExpiresAt) <= Date.parse(value.queryAsOf)) throw new Error('经营总览查询日期无效')
for (const key of ['inventoryFreshUntil', 'inventoryDrilldownExpiresAt'] as const) if (value[key] != null && (typeof value[key] !== 'string' || !Number.isFinite(Date.parse(value[key]!)))) throw new Error('经营总览库存时效无效')
return value
}
export function parseOverviewReturn(raw: string): OverviewReturn {
let value: OverviewReturn
try { value = JSON.parse(raw) as OverviewReturn } catch { throw new Error('经营总览返回条件无效') }
if (!value || !rangeOptions.some(option => option.value === value.rangePreset) || !Number.isFinite(value.scroll) || value.scroll < 0 || value.scroll > 1000000) throw new Error('经营总览返回条件无效')
if (value.rangePreset === 'CUSTOM' && validateCustomRange(value.startDate ?? '', value.endDate ?? '')) throw new Error('经营总览返回日期无效')
return value
}
export function parseDashboardDrilldown(query: LocationQuery, mode: 'sales' | 'inventory'): DashboardDrilldown | null {
if (query.from === undefined) {
if (['context', 'coverageBucket', 'coverageSelection', 'bucketRuleVersion', 'drilldownFrom', 'analysis', 'customerCode'].some(key => query[key] !== undefined)) throw new Error('经营总览来源参数缺失')
return null
}
if (scalar(query, 'from') !== 'overview') throw new Error('未知的经营分析来源')
const warehouse = scalar(query, 'warehouseCode', true)
if (warehouse.length > 128) throw new Error('仓库编码无效')
const context = parseContext(scalar(query, 'context', true))
const returnTo = parseOverviewReturn(scalar(query, 'overviewReturn', true))
const customerCode = scalar(query, 'customerCode') || undefined
const sku = scalar(query, 'sku')
if ((customerCode && customerCode.length > 128) || sku.length > 512 || (customerCode && !sku)) throw new Error('SKU/货主身份参数无效')
const result: DashboardDrilldown = { context, customerCode, analysis: 'summary', rangePreset: 'TODAY', startDate: '', endDate: '', inventory: {}, returnTo, summary: '来自经营总览' }
if (mode === 'sales') {
if (['coverageBucket', 'coverageSelection', 'bucketRuleVersion', 'attention', 'sortBy'].some(key => query[key] !== undefined)) throw new Error('销量下钻不能携带库存条件')
const analysis = scalar(query, 'analysis', true)
if (analysis === 'HOT') {
if (query.windowDays !== undefined || scalar(query, 'rangePreset', true) !== 'CUSTOM') throw new Error('热销下钻须使用榜单实际日期')
result.startDate = scalar(query, 'startDate', true); result.endDate = scalar(query, 'endDate', true)
if (validateCustomRange(result.startDate, result.endDate, context.anchorDate)) throw new Error('热销下钻日期无效')
result.analysis = 'hot'; result.rangePreset = 'CUSTOM'
result.summary += ` · 所选时段热销Top100 · ${result.startDate} 至 ${result.endDate}`
} else if (analysis === 'SKU') {
if (query.windowDays !== undefined) throw new Error('SKU日期下钻不能携带榜单窗口')
const range = scalar(query, 'rangePreset', true)
if (!rangeOptions.some(option => option.value === range)) throw new Error('销量日期枚举无效')
result.rangePreset = range as RangePreset
if (range === 'CUSTOM') {
result.startDate = scalar(query, 'startDate', true); result.endDate = scalar(query, 'endDate', true)
if (validateCustomRange(result.startDate, result.endDate, context.anchorDate)) throw new Error('销量下钻日期无效')
} else if (query.startDate !== undefined || query.endDate !== undefined) throw new Error('非自定义范围不能携带起止日期')
result.summary += ` · ${result.startDate || range} ${result.endDate}`
} else throw new Error('销量类别枚举无效')
} else {
if (query.drilldownFrom !== undefined && scalar(query, 'drilldownFrom') !== 'DASHBOARD') throw new Error('库存下钻来源枚举无效')
if (['analysis', 'rangePreset', 'startDate', 'endDate', 'windowDays'].some(key => query[key] !== undefined)) throw new Error('库存下钻不能携带销量日期条件')
result.startDate = context.rangeStartDate ?? ''
result.endDate = context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : ''
if (validateCustomRange(result.startDate, result.endDate, context.anchorDate)) throw new Error('库存下钻缺少有效的原图统计区间')
result.rangePreset = 'CUSTOM'
const bucket = scalar(query, 'coverageBucket')
const rule = scalar(query, 'bucketRuleVersion')
const selection = scalar(query, 'coverageSelection')
const attention = scalar(query, 'attention') || 'ALL'
const sort = scalar(query, 'sortBy') || 'AVAILABLE_DESC'
if (!['ALL', 'NO_OUTBOUND_IN_WINDOW'].includes(attention) || !inventorySortOptions.some(option => option.value === sort)) throw new Error('库存下钻枚举无效')
if ((bucket && (!buckets.some(item => item.code === bucket) || rule !== 'COVERAGE_DISTRIBUTION_V1' || selection || attention !== 'ALL')) || (!bucket && rule) || (selection && (selection !== 'FINITE_POSITIVE' || attention !== 'ALL'))) throw new Error('库存分组条件无效或互斥')
result.inventory = { attention: attention as 'ALL' | 'NO_OUTBOUND_IN_WINDOW', sortBy: sort as BusinessQuery['sortBy'], coverageBucket: bucket ? bucket as CoverageBucket : undefined, bucketRuleVersion: bucket ? 'COVERAGE_DISTRIBUTION_V1' : undefined, coverageSelection: selection ? 'FINITE_POSITIVE' : undefined, drilldownFrom: 'DASHBOARD' }
result.summary += ` · ${result.startDate} 至 ${result.endDate} · ${buckets.find(item => item.code === bucket)?.label ?? (selection ? '有限正可售天数,从高到低' : attention === 'NO_OUTBOUND_IN_WINDOW' ? '所选时段无出库,实物量从高到低' : '库存')}`
}
if (sku) result.summary += ` · ${customerCode ? `${customerCode} / ` : ''}${sku}`
return result
}
export function expired(context: QueryContext, now = Date.now()) { return now >= Date.parse(context.queryExpiresAt) }
export function inventoryExpired(context: QueryContext, now = Date.now()) { return expired(context, now) || !context.inventoryDrilldownExpiresAt || now >= Date.parse(context.inventoryDrilldownExpiresAt) }
export function previousDate(exclusive: string) { return new Date(Date.parse(`${exclusive}T00:00:00Z`) - 86400000).toISOString().slice(0, 10) }
@@ -0,0 +1,16 @@
import type { InventorySort, InventorySortMetric } from './types'
export const inventorySortColumns: Record<string, InventorySortMetric> = {
physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', frozenQuantity: 'FROZEN',
defectivePhysicalQuantity: 'DEFECTIVE', transitQuantity: 'TRANSIT',
windowOutboundQuantity: 'OUTBOUND', averageDailyOutboundQuantity: 'AVERAGE_DAILY',
inventoryCoverageDays: 'COVERAGE_DAYS',
}
const names: Record<InventorySortMetric, string> = {
AVAILABLE: '正品可售', PHYSICAL: '全品质实物', FROZEN: '全品质冻结', DEFECTIVE: '次品实物',
TRANSIT: '在途', OUTBOUND: '完整窗口出库', AVERAGE_DAILY: '完整窗口日均', COVERAGE_DAYS: '可售天数',
}
export const inventorySortOptions: { value: InventorySort; label: string }[] = (Object.keys(names) as InventorySortMetric[]).flatMap(metric => [
{ value: `${metric}_DESC` as InventorySort, label: `${names[metric]}从高到低` },
{ value: `${metric}_ASC` as InventorySort, label: `${names[metric]}从低到高` },
])
@@ -0,0 +1,144 @@
# 自营分析逐操作矩阵
依据:2026-10-12 用户提供的销量/库存页面截图及“主要统计SKU,在看当前SKU是哪个货主就行,货主不重要”的明确纠正,优先于一期方案中的货主经营面板目标;密度沿用 `docs/Admin高密度界面规范.md`。用户最新确认销量、库存样式已完成,本轮以其截图为数据采集统一样板;采集页修改后的视觉仍待人工1920×1080、100%截图。
| 页面 | 操作 | 依据与承载结论 | 布局与验证 |
| --- | --- | --- | --- |
| 库存周转 | 库存过期、采集失败与未知总量 | 2026-10-14 用户库存空白截图及本轮修复确认:沿现有同页提示展示过期时间、同仓失败原因与任务号;整体刷新同步只读采集状态,失败独立提示;指标主数值未知显示“—”,小计留辅助说明;原样本证据弹框解释库存日样本与出库历史的区别 | 保留原数据采集入口与弹框承载,不新增写入操作;回归未知/真实0、切仓和刷新恢复,待人工1920×1080、100%截图确认 |
| /self-operated/inventory | 页签与操作栏整合 | 2026-10-14 用户截图 `codex-clipboard-784338aa-b985-4eb7-9f5e-b14f87f6d5e8.png`:页签独立在外、操作栏左侧空白;两个视图的切换页签统一放入DataTable的toolbar-start,操作按钮保留右侧,筛选紧接下方;阈值提示缩短为“天数阈值(天)” | 保留视图选中态、切仓与原图下钻;不改接口与计算,待人工1920×1080、100%截图确认 |
| /self-operated/collection | 原型排版融入后台风格 | 用户截图 `codex-clipboard-9f9be8ba-792e-4f17-a07b-6ac38531e294.png`:保留左侧进度环、中部三项数量和预计时间、右侧后续安排、底部阶段流程;使用后台浅色卡片与蓝紫主色,完成/剩余采用绿/橙强调 | 压缩空态与区块间距,读取失败不显示阶段正在处理;刷新仍为原地只读操作,不新增配置或详情入口;待人工1920×1080、100%截图确认 |
| /self-operated/inventory | 库存周转/库存明细、SKU/货主 | 当前维护入口 `docs/自营业务/库存周转视图设计.md`:默认周转、默认合法仓及SKU;可选货主仅限本视图。三窗同屏、四指标、两张240px图表、关注过滤和分页;原总览严格下钻继续进入库存明细 | 概览与列表共用固定上下文,显式深链换版同时换概览;SKU趋势1120px居中弹框,样本证据1040px居中弹框;未知不补零,待人工1920×1080、100%截图确认 |
| 自营业务全部图表 | 统一ECharts | 2026-10-13 用户要求全面替换手写图表:经营总览日趋势、库存七桶与三个榜单、SKU日趋势、采集进度环统一通过AnalyticsChart使用ECharts Canvas;移除SVG路径生成与CSS条形绘图,保留真实数量、未知/零区分及原有下钻边界 | 日期点/分类轴/柱体点击及展开表格键盘操作同资格;未知进度仅空环;坐标、tooltip、缩放及尺寸由组件配置;待人工1920×1080、100%截图确认 |
| 库存/销量 | SKU每日出库趋势连线与坐标 | 2026-10-13 用户反馈线未连接、坐标不显示,并明确要求图表组件:保留居中弹框,改用ECharts+vue-echarts(Canvas),配置日期轴、数量轴、网格、提示框、图例说明和范围缩放;相邻有效日连接,涉及未完整日用虚线,未知或缺失日期断线 | 库存/销量/总览的SKU弹框共用组件;总览主图的连线修复同步回归;不把未知补零;待人工1920×1080、100%截图确认 |
| /self-operated/collection | 看板配色 | 2026-10-13 用户追加当前页面与原型首页截图:采用蓝紫主色、浅色圆形装饰数量卡、绿/橙数量强调;流程与仓库结果增加色彩层次,保持原有左右布局及真实状态文字 | 仅本看板作用域内应用;颜色不代替状态或代表未知完成度;待人工1920×1080、100%截图确认 |
| /self-operated/collection | 等待提示与刷新稳定性 | 2026-10-13 用户两张运行看板截图:移除环图上方临时插入的等待说明,原因合并到下方估算说明区;未知总量以“—”及目录总页数未返回说明展示;刷新保留已读数据,同任务优先使用账号活动摘要 | 数量说明预留一行,避免已知/未知切换挤动数字;阶段或尝试变化不沿用旧百分比;待人工1920×1080、100%截图确认 |
| /self-operated/collection | 单账号运行首屏 | 2026-10-13 最新实际页面及原型截图:首屏改为账号活动优先,环形进度、总量/完成/剩余及阶段预计时间;移除原筛选、配置、历史、详情和恢复表单,只读账号概览;待机保留阶段、最近发布及各仓结果 | 按用户原型排版,使用现有 Admin 白色卡片、主题强调色及统一按钮,无详情弹框;本行覆盖下方旧采集页面承载记录;后续流程不冒充调度顺序,有界待处理摘要不冒充完整队列;待人工1920×1080、100%截图确认 |
| 销量/库存 | 默认仓库与重置 | 2026-10-13 用户确认进入页面即按仓库显示:无传入仓库时自动选中接口返回的首个可用仓库并查询;链接带入仓库优先;重置保留当前仓库并重新查询 | 保留切仓与迟到响应隔离;没有可用仓库不发无仓库查询;自动回归覆盖两页首次进入、链接进入、缓存页返回及重置 |
| /self-operated/sales | 自定义销量日期 | 2026-10-13 用户截图 `codex-clipboard-fac03bb1-6703-467d-b357-5731ea8cfb32.png`:两个单日框分散换行,未完整选择时残留逆序错误和旧结果。原筛选区改为公共QueryToolbar单一日期范围控件,明确开始/结束日期,支持同日;不新增浮层或改变统计口径 | 日期范围整体换行,校验完整范围后查询;草稿修改不改变已查询列表的分页/趋势条件,提交失败不冒充新范围结果;补充自动回归,待人工1920×1080、100%截图确认 |
| /self-operated/overview | 查询、刷新与六项指标 | `docs/自营业务/经营总览图表设计.md`“菜单与页面布局”“指标带”;单仓+统计日期QueryToolbar、紧凑指标带,整体固定版本 | 不新增货主控件;未知/过期独立提示;待人工1920×1080截图确认 |
| /self-operated/overview | 日趋势、七桶与三个Top10 | 同设计“五张图表”;ECharts折线与横条、趋势约2/3宽、分布约1/3宽,三榜并排 | 热销随时间筛选按已采集量排名并提示覆盖不足;可聚焦提示、缺日断线、部分日文字、真实数值比例;待人工截图确认 |
| /self-operated/overview | SKU趋势 | 同设计“图表交互与视觉依据”;1120px居中AppDialog,三个榜单均沿所选时段,精确货主SKU | 关闭取消请求,不改现有详情承载;待人工截图确认 |
| 总览→销量/库存 | 日期/榜单/分桶下钻与返回 | 同设计“精确下钻合同”;目标页验证完整版本、精确筛选及互斥,显示可清除来源摘要,固定本地返回 | 库存时效到期阻止严格下钻;返回恢复趋势范围、版本、滚动;待人工截图确认 |
| /self-operated/sales | 仓库、SKU、日期/榜单查询及分页 | 当前截图:货主控件分散筛选;本轮冻结为单一紧凑筛选区,仓库明确提示必须选择,SKU为主 | 复用 AdminPage、QueryToolbar、DataTable、高密度样式;日期/榜单切换和分页保持版本;待人工截图确认 |
| /self-operated/sales | SKU 趋势 | 用户最新截图明确要求详情不再放列表下方;使用1120px居中AppDialog,保留整个日轴、未知断线及跨页SKU入口 | 可关闭/全屏;关闭取消请求、防止迟到回写;待人工截图确认 |
| /self-operated/sales | SKU销量、热销、滞销切换 | 本轮并入统一筛选区的销量类别选择;独立完整日窗口 | 热销独立入口 7/30 日,总览下钻沿所选日期与版本并标明未采齐;滞销 30 日;待人工截图确认 |
| /self-operated/inventory | SKU 库存关注、阈值及排序 | 当前截图:库存筛选散落且货主优先;仓库、SKU、销量窗口、统计截止日、关注、阈值、排序共用 QueryToolbar;移除货主经营概览 | SKU、商品名称靠前,所属货主为一辅助列;分页与排序保留所选窗口及固定版本;待人工1920×1080、100%截图确认 |
| /self-operated/inventory | 10/30天窗口与统计截止日 | 用户确认预计可售天数=当前正品可售库存÷(完整窗口出库量÷窗口天数);窗口选择和含当日截止日期放在原筛选区,默认30天、北京时间昨天,可固定历史截止日;切换窗口重置查询及页码 | 窗口出库与日均列名随实际窗口变化;未采齐不以部分销量推算,零销量明确显示“所选窗口无出库”,可售天数只展示服务端结果;无出库关注及出库排序跟随实际窗口;总览严格下钻使用原图CUSTOM区间;待人工截图确认 |
| /self-operated/inventory | 实际统计区间与库存观测说明 | 列表提示直接显示服务端实际销量起止日期、含截止日、窗口天数及销量状态;库存采集起止时间单独展示,明确当前库存来自一轮完整分页观测;详细公式仍在统计说明折叠区 | 截止日仅约束销量窗口,不伪装为该日历史库存;未知与零保持区分;待人工截图确认 |
| /self-operated/inventory | SKU 每日趋势 | 与销量页统一居中趋势弹框;点击SKU沿daily接口读取本行货主/SKU,使用当前响应的实际销量区间,排他上界减一天作为含当日结束参数 | 不新增操作列或菜单;保留版本、可关闭/全屏;待人工截图确认 |
| 销量/库存 | 所属货主及同SKU趋势 | 删除独立货主选择、货主翻页和经营概览,不请求owners/ownerOverview;所属货主名称优先、编码可辅助 | 同SKU跨货主保持分行,趋势仍传本行customerCode;不改后端统计键 |
| 销量/库存 | 规则说明及数据状态 | 当前截图:来源账号、技术时区和长段规则占主页面;本轮冻结为短说明和原生details折叠 | 主文案仅北京时间/件;未选仓库、暂无数据、查询失败分别显示;不新增浮层 |
| /self-operated/collection | 每日库存观测积累 | 本轮用户采集页截图确认长串缺口日期占用正文,要求优化;近30日样本数量保留主表,积累起点在配置弹框,缺口日期在弹框内折叠 | 服务端结果直出,不计算周转率;待人工截图确认 |
| /self-operated/collection | 运行概览、逻辑任务列表与分页 | 本轮截图确认各仓20字段描述表重复展开;用户进一步确认“展示列表,配置项直接做成弹框”。主表集中仓库/类型/开关/执行/发布/覆盖/时效,各行查看配置打开只读AppDialog,保留预算、验证和覆盖字段 | 操作列首列;不新增配置写接口;关闭采集与已有结果分别表达,合成测试仓明确提示;待人工截图确认 |
| /self-operated/collection | 任务详情和历史尝试 | 用户最新要求检查三页列表下方详情;改为1200px居中任务详情AppDialog,包含尝试历史 | 可关闭/全屏;保留任务业务号和分页;待人工截图确认 |
| /self-operated/collection | 本仓/共享上游执行时间与校验摘要 | 2026-10-13 用户截图:本仓尝试0次、共享上游执行中。本仓明确说明等待来源且尚未产生执行时间;上游单独显示接口已有的 latestAttempt 时间/心跳,质量未生成时明确说明,不冒充本仓尝试 | WAITING_SOURCE 显示等待来源数据并维持活跃轮询;真实0与未知区分;前端修改,不发布后端;待人工截图确认 |
| /self-operated/collection | 当前状态/任务记录可读性 | 用户补充截图:同仓多条销量任务容易误认为重复执行。上表明确当前状态、处理阶段与完成情况;下表明确是按数据时间段的当前及历史任务记录,不在前端合并或删除分页任务。共享上游在产品文案中称数据准备任务,说明先取来源数据、再分别处理各仓 | 进度比例仅有可靠阶段分母时展示,不作为全流程完成率;没有可靠耗时模型时明确无法预计完成时间;保留真实任务号、时间段和独立恢复权限;不部署后端 |
| /self-operated/collection | 两仓来源进度重复展示修正 | 用户指出US01与US02销量行均显示1218/2014。此前把同一来源账号准备进度重复放入仓库行,修正为独立“来源数据准备”区域,按真实taskNo仅展示一次;仓库行仅消费currentTask,不使用sharedUpstream计数回退 | 关联仓库只作适用范围说明;不把来源单数当单仓出库量,不估算或分摊两仓计数;本仓等待状态及独立库存进度保留;不部署后端 |
| /self-operated/collection | 恢复任务确认 | 原因表单与显式确认步骤移入任务弹框,RETRY单一命令不变 | 提交中锁定关闭;独立sync权限、资格及幂等不变;待人工截图确认 |
| 三页 | 新增/编辑/审核/删除/文件预览 | 不在一期页面业务范围 | 不创建操作入口 |
| 三页 | 公共搜索栏样式 | 用户提供异常订单公共搜索栏截图并要求自营统一;QueryToolbar放入集成DataTable的filters插槽 | 复用公共灰底筛选带、控件及查询/重置按钮,启用筛选布局设置;不复制CSS,不改查询和权限语义;待人工截图确认 |
自动测试与人工视觉验收分别记录;无真实截图不宣称视觉通过。
## 经营总览图形表现(2026-10-13 用户截图确认)
用户明确要求经营总览强化图表与动效。保留 `经营总览图表设计.md` 的五图、窗口、未知与下钻规则,以本轮截图为视觉修改依据。
| 操作/区域 | 当前问题与本轮承载结论 | 验证边界 |
| --- | --- | --- |
| 六项指标 | 普通文字带改为彩色细顶边指标带、大数字和辅助状态;保留已知小计与待核验数量 | 数值和未知口径不变;待人工截图确认 |
| 全仓出库趋势 | 继续日折线;增加网格、分段面积、绘制动效、悬停/键盘指引 | 缺日断线,部分日虚线与空心点;不插值补齐;待人工截图确认 |
| 库存分布/三榜 | 保留横向条形;分布分色,排行名次标记,柱条展开动效 | 不引入健康评级、风险率或虚假占位条;待人工截图确认 |
| 首次加载/更新 | 骨架加载、指标和图形入场;减少动态效果偏好下禁用动画 | 原版本、关闭取消、过期提示及权限保持;待人工截图确认 |
| 顶部区域/默认仓库 | 用户补充要求顶部提示紧凑且默认展示仓库;标题与操作合并一行,统计口径和采集时间默认折叠;首次自动加载接口首个仓库,深链指定仓库优先,重置保留仓库 | 保留过期/失败提示及切仓入口;不硬编码仓库;待人工截图确认 |
| 统一搜索/切仓 | 2026-10-13 用户以客户账户截图要求统一标题和右侧操作栏;复用 integrated DataTable 的 toolbar-start/toolbar-actions/filters,关闭结果区,不增加空表;QueryToolbar 接入筛选布局设置。切仓取消旧请求、清旧图后自动加载新仓,重置仍保留当前仓 | 自动查询只读本地聚合接口;验证迟到响应不覆盖、加载与重置;待人工截图确认 |
| 统计说明入口 | 用户最新截图要求统计口径与采集时间不占主屏;从独立折叠行移至右侧操作按钮旁的信息图标,点击打开460px说明气泡,保留窗口口径与本次查询采集时间 | 使用Element Plus Popover及可访问按钮名称;不隐藏真实过期/失败提示;待人工截图确认 |
## 数据采集样式统一(2026-10-12 当前截图确认)
用户确认销量、库存样式已完成,本轮以两页截图中的“顶部操作栏 → 灰底筛选带 → 提示区 → 表格”为样板,仅统一采集页。采集模式、合成样本声明及更新时间移入主表 notices,顶部保留查看销量/库存的跨页入口,未选仓库时禁用;概览与任务表之间保留单一标准间距。沿用既有配置弹框、任务内嵌详情和恢复确认,不修改采集接口、资格或状态机。修改后待人工1920×1080、100%截图确认。
## 数据采集进度改版(用户确认设计)
本包依据 `docs/自营业务/数据采集页面设计.md`“首页”“详情”“状态刷新”及配套 `collection-progress-preview.html` 的 `cp-overview`、`cp-detail`、`cp-progressarea`、`cp-flow`;以下采集页操作以该最新设计为准。保留本文件其他窗口的销量日期与总览说明条目。
| 页面 | 操作 | 承载与实现边界 | 验证边界 |
| --- | --- | --- | --- |
| /self-operated/collection | 查询与完整任务号定位 | 原 QueryToolbar 增加采集内容与完整任务号;按仓库直接读取受权限约束的任务详情,同步 `warehouseCode,taskNo`,不靠翻历史列表定位 | 不跨权限搜索;不存在、权限拒绝、首次读取失败沿服务端错误呈现;待人工1920×1080、100%截图确认 |
| /self-operated/collection | 当前关注与活动切换 | 筛选区下先展示关注任务及完整编号;默认只选服务端 `activeTasks`,手动选择和历史任务持续保留,提供返回当前活动;账号其他活动单列,不借作本任务来源 | 已确认 `progressSourceTaskNo` 才消费对应共享上游;来源数量不跨仓累加;待人工截图确认 |
| /self-operated/collection | 环图、阶段和结果 | 当前阶段ECharts环图与总量/完成/剩余共用区域;未知、零量、异常或变化分母撤去比例。出库五阶段、库存四阶段各自展示,阶段完成只依据同身份结束证据;本次发布与当前可用报表独立 | 不用步骤序号或数量100%推定发布,不把来源单数当SKU销量;待人工截图确认 |
| /self-operated/collection | 阶段速度与预计时间 | 仅显示服务端当前阶段估算,向上取整为分钟;同时展示读取、推进、心跳时钟,阶段耗时不使用尝试总耗时替代 | ETA过期、读取失败、隐藏、计数不一致撤销估算;不可估算原因明确说明;不推算全任务ETA;待人工截图确认 |
| /self-operated/collection | 历史与详情 | 历史压缩为操作、任务/范围、数据时间段、阶段进度、最近进展、执行/发布六列,默认10行。沿用1200px居中AppDialog及全屏,标题只出现一次完整任务号 | 无本仓尝试只显示摘要;执行记录和诊断默认折叠,尝试分页按需请求,轮询不重置分页;待人工截图确认 |
| /self-operated/collection | 刷新与恢复 | 一个运行轮询器,活动/等待10秒、闲置60秒,隐藏和离页停止,恢复立即读;旧响应取消与身份校验。配置仍为独立只读弹框;恢复表单仅独立sync权限且allowedActions允许、无阻断时出现 | 状态更新失败保留观测数量、不判采集失败;恢复沿原RETRY及幂等确认,仅提示已排队;自动测试与人工视觉分别记录 |
## Top10 展示与完整明细(2026-10-13 用户截图)
用户最新截图要求图表底部操作移至卡片右上角并使用 link 按钮。出库趋势、库存分布和三榜统一标题栏;明细、分组说明、待评估原因及查看全部均使用文字链接,移除底部操作区及重复辅助说明。弹框宽度、根部挂载、刷新关闭与原下钻权限保持,长标题下操作区允许自然换行且右对齐;待人工1920×1080、100%截图确认。
| 页面/操作 | 截图与当前实现 | 本轮结论 | 验证 |
| --- | --- | --- | --- |
| 经营总览/两个库存榜单空榜原因 | 用户截图显示符合条件0、待评估2711/2741;用户要求修改后自行截图 | 在两个库存榜单内提供“采集覆盖与待评估原因”,沿用1040px居中ChartDetailsDialog;显示当前查询的仓库、覆盖区间、库存时间/状态及全仓库存分布原因计数,明确非两榜各自排除明细、只含首个命中原因;保持计算与下钻条件 | 不新增请求,不混入轮询版本;保留未知和历史提示,刷新关闭旧弹框;待人工1920×1080、100%截图确认 |
| 页面/操作 | 截图与当前实现 | 本轮结论 | 验证 |
| --- | --- | --- | --- |
| 经营总览/排行榜 | 用户截图中长悬浮说明越界,标签拥挤;现为 ECharts 横条 | 保留 ECharts,压缩悬浮摘要、限制宽度并换行;名次独立强调、保留统一数值轴 | 待人工1920×1080、100%截图确认 |
| 经营总览/查看完整名称、数值与明细 | 当前为卡片内 details 展开;用户要求弹框 | 使用1040px居中 AppDialog,完整名称、数值、说明换行展示;下钻先关闭弹框,刷新数据关闭旧明细 | 自动交互回归;待人工截图确认 |
| 查看Top100/查看全部 | 当前跳转对应列表 | 保留原列表入口与筛选上下文,完整明细弹框仅展示当前图中的数据 | 原下钻合同不变 |
| 经营总览/每日数值、库存分组原因、SKU趋势覆盖证据 | 用户要求移除图表后方的内嵌展开 | 改为根部挂载的居中 AppDialog:数值/证据1040px,分组说明760px;保留未知、覆盖原因和原日期下钻资格 | 定向测试通过;待人工截图确认 |
| 图表完整明细遮罩与定位 | 用户截图显示被卡片裁剪 | 所有新增图表弹框 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%截图确认 |
| 库存周转/概览与明细(2026-10-14) | 用户截图要求优化样式,并出现对象文本、英文状态、空白出库列 | 概览移出筛选区,四色指标卡区分主数值与核验提示,图表白底分组、空数据提示,快捷筛选显示选中状态;统计说明移入右上角信息气泡。修正 DataTable slotName 契约,趋势及证据弹框根部挂载 | 自动验证另列;待人工1920×1080、100%截图确认 |
| 库存分析/视图切换 | 用户示例要求切换置于搜索内 | 库存周转与库存明细共用下划线导航,经 view-tabs 插槽放入各自表格搜索区顶部;切换保留当前仓库,清除另一视图专属条件 | 待人工1920×1080、100%截图确认 |
| 数据采集/左右内容高度(2026-10-14) | 用户截图显示右侧采集安排撑高主行,左侧进度下出现大块空白 | 处理阶段及各仓结果放入左侧内容列,紧接当前进度;右侧保留等待处理和自动计划并顶部对齐,压缩计划卡间距,避免后续信息等待右栏结束才展示 | 待人工1920×1080、100%截图确认 |
| 库存周转/图表卡片高度与入口(2026-10-14) | 用户截图要求两个明细入口移至右上角、删除卡底留白 | 明细按钮与标题同行靠右;卡片按内容自然高度顶部对齐,不再等高拉伸;货主无缺失时不显示零缺失说明,全部日均未知时用覆盖提示替代空坐标轴 | 待人工1920×1080、100%截图确认 |
| 库存周转/关注筛选(2026-10-14) | 用户反馈按钮样式不统一、快慢点击无变化 | 改下划线 tabs;快捷快慢筛选缺省采用可编辑30天阈值,沿用已有自定义值,非法值在列表旁提示;保留成功查询选中态、失败不冒充已筛选。数据不足仍待评估 | SKU与货主各筛选及阈值回归;视觉待人工截图确认 |
| 库存周转/日均出库覆盖 | 用户反馈已有15天数据却显示完整0天 | 日均图按固定已发布销量版本核对7/15/30日覆盖;后台待重算单独显示发布时间与刷新提示,不混入未发布数据;SKU当前库存评估保留待重算保护 | 回归18日覆盖时7/15可算、30日仍不足及新版刷新;视觉待人工截图确认 |
| 库存周转/数值表头排序 | 用户要求实物、可售、三窗出库、日均及可售天数等可排序 | 表头点击切换降序/升序;扩展数值列同步支持,与排序下拉框共用服务端全量排序;回到第一页并保留查询版本,未知值末尾,失败保留成功排序箭头 | 两种分组、三种日均基准、升降序、跨页、空值与失败回归;视觉待人工1920×1080、100%截图确认 |
| 库存周转/表格筛选间距 | 用户反馈tabs与表格分离、左右边距不一致 | 移除重复的筛选结果独占行及叠加内边距,保留底部分页总数;概览、关注tabs、阈值和表格使用同一20px内容边界,tabs紧接表头 | 待人工1920×1080、100%截图确认 |
| 库存周转/两图固定高度及单位裁切 | 用户最新截图要求等高,并反馈件/日被截断 | 两张卡片固定360px,标题与底部摘要保持内容高度,图表填充剩余空间;货主图随卡片增高以消除底部高度差;日均图顶部预留36px显示单位,未知状态保持同高空态 | 待人工1920×1080、100%截图确认 |
| 数据采集/当前阶段圆环动效 | 用户反馈圆环跳闪、未知总量看似停止 | ECharts Canvas圆环单独启用合并更新及700ms进度过渡;未知总量仅在可见、状态有效且执行中显示旋转指示及执行中文字,不伪造比例;等待、陈旧、隐藏或阶段结束停止动效,遵循减少动态效果偏好 | 状态与轮询回归;视觉待人工截图确认 |
| 经营总览/统一统计时间(2026-10-14) | 用户要求分布、预计消化最长及无出库库存榜跟随顶部搜索时间 | 三榜、分布及日均/无出库指标统一使用所选区间;名称改为“所选时段无出库库存Top10”,明细保留原日期并锁定;库存取当前版本,缺日不填零,30/60/90天分组边界不变 | 日期、分组、排序与下钻回归;待人工1920×1080、100%截图确认 |
| 销量分析/今日空数量及日均精度 | 用户反馈历史采齐但今日为空、日均悬浮小数过长 | 销量日期统一最近3/7/15/30天及自定义,默认最近30天,快捷范围截至北京时间昨日;每日趋势沿用已查询区间,原图下钻保持原日期;空值不标已采集。日均悬浮与摘要统一最多2位小数、件/日单位,底层精度不变 | 日期请求与精度回归;实际服务覆盖需返回值核对 |
| 库存明细/数值排序与缺失原因 | 用户要求明细排序、补齐缺失数据,并明确今天未结束的统计处理 | 八个数值表头支持全量升降序,保留版本、第一页、失败恢复;未知正式出库显示已采集小计与原因,未完整日不推算日均;显示实际统计区间及排除今天说明,新版提供更新数据入口 | 全量排序、空值、跨页、刷新及截至昨天回归;视觉待人工1920×1080、100%截图确认 |
| /self-operated/overview | 仅查询已结束自然日 | 2026-10-14用户截图 `codex-clipboard-9ba776f6-4c0a-4f00-9ca9-2c86748fdd36.png` 确认去掉今日;保留原顶部QueryToolbar,快捷范围标注截至昨日,默认完整15天,自定义禁选今日及未来 | 六项指标、五张图与下钻共用实际起止日期;四个统计页面共用日期选择规则;自动检查与人工1920×1080、100%截图分别验收,视觉待人工确认 |
| 销量分析/数量排序 | 用户要求支持排序 | 区间出库量/已采集量表头切换全量升降序,未知末尾、同值按SKU及货主稳定排序;保持日期及版本,回到第一页;失败恢复原排序 | 前后端排序回归,待人工截图确认 |
| 库存明细/观察期独立提示 | 用户确认完整销量窗口不再被商品观察期阻断 | 完整可信窗口正常显示出库、日均及结合当前有效可售量计算的天数;观察不足仅在说明列提示,不单独归入待评估;完整零出库显示0及无出库原因,真实缺口仍未知;总览分布与下钻同步口径 | 新发现商品、有出库/零出库/未知、两位小数和总览下钻一致性回归;视觉待人工截图确认 |
| 四个统计页面/日期统一 | 用户确认经营总览、销量分析、库存明细、库存周转 | 同一组最近3/7/15/30天、自定义;快捷范围截至北京时间昨日,自定义1~366天且最晚昨日;库存周转保留明确标注的7/15/30天对比,主要计算及无出库按所选时段;分页、下钻、返回使用固定区间 | 日期边界、自定义超过30天与样本缺口、查询及返回回归;视觉待人工1920×1080、100%截图确认 |
| 库存周转/日均出库对比 | 用户截图 `codex-clipboard-16bbd875-0161-4bc1-b86b-08436e702fe6.png` 确认柱顶显示数字、去除图下重复卡片 | 保留所选时段与7/15/30日对比(去重);柱顶显示最多两位小数,单位在纵轴;图下仅展示不完整窗口覆盖提示,完整数值不重复;原查看明细入口保留 | 定向测试与类型检查;视觉待人工1920×1080、100%截图确认 |
| 库存周转/可售天数阈值 | 用户截图 `codex-clipboard-814d8477-e5aa-4839-a6e4-12526740efd7.png` 指出搜索区裸数字30含义不明 | 删除搜索区重复阈值输入,仅在消化较慢/较快条件下保留具名的“可售天数阈值(天)”;说明与统计时间范围无关,其余关注条件不提交隐藏阈值;复用现有页内输入与应用按钮 | 定向交互与类型检查;视觉待人工1920×1080、100%截图确认 |
| 经营总览/三张榜单精简 | 用户截图确认说明过多占位 | 日期、资格数量、完整性详情及统计口径收进右上角“说明”;卡片仅留一行必要的未采齐/待评估/过期提示,三个图表顶部对齐;消化天数图上最多两位小数,明细保留原值 | 构建与交互回归;视觉待人工1920×1080、100%截图确认 |
| 库存周转/在途列 | 用户要求在库存周转表格添加在途 | 正品可售后默认展示在途,SKU及货主汇总共用固定库存版本;表头及筛选区支持全量升降序,未知末尾;全品质独立汇总,缺失显示已知小计与待核验数,不计入实物、可售或周转分母 | 字段映射、品质聚合、未知传播及排序回归;视觉待人工1920×1080、100%截图确认 |
## 2026-10-14 货主名称与客户查询
本轮用户确认补齐客户名称、按客户查询销量与周转。该确认覆盖此前移除独立货主筛选的局部规则;经营总览继续保留现有筛选,只更新名称展示。
| 页面 | 操作 | 承载依据与实现 | 验收 |
|---|---|---|---|
| 销量分析、库存分析 | 名称或编码选客户 | 本轮确认;复用现有 QueryToolbar 可搜索下拉框,标签为名称及编码,提交仍使用 customerCode;服务端先筛选再排名分页 | 客户筛选、固定分页、名称展示定向测试及类型检查通过;视觉待人工1920×1080、100%截图 |
| 总览、销量、库存周转 | 货主归属展示 | 本轮客户截图与名称映射确认;名称优先、编码辅助或名称缺失时显示编码 | 待人工截图 |
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { beijingDate, overviewRange, overviewRangeOptions } from './overviewRange'
describe('overview completed calendar days', () => {
it('excludes today and uses exactly fifteen or three days through yesterday', () => {
expect(overviewRangeOptions.some(option => option.value === 'TODAY')).toBe(false)
expect(overviewRange('LAST_15_DAYS', '2026-10-14')).toEqual({ rangePreset: 'CUSTOM', startDate: '2026-09-29', endDate: '2026-10-13' })
expect(overviewRange('LAST_3_DAYS', '2026-01-02')).toMatchObject({ startDate: '2025-12-30', endDate: '2026-01-01' })
expect(() => overviewRange('TODAY', '2026-10-14')).toThrow()
})
it('offers the same four complete-day shortcuts and custom range', () => {
expect(overviewRangeOptions.map(option => option.label)).toEqual(['最近3天', '最近7天', '最近15天', '最近30天', '自定义'])
expect(overviewRange('LAST_7_DAYS', '2026-10-14')).toMatchObject({ startDate: '2026-10-07', endDate: '2026-10-13' })
expect(overviewRange('LAST_30_DAYS', '2026-10-14')).toMatchObject({ startDate: '2026-09-14', endDate: '2026-10-13' })
})
it('rejects custom today and future, allows yesterday and follows Beijing midnight', () => {
expect(() => overviewRange('CUSTOM', '2026-10-14', '2026-10-01', '2026-10-14')).toThrow('最晚为昨日')
expect(() => overviewRange('CUSTOM', '2026-10-14', '2026-10-01', '2026-10-15')).toThrow('最晚为昨日')
expect(overviewRange('CUSTOM', '2026-10-14', '2026-10-13', '2026-10-13')).toMatchObject({ startDate: '2026-10-13', endDate: '2026-10-13' })
expect(beijingDate(Date.parse('2026-10-13T15:59:59Z'))).toBe('2026-10-13')
expect(beijingDate(Date.parse('2026-10-13T16:00:00Z'))).toBe('2026-10-14')
})
})
@@ -0,0 +1,26 @@
import type { RangePreset } from './types'
import { previousDate } from './dashboardNavigation'
import { validateCustomRange } from './state'
export const overviewRangeOptions: { value: RangePreset; label: string }[] = [3, 7, 15, 30].map(days => ({ value: `LAST_${days}_DAYS` as RangePreset, label: `最近${days}天` })).concat([{ value: 'CUSTOM', label: '自定义' }])
export function beijingDate(now: number) {
return new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(now)
}
export function overviewRange(preset: RangePreset, anchor: string, start = '', end = '') {
const yesterday = previousDate(anchor)
if (preset === 'CUSTOM') {
const problem = validateCustomRange(start, end)
if (problem) throw new Error(problem)
if (end > yesterday) throw new Error('仅查询已结束的自然日,结束日期最晚为昨日')
} else {
end = yesterday
const day = new Date(`${anchor}T00:00:00Z`)
const windows: Partial<Record<RangePreset, number>> = { LAST_3_DAYS: 3, LAST_7_DAYS: 7, LAST_15_DAYS: 15, LAST_30_DAYS: 30 }
const days = windows[preset]
if (!days) throw new Error('请选择有效的统计时间范围')
day.setUTCDate(day.getUTCDate() - days)
start = day.toISOString().slice(0, 10)
if (start > end) throw new Error('当前周期还没有已结束的自然日,请选择其他时间范围')
}
return { rangePreset: 'CUSTOM' as const, startDate: start, endDate: end }
}
@@ -0,0 +1,25 @@
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import CollectionAccountMonitor from '../components/CollectionAccountMonitor.vue'
import type { Overview, WarehouseOverview } from '../types'
describe('prototype collection monitor layout', () => {
it('keeps the process and independent warehouse results visible even while idle', () => {
const overview: Overview = { sourceAccount: 'lingxing-primary', environmentLabel: 'TEST', collectionMode: 'BUSINESS', observedAt: '2026-10-13T08:00:00Z', warehouses: [
{ warehouseCode: 'US01', dataFamily: 'OUTBOUND', lastPublishedAt: null, protection: {}, currentTask: { taskStatus: 'DEAD' } },
{ warehouseCode: 'US01', dataFamily: 'INVENTORY', lastPublishedAt: '2026-10-13T07:00:00Z', dataFreshness: 'FRESH', protection: {} },
{ warehouseCode: 'US02', dataFamily: 'INVENTORY', lastPublishedAt: '2026-10-13T06:00:00Z', dataFreshness: 'STALE', protection: {} },
] as WarehouseOverview[] }
const w = mount(CollectionAccountMonitor, { props: { overview }, global: { stubs: { 'el-button': true } } })
expect(w.find('.standby-ring').exists()).toBe(true)
expect(w.find('[role="progressbar"]').exists()).toBe(false)
expect(w.findAll('.account-flow li')).toHaveLength(5)
expect(w.findAll('.warehouse-results article')).toHaveLength(2)
expect(w.text()).toContain('最近有任务停止')
expect(w.text()).toContain('最近发布结果')
expect(w.findAll('.warehouse-result').map(r => r.text())).toEqual(expect.arrayContaining([expect.stringContaining('尚未发布'), expect.stringContaining('已有结果')]))
expect(w.text()).not.toContain('全部完成'); expect(w.text()).not.toContain('100%')
w.unmount()
})
})
@@ -0,0 +1,64 @@
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
import { defineComponent, h, reactive } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CollectionView from './CollectionView.vue'
import type { Overview, Task } from '../types'
const mocks = vi.hoisted(() => ({ overview: vi.fn(), task: vi.fn(), tasks: vi.fn(), runs: vi.fn(), retry: vi.fn(), allowed: true, route: { query: {} } }))
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
vi.mock('vue-router', () => ({ useRoute: () => mocks.route }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) }))
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } })
const task: Task = { taskNo: 'ACTIVE', warehouseCode: '', dataFamily: 'OUTBOUND', taskType: 'BACKFILL', taskStatus: 'RUNNING', queryFrom: null, queryUntil: null, applyState: null, attemptCount: 1, latestAttemptNo: 1, nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: { phase: 'DETAIL', progressScope: 'ACCOUNT_SHARED', countBasis: 'TASK_CUMULATIVE', completed: 339, total: 2787, totalKnown: true, unit: 'ORDER' }, resultPublicationState: 'NOT_PUBLISHED' }
const overview: Overview = { sourceAccount: 'lingxing-primary', environmentLabel: 'TEST', collectionMode: 'BUSINESS', observedAt: '2026-10-13T08:00:00Z', accountActivity: task, warehouses: [] }
const wrappers: ReturnType<typeof mount>[] = []
function page() { const w = mount(CollectionView, { global: { stubs: { AdminPage: shell, 'el-button': button, ApiErrorMeta: true } } }); wrappers.push(w); return w }
beforeEach(() => { vi.clearAllMocks(); mocks.allowed = true; mocks.route = reactive({ query: { warehouseCode: 'US01', taskNo: 'OLD-HISTORY' } }); mocks.overview.mockResolvedValue(overview) })
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers(); Object.defineProperty(document, 'hidden', { configurable: true, value: false }) })
describe('account-only collection page', () => {
it('keeps the last successful progress visible while a manual refresh is pending', async () => {
const w = page(); await flushPromises()
let finish!: (value: Overview) => void
mocks.overview.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
await w.findAll('button').find(b => b.text().includes('刷新状态'))!.trigger('click')
expect(w.text()).toContain('12.1%')
expect(w.text()).toContain('2448')
finish(overview); await flushPromises()
expect(w.text()).toContain('12.1%')
})
it('ignores old warehouse/history links and only reads the account overview', async () => {
const w = page(); await flushPromises()
expect(mocks.overview.mock.calls[0]?.slice(0, 2)).toEqual(['lingxing-primary', undefined])
expect(w.text()).toContain('12.1%'); expect(w.text()).toContain('2448')
expect(w.text()).not.toContain('OLD-HISTORY')
for (const name of ['task', 'tasks', 'runs', 'retry'] as const) expect(mocks[name]).not.toHaveBeenCalled()
for (const text of ['查看配置', '查看详情', '历史记录与排查', '核对恢复请求', '任务号定位']) expect(w.text()).not.toContain(text)
expect(w.find('form').exists()).toBe(false); expect(w.find('details').exists()).toBe(false)
})
it('uses one poller and aborts hidden requests before resuming', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
page(); await flushPromises(); expect(mocks.overview).toHaveBeenCalledTimes(1)
let finish!: (value: Overview) => void
mocks.overview.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
await vi.advanceTimersByTimeAsync(10000)
const signal = mocks.overview.mock.calls.at(-1)?.[2] as AbortSignal
Object.defineProperty(document, 'hidden', { configurable: true, value: true }); document.dispatchEvent(new Event('visibilitychange'))
expect(signal.aborted).toBe(true)
finish({ ...overview, accountActivity: null }); await flushPromises()
const count = mocks.overview.mock.calls.length
await vi.advanceTimersByTimeAsync(60000); expect(mocks.overview).toHaveBeenCalledTimes(count)
Object.defineProperty(document, 'hidden', { configurable: true, value: false }); document.dispatchEvent(new Event('visibilitychange')); await flushPromises()
expect(mocks.overview).toHaveBeenCalledTimes(count + 1)
})
it('retains last counts after a failed refresh and never turns failure into idle', async () => {
const w = page(); await flushPromises(); mocks.overview.mockRejectedValue(new Error('offline'))
await w.findAll('button').find(b => b.text().includes('刷新状态'))!.trigger('click'); await flushPromises()
expect(w.text()).toContain('状态更新失败'); expect(w.text()).toContain('339')
expect(w.text()).not.toContain('等待下一次采集')
})
it('does not request account data without list permission', async () => {
mocks.allowed = false; const w = page(); await flushPromises()
expect(w.text()).toContain('没有采集状态查看权限'); expect(mocks.overview).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,56 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { analyticsApi } from '../api/analytics'
import type { Overview } from '../types'
import { RequestFence, RuntimePoller, hasActiveTask } from '../state'
import CollectionAccountMonitor from '../components/CollectionAccountMonitor.vue'
const access = useAccessPreviewStore()
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
const overview = ref<Overview | null>(null)
const error = ref<ApiErrorDetails | null>(null)
const loading = ref(false)
const visible = ref(!document.hidden)
const fence = new RequestFence()
let active = false
function busy() {
const tasks = [overview.value?.accountActivity, ...(overview.value?.warehouses.flatMap(w => [...(w.activeTasks ?? []), w.currentTask, w.sharedUpstream]) ?? [])].filter(t => t != null)
return hasActiveTask(tasks) || tasks.some(t => ['READY', 'APPLYING', 'WAITING', 'QUEUED'].includes(t.taskStatus))
}
async function read() {
if (!active || !visible.value || !permitted.value) return false
const request = fence.begin()
loading.value = true
try {
const result = await analyticsApi.overview('lingxing-primary', undefined, request.signal)
if (!request.current()) return false
overview.value = result; error.value = null
} catch (cause) {
if (request.current()) error.value = getApiErrorDetails(cause, '采集状态更新失败,请重试')
} finally { if (request.current()) loading.value = false }
return busy()
}
const poller = new RuntimePoller(read, () => fence.cancel())
function visibility() { visible.value = !document.hidden; poller.setVisible(visible.value) }
function resume() { if (active) return; active = true; visibility(); poller.start() }
function suspend() { active = false; visible.value = false; poller.stop(); loading.value = false }
onMounted(() => { resume(); document.addEventListener('visibilitychange', visibility) })
onActivated(resume)
onDeactivated(suspend)
onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange', visibility) })
</script>
<template>
<AdminPage class="admin-density">
<p v-if="!permitted" role="alert">没有采集状态查看权限。</p>
<template v-else>
<LoadErrorState v-if="error" title="采集状态更新失败" :message="error.message" :loading="loading" compact @retry="read"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<CollectionAccountMonitor :overview="overview" :loading="loading" :stale="Boolean(error)" :visible="visible" @refresh="read" />
</template>
</AdminPage>
</template>
@@ -0,0 +1,36 @@
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<string, string> }, 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: defineComponent({ name: 'AnalyticsView', props: ['mode'], setup(_, { slots }) { return () => h('section', { class: 'search-region' }, slots['view-tabs']?.()) } }), InventoryTurnoverView: defineComponent({ name: 'InventoryTurnoverView', setup(_, { slots }) { return () => h('section', { class: 'search-region' }, slots['view-tabs']?.()) } }), 'el-button': button } } }
beforeEach(() => { mocks.route = reactive({ query: {} }); mocks.push.mockReset() })
describe('inventory view entry', () => {
it('defaults to turnover and switches to detail without reusing turnover filters', async () => {
mocks.route.query = { warehouseCode: 'US02', focus: 'FROZEN' }
const w = mount(InventoryView, options)
expect(w.findComponent({ name: 'InventoryTurnoverView' }).exists()).toBe(true)
expect(w.find('.search-region .inventory-views').exists()).toBe(true)
expect(w.find('button[aria-pressed="true"]').text()).toBe('库存周转')
await w.findAll('button').find(b => b.text() === '库存明细')!.trigger('click')
expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/inventory', query: { view: 'DETAIL', warehouseCode: 'US02' } })
mocks.route.query = { view: 'DETAIL', warehouseCode: 'US02' }; await flushPromises()
expect(w.find('button[aria-pressed="true"]').text()).toBe('库存明细')
w.findComponent({ name: 'AnalyticsView' }).vm.$emit('warehouseSelected', 'US01')
await w.findAll('button').find(b => b.text() === '库存周转')!.trigger('click')
expect(mocks.push).toHaveBeenLastCalledWith({ path: '/self-operated/inventory', query: { view: 'TURNOVER', warehouseCode: 'US01' } })
w.unmount()
})
it('preserves original strict detail links and rejects contradictory parameters', async () => {
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()
})
})
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AnalyticsView from '../components/AnalyticsView.vue'
import InventoryTurnoverView from '../components/InventoryTurnoverView.vue'
import { inventoryView } from '../turnoverNavigation'
const route = useRoute(), router = useRouter()
const retainedWarehouse = ref('')
const selected = computed(() => {
try { return { view: inventoryView(route.query), error: '' } }
catch (cause) { return { view: null, error: (cause as Error).message } }
})
function switchView(view: 'TURNOVER' | 'DETAIL') {
const warehouseCode = retainedWarehouse.value || (typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '')
void router.push({ path: '/self-operated/inventory', query: { view, ...(warehouseCode ? { warehouseCode } : {}) } })
}
</script>
<template>
<p v-if="selected.error" role="alert">{{ selected.error }}</p>
<component v-else :is="selected.view === 'DETAIL' ? AnalyticsView : InventoryTurnoverView" v-bind="selected.view === 'DETAIL' ? { mode: 'inventory' as const } : {}" @warehouse-selected="retainedWarehouse = $event">
<template #view-tabs>
<nav class="inventory-views" aria-label="库存分析视图">
<button type="button" :aria-pressed="selected.view === 'TURNOVER'" @click="switchView('TURNOVER')">库存周转</button>
<button type="button" :aria-pressed="selected.view === 'DETAIL'" @click="switchView('DETAIL')">库存明细</button>
</nav>
</template>
</component>
</template>
<style scoped>
.inventory-views { display: flex; align-items: center; gap: var(--admin-space-4); }
.inventory-views button { border: 0; border-bottom: 2px solid transparent; background: transparent; min-height: var(--admin-control-height); padding: var(--admin-space-1) var(--admin-space-2); color: var(--el-text-color-regular); font: inherit; cursor: pointer; white-space: nowrap; }
.inventory-views button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
.inventory-views button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
</style>
@@ -0,0 +1,360 @@
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import OverviewView from './OverviewView.vue'
import DashboardBars from '../components/DashboardBars.vue'
import DashboardTrend from '../components/DashboardTrend.vue'
import DataTable from '@/shared/components/DataTable.vue'
import { dashboardFixture } from './dashboard.fixture'
const mocks = vi.hoisted(() => ({ dashboard: vi.fn(), overview: vi.fn(), daily: vi.fn(), push: vi.fn(), query: {} as Record<string, string>, permitted: true }))
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }), useRouter: () => ({ push: mocks.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.permitted }) }))
const passthrough = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const query = defineComponent({ props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], setup(_, { emit }) { return () => h('button', { onClick: () => emit('search') }, '查询') } })
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
const dialog = defineComponent({ props: ['modelValue', 'width', 'title', 'appendToBody'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', [h('button', { onClick: () => undefined }, '趋势弹框'), slots.default?.()]) : null } })
const error = defineComponent({ props: ['message'], setup(props) { return () => h('p', props.message) } })
const popover = defineComponent({ props: ['trigger', 'placement', 'title', 'width'], setup(_, { slots }) { return () => h('div', slots.reference?.()) } })
const options = { global: { directives: { loading: () => undefined }, stubs: { AdminPage: passthrough, QueryToolbar: query, AppDialog: dialog, LoadErrorState: error, ApiErrorMeta: true, SkuTrend: true, 'el-button': button, 'el-popover': popover, 'el-table-column': true, 'el-table': true, 'el-option': true, 'el-select': true } } }
const wrappers: ReturnType<typeof mount>[] = []
function page() { const wrapper = mount(OverviewView, options); wrappers.push(wrapper); return wrapper }
function findButton(wrapper: ReturnType<typeof mount>, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
async function choose(wrapper: ReturnType<typeof mount>, values: Record<string, string | string[] | null>) { const toolbar = wrapper.findComponent(query); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() }
beforeEach(() => {
vi.clearAllMocks(); mocks.query = {}; mocks.permitted = true
vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
mocks.overview.mockResolvedValue({ warehouses: [], observedAt: '2026-10-13T00:00:00Z' })
mocks.dashboard.mockResolvedValue(dashboardFixture)
mocks.daily.mockResolvedValue({ context: dashboardFixture.context, summary: { ...dashboardFixture.salesTrend.summary, customerCode: 'OWNER-A', sku: 'SKU-X' }, points: dashboardFixture.salesTrend.points })
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() })
describe('warehouse operating dashboard', () => {
it.each(['PARTIAL', 'UNKNOWN'])('renders omitted nullable KPI fields for %s instead of rejecting the dashboard', async status => {
const response = structuredClone(dashboardFixture)
for (const metric of Object.values(response.kpis)) {
metric.value = null; metric.knownSubtotal = null; metric.unknownSkuCount = null
metric.outboundQuantity = null; metric.observedQuantity = null; metric.dataStatus = status
}
response.kpis.intervalOutboundQuantity.observedQuantity = '8'
response.kpis.physicalQuantity.knownSubtotal = '128'
// Match a server response serialized with NON_NULL, not an explicit-null fixture.
mocks.dashboard.mockResolvedValue(JSON.parse(JSON.stringify(response, (_, value) => value === null ? undefined : value)))
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).not.toContain('指标结构不完整')
expect(wrapper.findAll('.dashboard-kpis strong').map(node => node.text())).toEqual(['8', '—', '—', '—', '—', '—'])
expect(wrapper.text()).toContain('已知小计 128')
expect(wrapper.findAllComponents(DashboardBars)).toHaveLength(4)
})
it.each(['intervalOutboundQuantity', 'averageDailyOutboundQuantity', 'physicalQuantity', 'availableQuantity', 'physicalSkuCount', 'noOutboundSkuCount'] as const)('keeps the page usable when the response omits %s and recovers after refresh', async key => {
const invalid = structuredClone(dashboardFixture)
delete (invalid.kpis as Partial<typeof invalid.kpis>)[key]
mocks.dashboard.mockResolvedValue(invalid)
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).toContain('经营总览返回的指标结构不完整')
expect(wrapper.find('.dashboard-kpis').exists()).toBe(false)
expect(wrapper.findComponent(query).exists()).toBe(true)
mocks.dashboard.mockResolvedValue(dashboardFixture)
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
expect(wrapper.text()).not.toContain('经营总览返回的指标结构不完整')
})
it('preserves the last valid result when a refreshed response has a null KPI', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, kpis: { ...dashboardFixture.kpis, averageDailyOutboundQuantity: null } })
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('经营总览返回的指标结构不完整,请重试或联系管理员;展示上次查询')
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
})
it('explains today and pending publication using the returned query context', async () => {
const data = structuredClone(dashboardFixture)
data.context.rebuildRequired = true
data.context.salesStatus = 'REBUILD_REQUIRED'
mocks.dashboard.mockResolvedValue(data)
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).toContain('所选时段包含今日(2026-10-13)')
expect(wrapper.text()).toContain('库存评估等待新版销量发布')
const refreshed = structuredClone(data)
refreshed.context.rangeEndDateExclusive = refreshed.context.anchorDate
refreshed.context.rebuildRequired = false
refreshed.context.salesStatus = 'VALID'
mocks.dashboard.mockResolvedValue(refreshed)
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(wrapper.text()).not.toContain('所选时段包含今日(')
expect(wrapper.text()).not.toContain('库存评估等待新版销量发布')
})
it('offers coverage evidence for both inventory rankings even when they are empty', async () => {
const data = structuredClone(dashboardFixture)
data.longCoverageSkus = { ...data.longCoverageSkus, items: [], eligibleCount: '0', unassessableCount: '2711', dataStatus: 'PARTIAL' }
data.noOutboundSkus = { ...data.noOutboundSkus, items: [], eligibleCount: '0', unassessableCount: '2741', dataStatus: 'PARTIAL' }
mocks.dashboard.mockResolvedValue(data)
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '说明')).toHaveLength(3)
await wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '说明')[1]!.trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('全仓库存分布的待核验原因')
expect(wrapper.text()).toContain('待评估 2711 SKU')
expect(wrapper.text()).toContain('待评估 2741 SKU')
expect(mocks.dashboard).toHaveBeenCalledOnce()
})
it('removes today, blocks custom today before querying and drills into the displayed dates', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
const toolbar = wrapper.findComponent(query)
const options = toolbar.props('fields').find((field: { key: string }) => field.key === 'rangePreset').options
expect(options.some((option: { value: string }) => option.value === 'TODAY')).toBe(false)
expect(options.find((option: { value: string }) => option.value === 'LAST_15_DAYS').label).toBe('最近15天')
const calls = mocks.dashboard.mock.calls.length
await choose(wrapper, { rangePreset: 'CUSTOM', dateRange: ['2026-10-01', '2026-10-13'] })
const disabled = toolbar.props('fields').find((field: { key: string }) => field.key === 'dateRange').props.disabledDate
expect(disabled(new Date(2026, 9, 13))).toBe(true)
expect(disabled(new Date(2026, 9, 12))).toBe(false)
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard).toHaveBeenCalledTimes(calls)
expect(wrapper.text()).toContain('结束日期最晚为昨日')
await findButton(wrapper, '查看销量').trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ rangePreset: 'CUSTOM', startDate: dashboardFixture.context.rangeStartDate, endDate: '2026-10-13' })
})
it('uses one date range field, blocks incomplete ranges and clears obsolete validation errors', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A', rangePreset: 'CUSTOM' })
const toolbar = wrapper.findComponent(query)
expect(toolbar.props('fields').filter((field: { type: string }) => field.type.includes('date'))).toMatchObject([{ key: 'dateRange', type: 'daterange', label: '日期范围', width: 300 }])
expect(mocks.dashboard).not.toHaveBeenCalled()
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard).not.toHaveBeenCalled()
await choose(wrapper, { dateRange: ['2026-10-10'] })
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard).not.toHaveBeenCalled()
expect(wrapper.findComponent(error).exists()).toBe(true)
await choose(wrapper, { dateRange: ['2026-10-10', '2026-10-10'] })
expect(wrapper.findComponent(error).exists()).toBe(false)
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard).toHaveBeenCalledOnce()
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' })
expect(mocks.dashboard.mock.calls[0]?.[0]).not.toHaveProperty('dateRange')
await choose(wrapper, { dateRange: null })
await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard).toHaveBeenCalledOnce()
await choose(wrapper, { rangePreset: 'LAST_3_DAYS' })
expect(wrapper.findComponent(error).exists()).toBe(false)
})
it('restores a custom range and keeps the applied dates when an unsubmitted range is edited', async () => {
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10', scroll: 123 }) }
const wrapper = page(); await flushPromises()
expect(wrapper.findComponent(query).props('modelValue').dateRange).toEqual(['2026-10-10', '2026-10-10'])
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ startDate: '2026-10-10', endDate: '2026-10-10', salesRunId: '11' })
await choose(wrapper, { dateRange: ['2026-10-11', '2026-10-12'] })
await findButton(wrapper, '查看Top100').trigger('click')
expect(JSON.parse(mocks.push.mock.calls.at(-1)?.[0].query.overviewReturn)).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' })
wrapper.findComponent(query).vm.$emit('reset'); await flushPromises()
expect(wrapper.findComponent(query).props('modelValue').dateRange).toEqual([])
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toHaveProperty('startDate')
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toHaveProperty('endDate')
})
it('does not replace an invalid warehouse-less return with the default warehouse', async () => {
mocks.query = { context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'LAST_7_DAYS', scroll: 760 }) }
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A', warehouseName: 'A仓' }], observedAt: '2026-10-13T00:00:00Z' })
const wrapper = page(); await flushPromises()
expect(wrapper.text()).toContain('经营总览返回参数无效')
expect(wrapper.findComponent(query).props('modelValue').warehouseCode).toBe('')
expect(mocks.dashboard).not.toHaveBeenCalled()
await choose(wrapper, { warehouseCode: 'WH-A' })
expect(mocks.dashboard).toHaveBeenCalledOnce()
expect(mocks.dashboard.mock.calls[0]?.[0]).not.toHaveProperty('queryAsOf')
})
it('uses the common title/actions/filter surface without an empty result table', async () => {
const wrapper = page(); await flushPromises()
expect(wrapper.findComponent(DataTable).props()).toMatchObject({ appearance: 'integrated', resultsVisible: false, columnSettingsMode: 'hide' })
expect(wrapper.find('.data-table__toolbar-start h2').text()).toBe('经营总览')
expect(wrapper.find('.data-table__toolbar-actions').text()).toContain('整体刷新')
expect(wrapper.find('.data-table__filters').findComponent(query).exists()).toBe(true)
expect(wrapper.findComponent(query).attributes('preference-key')).toBe('self-operated-overview-query')
expect(wrapper.find('el-table-stub').exists()).toBe(false)
expect(wrapper.findComponent(popover).props()).toMatchObject({ trigger: 'click', placement: 'bottom-end', title: '统计口径与采集时间', width: 460 })
expect(wrapper.find('.data-table__toolbar-actions button[aria-label="统计口径与采集时间"]').exists()).toBe(true)
expect(wrapper.find('details.dashboard-data-notes').exists()).toBe(false)
})
it('automatically loads the first warehouse and keeps it selected on reset', async () => {
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A', warehouseName: '仓A' }, { warehouseCode: 'WH-A' }, { warehouseCode: 'WH-B' }] })
const wrapper = page(); await flushPromises()
expect(wrapper.findComponent(query).props('modelValue').warehouseCode).toBe('WH-A')
expect(mocks.dashboard).toHaveBeenCalledTimes(1)
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'CUSTOM' })
expect(wrapper.find('.dashboard-data-notes').exists()).toBe(false)
await choose(wrapper, { rangePreset: 'LAST_3_DAYS' })
wrapper.findComponent(query).vm.$emit('reset'); await flushPromises()
expect(wrapper.findComponent(query).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' })
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('queryAsOf')
})
it('preserves a warehouse supplied by a direct link instead of replacing it with the default', async () => {
mocks.query = { warehouseCode: 'WH-B' }
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A' }, { warehouseCode: 'WH-B' }] })
const wrapper = page(); await flushPromises()
expect(mocks.dashboard).toHaveBeenCalledTimes(1)
expect(mocks.dashboard.mock.calls[0]?.[0].warehouseCode).toBe('WH-B')
expect(wrapper.findComponent(query).props('modelValue').warehouseCode).toBe('WH-B')
})
it('restores the saved warehouse, range, fixed context and scroll when returning from a drilldown', async () => {
const scroll = vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'LAST_7_DAYS', scroll: 760 }) }
const wrapper = page(); await flushPromises()
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'CUSTOM', startDate: '2026-10-06', endDate: '2026-10-12', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
expect(wrapper.findComponent(query).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_7_DAYS' })
expect(scroll).toHaveBeenCalledWith({ top: 760 })
})
it('does not silently refresh an expired return context', async () => {
mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify({ ...dashboardFixture.context, queryExpiresAt: '2026-10-13T00:01:00Z' }), overviewReturn: JSON.stringify({ rangePreset: 'LAST_3_DAYS', scroll: 0 }) }
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] }); vi.setSystemTime(new Date('2026-10-13T00:02:00Z'))
const wrapper = page(); await flushPromises()
expect(mocks.dashboard).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('原图查询已过期')
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(mocks.dashboard.mock.calls[0]?.[0]).not.toHaveProperty('queryAsOf')
})
it('keeps sales available when inventory is unavailable, with no fabricated distribution', async () => {
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, inventoryDrilldownExpiresAt: null }, coverageDistribution: { ...dashboardFixture.coverageDistribution, candidateSkuCount: null, buckets: [], dataStatus: 'UNKNOWN', reasonCodes: ['INVENTORY_UNAVAILABLE'] } })
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).toContain('无可用库存分布资料')
expect(wrapper.text()).toContain('库存资料不可用')
expect(wrapper.findComponent(DashboardTrend).exists()).toBe(true)
expect(findButton(wrapper, '查看Top100').attributes('disabled')).toBeUndefined()
})
it('keeps inventory present without published sales and never derives a daily average from partial sales', async () => {
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, salesRunId: null, salesScopeHash: null }, kpis: { ...dashboardFixture.kpis, averageDailyOutboundQuantity: { ...dashboardFixture.kpis.averageDailyOutboundQuantity, value: null, knownSubtotal: null, dataStatus: 'UNKNOWN', reasonCodes: ['SALES_UNAVAILABLE'] }, physicalQuantity: { ...dashboardFixture.kpis.physicalQuantity, value: '128', dataStatus: 'COMPLETE' } }, salesTrend: { ...dashboardFixture.salesTrend, points: [] } })
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findAll('.dashboard-kpis strong')[1]!.text()).toBe('—')
expect(wrapper.findAll('.dashboard-kpis strong')[2]!.text()).toBe('128')
expect(wrapper.text()).toContain('销量资料不可用')
expect(wrapper.findAllComponents(DashboardBars)[0]!.props('rows')).toHaveLength(7)
})
it('does not request business data without a selected warehouse and renders six metrics/five charts from one snapshot', async () => {
const wrapper = page(); await flushPromises()
expect(mocks.dashboard).not.toHaveBeenCalled()
await choose(wrapper, { warehouseCode: 'WH-A' })
expect(mocks.dashboard).toHaveBeenCalledTimes(1)
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'CUSTOM' })
expect(wrapper.findAll('.dashboard-kpis section')).toHaveLength(6)
expect(wrapper.findAllComponents(DashboardBars)).toHaveLength(4)
expect(wrapper.findAllComponents(DashboardTrend)).toHaveLength(1)
expect(wrapper.text()).toContain('所选时段无出库库存Top10')
expect(wrapper.text()).not.toContain('近30日无出库')
expect(wrapper.findAllComponents(DashboardBars).slice(1).every(chart => !chart.text().includes('2026-10-10 至 2026-10-14'))).toBe(true)
expect(wrapper.text()).toContain('区间已采集出库量')
expect(wrapper.text()).toContain('已知小计 128')
expect(wrapper.text()).toContain('另 1 SKU 待核验')
expect(wrapper.text()).not.toContain('lingxing-primary')
})
it('changes the shared date range while pinning versions, then explicitly refreshes the whole snapshot', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
await choose(wrapper, { rangePreset: 'LAST_3_DAYS' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-12', salesRunId: '11', inventoryRunId: '12', queryAsOf: dashboardFixture.context.queryAsOf })
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('salesRunId')
})
it('automatically loads the new warehouse, shows loading and ignores the old late response', async () => {
let finish!: (value: unknown) => void
let finishNew!: (value: unknown) => void
mocks.dashboard.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
mocks.dashboard.mockImplementationOnce(() => new Promise(resolve => { finishNew = resolve }))
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
await choose(wrapper, { warehouseCode: 'WH-B' })
expect(wrapper.find('.dashboard-kpis').exists()).toBe(false)
expect(wrapper.find('.dashboard-placeholder').exists()).toBe(true)
expect(mocks.dashboard).toHaveBeenCalledTimes(2)
expect(mocks.dashboard.mock.calls[1]?.[0]).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'CUSTOM' })
expect(mocks.dashboard.mock.calls[1]?.[0]).not.toHaveProperty('queryAsOf')
finishNew(dashboardFixture); await flushPromises()
expect(wrapper.find('.dashboard-kpis').exists()).toBe(true)
finish({ ...dashboardFixture, kpis: { ...dashboardFixture.kpis, physicalQuantity: { ...dashboardFixture.kpis.physicalQuantity, value: '999999' } } }); await flushPromises()
expect(wrapper.text()).not.toContain('999999')
expect(mocks.dashboard.mock.calls[0]?.[1].aborted).toBe(true)
})
it('retains the switched warehouse when the query toolbar clears its fields on reset', async () => {
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A' }, { warehouseCode: 'WH-B' }] })
const wrapper = page(); await flushPromises()
await choose(wrapper, { warehouseCode: 'WH-B' })
const toolbar = wrapper.findComponent(query)
toolbar.props('modelValue').warehouseCode = ''
toolbar.props('modelValue').rangePreset = ''
toolbar.vm.$emit('reset'); await flushPromises()
expect(toolbar.props('modelValue')).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'LAST_15_DAYS' })
expect(mocks.dashboard).toHaveBeenCalledTimes(3)
expect(mocks.dashboard.mock.calls.at(-1)?.[0]).toMatchObject({ warehouseCode: 'WH-B', rangePreset: 'CUSTOM' })
})
it('preserves same-SKU owner identity in the 1120 dialog and cancels closed requests', async () => {
let finish!: (value: unknown) => void
mocks.daily.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
wrapper.findAllComponents(DashboardBars)[1]!.vm.$emit('select', '1'); await flushPromises()
expect(mocks.daily.mock.calls[0]?.[0]).toMatchObject({ customerCode: 'OWNER-B', sku: 'SKU-X', rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-13', salesRunId: '11' })
expect(wrapper.findAllComponents(dialog).find(item => item.props('title') === 'SKU每日出库趋势')!.props('width')).toBe('1120px')
for (const modal of wrapper.findAllComponents(dialog)) expect(modal.props('appendToBody')).not.toBeUndefined()
wrapper.findAllComponents(dialog).find(item => item.props('title') === 'SKU每日出库趋势')!.vm.$emit('update:modelValue', false); await flushPromises()
finish({ context: dashboardFixture.context, summary: { ...dashboardFixture.salesTrend.summary, sku: 'LATE-SKU' }, points: [] }); await flushPromises()
expect(mocks.daily.mock.calls[0]?.[1].aborted).toBe(true)
expect(wrapper.text()).not.toContain('LATE-SKU')
})
it('keeps an uncovered selected period empty without changing its dates or claiming zero sales', async () => {
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, hotSkus: { ...dashboardFixture.hotSkus, windowStartDate: '2026-10-13', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 1, eligibleCount: '0', items: [], dataStatus: 'UNKNOWN', reasonCodes: ['SALES_WINDOW_INCOMPLETE'] } })
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findAllComponents(DashboardBars)[1]!.props('rows')).toEqual([])
expect(wrapper.find('.dashboard-rankings').text()).not.toContain('未采集日期不计为零出库')
await findButton(wrapper, '说明').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('未采集日期不计为零出库')
await findButton(wrapper, '查看Top100').trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ startDate: '2026-10-13', endDate: '2026-10-13', rangePreset: 'CUSTOM' })
})
it('shows partial hot sales for the selected dates and preserves them in Top100 and SKU trends', async () => {
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, hotSkus: { ...dashboardFixture.hotSkus, windowStartDate: '2026-10-01', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 13, dataStatus: 'PARTIAL', reasonCodes: ['SALES_WINDOW_INCOMPLETE'], items: [{ ...dashboardFixture.hotSkus.items[0], windowOutboundQuantity: '37', value: '37', averageDailyOutboundQuantity: null, inventoryCoverageDays: null }] } })
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).toContain('所选时段热销Top10')
expect(wrapper.text()).toContain('数据未采齐,排名仅供参考')
const hot = wrapper.findAllComponents(DashboardBars)[1]!
expect(hot.props('rows')[0]).toMatchObject({ value: '37' })
expect(hot.props('rows')[0].detail).toContain('13天窗口已采集出库 37 件')
expect(hot.props('rows')[0].detail).not.toContain('近30日出库')
await findButton(wrapper, '查看Top100').trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-10-01', endDate: '2026-10-13' })
hot.vm.$emit('select', '0'); await flushPromises()
expect(mocks.daily.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-01', endDate: '2026-10-13', customerCode: 'OWNER-A', sku: 'SKU-X' })
})
it('emits exact bucket/day/ranking navigation and keeps unknown day navigation disabled', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
wrapper.findAllComponents(DashboardBars)[0]!.vm.$emit('select', 'DAYS_GT_90'); await flushPromises()
expect(mocks.push.mock.calls.at(-1)?.[0]).toMatchObject({ path: '/self-operated/inventory', query: { coverageBucket: 'DAYS_GT_90', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', from: 'overview' } })
const chart = wrapper.findComponent(DashboardTrend)
await chart.find('.chart-disclosure button').trigger('click')
await chart.findAll('tbody button')[1]!.trigger('click')
expect(mocks.push).toHaveBeenCalledTimes(1)
expect(chart.text()).toContain('销量未覆盖')
await chart.findAll('tbody button')[0]!.trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ analysis: 'SKU', rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' })
await findButton(wrapper, '查看Top100').trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-13' })
expect(JSON.parse(mocks.push.mock.calls.at(-1)?.[0].query.context)).toEqual(dashboardFixture.context)
})
it('expires inventory before sales while retaining stock-independent SKU trends', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] }); vi.setSystemTime(new Date('2026-10-13T00:00:00Z'))
mocks.dashboard.mockResolvedValue({ ...dashboardFixture, context: { ...dashboardFixture.context, inventoryDrilldownExpiresAt: '2026-10-13T00:00:01Z' } })
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
await vi.advanceTimersByTimeAsync(2000)
expect(wrapper.text()).toContain('原图库存已过期或不可用')
expect(wrapper.findAllComponents(DashboardBars)[0]!.props('disabled')).toBe(true)
expect(findButton(wrapper, '查看Top100').attributes('disabled')).toBeUndefined()
wrapper.findAllComponents(DashboardBars)[2]!.vm.$emit('select', '0'); await flushPromises()
expect(mocks.daily).toHaveBeenCalledTimes(1)
wrapper.findAllComponents(DashboardBars)[0]!.vm.$emit('select', 'DAYS_GT_90'); await flushPromises()
expect(mocks.push).not.toHaveBeenCalled()
})
it('retains prior figures on failed refresh and does not render unknown quantities as zero', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
mocks.dashboard.mockRejectedValue(new Error('offline'))
await findButton(wrapper, '整体刷新').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('展示上次查询')
expect(wrapper.find('.dashboard-kpis').exists()).toBe(true)
expect(wrapper.findAll('.dashboard-kpis strong')[2]!.text()).toBe('—')
})
})

Some files were not shown because too many files have changed in this diff Show More