Compare commits
56
Commits
e404fe1049
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2ec169eb3 | ||
|
|
46364d3f89 | ||
|
|
2a9c23ebc6 | ||
|
|
adfb1a5cb3 | ||
|
|
ff9ca74ba4 | ||
|
|
cb38114db9 | ||
|
|
e7411eb03f | ||
|
|
6b30e35d07 | ||
|
|
0830d7a3a3 | ||
|
|
6a905419fc | ||
|
|
d8a4160bc7 | ||
|
|
e0f54edcc9 | ||
|
|
3f728cd0b2 | ||
|
|
3291d34fb8 | ||
|
|
02a9d72ef8 | ||
|
|
2cf9129606 | ||
|
|
4c4a3db1eb | ||
|
|
698ca34529 | ||
|
|
133654327d | ||
|
|
260409c071 | ||
|
|
c7d9af0165 | ||
|
|
bda0743520 | ||
|
|
50df363f86 | ||
|
|
c61ae525e1 | ||
|
|
8bf7b20b89 | ||
|
|
1de73b5f1c | ||
|
|
6bdaff3fdf | ||
|
|
9dcd991ddb | ||
|
|
58df0ed9e3 | ||
|
|
b73dc0d410 | ||
|
|
cd3a32f372 | ||
|
|
600fdcb4b4 | ||
|
|
57c50e6876 | ||
|
|
449d0c4965 | ||
|
|
2186f1f3a6 | ||
|
|
533273769d | ||
|
|
cc4856ceac | ||
|
|
3f6d74dee1 | ||
|
|
92bdd32f86 | ||
|
|
20b4d1425b | ||
|
|
43611ecc55 | ||
|
|
f97cdad353 | ||
|
|
315cb8e663 | ||
|
|
ee174f8265 | ||
|
|
240aa5d6d0 | ||
|
|
d77abbef7c | ||
|
|
1edb65df77 | ||
|
|
baa69e66a1 | ||
|
|
d7f2d09b88 | ||
|
|
e124fbcf68 | ||
|
|
ebff6f8167 | ||
|
|
7f39adf9f5 | ||
|
|
67bd3e6e53 | ||
|
|
c0155b8293 | ||
|
|
175d8936b9 | ||
|
|
081f79a9a1 |
@@ -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 存在代替页面接入完成 |
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+37
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 },
|
||||
@@ -10,6 +23,17 @@ describe('appointment calendar interactions', () => {
|
||||
global: { stubs: { ElButton: { template: '<button><slot /></button>' } }, directives: { loading: {} } },
|
||||
})
|
||||
|
||||
it('opens a readable appointment without exposing write actions', async () => {
|
||||
const wrapper = render()
|
||||
const event = {key:'a',type:'delivery' as const,date:'2026-09-24',time:'09:00',title:'预约',fields:[],canView:true,canEdit:false,canDelete:false}
|
||||
await wrapper.setProps({events:[event]})
|
||||
const actions=wrapper.get('.detail-event footer')
|
||||
expect(actions.text()).toBe('查看预约')
|
||||
await actions.get('button').trigger('click')
|
||||
expect(wrapper.emitted('view')?.[0]).toEqual([event])
|
||||
expect(wrapper.emitted('edit')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('hides optional references by default and counts only appointments after enabling them', async () => {
|
||||
const wrapper = render()
|
||||
await wrapper.setProps({ events: [
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface CalendarEvent {
|
||||
fields: Array<{ label: string; value?: string | null }>
|
||||
inboundCodes?: Array<string | null | undefined>
|
||||
canEdit?: boolean
|
||||
canView?: boolean
|
||||
canDelete?: boolean
|
||||
sourcePath?: string
|
||||
}
|
||||
@@ -32,6 +33,7 @@ const emit = defineEmits<{
|
||||
range: [range: { from: string; to: string }]
|
||||
create: [date: string]
|
||||
edit: [event: CalendarEvent]
|
||||
view: [event: CalendarEvent]
|
||||
delete: [event: CalendarEvent]
|
||||
open: [event: CalendarEvent]
|
||||
}>()
|
||||
@@ -82,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>
|
||||
@@ -95,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>
|
||||
@@ -124,7 +129,8 @@ defineExpose({ selectedDate, mode })
|
||||
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
|
||||
<dl><div v-for="field in event.fields" :key="field.label" :class="{ 'wide-field': ['系统单号', '完整时段', '预约备注'].includes(field.label) }"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
|
||||
<AppointmentInboundCodes v-if="event.inboundCodes" :codes="event.inboundCodes" />
|
||||
<footer v-if="event.canEdit || event.canDelete || event.sourcePath" class="density-actions">
|
||||
<footer v-if="event.canView || event.canEdit || event.canDelete || event.sourcePath" class="density-actions">
|
||||
<el-button v-if="event.canView" class="density-action" :disabled="loading" @click="emit('view', event)">查看预约</el-button>
|
||||
<el-button v-if="event.sourcePath" class="density-action" :disabled="loading" @click="emit('open', event)">查看来源</el-button>
|
||||
<el-button v-if="event.canEdit" class="density-action" :disabled="loading" @click="emit('edit', event)">编辑</el-button>
|
||||
<el-button v-if="event.canDelete" class="density-action" data-tone="danger" :disabled="loading" @click="emit('delete', event)">取消预约</el-button>
|
||||
@@ -146,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); }
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { inject, onBeforeUnmount } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||
import { createFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse'
|
||||
import { createFbaAppointment, getFbaAppointment, updateFbaAppointment, listFbaAppointmentCalendar, listInboundOrders } from '@/modules/overseas-warehouse'
|
||||
import { listArrivalCalendar, listPickupCalendar } from './api'
|
||||
import { listTripCalendar } from './tripCalendar'
|
||||
vi.mock('./tripCalendar', async (original) => ({...(await original<typeof import('./tripCalendar')>()),listTripCalendar:vi.fn()}))
|
||||
import { resolvePlanningSource, saveFollowup, listUnscheduledAppointments } from './planningApi'
|
||||
const routing = vi.hoisted(() => ({ query: {} as Record<string,string>, canSourceRead: true, canPickup: true, canTrips: true }))
|
||||
const routing = vi.hoisted(() => ({ query: {} as Record<string,string>, canSourceRead: true, canPickup: true, canTrips: true, canWrite: true, containerPending: false }))
|
||||
vi.mock('./planningApi', () => ({ resolvePlanningSource: vi.fn(), listPlanningCargo: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listUnscheduledAppointments: vi.fn() }))
|
||||
import { listPlatformWarehouses } from '@/modules/warehouses'
|
||||
vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
|
||||
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRoute: () => routing, useRouter: () => ({ push: vi.fn() }) }))
|
||||
|
||||
vi.mock('./api', () => ({ listArrivalCalendar: vi.fn(), listPickupCalendar: vi.fn(), deleteAppointment: vi.fn() }))
|
||||
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => (permission !== 'source-read' || routing.canSourceRead) && (permission !== 'platform:firstleg:terminal-drayage:list' || routing.canPickup) && (permission !== 'platform:fleet:trip:list' || routing.canTrips) }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => (permission !== 'write' || routing.canWrite) && (permission !== 'source-read' || routing.canSourceRead) && (permission !== 'platform:firstleg:terminal-drayage:list' || routing.canPickup) && (permission !== 'platform:fleet:trip:list' || routing.canTrips) }) }))
|
||||
vi.mock('@/modules/overseas-warehouse', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/modules/overseas-warehouse')>()),
|
||||
createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), listFbaAppointmentCalendar: vi.fn(),
|
||||
listInboundOrders: vi.fn(), updateFbaAppointment: vi.fn(),
|
||||
@@ -29,10 +31,17 @@ const candidates = [
|
||||
]
|
||||
function render(openCalendar = true) {
|
||||
const wrapper = mount(AppointmentCalendarView, { global: { directives: { loading: {} }, stubs: {
|
||||
AppointmentCancellationDialog: {name:'AppointmentCancellationDialog',template:'<div>取消弹窗</div>'},
|
||||
AppointmentCancellationHistory: {name:'AppointmentCancellationHistory',template:'<div>取消历史详情</div>'},
|
||||
AppointmentCancellationRecords: {name:'AppointmentCancellationRecords',template:'<button data-history-view @click="$emit(\'view\',\'AP-CANCEL\')">查看取消记录</button>'},
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
AppointmentContainerPanel: true,
|
||||
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>' },
|
||||
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot /><button data-save @click="$emit(\'confirm\')">保存</button></div>' },
|
||||
AppointmentContainerPanel: { name: 'AppointmentContainerPanel', setup() {
|
||||
const register = inject<(guard: () => boolean) => () => void>('appointment-register-leave-guard')!
|
||||
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" /><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>' },
|
||||
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :placeholder="placeholder" :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
@@ -40,7 +49,7 @@ function render(openCalendar = true) {
|
||||
ElButton: { template: '<button><slot /></button>' },
|
||||
ElSelect: { name: 'ElSelect', emits: ['update:modelValue', 'change'], props: ['modelValue', 'remoteMethod'], template: '<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target.value); $emit(\'change\', $event.target.value)"><option value="">请选择</option><slot /></select>' },
|
||||
ElOption: { props: ['value', 'label'], template: '<option :value="value">{{ label }}</option>' },
|
||||
DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot name="toolbar-actions" /><button data-select @click="$emit(\'selection-change\', data)">选择货物</button></div>' },
|
||||
DataTable: { name: 'DataTable', props: ['data'], template: '<div><slot name="toolbar-actions" /><slot v-for="row in data" name="actions" :row="row"/><button data-select @click="$emit(\'selection-change\', data)">选择货物</button></div>' },
|
||||
LoadErrorState: true, ApiErrorMeta: true, AppointmentFollowupDialog: true, AppointmentCargoPanel: { name: 'AppointmentCargoPanel', props: ['externalSource'], template: '<div>合批工作区</div>' },
|
||||
} } })
|
||||
if (openCalendar) void wrapper.findAll('button').find(button => button.text() === '预约日历')!.trigger('click')
|
||||
@@ -50,7 +59,7 @@ function render(openCalendar = true) {
|
||||
describe('appointment calendar data integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
routing.query = {}; routing.canSourceRead = true; routing.canPickup = true; routing.canTrips = true
|
||||
routing.query = {}; routing.canSourceRead = true; routing.canPickup = true; routing.canTrips = true; routing.canWrite = true; routing.containerPending = false
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: ['ONT8', 'LAX9'].map(code => ({ id: code, tenantId: 0, warehouseCode: code, warehouseName: code, warehouseType: 'FBA' as const, status: 'ACTIVE' as const, internalCode: null, countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, remark: null })), page: 1, size: 50, pages: 1, total: 2 })
|
||||
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([])
|
||||
vi.mocked(listUnscheduledAppointments).mockResolvedValue({records: [], total: 0})
|
||||
@@ -61,6 +70,110 @@ 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:[]}
|
||||
vi.mocked(listFbaAppointmentCalendar).mockResolvedValue([record])
|
||||
vi.mocked(getFbaAppointment).mockResolvedValue(record)
|
||||
const wrapper=render();await flushPromises()
|
||||
const panel=wrapper.findComponent({name:'AppointmentCalendarPanel'})
|
||||
expect(panel.props('events')).toEqual(expect.arrayContaining([expect.objectContaining({key:'AP-DATED',canView:true,canEdit:false})]))
|
||||
panel.vm.$emit('view',{key:'AP-DATED'});await flushPromises()
|
||||
expect(getFbaAppointment).toHaveBeenCalledWith('AP-DATED')
|
||||
expect(wrapper.findComponent({name:'FbaAppointmentFields'}).props('disabled')).toBe(true)
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
wrapper.findComponent({name:'AppDialog'}).vm.$emit('confirm');await flushPromises()
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
it('uses the dedicated cancellation dialog for an undated appointment instead of editing its status', async () => {
|
||||
const record = { version: 3, id: '1', appointmentNo: 'AP-UNDATED', originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ABE3', items: [] }
|
||||
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()
|
||||
expect(wrapper.findComponent({name:'AppointmentCancellationDialog'}).attributes('appointment-no')).toBe('AP-UNDATED')
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
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.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)
|
||||
expect(wrapper.findComponent({name:'FbaAppointmentFields'}).props('disabled')).toBe(true)
|
||||
expect(wrapper.findComponent({name:'AppointmentCancellationHistory'}).exists()).toBe(false)
|
||||
const history=wrapper.findAll('details').find(d=>d.find('summary').text()==='取消历史')!
|
||||
;(history.element as HTMLDetailsElement).open=true;await history.trigger('toggle')
|
||||
expect(wrapper.findComponent({name:'AppointmentCancellationHistory'}).exists()).toBe(true)
|
||||
wrapper.findAllComponents({name:'AppDialog'}).at(-1)!.vm.$emit('confirm');await flushPromises()
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
|
||||
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.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()
|
||||
expect(wrapper.getComponent({name:'AppointmentCancellationRecords'}).vm).not.toBe(previous)
|
||||
expect(wrapper.findComponent({name:'AppointmentCancellationDialog'}).exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the container view mounted while a child has an unresolved command', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button')
|
||||
await tabs[1]!.trigger('click'); await flushPromises()
|
||||
routing.containerPending = true
|
||||
await tabs[0]!.trigger('click')
|
||||
expect(tabs[1]!.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.findComponent({ name: 'AppointmentContainerPanel' }).exists()).toBe(true)
|
||||
routing.containerPending = false
|
||||
await tabs[0]!.trigger('click')
|
||||
expect(tabs[0]!.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.findComponent({ name: 'AppointmentContainerPanel' }).exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('opens an unscheduled appointment read-only without allowing a save', async () => {
|
||||
routing.canWrite = false
|
||||
const record = { version: 0, id: '1', appointmentNo: 'AP-READ', originWarehouseId: '100', appointmentStatus: 'PENDING', selectedCount: 1, fbaWarehouseCode: 'ONT8', items: [] }
|
||||
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)!
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled()
|
||||
expect(wrapper.findComponent({ name: 'FbaAppointmentFields' }).props('disabled')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('puts calendar first and opens it by default without reloading when switching back', async () => {
|
||||
const wrapper = render(false); await flushPromises()
|
||||
const tabs = wrapper.get('[aria-label="预约工作视图"]').findAll('button')
|
||||
@@ -81,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'})])
|
||||
@@ -98,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'})])
|
||||
@@ -114,7 +227,7 @@ describe('appointment calendar data integration', () => {
|
||||
vi.mocked(listTripCalendar).mockResolvedValueOnce({records:[],unresolvedRecords:[{sourceType:'TRIP_PLAN',tripNo:'TR-UNKNOWN',status:'PLANNED',originWarehouseId:null,originWarehouseCode:null,originWarehouseName:null,originCountryCode:null,originTimezone:null,originWarehouseStatus:null,plannedDepartureAt:'2026-10-10T12:00:00Z',expectedDriverEndAt:null,departedAt:null,completedAt:null,stops:[],incompleteReasons:['ORIGIN_WAREHOUSE_UNKNOWN']}]})
|
||||
const wrapper=render();await flushPromises()
|
||||
expect(wrapper.findComponent({name:'AppointmentCalendarPanel'}).props('events')).toEqual([])
|
||||
expect(wrapper.find('.appointment-extra').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.appointment-extra summary').map(node => node.text())).not.toContain('待核实地点的车次(1)')
|
||||
expect(wrapper.text()).not.toContain('原计划时间保留 UTC')
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看待核实车次(1)')!.trigger('click')
|
||||
expect(wrapper.text()).toContain('原计划时间保留 UTC')
|
||||
@@ -150,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' })
|
||||
@@ -163,6 +276,17 @@ describe('appointment calendar data integration', () => {
|
||||
await wrapper.get('[data-select]').trigger('click')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(createFbaAppointment).mock.calls[0]![0]
|
||||
await wrapper.get('[data-close]').trigger('click')
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(true)
|
||||
await wrapper.get('[data-create]').trigger('click')
|
||||
expect((wrapper.get('select[aria-label="预约仓库"]').element as HTMLSelectElement).value).toBe('ONT8')
|
||||
const done = vi.fn()
|
||||
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
|
||||
expect(done).not.toHaveBeenCalled()
|
||||
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
|
||||
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
|
||||
expect(guard()).toBe(false)
|
||||
}
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(createFbaAppointment).mock.calls[1]![0]).toEqual(first)
|
||||
expect(first.details.appointmentAt).toBeNull()
|
||||
@@ -174,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')
|
||||
})
|
||||
|
||||
@@ -226,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' })
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, provide, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import { resolvePlanningSource, listUnscheduledAppointments, type ResolvedPlanningSource, type PlanningSource } from './planningApi'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -21,12 +21,19 @@ import {
|
||||
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import AppointmentCalendarPanel, { type CalendarEvent } from './AppointmentCalendarPanel.vue'
|
||||
import AppointmentInboundCodes from './AppointmentInboundCodes.vue'
|
||||
import AppointmentCancellationDialog from './AppointmentCancellationDialog.vue'
|
||||
import AppointmentCancellationHistory from './AppointmentCancellationHistory.vue'
|
||||
import AppointmentCancellationRecords from './AppointmentCancellationRecords.vue'
|
||||
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
|
||||
import AppointmentContainerPanel from './AppointmentContainerPanel.vue'
|
||||
import { calendarRange, dateKey } from './calendar'
|
||||
import { deleteAppointment, listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, type PickupCalendarEntry } from './api'
|
||||
import { listArrivalCalendar, listPickupCalendar, type ArrivalCalendarEntry, type PickupCalendarEntry } from './api'
|
||||
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()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -35,6 +42,14 @@ const selectedSource = ref<PlanningSource | null>(null)
|
||||
const linkError = ref<ApiErrorDetails | null>(null)
|
||||
const linkLoading = ref(false)
|
||||
let linkRequest = 0
|
||||
const childLeaveGuards = new Set<() => boolean>()
|
||||
provide('appointment-register-leave-guard', (guard: () => boolean) => {
|
||||
childLeaveGuards.add(guard)
|
||||
return () => { childLeaveGuards.delete(guard) }
|
||||
})
|
||||
function switchWorkView(next: 'calendar' | 'cargo' | 'containers') {
|
||||
if (canLeave()) workView.value = next
|
||||
}
|
||||
const workView = ref<'calendar' | 'cargo' | 'containers'>('calendar')
|
||||
const calendarVisited = ref(true)
|
||||
const showTripLayer = ref(false)
|
||||
@@ -105,33 +120,33 @@ async function loadUnscheduled() {
|
||||
const statusLabels: Record<string, string> = { PENDING: '安排待确认', SUCCESS: '对方已确认', FAILED: '未约成', CANCELLED: '已取消' }
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'selection', type: 'selection', reserveSelection: true, width: 46, selectable: (row: InboundOrderSummaryDto) => row.fbaWarehouseCode === searchedWarehouse.value && (!row.appointmentStatus || ['FAILED', 'CANCELLED'].includes(row.appointmentStatus)) },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 150 },
|
||||
{ prop: 'customerName', label: '客户', slotName: 'customer', minWidth: 150 },
|
||||
{ prop: 'inboundCode', label: '入仓码', minWidth: 220, singleLine: false, showOverflowTooltip: false },
|
||||
{ prop: 'customerName', label: '客户', slotName: 'customer', minWidth: 180, singleLine: false, showOverflowTooltip: false },
|
||||
{ prop: 'fbaWarehouseCode', label: '目的仓', width: 110 },
|
||||
{ prop: 'overseasWarehouseName', label: '海外仓', minWidth: 150 },
|
||||
{ prop: 'overseasWarehouseName', label: '海外仓', minWidth: 150, singleLine: false, showOverflowTooltip: false },
|
||||
{ prop: 'packageCount', label: '箱数', width: 80 },
|
||||
{ prop: 'palletCount', label: '托数', width: 80 },
|
||||
{ prop: 'appointmentNo', label: '已有预约号', minWidth: 160 },
|
||||
{ prop: 'appointmentNo', label: '已有预约号', minWidth: 160, singleLine: false, showOverflowTooltip: false },
|
||||
]
|
||||
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: '原提柜业务时间,港口时区待核实' },
|
||||
@@ -152,6 +167,7 @@ const events = computed<CalendarEvent[]>(() => [...arrivals.value.map(arrival =>
|
||||
].filter(field => field.value != null && field.value !== ''),
|
||||
inboundCodes: appointment.items.map(item => item.inboundCode),
|
||||
canEdit: access.can(permission.inboundOrderAppointment),
|
||||
canView: access.can('platform:appointment:list'),
|
||||
canDelete: access.can('platform:appointment:delete'),
|
||||
})), ...tripEvents.value])
|
||||
|
||||
@@ -203,7 +219,17 @@ function openSource(event: CalendarEvent) {
|
||||
if (event.sourcePath) void router.push(event.sourcePath)
|
||||
}
|
||||
|
||||
function canLeave() {
|
||||
if (saving.value || pendingSave.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
|
||||
return [...childLeaveGuards].every(guard => guard())
|
||||
}
|
||||
function beforeCloseAppointment(done: () => void) { if (canLeave()) done() }
|
||||
function closeAppointment() { if (canLeave()) dialogOpen.value = false }
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
function openCreate(date: string) {
|
||||
if (!access.can(permission.inboundOrderAppointment) || !canLeave()) return
|
||||
forceReadOnly.value = false; cancellationHistoryOpen.value = false
|
||||
++candidateRequest
|
||||
++detailRequest
|
||||
detailLoading.value = false
|
||||
@@ -224,12 +250,13 @@ function openCreate(date: string) {
|
||||
void searchWarehouses('')
|
||||
}
|
||||
|
||||
async function openEdit(event: Pick<CalendarEvent, 'key'>) {
|
||||
async function openEdit(event: Pick<CalendarEvent, 'key'>, readOnly = false) {
|
||||
if (!canLeave()) return
|
||||
forceReadOnly.value = readOnly; cancellationHistoryOpen.value = false
|
||||
const request = ++detailRequest
|
||||
dialogError.value = null
|
||||
editing.value = [...appointments.value, ...unscheduled.value].find(item => item.appointmentNo === event.key) || null
|
||||
pendingSave.value = null
|
||||
if (!editing.value) return
|
||||
dialogOpen.value = true
|
||||
detailLoading.value = true
|
||||
detailReady.value = false
|
||||
@@ -294,7 +321,7 @@ async function loadCandidates() {
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving.value || detailLoading.value || !detailReady.value) return
|
||||
if (appointmentReadOnly.value || saving.value || detailLoading.value || !detailReady.value) return
|
||||
const validation = appointmentFormError(form)
|
||||
if (!pendingSave.value && validation) { ElMessage.warning(validation); return }
|
||||
if (!editing.value) {
|
||||
@@ -325,23 +352,21 @@ function selectCandidates(rows: object[]) {
|
||||
selected.value = rows as InboundOrderSummaryDto[]
|
||||
}
|
||||
|
||||
async function remove(event: CalendarEvent) {
|
||||
const appointment = appointments.value.find(item => item.appointmentNo === event.key)
|
||||
if (!appointment || loading.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认取消预约 ${appointment.appointmentNo}?将释放全部 ${appointment.selectedCount} 票货物的占用,历史仍保留。`, '取消预约',
|
||||
{ type: 'warning', confirmButtonText: '确认取消预约', cancelButtonText: '返回', customClass: 'admin-density-confirm' })
|
||||
} catch { return }
|
||||
loading.value = true
|
||||
try {
|
||||
await deleteAppointment(appointment.appointmentNo, appointment.version)
|
||||
ElMessage.success('预约已取消,历史仍保留')
|
||||
await load()
|
||||
} catch (error) {
|
||||
errorTitle.value = '预约取消失败'
|
||||
loadError.value = getApiErrorDetails(error, '预约取消失败')
|
||||
} finally { loading.value = false }
|
||||
function remove(event: CalendarEvent) {
|
||||
if (!access.can('platform:appointment:delete') || loading.value || !canLeave()) return
|
||||
cancellationTarget.value = appointments.value.find(item => item.appointmentNo === event.key) || null
|
||||
}
|
||||
function cancelEditing() {
|
||||
if (!editing.value || editing.value.appointmentStatus === 'CANCELLED' || !access.can('platform:appointment:delete') || !canLeave()) return
|
||||
cancellationTarget.value = editing.value
|
||||
dialogOpen.value = false
|
||||
}
|
||||
async function cancelled() {
|
||||
cancellationTarget.value = null; ++cancellationsRevision.value
|
||||
ElMessage.success('预约已取消,可在取消记录中查看原因与原记录')
|
||||
await Promise.all([load(),loadUnscheduled()])
|
||||
}
|
||||
|
||||
|
||||
watch(dialogOpen, open => {
|
||||
if (!open) { ++candidateRequest; ++detailRequest; ++warehouseRequest; candidateLoading.value = false; warehouseLoading.value = false; detailLoading.value = false; detailReady.value = false }
|
||||
@@ -382,9 +407,9 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
|
||||
<AppointmentFollowupDialog v-if="linkedSource" :source="linkedSource.source" :coverage="linkedSource.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(permission.inboundOrderAppointment)" @arrange="arrangeLinkedSource" @close="linkedSource = null" />
|
||||
<div class="appointment-heading">
|
||||
<div class="density-status-tabs" role="group" aria-label="预约工作视图">
|
||||
<button type="button" :class="{ 'is-active': workView === 'calendar' }" :aria-pressed="workView === 'calendar'" @click="workView = 'calendar'">预约日历</button>
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'containers' }" :aria-pressed="workView === 'containers'" @click="workView = 'containers'">提柜与到仓</button>
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="workView = 'cargo'">送仓与排车</button>
|
||||
<button type="button" :class="{ 'is-active': workView === 'calendar' }" :aria-pressed="workView === 'calendar'" @click="switchWorkView('calendar')">预约日历</button>
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'containers' }" :aria-pressed="workView === 'containers'" @click="switchWorkView('containers')">提柜与到仓</button>
|
||||
<button v-if="access.can(permission.lastMileOrderList)" type="button" :class="{ 'is-active': workView === 'cargo' }" :aria-pressed="workView === 'cargo'" @click="switchWorkView('cargo')">送仓与排车</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="cargoVisited" v-show="workView === 'cargo'"><AppointmentCargoPanel :external-source="selectedSource" @source-accepted="selectedSource = null" @created="load(); loadUnscheduled()" /></div>
|
||||
@@ -392,12 +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>
|
||||
<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" @edit="openEdit" @delete="remove" @open="openSource">
|
||||
<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">
|
||||
@@ -405,7 +441,8 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
|
||||
<template #issues="{ row }">{{ tripIssues(row as TripCalendarEntry) }}</template>
|
||||
</DataTable>
|
||||
</AppDialog>
|
||||
<AppDialog v-model="dialogOpen" class="admin-density-dialog" :title="editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingSave ? '重试本次保存' : '保存'" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
|
||||
<AppointmentCancellationDialog v-if="cancellationTarget" :appointment-no="cancellationTarget.appointmentNo" :version="cancellationTarget.version" :selected-count="cancellationTarget.selectedCount" @close="cancellationTarget = null" @cancelled="cancelled" />
|
||||
<AppDialog :model-value="dialogOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :show-confirm="!appointmentReadOnly" class="admin-density-dialog" :title="appointmentReadOnly ? '查看预约' : editing ? '编辑预约' : '新增预约'" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingSave ? '重试本次保存' : '保存'" :confirm-loading="saving" :confirm-disabled="detailLoading || !detailReady || (!editing && (!warehouse || !selected.length || candidateLoading || !!candidateError))" @confirm="save">
|
||||
<LoadErrorState v-if="dialogError" title="预约操作失败" :message="dialogError.message" :show-retry="false"><template #details><ApiErrorMeta :details="dialogError" /></template></LoadErrorState>
|
||||
<div v-loading="detailLoading">
|
||||
<el-form label-position="top" class="calendar-form density-form">
|
||||
@@ -418,7 +455,8 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
|
||||
</el-form>
|
||||
<LoadErrorState v-if="!editing && warehouseError" title="仓库加载失败" :message="warehouseError.message" @retry="searchWarehouses(warehouseKeyword)" />
|
||||
<p v-if="!editing && warehouseTotal > warehouseOptions.length && !warehouseError" class="appointment-hint">输入更完整的仓库编码或名称,缩小搜索范围。</p>
|
||||
<FbaAppointmentFields :model="form" :appointment-no="editing?.appointmentNo" :timezone="editing?.timezone || warehouseOptions.find(item => item.warehouseCode === warehouse)?.timezone" :disabled="saving || !!pendingSave" />
|
||||
<FbaAppointmentFields :model="form" :appointment-no="editing?.appointmentNo" :timezone="editing?.timezone || warehouseOptions.find(item => item.warehouseCode === warehouse)?.timezone" :disabled="appointmentReadOnly || saving || !!pendingSave" />
|
||||
<el-button v-if="editing && editing.appointmentStatus !== 'CANCELLED' && access.can('platform:appointment:delete')" data-cancel-editing type="danger" plain :disabled="saving || !!pendingSave || !detailReady" @click="cancelEditing">取消预约</el-button>
|
||||
<template v-if="!editing && warehouse">
|
||||
<DataTable :key="selectionEpoch" v-model:page="candidatePage" v-model:page-size="candidateSize" :data="candidates" :columns="columns" :total="candidateTotal" :loading="candidateLoading" row-key="lineId" :results-visible="!candidateError" @selection-change="selectCandidates" @update:page="loadCandidates()" @update:page-size="candidatePage = 1; loadCandidates()">
|
||||
<template #customer="{ row }">{{ row.customerName?.trim() || '客户名称缺失' }}</template>
|
||||
@@ -430,6 +468,7 @@ onBeforeUnmount(() => { ++unscheduledRequest; ++calendarRequest; ++candidateRequ
|
||||
<div v-else class="appointment-cargo">
|
||||
<span>{{ editing.selectedCount }} 票货物</span>
|
||||
<AppointmentInboundCodes :key="editing.appointmentNo" :codes="editing.items.map(item => item.inboundCode)" />
|
||||
<details :key="`cancellations-${editing.appointmentNo}`" @toggle="cancellationHistoryOpen = ($event.target as HTMLDetailsElement).open"><summary>取消历史</summary><AppointmentCancellationHistory v-if="cancellationHistoryOpen" :key="editing.appointmentNo" :appointment-no="editing.appointmentNo" /></details>
|
||||
</div>
|
||||
</div>
|
||||
</AppDialog>
|
||||
@@ -442,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>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentCancellationDialog from './AppointmentCancellationDialog.vue'
|
||||
import AppointmentCancellationHistory from './AppointmentCancellationHistory.vue'
|
||||
import AppointmentCancellationRecords from './AppointmentCancellationRecords.vue'
|
||||
import { deleteAppointment, listAppointmentCancellations, listAppointmentCancellationRecords } from './api'
|
||||
const access = vi.hoisted(() => ({ canCancel: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({useAccessPreviewStore: () => ({can: () => access.canCancel})}))
|
||||
vi.mock('vue-router', () => ({onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn()}))
|
||||
vi.mock('./api', () => ({deleteAppointment: vi.fn(),listAppointmentCancellations: vi.fn(),listAppointmentCancellationRecords: vi.fn()}))
|
||||
const record = {appointmentNo:'AP-CANCEL',version:1,cancelReason:null,cancelledAt:'2026-10-11T01:00:00',snapshot:{fbaWarehouseCode:'ABE3',appointmentStatus:'CANCELLED',appointmentRemark:'原备注保留',plannedDate:null,plannedAt:null,plannedEndAt:null,confirmationNo:'EXT-1',appointmentAt:'2026-10-15T09:00:00',appointmentEndAt:null,timezone:'America/New_York',selectedCount:1}}
|
||||
function dialog() {
|
||||
return mount(AppointmentCancellationDialog,{props:{appointmentNo:'AP-CANCEL',version:0,selectedCount:1},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose','showConfirm'],template:'<section><slot/><button v-if="showConfirm" data-save @click="$emit(\'confirm\')">取消</button></section>'}}}})
|
||||
}
|
||||
describe('appointment cancellation',()=>{
|
||||
beforeEach(()=>{vi.resetAllMocks();access.canCancel=true})
|
||||
it('requires a reason and retries the same reason and version after an unknown result',async()=>{
|
||||
vi.mocked(deleteAppointment).mockRejectedValueOnce(new Error('lost')).mockResolvedValueOnce(undefined)
|
||||
const wrapper=dialog();await flushPromises()
|
||||
await wrapper.get('[data-save]').trigger('click');expect(deleteAppointment).not.toHaveBeenCalled()
|
||||
await wrapper.get('textarea').setValue(' 仓库取消时段 ')
|
||||
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
expect(deleteAppointment).toHaveBeenCalledWith('AP-CANCEL',0,'仓库取消时段')
|
||||
const done=vi.fn();wrapper.getComponent({name:'AppDialog'}).props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('textarea').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
expect(deleteAppointment).toHaveBeenNthCalledWith(2,'AP-CANCEL',0,'仓库取消时段')
|
||||
expect(wrapper.emitted('cancelled')).toHaveLength(1);wrapper.unmount()
|
||||
})
|
||||
it('does not expose or invoke cancellation without its independent permission',async()=>{
|
||||
access.canCancel=false;const wrapper=dialog();await flushPromises()
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
wrapper.getComponent({name:'AppDialog'}).vm.$emit('confirm');await flushPromises()
|
||||
expect(deleteAppointment).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
it('reads history once per page and keeps missing historical reason explicit',async()=>{
|
||||
vi.mocked(listAppointmentCancellations).mockResolvedValue({records:[record],total:11})
|
||||
const wrapper=mount(AppointmentCancellationHistory,{props:{appointmentNo:'AP-CANCEL'},global:{plugins:[ElementPlus]}});await flushPromises()
|
||||
expect(listAppointmentCancellations).toHaveBeenCalledExactlyOnceWith('AP-CANCEL',1,10)
|
||||
expect(wrapper.text()).toContain('历史原因未记录');expect(wrapper.text()).toContain('原备注保留')
|
||||
wrapper.getComponent({name:'DataTable'}).vm.$emit('update:page',2);await flushPromises()
|
||||
expect(listAppointmentCancellations).toHaveBeenLastCalledWith('AP-CANCEL',2,10);wrapper.unmount()
|
||||
})
|
||||
it('discovers persisted cancellations with one server page and opens by business number',async()=>{
|
||||
vi.mocked(listAppointmentCancellationRecords).mockResolvedValue({records:[{appointmentNo:'AP-CANCEL',version:1,cancelReason:'实际原因',cancelledAt:record.cancelledAt,fbaWarehouseCode:'ABE3',selectedCount:1}],total:1})
|
||||
const wrapper=mount(AppointmentCancellationRecords,{global:{plugins:[ElementPlus]}});await flushPromises()
|
||||
expect(listAppointmentCancellationRecords).toHaveBeenCalledExactlyOnceWith(1,10)
|
||||
expect(listAppointmentCancellations).not.toHaveBeenCalled()
|
||||
await wrapper.get('.el-table__body-wrapper .el-table__row').findAll('button').find(b=>b.text()==='查看')!.trigger('click')
|
||||
expect(wrapper.emitted('view')).toEqual([['AP-CANCEL']]);wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { deleteAppointment, listAppointmentCancellations, listAppointmentCancellationRecords } from './api'
|
||||
vi.mock('@/shared/api/http',()=>({http:{get:vi.fn(),delete:vi.fn()},unwrapApiResponse:(result:{data:unknown})=>result.data}))
|
||||
beforeEach(()=>vi.resetAllMocks())
|
||||
it('sends the required cancellation body without the old reasonless query shape',async()=>{
|
||||
vi.mocked(http.delete).mockResolvedValue({data:undefined})
|
||||
await deleteAppointment('AP-TEST',3,'仓库取消时段')
|
||||
expect(http.delete).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/inbound-plans/fba-appointments/AP-TEST',{data:{version:3,cancelReason:'仓库取消时段'}})
|
||||
})
|
||||
it('uses paged cancellation reads and the original business number for detail history',async()=>{
|
||||
vi.mocked(http.get).mockResolvedValue({data:{records:[],total:'12'}})
|
||||
expect((await listAppointmentCancellationRecords(2,10)).total).toBe(12)
|
||||
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/cancellations',{params:{page:2,size:10}})
|
||||
await listAppointmentCancellations('AP/TEST',1,10)
|
||||
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/AP%2FTEST/cancellations',{params:{page:1,size:10}})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, onBeforeUnmount, ref } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { deleteAppointment } from './api'
|
||||
const props = defineProps<{ appointmentNo: string; version: number; selectedCount: number }>()
|
||||
const emit = defineEmits<{ close: []; cancelled: [] }>()
|
||||
const access = useAccessPreviewStore()
|
||||
const reason = ref(''), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
const pending = ref<{number: string; version: number; reason: string} | null>(null)
|
||||
function canLeave() {
|
||||
if (saving.value || pending.value) { ElMessage.warning('请先核实本次取消结果,再关闭或切换'); return false }
|
||||
return true
|
||||
}
|
||||
function beforeClose(done: () => void) { if (canLeave()) done() }
|
||||
function close() { if (canLeave()) emit('close') }
|
||||
const register = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
|
||||
const unregister = register?.(canLeave)
|
||||
onBeforeUnmount(() => unregister?.())
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
async function save() {
|
||||
if (!access.can('platform:appointment:delete') || saving.value) return
|
||||
if (!pending.value && !reason.value.trim()) { ElMessage.warning('请填写实际取消原因'); return }
|
||||
pending.value ||= {number: props.appointmentNo, version: props.version, reason: reason.value.trim()}
|
||||
saving.value = true; error.value = null
|
||||
try {
|
||||
await deleteAppointment(pending.value.number, pending.value.version, pending.value.reason)
|
||||
pending.value = null; emit('cancelled')
|
||||
} catch (cause) {
|
||||
error.value = getApiErrorDetails(cause, '取消结果待核实,请重试本次取消')
|
||||
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog :model-value="true" title="取消预约" width="min(600px, calc(100vw - 48px))" class="admin-density-dialog" confirm-type="danger" :confirm-text="pending ? '重试本次取消' : '确认取消预约'" :show-confirm="access.can('platform:appointment:delete')" :confirm-loading="saving" :before-close="beforeClose" @update:model-value="close" @confirm="save">
|
||||
<p>{{ appointmentNo }} · {{ selectedCount }} 票货物</p>
|
||||
<p>取消后释放货物占用,保留原备注、时间及取消历史;不会回退库存或取消车次。</p>
|
||||
<LoadErrorState v-if="error" title="取消未完成" :message="error.message" :show-retry="false" />
|
||||
<el-form label-position="top" :disabled="saving || !!pending">
|
||||
<el-form-item label="取消原因" required><el-input v-model="reason" type="textarea" maxlength="1000" show-word-limit placeholder="请填写实际取消原因" /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, onBeforeUnmount } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listAppointmentCancellations, type AppointmentCancellation } from './api'
|
||||
const props = defineProps<{ appointmentNo: string }>()
|
||||
const page = ref(1), size = ref(10), total = ref(0), rows = ref<AppointmentCancellation[]>([])
|
||||
const loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||
let request = 0
|
||||
const columns: DataTableColumn[] = [
|
||||
{prop:'cancelledAt',label:'取消时间(UTC)',slotName:'time',minWidth:170,singleLine:false},
|
||||
{prop:'cancelReason',label:'取消原因',slotName:'reason',minWidth:220,singleLine:false},
|
||||
{key:'snapshot',label:'取消时记录',slotName:'snapshot',minWidth:260,singleLine:false},
|
||||
]
|
||||
async function load() {
|
||||
const current = ++request; loading.value = true; error.value = null
|
||||
try { const result = await listAppointmentCancellations(props.appointmentNo,page.value,size.value); if(current===request) {rows.value=result.records;total.value=result.total} }
|
||||
catch(cause) { if(current===request) error.value=getApiErrorDetails(cause,'取消历史加载失败') }
|
||||
finally { if(current===request) loading.value=false }
|
||||
}
|
||||
onMounted(load)
|
||||
onBeforeUnmount(()=>++request)
|
||||
</script>
|
||||
<template>
|
||||
<LoadErrorState v-if="error" title="取消历史加载失败" :message="error.message" @retry="load" />
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" row-key="version" @update:page="load" @update:page-size="page=1;load()">
|
||||
<template #time="{row}">{{ row.cancelledAt?.replace('T',' ') || '—' }}</template>
|
||||
<template #reason="{row}">{{ row.cancelReason || '历史原因未记录' }}</template>
|
||||
<template #snapshot="{row}"><details><summary>{{ row.snapshot.fbaWarehouseCode }} · {{ row.snapshot.selectedCount }} 票 · 查看取消时记录</summary><p>原业务备注:{{ row.snapshot.appointmentRemark || '—' }}</p><p>原计划:{{ row.snapshot.plannedAt || row.snapshot.plannedDate || '待定' }}<span v-if="row.snapshot.plannedEndAt"> — {{ row.snapshot.plannedEndAt }}</span></p><p>原确认:{{ row.snapshot.appointmentAt || '未确认' }}<span v-if="row.snapshot.appointmentEndAt"> — {{ row.snapshot.appointmentEndAt }}</span> · {{ row.snapshot.timezone || '时区待核实' }}</p><p>外部确认号:{{ row.snapshot.confirmationNo || '—' }}</p></details></template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, onBeforeUnmount } from 'vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { listAppointmentCancellationRecords, type AppointmentCancellationSummary } from './api'
|
||||
const emit = defineEmits<{ view: [appointmentNo: string] }>()
|
||||
const page=ref(1),size=ref(10),total=ref(0),rows=ref<AppointmentCancellationSummary[]>([])
|
||||
const loading=ref(false),error=ref<ApiErrorDetails|null>(null)
|
||||
let request=0
|
||||
const columns: DataTableColumn[]=[
|
||||
{key:'actions',label:'操作',slotName:'actions',width:90,fixed:'left'},
|
||||
{prop:'appointmentNo',label:'预约单号',minWidth:220,singleLine:false},
|
||||
{prop:'fbaWarehouseCode',label:'目的仓',width:100},
|
||||
{prop:'selectedCount',label:'票数',width:80},
|
||||
{prop:'cancelledAt',label:'取消时间(UTC)',slotName:'time',minWidth:180,singleLine:false},
|
||||
{prop:'cancelReason',label:'取消原因',slotName:'reason',minWidth:240,singleLine:false},
|
||||
]
|
||||
async function load() {
|
||||
const current=++request;loading.value=true;error.value=null
|
||||
try {const result=await listAppointmentCancellationRecords(page.value,size.value);if(current===request){rows.value=result.records;total.value=result.total}}
|
||||
catch(cause){if(current===request)error.value=getApiErrorDetails(cause,'取消记录加载失败')}
|
||||
finally{if(current===request)loading.value=false}
|
||||
}
|
||||
onMounted(load)
|
||||
onBeforeUnmount(()=>++request)
|
||||
</script>
|
||||
<template>
|
||||
<LoadErrorState v-if="error" title="取消记录加载失败" :message="error.message" @retry="load" />
|
||||
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="(row: object) => `${(row as AppointmentCancellationSummary).appointmentNo}:${(row as AppointmentCancellationSummary).version}`" @update:page="load" @update:page-size="page=1;load()">
|
||||
<template #actions="{row}"><el-button @click="emit('view',row.appointmentNo)">查看</el-button></template>
|
||||
<template #time="{row}">{{ row.cancelledAt?.replace('T',' ') || '—' }}</template>
|
||||
<template #reason="{row}">{{ row.cancelReason || '历史原因未记录' }}</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -4,18 +4,41 @@ import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
|
||||
import { listPlanningCargo } from './planningApi'
|
||||
|
||||
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
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)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, reactive } from 'vue'
|
||||
import { inject, computed, onBeforeUnmount, onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
@@ -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()
|
||||
@@ -61,7 +62,7 @@ function toggle(row: PlanningCargo, checked: unknown) {
|
||||
}
|
||||
function selectPage() { for (const row of rows.value) if (selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row }
|
||||
async function previewBatch() {
|
||||
if (!selectedRows.value.length || batchLoading.value) return
|
||||
if (!selectedRows.value.length || batchLoading.value || creating.value || pendingCreate.value) return
|
||||
const current = ++batchRequest
|
||||
batchLoading.value = true; batchError.value = null
|
||||
try {
|
||||
@@ -105,7 +106,7 @@ const columns: DataTableColumn[] = [
|
||||
{ prop: 'packages', label: '预报箱数', width: 100 },
|
||||
{ prop: 'receivedPackages', label: '实收箱数', width: 100 },
|
||||
{ prop: 'estimatedArrivalAt', label: '来源预计到港', minWidth: 170 },
|
||||
{ prop: 'estimatedWarehouseAt', label: '已保存预计到仓', minWidth: 170 },
|
||||
{ prop: 'estimatedWarehouseAt', label: '已保存预计到仓', minWidth: 180, slotName: 'warehouseEta', singleLine: false },
|
||||
{ key: 'progress', label: '当前事项', slotName: 'progress', minWidth: 170 },
|
||||
]
|
||||
|
||||
@@ -133,16 +134,24 @@ async function openFollowup(row: Pick<PlanningCargo, 'sourceKey' | 'customerId'
|
||||
if (request === followupRequest) following.value = result
|
||||
} catch (cause) { if (request === followupRequest) followupError.value = getApiErrorDetails(cause, '来源核对失败') }
|
||||
}
|
||||
function canLeave() {
|
||||
if (creating.value || pendingCreate.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
|
||||
return true
|
||||
}
|
||||
function closeBatch() { if (canLeave()) batchPreview.value = null }
|
||||
function beforeClose(done: () => void) { if (canLeave()) done() }
|
||||
const registerLeaveGuard = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
|
||||
const unregisterLeaveGuard = registerLeaveGuard?.(canLeave)
|
||||
onBeforeUnmount(() => unregisterLeaveGuard?.())
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
onMounted(() => load())
|
||||
onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller?.abort() })
|
||||
</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>
|
||||
@@ -162,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 }}
|
||||
@@ -179,6 +193,7 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
|
||||
</template>
|
||||
<template #actions="{ row }"><RowActions :actions="[{ key: 'followup', label: '跟进' }, { key: 'source', label: '查看来源' }]" text-actions @action="key => key === 'followup' ? openFollowup(row as PlanningCargo) : source(row as PlanningCargo)" /></template>
|
||||
<template #select="{ row }"><el-checkbox :model-value="!!selected[row.sourceKey]" :aria-label="`选择 ${row.inboundCode || '未关联入仓码'}`" :disabled="batchLoading || (!selected[row.sourceKey] && (!selectable(row as PlanningCargo) || selectedRows.length >= 100))" @change="(value: boolean | string | number) => toggle(row as PlanningCargo, value)" /></template>
|
||||
<template #warehouseEta="{ row }"><span>{{ row.estimatedWarehouseAt?.replace('T', ' ') || '—' }}</span><div v-if="row.estimatedWarehouseAt" class="cargo-note">来源时区未明确</div></template>
|
||||
<template #progress="{ row }">
|
||||
<span v-if="row.detained">暂扣,待处理</span>
|
||||
<span v-else-if="row.mixedDestinations">含多个目的地,待核对</span>
|
||||
@@ -187,13 +202,13 @@ onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller
|
||||
</template>
|
||||
</DataTable>
|
||||
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" @arrange="arrangeFollowing" @close="following = null" />
|
||||
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="batchPreview = null">
|
||||
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="pendingCreate ? '重试本次保存' : '保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
|
||||
<p>{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
|
||||
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
|
||||
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
|
||||
<p>保存前尚未占用货物;先保存安排不代表已取得对方确认。</p>
|
||||
<LoadErrorState v-if="createError" title="预约安排保存未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
|
||||
<FbaAppointmentFields :model="arrangement" :disabled="creating || !!pendingCreate" />
|
||||
<FbaAppointmentFields :timezone="batchPreview.destinationTimezone" :model="arrangement" :disabled="creating || !!pendingCreate" />
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { getFollowups, saveFollowup, getContainerFollowups, saveContainerFollowup, type PlanningSource, type ContainerFollowupContext } from './planningApi'
|
||||
|
||||
vi.mock('./planningApi', () => ({ getFollowups: vi.fn(), saveFollowup: vi.fn(), getContainerFollowups: vi.fn(), saveContainerFollowup: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
|
||||
const history = { history: { records: [], page: 1, size: 10, total: 0, pages: 0 }, versions: { DRAYAGE_CONTACT: 0, TERMINAL_APPOINTMENT: 0, WAREHOUSE_RECEIVING: 0, FBA_RECEIVING: 0 } }
|
||||
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', canFollowup: true, blockReasons: [], orderId: '1', inboundCode: 'IN-1', bookingNo: null, containerNo: null, originWarehouseId: 'US-1', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: null, sourceVersion: null }
|
||||
function render(canWrite = true, container?: ContainerFollowupContext) {
|
||||
return mount(AppointmentFollowupDialog, { props: container ? { container, canWrite } : { source, coverage: { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }, canWrite }, global: { stubs: {
|
||||
AppDialog: { props: ['confirmDisabled'], template: '<div><slot/><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></div>' },
|
||||
AppDialog: { name: 'AppDialog', props: ['confirmDisabled', 'beforeClose'], template: '<div><slot/><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></div>' },
|
||||
ElForm: { template: '<form><slot/></form>' }, ElFormItem: { template: '<label><slot/></label>' },
|
||||
ElInput: { props: ['modelValue', 'placeholder'], template: '<input :value="modelValue" :placeholder="placeholder" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
ElSelect: true, ElOption: true, ElDatePicker: true,
|
||||
@@ -33,6 +35,12 @@ describe('appointment followup recovery', () => {
|
||||
expect(saveFollowup).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('describes FBA followup independently of batch action visibility', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('FBA收货跟进')
|
||||
expect(wrapper.text()).not.toContain('同柜多客户共用提柜')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('saves contact facts without requiring appointment, inbound plan or driver fields', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('已联系,等待回复')
|
||||
@@ -48,6 +56,15 @@ describe('appointment followup recovery', () => {
|
||||
await wrapper.get('input[placeholder="记录对方反馈,不会自动确认预约"]').setValue('等待码头开放')
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(saveFollowup).mock.calls[0]![2]
|
||||
await wrapper.get('[data-close]').trigger('click')
|
||||
expect(wrapper.emitted('close')).toBeUndefined()
|
||||
const done = vi.fn()
|
||||
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
|
||||
expect(done).not.toHaveBeenCalled()
|
||||
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
|
||||
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
|
||||
expect(guard()).toBe(false)
|
||||
}
|
||||
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||
expect(vi.mocked(saveFollowup).mock.calls[1]![2]).toEqual(first)
|
||||
expect(first.idempotencyKey).toBeTruthy()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { inject, computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
@@ -73,15 +74,28 @@ async function save() {
|
||||
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(saveError.value.code || '')) { pending.value = null; await load() }
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
function canLeave() {
|
||||
if (saving.value || pending.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
|
||||
return true
|
||||
}
|
||||
function close() { if (canLeave()) emit('close') }
|
||||
function arrange() { if (canLeave()) emit('arrange') }
|
||||
function beforeClose(done: () => void) { if (canLeave()) done() }
|
||||
const registerLeaveGuard = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
|
||||
const unregisterLeaveGuard = registerLeaveGuard?.(canLeave)
|
||||
onBeforeUnmount(() => unregisterLeaveGuard?.())
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
onMounted(load)
|
||||
onBeforeUnmount(() => { ++generation })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog :model-value="true" class="admin-density-dialog" :title="`预约跟进 · ${title}`" width="min(1080px, calc(100vw - 48px))" :confirm-text="pending ? '重试本次保存' : '保存跟进'" :confirm-loading="saving" :confirm-disabled="!canWrite || !canFollowup || !ready || loading" @confirm="save" @update:model-value="emit('close')">
|
||||
<AppDialog :model-value="true" class="admin-density-dialog" :title="`预约跟进 · ${title}`" width="min(1080px, calc(100vw - 48px))" :confirm-text="pending ? '重试本次保存' : '保存跟进'" :confirm-loading="saving" :confirm-disabled="!canWrite || !canFollowup || !ready || loading" @confirm="save" @update:model-value="close" :before-close="beforeClose">
|
||||
<p v-if="source">{{ source.containerNo || '—' }} · {{ source.originWarehouseName }} → {{ source.destinationWarehouseCode }}</p>
|
||||
<el-button v-if="source && canArrange && source.canFollowup && !coverage?.appointments.length" :disabled="saving" @click="emit('arrange')">加入预约合批</el-button>
|
||||
<p v-else>同柜多客户共用{{ container?.warehouseId ? '此接仓点' : '提柜' }}跟进记录;保存联系不自动确认预约或推进收货。</p>
|
||||
<el-button v-if="source && canArrange && source.canFollowup && !coverage?.appointments.length" :disabled="saving || !!pending" @click="arrange">加入预约合批</el-button>
|
||||
<p v-if="container">同柜多客户共用{{ container?.warehouseId ? '此接仓点' : '提柜' }}跟进记录;保存联系不自动确认预约或推进收货。</p>
|
||||
<p v-else>本条记录用于此货源的FBA收货跟进;保存联系不自动确认预约或推进收货。</p>
|
||||
<LoadErrorState v-if="error" title="跟进记录加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="saveError" title="跟进保存未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||
<p v-for="reason in blockReasons" :key="reason">{{ reason }}</p>
|
||||
|
||||
@@ -2,33 +2,100 @@ import { flushPromises, mount } from '@vue/test-utils'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AppointmentImpactDialog from './AppointmentImpactDialog.vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import { getImpact, handleImpact, type ImpactDetail } from './impactApi'
|
||||
vi.mock('./impactApi', async importOriginal => ({ ...await importOriginal<typeof import('./impactApi')>(), getImpact: vi.fn(), handleImpact: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
const guards = vi.hoisted(() => ({ register: vi.fn(), unregister: vi.fn(), push: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: guards.push }) }))
|
||||
const detail: ImpactDetail = { impact: { containerBookingId: '1', impactNo: 'AI-TEST', version: 1, status: 'OPEN', reason: 'FORECAST_CHANGED', ownerId: null, ownerName: null, baseline: { estimatedAt: '2026-10-15T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, latest: { estimatedAt: '2026-10-17T09:00:00', actualAt: null, observedAt: null, providerCode: 'carrier', sourceReference: 'ref' }, forecastToken: 'current', updatedAt: '2026-10-10T12:00:00' }, cargo: [{ inboundCode: 'IN-A', customerName: '客户甲', packages: 10, destination: 'ONT8' }], appointments: [{ appointmentNo: 'AP-1', status: 'SUCCESS', destination: 'ONT8', confirmedAt: '2026-10-16T09:00:00', timezone: 'America/Los_Angeles' }], transport: null, history: { records: [], total: 0 } }
|
||||
const render = (canWrite = true) => mount(AppointmentImpactDialog, { props: { containerId: '1', canWrite }, global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button></section>' } } } })
|
||||
const render = (canWrite = true) => mount(AppointmentImpactDialog, { props: { containerId: '1', canWrite }, global: { provide: { 'appointment-register-leave-guard': guards.register }, plugins: [ElementPlus], stubs: { AppDialog: { name: 'AppDialog', props: ['beforeClose'], template: '<section><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button></section>' } } } })
|
||||
describe('appointment impact handling', () => {
|
||||
beforeEach(() => { vi.resetAllMocks(); vi.mocked(getImpact).mockResolvedValue(detail) })
|
||||
beforeEach(() => { vi.resetAllMocks(); guards.register.mockReturnValue(guards.unregister); vi.mocked(getImpact).mockResolvedValue(detail) })
|
||||
function deferred() {
|
||||
let resolve!: (value: ImpactDetail) => void, reject!: (reason: Error) => void
|
||||
const promise=new Promise<ImpactDetail>((yes,no)=>{resolve=yes;reject=no})
|
||||
return {promise,resolve,reject}
|
||||
}
|
||||
const pageDetail=(label:string):ImpactDetail=>({...detail,history:{total:60,records:[{version:1,action:'RECORD',operatorName:null,result:label,nextAction:null,followupDate:null,createdAt:'2026-10-11T00:00:00'}]}})
|
||||
it('keeps the latest history page when responses finish in reverse order',async()=>{
|
||||
vi.mocked(getImpact).mockResolvedValueOnce(pageDetail('第一页'))
|
||||
const wrapper=render();await flushPromises()
|
||||
const second=deferred(),third=deferred()
|
||||
vi.mocked(getImpact).mockReturnValueOnce(second.promise).mockReturnValueOnce(third.promise)
|
||||
const pager=wrapper.findComponent({name:'ElPagination'})
|
||||
pager.vm.$emit('update:current-page',2);pager.vm.$emit('current-change',2)
|
||||
pager.vm.$emit('update:current-page',3);pager.vm.$emit('current-change',3)
|
||||
await flushPromises()
|
||||
third.resolve(pageDetail('第三页最新'));await flushPromises()
|
||||
second.resolve(pageDetail('第二页过期'));await flushPromises()
|
||||
expect(wrapper.text()).toContain('第三页最新');expect(wrapper.text()).not.toContain('第二页过期')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('ignores an obsolete failure and keeps loading until the current request finishes',async()=>{
|
||||
vi.mocked(getImpact).mockResolvedValueOnce(pageDetail('第一页'))
|
||||
const wrapper=render();await flushPromises()
|
||||
const second=deferred(),third=deferred()
|
||||
vi.mocked(getImpact).mockReturnValueOnce(second.promise).mockReturnValueOnce(third.promise)
|
||||
const pager=wrapper.findComponent({name:'ElPagination'})
|
||||
pager.vm.$emit('update:current-page',2);pager.vm.$emit('current-change',2)
|
||||
pager.vm.$emit('update:current-page',3);pager.vm.$emit('current-change',3)
|
||||
second.reject(new Error('obsolete page failure'));await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('obsolete page failure')
|
||||
expect(pager.props('disabled')).toBe(true)
|
||||
expect(wrapper.findAll('button').find(b=>b.text()==='认领跟进')!.attributes('disabled')).toBeDefined()
|
||||
wrapper.findAllComponents({name:'ElButton'}).find(b=>b.text()==='认领跟进')!.vm.$emit('click')
|
||||
await flushPromises();expect(handleImpact).not.toHaveBeenCalled()
|
||||
third.resolve(pageDetail('第三页最新'));await flushPromises()
|
||||
expect(pager.props('disabled')).toBe(false);wrapper.unmount()
|
||||
})
|
||||
it('does not let a closed dialog response replace a newly opened impact',async()=>{
|
||||
const old=deferred();vi.mocked(getImpact).mockReturnValueOnce(old.promise)
|
||||
const first=render();await flushPromises();first.unmount()
|
||||
vi.mocked(getImpact).mockResolvedValueOnce({...pageDetail('新影响历史'),impact:{...detail.impact,impactNo:'AI-NEW'}})
|
||||
const next=render();await flushPromises()
|
||||
old.resolve(pageDetail('已关闭历史'));await flushPromises()
|
||||
expect(next.text()).toContain('AI-NEW');expect(next.text()).not.toContain('已关闭历史');next.unmount()
|
||||
})
|
||||
it('shows separate forecasts, unchanged confirmed appointment and omitted transport permission', async () => {
|
||||
const wrapper = render(false); await flushPromises()
|
||||
expect(wrapper.text()).toContain('2026-10-15T09:00:00')
|
||||
expect(wrapper.text()).toContain('2026-10-17T09:00:00')
|
||||
expect(wrapper.text()).toContain('2026-10-16T09:00:00')
|
||||
expect(wrapper.text()).toContain('无车次查看权限')
|
||||
expect(wrapper.text()).toContain('来源:carrier')
|
||||
expect(wrapper.text()).toContain('核对原因:到港预测变化')
|
||||
expect(wrapper.text()).not.toContain('认领跟进')
|
||||
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('labels manual observations without exposing internal operator references and explains missing forecasts', async () => {
|
||||
vi.mocked(getImpact).mockResolvedValueOnce({ ...detail, impact: { ...detail.impact, reason: 'FORECAST_MISSING', latest: { ...detail.impact.latest!, providerCode: 'MANUAL', sourceReference: 'MANUAL:1000001', estimatedAt: null } } })
|
||||
const wrapper = render(); await flushPromises()
|
||||
expect(wrapper.text()).toContain('来源:人工维护')
|
||||
expect(wrapper.text()).toContain('来源预计到港缺失,待核实')
|
||||
expect(wrapper.text()).not.toContain('1000001')
|
||||
expect(wrapper.text()).not.toContain('MANUAL:')
|
||||
expect(wrapper.text()).not.toContain('已延误')
|
||||
expect(wrapper.text()).toContain('认领跟进')
|
||||
expect(handleImpact).not.toHaveBeenCalled(); wrapper.unmount()
|
||||
})
|
||||
it('freezes an uncertain command and retries exactly once with original version and key', async () => {
|
||||
vi.mocked(handleImpact).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...detail.impact, version: 2 })
|
||||
vi.mocked(getImpact).mockResolvedValueOnce({...detail,transport:{trips:[{tripNo:'TRIP-1',status:'PLANNED',plannedDepartureAt:null}],sameTripBookings:[]}})
|
||||
const wrapper = render(); await flushPromises()
|
||||
await wrapper.findAll('button').find(b => b.text() === '认领跟进')!.trigger('click'); await flushPromises()
|
||||
const original = vi.mocked(handleImpact).mock.calls[0]![1]
|
||||
expect(original).toMatchObject({ action: 'CLAIM', expectedVersion: 1, forecastToken: 'current' })
|
||||
await wrapper.get('[data-close]').trigger('click'); expect(wrapper.emitted('close')).toBeUndefined()
|
||||
const done=vi.fn(); wrapper.findComponent({name:'AppDialog'}).props('beforeClose')(done); expect(done).not.toHaveBeenCalled()
|
||||
expect(guards.register.mock.calls[0]![0]()).toBe(false)
|
||||
await wrapper.findAll('button').find(b=>b.text()==='TRIP-1')!.trigger('click');expect(guards.push).not.toHaveBeenCalled()
|
||||
for (const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
|
||||
await wrapper.findAll('button').find(b => b.text() === '重试本次处理')!.trigger('click'); await flushPromises()
|
||||
expect(handleImpact).toHaveBeenNthCalledWith(2, '1', original)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
expect(getImpact).toHaveBeenCalledTimes(2); wrapper.unmount()
|
||||
expect(getImpact).toHaveBeenCalledTimes(2)
|
||||
expect(guards.register.mock.calls[0]![0]()).toBe(true)
|
||||
wrapper.unmount(); expect(guards.unregister).toHaveBeenCalledOnce()
|
||||
})
|
||||
it('does not resolve without an actual handling result', async () => {
|
||||
const wrapper = render(); await flushPromises()
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { inject, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { getImpact, handleImpact, impactStatus, type ImpactDetail, type ImpactCommand } from './impactApi'
|
||||
import { getImpact, handleImpact, impactStatus, impactReason, type ImpactDetail, type ImpactCommand } from './impactApi'
|
||||
const props = defineProps<{ containerId: string; canWrite: boolean }>()
|
||||
const emit = defineEmits<{ close: []; saved: [] }>()
|
||||
const router = useRouter(), detail = ref<ImpactDetail | null>(null), loading = ref(false), saving = ref(false), historyPage = ref(1)
|
||||
const error = ref<ApiErrorDetails | null>(null), pending = ref<ImpactCommand | null>(null)
|
||||
const form = reactive({ result: '', nextAction: '', followupDate: '' })
|
||||
const actions: Record<string, string> = { OBSERVED: '船期核对', CLAIM: '认领', RECORD: '记录处理', RESOLVE: '完成核对' }
|
||||
let loadGeneration = 0
|
||||
async function load() {
|
||||
const generation = ++loadGeneration
|
||||
loading.value = true; error.value = null
|
||||
try { detail.value = await getImpact(props.containerId, historyPage.value) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '影响详情加载失败') }
|
||||
finally { loading.value = false }
|
||||
try { const result = await getImpact(props.containerId, historyPage.value); if (generation === loadGeneration) detail.value = result }
|
||||
catch (cause) { if (generation === loadGeneration) error.value = getApiErrorDetails(cause, '影响详情加载失败') }
|
||||
finally { if (generation === loadGeneration) loading.value = false }
|
||||
}
|
||||
async function save(action: ImpactCommand['action']) {
|
||||
if (!detail.value || saving.value || !props.canWrite) return
|
||||
if (!detail.value || loading.value || saving.value || !props.canWrite) return
|
||||
if (!pending.value && action !== 'CLAIM' && !form.result.trim()) { ElMessage.warning('请填写实际处理结果'); return }
|
||||
pending.value ||= { expectedVersion: detail.value.impact.version, forecastToken: detail.value.impact.forecastToken, idempotencyKey: crypto.randomUUID(), action, result: form.result.trim() || null, nextAction: form.nextAction.trim() || null, followupDate: form.followupDate || null }
|
||||
saving.value = true; error.value = null
|
||||
@@ -27,22 +29,31 @@ async function save(action: ImpactCommand['action']) {
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '处理结果待核实,请重试原请求'); if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
function close() { if (saving.value || pending.value) { ElMessage.warning('请先核实本次处理结果,再关闭'); return } emit('close') }
|
||||
function canLeave() { if (saving.value || pending.value) { ElMessage.warning('请先核实本次处理结果,再关闭或切换'); return false } return true }
|
||||
function close() { if (canLeave()) emit('close') }
|
||||
function beforeClose(done: () => void) { if (canLeave()) done() }
|
||||
function openTrip(tripNo: string) { if (canLeave()) void router.push(`/overseas-master-data/trips/${encodeURIComponent(tripNo)}`) }
|
||||
const register = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
|
||||
const unregister = register?.(canLeave)
|
||||
onBeforeUnmount(() => { ++loadGeneration; unregister?.() })
|
||||
onBeforeRouteLeave(canLeave)
|
||||
onBeforeRouteUpdate(canLeave)
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<AppDialog :model-value="true" title="船期变化影响核对" width="min(1200px, calc(100vw - 48px))" :show-confirm="false" class="admin-density-dialog" @update:model-value="close">
|
||||
<AppDialog :model-value="true" title="船期变化影响核对" width="min(1200px, calc(100vw - 48px))" :show-confirm="false" class="admin-density-dialog" @update:model-value="close" :before-close="beforeClose">
|
||||
<LoadErrorState v-if="error" title="核对未完成" :message="error.message" :show-retry="!pending" @retry="load" />
|
||||
<template v-if="detail">
|
||||
<p>{{ detail.impact.impactNo }} · {{ impactStatus[detail.impact.status] }} · 负责人:{{ detail.impact.ownerName || '未认领' }}</p>
|
||||
<p>核对原因:{{ detail.impact.reason === 'FORECAST_MISSING' ? '来源预计到港缺失,待核实' : impactReason[detail.impact.reason || ''] || '待核实' }}</p>
|
||||
<p>基准 ETA:{{ detail.impact.baseline?.estimatedAt || '待核实' }} → 最新 ETA:{{ detail.impact.latest?.estimatedAt || '待核实' }}</p>
|
||||
<p>来源:{{ detail.impact.latest?.providerCode || '待核实' }} · {{ detail.impact.latest?.sourceReference || '—' }} · 观察时间:{{ detail.impact.latest?.observedAt || '—' }}</p>
|
||||
<p>来源:{{ detail.impact.latest?.providerCode === 'MANUAL' ? '人工维护' : detail.impact.latest?.providerCode || '待核实' }} · 观察时间:{{ detail.impact.latest?.observedAt || '—' }}</p>
|
||||
<p>实际到港:{{ detail.impact.latest?.actualAt || '未记录' }}。以上为来源原始时间,时区未核实;不能据此判定跨时区违约。</p>
|
||||
<details open><summary>直接关联货物 {{ detail.cargo.length }} 票</summary><div class="impact-list"><p v-for="(row, index) in detail.cargo" :key="index">{{ row.inboundCode || '未关联入仓码' }} · {{ row.customerName || '—' }} · {{ row.destination || '—' }} · {{ row.packages ?? '待核实' }} 箱</p></div></details>
|
||||
<details open><summary>需复核的收货预约 {{ detail.appointments.length }} 张(确认时间不自动修改)</summary><div class="impact-list"><p v-for="row in detail.appointments" :key="row.appointmentNo">{{ row.appointmentNo }} · {{ row.destination }} · {{ row.confirmedAt || '尚未确认时段' }} {{ row.timezone || '时区待核实' }}</p></div></details>
|
||||
<p v-if="!detail.transport">无车次查看权限,运输影响未加载。</p>
|
||||
<template v-else>
|
||||
<details open><summary>关联车次 {{ detail.transport.trips.length }} 辆</summary><div class="impact-list"><p v-for="row in detail.transport.trips" :key="row.tripNo"><el-button link type="primary" @click="router.push(`/overseas-master-data/trips/${encodeURIComponent(row.tripNo)}`)">{{ row.tripNo }}</el-button> · 计划出发(UTC):{{ row.plannedDepartureAt || '待定' }}</p></div></details>
|
||||
<details open><summary>关联车次 {{ detail.transport.trips.length }} 辆</summary><div class="impact-list"><p v-for="row in detail.transport.trips" :key="row.tripNo"><el-button link type="primary" @click="openTrip(row.tripNo)">{{ row.tripNo }}</el-button> · 计划出发(UTC):{{ row.plannedDepartureAt || '待定' }}</p></div></details>
|
||||
<details><summary>同车外接承诺 {{ detail.transport.sameTripBookings.length }} 票,仅提示运输安排复核</summary><div class="impact-list"><p v-for="row in detail.transport.sameTripBookings" :key="row.bookingNo">{{ row.bookingNo }} · {{ row.customerName || '—' }} · {{ row.tripNo }} · {{ row.destinationLabel || '—' }}</p></div></details>
|
||||
</template>
|
||||
<el-form v-if="canWrite && detail.impact.status === 'OPEN'" label-position="top" :disabled="saving || !!pending">
|
||||
@@ -56,7 +67,7 @@ onMounted(load)
|
||||
<el-button v-if="!pending" type="primary" :disabled="saving || loading" @click="save('RESOLVE')">完成本次核对</el-button>
|
||||
<el-button v-else type="primary" :loading="saving" @click="save(pending.action)">重试本次处理</el-button>
|
||||
</div>
|
||||
<details><summary>观察及处理历史({{ detail.history.total }} 条)</summary><div class="impact-list"><p v-for="row in detail.history.records" :key="row.version">{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC<br>{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}</p></div><el-pagination v-model:current-page="historyPage" :page-size="20" :total="detail.history.total" layout="prev, pager, next" :disabled="saving || !!pending" @current-change="load" /></details>
|
||||
<details><summary>观察及处理历史({{ detail.history.total }} 条)</summary><div class="impact-list"><p v-for="row in detail.history.records" :key="row.version">{{ actions[row.action] || '核对记录' }} · {{ row.operatorName || '—' }} · {{ row.createdAt }} UTC<br>{{ row.result || '记录来源观察' }} · {{ row.nextAction || '—' }} · {{ row.followupDate || '—' }}</p></div><el-pagination v-model:current-page="historyPage" :page-size="20" :total="detail.history.total" layout="prev, pager, next" :disabled="loading || saving || !!pending" @current-change="load" /></details>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import ElementPlus from 'element-plus'
|
||||
import { listImpacts } from './impactApi'
|
||||
vi.mock('./impactApi', async original => ({ ...await original<typeof import('./impactApi')>(), listImpacts: vi.fn(), refreshImpacts: vi.fn() }))
|
||||
@@ -9,10 +10,10 @@ import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||
import { listExternalAppointmentCandidates, getFollowups, listPlanningCargo, resolvePlanningSource, listPlanningContainers, resolvePlanningContainer, previewPlanningBatch, createSourceAppointment, type PlanningCargo, type PlanningContainer, type PlanningSource } from './planningApi'
|
||||
|
||||
vi.mock('./planningApi', () => ({ listExternalAppointmentCandidates: vi.fn(), listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn(), getFollowups: vi.fn(), saveFollowup: vi.fn(), listPlanningContainers: vi.fn(), resolvePlanningContainer: vi.fn(), previewPlanningBatch: vi.fn(), createSourceAppointment: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
const source: PlanningSource = { sourceKey: 'ORDER:1', customerId: 'A', orderId: '1', inboundCode: 'IN-A', bookingNo: null, containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseName: '美国仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'ORDER:1', canFollowup: true, blockReasons: [] }
|
||||
const cargo: PlanningCargo = { sourceKey: source.sourceKey, customerId: 'A', customerName: '客户甲', orderId: '1', inboundCode: 'IN-A', containerBookingId: '2', containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseCode: 'US', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: null, shippedPackages: null, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
|
||||
const cargo: PlanningCargo = { sourceKey: source.sourceKey, customerId: 'A', customerName: '客户甲', orderId: '1', inboundCode: 'IN-A', containerBookingId: '2', containerNo: 'CONTAINER-A', originWarehouseId: '100', originWarehouseCode: 'US', originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: null, shippedPackages: null, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: '2026-10-10T13:48:15', orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
|
||||
const coverage = { status: 'UNASSIGNED', appointments: [], missingFields: [], changeReviewRequired: false }
|
||||
|
||||
describe('appointment tables use actual shared table slots', () => {
|
||||
@@ -27,13 +28,17 @@ describe('appointment tables use actual shared table slots', () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const external: PlanningSource = { ...source, sourceKey: 'BOOKING:B-1', customerId: 'B', orderId: null, inboundCode: null, bookingNo: 'B-1', packages: null }
|
||||
vi.mocked(listExternalAppointmentCandidates).mockResolvedValue({ records: [{ source: external, customerName: '客户乙', tripNo: 'TR-1', coverage: { status: 'UNSCHEDULED', appointments: [], missingFields: [], changeReviewRequired: false } }], total: 1 })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: null, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 1 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', sources: [source, external], missingFields: [] })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: null, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 1 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', destinationTimezone: 'America/Los_Angeles', sources: [source, external], missingFields: [] })
|
||||
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()
|
||||
@@ -44,9 +49,9 @@ describe('appointment tables use actual shared table slots', () => {
|
||||
it('combines an original external booking with first-leg cargo and retries the identical save command', async () => {
|
||||
const external: PlanningSource={...source,sourceKey:'BOOKING:B-1',customerId:'B',bookingNo:'B-1',orderId:null,inboundCode:null,packages:null}
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({records:[cargo],page:1,size:20,total:1,pages:1})
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({group:{originWarehouseId:'100',destinationKey:'FBA:ONT8',packages:null,sourceCount:2,customerCount:2,readyFrom:null,readyTo:null,readinessIncomplete:true,unknownQuantitySources:1},originWarehouseName:'美国仓',destinationWarehouseCode:'ONT8',sources:[external,source],missingFields:['CARGO_QUANTITY_REVIEW']})
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({group:{originWarehouseId:'100',destinationKey:'FBA:ONT8',packages:null,sourceCount:2,customerCount:2,readyFrom:null,readyTo:null,readinessIncomplete:true,unknownQuantitySources:1},originWarehouseName:'美国仓',destinationWarehouseCode:'ONT8',destinationTimezone:null,sources:[external,source],missingFields:['CARGO_QUANTITY_REVIEW']})
|
||||
vi.mocked(createSourceAppointment).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({id:'1',version:0,appointmentNo:'AP-MIXED',appointmentStatus:'PENDING',originWarehouseId:'100',fbaWarehouseCode:'ONT8',selectedCount:2,items:[]})
|
||||
const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:external},global:{plugins:[ElementPlus],stubs:{AppDialog:{template:'<section><slot /><button data-save @click="$emit(\'confirm\')">Save</button></section>'}}}})
|
||||
const wrapper=mount(AppointmentCargoPanel,{props:{externalSource:external},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose'],template:'<section><slot /><button data-close @click="$emit(\'update:model-value\',false)">关闭</button><button data-save @click="$emit(\'confirm\')">Save</button></section>'}}}})
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(b=>b.text()==='加入本次选择')!.trigger('click')
|
||||
await wrapper.findAll('button').find(b=>b.text()==='选择本页可合批货物')!.trigger('click')
|
||||
@@ -54,6 +59,15 @@ describe('appointment tables use actual shared table slots', () => {
|
||||
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith([{sourceKey:'BOOKING:B-1',customerId:'B',destinationKey:'FBA:ONT8'},{sourceKey:'ORDER:1',customerId:'A',destinationKey:'FBA:ONT8'}])
|
||||
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
const first=vi.mocked(createSourceAppointment).mock.calls[0]![0]
|
||||
await wrapper.get('[data-close]').trigger('click')
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(true)
|
||||
const done = vi.fn()
|
||||
wrapper.findAllComponents({ name: 'AppDialog' }).at(-1)!.props('beforeClose')(done)
|
||||
expect(done).not.toHaveBeenCalled()
|
||||
for (const hook of [onBeforeRouteLeave, onBeforeRouteUpdate]) {
|
||||
const guard = vi.mocked(hook).mock.calls.at(-1)![0] as () => boolean
|
||||
expect(guard()).toBe(false)
|
||||
}
|
||||
expect(first).toMatchObject({originWarehouseId:'100',fbaWarehouseCode:'ONT8',details:{appointmentStatus:'PENDING',confirmationNo:null,appointmentAt:null}})
|
||||
expect(first.sources[0]!.sourceKey).toBe('BOOKING:B-1')
|
||||
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
@@ -72,7 +86,7 @@ describe('appointment tables use actual shared table slots', () => {
|
||||
})
|
||||
it('selects multiple customers and makes one authoritative batch check without per-row resolution', async () => {
|
||||
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo, { ...cargo, sourceKey: 'ORDER:2', orderId: '2', inboundCode: 'IN-B', customerId: 'B', customerName: '客户乙' }], page: 1, size: 20, total: 2, pages: 1 })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: 40, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', sources: [source], missingFields: ['READY_WINDOW_UNVERIFIED'] })
|
||||
vi.mocked(previewPlanningBatch).mockResolvedValue({ group: { originWarehouseId: '100', destinationKey: 'FBA:ONT8', packages: 40, sourceCount: 2, customerCount: 2, readyFrom: null, readyTo: null, readinessIncomplete: true, unknownQuantitySources: 0 }, originWarehouseName: '美国仓', destinationWarehouseCode: 'ONT8', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: ['READY_WINDOW_UNVERIFIED'] })
|
||||
const wrapper = mount(AppointmentCargoPanel, { global: { plugins: [ElementPlus], stubs: { AppDialog: { template: '<section><slot /></section>' } } } })
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '选择本页可合批货物')!.trigger('click')
|
||||
@@ -82,6 +96,10 @@ describe('appointment tables use actual shared table slots', () => {
|
||||
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('2 票 / 2 个客户 / 预报 40 箱')
|
||||
expect(wrapper.text()).toContain('保存前尚未占用货物')
|
||||
expect(wrapper.text()).toContain('America/Los_Angeles')
|
||||
expect(wrapper.text()).not.toContain('目的仓时区待核实')
|
||||
expect(wrapper.text()).toContain('2026-10-10 13:48:15')
|
||||
expect(wrapper.text()).toContain('来源时区未明确')
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('keeps the cargo list usable when impact service is unavailable and never reports a false zero', async () => {
|
||||
@@ -136,7 +154,7 @@ describe('appointment tables use actual shared table slots', () => {
|
||||
})
|
||||
it('keeps arrangement permission independent from contact-record writes and avoids duplicate active appointments', async () => {
|
||||
vi.mocked(getFollowups).mockResolvedValue({history:{records:[],total:0,page:1,size:10,pages:0},versions:{DRAYAGE_CONTACT:0,TERMINAL_APPOINTMENT:0,WAREHOUSE_RECEIVING:0,FBA_RECEIVING:0}})
|
||||
const wrapper=mount(AppointmentFollowupDialog,{props:{source,coverage,canWrite:false,canArrange:true},global:{plugins:[ElementPlus],stubs:{AppDialog:{template:'<section><slot /></section>'}}}})
|
||||
const wrapper=mount(AppointmentFollowupDialog,{props:{source,coverage,canWrite:false,canArrange:true},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose'],template:'<section><slot /></section>'}}}})
|
||||
await flushPromises();await wrapper.findAll('button').find(b=>b.text()==='加入预约合批')!.trigger('click')
|
||||
expect(wrapper.emitted('arrange')).toHaveLength(1)
|
||||
await wrapper.setProps({canArrange:false});expect(wrapper.findAll('button').some(b=>b.text()==='加入预约合批')).toBe(false)
|
||||
|
||||
@@ -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,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
|
||||
@@ -42,10 +42,32 @@ export async function listArrivalCalendar(query: { from: string; to: string }, s
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function deleteAppointment(appointmentNo: string, version: number) {
|
||||
export async function deleteAppointment(appointmentNo: string, version: number, cancelReason: string) {
|
||||
const response = await http.delete<ApiEnvelope<void>, ApiEnvelope<void>>(
|
||||
`/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}`,
|
||||
{ params: { version } },
|
||||
{ data: { version, cancelReason } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export interface AppointmentCancellation {
|
||||
appointmentNo: string; version: number; cancelReason: string | null; cancelledAt: string
|
||||
snapshot: { fbaWarehouseCode: string; appointmentStatus: string; appointmentRemark: string | null;
|
||||
plannedDate: string | null; plannedAt: string | null; plannedEndAt: string | null;
|
||||
confirmationNo: string | null; appointmentAt: string | null; appointmentEndAt: string | null;
|
||||
timezone: string | null; selectedCount: number }
|
||||
}
|
||||
export async function listAppointmentCancellations(appointmentNo: string, page: number, size: number) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<{records: AppointmentCancellation[]; total: number}>, ApiEnvelope<{records: AppointmentCancellation[]; total: number}>>(
|
||||
`/platform/v1/last-leg/inbound-plans/fba-appointments/${encodeURIComponent(appointmentNo)}/cancellations`, {params: {page, size}},
|
||||
))
|
||||
return {...result, total: Number(result.total)}
|
||||
}
|
||||
|
||||
export type AppointmentCancellationSummary = Omit<AppointmentCancellation, 'snapshot'> & {fbaWarehouseCode: string; selectedCount: number}
|
||||
export async function listAppointmentCancellationRecords(page: number, size: number) {
|
||||
const result = unwrapApiResponse(await http.get<ApiEnvelope<{records: AppointmentCancellationSummary[]; total: number}>, ApiEnvelope<{records: AppointmentCancellationSummary[]; total: number}>>(
|
||||
'/platform/v1/last-leg/inbound-plans/fba-appointments/cancellations', {params: {page, size}},
|
||||
))
|
||||
return {...result, total: Number(result.total)}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface PlanningBatchPreview {
|
||||
group: { originWarehouseId: string; destinationKey: string; packages: number | null; sourceCount: number; customerCount: number; readinessIncomplete: boolean; readyFrom: string | null; readyTo: string | null; unknownQuantitySources: number }
|
||||
originWarehouseName: string
|
||||
destinationWarehouseCode: string
|
||||
destinationTimezone: string | null
|
||||
sources: PlanningSource[]
|
||||
missingFields: string[]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -41,6 +41,39 @@ beforeEach(() => {
|
||||
vi.mocked(uploadProcurementFile).mockReset().mockResolvedValue({ id: '11', fileId: '101', category: 'PURCHASE', fileName: 'purchase.pdf', contentType: 'application/pdf', sizeBytes: 3, createdAt: '' })
|
||||
})
|
||||
describe('procurement purchase node operations', () => {
|
||||
it('disables terminal physical changes and keeps the management evidence form open', async () => {
|
||||
const value = fixture('MANAGE'); value.customerView.order.status = 'SHIPPED_TO_CUSTOMER'
|
||||
value.purchaseFacts = { overseas_shipped_at: '2020-01-02', customer_shipped_at: '2020-01-03', actual_warehouse_id: '20' }
|
||||
value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'DOMESTIC', sourceCartonId: '31', goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-02', active: true }]
|
||||
const wrapper = render(value); const dialog = await open(wrapper)
|
||||
const selector = dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '实物更正操作')!.findComponent({ name: 'ElSelect' })
|
||||
expect(selector.props('modelValue')).toBe('control-notes')
|
||||
const options = selector.findAllComponents({ name: 'ElOption' })
|
||||
expect(options.find(option => option.props('value') === 'receipts/correct')!.props()).toMatchObject({ disabled: true, label: expect.stringContaining('订单已结束') })
|
||||
expect(options.find(option => option.props('value') === 'control-notes')!.props('disabled')).toBe(false)
|
||||
expect(options.find(option => option.props('value') === 'actual-warehouse/correct')!.props('disabled')).toBe(false)
|
||||
selector.vm.$emit('update:modelValue', 'receipts/correct'); await flushPromises()
|
||||
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||
await confirm(wrapper)
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
expect(sessionStorage.getItem(storageKey)).toBeNull()
|
||||
})
|
||||
it('disables overseas receipt candidates after international shipment without blocking domestic correction', async () => {
|
||||
const value = fixture('MANAGE'); value.customerView.order.status = 'DOMESTIC_RECEIVING'
|
||||
value.purchaseFacts.overseas_shipped_at = '2020-01-02'
|
||||
const receipt = { id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS' as const, sourceCartonId: null, goodQuantity: 2, damagedQuantity: 0, extraQuantity: 0, receivedAt: '2020-01-01', active: true }
|
||||
value.physical.receipts = [receipt, { ...receipt, id: '12', receiptNo: 'PR-12', leg: 'DOMESTIC', sourceCartonId: '31' }]
|
||||
const wrapper = render(value); const dialog = await open(wrapper)
|
||||
dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '实物更正操作')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'receipts/correct')
|
||||
await flushPromises()
|
||||
const selector = dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === '待更正实收')!.findComponent({ name: 'ElSelect' })
|
||||
const options = selector.findAllComponents({ name: 'ElOption' })
|
||||
expect(options.find(option => option.props('value') === '11')!.props()).toMatchObject({ disabled: true, label: expect.stringContaining('本段已实际交寄') })
|
||||
expect(options.find(option => option.props('value') === '12')!.props('disabled')).toBe(false)
|
||||
selector.vm.$emit('update:modelValue', '12'); await flushPromises()
|
||||
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||
expect(submitProcurementCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
it('saves and restores a separate correction while the immutable original request is pending, scoped to operator/customer/order', async () => {
|
||||
const value = fixture('FINANCE_ADJUST', false)
|
||||
value.fees = [{ id: '11', feeNo: 'PF-11', purpose: 'SAMPLE_PAYMENT', confirmationOrigin: 'BUSINESS', feeItemCode: 'SAMPLE_PAYMENT', feeItemName: '样品货款', feeVersion: 1, active: true, determination: 'POSITIVE', amount: '70.00', currency: 'CNY', originalAmount: '10.00', originalCurrency: 'USD', exchangeRate: '7', exchangeRateMonth: '2020-01', businessDate: '2020-01-01', parentFeeId: null, sourceFeeId: null, voucherFileId: '501', voucherFileName: 'invoice.pdf', walletStatus: 'PENDING', walletLedgerId: null, walletErrorCode: null, walletErrorMessage: null, remark: null }]
|
||||
@@ -181,7 +214,7 @@ describe('procurement purchase node operations', () => {
|
||||
expect(submitProcurementCommand).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('uses exception evidence and source quantities for damaged goods disposition', async () => {
|
||||
const value = fixture('MANAGE'); value.purchaseFacts.purchased_quantity = 2
|
||||
const value = fixture('MANAGE'); value.purchaseFacts.purchased_quantity = 2; value.purchaseFacts.purchased_at = '2020-01-01T10:00:00'
|
||||
value.physical.receipts = [{ id: '11', receiptNo: 'PR-11', leg: 'OVERSEAS', sourceCartonId: null, goodQuantity: 1, damagedQuantity: 1, extraQuantity: 0, receivedAt: '2020-01-02T10:00:00', active: true }]
|
||||
vi.mocked(uploadProcurementFile).mockResolvedValue({ id: '12', fileId: '102', category: 'EXCEPTION', fileName: 'damage.png', contentType: 'image/png', sizeBytes: 3, createdAt: '' })
|
||||
const wrapper = render(value); const dialog = await open(wrapper); await explain(wrapper, '破损样品已经处理,客户已确认')
|
||||
|
||||
@@ -21,7 +21,7 @@ import { financeCommands, financeForm, buildFinance, feeRetryPermission } from '
|
||||
import ProcurementTransportFields from './ProcurementTransportFields.vue'
|
||||
import { transportCommands, transportForm, buildTransport } from '../procurementTransportForm'
|
||||
import ProcurementPhysicalFields from './ProcurementPhysicalFields.vue'
|
||||
import { physicalCommands, physicalForm, buildPhysical } from '../procurementPhysicalForm'
|
||||
import { physicalCommands, physicalForm, buildPhysical, managementOptions, managementReason, receiptCorrectionReason, dispositionReason } from '../procurementPhysicalForm'
|
||||
const props = defineProps<{ detail: PlatformProcurement; customerId: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const auth = useAuthStore(); const access = useAccessPreviewStore()
|
||||
@@ -49,6 +49,17 @@ const draftKey = computed(() => key.value ? `${key.value}:correction-draft` : ''
|
||||
const canDraft = computed(() => !!key.value && access.can('platform:overseas-business:purchase:finance-adjust') && props.detail.fees.some(fee => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(fee.purpose)))
|
||||
const commandPermissions: Record<ProcurementCommand, string> = { review: 'review', purchase: 'buy', 'purchase/confirm': 'buy', 'budget-authorizations': 'review', 'planned-warehouse': 'review', cancel: 'cancel', 'overseas-receipts': 'receive-overseas', 'domestic-receipts': 'receive-domestic', 'overseas-receipts/complete': 'receive-overseas', 'domestic-receipts/complete': 'receive-domestic', packing: 'pack', 'packing/confirm': 'pack', 'packing/withdraw': 'manage', dispositions: 'manage', 'receipts/correct': 'manage', 'dispositions/revoke': 'manage', labels: 'label', 'international-estimate': 'estimate', 'labels/apply': 'apply-label', 'overseas-shipment': 'ship-overseas', 'customer-shipment': 'ship-customer', 'domestic-repack': 'receive-domestic', 'charges/confirm': 'charge', 'fees/retry': 'charge', 'fees/terminate': 'finance-adjust', 'fees/release-estimate': 'finance-adjust', 'fees/correct-target': 'finance-adjust', 'fees/additional': 'finance-adjust', 'fees/replace-failed': 'finance-adjust', 'fees/refund': 'finance-adjust', 'control-notes': 'manage', 'control-notes/resolve': 'manage', 'actual-warehouse/correct': 'manage', 'exception-close': 'manage' }
|
||||
const command = computed(() => editingDraft.value ? 'fees/correct-target' : pending.value?.command ?? (active.value?.key === 'MANAGE' ? manageCommand.value : active.value?.key === 'FINANCE_ADJUST' ? financialCommand.value : active.value ? routes[active.value.key] : undefined))
|
||||
const manageOptions = computed(() => managementOptions.map(option => ({ ...option, reason: managementReason(option.value, props.detail) })))
|
||||
const managementBlock = computed(() => {
|
||||
if (active.value?.key !== 'MANAGE' || !command.value || pending.value) return null
|
||||
const reason = managementReason(command.value, props.detail)
|
||||
if (reason) return reason
|
||||
if (command.value === 'dispositions') return dispositionReason(props.detail, physical.value.leg)
|
||||
const row = command.value === 'receipts/correct'
|
||||
? props.detail.physical.receipts.find(item => item.id === physical.value.receiptId)
|
||||
: command.value === 'dispositions/revoke' ? props.detail.physical.dispositions.find(item => item.id === physical.value.dispositionId) : null
|
||||
return row ? receiptCorrectionReason(props.detail, row.leg) : null
|
||||
})
|
||||
const isControl = computed(() => !!command.value && controlCommands.has(command.value))
|
||||
const isFinance = computed(() => !!command.value && financeCommands.has(command.value))
|
||||
const isTransport = computed(() => !!command.value && transportCommands.has(command.value))
|
||||
@@ -77,7 +88,7 @@ async function loadWarehouses() {
|
||||
}
|
||||
function open(action: ProcurementAction) {
|
||||
if (!action.allowed || pending.value || storageError.value || saving.value) return
|
||||
editingDraft.value = false; active.value = action; error.value = null; manageCommand.value = 'dispositions'; physical.value = physicalForm(props.detail)
|
||||
editingDraft.value = false; active.value = action; error.value = null; manageCommand.value = manageOptions.value.find(option => !option.reason)?.value || 'control-notes'; physical.value = physicalForm(props.detail)
|
||||
control.value = controlForm(props.detail)
|
||||
finance.value = financeForm(props.detail); financialCommand.value = 'fees/correct-target'
|
||||
transport.value = transportForm(props.detail, ['SHIP_CUSTOMER', 'REPACK'].includes(action.key) ? 'DOMESTIC' : action.key === 'LABELS' ? undefined : 'OVERSEAS')
|
||||
@@ -150,6 +161,7 @@ async function submit() {
|
||||
if (!pending.value) {
|
||||
if (!active.value?.allowed || !access.can(active.value.permission) || !command.value) return
|
||||
try {
|
||||
if (managementBlock.value) throw new Error(managementBlock.value)
|
||||
const request = { command: command.value, payload: build() }
|
||||
const serialized = JSON.stringify(request); readPendingProcurement(serialized)
|
||||
sessionStorage.setItem(scope, serialized); pending.value = request
|
||||
@@ -201,10 +213,11 @@ async function upload(event: Event) {
|
||||
<el-alert v-if="draftError" :title="draftError" type="error" :closable="false" /><el-alert v-if="storageError" :title="storageError" type="error" :closable="false" />
|
||||
<el-alert v-if="pending" title="上次提交结果尚未确认,重试将使用原始内容与原请求编号。" type="warning" :closable="false"><p>原操作:{{ pending.command === 'review' ? (pending.payload.decision === 'APPROVE' ? '审核通过' : '审核拒绝') : actions.find(action => routes[action.key] === pending?.command)?.label || '采购节点操作' }} · {{ pending.payload.remark || pending.payload.reason }}</p><p v-if="pending.payload.amount !== undefined">原提交金额:{{ pending.payload.amount }} {{ pending.payload.currency || (['international-estimate', 'fees/refund'].includes(pending.command) ? 'CNY' : '') }}</p><el-button :loading="saving" @click="retryPending">确认上次提交结果</el-button></el-alert>
|
||||
<ApiErrorMeta v-if="error && !opened" :details="error" />
|
||||
<AppDialog class="admin-density-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交'" @confirm="submit">
|
||||
<AppDialog class="admin-density-dialog" v-model="opened" :title="active?.label || '采购节点操作'" :width="['packing', 'labels', 'charges/confirm', 'exception-close'].includes(command || '') ? 'min(1120px, calc(100vw - 28px))' : 'min(760px, calc(100vw - 28px))'" :confirm-loading="saving || uploading" :confirm-disabled="!!managementBlock || uploading || warehouseLoading || (!editingDraft && !!storageError) || !key" :confirm-text="editingDraft ? '保存本地草稿' : pending ? '重试原请求' : '确认提交'" @confirm="submit">
|
||||
<template #default><el-alert v-if="editingDraft" title="草稿仅保存在当前浏览器标签页,关闭标签页后可能丢失;不代表已确认费用。原请求未决时只能保存,结果明确后请刷新订单再核对确认。" type="info" :closable="false" /><el-alert v-if="command === 'purchase'" title="记录已实际购买的商品和供应商实付总额。资金未完成时,真实购买事实仍保留。" type="info" :closable="false" />
|
||||
<el-alert v-if="managementBlock" :title="managementBlock" type="warning" :closable="false" />
|
||||
<el-form class="density-form" label-position="top" :disabled="saving || uploading || (!!pending && !editingDraft)">
|
||||
<el-form-item v-if="active?.key === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option value="dispositions" label="记录差异处置" /><el-option value="receipts/correct" label="更正原实收数量" /><el-option value="dispositions/revoke" label="撤销原差异处置" /><el-option value="packing/withdraw" label="撤回未交寄的确认清单" /><el-option value="control-notes" label="追加事实说明或阻断" /><el-option value="control-notes/resolve" label="解除已处理的阻断" /><el-option value="actual-warehouse/correct" label="实际仓受控更正" /></el-select></el-form-item>
|
||||
<el-form-item v-if="active?.key === 'MANAGE'" label="实物更正操作"><el-select v-model="manageCommand" placeholder="选择处置或更正操作"><el-option v-for="option in manageOptions" :key="option.value" :value="option.value" :disabled="!!option.reason" :label="option.reason ? `${option.label}(${option.reason})` : option.label" /></el-select></el-form-item>
|
||||
<el-form-item v-if="active?.key === 'FINANCE_ADJUST'" label="财务处理方式"><el-select v-model="financialCommand" placeholder="选择财务处理方式"><el-option value="fees/additional" label="登记独立新发生费用" /><el-option value="fees/correct-target" label="更正原费用链目标总额" /><el-option value="fees/refund" label="关联原扣款退款" /><el-option value="fees/terminate" label="终止明确失败的费用版本" /><el-option value="fees/replace-failed" label="重新核定已终止的未扣款费用" /><el-option value="fees/release-estimate" label="释放有效预估冻结" /></el-select></el-form-item>
|
||||
<ProcurementControlFields v-if="isControl && command" v-model="control" :command="command" :detail="detail" :files="allFiles" />
|
||||
<template v-if="editingDraft"><el-form-item label="原费用"><el-select v-model="finance.feeId" placeholder="选择本单原实际费用"><el-option v-for="fee in detail.fees.filter(row => !['INTERNATIONAL_ESTIMATE', 'ADJUSTMENT', 'REFUND'].includes(row.purpose))" :key="fee.id" :value="fee.id" :label="`${fee.feeNo} · ${fee.feeItemName}`" /></el-select></el-form-item><el-form-item label="正确原币总额"><el-input v-model="finance.targetOriginalAmount" placeholder="填写原费用链正确总额,最多两位小数" /></el-form-item></template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
||||
import { computed } from 'vue'
|
||||
import type { ProcurementCommand } from '../api/procurementCommands'
|
||||
import type { PlatformProcurement } from '../procurementTypes'
|
||||
import { newCarton, shippedCartons, type PhysicalForm } from '../procurementPhysicalForm'
|
||||
import { newCarton, shippedCartons, receiptCorrectionReason, dispositionReason, type PhysicalForm } from '../procurementPhysicalForm'
|
||||
const props = defineProps<{ command: ProcurementCommand; detail: PlatformProcurement }>()
|
||||
const form = defineModel<PhysicalForm>({ required: true })
|
||||
const receipts = computed(() => props.detail.physical.receipts.filter(row => row.active && (props.command === 'receipts/correct' || (row.leg === form.value.leg && row.sourceCartonId === (form.value.leg === 'DOMESTIC' ? form.value.sourceCartonId : null)))))
|
||||
@@ -22,12 +22,12 @@ const differenceNames = { SHORTAGE: '短少', DAMAGED: '破损', EXTRA: '多货'
|
||||
<el-alert v-if="command.endsWith('/complete')" title="仅在全部到齐或差异已有明确处置后确认收货完成;登记实收不会自动完成收货。" type="info" :closable="false" />
|
||||
<el-alert v-if="command === 'packing/confirm'" :title="`确认第 ${detail.physical.packingVersion || '—'} 版清单。须完成海外收货、样品货款及费用来源关联。`" type="info" :closable="false" />
|
||||
<el-alert v-if="command === 'packing/withdraw'" title="撤回未交寄清单后,需要重新确认装箱并核对后续面单和费用。" type="warning" :closable="false" />
|
||||
<el-form-item v-if="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS">海外收货</el-radio><el-radio value="DOMESTIC">国内收货</el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="command === 'receipts/correct'" label="待更正实收" required><el-select v-model="form.receiptId" placeholder="选择有效原实收记录" @change="selectReceipt"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}`" /></el-select><span>保留原记录,更正不能改变运输段或来源箱;已交寄后只能追加更正说明。</span></el-form-item>
|
||||
<el-form-item v-if="command === 'dispositions'" label="差异发生段" required><el-radio-group v-model="form.leg" @change="form.sourceCartonId = ''; form.receiptId = ''"><el-radio value="OVERSEAS" :disabled="!!dispositionReason(detail, 'OVERSEAS')">海外收货<span v-if="dispositionReason(detail, 'OVERSEAS')">({{ dispositionReason(detail, 'OVERSEAS') }})</span></el-radio><el-radio value="DOMESTIC" :disabled="!!dispositionReason(detail, 'DOMESTIC')">国内收货<span v-if="dispositionReason(detail, 'DOMESTIC')">({{ dispositionReason(detail, 'DOMESTIC') }})</span></el-radio></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="command === 'receipts/correct'" label="待更正实收" required><el-select v-model="form.receiptId" placeholder="选择有效原实收记录" @change="selectReceipt"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.receiptNo} · ${row.leg === 'OVERSEAS' ? '海外' : '国内'}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select><span>保留原记录,更正不能改变运输段或来源箱;已交寄后只能追加更正说明。</span></el-form-item>
|
||||
<el-form-item v-if="(showReceipt || command === 'dispositions') && form.leg === 'DOMESTIC'" label="国际交寄原箱" required><el-select v-model="form.sourceCartonId" :disabled="command === 'receipts/correct'" placeholder="选择本单已国际交寄的箱" @change="form.receiptId = ''"><el-option v-for="box in domesticBoxes" :key="box.id" :value="box.id" :label="`${box.cartonNo} · ${box.quantity} 件`" /></el-select></el-form-item>
|
||||
<template v-if="showReceipt"><div class="physical-grid"><el-form-item label="合格件数" required><el-input-number v-model="form.goodQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次合格件数" /></el-form-item><el-form-item label="破损件数" required><el-input-number v-model="form.damagedQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次破损件数" /></el-form-item><el-form-item label="多货件数" required><el-input-number v-model="form.extraQuantity" :min="0" :precision="0" controls-position="right" placeholder="本次多到件数" /></el-form-item></div><el-form-item label="实际收货时间" required><el-date-picker v-model="form.receivedAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item></template>
|
||||
<template v-if="command === 'dispositions'"><el-form-item label="差异及处置" required><el-select v-model="form.kind" placeholder="选择差异处理方式" @change="form.receiptId = ''; form.quantity = undefined"><el-option value="SHORTAGE" label="短少 · 客户接受短少" /><el-option value="DAMAGED" label="破损 · 处理破损货物" /><el-option value="EXTRA" label="多货 · 退回多到货物" /></el-select></el-form-item><el-form-item v-if="form.kind !== 'SHORTAGE'" label="对应实收" required><el-select v-model="form.receiptId" placeholder="选择差异来源实收"><el-option v-for="row in receipts" :key="row.id" :value="row.id" :label="`${row.receiptNo} · 破损 ${row.damagedQuantity} / 多货 ${row.extraQuantity}`" /></el-select></el-form-item><el-form-item label="本次处置件数" required><el-input-number v-model="form.quantity" :min="1" :precision="0" controls-position="right" placeholder="填写已完成处置的件数" /></el-form-item></template>
|
||||
<el-form-item v-if="command === 'dispositions/revoke'" label="撤销差异处置" required><el-select v-model="form.dispositionId" placeholder="选择有效处置记录"><el-option v-for="row in detail.physical.dispositions.filter(item => item.active)" :key="row.id" :value="row.id" :label="`${row.leg === 'OVERSEAS' ? '海外' : '国内'} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}`" /></el-select></el-form-item>
|
||||
<el-form-item v-if="command === 'dispositions/revoke'" label="撤销差异处置" required><el-select v-model="form.dispositionId" placeholder="选择有效处置记录"><el-option v-for="row in detail.physical.dispositions.filter(item => item.active)" :key="row.id" :value="row.id" :disabled="!!receiptCorrectionReason(detail, row.leg)" :label="`${row.leg === 'OVERSEAS' ? '海外' : '国内'} · ${differenceNames[row.kind]} ${row.quantity} 件 · ${row.reason}${receiptCorrectionReason(detail, row.leg) ? ' · ' + receiptCorrectionReason(detail, row.leg) : ''}`" /></el-select></el-form-item>
|
||||
<template v-if="command === 'packing'">
|
||||
<el-alert title="每箱填写箱规、毛重及来源实收件数。草稿允许暂未分配完,确认时必须覆盖全部合格实收。" type="info" :closable="false" />
|
||||
<section v-for="(box, index) in form.cartons" :key="index" class="packing-box">
|
||||
|
||||
@@ -206,6 +206,19 @@ describe('overseas business mounted pages', () => {
|
||||
})
|
||||
|
||||
it('mounts procurement list with customer candidates and real data', async () => { const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); expect(listOverseasPurchaseCustomerOptions).toHaveBeenCalled(); expect(listOverseasPurchases).toHaveBeenCalledWith({ page: 1, size: 20, customerId: '', orderNo: '', purchaseCategory: '', productName: '', status: '' }); expect(wrapper.text()).toContain('OP-100'); expect(wrapper.text()).toContain('待装箱'); expect(wrapper.text()).toContain('2026-08-01 10:00') })
|
||||
it('keeps long procurement identifiers and descriptive fields out of single-line truncation', async () => {
|
||||
const record = { ...purchase, orderNo: 'QLYSXDKH202610100002', customerName: '全量验收下单客户-20261010-长客户名称', productName: 'PROC-PURCHASE-20261010-长商品名称', productSpecification: '多规格描述-完整展示', purchaseCategory: 'OFFICE_SUPPLIES', purchaseWarehouseName: '洛杉矶海外采购收货仓库' }
|
||||
vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [record], page: 1, size: 20, total: 1, pages: 1 })
|
||||
const wrapper = mountPage(PurchaseOrdersView)
|
||||
await flushPromises()
|
||||
const cells = wrapper.findAll('.el-table__body-wrapper tbody .el-table__row')[0]!.findAll('td')
|
||||
expect(cells[0]!.classes()).toContain('el-table-fixed-column--left')
|
||||
for (const [index, value] of [record.orderNo, record.customerName, record.purchaseWarehouseName, record.purchaseCategory, record.productName, record.productSpecification].entries()) {
|
||||
expect(cells[index + 1]!.text()).toBe(value)
|
||||
expect(cells[index + 1]!.classes()).not.toContain('data-table__single-line-cell')
|
||||
expect(cells[index + 1]!.find('.cell').classes()).not.toContain('el-tooltip')
|
||||
}
|
||||
})
|
||||
it('refreshes the cached procurement list after returning from detail or edit', () => { expect(purchaseListSource).toContain('onActivated'); expect(purchaseListSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
|
||||
it('refreshes the cached procurement detail after an edit completes', () => { expect(purchaseDetailSource).toContain('onActivated'); expect(purchaseDetailSource).toMatch(/onActivated\(\(\) => \{ if \(activatedOnce\) void load\(\)/) })
|
||||
it('paginates procurement results', async () => { vi.mocked(listOverseasPurchases).mockResolvedValue({ records: [purchase], page: 1, size: 20, total: 40, pages: 2 }); const wrapper = mountPage(PurchaseOrdersView); await flushPromises(); await wrapper.find('button[aria-label="第 2 页"]').trigger('click'); await flushPromises(); expect(listOverseasPurchases).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) })
|
||||
@@ -268,4 +281,17 @@ describe('overseas business mounted pages', () => {
|
||||
it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
|
||||
it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); const groups = wrapper.findAllComponents({ name: 'ElRadioGroup' }); groups[0].vm.$emit('update:modelValue', 'SHARED_TRUCK'); groups[1].vm.$emit('update:modelValue', 'YES'); groups[2].vm.$emit('update:modelValue', 'WAREHOUSE_TO_WAREHOUSE'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() })
|
||||
it.each([true, false])('opens only the matching pool order read-only arrangement: %s', matching => {
|
||||
routing.route.query = { orderNo: truck.orderNo, fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: matching ? arrangement.bookingNo : 'FB-OTHER' }
|
||||
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
return (async () => {
|
||||
const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '承运安排')!
|
||||
expect(dialog.props('modelValue')).toBe(matching); expect(dialog.props('showConfirm')).toBe(false)
|
||||
await wrapper.findAll('button').find(item => item.text() === '返回原拼车登记')!.trigger('click')
|
||||
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: matching ? arrangement.bookingNo : 'FB-OTHER' } })
|
||||
expect(updateUsTruckOrder).not.toHaveBeenCalled()
|
||||
})()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -30,12 +30,12 @@ const fields = computed<QueryField[]>(() => [
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 176, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
|
||||
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
|
||||
{ key: 'purchaseCategory', prop: 'purchaseCategory', label: '采购品类', minWidth: 130 },
|
||||
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 160 },
|
||||
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 220, singleLine: false },
|
||||
{ key: 'customerName', prop: 'customerName', label: '客户名称', minWidth: 220, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.customerName || '—' },
|
||||
{ key: 'purchaseWarehouseName', prop: 'purchaseWarehouseName', label: '采购仓库', minWidth: 160, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.purchaseWarehouseName || '—' },
|
||||
{ key: 'purchaseCategory', prop: 'purchaseCategory', label: '采购品类', minWidth: 130, singleLine: false },
|
||||
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 220, singleLine: false },
|
||||
{ key: 'productSpecification', prop: 'productSpecification', label: '商品规格', minWidth: 180, singleLine: false, formatter: (row: OverseasPurchaseSummary) => row.productSpecification || '—' },
|
||||
{ key: 'budgetUnitPrice', label: '预算单价', width: 120, align: 'right', formatter: (row: OverseasPurchaseSummary) => money(row.budgetUnitPrice, row.currency) },
|
||||
{ key: 'purchaseQuantity', prop: 'purchaseQuantity', label: '采购数量', width: 100, align: 'right' },
|
||||
{ key: 'status', label: '状态', minWidth: 145, formatter: (row: OverseasPurchaseSummary) => statusLabel(row.status) },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
|
||||
import { inquiryReturnTarget, TransportRouteDialog, routeLocation, type RouteLocation } from '@/modules/overseas-master-data'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Plus } from '@lucide/vue'
|
||||
@@ -76,6 +76,9 @@ const columns = computed(() => {
|
||||
})
|
||||
function formatDateTime(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
async function load() {
|
||||
const sourceBooking = route.query.fleetBookingNo
|
||||
const sourceOrder = route.query.orderNo
|
||||
arrangementOpen.value = false; arrangement.value = null
|
||||
rows.value = []; total.value = 0; loadError.value = null; loading.value = true
|
||||
try {
|
||||
if (isLooseboard.value) {
|
||||
@@ -85,6 +88,10 @@ async function load() {
|
||||
const result = await listUsTruckOrders({ page: page.value, size: pageSize.value, orderNo: String(applied.value.orderNo || ''), routeKeyword: String(applied.value.routeKeyword || ''), status: String(applied.value.status || '') as LocalTransportStatus | '' } satisfies LocalTransportQuery)
|
||||
rows.value = result.records; total.value = result.total
|
||||
}
|
||||
if (inquiryReturnTarget(route.query) && route.query.fleetBookingNo === sourceBooking && route.query.orderNo === sourceOrder) {
|
||||
const target = rows.value.find(row => row.orderNo === sourceOrder && row.fleetArrangement?.bookingNo === sourceBooking)
|
||||
if (target) showArrangement(target)
|
||||
}
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单加载失败,请稍后重试`) } finally { loading.value = false }
|
||||
}
|
||||
function search(model: QueryModel) { applied.value = { ...model }; if (page.value === 1) void load(); else page.value = 1 }
|
||||
@@ -99,7 +106,7 @@ watch([page, pageSize], () => { void load() }); watch([isLooseboard, () => route
|
||||
|
||||
|
||||
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" :preference-key="`overseas-business-${domain}`" row-key="id" :empty-text="`暂无符合筛选条件的${domainLabel}订单`">
|
||||
<template #toolbar-start><h1>{{ domainLabel }}</h1></template>
|
||||
<template #toolbar-start><el-button v-if="inquiryReturnTarget(route.query)" link @click="router.push(inquiryReturnTarget(route.query)!)">返回原拼车登记</el-button><h1>{{ domainLabel }}</h1></template>
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" :ariaLabel="`${domainLabel}订单筛选`" :preference-key="queryPreferenceKey" @search="search" /></template>
|
||||
<template #notices><template v-if="loadError"><LoadErrorState :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template></template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildPacking, buildPhysical, physicalForm, shippedCartons } from './procurementPhysicalForm'
|
||||
import { buildPacking, buildPhysical, physicalForm, shippedCartons, managementReason, receiptCorrectionReason } from './procurementPhysicalForm'
|
||||
import { readPendingProcurement } from './procurementPending'
|
||||
import type { PlatformProcurement } from './procurementTypes'
|
||||
const base = { version: 7, idempotencyKey: 'physical-one', reason: '现场核对' }
|
||||
@@ -65,11 +65,52 @@ describe('procurement physical request validation', () => {
|
||||
expect(payload).toMatchObject({ kind: 'SHORTAGE', decision: 'ACCEPT_SHORTAGE', quantity: 3 }); expect(payload).not.toHaveProperty('receiptId')
|
||||
})
|
||||
it('corrects an original receipt without counting it twice or moving its origin', () => {
|
||||
const value = detail(); const form = physicalForm(); form.receiptId = '11'; form.leg = 'DOMESTIC'; form.sourceCartonId = '31'; form.goodQuantity = 5; form.damagedQuantity = 1; form.receivedAt = '2020-01-02T10:00:00'
|
||||
const value = detail(); value.purchaseFacts.overseas_shipped_at = null; const form = physicalForm(); form.receiptId = '11'; form.leg = 'DOMESTIC'; form.sourceCartonId = '31'; form.goodQuantity = 5; form.damagedQuantity = 1; form.receivedAt = '2020-01-02T10:00:00'
|
||||
const payload = buildPhysical('receipts/correct', form, value, base, '20', '901')
|
||||
expect(payload).toMatchObject({ receiptId: '11', leg: 'OVERSEAS', goodQuantity: 5 }); expect(payload).not.toHaveProperty('sourceCartonId')
|
||||
expect(readPendingProcurement(JSON.stringify({ command: 'receipts/correct', payload }))).not.toBeNull()
|
||||
})
|
||||
it('blocks shipped receipt corrections while preserving domestic corrections before customer shipment', () => {
|
||||
const value = detail()
|
||||
expect(receiptCorrectionReason(value, 'OVERSEAS')).toContain('本段已实际交寄')
|
||||
expect(receiptCorrectionReason(value, 'DOMESTIC')).toBeNull()
|
||||
value.purchaseFacts.customer_shipped_at = '2020-01-05T10:00:00'
|
||||
expect(receiptCorrectionReason(value, 'DOMESTIC')).toContain('本段已实际交寄')
|
||||
value.purchaseFacts.overseas_shipped_at = null
|
||||
value.physical.packingStatus = 'CONFIRMED'
|
||||
expect(receiptCorrectionReason(value, 'OVERSEAS')).toContain('先撤回')
|
||||
value.physical.packingStatus = 'DRAFT'
|
||||
expect(receiptCorrectionReason(value, 'OVERSEAS')).toBeNull()
|
||||
})
|
||||
it.each(['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'])('blocks physical changes for %s but leaves evidence and warehouse correction available', status => {
|
||||
const value = detail(); value.customerView.order.status = status
|
||||
expect(managementReason('receipts/correct', value)).toContain('订单已结束')
|
||||
expect(managementReason('dispositions', value)).toContain('订单已结束')
|
||||
expect(managementReason('packing/withdraw', value)).toContain('订单已结束')
|
||||
expect(managementReason('control-notes', value)).toBeNull()
|
||||
expect(managementReason('actual-warehouse/correct', value)).toBeNull()
|
||||
const form = physicalForm(); form.receiptId = '11'
|
||||
expect(() => buildPhysical('receipts/correct', form, value, base, '20', '901')).toThrow('订单已结束')
|
||||
})
|
||||
it('requires the correct physical source and unfinished receipt before recording differences', () => {
|
||||
const value = detail(); value.purchaseFacts = {}
|
||||
expect(managementReason('dispositions', value)).toContain('交寄来源')
|
||||
value.purchaseFacts.purchased_at = '2020-01-01'
|
||||
expect(managementReason('dispositions', value)).toBeNull()
|
||||
value.purchaseFacts.overseas_received_at = '2020-01-02'
|
||||
expect(managementReason('dispositions', value)).not.toBeNull()
|
||||
value.purchaseFacts.overseas_shipped_at = '2020-01-03'
|
||||
expect(managementReason('dispositions', value)).toBeNull()
|
||||
value.purchaseFacts.domestic_received_at = '2020-01-04'
|
||||
expect(managementReason('dispositions', value)).toContain('本段收货已完成')
|
||||
})
|
||||
it('rejects a shipped receipt even when another leg still offers a correctable receipt', () => {
|
||||
const value = detail()
|
||||
value.physical.receipts.push({ ...value.physical.receipts[0]!, id: '13', leg: 'DOMESTIC', sourceCartonId: '31' })
|
||||
expect(managementReason('receipts/correct', value)).toBeNull()
|
||||
const form = physicalForm(); form.receiptId = '11'
|
||||
expect(() => buildPhysical('receipts/correct', form, value, base, '20', '901')).toThrow('本段已实际交寄')
|
||||
})
|
||||
it('confirms the exact draft version and rejects malformed persisted packing before replay', () => {
|
||||
const value = detail(); const form = physicalForm(value)
|
||||
expect(buildPhysical('packing/confirm', form, value, base, '', '')).toEqual({ ...base, packingVersion: 2 })
|
||||
|
||||
@@ -1,6 +1,43 @@
|
||||
import type { PlatformProcurement } from './procurementTypes'
|
||||
import type { ProcurementCommand, ProcurementCommandBody, ProcurementPackingCarton } from './api/procurementCommands'
|
||||
export type Leg = 'OVERSEAS' | 'DOMESTIC'
|
||||
const closedStatuses = new Set(['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'])
|
||||
export function receiptCorrectionReason(detail: PlatformProcurement, leg: Leg): string | null {
|
||||
if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,只能追加事实说明,不能更正实收数量'
|
||||
if (detail.purchaseFacts[leg === 'OVERSEAS' ? 'overseas_shipped_at' : 'customer_shipped_at']) return '本段已实际交寄,只能追加更正说明,不能改写数量'
|
||||
if (leg === 'OVERSEAS' && detail.physical.packingStatus === 'CONFIRMED') return '须先撤回尚未交寄的确认清单,再更正海外实收'
|
||||
return null
|
||||
}
|
||||
export function dispositionReason(detail: PlatformProcurement, leg: Leg): string | null {
|
||||
if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,不能修改差异处置'
|
||||
if (detail.purchaseFacts[leg === 'OVERSEAS' ? 'overseas_received_at' : 'domestic_received_at']) return '本段收货已完成,须先受控更正,不能直接改差异结果'
|
||||
if (leg === 'OVERSEAS' && !detail.purchaseFacts.purchased_at) return '尚无本单真实购买来源'
|
||||
if (leg === 'DOMESTIC' && !detail.purchaseFacts.overseas_shipped_at) return '尚无本单国际交寄来源'
|
||||
return null
|
||||
}
|
||||
export const managementOptions: { value: ProcurementCommand; label: string }[] = [
|
||||
{ value: 'dispositions', label: '记录差异处置' }, { value: 'receipts/correct', label: '更正原实收数量' },
|
||||
{ value: 'dispositions/revoke', label: '撤销原差异处置' }, { value: 'packing/withdraw', label: '撤回未交寄的确认清单' },
|
||||
{ value: 'control-notes', label: '追加事实说明或阻断' }, { value: 'control-notes/resolve', label: '解除已处理的阻断' },
|
||||
{ value: 'actual-warehouse/correct', label: '实际仓受控更正' },
|
||||
]
|
||||
export function managementReason(command: ProcurementCommand, detail: PlatformProcurement): string | null {
|
||||
if (command === 'receipts/correct' || command === 'dispositions/revoke') {
|
||||
const records = command === 'receipts/correct' ? detail.physical.receipts : detail.physical.dispositions
|
||||
const active = records.filter(row => row.active)
|
||||
if (!active.length) return command === 'receipts/correct' ? '暂无有效实收记录' : '暂无有效差异处置'
|
||||
return active.some(row => !receiptCorrectionReason(detail, row.leg)) ? null : receiptCorrectionReason(detail, active[0]!.leg)
|
||||
}
|
||||
if (command === 'dispositions') return dispositionReason(detail, 'OVERSEAS') && dispositionReason(detail, 'DOMESTIC')
|
||||
if (command === 'packing/withdraw') {
|
||||
if (closedStatuses.has(detail.customerView.order.status)) return '订单已结束,不能撤回装箱事实'
|
||||
if (detail.purchaseFacts.overseas_shipped_at) return '国际段已交寄,不能撤回装箱事实'
|
||||
if (detail.physical.packingStatus !== 'CONFIRMED') return '暂无已确认清单可撤回'
|
||||
}
|
||||
if (command === 'control-notes/resolve' && !detail.controls.notes.some(row => row.blocking && !row.resolved_at)) return '暂无待处理阻断'
|
||||
if (command === 'actual-warehouse/correct' && !detail.purchaseFacts.actual_warehouse_id) return '尚无实际收货仓,请修改计划仓'
|
||||
return null
|
||||
}
|
||||
export const physicalCommands = new Set<ProcurementCommand>(['overseas-receipts', 'domestic-receipts', 'overseas-receipts/complete', 'domestic-receipts/complete', 'packing', 'packing/confirm', 'packing/withdraw', 'dispositions', 'receipts/correct', 'dispositions/revoke'])
|
||||
export interface PackingDraft { cartonNo: string; lengthCm: string; widthCm: string; heightCm: string; grossWeightKg: string; sources: { receiptId: string; quantity: number | undefined }[] }
|
||||
export interface PhysicalForm { leg: Leg; sourceCartonId: string; receiptId: string; dispositionId: string; goodQuantity: number | undefined; damagedQuantity: number | undefined; extraQuantity: number | undefined; receivedAt: string; kind: 'SHORTAGE' | 'DAMAGED' | 'EXTRA'; quantity: number | undefined; cartons: PackingDraft[] }
|
||||
@@ -28,6 +65,8 @@ export function buildPacking(cartons: PackingDraft[], detail: PlatformProcuremen
|
||||
})
|
||||
}
|
||||
export function buildPhysical(command: ProcurementCommand, form: PhysicalForm, detail: PlatformProcurement, base: ProcurementCommandBody, warehouseId: string, voucherFileId: string): ProcurementCommandBody {
|
||||
const blocked = managementReason(command, detail)
|
||||
if (blocked) throw new Error(blocked)
|
||||
const closed = ['REJECTED', 'CANCELLED', 'EXCEPTION_CLOSED', 'SHIPPED_TO_CUSTOMER'].includes(detail.customerView.order.status)
|
||||
if (closed) throw new Error('订单已结束,不能改写实物记录')
|
||||
if (command === 'packing') return { ...base, cartons: buildPacking(form.cartons, detail) }
|
||||
@@ -40,7 +79,10 @@ export function buildPhysical(command: ProcurementCommand, form: PhysicalForm, d
|
||||
return base
|
||||
}
|
||||
if (command === 'dispositions/revoke') {
|
||||
if (!detail.physical.dispositions.some(row => row.id === form.dispositionId && row.active)) throw new Error('请选择有效差异处置')
|
||||
const row = detail.physical.dispositions.find(row => row.id === form.dispositionId && row.active)
|
||||
if (!row) throw new Error('请选择有效差异处置')
|
||||
const reason = receiptCorrectionReason(detail, row.leg)
|
||||
if (reason) throw new Error(reason)
|
||||
return { ...base, dispositionId: form.dispositionId, voucherFileId }
|
||||
}
|
||||
if (command.endsWith('/complete')) return { ...base, leg: command.startsWith('overseas') ? 'OVERSEAS' : 'DOMESTIC' }
|
||||
@@ -48,6 +90,8 @@ export function buildPhysical(command: ProcurementCommand, form: PhysicalForm, d
|
||||
const old = correction ? detail.physical.receipts.find(row => row.id === form.receiptId && row.active) : undefined
|
||||
if (correction && !old) throw new Error('请选择有效原实收记录')
|
||||
const leg: Leg = old?.leg ?? (command === 'overseas-receipts' ? 'OVERSEAS' : command === 'domestic-receipts' ? 'DOMESTIC' : form.leg)
|
||||
const reason = correction ? receiptCorrectionReason(detail, leg) : command === 'dispositions' ? dispositionReason(detail, leg) : null
|
||||
if (reason) throw new Error(reason)
|
||||
const sourceCartonId = old?.sourceCartonId || form.sourceCartonId
|
||||
if (leg === 'DOMESTIC' && !shippedCartons(detail).some(row => row.id === sourceCartonId)) throw new Error('请选择本单已国际交寄的原箱')
|
||||
if (command === 'dispositions') {
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface InquiryResult extends OfferResult { inquiry: CapacityInquiry }
|
||||
export interface OfferCustomerOption { id: string; code: string; name: string }
|
||||
export const offerStatusLabels = { DRAFT: '发布草稿', OPEN: '接单中', PAUSED: '已暂停', CLOSED: '已关闭' }
|
||||
export const inquiryStatusLabels = { DRAFT: '已登记', CONFIRMED: '已确认承运', RECEIVED: '已确认实收', BOUND: '已排入本车', CANCELLED: '已取消' }
|
||||
export const inquiryCargoLabel = (status?: CapacityInquiry['status']) => status === 'RECEIVED' || status === 'BOUND' ? '已核实' : status === 'CANCELLED' ? '记录' : '预报'
|
||||
export const offerTime = (value: string | null) => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
|
||||
export const loadText = (value: CapacityLoad | null) => value ? `${value.palletSpaces} 托位 / ${value.weightKg} kg / ${value.volumeCbm} m³` : '待调度核对'
|
||||
|
||||
|
||||
@@ -59,3 +59,36 @@ it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated
|
||||
expect(listOfferQuotes).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('locks the destination in readonly follow-up even without offer detail, while allowing multi-stop editing', async () => {
|
||||
permissions.values = new Set(['platform:fleet:capacity-offer:manage'])
|
||||
const multi = { ...offer, destinations: [...offer.destinations, { destinationKey: 'FBA:ACY2', name: 'ACY2', deliveryIndex: 2, remaining: null }] }
|
||||
const row: CapacityInquiry = {
|
||||
bookingNo: 'FB-READONLY', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1,
|
||||
offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17',
|
||||
customerId: '19', customerCode: 'C-19', customerName: '测试客户', businessType: 'LOCAL_LOOSEBOARD', deliveryMode: 'SELF_DELIVERY',
|
||||
estimatedArrivalAt: null, expectedPalletCount: null,
|
||||
cargo: { destinationKey: 'FBA:ACY2', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null },
|
||||
destinationLabel: 'ACY2', agreedAmount: null, quoteSource: null, businessOrderNo: null, incomingNo: null, dispatchNo: null,
|
||||
updatedAt: '2026-10-10T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null }, appointmentCoverage: null,
|
||||
progress: { stage: 'NEEDS_DETAILS', label: '待补资料', reservation: '尚未保位', owner: '销售', primaryAction: 'EDIT', message: '待补资料', missingFields: [] },
|
||||
}
|
||||
const wrapper = mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer: null, inquiry: row, readonly: true }, global: { plugins: [createPinia(), ElementPlus] } })
|
||||
wrappers.push(wrapper); await flushPromises()
|
||||
const destination = () => formItem(wrapper, '本车卸货站').findComponent({ name: 'ElSelect' })
|
||||
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeDefined()
|
||||
await destination().find('.el-select__wrapper').trigger('click'); await flushPromises()
|
||||
expect(destination().get('input[role="combobox"]').attributes('aria-expanded')).toBe('false')
|
||||
expect(destination().props('modelValue')).toBe('FBA:ACY2')
|
||||
await wrapper.setProps({ offer: multi, readonly: false }); await flushPromises()
|
||||
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeUndefined()
|
||||
await destination().find('.el-select__wrapper').trigger('click'); await flushPromises()
|
||||
expect(destination().get('input[role="combobox"]').attributes('aria-expanded')).toBe('true')
|
||||
await select(wrapper, '本车卸货站', 'FBA:ONT8')
|
||||
expect(destination().props('modelValue')).toBe('FBA:ONT8')
|
||||
await wrapper.setProps({ inquiry: null }); await flushPromises()
|
||||
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeUndefined()
|
||||
await wrapper.setProps({ offer }); await flushPromises()
|
||||
expect(destination().get('input[role="combobox"]').attributes('disabled')).toBeDefined()
|
||||
expect(saveCapacityInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -8,10 +8,11 @@ import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/sh
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry, saveAndConfirmInquiry } from '../api/capacityOffers'
|
||||
import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
|
||||
import { offerTime, appointmentCoverageLabels, appointmentMissingLabels } from '../capacityOfferTypes'
|
||||
import { offerTime, appointmentCoverageLabels, appointmentMissingLabels, inquiryCargoLabel } from '../capacityOfferTypes'
|
||||
import type { CapacityOffer, CapacityInquiry, InquiryResult, InquiryWrite, OfferCustomerOption } from '../capacityOfferTypes'
|
||||
import type { TripBookingCargo } from '../bookingTypes'
|
||||
import { useFleetDraftGuard } from '../useFleetDraftGuard'
|
||||
import { inquiryOrderTarget } from '../inquiryNavigation'
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const props = defineProps<{ offer: CapacityOffer | null; inquiry: CapacityInquiry | null; readonly: boolean; confirmMode?: boolean }>()
|
||||
const emit = defineEmits<{ saved: [value: InquiryResult]; refresh: []; action: [key: string, row: CapacityInquiry] }>()
|
||||
@@ -37,6 +38,7 @@ const primaryFollowup = computed(() => {
|
||||
return followupActions.value.find(action => action.key.toUpperCase() === key)
|
||||
})
|
||||
const secondaryFollowups = computed(() => followupActions.value.filter(action => action !== primaryFollowup.value))
|
||||
const canReadOrder = computed(() => props.inquiry && inquiryOrderTarget(props.inquiry, access.can))
|
||||
const blankCargo = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: null, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null })
|
||||
const form = reactive({ customerId: '', businessType: 'LOCAL_LOOSEBOARD' as CapacityInquiry['businessType'], deliveryMode: 'SELF_DELIVERY' as CapacityInquiry['deliveryMode'], estimatedArrivalAt: null as string | null, expectedPalletCount: null as number | null, cargo: blankCargo(), quoteRef: null as FleetQuoteReference | null })
|
||||
const quoteSource = ref<FleetQuoteSourceSnapshot | null>(null), customers = ref<OfferCustomerOption[]>([]), customerLoading = ref(false)
|
||||
@@ -47,6 +49,8 @@ const target = computed(() => props.inquiry?.bookingNo || props.offer?.offerNo |
|
||||
const writable = computed(() => !props.readonly && (access.can('platform:fleet:capacity-offer:manage') || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${props.inquiry ? 'edit' : 'create'}`)) && (props.inquiry ? props.inquiry.actions.canEdit : props.offer?.canCreateInquiry))
|
||||
const canQuote = computed(() => access.can('platform:fleet:quote:list'))
|
||||
const lock = computed(() => !writable.value || busy.value || !!pending.value)
|
||||
const cargoLabel = computed(() => inquiryCargoLabel(props.inquiry?.status))
|
||||
const selectedCapacity = computed(() => props.offer?.capacityKnown ? props.offer.destinations.find(stop => stop.destinationKey === form.cargo.destinationKey)?.remaining : null)
|
||||
const quoteReady = computed(() => !!form.customerId && form.cargo.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.cargo.currency || ''))
|
||||
const guard = useFleetDraftGuard('capacity-inquiry-editor', open, form, busy, computed(() => !!pending.value))
|
||||
let generation = 0, customerSequence = 0, quoteSequence = 0
|
||||
@@ -104,9 +108,15 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
|
||||
<AppDialog v-model="open" :before-close="guard.beforeClose" class="admin-density-dialog" destroy-on-close :title="readonly ? '客户货物跟进' : inquiry ? '补充客户货物资料' : '登记客户货物'" width="1080px" :show-confirm="writable" :confirm-text="pending ? '重试原请求' : confirmMode ? '确认承运并保位' : '保存登记'" :confirm-loading="busy" @confirm="save">
|
||||
<LoadErrorState v-if="error" title="意向未能保存" :message="error.message" @retry="pending ? save() : emit('refresh')"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<p v-if="confirmMode">核对本票货量、价格与交货资料后确认承运。成功将保留货位并创建关联本土订单,不自动安排仓库收货。</p>
|
||||
<section v-if="confirmMode" aria-label="本目的站可接余位">
|
||||
<strong>本目的站可接余位</strong>
|
||||
<p v-if="selectedCapacity">{{ selectedCapacity.palletSpaces }} 托位 · {{ selectedCapacity.weightKg }} kg · {{ selectedCapacity.volumeCbm }} m³</p>
|
||||
<p v-else>待调度核对,当前没有可靠的可接余位。</p>
|
||||
<p>托位、重量、体积须同时满足;确认时由系统重新核验,不代表已经保位。</p>
|
||||
</section>
|
||||
<p v-if="pending" role="status">上次提交结果尚未确定,请保留原内容重试或核对。</p>
|
||||
<p v-if="offer">{{ offer.offerNo }} · {{ offer.warehouseName }} · {{ offer.blockReason || '询价不占位,调度确认承运后占位。' }}</p>
|
||||
<section v-if="inquiry" aria-label="本票处理进度"><strong>{{ inquiry.progress.label }} · {{ inquiry.progress.reservation }}</strong><p>下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}</p></section>
|
||||
<section v-if="inquiry" aria-label="本票处理进度"><strong>{{ inquiry.customerName }} · {{ inquiry.destinationLabel }}</strong><p>车次:{{ inquiry.tripNo }} · 登记:{{ inquiry.bookingNo }}</p><strong>{{ inquiry.progress.label }} · {{ inquiry.progress.reservation }}</strong><p>下一步负责人:{{ inquiry.progress.owner }} · {{ inquiry.progress.message }}</p></section>
|
||||
<p v-else>先选客户即可登记;多站车次需选择卸货站。保存后调度可见,尚不保位。</p>
|
||||
<p v-if="!inquiry">业务类型:{{ form.businessType === 'US_TRUCK' ? '本土卡车' : '本土散板' }},可在“确认承运前待补资料”中调整。</p>
|
||||
<p v-if="offer">计划发车:{{ offerTime(offer.plannedDepartureAt) }} · 最晚送仓:{{ offerTime(offer.cutoffAt) }}</p>
|
||||
@@ -119,13 +129,13 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
|
||||
</section>
|
||||
<el-form label-position="top" class="density-form capacity-inquiry-form" :disabled="lock">
|
||||
<el-form-item label="委托客户" required><el-select v-model="form.customerId" filterable remote :remote-method="searchCustomers" :loading="customerLoading" :disabled="!!inquiry" placeholder="按客户编号或名称搜索" @focus="searchCustomers()"><el-option v-if="inquiry" :value="inquiry.customerId" :label="`${inquiry.customerCode} · ${inquiry.customerName}`" /><el-option v-for="customer in customers" :key="customer.id" :value="customer.id" :label="`${customer.code} · ${customer.name}`" /></el-select><span v-if="customerSearched && !customers.length">暂无可选客户</span></el-form-item>
|
||||
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="offer?.destinations.length === 1" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
|
||||
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>已知货量(可先填写,未知可留空)</summary><div class="capacity-inquiry-form">
|
||||
<el-form-item label="预计箱数"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
|
||||
<el-form-item label="本车卸货站" required><el-select v-model="form.cargo.destinationKey" :disabled="lock || offer?.destinations.length === 1" placeholder="选择本车既有卸货站"><el-option v-for="stop in offer?.destinations || []" :key="stop.destinationKey" :value="stop.destinationKey" :label="stop.name" /><el-option v-if="!offer && inquiry" :value="inquiry.cargo.destinationKey" :label="inquiry.destinationLabel" /></el-select></el-form-item>
|
||||
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>{{ cargoLabel === '已核实' ? '已核实货量与预计实托数' : readonly ? `${cargoLabel}货量` : '已知货量(可先填写,未知可留空)' }}</summary><div class="capacity-inquiry-form">
|
||||
<el-form-item :label="`${cargoLabel}箱数`"><el-input-number v-model="form.cargo.packageCount" :min="1" :precision="0" placeholder="未知可留空" /></el-form-item>
|
||||
<el-form-item label="预计实托数"><el-input-number v-model="form.expectedPalletCount" :min="1" :precision="0" placeholder="卡车确认承运前必填" /></el-form-item>
|
||||
<el-form-item label="预计标准托位"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计重量(kg)"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item label="预计体积(m³)"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item :label="`${cargoLabel}标准托位`"><el-input-number v-model="form.cargo.palletSpaces" :min="1" :precision="0" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item :label="`${cargoLabel}重量(kg)`"><el-input-number v-model="form.cargo.weightKg" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
<el-form-item :label="`${cargoLabel}体积(m³)`"><el-input-number v-model="form.cargo.volumeCbm" :min="0.001" :precision="3" placeholder="确认承运前填写" /></el-form-item>
|
||||
</div></details>
|
||||
<details class="capacity-inquiry-wide" :open="readonly || confirmMode"><summary>确认承运前待补资料{{ inquiry?.progress.missingFields.length ? `(还需:${inquiry.progress.missingFields.join('、')})` : '(货名、价格、到仓及联系信息)' }}</summary><div class="capacity-inquiry-form">
|
||||
<el-form-item label="本土业务类型"><el-select v-model="form.businessType"><el-option value="LOCAL_LOOSEBOARD" label="本土散板" /><el-option value="US_TRUCK" label="本土卡车" /></el-select></el-form-item>
|
||||
@@ -155,7 +165,7 @@ onBeforeUnmount(() => { generation++; customerSequence++; quoteSequence++; clear
|
||||
<p v-for="appointment in inquiry.appointmentCoverage.appointments" :key="appointment.appointmentNo">预约:{{ appointment.appointmentNo }} · 外部确认号:{{ appointment.confirmationNo || '尚未确认' }}</p>
|
||||
<p v-if="inquiry.appointmentCoverage.changeReviewRequired">货源或数量发生变化,请负责预约的人员重新核对覆盖。</p>
|
||||
</section>
|
||||
<p v-if="inquiry">本土订单:{{ inquiry.businessOrderNo || '尚未成交' }} · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}</p>
|
||||
<p v-if="inquiry">本土订单:<el-button v-if="canReadOrder" link @click="emit('action', 'business-order', inquiry)">{{ inquiry.businessOrderNo }}</el-button><span v-else>{{ inquiry.businessOrderNo || '尚未成交' }}</span> · 来货:{{ inquiry.incomingNo || '尚未登记' }} · 排车:{{ inquiry.dispatchNo || '尚未关联' }}</p>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>.capacity-inquiry-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.capacity-inquiry-form :deep(.el-select),.capacity-inquiry-form :deep(.el-input-number),.capacity-inquiry-form :deep(.el-date-editor){width:100%}.capacity-inquiry-wide{grid-column:1/-1}</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { inquiryStatusLabels, offerTime, type CapacityInquiry } from '../capacityOfferTypes'
|
||||
import { inquiryCargoLabel, offerTime, type CapacityInquiry } from '../capacityOfferTypes'
|
||||
withDefaults(defineProps<{ rows: CapacityInquiry[]; total: number; loading: boolean; busy?: boolean; integrated?: boolean; failed?: boolean }>(), { busy: false, integrated: false, failed: false })
|
||||
const page = defineModel<number>('page', { default: 1 }), size = defineModel<number>('size', { default: 20 })
|
||||
const emit = defineEmits<{ action: [key: string, row: CapacityInquiry] }>()
|
||||
@@ -10,7 +10,7 @@ const access = useAccessPreviewStore()
|
||||
function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
|
||||
const manage = access.can('platform:fleet:capacity-offer:manage')
|
||||
const write = (kind: string) => manage || access.can('platform:fleet:capacity-offer:list') && access.can(`platform:fleet:capacity-offer:inquiry-${kind}`)
|
||||
return [{ key: 'view', label: '查看进度', priority: 10 },
|
||||
const items: RowActionItem[] = [{ key: 'view', label: '查看进度', priority: 10 },
|
||||
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit'), disabled: busy },
|
||||
{ key: 'receipt', label: '查看收货并核量', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm'), disabled: busy },
|
||||
{ key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'), disabled: busy },
|
||||
@@ -18,12 +18,15 @@ function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
|
||||
{ key: 'edit', label: '补充资料', visible: row.actions.canEdit && write('edit'), disabled: busy },
|
||||
{ key: 'confirm', label: '核对并确认', visible: (row.actions.canConfirm || row.actions.canEdit) && manage, disabled: busy },
|
||||
{ key: 'cancel', label: '取消意向', visible: row.actions.canCancel && write('cancel'), disabled: busy, tone: 'danger' }]
|
||||
const primary = items.find(action => action.visible !== false && action.key.toUpperCase() === row.progress.primaryAction)
|
||||
if (primary) { primary.priority = 20; primary.tone = 'primary' }
|
||||
return items
|
||||
}
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 320, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'bookingNo', label: '意向 / 预订号', minWidth: 210, singleLine: false }, { key: 'customerName', label: '客户', minWidth: 210, singleLine: false },
|
||||
{ key: 'tripNo', label: '车次号', minWidth: 210 }, { key: 'destinationLabel', label: '卸货站', minWidth: 130 },
|
||||
{ key: 'status', label: '当前进度 / 下一步', minWidth: 270, slotName: 'status', singleLine: false }, { key: 'cargo', label: '预计货量', minWidth: 270, slotName: 'cargo' },
|
||||
{ key: 'status', label: '当前进度 / 下一步', minWidth: 270, slotName: 'status', singleLine: false }, { key: 'cargo', label: '货量', minWidth: 270, slotName: 'cargo', singleLine: false },
|
||||
{ key: 'agreedAmount', label: '约定运费(非实收)', minWidth: 165, slotName: 'amount' }, { key: 'businessOrderNo', label: '本土订单号', minWidth: 210 },
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 180, slotName: 'time' },
|
||||
]
|
||||
@@ -32,9 +35,9 @@ const columns: DataTableColumn[] = [
|
||||
<template v-if="$slots['toolbar-start']" #toolbar-start><slot name="toolbar-start" /></template>
|
||||
<template v-if="$slots.filters" #filters><slot name="filters" /></template>
|
||||
<template v-if="$slots.notices" #notices><slot name="notices" /></template>
|
||||
<template #actions="{ row }"><RowActions :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
|
||||
<template #actions="{ row }"><RowActions :max-visible="1" :actions="actions(row as CapacityInquiry, busy)" text-actions @action="emit('action', $event, row as CapacityInquiry)" /></template>
|
||||
<template #status="{ row }"><strong>{{ row.progress.label }} · {{ row.progress.reservation }}</strong><div>下一步:{{ row.progress.owner }}</div><small class="inquiry-reason">{{ row.progress.message }}</small></template>
|
||||
<template #cargo="{ row }">{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.expectedPalletCount ?? '—' }} 实托 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
|
||||
<template #cargo="{ row }"><div>{{ inquiryCargoLabel(row.status) }}:{{ row.cargo.packageCount ?? '—' }} 箱 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</div><div>预计实托数:{{ row.expectedPalletCount ?? '—' }}</div></template>
|
||||
<template #amount="{ row }">{{ row.agreedAmount == null ? '待报价' : `${Number(row.agreedAmount).toFixed(2)} ${row.cargo.currency || ''}` }}</template>
|
||||
<template #time="{ row }">{{ offerTime(row.updatedAt) }}</template>
|
||||
</DataTable></template>
|
||||
|
||||
@@ -19,7 +19,7 @@ function mount(realPlanner = false) {
|
||||
const wrapper = shallowMount(Dialog, { props: { modelValue: true, trip }, global: { plugins: [createPinia()], stubs: {
|
||||
AppDialog: { name: 'AppDialog', props: ['confirmText'], emits: ['confirm'], template: '<div><slot/><button @click="$emit(\'confirm\')">{{confirmText}}</button></div>' },
|
||||
TripLoadingPanel: realPlanner ? false : defineComponent({ name: 'TripLoadingPanel', setup(_, { expose }) { expose({ save: state.plan }); return {} }, template: '<div/>' }),
|
||||
DataTable: { name: 'DataTable', props: ['data'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="weight" :row="row"/><slot name="volume" :row="row"/></div></div>' },
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="palletNo" :row="row"/><slot name="weight" :row="row"/><slot name="volume" :row="row"/></div></div>' },
|
||||
ElInputNumber: { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '<input/>' },
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
|
||||
ElButton: { template: '<button><slot/></button>' }, ElDatePicker: true, ElInput: true, LoadErrorState: true, ApiErrorMeta: true,
|
||||
@@ -61,10 +61,14 @@ it('retries only the uncertain publish command with the original key', async ()
|
||||
})
|
||||
|
||||
it('submits measurements from the embedded panel before saving and publishing', async () => {
|
||||
const basis = { tripNo: 'TR-1', recordVersion: 5, cargo: [{ dispatchNo: 'DP-1', customerId: '1', dispatchItemId: '2', warehousePalletId: '3', palletNo: 'PLT-1', packages: 1, destinationKey: 'FBA:ACY2', destinationLabel: 'ACY2' }], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] }
|
||||
const basis = { tripNo: 'TR-1', recordVersion: 5, cargo: [{ dispatchNo: 'DP-1', customerId: '1', dispatchItemId: '2', warehousePalletId: '3', palletNo: 'PLT-USLAX01-261010-G0RD3LYHGD8H', packages: 1, destinationKey: 'FBA:ACY2', destinationLabel: 'ACY2' }], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] }
|
||||
vi.mocked(trips.getTripCapacityPlan).mockResolvedValue(basis)
|
||||
vi.mocked(trips.confirmTripCapacityPlan).mockResolvedValue({ ...basis, recordVersion: 6, planValid: true })
|
||||
const wrapper = mount(true); await flushPromises()
|
||||
expect(wrapper.text()).toContain('PLT-USLAX01-261010-G0RD3LYHGD8H')
|
||||
const columns = wrapper.findComponent({ name: 'DataTable' }).props('columns') as { key: string; singleLine?: boolean; width?: number; minWidth?: number }[]
|
||||
expect(columns.filter(column => ['palletNo', 'spaces', 'weight', 'volume', 'stop'].includes(column.key)).every(column => column.singleLine === false)).toBe(true)
|
||||
expect(columns.reduce((sum, column) => sum + (column.width || column.minWidth || 0), 0)).toBeLessThanOrEqual(1048)
|
||||
const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
|
||||
fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 2); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index'
|
||||
import { ElInputNumber } from 'element-plus/es/components/input-number/index'
|
||||
import { ElInput } from 'element-plus/es/components/input/index'
|
||||
import { createPinia } from 'pinia'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { mount as mountFull, shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
|
||||
import * as api from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
@@ -17,9 +21,9 @@ function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
|
||||
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
|
||||
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode, arrivalAt: '2026-10-03T08:00:00Z' }, global: { plugins: [createPinia()], directives: { loading: () => undefined }, stubs: {
|
||||
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' },
|
||||
DataTable: { props: ['data'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
|
||||
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/></div></div>' },
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
|
||||
LoadErrorState: true, ApiErrorMeta: true,
|
||||
ElButton: { template: '<button><slot/></button>' }, LoadErrorState: true, ApiErrorMeta: true,
|
||||
} } }); wrappers.push(wrapper); return wrapper
|
||||
}
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
|
||||
@@ -27,10 +31,12 @@ afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
|
||||
it('uses server actual pallets and preview versions while requiring explicit price confirmation', async () => {
|
||||
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
|
||||
const wrapper = mount('receipt'); await flushPromises()
|
||||
const inputs = wrapper.findAllComponents({ name: 'ElInputNumber' }); inputs[1]!.vm.$emit('update:modelValue', 55); await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('columns')).toContainEqual(expect.objectContaining({ key: 'spaces', singleLine: false }))
|
||||
const inputs = wrapper.findAllComponents({ name: 'ElInputNumber' }); inputs[0]!.vm.$emit('update:modelValue', 2); inputs[1]!.vm.$emit('update:modelValue', 55); await flushPromises()
|
||||
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
|
||||
expect(api.acceptTripIncoming).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 5, recordVersion: 2, receiptHash: 'receipt-hash', unitPrice: 55, pallets: [{ warehousePalletId: '902', palletSpaces: 1 }] }))
|
||||
expect(api.acceptTripIncoming).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 5, recordVersion: 2, receiptHash: 'receipt-hash', unitPrice: 55, pallets: [{ warehousePalletId: '902', palletSpaces: 2 }] }))
|
||||
expect(api.startTripIncoming).not.toHaveBeenCalled(); expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
|
||||
expect(ElMessage.success).toHaveBeenCalledWith('实收货量及约定运费已核实')
|
||||
})
|
||||
it('does not accept a receipt while the warehouse is still waiting for actual goods', async () => {
|
||||
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: 'WAIT_PDA_RECEIVING', pallets: [], receivedPackages: 0 } })
|
||||
@@ -56,3 +62,56 @@ it('keeps an uncertain receipt command retryable after closing and reopening wit
|
||||
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', original)
|
||||
expect(api.getTripIncoming).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ bookingStatus: 'BOUND' as const, incomingStatus: 'READY_FOR_DISPATCH', expected: '已确认实收,已排入本车' },
|
||||
{ bookingStatus: 'RECEIVED' as const, incomingStatus: 'READY_FOR_DISPATCH', expected: '已确认实收,可安排排车' },
|
||||
{ bookingStatus: 'BOUND' as const, incomingStatus: 'OUTBOUNDED', expected: '已出库' },
|
||||
])('shows truthful receipt guidance for $bookingStatus / $incomingStatus', async ({ bookingStatus, incomingStatus, expected }) => {
|
||||
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: incomingStatus } })
|
||||
const wrapper = mount('view')
|
||||
await wrapper.setProps({ booking: { ...booking, status: bookingStatus, dispatchNo: bookingStatus === 'BOUND' ? 'DS-1' : null } })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain(expected)
|
||||
if (bookingStatus === 'BOUND') expect(wrapper.text()).not.toContain('可安排排车')
|
||||
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual(preview.incoming!.pallets)
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
|
||||
expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('anchors receipt control sizing inside the real DataTable slot and disables cell ellipsis', async () => {
|
||||
const wrapper = mountFull(TripBookingFulfillmentDialog, {
|
||||
props: { modelValue: true, trip, booking, mode: 'receipt' },
|
||||
global: { plugins: [createPinia()], components: { ElTable, ElTableColumn, ElInputNumber, ElInput }, directives: { loading: () => undefined }, stubs: {
|
||||
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElButton: { template: '<button><slot/></button>' },
|
||||
AppDialog: { template: '<div><slot/></div>' }, LoadErrorState: true, ApiErrorMeta: true,
|
||||
} },
|
||||
})
|
||||
wrappers.push(wrapper); await flushPromises()
|
||||
const control = wrapper.get('.el-table__body .receipt-spaces-control')
|
||||
const scope = control.element.getAttributeNames().find(name => name.startsWith('data-v-'))
|
||||
expect(scope).toBeDefined()
|
||||
expect(control.find('.el-input-number').exists()).toBe(true)
|
||||
expect(control.element.closest('td')?.classList.contains('data-table__single-line-cell')).toBe(false)
|
||||
expect(control.element.closest('.cell')?.classList.contains('el-tooltip')).toBe(false)
|
||||
// DataTable forwards classes to its inner table, which does not carry this SFC's scope.
|
||||
expect(wrapper.find(`.receipt-pallet-table[${scope}]`).exists()).toBe(false)
|
||||
const input = control.get('input')
|
||||
await input.setValue('2'); await input.trigger('change'); await flushPromises()
|
||||
expect(wrapper.text()).toContain('2 托位')
|
||||
})
|
||||
|
||||
it('compares real receipt with the forecast, preserves unknown values, and does not invent historical forecasts', async () => {
|
||||
const wrapper = mount('receipt'); await flushPromises()
|
||||
const rows = () => wrapper.get('[aria-label="预报与实收对照"]').findAll('tbody tr').map(row => row.findAll('td').map(cell => cell.text()))
|
||||
expect(rows()).toEqual([
|
||||
['箱数', '20', '22', '+2'], ['实托数', '—', '1', '—'], ['标准托位(待核对)', '2', '1', '-1'],
|
||||
['重量(kg)', '100', '150', '+50'], ['体积(m³)', '1', '1.5', '+0.5'],
|
||||
])
|
||||
await wrapper.setProps({ booking: { ...booking, status: 'RECEIVED' } })
|
||||
expect(rows().every(row => row[1] === '—' && row[3] === '—')).toBe(true)
|
||||
expect(wrapper.text()).toContain('无法还原原始预报')
|
||||
await wrapper.setProps({ mode: 'view' }); await flushPromises()
|
||||
expect(wrapper.find('[aria-label="预报与实收对照"]').exists()).toBe(false)
|
||||
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -27,9 +27,30 @@ const ready = computed(() => preview.value?.incoming && ['WAIT_CUSTOMER_CONFIRMA
|
||||
const totalSpaces = computed(() => Object.values(spaces).reduce((a,b) => a + (b || 0), 0))
|
||||
const amount = computed(() => unitPrice.value == null ? null : Math.round(unitPrice.value * (props.booking?.cargo.priceBasis === 'ORDER' ? 1 : totalSpaces.value) * 100) / 100)
|
||||
const statuses: Record<string,string> = { WAIT_PDA_RECEIVING: '等待仓库收货', RECEIVING: '仓库收货中', WAIT_CUSTOMER_CONFIRMATION: '实收完成,待确认', READY_FOR_DISPATCH: '已确认实收,可安排排车', OUTBOUNDED: '已出库' }
|
||||
const receiptComparison = computed(() => {
|
||||
const incoming = preview.value?.incoming
|
||||
const pallets = incoming?.pallets || []
|
||||
const forecast = props.booking?.status === 'CONFIRMED' ? props.booking.cargo : null
|
||||
const sum = (key: 'weightKg' | 'volumeCbm') => pallets.length && pallets.every(row => row[key] != null && Number.isFinite(row[key]))
|
||||
? pallets.reduce((total, row) => total + row[key]!, 0) : null
|
||||
const values = [
|
||||
{ label: '箱数', expected: forecast?.packageCount, actual: incoming?.receivedPackages },
|
||||
{ label: '实托数', expected: forecast ? props.booking?.expectedPalletCount : null, actual: pallets.length ? pallets.length : null },
|
||||
{ label: '标准托位(待核对)', expected: forecast?.palletSpaces, actual: pallets.length ? totalSpaces.value : null },
|
||||
{ label: '重量(kg)', expected: forecast?.weightKg, actual: sum('weightKg') },
|
||||
{ label: '体积(m³)', expected: forecast?.volumeCbm, actual: sum('volumeCbm') },
|
||||
]
|
||||
return values.map(row => ({ ...row, difference: row.expected == null || row.actual == null ? null : Number((row.actual - row.expected).toFixed(3)) }))
|
||||
})
|
||||
const quantityText = (value: number | null | undefined) => value == null ? '—' : String(Number(value.toFixed(3)))
|
||||
const incomingStatusLabel = computed(() => {
|
||||
const status = preview.value?.incoming?.status
|
||||
if (status === 'READY_FOR_DISPATCH' && props.booking?.status === 'BOUND') return '已确认实收,已排入本车'
|
||||
return statuses[status || ''] || '等待读取'
|
||||
})
|
||||
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实收托盘', minWidth: 180 }, { key: 'packages', prop: 'packages', label: '箱数', width: 95 },
|
||||
{ key: 'weightKg', prop: 'weightKg', label: '实测重量(kg)', minWidth: 150 }, { key: 'volumeCbm', prop: 'volumeCbm', label: '实测体积(m³)', minWidth: 150 },
|
||||
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 170 }] : [])])
|
||||
...(props.mode === 'receipt' ? [{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 180, singleLine: false, showOverflowTooltip: false }] : [])])
|
||||
async function load() {
|
||||
if (!props.booking) return
|
||||
const current = generation; loading.value = true; error.value = null
|
||||
@@ -77,7 +98,7 @@ async function save() {
|
||||
const value = props.pool ? await commandInquiryFulfillment(booking.bookingNo, mode === 'incoming' ? 'incoming' : mode === 'receipt' ? 'receipt-confirmation' : 'arrange-dispatch', mode === 'bind' ? { tripRecordVersion: (body as TripBookingBind).tripRecordVersion, recordVersion: (body as TripBookingBind).recordVersion, idempotencyKey: (body as TripBookingBind).idempotencyKey } : body) : mode === 'incoming' ? await startTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingStart)
|
||||
: mode === 'bind' ? await bindTripBookingDispatch(props.trip.tripNo, booking.bookingNo, body as TripBookingBind)
|
||||
: await acceptTripIncoming(props.trip.tripNo, booking.bookingNo, body as TripIncomingAccept)
|
||||
if (current === generation) { pending.value = null; guard.capture(); emit('saved', value); open.value = false; ElMessage.success('外接履约记录已更新') }
|
||||
if (current === generation) { pending.value = null; guard.capture(); emit('saved', value); open.value = false; ElMessage.success(value.booking.status === 'BOUND' ? '已排入本车,尚未装车或发车' : value.booking.status === 'RECEIVED' ? '实收货量及约定运费已核实' : value.booking.incomingNo ? '已安排来货,等待仓库实际收货' : '履约命令已完成,请查看最新进度') }
|
||||
} catch (cause) { if (current === generation) { error.value = getApiErrorDetails(cause, '外接履约操作未完成'); if (cause instanceof ApiResponseError && /^A04/.test(cause.code)) pending.value = null } }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
@@ -94,8 +115,15 @@ onBeforeUnmount(() => { generation++ })
|
||||
<template v-if="booking.cargo.destinationKey.startsWith('PRIVATE:')"><el-form-item label="收件地址"><el-input v-model="form.privateAddress" placeholder="填写约定卸货地址" /></el-form-item><el-form-item label="邮编"><el-input v-model="form.postalCode" placeholder="填写收件邮编" /></el-form-item><el-form-item label="收件人"><el-input v-model="form.recipientName" placeholder="填写收件人" /></el-form-item><el-form-item label="收件电话"><el-input v-model="form.recipientPhone" placeholder="填写收件电话" /></el-form-item></template>
|
||||
</el-form>
|
||||
<template v-if="mode === 'receipt' || mode === 'view'">
|
||||
<el-button :loading="loading" :disabled="busy || !!pending" @click="load">刷新收货进度</el-button><p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ statuses[preview?.incoming?.status || ''] || '等待读取' }}</p>
|
||||
<DataTable :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></template></DataTable>
|
||||
<el-button :loading="loading" :disabled="busy || !!pending" @click="load">刷新收货进度</el-button><p>来货单:{{ preview?.incoming?.incomingNo || '—' }} · {{ incomingStatusLabel }}</p>
|
||||
<DataTable class="receipt-pallet-table" :data="preview?.incoming?.pallets || []" :columns="columns" row-key="warehousePalletId" empty-text="仓库尚未完成实际打托"><template #spaces="{ row }"><div class="receipt-spaces-control"><el-input-number v-model="spaces[row.warehousePalletId]" :min="1" :precision="0" :disabled="busy || !!pending" placeholder="实际占用托位" /></div></template></DataTable>
|
||||
<section v-if="mode === 'receipt'" aria-label="预报与实收对照">
|
||||
<p>预报与实收对照;差异仅供核对,容量与费用仍需确认。</p>
|
||||
<p v-if="booking.status !== 'CONFIRMED'">本票已核量,当前记录无法还原原始预报,预报及差异显示“—”。</p>
|
||||
<table class="density-table"><thead><tr><th>项目</th><th>承运预报</th><th>当前实收 / 核对值</th><th>差异(实收减预报)</th></tr></thead>
|
||||
<tbody><tr v-for="item in receiptComparison" :key="item.label"><td>{{ item.label }}</td><td>{{ quantityText(item.expected) }}</td><td>{{ quantityText(item.actual) }}</td><td>{{ item.difference == null ? '—' : item.difference > 0 ? `+${quantityText(item.difference)}` : quantityText(item.difference) }}</td></tr></tbody>
|
||||
</table>
|
||||
</section>
|
||||
<el-form v-if="mode === 'receipt'" label-position="top" class="density-form incoming-form" :disabled="busy || !!pending"><el-form-item :label="booking.cargo.priceBasis === 'ORDER' ? '确认整单价格' : '确认每托位价格'"><el-input-number v-model="unitPrice" :min="0" :precision="2" placeholder="核对约定价格" /></el-form-item></el-form>
|
||||
<p v-if="mode === 'receipt'">原约定 {{ booking.agreedAmount?.toFixed(2) }} {{ booking.cargo.currency }};本次实收 {{ preview?.incoming?.receivedPackages || 0 }} 箱、{{ totalSpaces }} 托位,确认金额 {{ amount?.toFixed(2) || '—' }} {{ booking.cargo.currency }}。转派送时按本次确认报价登记应收。</p>
|
||||
</template>
|
||||
@@ -103,4 +131,4 @@ onBeforeUnmount(() => { generation++ })
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
<style scoped>.incoming-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.incoming-form :deep(.el-date-editor),.incoming-form :deep(.el-input-number){width:100%}</style>
|
||||
<style scoped>.incoming-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--admin-space-4) var(--admin-space-6)}.incoming-form :deep(.el-date-editor),.incoming-form :deep(.el-input-number){width:100%}.receipt-spaces-control{display:flex;width:100%;min-width:0}.receipt-spaces-control :deep(.el-input-number){width:100%;min-width:0;max-width:100%}</style>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ElInputNumber } from 'element-plus/es/components/input-number/index'
|
||||
import { ElInput } from 'element-plus/es/components/input/index'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import TripBookingPanel from './TripBookingPanel.vue'
|
||||
@@ -45,7 +47,6 @@ it('confirms using both current versions and updates the parent version without
|
||||
expect(wrapper.findAll('button').some(node => node.text() === '确认承运')).toBe(false)
|
||||
})
|
||||
it('reopens saved negotiated price in a fresh numeric control while retaining its separate quote source', async () => {
|
||||
const { ElInputNumber, ElInput } = await vi.importActual<typeof import('element-plus')>('element-plus')
|
||||
const initial = { ...row, cargo: { ...row.cargo, unitPrice: 16 }, quoteSource: { ...source, quotedUnitPrice: 16 } }
|
||||
const saved = { ...initial, cargo: { ...initial.cargo, unitPrice: 9 }, agreedAmount: 18, recordVersion: 1 }
|
||||
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [initial] })
|
||||
|
||||
@@ -15,10 +15,14 @@ const info = ref<TripLoading | TripCapacityPlanning | null>(null), rows = ref<Pa
|
||||
let sequence = 0
|
||||
const baseline = ref('')
|
||||
watch(rows, value => emit('changed', JSON.stringify(value)), { deep: true })
|
||||
const columns = computed<DataTableColumn[]>(() => [{ key: 'palletNo', prop: 'palletNo', label: '实物托盘', minWidth: 150 },
|
||||
{ key: 'destinations', prop: 'destinations', label: '货物目的地', minWidth: 160 }, { key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 175 },
|
||||
{ key: 'weight', label: props.planning ? '预计重量(kg)' : '实测重量(kg)', slotName: 'weight', width: 190 }, { key: 'volume', label: props.planning ? '预计体积(m³)' : '实测体积(m³)', slotName: 'volume', width: 190 },
|
||||
{ key: 'stop', label: '卸货站', slotName: 'stop', minWidth: 200 }])
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'palletNo', label: '实物托盘', slotName: 'palletNo', minWidth: 210, singleLine: false },
|
||||
{ key: 'destinations', prop: 'destinations', label: '货物目的地', minWidth: 140, singleLine: false },
|
||||
{ key: 'spaces', label: '占用标准托位', slotName: 'spaces', width: 160, singleLine: false },
|
||||
{ key: 'weight', label: props.planning ? '预计重量(kg)' : '实测重量(kg)', slotName: 'weight', width: 180, singleLine: false },
|
||||
{ key: 'volume', label: props.planning ? '预计体积(m³)' : '实测体积(m³)', slotName: 'volume', width: 180, singleLine: false },
|
||||
{ key: 'stop', label: '卸货站', slotName: 'stop', minWidth: 160, singleLine: false },
|
||||
])
|
||||
const savedLoad = computed(() => info.value && ('capacityPlan' in info.value ? info.value.capacityPlan : info.value.confirmedLoad))
|
||||
const segments = computed(() => info.value && 'availableSegments' in info.value ? info.value.availableSegments : savedLoad.value?.segments || [])
|
||||
const stalePlan = computed(() => info.value && 'planValid' in info.value && !!info.value.capacityPlan && !info.value.planValid)
|
||||
@@ -91,7 +95,8 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<p v-if="info?.blockReason" role="status">{{ info.blockReason }}</p>
|
||||
<p v-else-if="!planning && savedLoad">已确认实际装载;车辆、司机或计划调整后需要重新确认。</p>
|
||||
<p v-else-if="!planning">按实物托盘核对,同一托盘中的多客户货物只计一次。发车仍由海外 PDA 完成。</p>
|
||||
<DataTable :data="rows" :columns="columns" row-key="warehousePalletId" empty-text="关联排车单后显示实物托盘">
|
||||
<DataTable class="trip-loading-table" :data="rows" :columns="columns" row-key="warehousePalletId" empty-text="关联排车单后显示实物托盘">
|
||||
<template #palletNo="{ row }"><span class="trip-pallet-code">{{ row.palletNo }}</span></template>
|
||||
<template #spaces="{ row }"><el-input-number v-model="row.palletSpaces" :min="1" :precision="0" :disabled="!editable || busy" placeholder="占用托位" /></template>
|
||||
<template #weight="{ row }"><el-input-number v-model="row.weightKg" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计重量' : '实际重量'" /></template>
|
||||
<template #volume="{ row }"><el-input-number v-model="row.volumeCbm" :min="0.001" :precision="3" :disabled="!editable || busy" :placeholder="planning ? '预计体积' : '实际体积'" /></template>
|
||||
@@ -101,3 +106,8 @@ onBeforeUnmount(() => { sequence++ })
|
||||
<table v-if="savedLoad && !stalePlan" class="density-table"><thead><tr><th>行驶区间</th><th>占用托位</th><th>重量(kg)</th><th>体积(m³)</th><th v-if="planning">剩余托位 / kg / m³</th></tr></thead><tbody><tr v-for="segment in segments" :key="segment.fromIndex"><td>{{ trip.stops[segment.fromIndex]?.name }} → {{ trip.stops[segment.toIndex]?.name }}</td><td>{{ segment.occupied.palletSpaces }} / {{ trip.maxPalletSpaces }}</td><td>{{ segment.occupied.weightKg }} / {{ trip.maxLoadKg }}</td><td>{{ segment.occupied.volumeCbm }} / {{ trip.maxVolumeCbm }}</td><td v-if="planning">{{ segment.remaining.palletSpaces }} / {{ segment.remaining.weightKg }} / {{ segment.remaining.volumeCbm }}</td></tr></tbody></table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trip-pallet-code{white-space:normal;overflow-wrap:anywhere}
|
||||
.trip-loading-table :deep(.el-input-number),.trip-loading-table :deep(.el-select){width:100%;min-width:0;max-width:100%}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { overseasMasterDataRoutes } from './routes'
|
||||
export { inquiryReturnTarget } from './inquiryNavigation'
|
||||
export { overseasMasterDataPermissions } from './permissions'
|
||||
export type * from './types'
|
||||
export { default as TransportRouteDialog } from './components/TransportRouteDialog.vue'
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { inquiryReturnTarget, inquirySourceQuery, inquiryOrderTarget } from './inquiryNavigation'
|
||||
import type { CapacityInquiry } from './capacityOfferTypes'
|
||||
|
||||
it('returns only to a fixed internal inquiry route and rejects malformed or repeated values', () => {
|
||||
expect(inquiryReturnTarget(inquirySourceQuery({ offerNo: 'FO-1', bookingNo: 'FB-2' }))).toEqual({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } })
|
||||
for (const value of ['https://example.com', '../FB-2', ['FB-2'], '', null]) expect(inquiryReturnTarget({ fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: value })).toBeNull()
|
||||
expect(inquiryReturnTarget({ returnTo: 'https://example.com' })).toBeNull()
|
||||
})
|
||||
it.each(['US_TRUCK', 'LOCAL_LOOSEBOARD'] as const)('opens %s using only its list permission and the business number', businessType => {
|
||||
const row = { businessType, businessOrderNo: 'ORDER-1', offerNo: 'FO-1', bookingNo: 'FB-2' } as CapacityInquiry
|
||||
const domain = businessType === 'US_TRUCK' ? 'truck' : 'looseboard'
|
||||
expect(inquiryOrderTarget(row, permission => permission === `platform:overseas-business:${domain}:list`)).toMatchObject({ query: { orderNo: 'ORDER-1', fleetBookingNo: 'FB-2' } })
|
||||
expect(inquiryOrderTarget(row, permission => permission.endsWith(':edit'))).toBeNull()
|
||||
expect(inquiryOrderTarget({ ...row, businessOrderNo: null }, () => true)).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LocationQuery, RouteLocationRaw } from 'vue-router'
|
||||
import type { CapacityInquiry } from './capacityOfferTypes'
|
||||
|
||||
const businessNo = (value: unknown): value is string => typeof value === 'string' && /^[A-Z][A-Z0-9-]{1,63}$/.test(value)
|
||||
export function inquirySourceQuery(row: Pick<CapacityInquiry, 'offerNo' | 'bookingNo'>) {
|
||||
return { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo }
|
||||
}
|
||||
export function inquiryReturnTarget(query: LocationQuery = {}): RouteLocationRaw | null {
|
||||
if (query.fleetSource !== 'inquiry' || !businessNo(query.fleetOfferNo) || !businessNo(query.fleetBookingNo)) return null
|
||||
return { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: query.fleetOfferNo }, query: { inquiry: query.fleetBookingNo } }
|
||||
}
|
||||
export function inquiryOrderTarget(row: CapacityInquiry, can: (permission: string) => boolean): RouteLocationRaw | null {
|
||||
const domain = row.businessType === 'US_TRUCK' ? 'truck' : 'looseboard'
|
||||
if (!row.businessOrderNo || !can(`platform:overseas-business:${domain}:list`)) return null
|
||||
return { name: domain === 'truck' ? 'overseas-business-trucks' : 'overseas-business-looseboards', query: { orderNo: row.businessOrderNo, ...inquirySourceQuery(row) } }
|
||||
}
|
||||
@@ -14,16 +14,30 @@ import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment
|
||||
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
|
||||
import { getCapacityOffer, getCapacityInquiry, listCapacityInquiries } from '../api/capacityOffers'
|
||||
import { offerStatusLabels, offerTime, loadText, type CapacityOfferDetail, type CapacityInquiry, type InquiryResult } from '../capacityOfferTypes'
|
||||
import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
|
||||
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
|
||||
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||
const detail = ref<CapacityOfferDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), inquiryError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1), size = ref(20), inquiryLoading = ref(false), editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), readonly = ref(false)
|
||||
const confirmMode = ref(false)
|
||||
const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
|
||||
async function fulfillmentSaved() {
|
||||
const row = fulfillmentRow.value
|
||||
fulfillmentOpen.value = false
|
||||
if (!row) return
|
||||
const current = ++followupSequence
|
||||
await load()
|
||||
try {
|
||||
const updated = await getCapacityInquiry(row.bookingNo)
|
||||
if (current === followupSequence) await action('view', updated)
|
||||
} catch (cause) { if (current === followupSequence) error.value = getApiErrorDetails(cause, '操作已完成,下一步进度读取失败,请刷新原登记') }
|
||||
}
|
||||
let followupSequence = 0
|
||||
function openFulfillment(key: string, row: CapacityInquiry) {
|
||||
if (key === 'business-order') { const target = inquiryOrderTarget(row, access.can); if (target) void router.push(target); return true }
|
||||
if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
|
||||
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true }
|
||||
if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } }); return true }
|
||||
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo }, query: inquirySourceQuery(row) }); return true }
|
||||
if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: inquirySourceQuery(row) }); return true }
|
||||
if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
|
||||
editorOpen.value = false
|
||||
fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true
|
||||
@@ -38,7 +52,11 @@ async function load() {
|
||||
try {
|
||||
const value = await getCapacityOffer(String(route.params.offerNo))
|
||||
let checked: CapacityInquiry | null = null
|
||||
if (editing.value && editorOpen.value) checked = value.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo)
|
||||
if (typeof route.query?.inquiry === 'string' && /^[A-Z][A-Z0-9-]{1,63}$/.test(route.query.inquiry)) {
|
||||
const returned = await getCapacityInquiry(route.query.inquiry)
|
||||
if (returned.offerNo !== value.offer.offerNo) throw new Error('登记不属于当前发布')
|
||||
if (current === sequence) { checked = returned; readonly.value = true; confirmMode.value = false; editorOpen.value = true }
|
||||
} else if (editing.value && editorOpen.value) checked = value.inquiries.records.find(row => row.bookingNo === editing.value?.bookingNo) || await getCapacityInquiry(editing.value.bookingNo)
|
||||
if (current === sequence) { resetting = true; detail.value = value; if (checked) editing.value = checked; page.value = value.inquiries.page; size.value = value.inquiries.size; queueMicrotask(() => { resetting = false }) }
|
||||
}
|
||||
catch (cause) { if (current === sequence) { detail.value = null; error.value = getApiErrorDetails(cause, '发布详情读取失败') } }
|
||||
@@ -61,14 +79,14 @@ function accept(value: InquiryResult) {
|
||||
for (const row of rows) { row.offerRecordVersion = value.offer.recordVersion; row.tripRecordVersion = value.offer.tripRecordVersion }
|
||||
}
|
||||
const commands = useCapacityInquiryCommands('capacity-offer-command', accept)
|
||||
async function action(key: string, row: CapacityInquiry) { if (openFulfillment(key, row)) return; if (key === 'cancel') await commands.run(row, key); else { if (key === 'confirm' && !access.can('platform:fleet:capacity-offer:manage')) return; confirmMode.value = key === 'confirm'; editing.value = row; readonly.value = key !== 'edit' && key !== 'confirm'; editorOpen.value = true } }
|
||||
async function action(key: string, row: CapacityInquiry) { followupSequence++; if (openFulfillment(key, row)) return; if (key === 'cancel') await commands.run(row, key); else { if (key === 'confirm' && !access.can('platform:fleet:capacity-offer:manage')) return; confirmMode.value = key === 'confirm'; editing.value = row; readonly.value = key !== 'edit' && key !== 'confirm'; editorOpen.value = true } }
|
||||
function create() { if (!canCreate.value) return; confirmMode.value = false; editing.value = null; readonly.value = false; editorOpen.value = true }
|
||||
watch(() => [route.name, route.params.offerNo], ([name, offerNo]) => {
|
||||
watch(() => [route.name, route.params.offerNo, route.query?.inquiry], ([name, offerNo]) => {
|
||||
if (name !== 'overseas-master-data-capacity-offer-detail' || typeof offerNo !== 'string' || !offerNo) return
|
||||
detail.value = null; editorOpen.value = false; void load()
|
||||
followupSequence++; detail.value = null; editorOpen.value = false; void load()
|
||||
}, { immediate: true })
|
||||
watch([page, size], loadInquiries)
|
||||
onBeforeUnmount(() => { sequence++; inquirySequence++ })
|
||||
onBeforeUnmount(() => { followupSequence++; sequence++; inquirySequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="运力发布详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-capacity-offers' })">返回拼车接货</el-button><h1 class="density-detail-title">{{ detail?.offer.offerNo || '运力发布详情' }}</h1><span v-if="detail">{{ offerStatusLabels[detail.offer.status] }}</span></div><div class="density-actions"><el-button :loading="loading" @click="load">重新核对</el-button><el-button v-if="hasCreatePermission" type="primary" :disabled="loading || !canCreate" @click="create">登记客户货物</el-button></div></div></template>
|
||||
@@ -82,7 +100,7 @@ onBeforeUnmount(() => { sequence++; inquirySequence++ })
|
||||
<LoadErrorState v-if="inquiryError || commands.error.value" title="意向操作未完成" :message="(inquiryError || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : loadInquiries()"><template #details><ApiErrorMeta :details="(inquiryError || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button>
|
||||
<CapacityInquiryTable v-model:page="page" v-model:size="size" :rows="detail.inquiries.records" :total="detail.inquiries.total" :loading="inquiryLoading" :failed="!!inquiryError" :busy="commands.busy.value" @action="action" />
|
||||
</template></div>
|
||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="load" />
|
||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="detail?.offer || null" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="action" @saved="accept" @refresh="load" />
|
||||
</DetailPageLayout></AdminPage></template>
|
||||
<style scoped>.capacity-section-title{font-size:var(--admin-font-section);margin:var(--admin-space-6) 0 var(--admin-space-3)}</style>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import CapacityInquiryTable from '../components/CapacityInquiryTable.vue'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
@@ -10,7 +11,7 @@ import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||
import * as api from '../api/capacityOffers'
|
||||
import type { CapacityOffer, CapacityInquiry } from '../capacityOfferTypes'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, links: new Set<string>(), route: { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'CO-1' } }, push: vi.fn() }))
|
||||
const state = vi.hoisted(() => ({ manage: false, write: true, quote: false, links: new Set<string>(), route: { query: {} as Record<string, string>, name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'CO-1' } }, push: vi.fn() }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => state.route, useRouter: () => ({ push: state.push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => state.links.has(p) || (p === 'platform:fleet:capacity-offer:manage' ? state.manage : p === 'platform:fleet:quote:list' ? state.quote : p === 'platform:fleet:capacity-offer:list' || p.includes(':inquiry-') && state.write) }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
|
||||
@@ -35,8 +36,29 @@ function mount(component: object, props: Record<string, unknown> = {}): { wrappe
|
||||
} } }); wrappers.push(wrapper); return { wrapper, pinia }
|
||||
}
|
||||
const button = (w: VueWrapper, label: string) => w.findAll('button').find(b => b.text() === label)!
|
||||
beforeEach(() => { vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; state.links.clear(); vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) })
|
||||
beforeEach(() => { state.route.query = {}; vi.clearAllMocks(); state.manage = false; state.write = true; state.quote = false; state.links.clear(); vi.mocked(ElMessageBox.confirm).mockImplementation(vi.fn().mockResolvedValue(undefined)); vi.mocked(api.listCapacityOffers).mockResolvedValue(pageOf([structuredClone(offer)], 41)); vi.mocked(api.listCapacityInquiries).mockResolvedValue(pageOf([structuredClone(inquiry)])); vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer: structuredClone(offer), inquiries: pageOf([structuredClone(inquiry)]) }) })
|
||||
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
|
||||
it.each(['DRAFT', 'CONFIRMED', 'RECEIVED', 'BOUND', 'CANCELLED'] as const)('labels %s cargo consistently in the list and detail without treating estimated pallets as actual', async status => {
|
||||
const row: CapacityInquiry = { ...inquiry, status, expectedPalletCount: 7, cargo: { ...inquiry.cargo, packageCount: 12, palletSpaces: 3, weightKg: 80, volumeCbm: 2 } }
|
||||
const label = status === 'RECEIVED' || status === 'BOUND' ? '已核实' : status === 'CANCELLED' ? '记录' : '预报'
|
||||
const list = shallowMount(CapacityInquiryTable, { props: { rows: [row], total: 1, loading: false }, global: { stubs: {
|
||||
DataTable: { props: ['data', 'columns'], template: '<div><slot name="cargo" :row="data[0]"/></div>' },
|
||||
} } })
|
||||
wrappers.push(list)
|
||||
expect(list.text()).toContain(`${label}:12 箱 · 3 托位 · 80 kg · 2 m³`)
|
||||
expect(list.text()).toContain('预计实托数:7')
|
||||
expect(list.text()).not.toContain('7 实托')
|
||||
await list.setProps({ rows: [{ ...row, expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } }] })
|
||||
expect(list.text()).toContain(`${label}:— 箱`)
|
||||
expect(list.text()).toContain('预计实托数:—')
|
||||
const { wrapper: detail } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: true })
|
||||
await flushPromises()
|
||||
for (const suffix of ['箱数', '标准托位', '重量(kg)', '体积(m³)']) expect(detail.find(`[label="${label}${suffix}"]`).exists()).toBe(true)
|
||||
expect(detail.findAll('summary').map(item => item.text())).toContain(label === '已核实' ? '已核实货量与预计实托数' : `${label}货量`)
|
||||
expect(detail.find('[label="预计实托数"]').exists()).toBe(true)
|
||||
expect(detail.find('[label="已核实实托数"]').exists()).toBe(false)
|
||||
expect(api.saveCapacityInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
it('loads only the active tab and sends exactly one query after resetting from a later page', async () => {
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||
expect(api.listCapacityOffers).toHaveBeenCalledOnce(); expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||
@@ -173,9 +195,9 @@ it.each([CapacityOffersView, CapacityOfferDetailView])('opens the bound trip sep
|
||||
const { wrapper } = mount(component); await flushPromises()
|
||||
const dialog = wrapper.findComponent(CapacityInquiryDialog)
|
||||
dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
|
||||
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'DS-BOUND' } })
|
||||
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'DS-BOUND' }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
|
||||
dialog.vm.$emit('action', 'trip', row); await flushPromises()
|
||||
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } })
|
||||
expect(state.push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: { fleetSource: 'inquiry', fleetOfferNo: row.offerNo, fleetBookingNo: row.bookingNo } })
|
||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
||||
state.links.clear(); state.push.mockClear()
|
||||
dialog.vm.$emit('action', 'trip', row); dialog.vm.$emit('action', 'dispatch', row); await flushPromises()
|
||||
@@ -223,3 +245,102 @@ it('highlights only the allowed current step and keeps other actions secondary',
|
||||
await wrapper.setProps({ inquiry: { ...inquiry, progress: { ...inquiry.progress, primaryAction: 'INCOMING' } } })
|
||||
expect(section.findAll('button').filter(item => !item.element.closest('details')).map(item => item.text())).toEqual(['安排来货'])
|
||||
})
|
||||
|
||||
it('shows the selected destination capacity when confirming and never treats unknown capacity as zero', async () => {
|
||||
state.manage = true
|
||||
const knownOffer = { ...offer, capacityKnown: true, destinations: [
|
||||
{ ...offer.destinations[0]!, remaining: { palletSpaces: 0, weightKg: 12, volumeCbm: 3 } },
|
||||
{ destinationKey: 'FBA:ACY2', name: 'ACY2', deliveryIndex: 2, remaining: { palletSpaces: 7, weightKg: 800, volumeCbm: 20 } },
|
||||
] }
|
||||
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer: knownOffer, inquiry, readonly: false, confirmMode: true })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[aria-label="本目的站可接余位"]').text()).toContain('0 托位 · 12 kg · 3 m³')
|
||||
wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'FBA:ACY2'); await flushPromises()
|
||||
expect(wrapper.get('[aria-label="本目的站可接余位"]').text()).toContain('7 托位 · 800 kg · 20 m³')
|
||||
await wrapper.setProps({ offer: { ...knownOffer, capacityKnown: false } })
|
||||
expect(wrapper.get('[aria-label="本目的站可接余位"]').text()).toContain('待调度核对')
|
||||
expect(wrapper.get('[aria-label="本目的站可接余位"]').text()).not.toContain('800')
|
||||
expect(api.saveAndConfirmInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
it('prioritizes the allowed current list action and keeps other actions in the overflow menu', async () => {
|
||||
state.manage = true
|
||||
const wrapper = shallowMount(CapacityInquiryTable, { props: { rows: [inquiry], total: 1, loading: false }, global: { directives: { loading: () => undefined }, stubs: {
|
||||
DataTable: { props: ['data'], template: '<div><slot name="actions" :row="data[0]"/></div>' },
|
||||
RowActions: { name: 'RowActions', props: ['actions', 'maxVisible'], template: '<div/>' },
|
||||
} } })
|
||||
wrappers.push(wrapper)
|
||||
const actions = () => wrapper.findComponent({ name: 'RowActions' }).props('actions') as { key: string; visible?: boolean; priority?: number }[]
|
||||
const primary = () => actions().filter(item => item.visible !== false).sort((a,b) => (b.priority || 0) - (a.priority || 0))[0]?.key
|
||||
expect(wrapper.findComponent({ name: 'RowActions' }).props('maxVisible')).toBe(1)
|
||||
expect(primary()).toBe('edit')
|
||||
await wrapper.setProps({ rows: [{ ...inquiry, progress: { ...inquiry.progress, primaryAction: 'INCOMING' }, actions: { ...inquiry.actions, canEdit: false, canConfirm: false } }] })
|
||||
expect(primary()).toBe('view')
|
||||
state.links.add('platform:fleet:trip:booking-edit')
|
||||
await wrapper.setProps({ rows: [{ ...inquiry, progress: { ...inquiry.progress, primaryAction: 'INCOMING' } }] })
|
||||
expect(primary()).toBe('incoming')
|
||||
})
|
||||
|
||||
it('shows the business identity and exposes the order link only with its list permission', async () => {
|
||||
const row = { ...inquiry, businessOrderNo: 'ORDER-1' }
|
||||
const { wrapper } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: true })
|
||||
await flushPromises()
|
||||
const summary = wrapper.get('[aria-label="本票处理进度"]').text()
|
||||
expect(summary).toContain(row.tripNo); expect(summary).toContain(row.bookingNo)
|
||||
expect(summary).toContain(row.customerName); expect(summary).toContain(row.destinationLabel)
|
||||
expect(button(wrapper, 'ORDER-1')).toBeUndefined()
|
||||
state.links.add('platform:overseas-business:truck:list')
|
||||
await wrapper.setProps({ inquiry: { ...row } }); await flushPromises()
|
||||
await button(wrapper, 'ORDER-1').trigger('click')
|
||||
expect(wrapper.emitted('action')?.[0]).toEqual(['business-order', row])
|
||||
})
|
||||
it('reopens a returned inquiry through an authorized fetch and refuses a different offer', async () => {
|
||||
state.route.query = { inquiry: inquiry.bookingNo }
|
||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue(inquiry)
|
||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
|
||||
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
|
||||
expect(editor.props('inquiry').bookingNo).toBe(inquiry.bookingNo)
|
||||
wrapper.unmount()
|
||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, offerNo: 'FO-OTHER' })
|
||||
const rejected = mount(CapacityOfferDetailView).wrapper; await flushPromises()
|
||||
expect(rejected.findComponent({ name: 'CapacityInquiryDialog' }).props('modelValue')).toBe(false)
|
||||
expect(rejected.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
|
||||
})
|
||||
it('opens refreshed server guidance after fulfillment succeeds without replaying a write', async () => {
|
||||
state.manage = true
|
||||
const { wrapper } = mount(CapacityOffersView); await flushPromises()
|
||||
const updated = { ...inquiry, status: 'BOUND' as const, progress: { ...inquiry.progress, primaryAction: 'VIEW', message: '服务端下一待办', owner: '审核 / 仓库' } }
|
||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue(updated)
|
||||
wrapper.findComponent({ name: 'CapacityInquiryTable' }).vm.$emit('action', 'bind', inquiry); await flushPromises()
|
||||
wrapper.findComponent({ name: 'CapacityInquiryFulfillment' }).vm.$emit('saved'); await flushPromises()
|
||||
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
|
||||
expect(editor.props('modelValue')).toBe(true); expect(editor.props('readonly')).toBe(true)
|
||||
expect(editor.props('inquiry').progress.message).toBe('服务端下一待办')
|
||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
it('fetches an off-page returned inquiry once per refresh and retries without falling back to stale data', async () => {
|
||||
state.route.query = { inquiry: inquiry.bookingNo }
|
||||
vi.mocked(api.getCapacityOffer).mockResolvedValue({ offer, inquiries: pageOf<CapacityInquiry>([], 30) })
|
||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue(inquiry)
|
||||
const { wrapper } = mount(CapacityOfferDetailView); await flushPromises()
|
||||
const editor = wrapper.findComponent({ name: 'CapacityInquiryDialog' })
|
||||
expect(editor.props('modelValue')).toBe(true)
|
||||
vi.mocked(api.getCapacityOffer).mockClear(); vi.mocked(api.getCapacityInquiry).mockClear()
|
||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, recordVersion: 7 })
|
||||
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
|
||||
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
|
||||
expect(api.getCapacityInquiry).toHaveBeenCalledExactlyOnceWith(inquiry.bookingNo)
|
||||
expect(editor.props('inquiry').recordVersion).toBe(7)
|
||||
vi.mocked(api.getCapacityInquiry).mockRejectedValueOnce(new Error('读取失败'))
|
||||
await button(wrapper, '重新核对').trigger('click'); await flushPromises()
|
||||
const error = wrapper.findComponent({ name: 'LoadErrorState' })
|
||||
expect(error.exists()).toBe(true)
|
||||
vi.mocked(api.getCapacityOffer).mockClear(); vi.mocked(api.getCapacityInquiry).mockClear()
|
||||
vi.mocked(api.getCapacityInquiry).mockResolvedValue({ ...inquiry, recordVersion: 8 })
|
||||
error.vm.$emit('retry'); await flushPromises()
|
||||
expect(api.getCapacityOffer).toHaveBeenCalledOnce()
|
||||
expect(api.getCapacityInquiry).toHaveBeenCalledExactlyOnceWith(inquiry.bookingNo)
|
||||
expect(editor.props('inquiry').recordVersion).toBe(8)
|
||||
expect(api.listCapacityInquiries).not.toHaveBeenCalled()
|
||||
expect(api.commandCapacityInquiry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import CapacityInquiryFulfillment from '../components/CapacityInquiryFulfillment
|
||||
import CapacityInquiryDialog from '../components/CapacityInquiryDialog.vue'
|
||||
import { listCapacityOffers, listCapacityInquiries, getCapacityOffer, getCapacityInquiry } from '../api/capacityOffers'
|
||||
import { offerStatusLabels, inquiryStatusLabels, offerTime, loadText, type CapacityOffer, type CapacityInquiry, type InquiryResult, type OfferQuery, type InquiryQuery } from '../capacityOfferTypes'
|
||||
import { inquirySourceQuery, inquiryOrderTarget } from '../inquiryNavigation'
|
||||
import { useCapacityInquiryCommands } from '../useCapacityInquiryCommands'
|
||||
const router = useRouter(), access = useAccessPreviewStore()
|
||||
const manage = computed(() => access.can('platform:fleet:capacity-offer:manage'))
|
||||
@@ -25,10 +26,23 @@ const offers = ref<CapacityOffer[]>([]), inquiries = ref<CapacityInquiry[]>([])
|
||||
const editorOpen = ref(false), editing = ref<CapacityInquiry | null>(null), selectedOffer = ref<CapacityOffer | null>(null), readonly = ref(true)
|
||||
const confirmMode = ref(false)
|
||||
const fulfillmentOpen = ref(false), fulfillmentRow = ref<CapacityInquiry | null>(null), fulfillmentMode = ref<'incoming' | 'receipt' | 'bind' | 'view'>('view')
|
||||
async function fulfillmentSaved() {
|
||||
const row = fulfillmentRow.value
|
||||
fulfillmentOpen.value = false
|
||||
if (!row) return
|
||||
const current = ++followupSequence
|
||||
await load()
|
||||
try {
|
||||
const updated = await getCapacityInquiry(row.bookingNo)
|
||||
if (current === followupSequence) await inquiryAction('view', updated)
|
||||
} catch (cause) { if (current === followupSequence) error.value = getApiErrorDetails(cause, '操作已完成,下一步进度读取失败,请刷新原登记') }
|
||||
}
|
||||
let followupSequence = 0
|
||||
function openFulfillment(key: string, row: CapacityInquiry) {
|
||||
if (key === 'business-order') { const target = inquiryOrderTarget(row, access.can); if (target) void router.push(target); return true }
|
||||
if (key === 'appointment' && access.can('platform:appointment:list') && access.can('platform:overseas-warehouse:last-mile-order:list')) { void router.push({ name: 'appointment-calendar', query: { sourceKey: `BOOKING:${row.bookingNo}`, customerId: row.customerId, destinationKey: row.cargo.destinationKey } }); return true }
|
||||
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }); return true }
|
||||
if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } }); return true }
|
||||
if (key === 'dispatch' && row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list')) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo }, query: inquirySourceQuery(row) }); return true }
|
||||
if (key === 'trip' && row.dispatchNo && access.can('platform:fleet:trip:list')) { void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo }, query: inquirySourceQuery(row) }); return true }
|
||||
if (!['incoming', 'receipt', 'bind', 'fulfillment'].includes(key)) return false
|
||||
editorOpen.value = false
|
||||
fulfillmentRow.value = row; fulfillmentMode.value = key === 'fulfillment' ? 'view' : key as 'incoming' | 'receipt' | 'bind'; fulfillmentOpen.value = true
|
||||
@@ -83,6 +97,7 @@ function accept(value: InquiryResult) {
|
||||
}
|
||||
const commands = useCapacityInquiryCommands('capacity-pool-command', accept)
|
||||
async function inquiryAction(key: string, row: CapacityInquiry) {
|
||||
followupSequence++
|
||||
if (openFulfillment(key, row)) return
|
||||
if (key === 'cancel') { await commands.run(row, key); return }
|
||||
if (key === 'confirm' && !manage.value) return
|
||||
@@ -103,7 +118,7 @@ async function recheckEditor() {
|
||||
if (current === editorSequence) { selectedOffer.value = detail.offer; editing.value = checked }
|
||||
} catch (cause) { if (current === editorSequence) error.value = getApiErrorDetails(cause, '最新意向核对失败') }
|
||||
}
|
||||
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++; editorSequence++ })
|
||||
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followupSequence++; sequence++; editorSequence++ })
|
||||
</script>
|
||||
<template><AdminPage class="admin-density"><ListPageLayout aria-label="拼车接货">
|
||||
<DataTable v-if="tab === 'offers'" appearance="integrated" :stripe="false" preference-key="fleet-capacity-offers" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="offers" :columns="columns" :loading="loading" :total="total" row-key="offerNo" :empty-text="Object.values(applied).some(Boolean) ? '当前筛选条件下暂无运力发布' : '暂无获准查看的运力发布'">
|
||||
@@ -133,6 +148,6 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { sequenc
|
||||
</template>
|
||||
<template #notices><LoadErrorState v-if="error || commands.error.value" title="意向操作未完成" :message="(error || commands.error.value)?.message" @retry="commands.state.pending ? commands.retry() : load()"><template #details><ApiErrorMeta :details="(error || commands.error.value)!" /></template></LoadErrorState><el-button v-if="commands.state.pending" :loading="commands.busy.value" @click="commands.retry">重试原操作</el-button></template>
|
||||
</CapacityInquiryTable>
|
||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="load" />
|
||||
<CapacityInquiryFulfillment v-model="fulfillmentOpen" :inquiry="fulfillmentRow" :mode="fulfillmentMode" @saved="fulfillmentSaved" />
|
||||
<CapacityInquiryDialog v-model="editorOpen" :offer="selectedOffer" :inquiry="editing" :readonly="readonly" :confirm-mode="confirmMode" @action="inquiryAction" @saved="accept" @refresh="recheckEditor" />
|
||||
</ListPageLayout></AdminPage></template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { inquiryReturnTarget } from '../inquiryNavigation'
|
||||
import TripDispatchSelector from '../components/TripDispatchSelector.vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -185,7 +186,7 @@ onBeforeUnmount(() => { generation++; inspectionSequence++; historySequence++ })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density"><DetailPageLayout class="density-detail-layout surface-panel" aria-label="车次详情">
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push({ name: 'overseas-master-data-trips' })">返回车次列表</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
|
||||
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="router.push(inquiryReturnTarget(route.query) || { name: 'overseas-master-data-trips' })">{{ inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回车次列表' }}</el-button><h1 class="density-detail-title">{{ detail?.trip.tripNo || '车次详情' }}</h1><span v-if="detail">{{ tripStatusLabels[detail.trip.status] }}</span></div><div class="density-actions"><el-button :disabled="!!busy" @click="load">刷新车次</el-button><el-button v-if="access.can('platform:fleet:capacity-offer:manage')" @click="offerSettingsOpen = true">发布拼车余位</el-button><el-button v-if="editable" :disabled="!!busy" @click="editorOpen = true">编辑计划</el-button><el-button v-if="planned && access.can(permission.tripCancel)" type="danger" plain :disabled="!!busy || !!detail?.dispatches.length" @click="cancel">取消车次</el-button></div></div></template>
|
||||
<LoadErrorState v-if="error" title="车次详情加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="operationError" title="操作未完成" :message="operationError.message" @retry="load"><template #details><ApiErrorMeta :details="operationError" /></template></LoadErrorState>
|
||||
<div v-loading="loading"><template v-if="detail">
|
||||
|
||||
@@ -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; 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 }
|
||||
|
||||
@@ -22,7 +22,8 @@ export function appointmentPayload(form: AppointmentForm): FbaAppointmentDetails
|
||||
startOffset: form.startOffset.trim() || null, endOffset: form.endOffset.trim() || null }
|
||||
}
|
||||
export function appointmentFormError(form: AppointmentForm): string | null {
|
||||
if (form.appointmentStatus === 'CANCELLED') return '请在预约列表使用取消预约操作,并填写实际取消原因'
|
||||
if (form.appointmentStatus === 'SUCCESS' && (!form.confirmationNo.trim() || !form.appointmentAt)) return '登记对方确认时,请填写外部约号与确认时间'
|
||||
if (['FAILED', 'CANCELLED'].includes(form.appointmentStatus) && !form.appointmentRemark.trim()) return '请填写取消或未约成的原因'
|
||||
if (form.appointmentStatus === 'FAILED' && !form.appointmentRemark.trim()) return '请填写未约成的原因'
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,25 +6,29 @@ defineProps<{ model: AppointmentForm; appointmentNo?: string; timezone?: string
|
||||
<p>{{ appointmentNo ? `系统单号:${appointmentNo}` : '系统单号将在保存后生成' }} · {{ timezone || '目的仓时区待核实' }}</p>
|
||||
<el-form label-position="top" class="appointment-fields density-form" :disabled="disabled">
|
||||
<el-form-item label="计划送仓日期"><el-date-picker v-model="model.plannedDate" type="date" value-format="YYYY-MM-DD" placeholder="日期未定可留空" /></el-form-item>
|
||||
<el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="appointmentNo" label="取消安排" value="CANCELLED" /></el-select></el-form-item>
|
||||
<el-form-item label="本次登记"><el-select v-model="model.appointmentStatus"><el-option label="先保存安排,等待对方确认" value="PENDING" /><el-option label="已取得对方正式确认" value="SUCCESS" /><el-option v-if="appointmentNo" label="未约成" value="FAILED" /><el-option v-if="disabled && model.appointmentStatus === 'CANCELLED'" label="已取消" value="CANCELLED" disabled /></el-select></el-form-item>
|
||||
<template v-if="model.appointmentStatus === 'SUCCESS'">
|
||||
<el-form-item label="外部约号" required><el-input v-model="model.confirmationNo" maxlength="128" placeholder="填写收货方提供的确认号" /></el-form-item>
|
||||
<el-form-item label="对方确认开始时间" required><el-date-picker v-model="model.appointmentAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="目的仓当地时间" @change="model.startOffset = ''" /></el-form-item>
|
||||
<el-form-item label="对方确认结束时间"><el-date-picker v-model="model.appointmentEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="未明确可留空" @change="model.endOffset = ''" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item :label="['FAILED','CANCELLED'].includes(model.appointmentStatus) ? '原因' : '备注'" :required="['FAILED','CANCELLED'].includes(model.appointmentStatus)"><el-input v-model="model.appointmentRemark" maxlength="1000" placeholder="有需交接的事项时填写" /></el-form-item>
|
||||
<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>
|
||||
|
||||
@@ -11,6 +11,7 @@ withDefaults(defineProps<{
|
||||
referenceLabel: string
|
||||
referenceValue: string
|
||||
backLabel: string
|
||||
backText?: string
|
||||
domain: string
|
||||
pending?: boolean
|
||||
statusText?: string
|
||||
@@ -23,13 +24,13 @@ defineEmits<{ back: [] }>()
|
||||
<AdminPage as="div" class="overseas-detail-page" :class="{ 'admin-density': dense }" :aria-label="title">
|
||||
<header v-if="dense" class="density-detail-header">
|
||||
<div class="overseas-detail-title density-detail-heading">
|
||||
<el-button link class="density-back" :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<el-button link class="density-back" :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />{{ backText || '返回列表' }}</el-button>
|
||||
<h1 class="density-detail-title"><slot name="reference">{{ referenceValue }}</slot></h1>
|
||||
<StatusBadge v-if="statusText" :status="statusText" />
|
||||
</div>
|
||||
</header>
|
||||
<header v-else class="overseas-detail-head">
|
||||
<el-button :class="{ 'density-back': dense }" :link="dense" :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />返回</el-button>
|
||||
<el-button :class="{ 'density-back': dense }" :link="dense" :aria-label="backLabel" @click="$emit('back')"><ArrowLeft :size="16" />{{ backText || '返回' }}</el-button>
|
||||
<div class="overseas-detail-title">
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ referenceLabel }} <strong><slot name="reference">{{ referenceValue }}</slot></strong></p>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,13 +5,15 @@ import { getDispatch } from '../api/dispatches'
|
||||
import DispatchDetailView from './DispatchDetailView.vue'
|
||||
import { DataTableStub } from './phase5TestStubs'
|
||||
|
||||
const sourceQuery = vi.hoisted(() => ({} as Record<string, string>))
|
||||
const routerPush = vi.hoisted(() => vi.fn())
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { dispatchNo: 'PC/1' } }), useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { dispatchNo: 'PC/1' }, query: sourceQuery }), useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn() }))
|
||||
|
||||
describe('DispatchDetailView', () => {
|
||||
beforeEach(() => {
|
||||
routerPush.mockReset()
|
||||
Object.keys(sourceQuery).forEach(key => delete sourceQuery[key])
|
||||
vi.mocked(getDispatch).mockReset().mockResolvedValue({
|
||||
id: '1', dispatchNo: 'PC/1', vehiclePlate: 'CA-100', driverName: '张师傅', status: 'PENDING_DEPARTURE',
|
||||
route: 'LAX → ONT8', items: [{ id: '11', planPalletId: '20', orderNo: 'LM-1', warehousePalletId: '21', fbaWarehouseCode: 'ONT8', grossWeightKg: 88.5, volumeCbm: 1.25 }],
|
||||
@@ -40,4 +42,15 @@ describe('DispatchDetailView', () => {
|
||||
expect(wrapper.findComponent(DataTableStub).props('data')).toEqual([expect.objectContaining({ weight: null })])
|
||||
wrapper.unmount()
|
||||
})
|
||||
it.each([false, true])('returns to the original inquiry only for a valid source: %s', async fromInquiry => {
|
||||
if (fromInquiry) Object.assign(sourceQuery, { fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' })
|
||||
const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
|
||||
await flushPromises()
|
||||
const backButton = wrapper.get('button.density-back')
|
||||
expect(backButton.text()).toBe(fromInquiry ? '返回原拼车登记' : '返回列表')
|
||||
await backButton.trigger('click')
|
||||
expect(routerPush).toHaveBeenCalledWith(fromInquiry ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } } : { name: 'overseas-dispatches' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { inquiryReturnTarget } from '@/modules/overseas-master-data'
|
||||
import { physicalWeightKg } from '../dispatchPhysicalWeight'
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
@@ -128,13 +129,13 @@ async function load() {
|
||||
catch (error) { loadError.value = getApiErrorDetails(error, '排车详情加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function back() { void router.push({ name: 'overseas-dispatches' }) }
|
||||
function back() { void router.push(inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" back-label="返回排车列表" domain="排车详情" :pending="false" :status-text="detail ? dispatchStatus(detail.status) : '加载中'" @back="back">
|
||||
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="inquiryReturnTarget(route.query) ? '返回原拼车登记' : undefined" :back-label="inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'" domain="排车详情" :pending="false" :status-text="detail ? dispatchStatus(detail.status) : '加载中'" @back="back">
|
||||
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
|
||||
<div class="summary-grid">
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, expect, it, vi } from 'vitest'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||
import InboundOrdersView from './InboundOrdersView.vue'
|
||||
import { getFbaAppointment, updateFbaAppointment } from '../api/inboundOrders'
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({push: vi.fn()}), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
const access=vi.hoisted(()=>({canWrite:true}))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({can: (permission:string) => !permission.endsWith(':fba-appointment') || access.canWrite}) }))
|
||||
vi.mock('../api/inboundOrders', () => ({
|
||||
listInboundOrders: vi.fn().mockResolvedValue({records:[{lineId:'1',planNo:'IP-1',customerId:'A',appointmentNo:'AP-1',fbaWarehouseCode:'ONT8'}],total:1}),
|
||||
getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn(), createFbaAppointment: vi.fn(), updateInboundPlan: vi.fn(),
|
||||
}))
|
||||
vi.mock('element-plus', () => ({ ElMessage: {warning:vi.fn(),success:vi.fn()} }))
|
||||
const render=()=>mount(InboundOrdersView,{global:{directives:{loading:{}},stubs:{
|
||||
AdminPage:{template:'<main><slot/></main>'},ListPageLayout:{template:'<div><slot/></div>'},
|
||||
DataTable:{props:['data'],template:'<div><slot v-for="row in data" name="actions" :row="row"/></div>'},
|
||||
AppDialog:{name:'AppDialog',props:['modelValue','beforeClose','showConfirm'],template:'<section 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></section>'},
|
||||
ElButton:{template:'<button><slot/></button>'}, FbaAppointmentFields:true,SmartDispatchDialog:true,QueryToolbar:true,OperationErrorToast:true,
|
||||
}}})
|
||||
beforeEach(()=>{vi.clearAllMocks();access.canWrite=true;vi.mocked(getFbaAppointment).mockResolvedValue({id:'1',appointmentNo:'AP-1',version:2,originWarehouseId:'100',fbaWarehouseCode:'ONT8',appointmentStatus:'PENDING',selectedCount:1,items:[]})})
|
||||
it('opens linked appointment details for list-only users and blocks saving',async()=>{
|
||||
access.canWrite=false
|
||||
const wrapper=render();await flushPromises()
|
||||
expect(wrapper.findAll('button').some(b=>b.text()==='预约')).toBe(false)
|
||||
await wrapper.findAll('button').find(b=>b.text()==='查看预约')!.trigger('click');await flushPromises()
|
||||
expect(getFbaAppointment).toHaveBeenCalledWith('AP-1')
|
||||
expect(wrapper.findComponent({name:'FbaAppointmentFields'}).props('disabled')).toBe(true)
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
wrapper.findComponent({name:'AppDialog'}).vm.$emit('confirm');await flushPromises()
|
||||
expect(updateFbaAppointment).not.toHaveBeenCalled();wrapper.unmount()
|
||||
})
|
||||
it('keeps the inbound appointment retry across close, reopen and route attempts', async () => {
|
||||
vi.mocked(updateFbaAppointment).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({} as never)
|
||||
const wrapper=render()
|
||||
await flushPromises()
|
||||
const open=()=>wrapper.findAll('button').find(b=>b.text()==='预约')!.trigger('click')
|
||||
await open();await flushPromises();await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
const original=vi.mocked(updateFbaAppointment).mock.calls[0]![1]
|
||||
await wrapper.get('[data-close]').trigger('click')
|
||||
const dialog=wrapper.findAllComponents({name:'AppDialog'})[0]!
|
||||
const done=vi.fn();dialog.props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(true)
|
||||
await open();await flushPromises();expect(getFbaAppointment).toHaveBeenCalledTimes(1)
|
||||
for(const hook of [onBeforeRouteLeave,onBeforeRouteUpdate]) expect((vi.mocked(hook).mock.calls[0]![0] as () => boolean)()).toBe(false)
|
||||
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||
expect(updateFbaAppointment).toHaveBeenNthCalledWith(2,'AP-1',original)
|
||||
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||
expect((vi.mocked(onBeforeRouteLeave).mock.calls[0]![0] as () => boolean)()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { CalendarCheck2, Truck } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -58,7 +58,9 @@ const appointmentVersion = ref(0)
|
||||
const appointmentForm = reactive(createAppointmentForm())
|
||||
const appointmentTimezone = ref<string | null>(null)
|
||||
const pendingAppointment = ref<{appointmentNo: string | null; version: number; originWarehouseId: string; fbaWarehouseCode: string; lineIds: string[]; details: ReturnType<typeof appointmentPayload>; idempotencyKey: string} | null>(null)
|
||||
const appointmentTitle = computed(() => appointmentMode.value === 'edit' ? '编辑预约' : '预约')
|
||||
const appointmentViewOnly = ref(false)
|
||||
const appointmentReadOnly = computed(() => appointmentViewOnly.value || !access.can(permission.inboundOrderAppointment))
|
||||
const appointmentTitle = computed(() => appointmentReadOnly.value ? '查看预约' : appointmentMode.value === 'edit' ? '编辑预约' : '预约')
|
||||
const planEditOpen = ref(false); const planEditLoading = ref(false); const planEditSaving = ref(false); const planEditTarget = ref<InboundOrderListItem | null>(null); const planEditForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', version: 0 }); const warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
|
||||
|
||||
function selectableRow(source: object) {
|
||||
@@ -127,6 +129,8 @@ function selectionError() {
|
||||
}
|
||||
|
||||
function openAppointment() {
|
||||
if (!access.can(permission.inboundOrderAppointment) || !canLeaveAppointment()) return
|
||||
appointmentViewOnly.value = false
|
||||
const error = selectionError()
|
||||
if (error) { ElMessage.warning(error); return }
|
||||
appointmentMode.value = 'create'; editingAppointmentNo.value = ''
|
||||
@@ -141,9 +145,11 @@ function openSmartDispatch() {
|
||||
smartDispatchOpen.value = true
|
||||
}
|
||||
|
||||
async function openAppointmentEdit(source: object) {
|
||||
async function openAppointmentEdit(source: object, readOnly = false) {
|
||||
if (!canLeaveAppointment()) return
|
||||
const row = source as InboundOrderListItem
|
||||
if (!row.appointmentNo || row.appointmentNo === '—') return
|
||||
appointmentViewOnly.value = readOnly
|
||||
appointmentMode.value = 'edit'; editingAppointmentNo.value = row.appointmentNo
|
||||
appointmentLoading.value = true; mutationError.value = null; appointmentOpen.value = true
|
||||
try {
|
||||
@@ -158,7 +164,16 @@ async function openAppointmentEdit(source: object) {
|
||||
} finally { appointmentLoading.value = false }
|
||||
}
|
||||
|
||||
function canLeaveAppointment() {
|
||||
if (saving.value || pendingAppointment.value) { ElMessage.warning('请先核实本次预约保存结果,再关闭或切换'); return false }
|
||||
return true
|
||||
}
|
||||
function closeAppointment() { if (canLeaveAppointment()) appointmentOpen.value = false }
|
||||
function beforeCloseAppointment(done: () => void) { if (canLeaveAppointment()) done() }
|
||||
onBeforeRouteLeave(canLeaveAppointment)
|
||||
onBeforeRouteUpdate(canLeaveAppointment)
|
||||
async function submitAppointment() {
|
||||
if (appointmentReadOnly.value || saving.value || appointmentLoading.value) return
|
||||
if (appointmentMode.value === 'create') { const error = selectionError(); if (error) { ElMessage.warning(error); return } }
|
||||
const validation = appointmentFormError(appointmentForm)
|
||||
if (!pendingAppointment.value && validation) { ElMessage.warning(validation); return }
|
||||
@@ -209,14 +224,14 @@ onMounted(() => { void load() })
|
||||
|
||||
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.inboundOrderAppointment)" plain :disabled="!selectedRows.length" @click="openAppointment"><CalendarCheck2 :size="16" />预约</el-button><el-button v-if="access.can(permission.inboundOrderSmartDispatch)" type="primary" @click="openSmartDispatch"><Truck :size="16" />智能排车</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" @click="openPlanEdit(row)">编辑</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button class="density-action" data-tone="neutral" size="small" @click="openDetail(row)">查看</el-button><el-button class="density-action" data-tone="neutral" v-if="access.can(permission.inboundOrderEdit) && ['待入库'].includes((row as InboundOrderListItem).status)" size="small" @click="openPlanEdit(row)">编辑</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderList)" size="small" @click="openAppointmentEdit(row, true)">查看预约</el-button><el-button class="density-action" data-tone="neutral" v-if="(row as InboundOrderListItem).appointmentNo !== '—' && access.can(permission.inboundOrderAppointment)" size="small" @click="openAppointmentEdit(row)">预约</el-button></div></template>
|
||||
<template #pagination-left>共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AppDialog class="admin-density-dialog" v-model="appointmentOpen" :title="appointmentTitle" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
|
||||
<AppDialog class="admin-density-dialog" :model-value="appointmentOpen" @update:model-value="closeAppointment" :before-close="beforeCloseAppointment" :title="appointmentTitle" :show-confirm="!appointmentReadOnly" width="min(1080px, calc(100vw - 48px))" :confirm-text="pendingAppointment ? '重试本次保存' : '保存预约安排'" :confirm-loading="saving" @confirm="submitAppointment">
|
||||
<div v-loading="appointmentLoading">
|
||||
<div class="appointment-summary">已选择 {{ appointmentSelectedCount }} 条,FBA仓库:{{ appointmentFbaWarehouse }}</div>
|
||||
<FbaAppointmentFields :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="saving || !!pendingAppointment" />
|
||||
<FbaAppointmentFields :model="appointmentForm" :appointment-no="editingAppointmentNo || undefined" :timezone="appointmentTimezone" :disabled="appointmentReadOnly || appointmentLoading || saving || !!pendingAppointment" />
|
||||
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,11 +10,12 @@ vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn().mockRes
|
||||
vi.mock('../api/outbounds', () => ({ listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }), listOneClickOutboundCandidates: vi.fn().mockResolvedValue([]), listReturnOutboundCandidates: vi.fn().mockResolvedValue([]), createReturnOutbound: api.create }))
|
||||
vi.mock('../api/returnInventory', () => ({ listReturnStock: api.stock, listReturnCartons: api.cartons, getReturnCarton: api.detail }))
|
||||
const sources = [
|
||||
{ cartonMark: 'BOX-A', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
|
||||
{ cartonMark: 'BOX-B', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
|
||||
{ cartonMark: 'LOOSE-A', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
|
||||
{ cartonMark: 'LOOSE-B', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
|
||||
{ cartonMark: 'PARCEL', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-B', quantity: 6, availableQuantity: 6 }] },
|
||||
{ cartonMark: 'PACKED', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 5, availableQuantity: 5 }] },
|
||||
{ cartonMark: 'MIXED', sourceUnitType: 'BOX', sourceReturnNo: 'RT-1', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 3 }, { skuCode: 'SKU-B', quantity: 2 }] },
|
||||
{ cartonMark: 'OTHER-ORDER', sourceUnitType: 'BOX', sourceReturnNo: 'RT-2', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 100 }] },
|
||||
{ cartonMark: 'OTHER-ORDER', sourceUnitType: 'PARCEL', sourceReturnNo: 'RT-2', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 100 }] },
|
||||
]
|
||||
let wrapper: VueWrapper
|
||||
beforeEach(() => {
|
||||
@@ -37,54 +38,34 @@ 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[]) {
|
||||
for (const mark of marks) right().findAllComponents(ElCheckbox).find(check => check.element.closest('tr')?.textContent?.includes(mark === 'PARCEL' ? '包裹散件(按 SKU 拣货)' : mark))!.vm.$emit('change', true)
|
||||
for (const mark of marks) {
|
||||
const rows = right().props('data') as Array<{ cartonMark: string }>
|
||||
right().findAllComponents(ElCheckbox)[rows.findIndex(row => row.cartonMark === mark)]!.vm.$emit('change', true)
|
||||
}
|
||||
await flushPromises()
|
||||
}
|
||||
describe('single SKU outbound source and piece quantity', () => {
|
||||
it('selects packed single-SKU boxes, scopes quantities to the return order and excludes mixed boxes', async () => {
|
||||
describe('physical return outbound', () => {
|
||||
it('submits whole multi-SKU parcels and boxes without a direct partial payload', async () => {
|
||||
await open('SKU-A')
|
||||
expect(right().props('data').map((row: { cartonMark: string }) => row.cartonMark)).toEqual(['BOX-A', 'BOX-B'])
|
||||
expect(wrapper.text()).not.toContain('OTHER-ORDER')
|
||||
await selectMarks(['BOX-A', 'BOX-B'])
|
||||
expect(wrapper.findComponent(ElInputNumber).props('modelValue')).toBe(10)
|
||||
await selectMarks(['LOOSE-A', 'PARCEL'])
|
||||
expect(wrapper.findAllComponents(ElInputNumber)).toHaveLength(0)
|
||||
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
|
||||
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ skuCode: 'SKU-A', quantity: 10, cartonMarks: ['BOX-A', 'BOX-B'] }))
|
||||
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('submits six pieces from two five-piece boxes', async () => {
|
||||
await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-B'])
|
||||
wrapper.findComponent(ElInputNumber).vm.$emit('update:modelValue', 6); await flushPromises()
|
||||
await wrapper.get('[data-test="submit"]').trigger('click'); await flushPromises()
|
||||
expect(api.create).toHaveBeenCalledWith('C1', expect.objectContaining({ quantity: 6 }))
|
||||
it('includes physical parcels while excluding other return orders', async () => {
|
||||
await open('SKU-A')
|
||||
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('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({ 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,10]])('clamps input %s to the allowed bound %s', async (quantity, expected) => {
|
||||
await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-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({quantity: expected}))
|
||||
})
|
||||
it.each([1.5])('blocks invalid quantity %s before creating an order', async quantity => {
|
||||
await open('SKU-A'); await selectMarks(['BOX-A', 'BOX-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,8 @@ 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() }))
|
||||
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() }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn().mockResolvedValue({ records: [] }) }))
|
||||
@@ -13,7 +14,8 @@ vi.mock('../api/outbounds', () => ({
|
||||
listOutbounds: vi.fn().mockResolvedValue({ records: [], total: 0 }),
|
||||
listOneClickOutboundCandidates: api.candidates,
|
||||
createOneClickDropshipOutbound: api.create,
|
||||
cancelOutbound: vi.fn(), createReturnOutbound: vi.fn(), listReturnOutboundCandidates: vi.fn(),
|
||||
listWarehouseTransferOutboundCandidates: api.transfers, bindWarehouseTransferDestination: api.bindDestination,
|
||||
cancelOutbound: vi.fn(), createReturnOutbound: vi.fn(), listReturnOutboundCandidates: api.returnCandidates,
|
||||
}))
|
||||
|
||||
const fixtures = Array.from({ length: 7 }, (_, index) => ({
|
||||
@@ -48,12 +50,43 @@ async function selectBothPages() {
|
||||
}
|
||||
|
||||
describe('outbound candidate search and selection', () => {
|
||||
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 },
|
||||
{ cartonMark: 'P-B', sourceUnitType: 'PALLET', skuCode: 'SKU-B', quantity: 6 },
|
||||
{ cartonMark: 'BOX-A', sourceUnitType: 'BOX', skuCode: 'SKU-A', quantity: 3 },
|
||||
{ cartonMark: 'SP-A', sourceUnitType: 'PARCEL', skuCode: 'SKU-A', quantity: 1 },
|
||||
{ cartonMark: 'SP-B', sourceUnitType: 'PARCEL', skuCode: 'SKU-B', quantity: 5 },
|
||||
{ cartonMark: 'SP-OTHER', sourceUnitType: 'PARCEL', skuCode: 'SKU-A', quantity: 20 },
|
||||
].map(row => ({ ...row, status: 'AVAILABLE', sourceReturnNo: row.cartonMark === 'SP-OTHER' ? 'RT-OTHER' : 'RT-MIXED' }))
|
||||
api.returnCandidates.mockResolvedValue([])
|
||||
api.stock.mockResolvedValue({ records: [{ skuCode: 'SKU-A', availableQuantity: 29 }, { skuCode: 'SKU-B', availableQuantity: 11 }] })
|
||||
api.cartons.mockResolvedValue({ records: sources })
|
||||
api.carton.mockImplementation(async (mark: string) => { const row = sources.find(row => row.cartonMark === mark)!; return { ...row, items: [{ skuCode: row.skuCode, quantity: row.quantity, availableQuantity: row.quantity }] } })
|
||||
await openCreate()
|
||||
const type = wrapper.findComponent({ name: 'ElRadioGroup' })
|
||||
type.vm.$emit('update:modelValue', 'RETURN_OUTBOUND'); type.vm.$emit('change'); await flushPromises()
|
||||
const customer = wrapper.findAllComponents(ElSelect).find(row => row.props('placeholder') === '请选择客户')!
|
||||
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 sourceTable = wrapper.findAll('.return-list-card')[1]!
|
||||
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 })
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||
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' },
|
||||
@@ -61,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>>)
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('OutboundsView permissions and navigation', () => {
|
||||
expect(source).toContain('@click="searchReturnSkus"')
|
||||
expect(source).toContain('@click="searchReturnCartons"')
|
||||
expect(source).toContain(':page-sizes="[5, 10, 20, 50]"')
|
||||
expect(source).toContain('width="min(1320px, calc(100vw - 32px))"')
|
||||
expect(source).toContain('width="min(1760px, calc(100vw - 32px))" fullscreenable')
|
||||
})
|
||||
|
||||
it('resolves warehouse-transfer customer names from the platform customer list', () => {
|
||||
|
||||
@@ -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 returnQuantity = ref<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,15 +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 (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(',')),
|
||||
@@ -85,23 +74,17 @@ 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') {
|
||||
const detail = returnCartonDetails.value[item.cartonMark]
|
||||
if (!selectedReturnSkus.value.length || detail?.items?.length !== 1 || detail.items[0]?.skuCode !== selectedReturnSkus.value[0]) 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[])
|
||||
const selectedReturnQuantity = computed(() => effectiveReturnMarks.value.reduce((sum, mark) => {
|
||||
const line = returnCartonDetails.value[mark]?.items.find(item => item.skuCode === selectedReturnSkus.value[0])
|
||||
return sum + Number(line?.availableQuantity ?? line?.quantity ?? 0)
|
||||
}, 0))
|
||||
watch(selectedReturnQuantity, quantity => { returnQuantity.value = quantity || undefined })
|
||||
|
||||
watch([selectedReturnNo, selectedCustomerId, selectedWarehouseId], () => {
|
||||
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())))
|
||||
const pagedDropshipCandidates = computed(() => filteredDropshipCandidates.value.slice((dropshipPage.value - 1) * dropshipPageSize.value, dropshipPage.value * dropshipPageSize.value))
|
||||
@@ -150,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])]
|
||||
@@ -166,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()) {
|
||||
@@ -177,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) }
|
||||
@@ -192,10 +190,10 @@ 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 ? [sku] : []; selectedReturnMarks.value = [] }
|
||||
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) }
|
||||
function toggleReturnCarton(cartonMark: string, checked: boolean | string | number) {
|
||||
selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, cartonMark])] : selectedReturnMarks.value.filter(value => value !== cartonMark)
|
||||
}
|
||||
function toggleReturnPallet(palletCode: string, checked: boolean | string | number) {
|
||||
const marks = returnCandidates.value.filter(item => item.palletCode === palletCode && item.cartonMark).map(item => item.cartonMark!)
|
||||
selectedReturnMarks.value = checked ? [...new Set([...selectedReturnMarks.value, ...marks])] : selectedReturnMarks.value.filter(mark => !marks.includes(mark))
|
||||
@@ -216,17 +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' && selectedReturnSkus.value.length !== 1) { ElMessage.warning('请选择一个 SKU'); return }
|
||||
if (returnPickMode.value === 'SKU' && (!Number.isSafeInteger(returnQuantity.value) || Number(returnQuantity.value) <= 0 || Number(returnQuantity.value) > selectedReturnQuantity.value)) {
|
||||
ElMessage.warning(`请填写 1-${selectedReturnQuantity.value} 件的出库数量`); 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 的可用单 SKU 箱或散件库存;混合箱请先拆箱'
|
||||
: '请选择出库箱号或托盘')
|
||||
ElMessage.warning('请选择出库包裹/箱号或托盘')
|
||||
return
|
||||
}
|
||||
await createReturnOutbound(selectedCustomerId.value, { recipient: recipient.value.trim(), overseasWarehouseId: selectedWarehouseId.value, returnNo: selectedReturnNo.value, cartonMarks: selectedReturnMarks.value, skuCode: returnPickMode.value === 'SKU' ? selectedReturnSkus.value[0] : undefined, quantity: returnPickMode.value === 'SKU' ? returnQuantity.value : 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)))
|
||||
@@ -264,7 +258,7 @@ onMounted(load)
|
||||
<template #status="{ row }"><StatusBadge :status="row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : row.status === 'WAIT_OUTBOUND' ? '待出库' : row.status === 'PICKING' ? '拣货中' : row.status === 'WAIT_PICK' ? '待拣货' : '未知状态'" /></template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1320px, calc(100vw - 32px))" :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving" @confirm="submit">
|
||||
<AppDialog class="admin-density-dialog" v-model="createOpen" title="新建出库单" width="min(1760px, calc(100vw - 32px))" fullscreenable :confirm-text="outboundType === 'WAREHOUSE_TRANSFER_OUTBOUND' ? '绑定目的地并进入排车' : '生成出库单并推送PDA'" :confirm-loading="saving" @confirm="submit">
|
||||
<el-form class="create-outbound-form density-form" label-position="top">
|
||||
<el-form-item label="出库类型" required>
|
||||
<el-radio-group v-model="outboundType" @change="changeOutboundType">
|
||||
@@ -346,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 扫箱唛按计划件数拣货,剩余数量保留在原箱。</p><el-form-item v-if="returnPickMode === 'SKU'" label="出库数量(件)" required><el-input-number v-model="returnQuantity" :min="1" :max="selectedReturnQuantity || 1" :precision="0" placeholder="选择来源后填写件数" /><span class="candidate-hint">所选来源可用 {{ selectedReturnQuantity }} 件;按来源箱号顺序分配</span></el-form-item><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"><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="190" show-overflow-tooltip><template #default="{ row }">{{ row.sourceUnitType === 'PARCEL' ? '包裹散件(按 SKU 拣货)' : 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 }} 个{{ 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>
|
||||
@@ -392,7 +386,7 @@ onMounted(load)
|
||||
.candidate-pagination :deep(.el-pagination__sizes), .return-pagination :deep(.el-pagination__sizes) { flex: 0 0 128px; }
|
||||
.candidate-pagination :deep(.el-pagination__sizes .el-select), .return-pagination :deep(.el-pagination__sizes .el-select) { width: 128px; }
|
||||
.candidate-pagination :deep(.el-pagination__jump .el-input) { width: 56px; }
|
||||
.return-lists { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--admin-space-5); width: 100%; margin: var(--admin-space-1) 0 var(--admin-space-5); }
|
||||
.return-lists { display: grid; grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); gap: var(--admin-space-5); width: 100%; margin: var(--admin-space-1) 0 var(--admin-space-5); }
|
||||
.return-pallet-options { display: flex; flex-wrap: wrap; gap: 8px 16px; }
|
||||
.return-list-card { min-width: 0; padding: var(--admin-space-5); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-extra-light); }
|
||||
.list-title { display: flex; align-items: baseline; justify-content: space-between; gap: var(--admin-space-5); margin-bottom: var(--admin-space-5); }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -369,7 +369,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(detail).toContain('单据信息')
|
||||
expect(detail).toContain('关联订单明细')
|
||||
expect(detail).toContain(':data="relatedOrders"')
|
||||
expect(detail).toContain("router.push({ name: 'overseas-dispatches' })")
|
||||
expect(detail).toContain("router.push(inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' })")
|
||||
expect(detail).toContain("inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'")
|
||||
expect(detail).not.toContain('ElMessage.success')
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import InventoryTransactionsView from './InventoryTransactionsView.vue'
|
||||
import LastMileOrderDetailView from './LastMileOrderDetailView.vue'
|
||||
|
||||
const routing = vi.hoisted(() => ({ push: vi.fn(), route: { params: {} as Record<string, string>, query: {} as Record<string, string> } }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRoute: () => routing.route, useRouter: () => ({ push: routing.push }) }))
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('../api/inboundOrders', () => ({
|
||||
listInboundOrders: vi.fn(), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user